tutorial
Build a project management app: a Gantt and a board, one dataset
Last updated 5 September 2026
A project lives as two pictures of the same work. A schedule answers when: what runs before what, which task cannot slip without moving the end date, when the milestone lands. A board answers who and where: what is in flight, what is stuck, what is done. This tutorial builds both from one task list, so a change on either side is a change to the same work, not a second copy to keep in step.
You will build an editable Gantt with real scheduling, its critical path called out, dependency links and a milestone, next to a Kanban board of the same tasks. Drag a bar to reschedule it or a card to move it, and every view agrees, because there is only ever one set of rows behind them. It runs with no backend. The finished app is one click away if you want to see the destination first.
Open the finished app in the sandbox
The problem: one body of work, two pictures
The trap is to build the schedule and the board as separate things. Two stores of the same tasks drift apart within a week: a card moves to done but its bar still shows work outstanding, a task slips on the timeline but the board never hears about it, and soon nobody trusts either view. The reader is left reconciling two truths by eye.
The better shape is one dataset with two views over it. A single task list is the source of truth. A schedule reads it and draws a timeline; a board reads it and draws cards. When one view changes a task, it writes back to that same list, and the other view redraws from it. There is nothing to reconcile, because there is only one thing to be right.
Set up the page
Load three things from the CDN with ordinary script tags: the grid, the Gantt
module and the board module. No build step and no import: the grid is on the
global LatticeGrid, the Gantt module is on
LatticeGridGantt, and the board module is on
LatticeGridKanban. Each module is a separate, opt-in bundle that
adds nothing to a page that does not load it. On localhost the grid is free to
use; a deployed site is licensed per domain, which the sandbox already carries
for you.
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.34.1/lattice-grid.min.css">
<script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.34.1/lattice-grid.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.34.1/modules/gantt.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.34.1/modules/kanban.min.js"></script>
Lay out a task grid and a timeline side by side, with the board underneath. Each view mounts into its element by id.
<style>
body { font-family: system-ui, sans-serif; margin: 0; padding: 16px; background: #f6f7f9; color: #1b2430; }
h2 { font-size: 13px; margin: 0 0 8px; color: #3a4250; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
.plan { display: grid; grid-template-columns: 470px 1fr; gap: 0; background: #fff; border: 1px solid #e3e6ea; border-radius: 10px; overflow: hidden; }
#tasks { border-right: 1px solid #eef0f3; }
#tasks, #timeline { height: 320px; }
#board { height: 300px; background: #fff; border: 1px solid #e3e6ea; border-radius: 10px; overflow: hidden; margin-top: 16px; }
section { margin-bottom: 4px; }
@media (max-width: 820px) { .plan { grid-template-columns: 1fr; } #tasks { border-right: 0; border-bottom: 1px solid #eef0f3; } }
</style>
<section>
<h2>Schedule</h2>
<div class="plan">
<div id="tasks"></div> <!-- the editable task grid -->
<div id="timeline"></div> <!-- the Gantt timeline -->
</div>
</section>
<section>
<h2>Board</h2>
<div id="board"></div>
</section>
Model the work
Everything starts from one task list. Each task carries its schedule, in whole days, and the stage the board groups on. A milestone is a checkpoint with no duration. Dependencies are a separate list, and they are the real substance of a schedule: the four standard link types say how one task constrains another, and a lag shifts a link by a number of days. Finish to start is the common one; the schedule handles all four.
// A day-number is whole days since 1970-01-01 (UTC). Anchoring the plan to a
// real date lets the timeline read as a calendar; day0 is the date it begins.
var day0 = Math.floor(Date.UTC(2026, 8, 7) / 86400000); // 2026-09-07
// One task list drives everything. Each task carries its schedule (start as a
// day-number, duration in whole days, percentComplete) and the stage the board
// groups on. A milestone is a zero-duration checkpoint. Dependencies are a
// separate list: 'type' is one of the four standard links and 'lag' shifts a
// link by whole days.
var tasks = [
{ id: 'discovery', name: 'Discovery', stage: 'done', start: day0 + 0, duration: 5, percentComplete: 100, assignee: 'Ava', ord: 1 },
{ id: 'design', name: 'Design', stage: 'doing', start: day0 + 5, duration: 6, percentComplete: 55, assignee: 'Ravi', ord: 2 },
{ id: 'build-api', name: 'Build the API', stage: 'doing', start: day0 + 11, duration: 8, percentComplete: 15, assignee: 'Mia', ord: 3 },
{ id: 'build-ui', name: 'Build the UI', stage: 'todo', start: day0 + 7, duration: 10, percentComplete: 0, assignee: 'Leo', ord: 4 },
{ id: 'integrate', name: 'Integrate', stage: 'todo', start: day0 + 19, duration: 4, percentComplete: 0, assignee: 'Mia', ord: 5 },
{ id: 'qa', name: 'QA and hardening', stage: 'todo', start: day0 + 23, duration: 5, percentComplete: 0, assignee: 'Sam', ord: 6 },
{ id: 'handover', name: 'Ops handover', stage: 'todo', start: day0 + 24, duration: 4, percentComplete: 0, assignee: 'Priya', ord: 7 },
{ id: 'launch', name: 'Launch', stage: 'todo', milestone: true, start: day0 + 28, percentComplete: 0, assignee: 'Ava', ord: 8 },
];
// The four standard link types, each optionally with lag or lead:
// FS finish-to-start (the common one: B starts after A finishes)
// SS start-to-start (B starts alongside A, here two days in)
// FF finish-to-finish (B finishes when A does)
// SF start-to-finish (B finishes as A starts; the rare one - the ops
// handover must be complete the moment launch begins)
var dependencies = [
{ from: 'discovery', to: 'design', type: 'FS' },
{ from: 'design', to: 'build-api', type: 'FS' },
{ from: 'design', to: 'build-ui', type: 'SS', lag: 2 },
{ from: 'build-api', to: 'integrate', type: 'FS' },
{ from: 'build-ui', to: 'integrate', type: 'FF' },
{ from: 'integrate', to: 'qa', type: 'FS' },
{ from: 'qa', to: 'launch', type: 'FS' },
{ from: 'launch', to: 'handover', type: 'SF' },
];
Hold it in a grid
The grid holds the task list, and it is the single source of truth the other two views read from and write to. It is editable in its own right: change a name, an owner or a stage in a cell and it commits like any grid. The start and duration columns are the schedule the timeline draws; the Start column shows each day as a date so the list reads the way the timeline does.
// Show a day-number as a short calendar date, so the Start column reads the way
// the timeline does.
function asDate(day) {
if (day == null || day === '') return '';
return new Date(day * 86400000).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' });
}
// The grid is the single source of truth. Both the timeline and the board bind
// to it, so an edit anywhere writes back here and the other views follow. The
// start and duration columns are the schedule the Gantt reads and, on a drag,
// writes back to.
var grid = LatticeGrid.createGrid(document.getElementById('tasks'), {
rowKey: 'id',
theme: 'light',
rows: tasks,
rowHeight: 32,
columns: [
{ field: 'name', title: 'Task', layout: { width: 150 }, edit: { enabled: true, editor: 'text' } },
{ field: 'assignee', title: 'Owner', layout: { width: 84 }, edit: { enabled: true, editor: 'text' } },
{ field: 'stage', title: 'Stage', layout: { width: 92 }, edit: { enabled: true, editor: 'text' } },
{
field: 'start', title: 'Start', type: 'number', layout: { width: 82 },
cell: { render: function (p) { return asDate(p.value); } }, // day-number shown as a date
edit: { enabled: true, editor: 'number' },
},
{ field: 'duration', title: 'Days', type: 'number', layout: { width: 56 }, edit: { enabled: true, editor: 'number' } },
],
edit: { enabled: true },
});
Draw the schedule
Now the timeline. The Gantt does not hold its own copy of the work: it consumes the grid. Hand it the tasks and dependencies to schedule, and the grid to write back to. It runs a critical path calculation over the tasks, honouring every link type and lag, and works out each task's earliest and latest start, its slack, and the chain with no slack at all: the critical path, the run of tasks that decides the end date. It recomputes on every edit.
// The Gantt consumes the grid: it schedules the tasks and draws the timeline,
// and a drag on a bar writes the new dates back through the grid's edit path.
// autoSchedule cascades the change down the dependency chain, and the critical
// path is recomputed and highlighted on every edit.
var gantt = LatticeGridGantt.createGantt({
tasks: tasks,
dependencies: dependencies,
grid: grid, // bind to the grid for write-back
columns: { start: 'start', duration: 'duration' }, // task field -> grid column
projectStart: day0, // anchors the calendar axis
autoSchedule: true,
});
gantt.mount(document.getElementById('timeline'), {
editable: true, // drag a bar to move it, drag its edge to resize it
label: 'name', // draw the task name on each bar
dateAxis: true, // axis ticks as calendar dates
nonWorking: 'weekends', // shade Saturdays and Sundays
zoom: 'week',
});
// Keep the two panes aligned as either one scrolls.
gantt.view.linkVerticalScroll(grid.element);
Mount it and you get a timeline on a calendar scale: a bar per task with its progress filled in, dependency arrows between them, the critical path called out, a milestone drawn as a diamond, a today line, and weekends shaded. The bars are editable. Drag a bar to move a task or drag its edge to resize it, and the new dates travel back through the grid's own edit path to the one task list, so the schedule cascades down the dependencies and the board sees the change too. A task dragged earlier than its predecessors allow is flagged rather than quietly accepted.
Because the schedule is a plain calculation, you can run it on its own with no timeline at all, for a report or a test.
// The schedule engine is a pure function you can call on its own, with no DOM:
var s = LatticeGridGantt.computeSchedule(tasks, dependencies, { projectStart: day0 });
console.log('project length in days:', s.projectDuration);
console.log('critical path:', s.critical.join(' -> '));
// A dependency cycle is refused with a code, never thrown or looped:
// s.ok === false and s.error.code names why.
Add the board
The board is the same work seen as cards, grouped into columns by the stage property. It binds to the same grid, so it is not a second dataset: it is another window on the first. Configure the columns you want, in the order you want, and set a work in progress limit on any of them to flag a column that is carrying too much. Each card shows the fields you name.
// The board is the same task list seen as cards, grouped into columns by the
// 'stage' property. It binds to the same grid, so dragging a card to another
// column writes the new stage back through the grid, and the grid and timeline
// update to match. Dragging within a column writes the card order.
var board = LatticeGridKanban.createKanban(document.getElementById('board'), {
grid: grid, // bind to the live grid
rowKey: 'id',
columnProperty: 'stage', // the group-by property
orderProperty: 'ord', // written when a card is reordered in a column
columns: [
{ id: 'todo', title: 'To do' },
{ id: 'doing', title: 'In progress', wipLimit: 2 },
{ id: 'done', title: 'Done' },
],
card: { title: 'name', subtitle: 'assignee' },
});
Drag a card to another column and the board writes the new stage back through the grid, so the task list updates and the timeline follows. Drag a card within a column and it writes the new order. The move is fully keyboard driven too, and a card carries its own count and rolls up toward the columns you mark as done. It is the same write-back path the grid uses for an inline edit, so the one pipeline owns applying the change and rolling it back if it is refused.
One dataset, kept in sync
That is the whole app: a grid, a timeline and a board, all over one task list. Rename a task in the grid and it changes on the board and the timeline. Drag a bar to reschedule and the dates change in the grid. Move a card to done and its stage changes everywhere. No view holds a private copy, and nothing has to be reconciled, because there is only one set of rows and every view reads and writes it. Run it in the sandbox and edit any part of it live.
Take it further
The app you built is the foundation. The same two views carry the rest of what a planning tool tends to grow into, off the one task list:
- Group the work into phases. Name a task as another's parent and it becomes a summary that spans its children, so a phase shows its own dates and rolled up progress while the tasks under it schedule themselves.
- Give the board swimlanes. Band the columns by owner or by phase, so a card sits at the crossing of its lane and its stage, and a drag across lanes reassigns it.
- Drive it from a live feed. Because both views take rows through the same update contract a grid does, a Data Router can drive the whole screen from one stream, so a change made elsewhere appears on the timeline and the board without a reload.
- Hand the plan off. Export the scheduled tasks, or serialise the drawn timeline as an image, for the version that goes in a report or a deck.
What you built
One task list became a working planner: a schedule that computes its own critical path and redraws on every edit, and a board of the same work that moves with it. Each view is simple because it only ever reads and writes rows, and the app stays honest because there is one source of truth behind all of it.
Next, see the Data Router for driving a screen like this from a live feed, or browse the demo catalogue for the grid features each view here is built on.