Project schedules, from the REPL to the browser — over the same data, live.

A Gantt chart and a kanban board served by your own Julia session. The model and the engine are ordinary Julia, so a plan is not a document you export from: it is something you compute with.

  • Julia ≥ 1.10 one process serves the API and the browser
  • No build step plain JS and CSS — no framework, no node_modules
  • CPM · PERT · levelling the arithmetic is in the package, not in the page
  • 2,699 checks 1,882 in Julia, 777 in jsdom, 40 in a real browser
  • Local-first JSON on your disk, a diffable .perth.jl beside it
  • MIT v0.16.1 — free to use, fork and extend
julia
julia> using Perth

julia> Perth.run()
  Perth running at http://localhost:8123
  Localhost only — the REPL stays free

I — The tour

A minute of the real thing

No mock-up and no motion graphics: a screen recording of Perth serving a plan for a water treatment plant, driven by hand.

What happens, in order: a task opened for editing — parent, dates, progress, cost, effort and the three-point estimate, which answers expected 5.8 d · σ 0.2 while you type · the zoom taken out until the whole plan fits · a task created from scratch, its start picked from the calendar · the chat in the corner, which every connected machine sees · and, at the end, the interface changed language on the spot. Perth speaks English, Portuguese, Spanish, French and Chinese, and nothing reloads.

II — Sixty seconds

A plan is a value, and the engine is a function

Everything the browser does is a call you can also make from the REPL — and the open page notices, and reloads on its own.

using Perth

p = create_project("Water treatment plant — expansion")

survey  = add_task!(p, "Topographic survey"; start = Date(2026, 9, 1),
                    duration = 5, assignee = "Clara", progress = 100)

design  = add_task!(p, "Hydraulic design"; start = Date(2026, 9, 8),
                    duration = 8, assignee = "Ana",
                    dependencies = [survey.id],
                    notes = "Check **NBR 12216** before sizing.")

# a commitment, not a plan: a deadline never moves the task,
# it turns the slack negative
add_task!(p, "Pipework and valves"; start = Date(2026, 11, 12),
          duration = 10, deadline = Date(2026, 11, 26))

schedule!(p)        # CPM: successors to their earliest date
critical_path(p)    # the chain with no slack
tasks(p)            # Tables.jl rows — straight into a DataFrame

Perth.run()         # and now look at it

Why a Gantt package in Julia

Because the browser is only one of the views. A schedule that lives in a spreadsheet cannot be joined against your measurements, and a desktop Gantt makes you export first.

using DataFrames

df = DataFrame(tasks(p))
combine(groupby(df, :assignee), :duration => sum => :days)

# the schedule reacts to your data, not the other way round
for row in eachrow(measurements)
    update_task!(p, row.id; progress = row.done_pct)
end
schedule!(p)
One wrinkle worth knowing. A variable you bound earlier is a snapshot. After editing in the browser, ask for the project again — project(id) hands you what the UI just saved, while p still holds what it held when you assigned it.

III — The chart

Every gesture is also a function call

Drag a bar to move a task, its right edge to resize, and between two bars to link them. Drag a row into the gap to reorder, or onto a task to make it a subtask — one gesture, two destinations.

Perth's Gantt chart: a fourteen-task plan with WBS summaries, milestones, dependency arrows, a shaded rainy-season band, a contract-deadline line and baseline ghost bars showing a two-day slip.
Summary rows roll up their children · milestones as diamonds · the ghost bar under a task is the baseline, and +2d is the slippage against it · the shaded stretch is a named calendar band, annotation that never moves a task.
  • Zoomday / week / month / fit (1–4), and Ctrl+wheel keeps the date under the pointer where it is
  • Marked daysdouble-click the ruler and name it: a line across the whole chart, for a date that matters to every task at once
  • Swimlanesgroup by person or team; collapsed summaries survive the reload
  • Highlight filterdims what does not match — or hides it outright (Only these, O) once scrolling past the grey is most of the reading
  • Notesthe red dot opens a note rendering **bold**, `code`, ~~strike~~ and links
  • Nothing overlapslines open a gap where they cross a label, and sideways names look for a free stretch — measured by a test, in a real browser, at four zooms
  • UndoCtrl+Z and Ctrl+Shift+Z, over drags as well as dialogs
  • PresentationP hides the furniture; D turns the lights down
The same plan in dark mode: quiet surfaces, the same bars and arrows.
The same plan, D. The interface is monospaced end to end, on purpose: Perth should look like the REPL it comes from.

IV — Reading the plan

The plan already knows; the question is whether it says so

Progress against the baseline, work against capacity, and the warnings a plan earns — each one a panel in the browser and a function in the REPL.

Warnings that were earned

Dependency cycle · past deadline · overdue · overallocation · over capacity · behind the baseline · starts before its dependencies allow.

A glossary in the app

Help → What the words mean: slack, critical path, baseline, bottleneck, P80 — in the words of someone who has to explain them at a site meeting.

Getting it out

Project (.perth.jl), tasks (CSV), milestones and deadlines (iCalendar), the chart (PNG), or a static figure through Makie. And CSV walks back in.


V — Under uncertainty

One number for a duration is a guess wearing a suit

Give three. PERT turns optimistic, most likely and pessimistic into an expected duration and a spread — and the estimates never move anything until you say so.

