A real-time, multi-user project & task tracker. Go REST + WebSocket API, React/TypeScript frontend, Postgres. Create projects, invite collaborators by email, plan work on a drag-and-drop board, a calendar or a Gantt-style timeline, and see who else is looking at the same project as it happens — live cursors, presence and typing indicators, over a real WebSocket hub, not polling.
Live: kairo.davida.ink
- JWT auth (register, login, bcrypt password hashing)
- Every project/task/comment route checks the caller is a member; mutating a project or its membership requires being the owner
- Full project CRUD, owned by the creator
- Owner is added as a project member automatically, in the same DB transaction as project creation
- Invite collaborators by email — this only works for people who already have a Kairo account; the UI says so up front rather than silently failing
-
Full task CRUD, nested under a project (
/projects/:id/tasks), with astart_date/due_daterange and status tracking (todo/in_progress/done) -
Drag-and-drop Kanban board (native HTML5 DnD — no extra dependency), color-coded by status, plus a status dropdown as a keyboard/accessible fallback
-
A task can never be read or modified through another project's URL, even by a member of that other project — verified both at the unit-test level and against a live backend
-
Timeline view: tasks rendered as date-scaled Gantt bars, color-coded by status, with an "unscheduled" list for anything missing dates
-
Calendar view: month grid with tasks on their due date, click any day to see everything due and add a new task straight onto it, drag a task to a different day to reschedule
- Threaded comments per task (
/projects/:id/tasks/:id/comments) - Delete authorization: a comment's own author, or the project owner (moderation)
- A WebSocket per project (
/api/projects/:id/ws,backend/internal/realtime) broadcasts presence, cursor position, and typing state to every other connected member - Presence: glowing avatars for everyone currently viewing the project, live join/leave
- Live cursors: colored pointer + name tag tracking collaborators' mouse position on the board in real time
- Typing indicators: animated dots while a teammate is writing a comment
- Authenticated the same way the REST API is (JWT), just carried as a
?token=query param since the browser's WebSocket handshake can't set anAuthorizationheader — validated, and project membership checked, before the connection is ever upgraded
-
Live charts over real task/project data: task status breakdown, tasks per project, upcoming deadlines — no fixture data, no fake charts
- React + TypeScript + Vite + Tailwind + TanStack Query
- Persistent app shell: sticky top nav, a GitHub-settings-style project sidebar (Board / Timeline / Calendar / Members / Settings), footer
- Login/register, dashboard, task detail modal (comments + typing indicator), member management UI, project settings (rename/delete) that hide owner-only actions from non-owners
Backend
- Go, Gin, PostgreSQL,
sqlc(typed queries),golang-migrate(applied automatically on boot — embedded in the binary, no separate migration step for a deploy) - JWT auth (
golang-jwt), bcrypt password hashing gorilla/websocketfor the real-time presence/cursor/typing channel- Clean layering:
handler → service → repository, one package per domain underbackend/internal/ - Docker + Docker Compose
Frontend
- React 19, TypeScript, Vite, Tailwind CSS v4
- TanStack Query for server state, React Router for routing, Axios for HTTP
- Native
WebSocketclient (no socket library) for presence/cursors/typing - Vitest + React Testing Library
Infra
- Backend: Render (Docker web service)
- Database: Neon (serverless Postgres)
- Frontend: Vercel (static Vite build)
- CI: CircleCI —
go build/go vet/gofmt/go test ./...on every push (seeDEPLOY.mdfor the full deploy writeup)
- Go 1.24+
- Node 20+
- Docker + Docker Compose
cd backend
cp .env.example .env # adjust values if needed - defaults work with docker-compose
docker-compose up --buildThis brings up Postgres and the API together, and the API migrates its
own schema on boot — no separate migration step needed. The API listens on
http://localhost:8080.
Run the backend test suite:
go test ./...cd frontend
npm install
cp .env.example .env.local # VITE_API_URL should point at the backend above
npm run devOpen http://localhost:5173. The dev server origin is already allowed by
the backend's CORS config (CORS_ALLOWED_ORIGINS in backend/.env) — the
same allow-list also gates the WebSocket handshake's Origin header.
Run the frontend test suite:
npm run testSee DEPLOY.md for the full Render + Neon + Vercel walkthrough.
frontend (React/Vite) --HTTP/JSON--> backend (Gin) --sqlc--> Postgres
--WebSocket--> internal/realtime (per-project hub)
The backend follows a handler → service → repository layering per domain
(auth, project, task, comment under backend/internal/).
Repositories wrap sqlc-generated queries; only project's repository
uses a transaction (creating a project and adding its owner as a member
atomically). Two Gin middlewares gate every project/task/comment route:
RequireProjectMember and RequireProjectOwner, both driven off the
:projectID URL param and the JWT-derived caller ID.
internal/realtime runs one Hub goroutine per project (created lazily,
evicted once empty), fanning presence_join / presence_leave / cursor
/ typing_start / typing_stop messages out to every other connected
client. All shared state is only ever touched from inside each hub's own
goroutine, driven by channels — no shared-memory locking bugs by
construction.
The frontend talks to the backend over REST for everything except live
presence, which uses one native WebSocket per project (opened once in
ProjectLayout, shared by every page under it via PresenceContext).
TanStack Query owns all server state and cache invalidation.
All routes are under /api. Auth column: public · requires a JWT · requires project membership · requires project ownership.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /auth/register |
public | Register a new user |
| POST | /auth/login |
public | Log in, get a JWT |
| GET | /protected/profile |
requires JWT | Demo route echoing the caller's claims |
| POST | /projects |
requires JWT | Create a project (caller becomes owner) |
| GET | /projects |
requires JWT | List projects the caller owns or belongs to |
| GET | /projects/:projectID |
requires project membership | Get a project |
| PATCH | /projects/:projectID |
requires project ownership | Update a project's name/description |
| DELETE | /projects/:projectID |
requires project ownership | Delete a project |
| GET | /projects/:projectID/members |
requires project membership | List project members |
| POST | /projects/:projectID/members |
requires project ownership | Add a member by email |
| DELETE | /projects/:projectID/members/:userID |
requires project ownership | Remove a member |
| POST | /projects/:projectID/tasks |
requires project membership | Create a task |
| GET | /projects/:projectID/tasks |
requires project membership | List a project's tasks |
| GET | /projects/:projectID/tasks/:taskID |
requires project membership | Get a task |
| PATCH | /projects/:projectID/tasks/:taskID |
requires project membership | Update a task's fields |
| PATCH | /projects/:projectID/tasks/:taskID/status |
requires project membership | Move a task between todo/in_progress/done |
| DELETE | /projects/:projectID/tasks/:taskID |
requires project membership | Delete a task |
| POST | /projects/:projectID/tasks/:taskID/comments |
requires project membership | Add a comment |
| GET | /projects/:projectID/tasks/:taskID/comments |
requires project membership | List a task's comments |
| DELETE | /projects/:projectID/tasks/:taskID/comments/:commentID |
requires project membership* | Delete a comment (*author or owner only) |
| GET | /projects/:projectID/ws |
requires project membership** | WebSocket: presence, live cursors, typing (**auth via ?token=, not a header) |
Deliberately out of scope for now:
- Labels/tags, task priority, task dependencies
- Idea-capture flow (raw ideas → promoted into projects/tasks)
- Real email invites (adding a member by email currently requires them to already have an account — no invite email is sent)
- OpenAPI/Swagger docs
This project is licensed under the MIT License.



