A collaborative Pomodoro for teams. Everyone joins a room, runs their own focus and break cycles, and the whole team sees each other's state live — who's in focus, who's on a break, and who marked "do not disturb." Not surveillance: a shared, respectful rhythm.
Stack: Docker · NestJS (REST + WebSocket) · Angular 17 · PostgreSQL · Socket.io · JWT
Small teams and study groups work side by side but constantly break each other's concentration. FocusTeam was built to improve the team's organizational climate and protect individual focus. Each person controls their own Pomodoro, while the group can tell at a glance who's heads-down and who's free — so people reach out at the right moment instead of interrupting deep work.
The tool is in active, daily use and has been delivering results:
- Focus blocks are respected — fewer mistimed interruptions, because "do not disturb" is visible to everyone.
- Healthier organizational climate — async by default; questions go to the room chat instead of breaking someone's concentration.
- Availability at a glance — no more "are you busy?" pings; the live board answers it.
- A shared rhythm — breaks naturally line up, so conversation happens when people are actually free.
This project was built as a human + AI collaboration — pair-programming with an AI assistant from architecture through implementation and these docs. It's both a real internal tool and a portfolio piece showcasing an AI-assisted, full-stack workflow, end to end.
- JWT login — register and authenticate with e-mail/password (bcrypt + tokens).
- Rooms — create a room and invite the team with a 6-character code.
- Per-person timer — each member runs their own cycle (focus / short break / long break).
- Off-page alerts — Web Push notifies about messages and phase changes even when the tab is in the background or closed.
- Chat mentions — type
@to pick a person. Mentions are highlighted with a red counter in the main chat, the widget, and Picture-in-Picture. - Timer widget — on supported browsers, the timer can float in a Picture-in-Picture window; the remaining time also shows in the tab title.
- Per-person break settings — durations, sessions until the long break, and auto-start of breaks/focus. Settings are individual.
- Live presence — each person's card shows the current phase (🎯 focus, ☕/🌿 break, 💤 idle) and a countdown synchronized in real time for everyone.
- Available / Busy toggle — signal "🔕 do not disturb" to the team.
- Online/offline — anyone with the room open shows as online.
- Server is the source of truth for time — phase transitions happen on the backend
(via
setTimeout) and are broadcast over WebSocket, so everyone sees the same state even if a tab is closed.
Each room has a real-time chat (floating button at the bottom-right of the dashboard), organized as a conversations panel (messenger-style):
- # General — a channel with everyone in the room (always present).
- Direct messages (1:1) — talk privately with a member.
- Groups — create subgroups with the people you choose, with an optional name, and add/remove members later (the 👥 button at the top of the conversation).
- Truly private delivery — DMs and groups are sent only to participants (each user has a personal Socket.io room). General goes to the whole room.
- History (last 50 messages) loaded on join, unread counts per conversation, plus a total badge on the floating button.
- Notifications — toast preview, a short sound, and a browser notification (when the tab is in the background).
- Respects "do not disturb" — while you're marked Busy, all notifications are silenced automatically (the unread count keeps tracking, without noise).
- Bell 🔔/🔕 at the top of the chat to toggle notifications manually (saved in the browser).
Prerequisite: Docker + Docker Compose.
# 1. (optional) adjust variables — sensible defaults are provided
cp .env.example .env
# 2. bring everything up (Postgres + API + frontend)
docker compose up --buildThen open http://localhost:8080.
Services and ports:
| Service | Port | Description |
|---|---|---|
| frontend | 8080 | Angular served by nginx (proxies to the API) |
| backend | 3000 | NestJS API + Socket.io (/api, /socket.io) |
| db | 5432 | PostgreSQL 16 |
The frontend's nginx proxies
/apiand/socket.ioto the backend, so everything runs on the same origin (:8080) — no CORS headaches.
- Open http://localhost:8080 and create an account.
- Create a room and copy the code.
- Open a second incognito window (or another browser), create another account, and join via "Join by code."
- Start a timer in one window and watch the card change state in the other instantly. Toggle Available/Busy and see the signal update.
Run only the database in Docker and the apps locally:
# database
docker compose up db
# backend (port 3000)
cd backend
npm install
DB_HOST=localhost npm run start:dev
# frontend (port 4200, proxied to the backend)
cd frontend
npm install
npm startDev frontend: http://localhost:4200 (proxy.conf.json forwards /api and
/socket.io to localhost:3000).
Defined in docker-compose.yml (override via .env):
| Variable | Default | Use |
|---|---|---|
POSTGRES_USER |
pomodoro |
Postgres user |
POSTGRES_PASSWORD |
pomodoro |
Postgres password |
POSTGRES_DB |
pomodoro |
database name |
JWT_SECRET |
super-secret-change-me |
change in production |
JWT_EXPIRES_IN |
7d |
token lifetime |
CORS_ORIGIN |
* |
origin allowed by the API |
VAPID_PUBLIC_KEY |
development key | Web Push public key |
VAPID_PRIVATE_KEY |
development key | Web Push private key |
VAPID_SUBJECT |
mailto:admin@focusteam.local |
Push administrator contact |
For production, generate a new VAPID pair and set it in .env. Web Push requires HTTPS,
except on localhost, where it works without a certificate. Permission is only requested
when a person clicks Enable alerts inside a room.
┌────────────┐ HTTP /api ┌───────────────────────────┐
│ Angular │ ────────────────▶ │ NestJS │
│ (nginx) │ WS /socket.io │ • Auth (JWT) │ ┌────────────┐
│ :8080 │ ◀───────────────▶ │ • Rooms / Memberships │ ──▶ │ PostgreSQL │
└────────────┘ │ • TimerGateway (Socket.io)│ │ :5432 │
│ • TimerService (ticker) │ └────────────┘
└───────────────────────────┘
Backend (/backend) — NestJS 10 + TypeORM
auth/— registration, login, JWT strategy and guard.users/— user entity + service.rooms/—RoomandMembershipentities (presence + per-person timer state/config), REST to create/join/list rooms, and room snapshot building.timer/—TimerService(phase-transition rules + scheduling viasetTimeout, re-arms timers after a restart) andTimerGateway(Socket.io: join, timer actions, presence, config; broadcastsroom:stateto the room).chat/—conversations,conversation_participants, andmessagesentities + service.push/— Web Push subscriptions + service.
Frontend (/frontend) — Angular 17 standalone + signals
core/—AuthService, JWT interceptor + guard,ApiService,SocketService.pages/login— login/registration.pages/rooms— room list, create, and join by code.pages/room— dashboard: personal control panel (progress ring, start/pause/skip/reset, break settings, available/busy toggle) + live member grid.components/—MemberCardComponentandTimerConfigComponent.
| Event (client → server) | Payload |
|---|---|
room:join |
{ roomId } |
room:leave |
{ roomId } |
timer:action |
{ roomId, action } (start/pause/skip/reset) |
presence:set |
{ roomId, status } (available/busy) |
config:update |
{ roomId, config } |
chat:open |
{ conversationId } |
chat:send |
{ conversationId, text } |
chat:createConversation |
{ roomId, kind, participantIds, name? } |
chat:addParticipants / chat:removeParticipant |
{ conversationId, ... } |
| Event (server → client) | Payload |
|---|---|
room:state |
full room snapshot |
chat:conversations |
the user's conversation list |
chat:conversation / chat:conversationOpened |
a single conversation |
chat:history |
recent messages on open |
chat:message |
new message (broadcast to participants) |
error:auth / error:room |
error message |
TypeORMruns withsynchronize: trueto keep the MVP simple (it creates the schema automatically). In production, disable it and use migrations.- The JWT token is stored in the browser's
localStorage. - The floating window uses Document Picture-in-Picture, available mainly on Chrome and Edge desktop; on other browsers the tab title keeps showing the remaining time.
- The timer is server-authoritative: when a phase expires the backend transitions and
re-emits state, so the front-end countdown is just a rendering of the received
phaseEndsAt. - If something looks off after an update, run
docker compose down -vonce to reset the database, then bring it back up.
Built with care to help a real team focus — and as a portfolio demonstration of an AI-assisted, full-stack workflow.