From d9c42f4cf70550ee1ee4d23cffdd0b0b74b31d05 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Fri, 23 Jan 2026 04:23:53 -0300 Subject: [PATCH 01/18] feat: [US-054] - Character and Variable TypeScript types Co-Authored-By: Claude Opus 4.5 --- prd.json | 810 +++++++++------------------- progress.txt | 415 +------------- src/app/editor/[projectId]/page.tsx | 11 +- src/types/flowchart.ts | 44 +- 4 files changed, 320 insertions(+), 960 deletions(-) diff --git a/prd.json b/prd.json index 29d01a7..3c6e280 100644 --- a/prd.json +++ b/prd.json @@ -1,18 +1,19 @@ { "project": "WebVNWrite", - "branchName": "ralph/vn-flowchart-editor", - "description": "Visual Novel Flowchart Editor - A web-based tool for authoring visual novels with drag-and-drop nodes, branching connections, user authentication, and Ren'Py JSON export", + "branchName": "ralph/collaboration-and-character-variables", + "description": "Real-time Collaboration & Character/Variable Management - Enable multi-user editing with CRDT sync, presence indicators, audit trail, plus centralized character/variable definitions with dropdown selectors", "userStories": [ { - "id": "US-001", - "title": "Project scaffolding and configuration", - "description": "As a developer, I need the project set up with Next.js, TailwindCSS, and Supabase so that I can build the application.", + "id": "US-054", + "title": "Character and Variable TypeScript types", + "description": "As a developer, I need TypeScript types for Character and Variable models so that the rest of the feature can be built with type safety.", "acceptanceCriteria": [ - "Initialize Next.js project with TypeScript and App Router", - "Install and configure TailwindCSS", - "Install Supabase client library (@supabase/supabase-js)", - "Create .env.example with NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY", - "Basic folder structure: app/, components/, lib/, types/", + "Add Character type to types/flowchart.ts: id (string), name (string), color (string, hex), description (string, optional)", + "Add Variable type to types/flowchart.ts: id (string), name (string), type ('numeric' | 'string' | 'boolean'), initialValue (number | string | boolean), description (string, optional)", + "Update FlowchartData type to include characters: Character[] and variables: Variable[]", + "Update DialogueNodeData to add optional characterId: string field (alongside existing speaker for migration)", + "Update Condition type to add optional variableId: string field (alongside existing variableName for migration)", + "Update VariableNodeData to add optional variableId: string field (alongside existing variableName for migration)", "Typecheck passes" ], "priority": 1, @@ -20,680 +21,401 @@ "notes": "" }, { - "id": "US-002", - "title": "Define TypeScript types for flowchart data", - "description": "As a developer, I need TypeScript types for nodes, connections, and conditions.", + "id": "US-055", + "title": "Database schema update for characters and variables", + "description": "As a developer, I need the database schema to store characters and variables as part of the project's flowchart data.", "acceptanceCriteria": [ - "Create types/flowchart.ts file", - "DialogueNode type: id, type='dialogue', position: {x,y}, data: { speaker?: string, text: string }", - "ChoiceNode type: id, type='choice', position: {x,y}, data: { prompt: string, options: { id: string, label: string }[] }", - "VariableNode type: id, type='variable', position: {x,y}, data: { variableName: string, operation: 'set'|'add'|'subtract', value: number }", - "Condition type: { variableName: string, operator: '>'|'<'|'=='|'>='|'<='|'!=', value: number }", - "FlowchartEdge type: id, source, sourceHandle?, target, targetHandle?, data?: { condition?: Condition }", - "FlowchartData type: { nodes: (DialogueNode|ChoiceNode|VariableNode)[], edges: FlowchartEdge[] }", - "All types exported from types/flowchart.ts", + "Create migration that documents the new JSONB structure (characters/variables arrays stored within flowchart_data)", + "Update the default value for flowchart_data column to include characters: [] and variables: []", + "Existing projects with no characters/variables arrays continue to load (handled as empty arrays in app code)", "Typecheck passes" ], "priority": 2, - "passes": true, - "notes": "" + "passes": false, + "notes": "Dependencies: US-054" }, { - "id": "US-003", - "title": "Supabase schema for users and projects", - "description": "As a developer, I need database tables to store users and their projects.", + "id": "US-065", + "title": "Searchable combobox component", + "description": "As a developer, I need a reusable searchable combobox component so that all character/variable dropdowns share consistent behavior and styling.", "acceptanceCriteria": [ - "Create supabase/migrations/ directory", - "Create SQL migration file with profiles table: id (uuid, references auth.users), email (text), display_name (text), is_admin (boolean default false), created_at (timestamptz)", - "Create projects table: id (uuid), user_id (uuid, foreign key to profiles.id), name (text), flowchart_data (jsonb), created_at (timestamptz), updated_at (timestamptz)", - "Add RLS policy: users can SELECT/INSERT/UPDATE/DELETE their own projects (user_id = auth.uid())", - "Add RLS policy: users can SELECT their own profile", - "Add RLS policy: admin users (is_admin=true) can SELECT all profiles", - "Typecheck passes" + "Create components/editor/Combobox.tsx - a reusable searchable dropdown component", + "Props: items (id, label, color?, badge?), value, onChange, placeholder, onAddNew (optional callback)", + "Typing in the input filters the list by name (case-insensitive)", + "Keyboard navigation: arrow keys to move, Enter to select, Escape to close", + "Shows color swatch and/or badge next to item labels when provided", + "'Add new...' option rendered at bottom when onAddNew prop is provided", + "Dropdown positions itself above or below input based on available space", + "Matches existing editor styling (TailwindCSS, dark mode support)", + "Typecheck passes", + "Verify in browser using dev-browser skill" ], "priority": 3, - "passes": true, + "passes": false, "notes": "" }, { - "id": "US-004", - "title": "Supabase client configuration", - "description": "As a developer, I need Supabase client utilities for auth and database access.", + "id": "US-056", + "title": "Character management UI in project settings", + "description": "As a user, I want a dedicated page to manage my project's characters so that I can define them once and reuse them throughout the flowchart.", "acceptanceCriteria": [ - "Create lib/supabase/client.ts with browser client (createBrowserClient)", - "Create lib/supabase/server.ts with server client (createServerClient for App Router)", - "Create lib/supabase/middleware.ts with middleware client helper", - "Export typed database client using generated types or manual types", - "Typecheck passes" + "Add 'Project Settings' button to editor toolbar", + "Project settings opens as a modal with 'Characters' and 'Variables' tabs", + "Characters tab shows a list of defined characters with name, color swatch, and description", + "'Add Character' button opens inline form with: name (required), color picker (required, defaults to random), description (optional)", + "Each character row has Edit and Delete buttons", + "Deleting a character referenced by nodes shows warning with usage count", + "Character names must be unique within the project (validation error if duplicate)", + "Changes are saved to the flowchart data (same save mechanism as nodes/edges)", + "Typecheck passes", + "Verify in browser using dev-browser skill" ], "priority": 4, - "passes": true, - "notes": "" + "passes": false, + "notes": "Dependencies: US-054, US-055" }, { - "id": "US-005", - "title": "Protected routes middleware", - "description": "As a developer, I need authentication middleware so that only logged-in users can access the app.", + "id": "US-057", + "title": "Variable management UI in project settings", + "description": "As a user, I want a dedicated page to manage my project's variables so that I can define them with types and initial values for use throughout the flowchart.", "acceptanceCriteria": [ - "Create middleware.ts at project root", - "Middleware checks Supabase session on each request", - "Unauthenticated users accessing /dashboard or /editor/* are redirected to /login", - "Authenticated users accessing /login or /signup are redirected to /dashboard", - "Public routes allowed without auth: /login, /signup, /forgot-password, /reset-password", - "Typecheck passes" + "Variables tab in project settings modal shows a list of defined variables", + "Each variable displays: name, type badge (numeric/string/boolean), initial value, description", + "'Add Variable' button opens inline form with: name (required), type dropdown (required), initial value (required, input adapts to type), description (optional)", + "Each variable row has Edit and Delete buttons", + "Deleting a variable referenced by nodes/edges shows warning with usage count", + "Variable names must be unique within the project", + "Changes are saved to the flowchart data", + "Typecheck passes", + "Verify in browser using dev-browser skill" ], "priority": 5, - "passes": true, - "notes": "" + "passes": false, + "notes": "Dependencies: US-054, US-055" }, { - "id": "US-006", - "title": "Login page", - "description": "As a user, I want to log in with my email and password so that I can access my projects.", + "id": "US-058", + "title": "Dialogue node speaker dropdown", + "description": "As a user, I want to select a character from a dropdown in the dialogue node instead of typing a name so that I avoid typos and maintain consistency.", "acceptanceCriteria": [ - "Create app/login/page.tsx", - "Form with email and password input fields", - "Submit button calls Supabase signInWithPassword", - "Show error message for invalid credentials", - "On success, redirect to /dashboard", - "Link to /forgot-password page", - "Styled with TailwindCSS", + "Replace the speaker text input in DialogueNode with the Combobox component", + "Dropdown lists all characters defined in the project, showing color swatch + name", + "Selecting a character sets characterId on the node data", + "Dropdown includes 'Add new character...' option at the bottom", + "Clicking 'Add new character...' opens a mini form inline (name + color) that creates the character and selects it", + "If node has a characterId that doesn't match any defined character, show orange warning border on the dropdown", + "Empty/unset speaker shows placeholder 'Select speaker...'", "Typecheck passes", "Verify in browser using dev-browser skill" ], "priority": 6, - "passes": true, - "notes": "" + "passes": false, + "notes": "Dependencies: US-056, US-065" }, { - "id": "US-007", - "title": "Sign up page (invite-only)", - "description": "As an invited user, I want to complete my account setup so that I can access the tool.", + "id": "US-059", + "title": "Variable node variable dropdown", + "description": "As a user, I want to select a variable from a dropdown in the variable node instead of typing a name so that I avoid typos and maintain consistency.", "acceptanceCriteria": [ - "Create app/signup/page.tsx", - "Form with email (pre-filled if from invite link), password, and confirm password fields", - "Validate passwords match before submission", - "Handle Supabase invite token from URL (type=invite or type=signup)", - "On success, create profile record in profiles table and redirect to /dashboard", - "Show error message if signup fails", - "Styled with TailwindCSS", + "Replace the variableName text input in VariableNode with the Combobox component", + "Dropdown lists all variables defined in the project, showing type badge + name", + "Selecting a variable sets variableId on the node data", + "Dropdown includes 'Add new variable...' option that opens inline creation form", + "If node references a variableId that doesn't match any defined variable, show orange warning border", + "Operation options (set/add/subtract) are filtered based on selected variable's type (add/subtract only for numeric)", "Typecheck passes", "Verify in browser using dev-browser skill" ], "priority": 7, - "passes": true, - "notes": "" + "passes": false, + "notes": "Dependencies: US-057, US-065" }, { - "id": "US-008", - "title": "Logout functionality", - "description": "As a user, I want to log out so that I can secure my session.", + "id": "US-060", + "title": "Edge condition variable dropdown", + "description": "As a user, I want to select a variable from a dropdown when setting edge conditions so that I reference valid variables consistently.", "acceptanceCriteria": [ - "Create components/LogoutButton.tsx component", - "Button calls Supabase signOut", - "On success, redirect to /login", + "Replace the variableName text input in ConditionEditor with the Combobox component", + "Dropdown lists all variables defined in the project, showing type badge + name", + "Selecting a variable sets variableId on the condition object", + "Dropdown includes 'Add new variable...' option", + "If condition references an undefined variableId, show orange warning indicator", + "Operator options are filtered based on variable type (comparison operators for numeric, == and != for string/boolean)", + "Value input adapts to variable type (number input for numeric, text for string, checkbox for boolean)", "Typecheck passes", "Verify in browser using dev-browser skill" ], "priority": 8, - "passes": true, - "notes": "" + "passes": false, + "notes": "Dependencies: US-057, US-065" }, { - "id": "US-009", - "title": "Password reset - forgot password page", - "description": "As a user, I want to request a password reset if I forget my password.", + "id": "US-061", + "title": "Choice option condition variable dropdown", + "description": "As a user, I want to select a variable from a dropdown when setting choice option conditions so that I reference valid variables consistently.", "acceptanceCriteria": [ - "Create app/forgot-password/page.tsx", - "Form with email input field", - "Submit button calls Supabase resetPasswordForEmail", - "Show confirmation message after sending (check your email)", - "Link back to /login", - "Styled with TailwindCSS", + "Replace the variableName text input in OptionConditionEditor with the Combobox component", + "Dropdown lists all variables defined in the project, showing type badge + name", + "Selecting a variable sets variableId on the option's condition object", + "Dropdown includes 'Add new variable...' option", + "If condition references an undefined variableId, show orange warning indicator", + "Operator and value inputs adapt to variable type (same behavior as US-060)", "Typecheck passes", "Verify in browser using dev-browser skill" ], "priority": 9, - "passes": true, - "notes": "" + "passes": false, + "notes": "Dependencies: US-057, US-065" }, { - "id": "US-010", - "title": "Password reset - set new password page", - "description": "As a user, I want to set a new password after clicking the reset link.", + "id": "US-062", + "title": "Auto-migration of existing free-text values", + "description": "As a user, I want my existing projects to automatically create character and variable definitions from free-text values so that I don't have to manually re-enter them.", "acceptanceCriteria": [ - "Create app/reset-password/page.tsx", - "Form with new password and confirm password fields", - "Handle Supabase recovery token from URL", - "Submit calls Supabase updateUser with new password", - "On success, redirect to /login with success message", - "Show error if token invalid or expired", - "Styled with TailwindCSS", - "Typecheck passes", - "Verify in browser using dev-browser skill" + "On project load, if characters array is empty but nodes have speaker values, auto-create Character entries from unique speaker names", + "Auto-created characters get randomly assigned colors and the speaker text as name", + "On project load, if variables array is empty but nodes/edges have variableName values, auto-create Variable entries (default type: numeric, initial value: 0)", + "After auto-creation, update all nodes to set characterId/variableId references pointing to the new entries", + "Show a toast notification: 'Auto-imported N characters and M variables from existing data'", + "Migration only runs once (presence of characters/variables arrays, even if empty, means migration already happened)", + "Typecheck passes" ], "priority": 10, - "passes": true, - "notes": "" + "passes": false, + "notes": "Dependencies: US-054, US-058, US-059" }, { - "id": "US-011", - "title": "Dashboard layout with navbar", - "description": "As a user, I want a consistent layout with navigation so that I can move around the app.", + "id": "US-063", + "title": "Import characters/variables from another project", + "description": "As a user, I want to import character and variable definitions from another project so that I can reuse them without redefining everything.", "acceptanceCriteria": [ - "Create app/dashboard/layout.tsx", - "Navbar component with app title/logo", - "Navbar shows current user email", - "Navbar includes LogoutButton", - "Main content area below navbar", - "Styled with TailwindCSS", + "Add 'Import from project' button in both Characters and Variables tabs of project settings", + "Button opens a modal listing the user's other projects", + "Selecting a project shows its characters (or variables) with checkboxes for selection", + "User can select which entries to import (select all / none / individual)", + "Imported entries are added to the current project (duplicates by name are skipped with a warning)", + "Imported characters keep their colors; imported variables keep their types and initial values", "Typecheck passes", "Verify in browser using dev-browser skill" ], "priority": 11, - "passes": true, - "notes": "" + "passes": false, + "notes": "Dependencies: US-056, US-057" }, { - "id": "US-012", - "title": "Dashboard - list projects", - "description": "As a user, I want to see all my projects so that I can choose which one to edit.", + "id": "US-064", + "title": "Export validation for undefined references", + "description": "As a user, I want to be warned before exporting if any nodes reference undefined characters or variables so that I can fix issues before generating output.", "acceptanceCriteria": [ - "Create app/dashboard/page.tsx", - "Fetch projects from Supabase for current user", - "Display projects as cards in a grid", - "Each card shows: project name, last updated date (formatted)", - "Click card navigates to /editor/[projectId]", - "Empty state with message when no projects exist", - "Loading state while fetching", + "Before export, scan all nodes and edges for characterId/variableId references that don't match defined entries", + "If issues found, show a warning modal listing: node type, node content snippet, and the undefined reference", + "Modal offers 'Export anyway' and 'Cancel' options", + "Nodes with undefined references are highlighted on the canvas with orange warning borders when modal is shown", + "If no issues found, export proceeds normally", "Typecheck passes", "Verify in browser using dev-browser skill" ], "priority": 12, - "passes": true, - "notes": "" + "passes": false, + "notes": "Dependencies: US-058, US-059, US-060, US-061" }, { - "id": "US-013", - "title": "Create new project", - "description": "As a user, I want to create a new project so that I can start a new flowchart.", + "id": "US-043", + "title": "Database schema for collaboration sessions and audit trail", + "description": "As a developer, I need database tables to track active collaboration sessions and store the full change history for projects.", "acceptanceCriteria": [ - "Add 'New Project' button on dashboard", - "Clicking opens modal with project name input", - "Submit creates project in Supabase with empty flowchart_data: { nodes: [], edges: [] }", - "On success, redirect to /editor/[newProjectId]", - "Show error if creation fails", - "Typecheck passes", - "Verify in browser using dev-browser skill" + "Create migration adding project_collaborators table: id (uuid), project_id (references projects), user_id (references profiles), role ('owner' | 'editor' | 'viewer'), invited_at (timestamptz), accepted_at (timestamptz)", + "Create collaboration_sessions table: id (uuid), project_id, user_id, cursor_position (jsonb), selected_node_id (text nullable), connected_at (timestamptz), last_heartbeat (timestamptz)", + "Create audit_trail table: id (uuid), project_id, user_id, action_type (text: 'node_add' | 'node_update' | 'node_delete' | 'edge_add' | 'edge_update' | 'edge_delete'), entity_id (text), previous_state (jsonb), new_state (jsonb), created_at (timestamptz)", + "Add RLS policies: collaborators can access sessions/audit for projects they belong to", + "Add index on audit_trail(project_id, created_at) for efficient history queries", + "Typecheck passes" ], "priority": 13, - "passes": true, + "passes": false, "notes": "" }, { - "id": "US-014", - "title": "Delete project", - "description": "As a user, I want to delete a project I no longer need.", + "id": "US-045", + "title": "Supabase Realtime channel and connection management", + "description": "As a developer, I need a WebSocket connection layer using Supabase Realtime so that clients can exchange presence and change events in real time.", "acceptanceCriteria": [ - "Add delete icon/button on each project card", - "Clicking shows confirmation dialog (Are you sure?)", - "Confirm deletes project from Supabase", - "Project removed from dashboard list without page reload", - "Show success toast after deletion", + "Create lib/collaboration/realtime.ts module", + "On editor mount, join a Supabase Realtime channel scoped to the project ID", + "Track connection state (connecting, connected, disconnected, reconnecting)", + "Implement heartbeat mechanism (update last_heartbeat every 30 seconds)", + "Auto-reconnect on network interruption with exponential backoff", + "Clean up session record on disconnect/unmount", + "Show connection status indicator in editor toolbar (green=connected, yellow=reconnecting, red=disconnected)", "Typecheck passes", "Verify in browser using dev-browser skill" ], "priority": 14, - "passes": true, - "notes": "" + "passes": false, + "notes": "Dependencies: US-043" }, { - "id": "US-015", - "title": "Rename project", - "description": "As a user, I want to rename a project to keep my work organized.", + "id": "US-044", + "title": "Project sharing and collaborator management", + "description": "As a project owner, I want to invite other users to collaborate on my project so that we can work together.", "acceptanceCriteria": [ - "Add edit/rename icon on project card", - "Clicking opens modal or enables inline edit for project name", - "Submit updates project name in Supabase", - "UI updates immediately without page reload", - "Show error if rename fails", + "Add 'Share' button in the editor toolbar", + "Share modal displays current collaborators with roles (owner/editor/viewer)", + "Owner can invite users by email with a selected role", + "Owner can change collaborator roles or remove collaborators", + "Invited users see shared projects on their dashboard with a 'Shared with me' indicator", + "RLS policies updated so collaborators can read/write projects based on their role", "Typecheck passes", "Verify in browser using dev-browser skill" ], "priority": 15, - "passes": true, - "notes": "" + "passes": false, + "notes": "Dependencies: US-043" }, { - "id": "US-016", - "title": "Admin - invite new user", - "description": "As an admin, I want to invite new users so that collaborators can access the tool.", + "id": "US-046", + "title": "Presence indicators for active collaborators", + "description": "As a user, I want to see who else is currently viewing or editing the project so that I am aware of my collaborators.", "acceptanceCriteria": [ - "Create app/admin/invite/page.tsx", - "Only accessible by users with is_admin=true (redirect others to /dashboard)", - "Form with email address input", - "Submit calls Supabase admin inviteUserByEmail (requires service role key in server action)", - "Show success message with invite sent confirmation", - "Show error if invite fails", - "Link to this page visible in navbar only for admins", + "Display a row of avatar circles in the editor toolbar showing connected users", + "Each avatar shows the user's display_name on hover (tooltip)", + "Each user is assigned a consistent color (derived from user ID hash)", + "Avatars appear when users join and disappear when they leave", + "Maximum 5 avatars shown with '+N' overflow indicator", + "Own avatar not shown in the list", "Typecheck passes", "Verify in browser using dev-browser skill" ], "priority": 16, - "passes": true, - "notes": "" + "passes": false, + "notes": "Dependencies: US-045" }, { - "id": "US-017", - "title": "Editor page with React Flow canvas", - "description": "As a user, I want an editor page with a canvas where I can build my flowchart.", + "id": "US-048", + "title": "Integrate Yjs CRDT for conflict-free node/edge synchronization", + "description": "As a developer, I need to integrate a CRDT library so that concurrent edits from multiple users merge automatically without data loss.", "acceptanceCriteria": [ - "Install reactflow package", - "Create app/editor/[projectId]/page.tsx", - "Fetch project from Supabase by ID", - "Show error if project not found or user unauthorized", - "Show loading state while fetching", - "Render React Flow canvas filling the editor area", - "Canvas has grid background (React Flow Background component)", - "Header shows project name with back link to /dashboard", - "Initialize React Flow with nodes and edges from flowchart_data", - "Typecheck passes", - "Verify in browser using dev-browser skill" + "Install and configure Yjs with a Supabase-compatible provider (or WebSocket provider)", + "Create lib/collaboration/crdt.ts module wrapping Yjs document setup", + "Model flowchart nodes as a Y.Map keyed by node ID", + "Model flowchart edges as a Y.Map keyed by edge ID", + "Local React Flow state changes are synced to the Yjs document", + "Remote Yjs document changes update local React Flow state", + "Initial load populates Yjs document from database state", + "Periodic persistence of Yjs document state to Supabase (debounced 2 seconds)", + "Typecheck passes" ], "priority": 17, - "passes": true, - "notes": "" + "passes": false, + "notes": "Dependencies: US-045" }, { - "id": "US-018", - "title": "Canvas pan and zoom controls", - "description": "As a user, I want to pan and zoom the canvas to navigate large flowcharts.", + "id": "US-047", + "title": "Live cursor positions on canvas", + "description": "As a user, I want to see other collaborators' cursor positions on the canvas so that I can understand where they are working.", "acceptanceCriteria": [ - "Canvas supports click-and-drag panning (React Flow default)", - "Mouse wheel zooms in/out (React Flow default)", - "Add React Flow Controls component with zoom +/- buttons", - "Add fitView button to show all nodes", - "Controls positioned in bottom-right corner", + "Broadcast local cursor position to the Realtime channel (throttled to 50ms)", + "Render remote cursors as colored arrows/pointers on the canvas with user name labels", + "Cursor color matches the user's assigned presence color", + "Remote cursors smoothly interpolate between position updates (no jumping)", + "Remote cursors fade out after 5 seconds of inactivity", + "Cursors are rendered in screen coordinates and properly transform with canvas zoom/pan", "Typecheck passes", "Verify in browser using dev-browser skill" ], "priority": 18, - "passes": true, - "notes": "" + "passes": false, + "notes": "Dependencies: US-045, US-046" }, { - "id": "US-019", - "title": "Editor toolbar", - "description": "As a user, I want a toolbar with actions for adding nodes and saving/exporting.", + "id": "US-050", + "title": "Join/leave notifications", + "description": "As a user, I want to be notified when collaborators join or leave the editing session so that I stay aware of the team's activity.", "acceptanceCriteria": [ - "Create components/editor/Toolbar.tsx", - "Toolbar positioned at top of editor below header", - "Buttons: Add Dialogue, Add Choice, Add Variable (no functionality yet)", - "Buttons: Save, Export, Import (no functionality yet)", - "Buttons styled with TailwindCSS, icons optional", + "Show a toast notification when a collaborator joins: '[Name] joined'", + "Show a toast notification when a collaborator leaves: '[Name] left'", + "Notifications use the collaborator's assigned color as an accent", + "Notifications auto-dismiss after 3 seconds (matches existing Toast behavior)", + "No notification shown for own join/leave events", "Typecheck passes", "Verify in browser using dev-browser skill" ], "priority": 19, - "passes": true, - "notes": "" + "passes": false, + "notes": "Dependencies: US-045, US-046" }, { - "id": "US-020", - "title": "Create custom dialogue node component", - "description": "As a user, I want dialogue nodes to display and edit character speech.", + "id": "US-049", + "title": "Node editing lock indicators", + "description": "As a user, I want to see when another collaborator is actively editing a specific node so that I can avoid conflicts and wait for them to finish.", "acceptanceCriteria": [ - "Create components/editor/nodes/DialogueNode.tsx", - "Node styled with blue background/border", - "Displays editable input for speaker name (placeholder: 'Speaker')", - "Displays editable textarea for dialogue text (placeholder: 'Dialogue text...')", - "Has one Handle at top (type='target', id='input')", - "Has one Handle at bottom (type='source', id='output')", - "Register as custom node type in React Flow", + "When a user focuses/opens a node for editing, broadcast the node ID to the channel", + "Nodes being edited by others show a colored border matching the editor's presence color", + "A small label with the editor's name appears on the locked node", + "Other users can still view but see a 'Being edited by [name]' indicator if they try to edit", + "Lock is released when the user clicks away, closes the node, or disconnects", + "Lock auto-expires after 60 seconds of inactivity as a safety measure", "Typecheck passes", "Verify in browser using dev-browser skill" ], "priority": 20, - "passes": true, - "notes": "" + "passes": false, + "notes": "Dependencies: US-045, US-048" }, { - "id": "US-021", - "title": "Add dialogue node from toolbar", - "description": "As a user, I want to add dialogue nodes by clicking the toolbar button.", + "id": "US-051", + "title": "Audit trail recording", + "description": "As a developer, I need all node and edge changes to be recorded in the audit trail so that users can review history and revert changes.", "acceptanceCriteria": [ - "Clicking 'Add Dialogue' in toolbar creates new DialogueNode", - "Node appears at center of current viewport", - "Node has unique ID (use nanoid or uuid)", - "Node added to React Flow nodes state", - "Node can be dragged to reposition", - "Typecheck passes", - "Verify in browser using dev-browser skill" + "Every node add/update/delete operation writes a record to audit_trail table", + "Every edge add/update/delete operation writes a record to audit_trail table", + "Records include previous_state (null for additions) and new_state (null for deletions)", + "Records include the acting user's ID and timestamp", + "Writes are batched/debounced to avoid excessive DB calls (max 1 write per second per entity)", + "Audit writes do not block the user's editing flow (fire-and-forget with error logging)", + "Typecheck passes" ], "priority": 21, - "passes": true, - "notes": "" + "passes": false, + "notes": "Dependencies: US-043, US-048" }, { - "id": "US-022", - "title": "Create custom choice node component", - "description": "As a user, I want choice nodes to display branching decisions.", + "id": "US-052", + "title": "Activity history sidebar", + "description": "As a user, I want to view a history of all changes made to the project so that I can see what collaborators have done and when.", "acceptanceCriteria": [ - "Create components/editor/nodes/ChoiceNode.tsx", - "Node styled with green background/border", - "Displays editable input for prompt text (placeholder: 'What do you choose?')", - "Displays 2 default options, each with editable label input", - "Has one Handle at top (type='target', id='input')", - "Each option has its own Handle at bottom (type='source', id='option-0', 'option-1', etc.)", - "Register as custom node type in React Flow", + "Add 'History' button to editor toolbar that opens a right sidebar panel", + "Sidebar displays a chronological list of changes with: user name, action type, entity description, timestamp", + "Entries are grouped by time period (Today, Yesterday, Earlier)", + "Each entry shows the user's presence color as an accent", + "Clicking an entry highlights/selects the affected node or edge on the canvas", + "Paginated loading (20 entries per page) with 'Load more' button", "Typecheck passes", "Verify in browser using dev-browser skill" ], "priority": 22, - "passes": true, - "notes": "" + "passes": false, + "notes": "Dependencies: US-051" }, { - "id": "US-023", - "title": "Add choice node from toolbar", - "description": "As a user, I want to add choice nodes by clicking the toolbar button.", + "id": "US-053", + "title": "Revert changes from audit trail", + "description": "As a user, I want to revert a specific change from the history so that I can undo mistakes made by myself or collaborators.", "acceptanceCriteria": [ - "Clicking 'Add Choice' in toolbar creates new ChoiceNode", - "Node appears at center of current viewport", - "Node has unique ID", - "Node initialized with 2 options (each with unique id and empty label)", - "Node added to React Flow nodes state", - "Node can be dragged to reposition", + "Each entry in the activity history sidebar has a 'Revert' button", + "Clicking 'Revert' shows a confirmation dialog with before/after preview", + "Reverting a node addition deletes the node", + "Reverting a node update restores the previous state", + "Reverting a node deletion re-creates the node with its previous state", + "Reverting an edge change follows the same add/update/delete logic", + "The revert itself is recorded as a new audit trail entry", + "Reverted state is synced to all connected clients via CRDT", "Typecheck passes", "Verify in browser using dev-browser skill" ], "priority": 23, - "passes": true, - "notes": "" - }, - { - "id": "US-024", - "title": "Add/remove choice options", - "description": "As a user, I want to add or remove choice options (2-6 options supported).", - "acceptanceCriteria": [ - "ChoiceNode has '+' button to add new option", - "Maximum 6 options (button disabled or hidden at max)", - "Each option has 'x' button to remove it", - "Minimum 2 options (remove button disabled or hidden at min)", - "Adding option creates new output Handle dynamically", - "Removing option removes its Handle", - "Node data updates in React Flow state", - "Typecheck passes", - "Verify in browser using dev-browser skill" - ], - "priority": 24, - "passes": true, - "notes": "" - }, - { - "id": "US-025", - "title": "Create custom variable node component", - "description": "As a user, I want variable nodes to set or modify story variables.", - "acceptanceCriteria": [ - "Create components/editor/nodes/VariableNode.tsx", - "Node styled with orange background/border", - "Displays editable input for variable name (placeholder: 'variableName')", - "Displays dropdown/select for operation: set, add, subtract", - "Displays editable number input for value (default: 0)", - "Has one Handle at top (type='target', id='input')", - "Has one Handle at bottom (type='source', id='output')", - "Register as custom node type in React Flow", - "Typecheck passes", - "Verify in browser using dev-browser skill" - ], - "priority": 25, - "passes": true, - "notes": "" - }, - { - "id": "US-026", - "title": "Add variable node from toolbar", - "description": "As a user, I want to add variable nodes by clicking the toolbar button.", - "acceptanceCriteria": [ - "Clicking 'Add Variable' in toolbar creates new VariableNode", - "Node appears at center of current viewport", - "Node has unique ID", - "Node initialized with empty variableName, operation='set', value=0", - "Node added to React Flow nodes state", - "Node can be dragged to reposition", - "Typecheck passes", - "Verify in browser using dev-browser skill" - ], - "priority": 26, - "passes": true, - "notes": "" - }, - { - "id": "US-027", - "title": "Connect nodes with edges", - "description": "As a user, I want to connect nodes with arrows to define story flow.", - "acceptanceCriteria": [ - "Dragging from source Handle to target Handle creates edge (React Flow default)", - "Edges render as smooth bezier curves (default edge type or smoothstep)", - "Edges show arrow marker indicating direction (markerEnd)", - "Edges update position when nodes are moved", - "Cannot connect source-to-source or target-to-target (React Flow handles this)", - "New edges added to React Flow edges state", - "Typecheck passes", - "Verify in browser using dev-browser skill" - ], - "priority": 27, - "passes": true, - "notes": "" - }, - { - "id": "US-028", - "title": "Select and delete nodes", - "description": "As a user, I want to delete nodes to revise my flowchart.", - "acceptanceCriteria": [ - "Clicking a node selects it (visual highlight via React Flow)", - "Pressing Delete or Backspace key removes selected node(s)", - "Deleting node also removes all connected edges", - "Use onNodesDelete callback to handle deletion", - "Typecheck passes", - "Verify in browser using dev-browser skill" - ], - "priority": 28, - "passes": true, - "notes": "" - }, - { - "id": "US-029", - "title": "Select and delete edges", - "description": "As a user, I want to delete connections between nodes.", - "acceptanceCriteria": [ - "Clicking an edge selects it (visual highlight via React Flow)", - "Pressing Delete or Backspace key removes selected edge(s)", - "Use onEdgesDelete callback to handle deletion", - "Typecheck passes", - "Verify in browser using dev-browser skill" - ], - "priority": 29, - "passes": true, - "notes": "" - }, - { - "id": "US-030", - "title": "Right-click context menu", - "description": "As a user, I want a context menu for quick actions.", - "acceptanceCriteria": [ - "Create components/editor/ContextMenu.tsx", - "Right-click on canvas shows menu: Add Dialogue, Add Choice, Add Variable", - "New node created at click position", - "Right-click on node shows menu: Delete", - "Right-click on edge shows menu: Delete, Add Condition", - "Clicking elsewhere or pressing Escape closes menu", - "Menu styled with TailwindCSS", - "Typecheck passes", - "Verify in browser using dev-browser skill" - ], - "priority": 30, "passes": false, - "notes": "" - }, - { - "id": "US-031", - "title": "Condition editor modal", - "description": "As a user, I want to add conditions to edges so branches depend on variables.", - "acceptanceCriteria": [ - "Create components/editor/ConditionEditor.tsx modal/popover", - "Opens on double-click edge or via context menu 'Add Condition'", - "Form fields: variable name input, operator dropdown (>, <, ==, >=, <=, !=), value number input", - "Pre-fill fields if edge already has condition", - "Save button applies condition to edge data", - "Clear/Remove button removes condition from edge", - "Cancel button closes without saving", - "Typecheck passes", - "Verify in browser using dev-browser skill" - ], - "priority": 31, - "passes": false, - "notes": "" - }, - { - "id": "US-032", - "title": "Display conditions on edges", - "description": "As a user, I want to see conditions displayed on edges.", - "acceptanceCriteria": [ - "Create custom edge component or use edge labels", - "Edges with conditions render as dashed lines (strokeDasharray)", - "Condition label displayed on edge (e.g., 'score > 5')", - "Unconditional edges remain solid lines", - "Typecheck passes", - "Verify in browser using dev-browser skill" - ], - "priority": 32, - "passes": false, - "notes": "" - }, - { - "id": "US-033", - "title": "Auto-save to LocalStorage", - "description": "As a user, I want my work auto-saved locally so I don't lose progress if the browser crashes.", - "acceptanceCriteria": [ - "Save flowchart state (nodes + edges) to LocalStorage on every change", - "Debounce saves (e.g., 1 second delay after last change)", - "LocalStorage key format: 'vnwrite-draft-{projectId}'", - "On editor load, check LocalStorage for saved draft", - "If local draft exists and differs from database, show prompt to restore or discard", - "Typecheck passes", - "Verify in browser using dev-browser skill" - ], - "priority": 33, - "passes": false, - "notes": "" - }, - { - "id": "US-034", - "title": "Save project to database", - "description": "As a user, I want to save my project to the database manually.", - "acceptanceCriteria": [ - "Clicking 'Save' in toolbar saves current nodes/edges to Supabase", - "Update project's flowchart_data and updated_at fields", - "Show saving indicator/spinner while in progress", - "Show success toast on completion", - "Clear LocalStorage draft after successful save", - "Show error toast if save fails", - "Typecheck passes", - "Verify in browser using dev-browser skill" - ], - "priority": 34, - "passes": false, - "notes": "" - }, - { - "id": "US-035", - "title": "Export project as .vnflow file", - "description": "As a user, I want to export my project as a JSON file for backup or sharing.", - "acceptanceCriteria": [ - "Clicking 'Export' in toolbar triggers file download", - "File named '[project-name].vnflow'", - "File contains JSON with nodes and edges arrays", - "JSON is pretty-printed (2-space indent) for readability", - "Uses browser download API (create blob, trigger download)", - "Typecheck passes", - "Verify in browser using dev-browser skill" - ], - "priority": 35, - "passes": false, - "notes": "" - }, - { - "id": "US-036", - "title": "Import project from .vnflow file", - "description": "As a user, I want to import a .vnflow file to restore or share projects.", - "acceptanceCriteria": [ - "Clicking 'Import' in toolbar opens file picker", - "Accept .vnflow and .json file extensions", - "If current project has unsaved changes, show confirmation dialog", - "Validate imported file has nodes and edges arrays", - "Show error toast if file is invalid", - "Load valid data into React Flow state (replaces current flowchart)", - "Typecheck passes", - "Verify in browser using dev-browser skill" - ], - "priority": 36, - "passes": false, - "notes": "" - }, - { - "id": "US-037", - "title": "Export to Ren'Py JSON format", - "description": "As a user, I want to export my flowchart to Ren'Py-compatible JSON for use in my game.", - "acceptanceCriteria": [ - "Add 'Export to Ren'Py' option (button or dropdown item)", - "File named '[project-name]-renpy.json'", - "Dialogue nodes export as: { type: 'dialogue', speaker: '...', text: '...' }", - "Choice nodes export as: { type: 'menu', prompt: '...', choices: [{ label: '...', next: '...' }] }", - "Variable nodes export as: { type: 'variable', name: '...', operation: '...', value: ... }", - "Edges with conditions include condition object on the choice/jump", - "Organize nodes into labeled sections based on flow (traverse from first node)", - "Include metadata: projectName, exportedAt timestamp", - "Output JSON is valid (test with JSON.parse)", - "Typecheck passes" - ], - "priority": 37, - "passes": false, - "notes": "" - }, - { - "id": "US-038", - "title": "Unsaved changes warning", - "description": "As a user, I want a warning before losing unsaved work.", - "acceptanceCriteria": [ - "Track dirty state: true when flowchart modified after last save", - "Set dirty=true on node/edge add, delete, or modify", - "Set dirty=false after successful save", - "Browser beforeunload event shows warning if dirty", - "Navigating to dashboard shows confirmation modal if dirty", - "Typecheck passes", - "Verify in browser using dev-browser skill" - ], - "priority": 38, - "passes": false, - "notes": "" - }, - { - "id": "US-039", - "title": "Loading and error states", - "description": "As a user, I want clear feedback when things are loading or when errors occur.", - "acceptanceCriteria": [ - "Loading spinner component for async operations", - "Editor shows loading spinner while fetching project", - "Error message displayed if project fails to load (with back to dashboard link)", - "Toast notification system for success/error messages", - "Save error shows toast with retry option", - "Typecheck passes", - "Verify in browser using dev-browser skill" - ], - "priority": 39, - "passes": false, - "notes": "" + "notes": "Dependencies: US-052, US-048" } ] } diff --git a/progress.txt b/progress.txt index dab9d29..89cf9ce 100644 --- a/progress.txt +++ b/progress.txt @@ -24,412 +24,21 @@ - Register custom node types in nodeTypes object (memoized with useMemo) and pass to ReactFlow component - FlowchartEditor uses ReactFlowProvider wrapper + inner component pattern for useReactFlow() hook access - Use nanoid for generating unique node IDs (import from 'nanoid') +- Reusable LoadingSpinner component in `src/components/LoadingSpinner.tsx` with size ('sm'|'md'|'lg') and optional message +- Toast component supports an optional `action` prop: `{ label: string; onClick: () => void }` for retry/undo buttons +- Settings page at `/dashboard/settings` reuses dashboard layout; re-auth via signInWithPassword before updateUser +- Character/Variable types (`Character`, `Variable`) and extracted node data types (`DialogueNodeData`, `VariableNodeData`) are in `src/types/flowchart.ts` +- New JSONB fields (characters, variables) must be defaulted to `[]` when reading from DB in page.tsx to handle pre-existing data --- -## 2026-01-21 - US-001 -- What was implemented: Project scaffolding and configuration +## 2026-01-23 - US-054 +- What was implemented: Character and Variable TypeScript types added to `src/types/flowchart.ts` - Files changed: - - package.json - project dependencies and scripts - - tsconfig.json - TypeScript configuration - - next.config.ts - Next.js configuration - - postcss.config.mjs - PostCSS with TailwindCSS - - eslint.config.mjs - ESLint configuration - - .env.example - environment variables template - - .gitignore - git ignore rules - - src/app/ - Next.js App Router pages - - src/components/.gitkeep - components directory placeholder - - src/lib/.gitkeep - lib directory placeholder - - src/types/.gitkeep - types directory placeholder + - `src/types/flowchart.ts` - Added `Character`, `Variable`, `DialogueNodeData`, `VariableNodeData` types; updated `FlowchartData`, `DialogueNode`, `VariableNode`, `Condition` types + - `src/app/editor/[projectId]/page.tsx` - Updated FlowchartData initialization to include `characters: []` and `variables: []` defaults - **Learnings for future iterations:** - - Next.js 16 uses `@tailwindcss/postcss` for TailwindCSS 4 integration - - Use --src-dir flag for create-next-app to put source in src/ folder - - npm package names can't have capital letters (use lowercase) - - .gitignore needs explicit exclusion for .env files, but include .env.example ---- - -## 2026-01-21 - US-002 -- What was implemented: TypeScript types for flowchart data structures -- Files changed: - - src/types/flowchart.ts - new file with all flowchart type definitions - - package.json - added typecheck script (tsc --noEmit) -- **Learnings for future iterations:** - - Position is a helper type for {x, y} coordinates used by nodes - - FlowchartNode is a union type of DialogueNode | ChoiceNode | VariableNode - - ChoiceOption is a separate type to make options array cleaner - - All types use `export type` for TypeScript isolatedModules compatibility ---- - -## 2026-01-21 - US-003 -- What was implemented: Supabase schema for users and projects -- Files changed: - - supabase/migrations/20260121000000_create_profiles_and_projects.sql - new file with all database schema -- **Learnings for future iterations:** - - Supabase migrations are plain SQL files in supabase/migrations/ directory - - Migration filenames use timestamp prefix (YYYYMMDDHHMMSS_description.sql) - - RLS policies need separate policies for SELECT, INSERT, UPDATE, DELETE operations - - Admin check policy uses EXISTS subquery to check is_admin flag on profiles table - - projects table references profiles.id (not auth.users.id directly) for proper FK relationships - - flowchart_data column uses JSONB type with default empty structure - - Added auto-update trigger for updated_at timestamp on projects table ---- - -## 2026-01-21 - US-004 -- What was implemented: Supabase client configuration utilities -- Files changed: - - src/lib/supabase/client.ts - browser client using createBrowserClient from @supabase/ssr - - src/lib/supabase/server.ts - server client for App Router with async cookies() API - - src/lib/supabase/middleware.ts - middleware helper with updateSession function - - src/lib/.gitkeep - removed (no longer needed) -- **Learnings for future iterations:** - - @supabase/ssr package provides createBrowserClient and createServerClient functions - - Server client requires async cookies() from next/headers in Next.js 16 - - Middleware client returns both user object and supabaseResponse for route protection - - Cookie handling uses getAll/setAll pattern for proper session management - - setAll in server.ts wrapped in try/catch to handle Server Component limitations ---- - -## 2026-01-21 - US-005 -- What was implemented: Protected routes middleware for authentication -- Files changed: - - middleware.ts - new file at project root for route protection -- **Learnings for future iterations:** - - Next.js middleware.ts must be at project root (not in src/) - - updateSession helper from lib/supabase/middleware.ts returns { user, supabaseResponse } - - Use startsWith() for route matching to handle nested routes (e.g., /editor/*) - - Matcher config excludes static files and images to avoid unnecessary middleware calls - - Clone nextUrl before modifying pathname for redirects ---- - -## 2026-01-21 - US-006 -- What was implemented: Login page with email/password authentication -- Files changed: - - src/app/login/page.tsx - new file with login form and Supabase auth -- **Learnings for future iterations:** - - Auth pages use 'use client' directive since they need useState and form handling - - Use createClient() from lib/supabase/client.ts for browser-side auth operations - - supabase.auth.signInWithPassword returns { error } object for handling failures - - useRouter from next/navigation for programmatic redirects after auth - - Error state displayed in red alert box with dark mode support - - Loading state disables submit button and shows "Signing in..." text - - TailwindCSS dark mode uses dark: prefix (e.g., dark:bg-zinc-950) ---- - -## 2026-01-21 - US-007 -- What was implemented: Sign up page for invite-only account setup -- Files changed: - - src/app/signup/page.tsx - new file with signup form and Supabase auth -- **Learnings for future iterations:** - - Supabase invite tokens come via URL hash fragment (window.location.hash) - - Parse hash with URLSearchParams after removing leading '#' - - Check for type=invite or type=signup to detect invite flow - - Use setSession() with access_token and refresh_token to establish session from invite link - - For invited users, update password with updateUser() then create profile with upsert() - - Use upsert() instead of insert() for profiles to handle edge cases - - Validate password confirmation before submission (passwords match check) - - display_name defaults to email prefix (split('@')[0]) ---- - -## 2026-01-21 - US-008 -- What was implemented: Logout functionality component -- Files changed: - - src/components/LogoutButton.tsx - new client component with signOut and redirect - - src/components/.gitkeep - removed (no longer needed) -- **Learnings for future iterations:** - - LogoutButton is a reusable component that will be used in the navbar (US-011) - - Component uses 'use client' directive for client-side auth operations - - Loading state prevents double-clicks during signOut - - Styled with neutral zinc colors to work as a secondary button in navbars ---- - -## 2026-01-21 - US-009 -- What was implemented: Password reset - forgot password page -- Files changed: - - src/app/forgot-password/page.tsx - new file with forgot password form and email reset -- **Learnings for future iterations:** - - resetPasswordForEmail requires redirectTo option to specify where user lands after clicking reset link - - Use `window.location.origin` to get the current site URL for redirectTo - - Page shows different UI after success (conditional rendering with success state) - - Use ' for apostrophe in JSX to avoid HTML entity issues - - Follow same styling pattern as login page for consistency across auth pages ---- - -## 2026-01-21 - US-010 -- What was implemented: Password reset - set new password page -- Files changed: - - src/app/reset-password/page.tsx - new file with password reset form - - src/app/login/page.tsx - updated to show success message from password reset -- **Learnings for future iterations:** - - Supabase recovery tokens come via URL hash fragment with type=recovery - - Use setSession() with access_token and refresh_token from hash to establish recovery session - - Show loading state while verifying token validity (tokenValid === null) - - Show error state with link to request new reset if token is invalid - - After password update, sign out the user and redirect to login with success message - - Use query param (message=password_reset_success) to pass success state between pages - - Login page uses useSearchParams to read and display success messages - - Success messages styled with green background (bg-green-50) ---- - -## 2026-01-21 - US-011 -- What was implemented: Dashboard layout with navbar component -- Files changed: - - src/app/dashboard/layout.tsx - new file with dashboard layout wrapper - - src/components/Navbar.tsx - new reusable navbar component -- **Learnings for future iterations:** - - Dashboard layout is a server component that fetches user data via createClient() from lib/supabase/server.ts - - Navbar accepts userEmail prop to display current user - - Layout wraps children with consistent max-w-7xl container and padding - - Navbar uses Link component to allow clicking app title to go back to dashboard - - Navbar has border-b styling with dark mode support for visual separation - - Use gap-4 for spacing between navbar items (user email and logout button) ---- - -## 2026-01-21 - US-012 -- What was implemented: Dashboard page listing user projects -- Files changed: - - src/app/dashboard/page.tsx - new file with project listing, cards, and empty state -- **Learnings for future iterations:** - - Dashboard page is a server component that fetches projects directly from Supabase - - Use .eq('user_id', user.id) for RLS-backed queries (though RLS also enforces this) - - Order by updated_at descending to show most recent projects first - - formatDate() helper with toLocaleDateString for human-readable dates - - Project cards use Link component for navigation to /editor/[projectId] - - Empty state uses dashed border (border-dashed) with centered content and icon - - Hover effects on cards: border-blue-300, shadow-md, and text color change on title - - Error state displayed if Supabase query fails ---- - -## 2026-01-21 - US-013 -- What was implemented: Create new project functionality -- Files changed: - - src/components/NewProjectButton.tsx - new client component with modal dialog - - src/app/dashboard/page.tsx - added NewProjectButton to header area - - src/app/signup/page.tsx - fixed lint error (setState in effect) by initializing email from searchParams -- **Learnings for future iterations:** - - Modal dialogs use fixed positioning with backdrop (bg-black/50) for overlay effect - - Form submission uses Supabase insert with .select('id').single() to get the new record ID - - Initialize flowchart_data with { nodes: [], edges: [] } for new projects - - router.push() for programmatic navigation after successful creation - - autoFocus on input for better UX when modal opens - - Prevent modal close while loading (check isLoading before calling handleClose) - - ESLint rule react-hooks/set-state-in-effect warns against synchronous setState in useEffect - - Initialize state from searchParams directly in useState() instead of setting in useEffect ---- - -## 2026-01-21 - US-014 -- What was implemented: Delete project functionality with confirmation dialog and toast -- Files changed: - - src/components/ProjectCard.tsx - new client component replacing Link, with delete button and confirmation dialog - - src/components/ProjectList.tsx - new wrapper component to manage project list state and toast notifications - - src/components/Toast.tsx - new reusable toast notification component - - src/app/dashboard/page.tsx - updated to use ProjectList instead of inline rendering -- **Learnings for future iterations:** - - To enable client-side state updates (like removing items), extract list rendering from server components into client components - - ProjectList accepts initialProjects from server and manages state locally for immediate UI updates - - Use onDelete callback pattern to propagate deletion events from child (ProjectCard) to parent (ProjectList) - - Delete button uses e.stopPropagation() to prevent card click navigation when clicking delete - - Confirmation dialogs should disable close/cancel while action is in progress (isDeleting check) - - Toast component uses useCallback for handlers and auto-dismiss with setTimeout - - Toast animations can use TailwindCSS animate-in utilities (fade-in, slide-in-from-bottom-4) - - Delete icon appears on hover using group-hover:opacity-100 with parent group class ---- - -## 2026-01-21 - US-015 -- What was implemented: Rename project functionality -- Files changed: - - src/components/ProjectCard.tsx - added rename button, modal dialog, and Supabase update logic - - src/components/ProjectList.tsx - added handleRename callback and toast notification -- **Learnings for future iterations:** - - Multiple action buttons on a card can be grouped in a flex container with gap-1 - - Rename modal follows same pattern as delete dialog: fixed positioning, backdrop, form - - Use onKeyDown to handle Enter key for quick form submission - - Reset form state (newName, error) when opening modal to handle edge cases - - Check if name is unchanged before making API call to avoid unnecessary requests - - Trim whitespace from input value before validation and submission - - handleRename callback updates project name in state using map() to preserve list order ---- - -## 2026-01-21 - US-016 -- What was implemented: Admin invite user functionality -- Files changed: - - src/app/admin/invite/page.tsx - new admin-only page with access check (redirects non-admins) - - src/app/admin/invite/InviteForm.tsx - client component with invite form and state management - - src/app/admin/invite/actions.ts - server action using service role key to call inviteUserByEmail - - src/components/Navbar.tsx - added isAdmin prop and "Invite User" link (visible only to admins) - - src/app/dashboard/layout.tsx - fetches profile.is_admin and passes it to Navbar - - .env.example - added SUPABASE_SERVICE_ROLE_KEY and NEXT_PUBLIC_SITE_URL -- **Learnings for future iterations:** - - Admin operations require SUPABASE_SERVICE_ROLE_KEY (server-side only, not NEXT_PUBLIC_*) - - Use createClient from @supabase/supabase-js directly for admin client (not @supabase/ssr) - - Admin client needs auth config: { autoRefreshToken: false, persistSession: false } - - inviteUserByEmail requires redirectTo option for the signup link in email - - Server actions ('use server') can access private env vars safely - - Admin check should happen both in server component (redirect) and server action (double check) - - Admin page uses its own layout (not dashboard layout) to have custom styling ---- - -## 2026-01-21 - US-017 -- What was implemented: Editor page with React Flow canvas -- Files changed: - - package.json - added reactflow dependency - - src/app/editor/[projectId]/page.tsx - new server component that fetches project from Supabase, handles auth/not found, renders header with back link - - src/app/editor/[projectId]/FlowchartEditor.tsx - new client component with React Flow canvas, Background component, type converters for nodes/edges - - src/app/editor/[projectId]/loading.tsx - new loading state component with spinner -- **Learnings for future iterations:** - - React Flow requires 'use client' directive since it uses browser APIs - - Import 'reactflow/dist/style.css' for default React Flow styling - - Use useNodesState and useEdgesState hooks for managing nodes/edges state - - Convert app types (FlowchartNode, FlowchartEdge) to React Flow types with helper functions - - Next.js dynamic route params come as Promise in App Router 16+ (need to await params) - - Use notFound() from next/navigation for 404 responses - - React Flow canvas needs parent container with explicit height (h-full, h-screen) - - Background component accepts variant (Dots, Lines, Cross) and gap/size props - - Loading page (loading.tsx) provides automatic loading UI for async server components ---- - -## 2026-01-21 - US-018 -- What was implemented: Canvas pan and zoom controls -- Files changed: - - src/app/editor/[projectId]/FlowchartEditor.tsx - added Controls import and component -- **Learnings for future iterations:** - - React Flow Controls component provides zoom +/-, fitView, and lock buttons out of the box - - Use position="bottom-right" prop to position controls in bottom-right corner - - Pan (click-and-drag) and zoom (mouse wheel) are React Flow defaults, no extra config needed ---- - -## 2026-01-21 - US-019 -- What was implemented: Editor toolbar with add/save/export/import buttons -- Files changed: - - src/components/editor/Toolbar.tsx - new toolbar component with styled buttons - - src/app/editor/[projectId]/FlowchartEditor.tsx - integrated toolbar with placeholder handlers -- **Learnings for future iterations:** - - Toolbar component accepts callback props for actions (onAddDialogue, onSave, etc.) - - Node type buttons use color coding: blue (Dialogue), green (Choice), orange (Variable) - - Action buttons (Save, Export, Import) use neutral bordered styling - - FlowchartEditor now uses flex-col layout to stack toolbar above canvas - - Placeholder handlers with TODO comments help track future implementation work ---- - -## 2026-01-21 - US-020 -- What was implemented: Custom DialogueNode component for displaying/editing character dialogue -- Files changed: - - src/components/editor/nodes/DialogueNode.tsx - new custom node component with editable speaker and text fields - - src/app/editor/[projectId]/FlowchartEditor.tsx - registered DialogueNode as custom node type -- **Learnings for future iterations:** - - Custom React Flow nodes use NodeProps for typing, where T is the data shape - - Use useReactFlow() hook to get setNodes for updating node data from within the node component - - Handle components need Position enum (Position.Top, Position.Bottom) for positioning - - Custom handles can be styled with className and TailwindCSS, use ! prefix to override defaults (e.g., !h-3, !w-3) - - Node types must be registered in a nodeTypes object and passed to ReactFlow component - - Memoize nodeTypes with useMemo to prevent unnecessary re-renders - - Custom node components go in src/components/editor/nodes/ directory ---- - -## 2026-01-21 - US-021 -- What was implemented: Add dialogue node from toolbar functionality -- Files changed: - - package.json - added nanoid dependency for unique ID generation - - src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleAddDialogue to create new dialogue nodes at viewport center -- **Learnings for future iterations:** - - useReactFlow() hook requires ReactFlowProvider wrapper, so split component into inner component and outer wrapper - - getViewport() returns { x, y, zoom } representing the current pan/zoom state - - Calculate viewport center: centerX = (-viewport.x + halfWidth) / viewport.zoom - - nanoid v5+ generates unique IDs synchronously with no dependencies - - Node creation pattern: create Node object with { id, type, position, data }, then add to state via setNodes - - React Flow nodes are draggable by default, no extra configuration needed ---- - -## 2026-01-21 - US-022 -- What was implemented: Custom ChoiceNode component for displaying branching decisions -- Files changed: - - src/components/editor/nodes/ChoiceNode.tsx - new custom node component with green styling, editable prompt, and dynamic option handles - - src/app/editor/[projectId]/FlowchartEditor.tsx - registered ChoiceNode as custom node type -- **Learnings for future iterations:** - - ChoiceNode follows same pattern as DialogueNode: NodeProps typing, useReactFlow() for updates - - Dynamic handles positioned using style={{ left: `${((index + 1) / (options.length + 1)) * 100}%` }} for even spacing - - Handle id format for options: 'option-0', 'option-1', etc. (matching the index) - - Each option needs a unique id (string) and label (string) per the ChoiceOption type - - updateOptionLabel callback pattern: find option by id, map over options array to update matching one ---- - -## 2026-01-21 - US-023 -- What was implemented: Add choice node from toolbar functionality -- Files changed: - - src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleAddChoice to create new choice nodes at viewport center -- **Learnings for future iterations:** - - handleAddChoice follows same pattern as handleAddDialogue: get viewport center, create node with nanoid, add to state - - Choice nodes must be initialized with 2 options (each with unique id via nanoid and empty label) - - Node data structure for choice: { prompt: '', options: [{ id, label }, { id, label }] } - - React Flow nodes are draggable by default after being added to state ---- - -## 2026-01-21 - US-024 -- What was implemented: Add/remove choice options functionality (2-6 options supported) -- Files changed: - - src/components/editor/nodes/ChoiceNode.tsx - added addOption and removeOption callbacks, '+' button to add options, 'x' button per option to remove -- **Learnings for future iterations:** - - Define MIN_OPTIONS and MAX_OPTIONS constants for clear limits - - Use disabled prop on buttons to enforce min/max constraints with appropriate styling (opacity-30, cursor-not-allowed) - - Remove button uses × character for simple cross icon - - Add button styled with border-dashed for visual distinction from action buttons - - Handles update dynamically via React Flow re-render when options array changes ---- - -## 2026-01-21 - US-025 -- What was implemented: Custom VariableNode component for setting/modifying story variables -- Files changed: - - src/components/editor/nodes/VariableNode.tsx - new custom node component with orange styling, editable variable name, operation dropdown, and numeric value input - - src/app/editor/[projectId]/FlowchartEditor.tsx - imported and registered VariableNode in nodeTypes -- **Learnings for future iterations:** - - VariableNode follows same pattern as DialogueNode: NodeProps typing, useReactFlow() for updates - - Use parseFloat() with fallback to 0 for number input handling: `parseFloat(e.target.value) || 0` - - Operation dropdown uses select element with options for 'set', 'add', 'subtract' - - Type assertion needed for select value: `e.target.value as 'set' | 'add' | 'subtract'` - - Use `??` (nullish coalescing) for number defaults instead of `||` to allow 0 values: `data.value ?? 0` ---- - -## 2026-01-21 - US-026 -- What was implemented: Add variable node from toolbar functionality -- Files changed: - - src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleAddVariable to create new variable nodes at viewport center -- **Learnings for future iterations:** - - handleAddVariable follows same pattern as handleAddDialogue and handleAddChoice: get viewport center, create node with nanoid, add to state - - Variable nodes initialized with { variableName: '', operation: 'set', value: 0 } - - All add node handlers share the same pattern and use the getViewportCenter helper ---- - -## 2026-01-21 - US-027 -- What was implemented: Connect nodes with edges including arrow markers and smooth styling -- Files changed: - - src/app/editor/[projectId]/FlowchartEditor.tsx - added MarkerType import, updated onConnect to create edges with smoothstep type and arrow markers, updated toReactFlowEdges to apply same styling to loaded edges -- **Learnings for future iterations:** - - Use `type: 'smoothstep'` for cleaner edge curves instead of default bezier - - Use `markerEnd: { type: MarkerType.ArrowClosed }` to add directional arrows to edges - - Connection type has nullable source/target, but Edge requires non-null strings - guard with early return - - Apply consistent edge styling in both onConnect (new edges) and toReactFlowEdges (loaded edges) - - Generate unique edge IDs with nanoid in onConnect callback ---- - -## 2026-01-21 - US-028 -- What was implemented: Select and delete nodes functionality -- Files changed: - - src/app/editor/[projectId]/FlowchartEditor.tsx - added deleteKeyCode prop to enable Delete/Backspace key deletion -- **Learnings for future iterations:** - - React Flow has built-in node selection via clicking - no extra configuration needed - - Use `deleteKeyCode={['Delete', 'Backspace']}` prop to enable keyboard deletion - - React Flow automatically removes connected edges when a node is deleted (no manual cleanup needed) - - The useNodesState/useEdgesState hooks with onNodesChange/onEdgesChange handle all deletion state updates - - No explicit onNodesDelete callback is needed - the onNodesChange handler covers deletion events ---- - -## 2026-01-21 - US-029 -- What was implemented: Select and delete edges functionality -- Files changed: - - src/app/editor/[projectId]/FlowchartEditor.tsx - added onEdgesDelete callback -- **Learnings for future iterations:** - - React Flow 11 edges are clickable and selectable by default (interactionWidth renders invisible interaction area) - - The `deleteKeyCode` prop works for both nodes and edges - same configuration covers both - - onEdgesDelete is optional if you just need state management (onEdgesChange handles removal events) - - onEdgesDelete is useful for additional logic like logging, dirty state tracking, or undo/redo - - Edge selection shows visual highlight via React Flow's built-in styling + - The node components (`DialogueNode.tsx`, `VariableNode.tsx`, `ChoiceNode.tsx`) define their own local data types that mirror the global types. When adding fields, both the global type and local component type may need updating in later stories. + - `flowchart_data` is a JSONB column in Supabase, so it comes as `any` type. Always provide defaults for new fields when reading from DB to handle existing data without those fields. + - The new `characterId` and `variableId` fields are optional alongside existing `speaker`/`variableName` fields to support migration from free-text to referenced-entity pattern. --- diff --git a/src/app/editor/[projectId]/page.tsx b/src/app/editor/[projectId]/page.tsx index 8117932..0750c85 100644 --- a/src/app/editor/[projectId]/page.tsx +++ b/src/app/editor/[projectId]/page.tsx @@ -31,10 +31,13 @@ export default async function EditorPage({ params }: PageProps) { notFound() } - const flowchartData = (project.flowchart_data || { - nodes: [], - edges: [], - }) as FlowchartData + const rawData = project.flowchart_data || {} + const flowchartData: FlowchartData = { + nodes: rawData.nodes || [], + edges: rawData.edges || [], + characters: rawData.characters || [], + variables: rawData.variables || [], + } return (
diff --git a/src/types/flowchart.ts b/src/types/flowchart.ts index 21a5c54..94ed51d 100644 --- a/src/types/flowchart.ts +++ b/src/types/flowchart.ts @@ -4,15 +4,35 @@ export type Position = { y: number; }; +// Character type: represents a defined character in the project +export type Character = { + id: string; + name: string; + color: string; // hex color + description?: string; +}; + +// Variable type: represents a defined variable in the project +export type Variable = { + id: string; + name: string; + type: 'numeric' | 'string' | 'boolean'; + initialValue: number | string | boolean; + description?: string; +}; + // DialogueNode type: represents character speech/dialogue +export type DialogueNodeData = { + speaker?: string; + characterId?: string; + text: string; +}; + export type DialogueNode = { id: string; type: 'dialogue'; position: Position; - data: { - speaker?: string; - text: string; - }; + data: DialogueNodeData; }; // Choice option type for ChoiceNode @@ -33,15 +53,18 @@ export type ChoiceNode = { }; // VariableNode type: represents variable operations +export type VariableNodeData = { + variableName: string; + variableId?: string; + operation: 'set' | 'add' | 'subtract'; + value: number; +}; + export type VariableNode = { id: string; type: 'variable'; position: Position; - data: { - variableName: string; - operation: 'set' | 'add' | 'subtract'; - value: number; - }; + data: VariableNodeData; }; // Union type for all node types @@ -50,6 +73,7 @@ export type FlowchartNode = DialogueNode | ChoiceNode | VariableNode; // Condition type for conditional edges export type Condition = { variableName: string; + variableId?: string; operator: '>' | '<' | '==' | '>=' | '<=' | '!='; value: number; }; @@ -70,4 +94,6 @@ export type FlowchartEdge = { export type FlowchartData = { nodes: FlowchartNode[]; edges: FlowchartEdge[]; + characters: Character[]; + variables: Variable[]; }; From 1f7bd321a20ea5da0a599c2de88efb0592326ea9 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Fri, 23 Jan 2026 04:24:58 -0300 Subject: [PATCH 02/18] feat: [US-055] - Database schema update for characters and variables Co-Authored-By: Claude Opus 4.5 --- ...characters_variables_to_flowchart_data.sql | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 supabase/migrations/20260123000000_add_characters_variables_to_flowchart_data.sql diff --git a/supabase/migrations/20260123000000_add_characters_variables_to_flowchart_data.sql b/supabase/migrations/20260123000000_add_characters_variables_to_flowchart_data.sql new file mode 100644 index 0000000..2a0642c --- /dev/null +++ b/supabase/migrations/20260123000000_add_characters_variables_to_flowchart_data.sql @@ -0,0 +1,45 @@ +-- Migration: Add characters and variables arrays to flowchart_data JSONB default +-- Part of Character/Variable Management feature (US-055) +-- +-- The characters and variables arrays are stored within the flowchart_data JSONB column. +-- This migration updates the default value for new projects to include empty arrays. +-- Existing projects without these fields are handled at the application layer, +-- which defaults missing characters/variables to empty arrays on read. + +-- ============================================================================= +-- UPDATE DEFAULT VALUE FOR flowchart_data +-- ============================================================================= +-- Update the default to include characters and variables arrays +ALTER TABLE projects + ALTER COLUMN flowchart_data + SET DEFAULT '{"nodes": [], "edges": [], "characters": [], "variables": []}'::jsonb; + +-- ============================================================================= +-- DOCUMENTATION: JSONB Structure +-- ============================================================================= +-- The flowchart_data column now expects the following top-level structure: +-- +-- { +-- "nodes": [...], -- Array of flowchart nodes +-- "edges": [...], -- Array of flowchart edges +-- "characters": [ -- Array of character definitions +-- { +-- "id": "string", -- Unique identifier (nanoid) +-- "name": "string", -- Character name (required, unique per project) +-- "color": "string", -- Hex color code (e.g., "#FF5733") +-- "description": "string" -- Optional description +-- } +-- ], +-- "variables": [ -- Array of variable definitions +-- { +-- "id": "string", -- Unique identifier (nanoid) +-- "name": "string", -- Variable name (required, unique per project) +-- "type": "string", -- One of: 'numeric', 'string', 'boolean' +-- "initialValue": "any", -- Initial value matching the type +-- "description": "string" -- Optional description +-- } +-- ] +-- } +-- +-- Note: Existing projects that do not have characters/variables fields +-- are handled at the application layer, which defaults them to empty arrays. From 11e8daf67c2a0ac89522e55a98aaf315fef1779e Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Fri, 23 Jan 2026 04:27:32 -0300 Subject: [PATCH 03/18] feat: [US-065] - Searchable combobox component Co-Authored-By: Claude Opus 4.5 --- src/components/editor/Combobox.tsx | 263 +++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 src/components/editor/Combobox.tsx diff --git a/src/components/editor/Combobox.tsx b/src/components/editor/Combobox.tsx new file mode 100644 index 0000000..b84b7bb --- /dev/null +++ b/src/components/editor/Combobox.tsx @@ -0,0 +1,263 @@ +'use client' + +import { useState, useRef, useEffect, useCallback, useMemo } from 'react' + +export type ComboboxItem = { + id: string + label: string + color?: string + badge?: string +} + +type ComboboxProps = { + items: ComboboxItem[] + value: string | undefined + onChange: (id: string) => void + placeholder?: string + onAddNew?: () => void +} + +export default function Combobox({ + items, + value, + onChange, + placeholder = 'Select...', + onAddNew, +}: ComboboxProps) { + const [isOpen, setIsOpen] = useState(false) + const [search, setSearch] = useState('') + const [highlightedIndex, setHighlightedIndex] = useState(0) + const [dropdownPosition, setDropdownPosition] = useState<'below' | 'above'>('below') + + const containerRef = useRef(null) + const inputRef = useRef(null) + const listRef = useRef(null) + + const selectedItem = useMemo( + () => items.find((item) => item.id === value), + [items, value] + ) + + const filteredItems = useMemo( + () => + items.filter((item) => + item.label.toLowerCase().includes(search.toLowerCase()) + ), + [items, search] + ) + + const totalOptions = filteredItems.length + (onAddNew ? 1 : 0) + + const updateDropdownPosition = useCallback(() => { + if (!containerRef.current) return + const rect = containerRef.current.getBoundingClientRect() + const spaceBelow = window.innerHeight - rect.bottom + const spaceAbove = rect.top + setDropdownPosition(spaceBelow < 200 && spaceAbove > spaceBelow ? 'above' : 'below') + }, []) + + const open = useCallback(() => { + setIsOpen(true) + setSearch('') + setHighlightedIndex(0) + updateDropdownPosition() + }, [updateDropdownPosition]) + + const close = useCallback(() => { + setIsOpen(false) + setSearch('') + }, []) + + const selectItem = useCallback( + (id: string) => { + onChange(id) + close() + }, + [onChange, close] + ) + + // Close on outside click + useEffect(() => { + if (!isOpen) return + + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + close() + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [isOpen, close]) + + // Scroll highlighted item into view + useEffect(() => { + if (!isOpen || !listRef.current) return + const items = listRef.current.querySelectorAll('[data-combobox-item]') + const highlighted = items[highlightedIndex] + if (highlighted) { + highlighted.scrollIntoView({ block: 'nearest' }) + } + }, [highlightedIndex, isOpen]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!isOpen) { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') { + e.preventDefault() + open() + } + return + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setHighlightedIndex((prev) => (prev + 1) % totalOptions) + break + case 'ArrowUp': + e.preventDefault() + setHighlightedIndex((prev) => (prev - 1 + totalOptions) % totalOptions) + break + case 'Enter': + e.preventDefault() + if (highlightedIndex < filteredItems.length) { + selectItem(filteredItems[highlightedIndex].id) + } else if (onAddNew && highlightedIndex === filteredItems.length) { + onAddNew() + close() + } + break + case 'Escape': + e.preventDefault() + close() + break + } + }, + [isOpen, open, close, highlightedIndex, filteredItems, totalOptions, selectItem, onAddNew] + ) + + return ( +
+
{ + if (isOpen) { + close() + } else { + open() + setTimeout(() => inputRef.current?.focus(), 0) + } + }} + > + {isOpen ? ( + { + setSearch(e.target.value) + setHighlightedIndex(0) + }} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className="w-full bg-transparent outline-none placeholder-zinc-400 dark:placeholder-zinc-500" + onClick={(e) => e.stopPropagation()} + /> + ) : ( + + {selectedItem ? ( + + {selectedItem.color && ( + + )} + {selectedItem.badge && ( + + {selectedItem.badge} + + )} + {selectedItem.label} + + ) : ( + placeholder + )} + + )} + + + +
+ + {isOpen && ( +
    + {filteredItems.length === 0 && !onAddNew && ( +
  • + No results found +
  • + )} + + {filteredItems.map((item, index) => ( +
  • selectItem(item.id)} + onMouseEnter={() => setHighlightedIndex(index)} + > + {item.color && ( + + )} + {item.badge && ( + + {item.badge} + + )} + {item.label} +
  • + ))} + + {onAddNew && ( +
  • { + onAddNew() + close() + }} + onMouseEnter={() => setHighlightedIndex(filteredItems.length)} + > + + + + Add new... +
  • + )} +
