Build a Conflict-Aware Sprint Board
Deploy a React sprint board that rejects stale task updates with HTTP 409.
Introduction
30 Second Summary
When two people update the same task from different screens, the latest click can quietly erase work that was already saved. The board still looks normal even though someone’s decision has vanished.
In this project, you will build a three-column Kanban sprint board with a React frontend backed by a Spring Boot API and PostgreSQL. You will turn a silent two-tab overwrite into a visible HTTP 409 conflict by enforcing optimistic concurrency.
What You'll Build
Your finished board runs at one public Render URL where a recruiter can watch a newer task move survive a stale update from a second browser tab.
By the end of this project, you'll have:
- A three-column sprint board where you can create tasks and move cards with the HTML Drag and Drop API or accessible button controls.
- A repeatable lost-update demonstration where two browser tabs load the same task revision before one silently overwrites the other.
- A conflict-aware workflow that rejects stale moves with HTTP 409 while preserving the latest task state in PostgreSQL.
- Secret Mission: Replay an old task version against the deployed API. Preserve the HTTP 409 response as evidence that the newer move survived.
Are there any prerequisites?
You should have completed one CRUD application. You also need local PostgreSQL access plus a GitHub account.
Before We Start
Before the hands-on work begins, lock in what you are building. You will create a collaborative Sprint Board that exposes a silent overwrite before optimistic concurrency rejects stale task revisions with HTTP 409 Conflict.
Set Up the Sprint Board Monorepo
Your board eventually asks a React frontend to exchange task revisions with a Spring Boot API. A mismatched runtime can break that workflow before you reach the concurrency lesson.
This step gives both sides reproducible foundations. You will verify Java 21, Node.js, Git, and PostgreSQL before assembling one monorepo.
In this step, get ready to:
- Verify the local runtimes required by the backend and frontend.
- Generate the Java backend with its Maven Wrapper files.
- Create the React frontend and confirm that its production build succeeds.
Verify your local tools
The backend targets Java 21. The frontend uses Vite 8.3.4, which requires Node.js 20.19+ or 22.12+.
- Press the Windows key to open Windows Search.
- Type PowerShell into the search bar.
- Press Enter to open PowerShell.
- Check your active Java runtime by running this command:
java -version
What does this check prove?
The command reports the Java runtime available to terminal applications. The first version line must identify Java 21.
✔️ The required Java version is installed
Java is ready for the Spring Boot backend.
ⓧ I see an older Java version
PowerShell is resolving an older Java installation. Install Java 21 before generating the backend.
- Download Java 21 from the Microsoft Build of OpenJDK download page.
- Run the Windows installer.
- Close PowerShell after the installation completes.
- Open a fresh PowerShell window through Windows Search.
- Run the Java version check shown above again.
ⓧ Java is not found
Windows cannot currently find a Java runtime. Installing Java 21 gives the backend the required compiler and runtime.
- Download Java 21 from the Microsoft Build of OpenJDK download page.
- Run the Windows installer.
- Close PowerShell after the installation completes.
- Open a fresh PowerShell window through Windows Search.
- Run the Java version check shown above again.
- Check your active Node.js runtime by running this command:
node --version
What does the Node.js version mean?
Vite 8 supports Node.js 20.19+ or 22.12+. A version from either supported range can build the frontend.
✔️ The required Node.js version is installed
Node.js is ready to install the pinned frontend packages.
ⓧ I see an older Node.js version
The installed release falls below Vite's supported range. Update Node.js before installing the frontend packages.
- Download a supported release from the official Node.js download page.
- Run the Windows installer.
- Close PowerShell after the installation completes.
- Open a fresh PowerShell window through Windows Search.
- Run the Node.js version check shown above again.
ⓧ Node.js is not found
Windows cannot currently find Node.js. The official installer adds the runtime required by Vite.
- Download a supported release from the official Node.js download page.
- Run the Windows installer.
- Close PowerShell after the installation completes.
- Open a fresh PowerShell window through Windows Search.
- Run the Node.js version check shown above again.
- Check that Git is available by running this command:
git --version
What does this check prove?
The command confirms that PowerShell can reach the Git command-line tool. You will use it to turn the monorepo folder into a repository.
✔️ Git reports a version
Git is ready to track the monorepo.
ⓧ Git is not found
Install Git for Windows before creating the repository.
- Download the installer from the official Git for Windows page.
- Run the installer with its default options.
- Close PowerShell after the installation completes.
- Open a fresh PowerShell window through Windows Search.
- Run the Git version check shown above again.
Your local database must also be available before the backend can persist tasks. Windows exposes the PostgreSQL server as a background service.
- Press the Windows key to open Windows Search.
- Type Services into the search bar.
- Press Enter to open the Services app.
- Find the service whose name starts with PostgreSQL.
- Confirm that the PostgreSQL service is running.
✔️ PostgreSQL is running
Your local database server is ready for the backend configuration.
ⓧ PostgreSQL is stopped
- Right-click the PostgreSQL service.
- Select Start.
- Confirm that the service begins running.
ⓧ PostgreSQL is not listed
PostgreSQL is a prerequisite for the local API. Install it before continuing with the backend.
- Download the Windows installer from the official PostgreSQL download page.
- Run the installer.
- Store the database password somewhere private.
- Return to the Services app after installation.
- Confirm that the PostgreSQL service is running.
Generate the backend foundation
Spring Initializr creates the standard Java folders plus the Maven Wrapper. The wrapper keeps the build reproducible without requiring a separate Maven installation.
- Press the Windows key to open Windows Search.
- Type File Explorer into the search bar.
- Press Enter to open File Explorer.
- Select Desktop in the left sidebar.
- Click New in the toolbar.
- Select Folder.
- Name the folder sprint-board.
- Open Spring Initializr in your browser.
- Select Maven under Project.
- Select Java under Language.
- Select 4.1.1 under Spring Boot.
- Enter com.example in the Group field.
- Enter sprint-board in the Artifact field.
- Enter sprint-board in the Name field.
- Enter Conflict-aware collaborative sprint board in the Description field.
- Enter com.example.sprintboard in the Package name field.
Why does the package name matter?
The package places SprintBoardApplication under com.example.sprintboard. Spring Boot scans downward from that package when it discovers the task API classes you add later.
- Select Jar under Packaging.
- Select 21 under Java.
- Click Add Dependencies.
- Add Spring Web.
- Add Spring Data JPA.
- Add PostgreSQL Driver.
- Click Generate to download the project archive.
What does Spring Initializr generate?
The archive includes the Java entry point plus the standard Maven project structure. It also includes mvnw.cmd and the wrapper support files required on Windows.
- Return to File Explorer.
- Open your Downloads folder.
- Right-click the downloaded sprint-board.zip archive.
- Select Extract All.
- Choose the sprint-board folder on your Desktop as the destination.
- Create a backend folder in the destination picker.
- Extract the archive into sprint-board\backend.
- Confirm that mvnw.cmd sits directly inside the backend folder.
- Press the Windows key to open Windows Search.
- Type Visual Studio Code into the search bar.
- Press Enter to open VS Code.
- Click File in the top menu.
- Click Open Folder.
- Select the sprint-board folder on your Desktop.
- Click Select Folder.
The generated project already contains the parent metadata and build coordinates. Replace its dependency section so the API uses the exact project dependencies.
- Open backend\pom.xml from the VS Code Explorer.
- Find the complete <dependencies> block.
- Replace that block with this configuration:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
What do these dependencies provide?
- The web starter provides the Spring MVC classes used by the REST API.
- The JPA starter connects Java entities to database rows.
- The PostgreSQL driver lets the application communicate with the local database.
- Save backend\pom.xml.
- Find the complete <build> block.
- Replace that block with this plugin configuration:
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
What does the build plugin do?
The Spring Boot Maven plugin packages the application as an executable JAR. The deployment container uses that packaged application later.
- Save backend\pom.xml.
- Click Terminal in the VS Code menu.
- Click New Terminal.
The first Maven Wrapper run can download Maven before reporting its version. A short pause here means the wrapper is preparing the pinned build environment.
Before you run the check, do you expect the wrapper to use the Java runtime you verified earlier?
- Verify the backend Maven Wrapper from the repository root by running this command:
backend\mvnw.cmd --version
What should the wrapper report?
The output identifies Maven plus the Java runtime it is using. The Java line should identify version 21.
Maven Wrapper check failed?
Confirm that mvnw.cmd is directly inside backend. Move the extracted files up one folder if the archive created an extra nested sprint-board directory.
If the wrapper reports the wrong Java runtime, reopen VS Code after installing Java 21. Help me debug the Maven Wrapper check.
The initial properties file gives Spring Boot a local PostgreSQL connection shape. The password remains a plain placeholder during this setup step.
- Open backend\src\main\resources\application.properties.
- Replace its contents with this initial configuration:
spring.application.name=sprint-board
server.address=0.0.0.0
server.port=${PORT:8080}
spring.datasource.url=jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:postgres}
spring.datasource.username=${DB_USER:postgres}
spring.datasource.password=${DB_PASSWORD:your-postgres-password}
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false
What does this configuration prepare?
The defaults target a local PostgreSQL server. The environment placeholders also let a deployment provide its own port and database connection values.
The password line contains your-postgres-password. It does not contain a live credential.
- Save backend\src\main\resources\application.properties.
- Create .gitignore at the top level of the sprint-board folder.
- Add these generated paths to the ignore file:
.idea/
.vscode/
backend/target/
frontend/node_modules/
frontend/dist/
*.local
Why ignore these paths?
The listed folders contain editor settings or generated build output. Git can recreate them from the source files that remain tracked.
- Save .gitignore.
- Initialize the repository from the VS Code terminal by running this command:
git init
What does this command create?
Git creates its repository metadata inside the current sprint-board folder. The backend and frontend can now share one version history.
- Confirm that Git recognizes the monorepo by running this command:
git status
What should Git show?
Git reports the repository's current branch plus the untracked source files. Ignored editor settings and generated build folders stay out of that list.
Git initialized in the wrong folder?
The terminal path should end with sprint-board before you run the command. Reopen the repository root in VS Code if the terminal points somewhere else.
Help me check where my Git repository was initialized.
Create and build the frontend foundation
The frontend needs pinned package metadata plus a small React entry point. This scaffold proves that the JavaScript toolchain works before you add drag interactions.
- Create a frontend folder from the VS Code Explorer.
- Create a src folder inside frontend.
- Create package.json inside frontend.
- Add the pinned frontend package configuration:
{
"name": "sprint-board-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "6.1.2",
"vite": "8.3.4"
}
}
What does this package file control?
The dependencies pin React and React DOM to 19.3.1. The development dependencies pin the React plugin to 6.1.2 plus Vite to 8.3.4.
The scripts expose consistent commands for development builds and production builds.
- Save frontend\package.json.
- Right-click the frontend folder in the VS Code Explorer.
- Select Open in Integrated Terminal.
The first package installation can take a few minutes while npm downloads the pinned dependencies.
- Install the frontend dependencies by running this command:
npm install
What does npm install create?
npm reads package.json and downloads the required packages. It stores those packages in node_modules.
Package installation failed?
Confirm that the terminal path ends with frontend. Confirm that your Node.js version sits inside one of Vite's supported ranges.
Help me debug npm install for this frontend.
- Create vite.config.js inside frontend.
- Add the React plugin configuration:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})
What does the Vite configuration do?
The plugin teaches Vite how to process React components. The exported configuration activates that plugin for development and production builds.
- Save frontend\vite.config.js.
- Create index.html inside frontend.
- Add the browser entry point:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Conflict-aware collaborative sprint board" />
<title>Sprint Board</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
What does the HTML entry point provide?
The root element gives React a place to render. The module script loads src/main.jsx as the browser entry point.
- Save frontend\index.html.
- Confirm that index.html and vite.config.js appear inside frontend in the VS Code Explorer.
- Create App.jsx inside frontend\src.
- Add the initial Sprint Board component:
export default function App() {
return (
<main className="shell">
<header className="hero">
<div>
<h1>Sprint Board</h1>
</div>
</header>
</main>
)
}
What does the initial component prove?
The component gives the React entry point a valid default export. It also provides a visible Sprint Board heading for the local interface you build next.
- Save frontend\src\App.jsx.
- Create index.css inside frontend\src.
- Add the initial page theme:
:root {
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
color: #ecf3ff;
background: #07101d;
font-synthesis: none;
}
What does the initial stylesheet set?
The root styles establish the board's dark color palette and font stack. The complete board styles replace this small foundation in the next step.
- Save frontend\src\index.css.
- Create main.jsx inside frontend\src.
- Add the React entry point:
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './index.css'
createRoot(document.getElementById('root')).render(<App />)
What does the React entry point do?
The file imports the component plus its stylesheet. It renders App into the HTML element whose ID is root.
- Save frontend\src\main.jsx.
- Switch to the VS Code terminal at the repository root.
Before you run the final checks, do you expect every local tool to answer successfully from the same PowerShell session?
- Verify Java, Node.js, Git, and the backend wrapper by running these commands:
java -version
node --version
git --version
backend\mvnw.cmd --version
What should these checks confirm?
Each command should complete successfully. Java must report 21. Node.js must report 20.19+ or 22.12+.
Git should report a version. The Maven Wrapper should identify Maven plus Java 21.
- Right-click the frontend folder in the VS Code Explorer.
- Select Open in Integrated Terminal.
Before you build the frontend, do you expect the scaffold to compile into a production folder?
- Create the production frontend bundle by running this command:
npm run build
What does the build command prove?
Vite resolves the pinned packages and compiles the React entry point. A successful build writes the production assets to frontend\dist.
- Expand the frontend folder in the VS Code Explorer.
- Confirm that a dist folder now exists.
That is the foundation working. Your Java wrapper and React production build now succeed inside the same Git repository.
Frontend build failed?
Confirm that the active terminal path ends with frontend.
Check that main.jsx imports ./App.jsx plus ./index.css with matching capitalization. Help me debug the Vite production build.
✔️ Awesome, I've got everything!
Your monorepo scaffold matches the required end state.
ⓧ I'd like to double check the full code
.gitignore:
.idea/
.vscode/
backend/target/
frontend/node_modules/
frontend/dist/
*.local
What to compare
Confirm that editor settings plus generated backend and frontend output are ignored.
backend/pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>sprint-board</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>sprint-board</name>
<description>Conflict-aware collaborative sprint board</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
What to compare
Confirm the Java version plus the three dependencies and Spring Boot build plugin.
backend/src/main/java/com/example/sprintboard/SprintBoardApplication.java:
package com.example.sprintboard;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SprintBoardApplication {
public static void main(String[] args) {
SpringApplication.run(SprintBoardApplication.class, args);
}
}
What to compare
Confirm that the generated class uses the required package and starts Spring Boot.
backend/src/main/resources/application.properties:
spring.application.name=sprint-board
server.address=0.0.0.0
server.port=${PORT:8080}
spring.datasource.url=jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:postgres}
spring.datasource.username=${DB_USER:postgres}
spring.datasource.password=${DB_PASSWORD:your-postgres-password}
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false
What to compare
Confirm the local database defaults plus the deployment environment placeholders.
frontend/package.json:
{
"name": "sprint-board-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "6.1.2",
"vite": "8.3.4"
}
}
What to compare
Confirm every frontend package version plus the development and production scripts.
frontend/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Conflict-aware collaborative sprint board" />
<title>Sprint Board</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
What to compare
Confirm the page metadata plus the root element and module script path.
frontend/vite.config.js:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})
What to compare
Confirm that the Vite configuration activates the React plugin.
frontend/src/main.jsx:
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './index.css'
createRoot(document.getElementById('root')).render(<App />)
What to compare
Confirm the component and stylesheet imports plus the React root render call.
frontend/src/App.jsx:
export default function App() {
return (
<main className="shell">
<header className="hero">
<div>
<h1>Sprint Board</h1>
</div>
</header>
</main>
)
}
What to compare
Confirm that the initial component exports a valid Sprint Board page.
frontend/src/index.css:
:root {
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
color: #ecf3ff;
background: #07101d;
font-synthesis: none;
}
What to compare
Confirm that the initial stylesheet establishes the font and dark page colors.
Your monorepo is ready for feature work. Next, you will turn the small React scaffold into a three-column board with draggable task cards.
Build the Draggable Sprint Board
Your monorepo can build successfully. It still needs a board that turns task movement into something you can see.
A visible React board makes the Kanban workflow concrete before persistence introduces stale data. You will keep the first tasks in local state so every interaction has an immediate result.
In this step, get ready to:
- Build the task form with three workflow columns.
- Move local task cards with native drag events.
- Add button controls with responsive board styling.
Build the local sprint board
The first board keeps its tasks inside the browser. This gives you a fast visual loop without requiring the backend or PostgreSQL.
- In frontend/src/App.jsx, replace the existing file with this local board:
import { useState } from 'react'
const columns = [{ id: 'BACKLOG', label: 'Backlog' }, { id: 'IN_PROGRESS', label: 'In Progress' }, { id: 'DONE', label: 'Done' }]
export default function App() {
// Seed the board with local tasks so every column interaction is visible.
const [tasks, setTasks] = useState([{ id: 1, title: 'Review API contract', assignee: 'Avery', status: 'BACKLOG' }, { id: 2, title: 'Design task cards', assignee: 'Morgan', status: 'IN_PROGRESS' }])
// Track the form fields before a task joins the local board.
const [title, setTitle] = useState('')
const [assignee, setAssignee] = useState('')
function createTask(event) {
event.preventDefault()
if (!title.trim() || !assignee.trim()) return
setTasks([...tasks, { id: Date.now(), title: title.trim(), assignee: assignee.trim(), status: 'BACKLOG' }])
setTitle('')
setAssignee('')
}
return (
<main className="shell">
<header className="hero"><div><p className="eyebrow">Collaborative delivery workflow</p><h1>Sprint Board</h1><p>Drag work forward without silently overwriting a teammate's newer move.</p></div><form className="task-form" onSubmit={createTask}><input aria-label="Task title" placeholder="Task title" value={title} onChange={(event) => setTitle(event.target.value)} /><input aria-label="Assignee" placeholder="Assignee" value={assignee} onChange={(event) => setAssignee(event.target.value)} /><button type="submit">Add task</button></form></header>
<section className="board" aria-live="polite">{columns.map((column) => { const columnTasks = tasks.filter((task) => task.status === column.id); return <section className="column" key={column.id}><div className="column-heading"><h2>{column.label}</h2><span>{columnTasks.length}</span></div>{columnTasks.length === 0 && <p className="empty small">Drop a task here</p>}{columnTasks.map((task) => <article className="task-card" key={task.id}><div className="card-meta"><span>{task.assignee}</span></div><h3>{task.title}</h3></article>)}</section> })}</section>
</main>
)
}
What does this code do?
- The columns array defines the three workflow destinations.
- The tasks state stores the cards for this local version.
- The createTask() function adds form submissions to Backlog.
- Each column filters the task list by its matching status.
- Save frontend/src/App.jsx.
The development server keeps running while you edit the board. Its PowerShell session remains occupied so it can serve each saved change.
- Start the frontend development server from the repository root by running these commands:
Set-Location frontend
npm run dev
What do these commands do?
The first command changes PowerShell's current location to frontend. The second command starts the Vite development server.
Keep this PowerShell session running throughout the step. It serves the board at http://localhost:5173.
- Open http://localhost:5173 in your browser.
- Enter Prepare sprint demo in the task title field.
- Enter Jordan in the assignee field.
- Click Add task.
That first slice is working. You can see three columns plus a new card in Backlog.
Board not loading?
Confirm that PowerShell is still running the development server. Check that frontend/src/App.jsx has matching parentheses plus closing tags.
If the page remains blank, help me debug the local React board.
Move cards with native drag events
The Native HTML Drag and Drop API carries the dragged task ID from a card to a column. A drop target must cancel the default drag-over behavior before it can accept that data.
- In frontend/src/App.jsx, replace the current file with this draggable version:
import { useState } from 'react'
const columns = [{ id: 'BACKLOG', label: 'Backlog' }, { id: 'IN_PROGRESS', label: 'In Progress' }, { id: 'DONE', label: 'Done' }]
export default function App() {
// Keep task locations in browser memory for this frontend-only board.
const [tasks, setTasks] = useState([{ id: 1, title: 'Review API contract', assignee: 'Avery', status: 'BACKLOG' }, { id: 2, title: 'Design task cards', assignee: 'Morgan', status: 'IN_PROGRESS' }])
const [title, setTitle] = useState('')
const [assignee, setAssignee] = useState('')
function createTask(event) {
event.preventDefault()
if (!title.trim() || !assignee.trim()) return
setTasks([...tasks, { id: Date.now(), title: title.trim(), assignee: assignee.trim(), status: 'BACKLOG' }])
setTitle('')
setAssignee('')
}
// Update only the card that reaches a new workflow column.
function moveTask(taskId, status) { setTasks((currentTasks) => currentTasks.map((task) => task.id === taskId ? { ...task, status } : task)) }
function handleDragStart(event, taskId) { event.dataTransfer.setData('text/plain', String(taskId)) }
function handleDrop(event, status) { event.preventDefault(); const taskId = Number(event.dataTransfer.getData('text/plain')); moveTask(taskId, status) }
return (
<main className="shell">
<header className="hero"><div><p className="eyebrow">Collaborative delivery workflow</p><h1>Sprint Board</h1><p>Drag work forward without silently overwriting a teammate's newer move.</p></div><form className="task-form" onSubmit={createTask}><input aria-label="Task title" placeholder="Task title" value={title} onChange={(event) => setTitle(event.target.value)} /><input aria-label="Assignee" placeholder="Assignee" value={assignee} onChange={(event) => setAssignee(event.target.value)} /><button type="submit">Add task</button></form></header>
<section className="board" aria-live="polite">{columns.map((column) => { const columnTasks = tasks.filter((task) => task.status === column.id); return <section className="column" key={column.id} onDragOver={(event) => event.preventDefault()} onDrop={(event) => handleDrop(event, column.id)}><div className="column-heading"><h2>{column.label}</h2><span>{columnTasks.length}</span></div>{columnTasks.length === 0 && <p className="empty small">Drop a task here</p>}{columnTasks.map((task) => <article className="task-card" draggable={true} key={task.id} onDragStart={(event) => handleDragStart(event, task.id)}><div className="card-meta"><span>{task.assignee}</span></div><h3>{task.title}</h3></article>)}</section> })}</section>
</main>
)
}
How does dragging work?
- The draggable property lets the browser start a drag from a task card.
- The handleDragStart() function stores the task ID as drag data.
- The onDragOver handler calls preventDefault() so the column accepts a drop.
- The handleDrop() function reads the task ID before updating its status.
- Save frontend/src/App.jsx.
- Refresh http://localhost:5173 in your browser.
- Drag Review API contract from Backlog into In Progress.
You will see the card leave Backlog. Its destination column count increases immediately.
Card refuses to drop?
Confirm that the destination column includes onDragOver with preventDefault(). Check that the card passes task.id into handleDragStart().
If the drag begins but the card stays put, help me debug the drag data flow.
Add button controls and responsive styling
Drag input depends on a pointer. Left plus Right buttons give keyboard users the same workflow movement through standard button controls.
- In frontend/src/App.jsx, replace the current file with this completed local board:
import { useState } from 'react'
const columns = [{ id: 'BACKLOG', label: 'Backlog' }, { id: 'IN_PROGRESS', label: 'In Progress' }, { id: 'DONE', label: 'Done' }]
export default function App() {
// Keep task locations in browser memory until persistence is connected.
const [tasks, setTasks] = useState([{ id: 1, title: 'Review API contract', assignee: 'Avery', status: 'BACKLOG' }, { id: 2, title: 'Design task cards', assignee: 'Morgan', status: 'IN_PROGRESS' }])
// Track the form values used to create local demo cards.
const [title, setTitle] = useState('')
const [assignee, setAssignee] = useState('')
function createTask(event) {
event.preventDefault()
if (!title.trim() || !assignee.trim()) return
setTasks([...tasks, { id: Date.now(), title: title.trim(), assignee: assignee.trim(), status: 'BACKLOG' }])
setTitle('')
setAssignee('')
}
function moveTask(taskId, status) { setTasks((currentTasks) => currentTasks.map((task) => task.id === taskId ? { ...task, status } : task)) }
function handleDragStart(event, taskId) { event.dataTransfer.setData('text/plain', String(taskId)) }
function handleDrop(event, status) { event.preventDefault(); const taskId = Number(event.dataTransfer.getData('text/plain')); moveTask(taskId, status) }
function moveByOffset(task, offset) { const index = columns.findIndex((column) => column.id === task.status); const destination = columns[index + offset]; if (destination) moveTask(task.id, destination.id) }
return (
<main className="shell">
<header className="hero"><div><p className="eyebrow">Collaborative delivery workflow</p><h1>Sprint Board</h1><p>Drag work forward without silently overwriting a teammate's newer move.</p></div><form className="task-form" onSubmit={createTask}><input aria-label="Task title" placeholder="Task title" value={title} onChange={(event) => setTitle(event.target.value)} /><input aria-label="Assignee" placeholder="Assignee" value={assignee} onChange={(event) => setAssignee(event.target.value)} /><button type="submit">Add task</button></form></header>
<section className="board" aria-live="polite">{columns.map((column) => { const columnTasks = tasks.filter((task) => task.status === column.id); return <section className="column" key={column.id} onDragOver={(event) => event.preventDefault()} onDrop={(event) => handleDrop(event, column.id)}><div className="column-heading"><h2>{column.label}</h2><span>{columnTasks.length}</span></div>{columnTasks.length === 0 && <p className="empty small">Drop a task here</p>}{columnTasks.map((task) => <article className="task-card" draggable={true} key={task.id} onDragStart={(event) => handleDragStart(event, task.id)}><div className="card-meta"><span>{task.assignee}</span></div><h3>{task.title}</h3><div className="card-actions"><button type="button" aria-label={`Move ${task.title} left`} onClick={() => moveByOffset(task, -1)}>Left</button><button type="button" aria-label={`Move ${task.title} right`} onClick={() => moveByOffset(task, 1)}>Right</button></div></article>)}</section> })}</section>
</main>
)
}
What do the buttons add?
The moveByOffset() function finds a task's current column. It selects the adjacent column from an offset of -1 or 1.
Standard buttons work with pointer input plus keyboard focus. Their accessible labels include the task title plus movement direction.
- Save frontend/src/App.jsx.
- Refresh the board in your browser.
- Press Right on a Backlog card.
You will see that card move into In Progress. The same state update now serves drag input plus button input.
Buttons not moving cards?
Check that each button passes the complete task object into moveByOffset(). Confirm that the Right button uses an offset of 1.
If clicking a button has no effect, help me inspect the column offset logic.
✔️ Awesome, I've got everything!
Your completed frontend/src/App.jsx now supports local task creation plus two movement methods.
ⓧ I'd like to double check the full code
Compare your complete frontend/src/App.jsx with this reference:
import { useState } from 'react'
const columns = [{ id: 'BACKLOG', label: 'Backlog' }, { id: 'IN_PROGRESS', label: 'In Progress' }, { id: 'DONE', label: 'Done' }]
export default function App() {
// Keep task locations in browser memory until persistence is connected.
const [tasks, setTasks] = useState([{ id: 1, title: 'Review API contract', assignee: 'Avery', status: 'BACKLOG' }, { id: 2, title: 'Design task cards', assignee: 'Morgan', status: 'IN_PROGRESS' }])
// Track the form values used to create local demo cards.
const [title, setTitle] = useState('')
const [assignee, setAssignee] = useState('')
function createTask(event) {
event.preventDefault()
if (!title.trim() || !assignee.trim()) return
setTasks([...tasks, { id: Date.now(), title: title.trim(), assignee: assignee.trim(), status: 'BACKLOG' }])
setTitle('')
setAssignee('')
}
function moveTask(taskId, status) { setTasks((currentTasks) => currentTasks.map((task) => task.id === taskId ? { ...task, status } : task)) }
function handleDragStart(event, taskId) { event.dataTransfer.setData('text/plain', String(taskId)) }
function handleDrop(event, status) { event.preventDefault(); const taskId = Number(event.dataTransfer.getData('text/plain')); moveTask(taskId, status) }
function moveByOffset(task, offset) { const index = columns.findIndex((column) => column.id === task.status); const destination = columns[index + offset]; if (destination) moveTask(task.id, destination.id) }
return (
<main className="shell">
<header className="hero"><div><p className="eyebrow">Collaborative delivery workflow</p><h1>Sprint Board</h1><p>Drag work forward without silently overwriting a teammate's newer move.</p></div><form className="task-form" onSubmit={createTask}><input aria-label="Task title" placeholder="Task title" value={title} onChange={(event) => setTitle(event.target.value)} /><input aria-label="Assignee" placeholder="Assignee" value={assignee} onChange={(event) => setAssignee(event.target.value)} /><button type="submit">Add task</button></form></header>
<section className="board" aria-live="polite">{columns.map((column) => { const columnTasks = tasks.filter((task) => task.status === column.id); return <section className="column" key={column.id} onDragOver={(event) => event.preventDefault()} onDrop={(event) => handleDrop(event, column.id)}><div className="column-heading"><h2>{column.label}</h2><span>{columnTasks.length}</span></div>{columnTasks.length === 0 && <p className="empty small">Drop a task here</p>}{columnTasks.map((task) => <article className="task-card" draggable={true} key={task.id} onDragStart={(event) => handleDragStart(event, task.id)}><div className="card-meta"><span>{task.assignee}</span></div><h3>{task.title}</h3><div className="card-actions"><button type="button" aria-label={`Move ${task.title} left`} onClick={() => moveByOffset(task, -1)}>Left</button><button type="button" aria-label={`Move ${task.title} right`} onClick={() => moveByOffset(task, 1)}>Right</button></div></article>)}</section> })}</section>
</main>
)
}
What should this file contain?
The reference includes local task state plus form state. It also includes drag handlers plus adjacent-column button movement.
The interactions now work. The supplied styles turn their raw layout into a responsive board with clear drop zones.
- In frontend/src/index.css, replace the existing file with this first style group:
:root {
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
color: #ecf3ff;
background: #07101d;
font-synthesis: none;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input { font: inherit; }
.shell { width: min(1180px, 94vw); margin: 0 auto; padding: 42px 0 72px; }
.hero { display: flex; justify-content: space-between; gap: 28px; align-items: end; margin-bottom: 26px; }
h1 { margin: 4px 0; font-size: clamp(2.5rem, 7vw, 4.8rem); }
h2, h3, p { margin-top: 0; }
.eyebrow { color: #7bdcff; text-transform: uppercase; letter-spacing: .12em; font-size: .74rem; }
.task-form { display: grid; grid-template-columns: 1fr 150px auto; gap: 9px; min-width: min(100%, 520px); }
input, button { border-radius: 10px; padding: 11px 13px; }
input { border: 1px solid #38526f; color: #ecf3ff; background: #0b192a; }
button { border: 0; color: #07101d; background: #7bdcff; font-weight: 750; cursor: pointer; }
What does this style group control?
The root rules establish the dark color palette plus the type system. The shell constrains the content width.
The hero rules position the introduction beside the task form on wider screens. The input plus button rules make each control visually distinct.
- Save frontend/src/index.css.
- Refresh the board in your browser.
You will see a dark page with a large Sprint Board heading. The task form now has dark inputs plus a blue action button.
Styles not appearing?
Confirm that frontend/src/main.jsx still imports ./index.css. Check that you replaced the stylesheet inside frontend/src.
If the page remains unstyled, help me trace the stylesheet import.
- Append the remaining board styles to frontend/src/index.css with this code:
.board { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; align-items: start; }
.column { min-height: 430px; padding: 16px; border: 1px solid #263d59; border-radius: 18px; background: #0b192a; }
.column-heading, .card-meta, .card-actions { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.column-heading span { min-width: 28px; padding: 4px 8px; border-radius: 999px; text-align: center; background: #1b3450; }
.task-card { margin-top: 12px; padding: 16px; border: 1px solid #38526f; border-radius: 14px; background: #11243a; box-shadow: 0 12px 28px rgba(0, 0, 0, .22); cursor: grab; }
.task-card:active { cursor: grabbing; }
.card-meta { color: #a8bdd3; font-size: .8rem; }
.card-actions { margin-top: 16px; justify-content: flex-start; }
.card-actions button { padding: 7px 10px; background: #203d5c; color: #dceaff; }
.empty, .error { padding: 16px; border: 1px dashed #38526f; border-radius: 12px; color: #a8bdd3; }
.empty.small { margin-top: 12px; }
.error { color: #ffb3c3; border-style: solid; }
@media (max-width: 860px) {
.hero { align-items: stretch; flex-direction: column; }
.task-form { grid-template-columns: 1fr; min-width: 0; }
.board { grid-template-columns: 1fr; }
.column { min-height: 220px; }
}
How does the board respond?
The board uses three equal columns on wider screens. Each task becomes a raised card with a grab cursor plus compact controls.
Below a width of 860px, the form plus board switch to single-column layouts. The cards stay usable on a narrow screen.
- Save frontend/src/index.css.
- Refresh the board in your browser.
- Resize the browser window below 860px wide.
You will see the workflow columns stack vertically. The task form also becomes a single column.
Columns not stacking?
Confirm that the media query closes with its final brace. Check that the responsive selector remains .board.
If the layout stays in three columns, help me inspect the responsive CSS.
✔️ Awesome, I've got everything!
Your completed frontend/src/index.css now styles the full board plus its narrow-screen layout.
ⓧ I'd like to double check the full code
Compare your complete frontend/src/index.css with this reference:
:root {
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
color: #ecf3ff;
background: #07101d;
font-synthesis: none;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input { font: inherit; }
.shell { width: min(1180px, 94vw); margin: 0 auto; padding: 42px 0 72px; }
.hero { display: flex; justify-content: space-between; gap: 28px; align-items: end; margin-bottom: 26px; }
h1 { margin: 4px 0; font-size: clamp(2.5rem, 7vw, 4.8rem); }
h2, h3, p { margin-top: 0; }
.eyebrow { color: #7bdcff; text-transform: uppercase; letter-spacing: .12em; font-size: .74rem; }
.task-form { display: grid; grid-template-columns: 1fr 150px auto; gap: 9px; min-width: min(100%, 520px); }
input, button { border-radius: 10px; padding: 11px 13px; }
input { border: 1px solid #38526f; color: #ecf3ff; background: #0b192a; }
button { border: 0; color: #07101d; background: #7bdcff; font-weight: 750; cursor: pointer; }
.board { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; align-items: start; }
.column { min-height: 430px; padding: 16px; border: 1px solid #263d59; border-radius: 18px; background: #0b192a; }
.column-heading, .card-meta, .card-actions { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.column-heading span { min-width: 28px; padding: 4px 8px; border-radius: 999px; text-align: center; background: #1b3450; }
.task-card { margin-top: 12px; padding: 16px; border: 1px solid #38526f; border-radius: 14px; background: #11243a; box-shadow: 0 12px 28px rgba(0, 0, 0, .22); cursor: grab; }
.task-card:active { cursor: grabbing; }
.card-meta { color: #a8bdd3; font-size: .8rem; }
.card-actions { margin-top: 16px; justify-content: flex-start; }
.card-actions button { padding: 7px 10px; background: #203d5c; color: #dceaff; }
.empty, .error { padding: 16px; border: 1px dashed #38526f; border-radius: 12px; color: #a8bdd3; }
.empty.small { margin-top: 12px; }
.error { color: #ffb3c3; border-style: solid; }
@media (max-width: 860px) {
.hero { align-items: stretch; flex-direction: column; }
.task-form { grid-template-columns: 1fr; min-width: 0; }
.board { grid-template-columns: 1fr; }
.column { min-height: 220px; }
}
What should this file contain?
The reference styles the page shell plus the task form. It also styles the board columns plus the task cards.
The final media query stacks the interface on narrow screens. This keeps every workflow control reachable.
Before the final check, which movement method do you expect to feel faster for one nearby column?
- Refresh http://localhost:5173 in your browser.
- Create one task with the task form.
- Drag that task from Backlog to In Progress.
- Move that task into Done with the Right button.
You will see the task form above Backlog plus In Progress plus Done. The card moves through all three columns without a page reload.
The Left plus Right controls provide the same movement without drag input. Your frontend development server remains active in PowerShell.
Your sprint workflow is now visible plus interactive. Next, you will connect it to Spring Boot with PostgreSQL so two browser tabs can expose a real lost update.
Persist Tasks and Reproduce a Lost Update
Your draggable React board currently keeps every move inside one browser tab. A second tab has no shared source of truth, so the board cannot reveal what happens when two people move the same task.
PostgreSQL and Spring Boot give both tabs a shared task row. The initial move path uses last-write-wins behavior so you can experience the failure that concurrency controls need to prevent.
In this step, get ready to:
- Persist sprint tasks with a database-managed entity version.
- Connect the React board to the Spring Boot API.
- Reproduce a silent lost update with two stale browser tabs.
Add the PostgreSQL task model
A persisted task needs an identity plus the fields already visible on each card. JPA maps those Java fields to a PostgreSQL row.
The entity also carries a version number managed by the database layer. Each successful task update advances that revision.
- Start your local PostgreSQL server using the method from your existing installation.
- Return to the sprint-board workspace in VS Code.
- Select backend/src/main/resources/application.properties in the Explorer sidebar.
- Replace the file contents with this configuration:
spring.application.name=sprint-board
server.address=0.0.0.0
server.port=${PORT:8080}
spring.datasource.url=jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:postgres}
spring.datasource.username=${DB_USER:postgres}
spring.datasource.password=${DB_PASSWORD:your-postgres-password}
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false
What does this configuration do?
- The datasource properties connect the backend to the local PostgreSQL server by default.
- The environment fallbacks allow the same application to receive different database settings during deployment.
- The schema setting lets Hibernate create or update the table represented by the task entity.
Your PostgreSQL password is sensitive. Keep it on your computer during this project so it never enters the repository history.
- Replace your-postgres-password with your real local PostgreSQL password.
- Save backend/src/main/resources/application.properties.
- Keep the file out of every commit while it contains your real password.
Create the persistent task files
- Create backend/src/main/java/com/example/sprintboard/task/SprintTask.java from the VS Code Explorer.
- Add the entity code below:
package com.example.sprintboard.task;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Version;
@Entity
public class SprintTask {
@Id
@GeneratedValue
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String assignee;
@Column(nullable = false)
private String status;
@Version
private long version;
protected SprintTask() {
}
public SprintTask(String title, String assignee) {
this.title = title;
this.assignee = assignee;
this.status = "BACKLOG";
}
public void moveTo(String status) {
this.status = status;
}
public Long getId() { return id; }
public String getTitle() { return title; }
public String getAssignee() { return assignee; }
public String getStatus() { return status; }
public long getVersion() { return version; }
}
How does the task entity work?
- The generated id gives each task a stable database identity.
- The constructor places every new task in BACKLOG.
- The @Version field stores the revision managed by JPA.
- The moveTo() method changes the workflow status before the repository saves the row.
- Save SprintTask.java.
- Create backend/src/main/java/com/example/sprintboard/task/CreateTaskRequest.java from the VS Code Explorer.
- Add the request record below:
package com.example.sprintboard.task;
public record CreateTaskRequest(String title, String assignee) {
}
What does this request record hold?
The record describes the JSON fields accepted when the board creates a task. Spring MVC converts the incoming title and assignee into this Java value.
- Save CreateTaskRequest.java.
- Create backend/src/main/java/com/example/sprintboard/task/MoveTaskRequest.java from the VS Code Explorer.
- Add the move request record below:
package com.example.sprintboard.task;
public record MoveTaskRequest(String status, long version) {
}
What does the move request carry?
Each move supplies the destination status plus the revision that the browser loaded. This creates the API contract you will test with stale browser state.
- Save MoveTaskRequest.java.
- Confirm that CreateTaskRequest.java and MoveTaskRequest.java are both listed in the VS Code Explorer.
- Create backend/src/main/java/com/example/sprintboard/task/SprintTaskRepository.java from the VS Code Explorer.
- Add the repository code below:
package com.example.sprintboard.task;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SprintTaskRepository extends JpaRepository<SprintTask, Long> {
List<SprintTask> findAllByOrderByIdAsc();
}
What does the repository provide?
The repository supplies standard database operations for SprintTask rows. The derived query returns tasks in stable ascending ID order so the board does not shuffle between reloads.
- Save SprintTaskRepository.java.
- Create a second PowerShell terminal in the existing VS Code window.
Before you start the backend, do you think the new entity can connect to PostgreSQL with the credentials you supplied?
- Start the backend from the sprint-board folder by running this command:
backend\mvnw.cmd spring-boot:run
The backend compiles the new persistence classes. The terminal remains occupied while Spring Boot runs on http://localhost:8080.
Backend cannot connect to PostgreSQL?
Confirm that your local PostgreSQL server is running. Check that the password in application.properties matches the local postgres user.
If the backend still stops during startup, help me diagnose the PostgreSQL connection.
Expose the task API and connect React
The board now needs API operations that list tasks, create tasks, and move an existing task. The first move implementation reloads the current row before saving the requested status.
- Stop the running backend by pressing Ctrl+C in its PowerShell terminal.
- Create backend/src/main/java/com/example/sprintboard/task/SprintTaskService.java from the VS Code Explorer.
- Add the service code below:
package com.example.sprintboard.task;
import java.util.List;
import java.util.Set;
import org.springframework.stereotype.Service;
@Service
public class SprintTaskService {
private static final Set<String> STATUSES = Set.of("BACKLOG", "IN_PROGRESS", "DONE");
private final SprintTaskRepository taskRepository;
public SprintTaskService(SprintTaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
public List<SprintTask> list() {
return taskRepository.findAllByOrderByIdAsc();
}
public SprintTask create(CreateTaskRequest request) {
String title = requireText(request.title(), "Task title is required");
String assignee = requireText(request.assignee(), "Assignee is required");
return taskRepository.save(new SprintTask(title, assignee));
}
public SprintTask move(Long taskId, MoveTaskRequest request) {
SprintTask task = taskRepository.findById(taskId)
.orElseThrow(() -> new IllegalArgumentException("Task not found"));
String status = requireText(request.status(), "Status is required");
if (!STATUSES.contains(status)) {
throw new IllegalArgumentException("Status must be BACKLOG, IN_PROGRESS, or DONE");
}
task.moveTo(status);
return taskRepository.save(task);
}
private String requireText(String value, String message) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(message);
}
return value.trim();
}
}
How does the initial service work?
- The list() method returns the persisted tasks in ID order.
- The create() method rejects blank fields before saving a Backlog task.
- The move() method loads the latest row from PostgreSQL before applying the requested status.
- The request record carries a version value. This service does not compare that value yet.
- Save SprintTaskService.java.
- Create backend/src/main/java/com/example/sprintboard/task/SprintTaskController.java from the VS Code Explorer.
- Add the controller code below:
package com.example.sprintboard.task;
import java.util.List;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api")
public class SprintTaskController {
private final SprintTaskService taskService;
public SprintTaskController(SprintTaskService taskService) {
this.taskService = taskService;
}
@GetMapping("/health")
public Map<String, String> health() {
return Map.of("status", "ok");
}
@GetMapping("/tasks")
public List<SprintTask> list() {
return taskService.list();
}
@PostMapping("/tasks")
@ResponseStatus(HttpStatus.CREATED)
public SprintTask create(@RequestBody CreateTaskRequest request) {
return taskService.create(request);
}
@PostMapping("/tasks/{taskId}/move")
public SprintTask move(@PathVariable Long taskId, @RequestBody MoveTaskRequest request) {
return taskService.move(taskId, request);
}
}
What does the controller expose?
- The health route provides a small response that proves the backend is accepting requests.
- The task routes connect HTTP requests to the service methods for listing, creating, and moving tasks.
- A successful task creation returns HTTP 201.
- Save SprintTaskController.java.
Before you restart the backend, what response do you expect from the new health route?
- Restart the backend from the sprint-board folder by running this command:
backend\mvnw.cmd spring-boot:run
- Open http://localhost:8080/api/health in a browser tab.
You should see a JSON response containing {"status":"ok"}. This proves the API controller is running against the configured backend.
- Stop the backend by pressing Ctrl+C in its PowerShell terminal.
- Create backend/src/main/java/com/example/sprintboard/ApiExceptionHandler.java from the VS Code Explorer.
- Add the error handler below:
package com.example.sprintboard;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handleBadRequest(IllegalArgumentException exception) {
return Map.of("error", exception.getMessage());
}
}
How are invalid requests returned?
The advice converts validation failures into an HTTP 400 response. Its JSON error field gives the React client a message it can display.
- Save ApiExceptionHandler.java.
- Create backend/src/main/java/com/example/sprintboard/WebConfiguration.java from the VS Code Explorer.
- Add the local web configuration below:
package com.example.sprintboard;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:5173")
.allowedMethods("GET", "POST", "OPTIONS")
.allowedHeaders("Content-Type");
}
}
Why does the local API need CORS?
CORS controls which browser origins can call the API. This configuration permits the Vite frontend on http://localhost:5173 to send the requests used by the board.
- Save WebConfiguration.java.
- Restart the backend from the sprint-board folder by running this command:
backend\mvnw.cmd spring-boot:run
The backend should remain running on http://localhost:8080. Keep this terminal active while you connect the frontend.
Health route stopped working?
Check that every Java file uses the matching package shown in its code block. Confirm that ApiExceptionHandler.java and WebConfiguration.java sit directly inside the com/example/sprintboard folder.
If compilation still fails, help me trace the Java package or import error.
The frontend now replaces local demo state with data loaded from the API. Every move sends the task ID, destination status, and card version back to Spring Boot.
- Select frontend/src/App.jsx in the VS Code Explorer.
- Replace the file contents with the API-connected component below:
import { useEffect, useState } from 'react'
const columns = [
{ id: 'BACKLOG', label: 'Backlog' },
{ id: 'IN_PROGRESS', label: 'In Progress' },
{ id: 'DONE', label: 'Done' },
]
const apiBase = import.meta.env.VITE_API_URL || (import.meta.env.DEV ? 'http://localhost:8080' : '')
async function readJson(response) {
const body = await response.json()
if (!response.ok) {
throw new Error(body.error || 'Request failed')
}
return body
}
export default function App() {
const [tasks, setTasks] = useState([])
const [title, setTitle] = useState('')
const [assignee, setAssignee] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
async function loadTasks() {
setLoading(true)
try {
const response = await fetch(`${apiBase}/api/tasks`)
setTasks(await readJson(response))
} catch (requestError) {
setError(requestError.message)
} finally {
setLoading(false)
}
}
useEffect(() => {
loadTasks()
}, [])
async function createTask(event) {
event.preventDefault()
setError('')
try {
const response = await fetch(`${apiBase}/api/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, assignee }),
})
await readJson(response)
setTitle('')
setAssignee('')
await loadTasks()
} catch (requestError) {
setError(requestError.message)
}
}
async function moveTask(taskId, status) {
const task = tasks.find((candidate) => candidate.id === taskId)
if (!task || task.status === status) return
setError('')
try {
const response = await fetch(`${apiBase}/api/tasks/${taskId}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status, version: task.version }),
})
await readJson(response)
await loadTasks()
} catch (requestError) {
setError(requestError.message)
await loadTasks()
}
}
function handleDragStart(event, taskId) {
event.dataTransfer.setData('text/plain', String(taskId))
}
function handleDrop(event, status) {
event.preventDefault()
const taskId = Number(event.dataTransfer.getData('text/plain'))
moveTask(taskId, status)
}
function moveByOffset(task, offset) {
const index = columns.findIndex((column) => column.id === task.status)
const destination = columns[index + offset]
if (destination) moveTask(task.id, destination.id)
}
return (
<main className="shell">
<header className="hero">
<div>
<p className="eyebrow">Collaborative delivery workflow</p>
<h1>Sprint Board</h1>
<p>Drag work forward without silently overwriting a teammate's newer move.</p>
</div>
<form className="task-form" onSubmit={createTask}>
<input
aria-label="Task title"
placeholder="Task title"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
<input
aria-label="Assignee"
placeholder="Assignee"
value={assignee}
onChange={(event) => setAssignee(event.target.value)}
/>
<button type="submit">Add task</button>
</form>
</header>
{error && <p className="error" role="alert">{error}</p>}
{loading && <p className="empty">Loading sprint...</p>}
<section className="board" aria-live="polite">
{columns.map((column) => {
const columnTasks = tasks.filter((task) => task.status === column.id)
return (
<section
className="column"
key={column.id}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => handleDrop(event, column.id)}
>
<div className="column-heading">
<h2>{column.label}</h2>
<span>{columnTasks.length}</span>
</div>
{columnTasks.length === 0 && <p className="empty small">Drop a task here</p>}
{columnTasks.map((task) => (
<article
className="task-card"
draggable={true}
key={task.id}
onDragStart={(event) => handleDragStart(event, task.id)}
>
<div className="card-meta">
<span>{task.assignee}</span>
<code>v{task.version}</code>
</div>
<h3>{task.title}</h3>
<div className="card-actions">
<button
type="button"
aria-label={`Move ${task.title} left`}
onClick={() => moveByOffset(task, -1)}
>
Left
</button>
<button
type="button"
aria-label={`Move ${task.title} right`}
onClick={() => moveByOffset(task, 1)}
>
Right
</button>
</div>
</article>
))}
</section>
)
})}
</section>
</main>
)
}
How does the API-connected board work?
- The loadTasks() function replaces browser-only data with rows returned by GET /api/tasks.
- The createTask() function sends the form values to POST /api/tasks.
- The moveTask() function sends the destination status plus the card's current version.
- Each successful request reloads the board from PostgreSQL so the visible columns reflect persisted state.
- Save frontend/src/App.jsx.
- Return to the frontend at http://localhost:5173.
You should see the three columns plus an empty task form backed by the API. Any old hard-coded demo cards disappear because PostgreSQL now supplies the task list.
Board cannot load tasks?
Confirm that the backend terminal is still running. Confirm that the frontend development server is still serving http://localhost:5173.
Check that WebConfiguration.java allows the same frontend origin shown in your browser. If requests still fail, help me debug the connection between Vite and Spring Boot.
Reproduce the lost update
Two tabs can now hold different snapshots of the same database row. The test below lets both snapshots issue a move so you can inspect which state survives.
- Enter Prepare launch notes in the Task title field.
- Enter Alex in the Assignee field.
- Click Add task.
Your new card appears in Backlog with a visible version. That task now survives browser refreshes because PostgreSQL owns its state.
- Duplicate the Sprint Board browser tab.
- Label the original tab as tab A in your mind.
- Treat the duplicate as tab B.
- Confirm that both tabs show the same task in Backlog with the same version.
- Return to tab A.
- Drag the task from Backlog to In Progress.
Tab A reloads the saved row. You should see the task in In Progress with a newer version.
- Switch to tab B without refreshing it.
- Confirm that its stale card still appears in Backlog.
Before you move the stale card, do you think the API preserves tab A's newer move or accepts tab B's older view?
- Drag the stale card in tab B directly from Backlog to Done.
Tab B accepts the move without showing a conflict. The service loaded the latest row, replaced its status with Done, and saved it without checking the version sent by tab B.
- Refresh tab A.
- Refresh tab B.
- Confirm that both tabs show the task in Done.
You have reproduced a lost update. Tab B silently overwrote the newer In Progress move even though its request came from an older task revision.
Why did the version field not protect the move?
JPA updates the entity version when the current row is saved. The service still loaded that current row before applying tab B's requested status.
The stale client version reached the API inside MoveTaskRequest. No comparison gave that old revision any effect, so the write looked valid to the database.
You now have direct evidence of the failure hidden inside last-write-wins updates. Next, you will enforce the client's entity version so a stale move receives HTTP 409 while the newer task state survives.
Reject Stale Moves with Optimistic Concurrency
Your two-tab test exposed a lost update. The stale tab overwrote newer work because the API ignored the revision it received.
In this step, you will turn the JPA entity version into an optimistic concurrency contract. A stale revision will produce HTTP 409 Conflict.
Why optimistic concurrency?
Most board users usually change different task rows. Long-lived database locks would reduce concurrency for work that rarely overlaps.
Optimistic concurrency lets those edits proceed freely. It rejects a write when the client revision is older than the stored revision.
In this step, get ready to:
- Reject mismatched task versions with TaskConflictException.
- Map task conflicts to an HTTP 409 JSON response.
- Prove that a stale browser tab cannot overwrite a newer task move.
Create the conflict exception
A dedicated exception gives the concurrency failure a clear path through the backend. Its message also becomes the error that the board displays.
- Create TaskConflictException.java inside backend/src/main/java/com/example/sprintboard/task by adding the code below:
package com.example.sprintboard.task;
public class TaskConflictException extends RuntimeException {
public TaskConflictException(String message) {
super(message);
}
}
What does this exception do?
- TaskConflictException identifies a stale task move as its own failure type.
- RuntimeException carries the conflict message from the service to the API exception handler.
- Save TaskConflictException.java.
- Restart the running backend with the same command from the previous step.
Good progress. The new exception compiles when the backend starts without Java compilation errors.
Backend not restarting?
- Check that the package line matches the com/example/sprintboard/task folder path.
- Check that the filename uses the same capitalization as TaskConflictException.
If the compilation error continues, help me debug TaskConflictException.
✔️ Awesome, I've got everything!
Great. Your conflict exception matches the backend contract.
ⓧ I'd like to double check the full code
package com.example.sprintboard.task;
public class TaskConflictException extends RuntimeException {
public TaskConflictException(String message) {
super(message);
}
}
Enforce the version contract
The request already carries a version. The service needs to compare it with the row loaded from the database before changing the status.
- Add the client-version comparison above the String status = requireText(request.status(), "Status is required"); line in SprintTaskService.java by pasting this code:
if (task.getVersion() != request.version()) {
throw new TaskConflictException(
"This task changed in another tab. The board has been refreshed."
);
}
What does this check do?
- task.getVersion() reads the current revision from the row loaded by the service.
- request.version() reads the revision that the browser saw before making its move.
- A mismatch throws TaskConflictException before moveTo() can change the status.
- @Version remains a database-level guard if another write races with the save after this comparison.
- Save SprintTaskService.java.
- Restart the running backend with the same command from the previous step.
- Refresh the board at http://localhost:5173.
Your persisted task should load in its current column. This proves that the new comparison compiles without disrupting normal reads.
Board not loading after the comparison?
- Check that the comparison sits inside move() after the repository lookup.
- Check that both calls use the exact names task.getVersion() and request.version().
If the backend still fails to compile, help me debug the version comparison.
✔️ Awesome, I've got everything!
Great. Your service now stops stale requests before they change a task.
ⓧ I'd like to double check the full code
package com.example.sprintboard.task;
import java.util.List;
import java.util.Set;
import org.springframework.stereotype.Service;
@Service
public class SprintTaskService {
private static final Set<String> STATUSES = Set.of("BACKLOG", "IN_PROGRESS", "DONE");
private final SprintTaskRepository taskRepository;
public SprintTaskService(SprintTaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
public List<SprintTask> list() {
return taskRepository.findAllByOrderByIdAsc();
}
public SprintTask create(CreateTaskRequest request) {
String title = requireText(request.title(), "Task title is required");
String assignee = requireText(request.assignee(), "Assignee is required");
return taskRepository.save(new SprintTask(title, assignee));
}
public SprintTask move(Long taskId, MoveTaskRequest request) {
SprintTask task = taskRepository.findById(taskId)
.orElseThrow(() -> new IllegalArgumentException("Task not found"));
if (task.getVersion() != request.version()) {
throw new TaskConflictException(
"This task changed in another tab. The board has been refreshed."
);
}
String status = requireText(request.status(), "Status is required");
if (!STATUSES.contains(status)) {
throw new IllegalArgumentException("Status must be BACKLOG, IN_PROGRESS, or DONE");
}
task.moveTo(status);
return taskRepository.save(task);
}
private String requireText(String value, String message) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(message);
}
return value.trim();
}
}
The service can now identify the stale write. Spring Boot still needs an exception mapping that turns this Java failure into the API response expected by the browser.
- Replace the contents of ApiExceptionHandler.java with the code below:
package com.example.sprintboard;
import java.util.Map;
import com.example.sprintboard.task.TaskConflictException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handleBadRequest(IllegalArgumentException exception) {
return Map.of("error", exception.getMessage());
}
@ExceptionHandler(TaskConflictException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public Map<String, String> handleConflict(TaskConflictException exception) {
return Map.of("error", exception.getMessage());
}
}
How does the conflict become an API response?
- @ExceptionHandler(TaskConflictException.class) routes the new exception to handleConflict().
- HttpStatus.CONFLICT gives the response its HTTP 409 Conflict status.
- error keeps the same JSON key that the existing readJson() function already reads.
- Save ApiExceptionHandler.java.
- Restart the running backend with the same command from the previous step.
The backend should return to its running state without compilation errors.
- Refresh the board to confirm that the persisted task still loads.
The card should display its current status. Its version should still appear beside the assignee.
Backend returning a server error?
- Check that TaskConflictException is imported from com.example.sprintboard.task.
- Check that handleConflict() remains inside the ApiExceptionHandler class braces.
If the handler still fails, help me debug the HTTP 409 mapping.
✔️ Awesome, I've got everything!
Great. Your API now translates stale task moves into HTTP 409 responses.
ⓧ I'd like to double check the full code
package com.example.sprintboard;
import java.util.Map;
import com.example.sprintboard.task.TaskConflictException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handleBadRequest(IllegalArgumentException exception) {
return Map.of("error", exception.getMessage());
}
@ExceptionHandler(TaskConflictException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public Map<String, String> handleConflict(TaskConflictException exception) {
return Map.of("error", exception.getMessage());
}
}
Prove stale moves are rejected
How does the browser handle a conflict?
Your existing React move path already completes the client side of the contract.
- moveTask() sends the card's current version with the requested status.
- readJson() throws an error containing the API's JSON message when the response is unsuccessful.
- moveTask() displays that message before calling loadTasks() to fetch the latest PostgreSQL state.
- Switch back to tab A from the previous lost-update test.
- Move the test task into Backlog.
The card should now sit in Backlog. Its version should be one number higher.
- Reload tab A.
- Reload tab B.
Both tabs should now show the task in Backlog with the same version.
Before you make the next two moves, choose your prediction: tab B overwrites the move or receives a conflict.
- Move the task from Backlog to In Progress in tab A.
Tab A should show the card in In Progress. The displayed version should increment.
- Switch back to the stale copy in tab B.
- Drag the task from Backlog to Done.
You'll see This task changed in another tab. The board has been refreshed. above the board. The card reloads in In Progress with the newer version from tab A.
- Use tab B's browser request inspector to confirm that the rejected move returned HTTP 409.
- Refresh tab A.
The task remains in In Progress. The stale move never reaches the database.
Stale move still succeeding?
- Reload both tabs before moving the task in tab A so each tab starts with the same revision.
- Check that tab B remains untouched until after tab A completes its move.
- Check that moveTask() sends version: task.version in the request body.
If the stale move still overwrites tab A, help me trace the optimistic concurrency flow.
That's the concurrency boundary working. Your board now preserves the newer task move while the stale tab receives a visible conflict.
Your local board now protects newer work from stale browser tabs. Next up, you will package this contract into one container and deploy the demonstration to Render.
Deploy the Sprint Board to Render
Your local sprint board now rejects stale task moves before they erase newer work. A recruiter still needs a public URL where they can reproduce that protection for themselves.
This step uses Docker to package the React frontend with the Spring Boot API. The production image runs both pieces as one application.
GitHub becomes the source that Render deploys from. The finished service proves your concurrency handling outside your local environment.
In this step, get ready to:
- Package the frontend with the backend in a three-stage Docker image.
- Push the Sprint Board repository to GitHub.
- Deploy the Render Blueprint and reproduce the stale-move conflict.
Package the full stack for Render
A multi-stage build gives each tool one job. Node builds the browser files. Maven places those files inside Spring Boot. Java runs the packaged application.
- Select the sprint-board folder in the VS Code Explorer sidebar.
- Click the New File icon in the Explorer toolbar.
- Name the file Dockerfile.
- Define the production image by pasting this code into Dockerfile:
FROM node:20-bookworm-slim AS frontend-build
WORKDIR /frontend
COPY frontend/package.json ./
RUN npm install
COPY frontend/ ./
RUN npm run build
FROM maven:3.10.0-eclipse-temurin-21-noble AS backend-build
WORKDIR /backend
COPY backend/pom.xml ./
RUN mvn -B dependency:go-offline
COPY backend/src ./src
COPY --from=frontend-build /frontend/dist ./src/main/resources/static
RUN mvn -B package -DskipTests
FROM eclipse-temurin:21.0.12.1_1-jre-noble AS runtime
WORKDIR /app
COPY --from=backend-build /backend/target/sprint-board-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 10000
ENTRYPOINT ["java", "-jar", "app.jar"]
What does this Dockerfile do?
- The first stage installs the frontend packages and produces frontend/dist.
- The second stage copies those browser files into Spring Boot's static resources before Maven packages the JAR.
- The final stage runs sprint-board-0.0.1-SNAPSHOT.jar with Java 21.
- The runtime stage exposes port 10000 for the Render web service.
- Save Dockerfile.
- Confirm that Dockerfile is listed beside the backend and frontend folders.
You now have one build recipe that produces the frontend before packaging the backend.
Dockerfile Missing or Misplaced?
Confirm that the file is named exactly Dockerfile with no file extension. It must sit directly inside sprint-board.
If the stages or paths look different, help me compare my Dockerfile with the Sprint Board structure.
✔️ Awesome, I've got everything!
Your saved Dockerfile is ready for the Render build.
ⓧ I'd like to double check the full code
FROM node:20-bookworm-slim AS frontend-build
WORKDIR /frontend
COPY frontend/package.json ./
RUN npm install
COPY frontend/ ./
RUN npm run build
FROM maven:3.10.0-eclipse-temurin-21-noble AS backend-build
WORKDIR /backend
COPY backend/pom.xml ./
RUN mvn -B dependency:go-offline
COPY backend/src ./src
COPY --from=frontend-build /frontend/dist ./src/main/resources/static
RUN mvn -B package -DskipTests
FROM eclipse-temurin:21.0.12.1_1-jre-noble AS runtime
WORKDIR /app
COPY --from=backend-build /backend/target/sprint-board-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 10000
ENTRYPOINT ["java", "-jar", "app.jar"]
A Render Blueprint describes the web service plus its database in one YAML file. Render reads this file from the repository root.
- Select the sprint-board folder in the Explorer sidebar.
- Click the New File icon.
- Name the file render.yaml.
- Define the web service plus its first database references by pasting this code into render.yaml:
services:
- type: web
name: sprint-board
runtime: docker
plan: free
dockerfilePath: ./Dockerfile
dockerContext: .
healthCheckPath: /api/health
envVars:
- key: DB_HOST
fromDatabase:
name: sprint-board-db
property: host
- key: DB_PORT
fromDatabase:
name: sprint-board-db
property: port
- key: DB_USER
fromDatabase:
name: sprint-board-db
property: user
What Does This Service Configuration Do?
- The web resource builds the repository with the root Dockerfile.
- The /api/health endpoint gives Render a concrete service-health check.
- The first environment mappings read the database host, port, and user from sprint-board-db.
- Save render.yaml.
- Confirm that the file begins with services:.
The web service now points at the Docker build plus the first three database connection values.
YAML Indentation Looking Uneven?
Keep each nested level aligned with spaces. A database property line must align with the name line above it.
If the nesting is hard to spot, help me check the indentation in this first render.yaml section.
- Place your cursor below the property: user line in render.yaml.
- Complete the password, database-name, and PostgreSQL resource wiring by pasting this code:
- key: DB_PASSWORD
fromDatabase:
name: sprint-board-db
property: password
- key: DB_NAME
fromDatabase:
name: sprint-board-db
property: database
databases:
- name: sprint-board-db
plan: free
postgresMajorVersion: '18'
databaseName: sprintboard
user: sprintboard_user
ipAllowList: []
How Is the Database Connected?
Render supplies the database password plus database name through environment variables. Your application already reads those values from DB_PASSWORD and DB_NAME.
The database uses PostgreSQL 18. An empty ipAllowList keeps its connections internal to Render.
- Save render.yaml.
- Confirm that databases: aligns with services: at the left edge.
The Blueprint now defines the complete web-service and database pair.
Database Mapping Incomplete?
Confirm that every fromDatabase entry names sprint-board-db. Confirm that the database resource uses the same name.
If Render cannot parse the file later, help me compare my database mappings with the resource declaration.
✔️ Awesome, I've got everything!
Your render.yaml now defines both free Render resources.
ⓧ I'd like to double check the full code
services:
- type: web
name: sprint-board
runtime: docker
plan: free
dockerfilePath: ./Dockerfile
dockerContext: .
healthCheckPath: /api/health
envVars:
- key: DB_HOST
fromDatabase:
name: sprint-board-db
property: host
- key: DB_PORT
fromDatabase:
name: sprint-board-db
property: port
- key: DB_USER
fromDatabase:
name: sprint-board-db
property: user
- key: DB_PASSWORD
fromDatabase:
name: sprint-board-db
property: password
- key: DB_NAME
fromDatabase:
name: sprint-board-db
property: database
databases:
- name: sprint-board-db
plan: free
postgresMajorVersion: '18'
databaseName: sprintboard
user: sprintboard_user
ipAllowList: []
Push the monorepo to GitHub
Render needs the committed Dockerfile plus render.yaml before it can create your infrastructure. The repository also becomes the source for future automatic deployments.
Keep Your Password Local
Publishing source code can feel exposed. Your real PostgreSQL password must remain on your computer.
The committed properties file uses a harmless fallback. Render injects the production password through its database mapping.
- Return to backend/src/main/resources/application.properties.
- Confirm that the password line reads spring.datasource.password=${DB_PASSWORD:your-postgres-password}.
- Replace any real password on that line with your-postgres-password.
- Save application.properties.
- Open the GitHub new repository page.
- Enter sprint-board in the Repository name field.
- Select Public for the repository visibility.
- Leave the README, license, and Git ignore initialization options unselected.
- Click Create repository.
- Copy the repository URL from the Quick Setup page.
- Record the copied URL here: https://github.com/your-username/sprint-board.git.
Your GitHub repository is empty. The next commit captures every source file while respecting the existing .gitignore rules.
- Start a separate PowerShell terminal in VS Code.
- Confirm that the terminal path ends with the sprint-board folder.
- Create the local repository snapshot by running these commands:
git init -b main
git add .
git commit -m "Deploy Sprint Board to Render"
What Do These Git Commands Do?
- The first command initializes the repository with main as its initial branch.
- The second command stages the source files that are not excluded by .gitignore.
- The third command records a deployable snapshot in Git.
- Confirm that the terminal reports a new commit.
The commit now contains your application, container build, and Render Blueprint.
Commit Did Not Complete?
A first commit can stop when Git does not have your author identity. Follow the terminal's configuration guidance before repeating the commit.
If the commit includes files you expected Git to ignore, help me check my Sprint Board .gitignore before I push.
- Connect the local repository to the copied GitHub URL by running:
git remote add origin [[YOUR_REPO_URL="https://github.com/your-username/sprint-board.git"]]
git remote -v
What Does the Remote Do?
The name origin gives your GitHub repository a short local reference. The verification command prints the stored fetch and push destinations.
- Confirm that the terminal prints your GitHub URL for both remote operations.
Your local commit now has a verified destination on GitHub.
Remote Already Exists?
This means the local repository already has an origin remote. Compare the printed remote URL with the repository you just created.
If the URL points somewhere else, help me safely update my existing Git remote.
The push can open a GitHub authentication flow in your browser. Approve access for the Git account that owns the new repository.
- Push the main branch to GitHub by running this command:
git push -u origin main
- Refresh the sprint-board repository page on GitHub.
- Confirm that Dockerfile and render.yaml appear beside the application folders.
Good progress. GitHub now holds the exact deployment source that Render needs.
Push Rejected?
Confirm that you approved GitHub access with the account that owns the repository. A permissions failure usually means the terminal authenticated with a different account.
If the push still fails, help me diagnose my GitHub push without exposing credentials.
Deploy the Blueprint and test the conflict
Render reads the Blueprint from GitHub. It creates the Docker web service plus the managed PostgreSQL database as one deployment.
Free Plans Still Have Limits
This Blueprint requests plan: free for both resources. The free web service provides 512 MB of RAM.
The free database includes 1 GB of SSD storage. Usage beyond included allowances can incur charges.
- Open the Render Dashboard.
- Create a Render account with your GitHub identity if Render asks you to authenticate.
- Complete the GitHub authorization flow shown by Render.
- Click New in the Render Dashboard.
- Select Blueprint.
- Click Connect beside the sprint-board repository.
What Is Render Reading?
Render finds render.yaml at the repository root. The form should list a web service named sprint-board plus a database named sprint-board-db.
- Enter sprint-board in the Blueprint Name field.
- Confirm that main is selected in the Branch field.
- Keep render.yaml in the Blueprint Path field.
- Review the resource list for sprint-board and sprint-board-db.
- Click Deploy Blueprint.
The first deployment has three build stages, so give it a few minutes. A quiet log between downloads does not mean the build has stalled.
Render builds the frontend with Node. It packages the JAR with Maven before starting the Java runtime.
- Wait for green completion indicators beside both Blueprint resources.
- Select the sprint-board web service.
- Confirm that the latest deployment is serving traffic.
- Copy the public service URL shown at the top of the service page.
- Record the public URL here: https://sprint-board.onrender.com.
That public URL now serves the React build from Spring Boot's static resources. Local Docker was never required.
Blueprint Build Failed?
Check the first failing Docker stage in the deployment log. A frontend failure points to the Node stage. A backend failure points to the Maven stage.
If the error mentions a missing path or file, help me trace my Render build failure to the matching Dockerfile stage.
- Open the public service URL with /api/health appended.
- Confirm that the browser displays {"status":"ok"}.
The health response proves that the public Spring Boot API is running.
- Return to the public service URL without the health path.
- Enter Deploy conflict proof in the task title field.
- Enter Alex in the assignee field.
- Click Add task.
- Duplicate the browser tab containing the deployed board.
- Reload both tabs so they display the same task version.
- Move the task to In Progress in tab A.
Tab A saves the move in Render PostgreSQL. Its card now displays a newer version.
- Predict whether the API will accept the stale tab's older task version.
- Move the stale copy of the task to Done in tab B.
You will see This task changed in another tab. The board has been refreshed. Tab B reloads the current task in In Progress.
- Reload both deployed tabs.
- Confirm that the task remains in In Progress.
That is the deployment proof complete. Your public API returns HTTP 409 for the stale move while preserving the newer database state.
Stale Move Was Accepted?
Both tabs must load the same task version before tab A moves it. Reload both tabs first. Move tab A only once before testing tab B.
If tab B still overwrites the task, help me verify the deployed concurrency code and Render revision.
Secret mission
Replay a Stale Update
Your two-tab test proves that the deployed board rejects a stale move. Now replay an old task version directly against the deployed API and preserve the HTTP 409 response as portfolio evidence.
Clean Up Your Resources
Clean Up Your Resources
At intended resume-demo usage, the deployed Sprint Board's two Render resources cost $0 per month. Choose whether to keep the demo online, suspend its web service, or delete its deployed resources before usage exceeds the included allowances.
Cost warning
The free plans fit the intended demo usage. Usage beyond the included allowances can incur charges.
Choose Pause or Delete if you no longer need a public link.
Resources you used:
- Render Docker web service named sprint-board.
- Render PostgreSQL 18 database named sprint-board-db with 1 GB of included SSD storage.
Your GitHub repository remains the source record for this project. The cleanup choices below leave it intact.
Keep everything running
No action needed. Choose this if you want the public Sprint Board to remain available as a resume demo.
- Leave the sprint-board web service running.
- Leave sprint-board-db available so the existing tasks remain stored.
- Keep the dataset small to limit database storage usage.
- Monitor Render usage while the public URL remains available.
- Retain the GitHub repository as the source for the demo.
Pause - I'll come back to this later
Suspend the web service to take the public demo offline. The database stays available so its tasks remain intact.
- Return to your signed-in Render account from earlier.
- Select the sprint-board web service.
- Use the service controls to suspend sprint-board.
- Confirm the public service URL no longer loads the Sprint Board.
- Leave sprint-board-db available to preserve its tasks.
- Retain the GitHub repository containing your source plus concurrency evidence.
Delete - I don't want to use this again
Remove the deployed resources when you no longer need the public demo. This permanently removes the public URL plus stored tasks.
- Return to your signed-in Render account from earlier.
- Select the sprint-board web service.
- Open the settings page for sprint-board.
- Use the permanent deletion control to delete the web service.
- Select the sprint-board-db database.
- Open the settings page for sprint-board-db.
- Use the permanent deletion control to delete the database.
- Confirm both resources no longer appear in your Render account.
That clears the live resources while keeping your proof intact. Your project evidence still exists outside Render.
- Retain the GitHub repository containing README.md.
- Keep the local Sprint Board project as an offline copy.
- Preserve the captured HTTP 409 response as evidence of the repaired concurrency contract.
Nice Work!
Nice Work!
You made it. Your live Sprint Board now protects newer task moves from stale browser tabs.
You've learned how to:
- Build a responsive three-column sprint board with React. Move cards with the Native HTML Drag and Drop API. Support keyboard-friendly movement with button controls.
- Reproduce a silent lost update with two stale browser tabs. Enforce optimistic concurrency with entity versions. Return HTTP 409 Conflict for stale moves.
- Package the Spring Boot REST API with the React frontend in one Docker container. Deploy the service through a Render Blueprint backed by PostgreSQL.
- Complete the optional Secret Mission by replaying a stale API request. Preserve the HTTP 409 response as portfolio evidence. Confirm that the newer task state survives.
Ready to quiz yourself?