The task editor: name, dates, duration, assignee, progress, dependencies, deadline and the three-point estimate.
The same three numbers, in the dialog. Whatever you type here you could have typed in the REPL, and the other direction holds too.
set_estimate!(p, foundations.id, 9, 12, 22)

pert(p)                        # expected duration and σ, per task
pert_finish(p)                 # finish: expected, σ, P10/P50/P80/P90
finish_probability(p, Date(2026, 12, 10))
pert_date(p, 0.8)               # right four times out of five
pert!(p)                       # apply (o + 4m + p)/6 as the duration

The number the formula won't tell you

Analytic PERT assumes one critical chain. When several chains are nearly the same length, whichever runs late becomes critical, and the finish drifts later than any formula predicts.

sim = pert_simulate(p; n = 10_000)
sim.p80    # the date that survives 80% of the futures

The gap between pert_finish(p).p80 and sim.p80 is the cost of pretending there is only one critical path.


VI — The board

A kanban for the office, on the same network

Perth.kanban() starts a second, independent app on port 8150. WebSocket-authoritative end to end: every change is broadcast live, and each machine shows up as a labelled cursor anchored to a card, not to a pixel — so it survives different window sizes and zoom levels.

What happens, in order: cards dragged between the columns — dropped on done, a linked card completes the task in the Gantt, and back · a WIP limit set on a column, asked for as a number · the column's own menu: rename, sort by due date or by what came in last · the permission matrix, where the host says what each machine on the network may do · the board menu, from archived cards to metrics · and the share dialog, with the key and the link.

What a card holds

#tags, **markdown**, checklists, due dates, assignees, per-column WIP limits and an archive. A card also opens as a document — a description with lists and fenced code blocks, and screenshots pasted straight in with Ctrl+V, shrunk in the browser and stored by content, so the same picture pasted five times is one file.

kanban_from_project!(p)              # a plan becomes cards
kanban_add_card!("backlog", "Ship v1.0")
kanban_move_card!(id, "doing")
kanban_alias!("192.168.0.23", "Paulo")   # a name for a machine
kanban_cards() |> DataFrame            # (column, id, text) rows
The kanban board in dark mode: backlog, doing and done, with cards carrying assignees and due dates.
The host can restrict what a given machine may do — a matrix of 21 card and column actions against every IP that has connected, enforced server-side. A client cannot get around it by talking to the WebSocket directly; the UI merely hides what is denied.

VII — Sharing

A live switch, not a startup-only decision

By default Perth.run() is reachable from this machine only. Sharing turns on from the REPL, from the broadcast button in the menubar, or from File → Share / QR… — with the server already up and the browsers already open.

Perth.run(share = true)      # prints the LAN links (+ a QR code)
Perth.share!()              # start transmitting, server already up
Perth.share!(false)         # stop; remote browsers drop immediately
Perth.key!("obra-2026")     # require an access key from the network

# a link that only shows: a second key, granting reading
# and refusing writing — for the client, the director, the site
Perth.view_key!("just-looking")

Every connected machine shows up as a labelled cursor with its name and IP, pair-programming style, and there is a chat in the corner. Viewers appear as a hollow ring: present, not writing.

The refusal is the server's, decided by the method rather than by a list of routes, so a route added tomorrow is refused by default — including the door the interface does not use.

Security. Without a key, anyone on the network who knows the port can open and edit every project. A read-only link limits what a browser may do; it is not a login, and it is as private as the network it is on. Never expose the port to the internet.
The Share dialog: the LAN links, the access key and the read-only key.
With QRCoders loaded, the same dialog carries a QR code for the LAN link — and so does the terminal.

VIII — Where things live

One process, two doors, one file

Each project is a JSON file under ~/.perth. JSON is the machine format; .perth.jl is the interchange format for humans and version control.

A plan you received by e-mail cannot run code

Perth.load uses a restricted parser, not eval: only Project, GanttTask, Person, Band, Marker, MonthMark, Date and DateTime may be constructed. Any other call is refused.

Perth.save(p, "plans/plant.perth.jl")     # readable, diffable
q = Perth.load("plans/plant.perth.jl")
set_file_path!(p, "plans/plant.perth.jl")  # mirror on every save

Three suites keep it honest

1,882 tests in Julia (Pkg.test()) for the model and the engine; 777 checks in jsdom for DOM logic; and 40 in a real headless Chrome for geometry, event chains and overlap measurement — because a field that changed width while you typed, and a double click that never became a double click, both walked past jsdom without failing.

  • no framework
  • no build step
  • no node_modules
  • no cloud
  • no accounts

IX — Getting started

Two lines, and the plan is in front of you

using Pkg
Pkg.add("Perth")

using Perth
Perth.run()        # http://localhost:8123 — the REPL stays free
Perth.kanban()     # http://localhost:8150 — a board of its own

using Perth says so when a newer release is out — one dim line under the entry-point hint, read from the package registry already on your machine, with no network request of any kind.

Optional, and picked up automatically

Load them before Perth.run().

  • BusinessDaysbusiness-day calendars — set_calendar!(p, "Brazil"), so weekends and holidays stop counting
  • QRCodersa QR code for the LAN link, in the terminal and in the UI
  • CairoMakieany Makie backend: ganttplot and save_chart for static figures
Known limitations, said out loud. Not multi-user by identity — everyone on the network shares the same projects, and the access key is a door, not a login. Local-first by design: no cloud, no sync between machines beyond the LAN — the file is the sync. And level! is a heuristic, because the optimum is NP-hard.