+ )} +
+ ) +} From 8418f497878a4e98a119b38b5ddb123625722c15 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Fri, 23 Jan 2026 04:28:07 -0300 Subject: [PATCH 04/18] chore: mark US-065 as complete and update progress log Co-Authored-By: Claude Opus 4.5 --- prd.json | 4 ++-- progress.txt | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/prd.json b/prd.json index 3c6e280..923864a 100644 --- a/prd.json +++ b/prd.json @@ -31,7 +31,7 @@ "Typecheck passes" ], "priority": 2, - "passes": false, + "passes": true, "notes": "Dependencies: US-054" }, { @@ -51,7 +51,7 @@ "Verify in browser using dev-browser skill" ], "priority": 3, - "passes": false, + "passes": true, "notes": "" }, { diff --git a/progress.txt b/progress.txt index 89cf9ce..c4bf798 100644 --- a/progress.txt +++ b/progress.txt @@ -29,6 +29,7 @@ - Settings page at `/dashboard/settings` reuses dashboard layout; re-auth via signInWithPassword before updateUser - Character/Variable types (`Character`, `Variable`) and extracted node data types (`DialogueNodeData`, `VariableNodeData`) are in `src/types/flowchart.ts` - New JSONB fields (characters, variables) must be defaulted to `[]` when reading from DB in page.tsx to handle pre-existing data +- Reusable `Combobox` component at `src/components/editor/Combobox.tsx` - use for all character/variable dropdowns. Props: items (ComboboxItem[]), value, onChange, placeholder, onAddNew --- @@ -42,3 +43,27 @@ - `flowchart_data` is a JSONB column in Supabase, so it comes as `any` type. Always provide defaults for new fields when reading from DB to handle existing data without those fields. - The new `characterId` and `variableId` fields are optional alongside existing `speaker`/`variableName` fields to support migration from free-text to referenced-entity pattern. --- + +## 2026-01-23 - US-055 +- What was implemented: Database migration to update flowchart_data JSONB default to include `characters: []` and `variables: []` +- Files changed: + - `supabase/migrations/20260123000000_add_characters_variables_to_flowchart_data.sql` - New migration that alters the default value for the flowchart_data column and documents the expected JSONB structure +- **Learnings for future iterations:** + - Since characters and variables are stored within the existing flowchart_data JSONB column (not as separate tables), schema changes are minimal - just updating the column default. The real data integrity is handled at the application layer. + - The app-side defaults in page.tsx (from US-054) already handle existing projects gracefully, so no data migration of existing rows is needed. + - For JSONB-embedded arrays, the pattern is: update the DB default for new rows + handle missing fields in app code for old rows. +--- + +## 2026-01-23 - US-065 +- What was implemented: Reusable searchable combobox component at `src/components/editor/Combobox.tsx` +- Files changed: + - `src/components/editor/Combobox.tsx` - New component with searchable dropdown, keyboard navigation, color swatches, badges, "Add new..." option, and auto-positioning +- **Learnings for future iterations:** + - The Combobox exports both the default component and the `ComboboxItem` type for consumers to use + - Props: `items` (ComboboxItem[]), `value` (string | undefined), `onChange` (id: string) => void, `placeholder` (string), `onAddNew` (() => void, optional) + - ComboboxItem shape: `{ id: string, label: string, color?: string, badge?: string }` + - The component uses neutral zinc colors for borders/backgrounds (not blue/green/orange) so it can be reused across different node types + - Dropdown auto-positions above or below based on available viewport space (200px threshold) + - Keyboard: ArrowDown/Up navigate, Enter selects, Escape closes + - The component is designed to be a drop-in replacement for text inputs in node components (same `w-full` and `text-sm` sizing) +--- From 2ef605c0caf96fb8763a8fb77f47d8b46d86f55d Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Fri, 23 Jan 2026 04:31:42 -0300 Subject: [PATCH 05/18] feat: [US-056] - Character management UI in project settings Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 41 +- .../editor/ProjectSettingsModal.tsx | 421 ++++++++++++++++++ src/components/editor/Toolbar.tsx | 8 + 3 files changed, 468 insertions(+), 2 deletions(-) create mode 100644 src/components/editor/ProjectSettingsModal.tsx diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index a37e8fc..ee0c8f6 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useMemo } from 'react' +import { useCallback, useMemo, useState } from 'react' import ReactFlow, { Background, BackgroundVariant, @@ -22,7 +22,8 @@ import Toolbar from '@/components/editor/Toolbar' import DialogueNode from '@/components/editor/nodes/DialogueNode' import ChoiceNode from '@/components/editor/nodes/ChoiceNode' import VariableNode from '@/components/editor/nodes/VariableNode' -import type { FlowchartData, FlowchartNode, FlowchartEdge } from '@/types/flowchart' +import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal' +import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable } from '@/types/flowchart' type FlowchartEditorProps = { projectId: string @@ -76,6 +77,30 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { toReactFlowEdges(initialData.edges) ) + const [characters, setCharacters] = useState(initialData.characters) + const [variables, setVariables] = useState(initialData.variables) + const [showSettings, setShowSettings] = useState(false) + + const getCharacterUsageCount = useCallback( + (characterId: string) => { + return nodes.filter((n) => n.type === 'dialogue' && n.data?.characterId === characterId).length + }, + [nodes] + ) + + const getVariableUsageCount = useCallback( + (variableId: string) => { + const nodeCount = nodes.filter( + (n) => n.type === 'variable' && n.data?.variableId === variableId + ).length + const edgeCount = edges.filter( + (e) => e.data?.condition?.variableId === variableId + ).length + return nodeCount + edgeCount + }, + [nodes, edges] + ) + const onConnect = useCallback( (params: Connection) => { if (!params.source || !params.target) return @@ -177,6 +202,7 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { onSave={handleSave} onExport={handleExport} onImport={handleImport} + onProjectSettings={() => setShowSettings(true)} />
+ {showSettings && ( + setShowSettings(false)} + getCharacterUsageCount={getCharacterUsageCount} + getVariableUsageCount={getVariableUsageCount} + /> + )}
) } diff --git a/src/components/editor/ProjectSettingsModal.tsx b/src/components/editor/ProjectSettingsModal.tsx new file mode 100644 index 0000000..e8b5360 --- /dev/null +++ b/src/components/editor/ProjectSettingsModal.tsx @@ -0,0 +1,421 @@ +'use client' + +import { useState } from 'react' +import { nanoid } from 'nanoid' +import type { Character, Variable } from '@/types/flowchart' + +type Tab = 'characters' | 'variables' + +type ProjectSettingsModalProps = { + characters: Character[] + variables: Variable[] + onCharactersChange: (characters: Character[]) => void + onVariablesChange: (variables: Variable[]) => void + onClose: () => void + getCharacterUsageCount: (characterId: string) => number + getVariableUsageCount: (variableId: string) => number +} + +function randomHexColor(): string { + const colors = [ + '#ef4444', '#f97316', '#f59e0b', '#84cc16', '#22c55e', + '#14b8a6', '#06b6d4', '#3b82f6', '#6366f1', '#a855f7', + '#ec4899', '#f43f5e', + ] + return colors[Math.floor(Math.random() * colors.length)] +} + +export default function ProjectSettingsModal({ + characters, + variables, + onCharactersChange, + onVariablesChange, + onClose, + getCharacterUsageCount, + getVariableUsageCount, +}: ProjectSettingsModalProps) { + const [activeTab, setActiveTab] = useState('characters') + + return ( +
+ + ) +} + +// Characters Tab +type CharactersTabProps = { + characters: Character[] + onChange: (characters: Character[]) => void + getUsageCount: (characterId: string) => number +} + +type CharacterFormData = { + name: string + color: string + description: string +} + +function CharactersTab({ characters, onChange, getUsageCount }: CharactersTabProps) { + const [isAdding, setIsAdding] = useState(false) + const [editingId, setEditingId] = useState(null) + const [formData, setFormData] = useState({ name: '', color: randomHexColor(), description: '' }) + const [formError, setFormError] = useState(null) + const [deleteConfirm, setDeleteConfirm] = useState(null) + + const resetForm = () => { + setFormData({ name: '', color: randomHexColor(), description: '' }) + setFormError(null) + } + + const validateName = (name: string, excludeId?: string): boolean => { + if (!name.trim()) { + setFormError('Name is required') + return false + } + const duplicate = characters.find( + (c) => c.name.toLowerCase() === name.trim().toLowerCase() && c.id !== excludeId + ) + if (duplicate) { + setFormError('A character with this name already exists') + return false + } + setFormError(null) + return true + } + + const handleAdd = () => { + if (!validateName(formData.name)) return + const newCharacter: Character = { + id: nanoid(), + name: formData.name.trim(), + color: formData.color, + description: formData.description.trim() || undefined, + } + onChange([...characters, newCharacter]) + setIsAdding(false) + resetForm() + } + + const handleEdit = (character: Character) => { + setEditingId(character.id) + setFormData({ + name: character.name, + color: character.color, + description: character.description || '', + }) + setFormError(null) + setIsAdding(false) + } + + const handleSaveEdit = () => { + if (!editingId) return + if (!validateName(formData.name, editingId)) return + onChange( + characters.map((c) => + c.id === editingId + ? { ...c, name: formData.name.trim(), color: formData.color, description: formData.description.trim() || undefined } + : c + ) + ) + setEditingId(null) + resetForm() + } + + const handleDelete = (id: string) => { + const usageCount = getUsageCount(id) + if (usageCount > 0 && deleteConfirm !== id) { + setDeleteConfirm(id) + return + } + onChange(characters.filter((c) => c.id !== id)) + setDeleteConfirm(null) + } + + const handleCancelForm = () => { + setIsAdding(false) + setEditingId(null) + resetForm() + } + + return ( +
+
+

+ Define characters that can be referenced in dialogue nodes. +

+ {!isAdding && !editingId && ( + + )} +
+ + {/* Character List */} +
+ {characters.map((character) => ( +
+ {editingId === character.id ? ( + + ) : ( +
+
+
+
+ + {character.name} + + {character.description && ( +

+ {character.description} +

+ )} +
+
+
+ {deleteConfirm === character.id && ( + + Used in {getUsageCount(character.id)} node(s). Delete anyway? + + )} + + +
+
+ )} +
+ ))} + + {characters.length === 0 && !isAdding && ( +

+ No characters defined yet. Click "Add Character" to create one. +

+ )} +
+ + {/* Add Form */} + {isAdding && ( +
+ +
+ )} +
+ ) +} + +// Character Form +type CharacterFormProps = { + formData: CharacterFormData + formError: string | null + onChange: (data: CharacterFormData) => void + onSave: () => void + onCancel: () => void + saveLabel: string +} + +function CharacterForm({ formData, formError, onChange, onSave, onCancel, saveLabel }: CharacterFormProps) { + return ( +
+
+
+
+ + onChange({ ...formData, color: e.target.value })} + className="h-8 w-8 cursor-pointer rounded border border-zinc-300 dark:border-zinc-600" + /> +
+
+ + onChange({ ...formData, name: e.target.value })} + placeholder="Character name" + autoFocus + className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" + /> +
+
+
+ + onChange({ ...formData, description: e.target.value })} + placeholder="Optional description" + className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" + /> +
+ {formError && ( +

{formError}

+ )} +
+ + +
+
+
+ ) +} + +// Variables Tab (placeholder for US-057) +type VariablesTabProps = { + variables: Variable[] + onChange: (variables: Variable[]) => void + getUsageCount: (variableId: string) => number +} + +function VariablesTab({ variables }: VariablesTabProps) { + return ( +
+

+ Define variables that can be referenced in variable nodes and edge conditions. +

+ {variables.length === 0 ? ( +

+ No variables defined yet. Variable management will be available in a future update. +

+ ) : ( +
+ {variables.map((variable) => ( +
+
+ + {variable.type} + +
+ + {variable.name} + + {variable.description && ( +

+ {variable.description} +

+ )} +
+
+ + Initial: {String(variable.initialValue)} + +
+ ))} +
+ )} +
+ ) +} diff --git a/src/components/editor/Toolbar.tsx b/src/components/editor/Toolbar.tsx index 08f0243..81c2c45 100644 --- a/src/components/editor/Toolbar.tsx +++ b/src/components/editor/Toolbar.tsx @@ -7,6 +7,7 @@ type ToolbarProps = { onSave: () => void onExport: () => void onImport: () => void + onProjectSettings: () => void } export default function Toolbar({ @@ -16,6 +17,7 @@ export default function Toolbar({ onSave, onExport, onImport, + onProjectSettings, }: ToolbarProps) { return (
@@ -44,6 +46,12 @@ export default function Toolbar({
+ + )} +
+ + {/* Variable List */} +
+ {variables.map((variable) => ( +
+ {editingId === variable.id ? ( + + ) : ( +
+
+ + {variable.type} - {variable.description && ( -

- {variable.description} -

- )} +
+ + {variable.name} + + {variable.description && ( +

+ {variable.description} +

+ )} +
+
+
+ + Initial: {String(variable.initialValue)} + +
+ {deleteConfirm === variable.id && ( + + Used in {getUsageCount(variable.id)} node(s). Delete anyway? + + )} + + +
- - Initial: {String(variable.initialValue)} - -
- ))} + )} +
+ ))} + + {variables.length === 0 && !isAdding && ( +

+ No variables defined yet. Click "Add Variable" to create one. +

+ )} +
+ + {/* Add Form */} + {isAdding && ( +
+
)}
) } + +// Variable Form +type VariableFormProps = { + formData: VariableFormData + formError: string | null + onChange: (data: VariableFormData) => void + onSave: () => void + onCancel: () => void + saveLabel: string +} + +function VariableForm({ formData, formError, onChange, onSave, onCancel, saveLabel }: VariableFormProps) { + const handleTypeChange = (newType: VariableType) => { + onChange({ ...formData, type: newType, initialValue: defaultInitialValues[newType] }) + } + + return ( +
+
+
+
+ + onChange({ ...formData, name: e.target.value })} + placeholder="Variable name" + autoFocus + className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" + /> +
+
+ + +
+
+
+ + {formData.type === 'boolean' ? ( + + ) : formData.type === 'numeric' ? ( + onChange({ ...formData, initialValue: e.target.value })} + className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" + /> + ) : ( + onChange({ ...formData, initialValue: e.target.value })} + placeholder="Initial value" + className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" + /> + )} +
+
+ + onChange({ ...formData, description: e.target.value })} + placeholder="Optional description" + className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" + /> +
+ {formError && ( +

{formError}

+ )} +
+ + +
+
+
+ ) +} From 548f3743d19fcf027e5662cbdfde1b35718ddde9 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Fri, 23 Jan 2026 04:34:36 -0300 Subject: [PATCH 08/18] chore: mark US-057 as complete and update progress log Co-Authored-By: Claude Opus 4.5 --- prd.json | 2 +- progress.txt | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/prd.json b/prd.json index 4040563..61bc574 100644 --- a/prd.json +++ b/prd.json @@ -90,7 +90,7 @@ "Verify in browser using dev-browser skill" ], "priority": 5, - "passes": false, + "passes": true, "notes": "Dependencies: US-054, US-055" }, { diff --git a/progress.txt b/progress.txt index 1fd0342..3ae35fa 100644 --- a/progress.txt +++ b/progress.txt @@ -85,3 +85,15 @@ - The `randomHexColor()` utility picks from a curated list of 12 vibrant colors for character defaults. - No browser testing tools are available; manual verification is needed. --- + +## 2026-01-23 - US-057 +- What was implemented: Variable management UI with full CRUD in the project settings modal Variables tab +- Files changed: + - `src/components/editor/ProjectSettingsModal.tsx` - Replaced placeholder VariablesTab with full implementation: add/edit/delete with inline forms, type dropdown (numeric/string/boolean), type-adaptive initial value input (number input for numeric, text for string, select for boolean), name uniqueness validation, delete warnings with usage count, colored type badges +- **Learnings for future iterations:** + - The VariableForm uses a `handleTypeChange` helper that resets the initial value to the type's default when the type changes, preventing invalid state (e.g., "hello" as a numeric value) + - Initial values are stored as strings in form state and parsed to the correct type (number/string/boolean) on save via `parseInitialValue()` + - Type badges use distinct colors: blue for numeric, green for string, purple for boolean - making variable types instantly recognizable in the list + - The same form patterns from CharactersTab apply: inline form within the list for editing, appended form below the list for adding + - No browser testing tools are available; manual verification is needed. +--- From 5493adf44a735bfd51060c50b6255bc7aa9ee62c Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Fri, 23 Jan 2026 10:25:57 -0300 Subject: [PATCH 09/18] feat: [US-059] - Variable node variable dropdown Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 102 ++++++---- src/components/editor/EditorContext.tsx | 24 +++ src/components/editor/nodes/VariableNode.tsx | 178 +++++++++++++++--- 3 files changed, 237 insertions(+), 67 deletions(-) create mode 100644 src/components/editor/EditorContext.tsx diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index ee0c8f6..275304c 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -23,6 +23,7 @@ import DialogueNode from '@/components/editor/nodes/DialogueNode' import ChoiceNode from '@/components/editor/nodes/ChoiceNode' import VariableNode from '@/components/editor/nodes/VariableNode' import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal' +import { EditorProvider } from '@/components/editor/EditorContext' import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable } from '@/types/flowchart' type FlowchartEditorProps = { @@ -81,6 +82,31 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { const [variables, setVariables] = useState(initialData.variables) const [showSettings, setShowSettings] = useState(false) + const handleAddCharacter = useCallback( + (name: string, color: string): string => { + const id = nanoid() + const newCharacter: Character = { id, name, color } + setCharacters((prev) => [...prev, newCharacter]) + return id + }, + [] + ) + + const handleAddVariableDefinition = useCallback( + (name: string, type: 'numeric' | 'string' | 'boolean', initialValue: number | string | boolean): string => { + const id = nanoid() + const newVariable: Variable = { id, name, type, initialValue } + setVariables((prev) => [...prev, newVariable]) + return id + }, + [] + ) + + const editorContextValue = useMemo( + () => ({ characters, onAddCharacter: handleAddCharacter, variables, onAddVariable: handleAddVariableDefinition }), + [characters, handleAddCharacter, variables, handleAddVariableDefinition] + ) + const getCharacterUsageCount = useCallback( (characterId: string) => { return nodes.filter((n) => n.type === 'dialogue' && n.data?.characterId === characterId).length @@ -194,44 +220,46 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { }, []) return ( -
- setShowSettings(true)} - /> -
- - - - -
- {showSettings && ( - setShowSettings(false)} - getCharacterUsageCount={getCharacterUsageCount} - getVariableUsageCount={getVariableUsageCount} + +
+ setShowSettings(true)} /> - )} -
+
+ + + + +
+ {showSettings && ( + setShowSettings(false)} + getCharacterUsageCount={getCharacterUsageCount} + getVariableUsageCount={getVariableUsageCount} + /> + )} +
+ ) } diff --git a/src/components/editor/EditorContext.tsx b/src/components/editor/EditorContext.tsx new file mode 100644 index 0000000..39a921d --- /dev/null +++ b/src/components/editor/EditorContext.tsx @@ -0,0 +1,24 @@ +'use client' + +import { createContext, useContext } from 'react' +import type { Character, Variable } from '@/types/flowchart' + +type EditorContextValue = { + characters: Character[] + onAddCharacter: (name: string, color: string) => string // returns new character id + variables: Variable[] + onAddVariable: (name: string, type: 'numeric' | 'string' | 'boolean', initialValue: number | string | boolean) => string // returns new variable id +} + +const EditorContext = createContext({ + characters: [], + onAddCharacter: () => '', + variables: [], + onAddVariable: () => '', +}) + +export const EditorProvider = EditorContext.Provider + +export function useEditorContext() { + return useContext(EditorContext) +} diff --git a/src/components/editor/nodes/VariableNode.tsx b/src/components/editor/nodes/VariableNode.tsx index ee7ec4b..07fe50d 100644 --- a/src/components/editor/nodes/VariableNode.tsx +++ b/src/components/editor/nodes/VariableNode.tsx @@ -1,23 +1,52 @@ 'use client' -import { useCallback, ChangeEvent } from 'react' +import { useCallback, useMemo, useState, ChangeEvent } from 'react' import { Handle, Position, NodeProps, useReactFlow } from 'reactflow' +import Combobox from '@/components/editor/Combobox' +import type { ComboboxItem } from '@/components/editor/Combobox' +import { useEditorContext } from '@/components/editor/EditorContext' type VariableNodeData = { variableName: string + variableId?: string operation: 'set' | 'add' | 'subtract' value: number } export default function VariableNode({ id, data }: NodeProps) { const { setNodes } = useReactFlow() + const { variables, onAddVariable } = useEditorContext() - const updateVariableName = useCallback( - (e: ChangeEvent) => { + const [showAddForm, setShowAddForm] = useState(false) + const [newName, setNewName] = useState('') + const [newType, setNewType] = useState<'numeric' | 'string' | 'boolean'>('numeric') + + const variableItems: ComboboxItem[] = useMemo( + () => + variables.map((v) => ({ + id: v.id, + label: v.name, + badge: v.type, + })), + [variables] + ) + + const selectedVariable = useMemo(() => { + if (!data.variableId) return undefined + return variables.find((v) => v.id === data.variableId) + }, [data.variableId, variables]) + + const hasInvalidReference = useMemo(() => { + if (!data.variableId) return false + return !variables.some((v) => v.id === data.variableId) + }, [data.variableId, variables]) + + const updateNodeData = useCallback( + (updates: Partial) => { setNodes((nodes) => nodes.map((node) => node.id === id - ? { ...node, data: { ...node.data, variableName: e.target.value } } + ? { ...node, data: { ...node.data, ...updates } } : node ) ) @@ -25,35 +54,69 @@ export default function VariableNode({ id, data }: NodeProps) [id, setNodes] ) + const handleVariableSelect = useCallback( + (variableId: string) => { + const variable = variables.find((v) => v.id === variableId) + const updates: Partial = { + variableId, + variableName: variable?.name || '', + } + // Reset operation to 'set' if current operation is not valid for the variable's type + if (variable && variable.type !== 'numeric' && (data.operation === 'add' || data.operation === 'subtract')) { + updates.operation = 'set' + } + updateNodeData(updates) + }, + [variables, data.operation, updateNodeData] + ) + + const handleAddNew = useCallback(() => { + setShowAddForm(true) + setNewName('') + setNewType('numeric') + }, []) + + const handleSubmitNew = useCallback(() => { + if (!newName.trim()) return + const defaultValue = newType === 'numeric' ? 0 : newType === 'boolean' ? false : '' + const newId = onAddVariable(newName.trim(), newType, defaultValue) + updateNodeData({ + variableId: newId, + variableName: newName.trim(), + }) + setShowAddForm(false) + }, [newName, newType, onAddVariable, updateNodeData]) + + const handleCancelNew = useCallback(() => { + setShowAddForm(false) + }, []) + const updateOperation = useCallback( (e: ChangeEvent) => { - setNodes((nodes) => - nodes.map((node) => - node.id === id - ? { ...node, data: { ...node.data, operation: e.target.value as 'set' | 'add' | 'subtract' } } - : node - ) - ) + updateNodeData({ operation: e.target.value as 'set' | 'add' | 'subtract' }) }, - [id, setNodes] + [updateNodeData] ) const updateValue = useCallback( (e: ChangeEvent) => { const value = parseFloat(e.target.value) || 0 - setNodes((nodes) => - nodes.map((node) => - node.id === id - ? { ...node, data: { ...node.data, value } } - : node - ) - ) + updateNodeData({ value }) }, - [id, setNodes] + [updateNodeData] ) + // Filter operations based on selected variable type + const isNumeric = !selectedVariable || selectedVariable.type === 'numeric' + return ( -
+
) Variable
- +
+ + {hasInvalidReference && ( +
+ Variable not found +
+ )} +
+ + {showAddForm && ( +
+
+ New variable +
+
+ setNewName(e.target.value)} + placeholder="Name" + className="w-full rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-orange-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400" + onKeyDown={(e) => { + if (e.key === 'Enter') handleSubmitNew() + if (e.key === 'Escape') handleCancelNew() + }} + autoFocus + /> +
+
+ +
+
+ + +
+
+ )}
Date: Fri, 23 Jan 2026 10:26:42 -0300 Subject: [PATCH 10/18] chore: mark US-059 as complete and update progress log Co-Authored-By: Claude Opus 4.5 --- prd.json | 4 ++-- progress.txt | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/prd.json b/prd.json index 61bc574..cf98949 100644 --- a/prd.json +++ b/prd.json @@ -109,7 +109,7 @@ "Verify in browser using dev-browser skill" ], "priority": 6, - "passes": false, + "passes": true, "notes": "Dependencies: US-056, US-065" }, { @@ -127,7 +127,7 @@ "Verify in browser using dev-browser skill" ], "priority": 7, - "passes": false, + "passes": true, "notes": "Dependencies: US-057, US-065" }, { diff --git a/progress.txt b/progress.txt index 3ae35fa..64b07d5 100644 --- a/progress.txt +++ b/progress.txt @@ -28,11 +28,15 @@ - Toast component supports an optional `action` prop: `{ label: string; onClick: () => void }` for retry/undo buttons - Settings page at `/dashboard/settings` reuses dashboard layout; re-auth via signInWithPassword before updateUser - Character/Variable types (`Character`, `Variable`) and extracted node data types (`DialogueNodeData`, `VariableNodeData`) are in `src/types/flowchart.ts` +- `EditorContext` at `src/components/editor/EditorContext.tsx` provides shared state (characters, onAddCharacter) to all custom node components via React context +- Use `useEditorContext()` in node components to access project-level characters and variables without prop drilling through React Flow node data - New JSONB fields (characters, variables) must be defaulted to `[]` when reading from DB in page.tsx to handle pre-existing data - Reusable `Combobox` component at `src/components/editor/Combobox.tsx` - use for all character/variable dropdowns. Props: items (ComboboxItem[]), value, onChange, placeholder, onAddNew - `ProjectSettingsModal` at `src/components/editor/ProjectSettingsModal.tsx` manages characters/variables. Receives state + callbacks from FlowchartEditor - Characters and variables state is managed in `FlowchartEditorInner` with `useState` hooks, passed down to the modal - For settings-style modals, use `max-w-2xl h-[80vh]` with overflow-y-auto content area and fixed header/tabs +- `EditorContext` provides both characters (onAddCharacter) and variables (onAddVariable) to node components. Use `useEditorContext()` to access them. +- In FlowchartEditor, `handleAddVariable` adds a variable *node* to the canvas; `handleAddVariableDefinition` creates a variable *definition* in project data. Avoid naming collisions between "add node" and "add definition" callbacks. --- @@ -97,3 +101,17 @@ - The same form patterns from CharactersTab apply: inline form within the list for editing, appended form below the list for adding - No browser testing tools are available; manual verification is needed. --- + +## 2026-01-23 - US-059 +- What was implemented: Variable node variable dropdown using Combobox component, replacing the free-text input +- Files changed: + - `src/components/editor/nodes/VariableNode.tsx` - Replaced text input with Combobox for variable selection, added inline "Add new variable" form with name + type, added orange warning border for invalid references, filtered operation options (add/subtract only for numeric type) + - `src/components/editor/EditorContext.tsx` - Extended context to include `variables: Variable[]` and `onAddVariable` callback + - `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `handleAddVariableDefinition` callback and passed variables + onAddVariable through EditorContext +- **Learnings for future iterations:** + - The existing `handleAddVariable` in FlowchartEditor adds a variable *node* to the canvas (toolbar action). The new `handleAddVariableDefinition` creates a variable *definition* in the project's data. Name carefully to avoid collisions. + - EditorContext is the shared context for node components to access project-level characters and variables. Extend it when new entity types need to be accessible from custom node components. + - The VariableNode follows the same pattern as DialogueNode for Combobox integration: items derived via useMemo, handleSelect sets both variableId and variableName, inline add form for quick creation, hasInvalidReference for warning state. + - Operations filtering uses `isNumeric` flag: if no variable is selected (undefined) or type is 'numeric', all operations are shown; otherwise only 'set' is available. When selecting a non-numeric variable, operation is auto-reset to 'set' if it was 'add' or 'subtract'. + - No browser testing tools are available; manual verification is needed. +--- From b4b9f8cec926d3bff7371a0812285ff1c327c264 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Fri, 23 Jan 2026 10:30:42 -0300 Subject: [PATCH 11/18] feat: [US-060] - Edge condition variable dropdown Replace the variableName text input in edge conditions with a Combobox-based variable selector. Adds ConditionEditor modal that opens on edge click, with type-aware operators (comparison for numeric, == and != for string/boolean) and type-adaptive value inputs (number, text, or boolean toggle). Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 40 ++- src/components/editor/ConditionEditor.tsx | 322 ++++++++++++++++++ src/types/flowchart.ts | 2 +- 3 files changed, 361 insertions(+), 3 deletions(-) create mode 100644 src/components/editor/ConditionEditor.tsx diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 275304c..b1e4c8b 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import React, { useCallback, useMemo, useState } from 'react' import ReactFlow, { Background, BackgroundVariant, @@ -23,8 +23,9 @@ import DialogueNode from '@/components/editor/nodes/DialogueNode' import ChoiceNode from '@/components/editor/nodes/ChoiceNode' import VariableNode from '@/components/editor/nodes/VariableNode' import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal' +import ConditionEditor from '@/components/editor/ConditionEditor' import { EditorProvider } from '@/components/editor/EditorContext' -import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable } from '@/types/flowchart' +import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart' type FlowchartEditorProps = { projectId: string @@ -81,6 +82,7 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { const [characters, setCharacters] = useState(initialData.characters) const [variables, setVariables] = useState(initialData.variables) const [showSettings, setShowSettings] = useState(false) + const [selectedEdgeId, setSelectedEdgeId] = useState(null) const handleAddCharacter = useCallback( (name: string, color: string): string => { @@ -219,6 +221,31 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { console.log('Deleted edges:', deletedEdges.map((e) => e.id)) }, []) + // Handle edge click to open condition editor + const onEdgeClick = useCallback((_event: React.MouseEvent, edge: Edge) => { + setSelectedEdgeId(edge.id) + }, []) + + // Handle condition change from ConditionEditor + const handleConditionChange = useCallback( + (edgeId: string, condition: Condition | undefined) => { + setEdges((eds) => + eds.map((edge) => + edge.id === edgeId + ? { ...edge, data: condition ? { condition } : undefined } + : edge + ) + ) + }, + [setEdges] + ) + + // Get the selected edge's condition data + const selectedEdge = useMemo( + () => (selectedEdgeId ? edges.find((e) => e.id === selectedEdgeId) : null), + [selectedEdgeId, edges] + ) + return (
@@ -239,6 +266,7 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onEdgesDelete={onEdgesDelete} + onEdgeClick={onEdgeClick} onConnect={onConnect} deleteKeyCode={['Delete', 'Backspace']} fitView @@ -258,6 +286,14 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { getVariableUsageCount={getVariableUsageCount} /> )} + {selectedEdge && ( + setSelectedEdgeId(null)} + /> + )}
) diff --git a/src/components/editor/ConditionEditor.tsx b/src/components/editor/ConditionEditor.tsx new file mode 100644 index 0000000..b7259f2 --- /dev/null +++ b/src/components/editor/ConditionEditor.tsx @@ -0,0 +1,322 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import Combobox from '@/components/editor/Combobox' +import type { ComboboxItem } from '@/components/editor/Combobox' +import { useEditorContext } from '@/components/editor/EditorContext' +import type { Condition } from '@/types/flowchart' + +type ConditionEditorProps = { + edgeId: string + condition: Condition | undefined + onChange: (edgeId: string, condition: Condition | undefined) => void + onClose: () => void +} + +export default function ConditionEditor({ + edgeId, + condition, + onChange, + onClose, +}: ConditionEditorProps) { + const { variables, onAddVariable } = useEditorContext() + + const [showAddForm, setShowAddForm] = useState(false) + const [newName, setNewName] = useState('') + const [newType, setNewType] = useState<'numeric' | 'string' | 'boolean'>('numeric') + + const variableItems: ComboboxItem[] = useMemo( + () => + variables.map((v) => ({ + id: v.id, + label: v.name, + badge: v.type, + })), + [variables] + ) + + const selectedVariable = useMemo(() => { + if (!condition?.variableId) return undefined + return variables.find((v) => v.id === condition.variableId) + }, [condition?.variableId, variables]) + + const hasInvalidReference = useMemo(() => { + if (!condition?.variableId) return false + return !variables.some((v) => v.id === condition.variableId) + }, [condition?.variableId, variables]) + + // Determine operators based on variable type + const availableOperators = useMemo(() => { + if (!selectedVariable || selectedVariable.type === 'numeric') { + return [ + { value: '==', label: '==' }, + { value: '!=', label: '!=' }, + { value: '>', label: '>' }, + { value: '<', label: '<' }, + { value: '>=', label: '>=' }, + { value: '<=', label: '<=' }, + ] as const + } + // string and boolean only support == and != + return [ + { value: '==', label: '==' }, + { value: '!=', label: '!=' }, + ] as const + }, [selectedVariable]) + + const handleVariableSelect = useCallback( + (variableId: string) => { + const variable = variables.find((v) => v.id === variableId) + const defaultValue = variable + ? variable.type === 'numeric' + ? 0 + : variable.type === 'boolean' + ? false + : '' + : 0 + // Reset operator if current one is not valid for new type + const validOperator = + variable && variable.type !== 'numeric' && condition?.operator && !['==', '!='].includes(condition.operator) + ? '==' + : condition?.operator || '==' + + onChange(edgeId, { + variableName: variable?.name || '', + variableId, + operator: validOperator as Condition['operator'], + value: defaultValue, + }) + }, + [variables, condition?.operator, edgeId, onChange] + ) + + const handleOperatorChange = useCallback( + (operator: string) => { + if (!condition) return + onChange(edgeId, { + ...condition, + operator: operator as Condition['operator'], + }) + }, + [condition, edgeId, onChange] + ) + + const handleValueChange = useCallback( + (value: number | string | boolean) => { + if (!condition) return + onChange(edgeId, { + ...condition, + value, + }) + }, + [condition, edgeId, onChange] + ) + + const handleRemoveCondition = useCallback(() => { + onChange(edgeId, undefined) + onClose() + }, [edgeId, onChange, onClose]) + + const handleAddNew = useCallback(() => { + setShowAddForm(true) + setNewName('') + setNewType('numeric') + }, []) + + const handleSubmitNew = useCallback(() => { + if (!newName.trim()) return + const defaultValue = newType === 'numeric' ? 0 : newType === 'boolean' ? false : '' + const newId = onAddVariable(newName.trim(), newType, defaultValue) + onChange(edgeId, { + variableName: newName.trim(), + variableId: newId, + operator: '==', + value: defaultValue, + }) + setShowAddForm(false) + }, [newName, newType, onAddVariable, edgeId, onChange]) + + const handleCancelNew = useCallback(() => { + setShowAddForm(false) + }, []) + + // Render value input based on variable type + const renderValueInput = () => { + const varType = selectedVariable?.type || 'numeric' + + if (varType === 'boolean') { + return ( + + ) + } + + if (varType === 'string') { + return ( + handleValueChange(e.target.value)} + placeholder="Value..." + className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400" + /> + ) + } + + // numeric + return ( + handleValueChange(parseFloat(e.target.value) || 0)} + className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white" + /> + ) + } + + return ( +
+
+
+
+

+ Edge Condition +

+ +
+ + {/* Variable selector */} +
+ +
+ +
+ {hasInvalidReference && ( +
+ Variable not found +
+ )} +
+ + {/* Inline add form */} + {showAddForm && ( +
+
+ New variable +
+
+ setNewName(e.target.value)} + placeholder="Name" + className="w-full rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400" + onKeyDown={(e) => { + if (e.key === 'Enter') handleSubmitNew() + if (e.key === 'Escape') handleCancelNew() + }} + autoFocus + /> +
+
+ +
+
+ + +
+
+ )} + + {/* Operator and value (shown when variable is selected) */} + {condition?.variableId && ( + <> +
+ + +
+ +
+ + {renderValueInput()} +
+ + )} + + {/* Actions */} +
+ {condition?.variableId ? ( + + ) : ( +
+ )} + +
+
+
+ ) +} diff --git a/src/types/flowchart.ts b/src/types/flowchart.ts index 94ed51d..0379054 100644 --- a/src/types/flowchart.ts +++ b/src/types/flowchart.ts @@ -75,7 +75,7 @@ export type Condition = { variableName: string; variableId?: string; operator: '>' | '<' | '==' | '>=' | '<=' | '!='; - value: number; + value: number | string | boolean; }; // FlowchartEdge type: represents connections between nodes From b6cb0c703a912ec3963797489abb491ec7dfae30 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Fri, 23 Jan 2026 10:31:33 -0300 Subject: [PATCH 12/18] chore: mark US-060 as complete and update progress log Co-Authored-By: Claude Opus 4.5 --- prd.json | 2 +- progress.txt | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/prd.json b/prd.json index cf98949..0f828f8 100644 --- a/prd.json +++ b/prd.json @@ -146,7 +146,7 @@ "Verify in browser using dev-browser skill" ], "priority": 8, - "passes": false, + "passes": true, "notes": "Dependencies: US-057, US-065" }, { diff --git a/progress.txt b/progress.txt index 64b07d5..d3a35ea 100644 --- a/progress.txt +++ b/progress.txt @@ -37,6 +37,8 @@ - For settings-style modals, use `max-w-2xl h-[80vh]` with overflow-y-auto content area and fixed header/tabs - `EditorContext` provides both characters (onAddCharacter) and variables (onAddVariable) to node components. Use `useEditorContext()` to access them. - In FlowchartEditor, `handleAddVariable` adds a variable *node* to the canvas; `handleAddVariableDefinition` creates a variable *definition* in project data. Avoid naming collisions between "add node" and "add definition" callbacks. +- Edge interactions use `onEdgeClick` on ReactFlow component. ConditionEditor opens as a modal overlay since React Flow edges don't support inline panels. +- `Condition.value` supports `number | string | boolean` — always check variable type before rendering value inputs for edge conditions. --- @@ -115,3 +117,19 @@ - Operations filtering uses `isNumeric` flag: if no variable is selected (undefined) or type is 'numeric', all operations are shown; otherwise only 'set' is available. When selecting a non-numeric variable, operation is auto-reset to 'set' if it was 'add' or 'subtract'. - No browser testing tools are available; manual verification is needed. --- + +## 2026-01-23 - US-060 +- What was implemented: Edge condition variable dropdown using Combobox component, replacing free-text input with a type-aware condition editor modal +- Files changed: + - `src/types/flowchart.ts` - Updated `Condition.value` type from `number` to `number | string | boolean` to support all variable types + - `src/components/editor/ConditionEditor.tsx` - New component: modal-based condition editor with Combobox for variable selection, type-aware operator filtering, type-adaptive value inputs, inline "Add new variable" form, orange warning for invalid references, and "Remove condition" action + - `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `onEdgeClick` handler to open ConditionEditor, `handleConditionChange` to update edge condition data, `selectedEdgeId` state, and ConditionEditor rendering +- **Learnings for future iterations:** + - Edge interactions in React Flow use `onEdgeClick` prop on the ReactFlow component (not on individual edges). The handler receives `(event: React.MouseEvent, edge: Edge)`. + - The ConditionEditor is rendered as a modal overlay (fixed z-50), not as part of the edge itself — since edges don't have built-in panel/popover support in React Flow. + - `Condition.value` was originally typed as just `number` but needed broadening to `number | string | boolean` to support string/boolean variables in conditions. This change didn't break existing code since the VariableNode's `value` field is a separate type. + - Operator filtering for non-numeric types: only `==` and `!=` are available for string/boolean variables. When switching from a numeric variable to a string/boolean, the operator auto-resets to `==` if it was a comparison operator. + - Value input adapts to type: number input for numeric, text input for string, boolean dropdown for boolean. + - The `selectedEdge` is derived via `useMemo` from `edges` state and `selectedEdgeId`, so it always reflects the latest condition data. + - No browser testing tools are available; manual verification is needed. +--- From 92d892fb73a4d3d000f9f7a00d674f1fae9589a1 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Fri, 23 Jan 2026 10:36:16 -0300 Subject: [PATCH 13/18] feat: [US-061] - Choice option condition variable dropdown Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 10 +- .../editor/OptionConditionEditor.tsx | 315 ++++++++++++++++++ src/components/editor/nodes/ChoiceNode.tsx | 197 +++++++---- src/types/flowchart.ts | 17 +- 4 files changed, 470 insertions(+), 69 deletions(-) create mode 100644 src/components/editor/OptionConditionEditor.tsx diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index b1e4c8b..9edf400 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -124,7 +124,15 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { const edgeCount = edges.filter( (e) => e.data?.condition?.variableId === variableId ).length - return nodeCount + edgeCount + const choiceOptionCount = nodes.filter( + (n) => n.type === 'choice' + ).reduce((count, n) => { + const options = n.data?.options || [] + return count + options.filter( + (opt: { condition?: { variableId?: string } }) => opt.condition?.variableId === variableId + ).length + }, 0) + return nodeCount + edgeCount + choiceOptionCount }, [nodes, edges] ) diff --git a/src/components/editor/OptionConditionEditor.tsx b/src/components/editor/OptionConditionEditor.tsx new file mode 100644 index 0000000..b96b5c3 --- /dev/null +++ b/src/components/editor/OptionConditionEditor.tsx @@ -0,0 +1,315 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import Combobox from '@/components/editor/Combobox' +import type { ComboboxItem } from '@/components/editor/Combobox' +import { useEditorContext } from '@/components/editor/EditorContext' +import type { Condition } from '@/types/flowchart' + +type OptionConditionEditorProps = { + condition: Condition | undefined + onChange: (condition: Condition | undefined) => void + onClose: () => void +} + +export default function OptionConditionEditor({ + condition, + onChange, + onClose, +}: OptionConditionEditorProps) { + const { variables, onAddVariable } = useEditorContext() + + const [showAddForm, setShowAddForm] = useState(false) + const [newName, setNewName] = useState('') + const [newType, setNewType] = useState<'numeric' | 'string' | 'boolean'>('numeric') + + const variableItems: ComboboxItem[] = useMemo( + () => + variables.map((v) => ({ + id: v.id, + label: v.name, + badge: v.type, + })), + [variables] + ) + + const selectedVariable = useMemo(() => { + if (!condition?.variableId) return undefined + return variables.find((v) => v.id === condition.variableId) + }, [condition?.variableId, variables]) + + const hasInvalidReference = useMemo(() => { + if (!condition?.variableId) return false + return !variables.some((v) => v.id === condition.variableId) + }, [condition?.variableId, variables]) + + const availableOperators = useMemo(() => { + if (!selectedVariable || selectedVariable.type === 'numeric') { + return [ + { value: '==', label: '==' }, + { value: '!=', label: '!=' }, + { value: '>', label: '>' }, + { value: '<', label: '<' }, + { value: '>=', label: '>=' }, + { value: '<=', label: '<=' }, + ] as const + } + return [ + { value: '==', label: '==' }, + { value: '!=', label: '!=' }, + ] as const + }, [selectedVariable]) + + const handleVariableSelect = useCallback( + (variableId: string) => { + const variable = variables.find((v) => v.id === variableId) + const defaultValue = variable + ? variable.type === 'numeric' + ? 0 + : variable.type === 'boolean' + ? false + : '' + : 0 + const validOperator = + variable && variable.type !== 'numeric' && condition?.operator && !['==', '!='].includes(condition.operator) + ? '==' + : condition?.operator || '==' + + onChange({ + variableName: variable?.name || '', + variableId, + operator: validOperator as Condition['operator'], + value: defaultValue, + }) + }, + [variables, condition?.operator, onChange] + ) + + const handleOperatorChange = useCallback( + (operator: string) => { + if (!condition) return + onChange({ + ...condition, + operator: operator as Condition['operator'], + }) + }, + [condition, onChange] + ) + + const handleValueChange = useCallback( + (value: number | string | boolean) => { + if (!condition) return + onChange({ + ...condition, + value, + }) + }, + [condition, onChange] + ) + + const handleRemoveCondition = useCallback(() => { + onChange(undefined) + onClose() + }, [onChange, onClose]) + + const handleAddNew = useCallback(() => { + setShowAddForm(true) + setNewName('') + setNewType('numeric') + }, []) + + const handleSubmitNew = useCallback(() => { + if (!newName.trim()) return + const defaultValue = newType === 'numeric' ? 0 : newType === 'boolean' ? false : '' + const newId = onAddVariable(newName.trim(), newType, defaultValue) + onChange({ + variableName: newName.trim(), + variableId: newId, + operator: '==', + value: defaultValue, + }) + setShowAddForm(false) + }, [newName, newType, onAddVariable, onChange]) + + const handleCancelNew = useCallback(() => { + setShowAddForm(false) + }, []) + + const renderValueInput = () => { + const varType = selectedVariable?.type || 'numeric' + + if (varType === 'boolean') { + return ( + + ) + } + + if (varType === 'string') { + return ( + handleValueChange(e.target.value)} + placeholder="Value..." + className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400" + /> + ) + } + + return ( + handleValueChange(parseFloat(e.target.value) || 0)} + className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white" + /> + ) + } + + return ( +
+
+
+
+

+ Option Condition +

+ +
+ + {/* Variable selector */} +
+ +
+ +
+ {hasInvalidReference && ( +
+ Variable not found +
+ )} +
+ + {/* Inline add form */} + {showAddForm && ( +
+
+ New variable +
+
+ setNewName(e.target.value)} + placeholder="Name" + className="w-full rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400" + onKeyDown={(e) => { + if (e.key === 'Enter') handleSubmitNew() + if (e.key === 'Escape') handleCancelNew() + }} + autoFocus + /> +
+
+ +
+
+ + +
+
+ )} + + {/* Operator and value (shown when variable is selected) */} + {condition?.variableId && ( + <> +
+ + +
+ +
+ + {renderValueInput()} +
+ + )} + + {/* Actions */} +
+ {condition?.variableId ? ( + + ) : ( +
+ )} + +
+
+
+ ) +} diff --git a/src/components/editor/nodes/ChoiceNode.tsx b/src/components/editor/nodes/ChoiceNode.tsx index 7073d2d..1eb9e5a 100644 --- a/src/components/editor/nodes/ChoiceNode.tsx +++ b/src/components/editor/nodes/ChoiceNode.tsx @@ -1,12 +1,16 @@ 'use client' -import { useCallback, ChangeEvent } from 'react' +import { useCallback, useMemo, useState, ChangeEvent } from 'react' import { Handle, Position, NodeProps, useReactFlow } from 'reactflow' import { nanoid } from 'nanoid' +import { useEditorContext } from '@/components/editor/EditorContext' +import OptionConditionEditor from '@/components/editor/OptionConditionEditor' +import type { Condition } from '@/types/flowchart' type ChoiceOption = { id: string label: string + condition?: Condition } type ChoiceNodeData = { @@ -19,6 +23,8 @@ const MAX_OPTIONS = 6 export default function ChoiceNode({ id, data }: NodeProps) { const { setNodes } = useReactFlow() + const { variables } = useEditorContext() + const [editingConditionOptionId, setEditingConditionOptionId] = useState(null) const updatePrompt = useCallback( (e: ChangeEvent) => { @@ -54,6 +60,27 @@ export default function ChoiceNode({ id, data }: NodeProps) { [id, setNodes] ) + const updateOptionCondition = useCallback( + (optionId: string, condition: Condition | undefined) => { + setNodes((nodes) => + nodes.map((node) => + node.id === id + ? { + ...node, + data: { + ...node.data, + options: node.data.options.map((opt: ChoiceOption) => + opt.id === optionId ? { ...opt, condition } : opt + ), + }, + } + : node + ) + ) + }, + [id, setNodes] + ) + const addOption = useCallback(() => { if (data.options.length >= MAX_OPTIONS) return setNodes((nodes) => @@ -96,68 +123,118 @@ export default function ChoiceNode({ id, data }: NodeProps) { [id, data.options.length, setNodes] ) + const editingOption = useMemo(() => { + if (!editingConditionOptionId) return null + return data.options.find((opt) => opt.id === editingConditionOptionId) || null + }, [editingConditionOptionId, data.options]) + + const hasInvalidConditionReference = useCallback( + (option: ChoiceOption) => { + if (!option.condition?.variableId) return false + return !variables.some((v) => v.id === option.condition!.variableId) + }, + [variables] + ) + return ( -
- + <> +
+ -
- Choice +
+ Choice +
+ + + +
+ {data.options.map((option, index) => ( +
+
+ updateOptionLabel(option.id, e.target.value)} + placeholder={`Option ${index + 1}`} + className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400" + /> + + + +
+ {option.condition?.variableId && ( +
+ if {option.condition.variableName} {option.condition.operator} {String(option.condition.value)} +
+ )} +
+ ))} +
+ +
- - -
- {data.options.map((option, index) => ( -
- updateOptionLabel(option.id, e.target.value)} - placeholder={`Option ${index + 1}`} - className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400" - /> - - -
- ))} -
- - -
+ {editingOption && ( + updateOptionCondition(editingOption.id, condition)} + onClose={() => setEditingConditionOptionId(null)} + /> + )} + ) } diff --git a/src/types/flowchart.ts b/src/types/flowchart.ts index 0379054..4f4461c 100644 --- a/src/types/flowchart.ts +++ b/src/types/flowchart.ts @@ -21,6 +21,14 @@ export type Variable = { description?: string; }; +// Condition type for conditional edges and choice options +export type Condition = { + variableName: string; + variableId?: string; + operator: '>' | '<' | '==' | '>=' | '<=' | '!='; + value: number | string | boolean; +}; + // DialogueNode type: represents character speech/dialogue export type DialogueNodeData = { speaker?: string; @@ -39,6 +47,7 @@ export type DialogueNode = { export type ChoiceOption = { id: string; label: string; + condition?: Condition; }; // ChoiceNode type: represents branching decisions @@ -70,14 +79,6 @@ export type VariableNode = { // Union type for all node types export type FlowchartNode = DialogueNode | ChoiceNode | VariableNode; -// Condition type for conditional edges -export type Condition = { - variableName: string; - variableId?: string; - operator: '>' | '<' | '==' | '>=' | '<=' | '!='; - value: number | string | boolean; -}; - // FlowchartEdge type: represents connections between nodes export type FlowchartEdge = { id: string; From 68bfe8884250f33be87adf45e334a16fc193e9c0 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Fri, 23 Jan 2026 10:37:06 -0300 Subject: [PATCH 14/18] chore: mark US-061 as complete and update progress log Co-Authored-By: Claude Opus 4.5 --- prd.json | 2 +- progress.txt | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/prd.json b/prd.json index 0f828f8..d12839d 100644 --- a/prd.json +++ b/prd.json @@ -164,7 +164,7 @@ "Verify in browser using dev-browser skill" ], "priority": 9, - "passes": false, + "passes": true, "notes": "Dependencies: US-057, US-065" }, { diff --git a/progress.txt b/progress.txt index d3a35ea..dea5ea5 100644 --- a/progress.txt +++ b/progress.txt @@ -39,6 +39,8 @@ - In FlowchartEditor, `handleAddVariable` adds a variable *node* to the canvas; `handleAddVariableDefinition` creates a variable *definition* in project data. Avoid naming collisions between "add node" and "add definition" callbacks. - Edge interactions use `onEdgeClick` on ReactFlow component. ConditionEditor opens as a modal overlay since React Flow edges don't support inline panels. - `Condition.value` supports `number | string | boolean` — always check variable type before rendering value inputs for edge conditions. +- `OptionConditionEditor` at `src/components/editor/OptionConditionEditor.tsx` handles choice option conditions. Same pattern as `ConditionEditor` but with simpler props (no edgeId). +- `ChoiceOption` type includes optional `condition?: Condition`. When counting variable usage, check variable nodes + edge conditions + choice option conditions. --- @@ -133,3 +135,19 @@ - The `selectedEdge` is derived via `useMemo` from `edges` state and `selectedEdgeId`, so it always reflects the latest condition data. - No browser testing tools are available; manual verification is needed. --- + +## 2026-01-23 - US-061 +- What was implemented: Choice option condition variable dropdown using OptionConditionEditor component with Combobox +- Files changed: + - `src/types/flowchart.ts` - Added `condition?: Condition` to `ChoiceOption` type; moved `Condition` type definition before `ChoiceOption` for correct reference order + - `src/components/editor/OptionConditionEditor.tsx` - New component: modal-based condition editor for choice options with Combobox variable selection, type-aware operators, type-adaptive value inputs, inline "Add new variable" form, orange warning for invalid references + - `src/components/editor/nodes/ChoiceNode.tsx` - Added condition button per option (clipboard icon), condition summary text below options, OptionConditionEditor integration, EditorContext usage for variables, invalid reference detection with orange warning styling + - `src/app/editor/[projectId]/FlowchartEditor.tsx` - Extended `getVariableUsageCount` to also count variable references in choice option conditions +- **Learnings for future iterations:** + - The `OptionConditionEditor` follows the same pattern as `ConditionEditor` but with a simpler API: it doesn't need an edgeId since it works with a single option's condition via `onChange(condition | undefined)` callback + - The `ChoiceOption` type in `flowchart.ts` now references `Condition`, which required reordering type definitions (Condition must be defined before ChoiceOption) + - Each choice option shows a small clipboard icon button that turns blue when a condition is set, or orange when the referenced variable is invalid/deleted + - A condition summary line (e.g., "if score > 10") appears below each option label when a condition is active + - The `getVariableUsageCount` in FlowchartEditor now counts three sources: variable nodes, edge conditions, and choice option conditions + - No browser testing tools are available; manual verification is needed. +--- From b570dca1b8f67143f222423d62c5b940194bb287 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Fri, 23 Jan 2026 10:42:40 -0300 Subject: [PATCH 15/18] feat: [US-062] - Auto-migration of existing free-text values Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 163 +++++++++++++++++- src/app/editor/[projectId]/page.tsx | 5 + src/components/editor/nodes/DialogueNode.tsx | 146 ++++++++++++++-- 3 files changed, 293 insertions(+), 21 deletions(-) diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 9edf400..284a6ed 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -25,11 +25,13 @@ import VariableNode from '@/components/editor/nodes/VariableNode' import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal' import ConditionEditor from '@/components/editor/ConditionEditor' import { EditorProvider } from '@/components/editor/EditorContext' +import Toast from '@/components/Toast' import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart' type FlowchartEditorProps = { projectId: string initialData: FlowchartData + needsMigration?: boolean } // Convert our FlowchartNode type to React Flow Node type @@ -58,8 +60,148 @@ function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] { })) } +const RANDOM_COLORS = [ + '#EF4444', '#F97316', '#F59E0B', '#10B981', + '#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6', + '#6366F1', '#F43F5E', '#84CC16', '#06B6D4', +] + +function randomHexColor(): string { + return RANDOM_COLORS[Math.floor(Math.random() * RANDOM_COLORS.length)] +} + +// Compute auto-migration of existing free-text values to character/variable definitions +function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) { + if (!shouldMigrate) { + return { + characters: initialData.characters, + variables: initialData.variables, + nodes: initialData.nodes, + edges: initialData.edges, + toastMessage: null as string | null, + } + } + + // Collect unique speaker names from dialogue nodes + const speakerNames = new Set() + initialData.nodes.forEach((node) => { + if (node.type === 'dialogue' && node.data?.speaker) { + speakerNames.add(node.data.speaker) + } + }) + + // Create character definitions from unique speaker names + const newCharacters: Character[] = [] + const speakerToCharacterId = new Map() + speakerNames.forEach((name) => { + const id = nanoid() + newCharacters.push({ id, name, color: randomHexColor() }) + speakerToCharacterId.set(name, id) + }) + + // Collect unique variable names from variable nodes, edge conditions, and choice option conditions + const variableNames = new Set() + initialData.nodes.forEach((node) => { + if (node.type === 'variable' && node.data.variableName) { + variableNames.add(node.data.variableName) + } + if (node.type === 'choice' && node.data.options) { + node.data.options.forEach((opt) => { + if (opt.condition?.variableName) { + variableNames.add(opt.condition.variableName) + } + }) + } + }) + initialData.edges.forEach((edge) => { + if (edge.data?.condition?.variableName) { + variableNames.add(edge.data.condition.variableName) + } + }) + + // Create variable definitions from unique variable names + const newVariables: Variable[] = [] + const varNameToId = new Map() + variableNames.forEach((name) => { + const id = nanoid() + newVariables.push({ id, name, type: 'numeric', initialValue: 0 }) + varNameToId.set(name, id) + }) + + // If nothing to migrate, return original data + if (newCharacters.length === 0 && newVariables.length === 0) { + return { + characters: initialData.characters, + variables: initialData.variables, + nodes: initialData.nodes, + edges: initialData.edges, + toastMessage: null as string | null, + } + } + + // Update nodes with characterId/variableId references + const migratedNodes = initialData.nodes.map((node) => { + if (node.type === 'dialogue' && node.data.speaker) { + const characterId = speakerToCharacterId.get(node.data.speaker) + if (characterId) { + return { ...node, data: { ...node.data, characterId } } + } + } + if (node.type === 'variable' && node.data.variableName) { + const variableId = varNameToId.get(node.data.variableName) + if (variableId) { + return { ...node, data: { ...node.data, variableId } } + } + } + if (node.type === 'choice' && node.data.options) { + const updatedOptions = node.data.options.map((opt) => { + if (opt.condition?.variableName) { + const variableId = varNameToId.get(opt.condition.variableName) + if (variableId) { + return { ...opt, condition: { ...opt.condition, variableId } } + } + } + return opt + }) + return { ...node, data: { ...node.data, options: updatedOptions } } + } + return node + }) as typeof initialData.nodes + + // Update edges with variableId references + const migratedEdges = initialData.edges.map((edge) => { + if (edge.data?.condition?.variableName) { + const variableId = varNameToId.get(edge.data.condition.variableName) + if (variableId) { + return { + ...edge, + data: { ...edge.data, condition: { ...edge.data.condition, variableId } }, + } + } + } + return edge + }) + + // Build toast message + const parts: string[] = [] + if (newCharacters.length > 0) { + parts.push(`${newCharacters.length} character${newCharacters.length > 1 ? 's' : ''}`) + } + if (newVariables.length > 0) { + parts.push(`${newVariables.length} variable${newVariables.length > 1 ? 's' : ''}`) + } + + return { + characters: newCharacters, + variables: newVariables, + nodes: migratedNodes, + edges: migratedEdges, + toastMessage: `Auto-imported ${parts.join(' and ')} from existing data`, + } +} + // Inner component that uses useReactFlow hook -function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { +function FlowchartEditorInner({ initialData, needsMigration }: FlowchartEditorProps) { // Define custom node types - memoized to prevent re-renders const nodeTypes: NodeTypes = useMemo( () => ({ @@ -72,17 +214,21 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { const { getViewport } = useReactFlow() + // Compute migrated data once on first render using a lazy state initializer + const [migratedData] = useState(() => computeMigration(initialData, !!needsMigration)) + const [nodes, setNodes, onNodesChange] = useNodesState( - toReactFlowNodes(initialData.nodes) + toReactFlowNodes(migratedData.nodes) ) const [edges, setEdges, onEdgesChange] = useEdgesState( - toReactFlowEdges(initialData.edges) + toReactFlowEdges(migratedData.edges) ) - const [characters, setCharacters] = useState(initialData.characters) - const [variables, setVariables] = useState(initialData.variables) + const [characters, setCharacters] = useState(migratedData.characters) + const [variables, setVariables] = useState(migratedData.variables) const [showSettings, setShowSettings] = useState(false) const [selectedEdgeId, setSelectedEdgeId] = useState(null) + const [toastMessage, setToastMessage] = useState(migratedData.toastMessage) const handleAddCharacter = useCallback( (name: string, color: string): string => { @@ -302,6 +448,13 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { onClose={() => setSelectedEdgeId(null)} /> )} + {toastMessage && ( + setToastMessage(null)} + /> + )}
) diff --git a/src/app/editor/[projectId]/page.tsx b/src/app/editor/[projectId]/page.tsx index 0750c85..7c5f64a 100644 --- a/src/app/editor/[projectId]/page.tsx +++ b/src/app/editor/[projectId]/page.tsx @@ -39,6 +39,10 @@ export default async function EditorPage({ params }: PageProps) { variables: rawData.variables || [], } + // Migration flag: if the raw data doesn't have characters/variables arrays, + // the project was created before these features existed and may need auto-migration + const needsMigration = !rawData.characters && !rawData.variables + return (
@@ -70,6 +74,7 @@ export default async function EditorPage({ params }: PageProps) {
diff --git a/src/components/editor/nodes/DialogueNode.tsx b/src/components/editor/nodes/DialogueNode.tsx index 0867d7b..10afe97 100644 --- a/src/components/editor/nodes/DialogueNode.tsx +++ b/src/components/editor/nodes/DialogueNode.tsx @@ -1,22 +1,56 @@ 'use client' -import { useCallback, ChangeEvent } from 'react' +import { useCallback, useMemo, useState, ChangeEvent } from 'react' import { Handle, Position, NodeProps, useReactFlow } from 'reactflow' +import Combobox from '@/components/editor/Combobox' +import type { ComboboxItem } from '@/components/editor/Combobox' +import { useEditorContext } from '@/components/editor/EditorContext' type DialogueNodeData = { speaker?: string + characterId?: string text: string } +const RANDOM_COLORS = [ + '#EF4444', '#F97316', '#F59E0B', '#10B981', + '#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6', + '#6366F1', '#F43F5E', '#84CC16', '#06B6D4', +] + +function randomColor(): string { + return RANDOM_COLORS[Math.floor(Math.random() * RANDOM_COLORS.length)] +} + export default function DialogueNode({ id, data }: NodeProps) { const { setNodes } = useReactFlow() + const { characters, onAddCharacter } = useEditorContext() + + const [showAddForm, setShowAddForm] = useState(false) + const [newName, setNewName] = useState('') + const [newColor, setNewColor] = useState(randomColor) + + const characterItems: ComboboxItem[] = useMemo( + () => + characters.map((c) => ({ + id: c.id, + label: c.name, + color: c.color, + })), + [characters] + ) + + const hasInvalidReference = useMemo(() => { + if (!data.characterId) return false + return !characters.some((c) => c.id === data.characterId) + }, [data.characterId, characters]) const updateNodeData = useCallback( - (field: keyof DialogueNodeData, value: string) => { + (updates: Partial) => { setNodes((nodes) => nodes.map((node) => node.id === id - ? { ...node, data: { ...node.data, [field]: value } } + ? { ...node, data: { ...node.data, ...updates } } : node ) ) @@ -24,22 +58,52 @@ export default function DialogueNode({ id, data }: NodeProps) [id, setNodes] ) - const handleSpeakerChange = useCallback( - (e: ChangeEvent) => { - updateNodeData('speaker', e.target.value) + const handleCharacterSelect = useCallback( + (characterId: string) => { + const character = characters.find((c) => c.id === characterId) + updateNodeData({ + characterId, + speaker: character?.name || '', + }) }, - [updateNodeData] + [characters, updateNodeData] ) + const handleAddNew = useCallback(() => { + setShowAddForm(true) + setNewName('') + setNewColor(randomColor()) + }, []) + + const handleSubmitNew = useCallback(() => { + if (!newName.trim()) return + const newId = onAddCharacter(newName.trim(), newColor) + updateNodeData({ + characterId: newId, + speaker: newName.trim(), + }) + setShowAddForm(false) + }, [newName, newColor, onAddCharacter, updateNodeData]) + + const handleCancelNew = useCallback(() => { + setShowAddForm(false) + }, []) + const handleTextChange = useCallback( (e: ChangeEvent) => { - updateNodeData('text', e.target.value) + updateNodeData({ text: e.target.value }) }, [updateNodeData] ) return ( -
+
) Dialogue
- +
+ + {hasInvalidReference && ( +
+ Character not found +
+ )} +
+ + {showAddForm && ( +
+
+ New character +
+
+ setNewColor(e.target.value)} + className="h-6 w-6 shrink-0 cursor-pointer rounded border border-zinc-300 dark:border-zinc-600" + /> + setNewName(e.target.value)} + placeholder="Name" + className="w-full rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400" + onKeyDown={(e) => { + if (e.key === 'Enter') handleSubmitNew() + if (e.key === 'Escape') handleCancelNew() + }} + autoFocus + /> +
+
+ + +
+
+ )}