🏆 Second Place Winner — Build4Students Hackathon 2026
🔗 Try it out here | 🎨 Devpost Submission
A productivity application that helps you plan, schedule, and track your work. It goes beyond a simple to-do list by automatically arranging tasks into your calendar based on when you actually focus best.
Note: “Vellum” is a fine-quality parchment historically used for important manuscripts.(❁´◡`❁)
- Overview
- How the Scheduler Works (with example)
- Calendar Layout Algorithm
- AI Task Breakdown
- Project Structure
- Database Schema
- API Reference
- Technology Stack
- Getting Started
- Contributing
Most productivity tools treat all tasks as equal and ignore the fact that your energy changes throughout the day. Vellum addresses this by sitting between your task list and your calendar.
The system does three things:
- Breaks down big tasks into smaller steps using AI, so you know where to start.
- Schedules those tasks automatically into time slots where you historically focus best.
- Tracks your focus sessions so the scheduler gets smarter over time.
When you press the "Smart Schedule" button, here is exactly what happens
Before doing anything, the scheduler clears stale data:
- Any past instances that were never completed get marked as
missed. - Any future un-pinned instances get deleted. Pinned instances (ones you manually placed) are left alone.
This means each schedule run starts fresh, without duplicating old entries.
All active tasks (not completed, not archived) are sorted. The sort works on two levels:
- Priority first. High (weight 3) before Medium (weight 2) before Low (weight 1).
- Deadline second. If two tasks have the same priority, the one with the earlier deadline goes first.
This means a High-priority task due tomorrow will always be scheduled before a Low-priority task due next week.
The scheduler calls a Postgres function (get_user_peak_hours) that counts how many focus sessions you have logged at each hour of the day, using your timezone. This produces an array of 24 numbers — one per hour.
For example, if you tend to log work at 10 AM, 11 AM, and 2 PM, those hours will have higher counts. If you have never logged any sessions, all hours are treated equally (score of 5).
For each day (default: 3 days ahead), the scheduler:
- Reads your availability window for that day of the week (e.g., Monday 9:00–17:00).
- Breaks that window into 15-minute slots.
- Scores each slot using the energy profile from Step 3.
- Sorts slots by score, highest first.
This means the best hours are tried first.
For each task, the scheduler walks through the scored slots and tries to place it. A slot is accepted only if all of these checks pass:
| Check | What it does |
|---|---|
| Fits in window | The task's duration does not run past the end of your availability |
| Before deadline | The slot ends before the task's deadline |
| No collision | The slot does not overlap with any already-scheduled instance |
| Spacing respected | The slot is far enough from the same task's other sessions (default: 60 minutes apart) |
If a slot passes all four checks, the task is placed there. If the task allows multiple sessions per day (targetSessionsPerDay), the scheduler continues looking for more slots that same day.
Imagine you have two tasks:
- Task A: "Write essay" — High priority, deadline in 2 days, estimated 60 minutes, 1 session/day.
- Task B: "Review notes" — Medium priority, no deadline, estimated 30 minutes, 2 sessions/day.
Your availability is 9:00 AM to 5:00 PM, and your energy profile shows you focus best at 10 AM and 2 PM.
Here is what happens:
1. Tasks are sorted: [Task A (high), Task B (medium)]
2. Energy profile scores (simplified):
10 AM = 8, 2 PM = 7, 9 AM = 3, 11 AM = 4, ...
3. Scored slots for Day 1 (sorted by energy):
10:00 (score 8), 10:15 (score 8), 10:30 (score 8), 10:45 (score 8),
14:00 (score 7), 14:15 (score 7), ...
4. Place Task A (60 min, 1 session/day):
- Try 10:00–11:00 → no collision, no spacing issue → PLACED
- 1 session done for today, move on.
5. Place Task B (30 min, 2 sessions/day):
- Try 10:00 → collision with Task A → skip
- Try 10:15 → collision with Task A → skip
- ...
- Try 11:00–11:30 → no collision → PLACED (session 1)
- Try 14:00–14:30 → no collision, 3 hours from session 1
(spacing = 60 min, 3 hours > 60 min) → PLACED (session 2)
- 2 sessions done for today, move on.
Result for Day 1:
10:00–11:00 Task A "Write essay"
11:00–11:30 Task B "Review notes"
14:00–14:30 Task B "Review notes"
The same process repeats for Day 2 and Day 3.
When you open the calendar day view, the frontend needs to display overlapping events side by side (like Google Calendar does). This is handled by the positionedInstances function in frontend/src/views/CalendarView.tsx.
The algorithm runs in three passes over the day's instances (already sorted by start time):
Pass 1 — Pre-parse dates. Every instance's start and end strings are converted to numbers (milliseconds) once. This avoids creating new Date() objects repeatedly inside loops.
Pass 2 — Build clusters. Walk through instances in order. If the current instance starts before the running clusterMaxEnd, it belongs to the current cluster (it overlaps with something). Otherwise, start a new cluster. The key detail: clusterMaxEnd is updated with a simple comparison (if iEnd > clusterMaxEnd), not by re-scanning the whole cluster.
Pass 3 — Assign columns. For each cluster, assign instances to columns. A column tracks only the end-time of the last item placed in it (a single number, not an array of objects). For each instance, try existing columns. If the instance starts after a column's end-time, it fits in that column. Otherwise, create a new column.
| Step | Time Complexity | Why |
|---|---|---|
| Pre-parse dates | O(N) | One pass, one Map.set() per instance |
| Build clusters | O(N) | One pass, one comparison per instance |
| Assign columns | O(N x C) | N = instances, C = max overlapping columns (usually 2–3) |
| Total | O(N) in practice | C is bounded by screen width, so N x C is effectively linear |
(!) The worst can be O(N^2) if we re-scanned the entire cluster and created a new Date object for every item on every iteration.
When you add a task and choose "AI Breakdown", the backend sends your task description to Groq (Llama 3.3 70B) and asks it to split the task into 1–5 smaller steps.
The AI adjusts its time estimates based on the skill level you select:
| Skill Level | Multiplier | Chunk Detail |
|---|---|---|
| Total Novice / Beginner | 1.5x longer | More detailed, smaller steps |
| Intermediate | 1x (baseline) | Standard chunking |
| Advanced / Master | 0.7x shorter | Broader, fewer steps |
Each user is limited to 3 AI calls per day. This is enforced at the database level using a Postgres function (increment_ai_usage). The function inserts a usage row, counts today's rows, and raises an exception if the count exceeds 3. This is done atomically to prevent race conditions where two simultaneous requests could both slip through.
vellum/
├── backend/
│ └── src/
│ ├── ai/ # AI task breakdown (Groq/Llama)
│ │ ├── ai.service.ts # Prompt, parsing, rate limit check
│ │ ├── ai.controller.ts # POST /ai/classify-task
│ │ └── dto/ # Request/response shapes
│ ├── scheduler/ # Auto-scheduling engine
│ │ ├── scheduler.service.ts # All scheduling logic
│ │ └── scheduler.controller.ts # POST /scheduler/schedule
│ ├── tasks/ # Task CRUD and instances
│ │ ├── tasks.service.ts # Database operations
│ │ ├── tasks.controller.ts # REST endpoints
│ │ ├── dto/ # Validation classes
│ │ └── types.ts # TypeScript interfaces
│ ├── auth/ # JWT guard, user extraction
│ ├── supabase/ # Supabase client factory
│ └── main.ts # App bootstrap
│
├── frontend/
│ └── src/
│ ├── views/
│ │ ├── CalendarView.tsx # Day/week calendar with layout algorithm
│ │ ├── JournalView.tsx # Task list ("sketchbook" style)
│ │ ├── AnalysisView.tsx # Charts and productivity insights
│ │ ├── ArchiveView.tsx # Completed/archived tasks
│ │ └── GuideView.tsx # Onboarding help
│ ├── components/
│ │ ├── tasks/TaskCard.tsx # Individual task card
│ │ └── tasks/ChunkPanel.tsx # Sub-task management
│ ├── hooks/
│ │ ├── useTasks.ts # All task operations (add, update, delete, schedule)
│ │ └── useSound.ts # UI sound effects
│ ├── services/
│ │ ├── api.ts # HTTP calls to backend
│ │ └── supabase.ts # Supabase client
│ ├── types/index.ts # Shared TypeScript types
│ └── App.tsx # Root component, routing, auth
| Table | Purpose | Key Columns |
|---|---|---|
tasks |
Stores every task | id, description, priority, skill_level, deadline, estimated_time, status, total_time_seconds |
chunks |
Sub-tasks within a task | id, task_id, chunk_name, duration, completed |
task_instances |
Scheduled calendar slots | id, task_id, user_id, start_time, end_time, status, is_pinned |
progress_logs |
Logged focus sessions | id, task_id, user_id, start_time, end_time, duration_seconds |
user_preferences |
Availability windows, settings | user_id, available_hours (JSON), auto_schedule, sound_enabled |
ai_usage |
Tracks daily AI calls | user_id, created_at |
| Status | Meaning |
|---|---|
scheduled |
Placed on calendar, not yet done |
completed |
User finished the session |
missed |
The time passed without the user starting |
skipped |
User manually removed it |
The task_instances table has two indexes for fast lookups:
idx_task_instances_user_timeon(user_id, start_time, end_time)— used when the scheduler checks for collisions.idx_task_instances_parent_idon(task_id)— used when deleting a task and its instances.
All endpoints require a Bearer token in the Authorization header. The token comes from Supabase Auth.
| Method | Endpoint | What it does |
|---|---|---|
| GET | /tasks |
Get all tasks for the current user, including chunks, logs, and instances |
| POST | /tasks |
Create or update a task (upsert). Send the full task object. |
| DELETE | /tasks/:id |
Delete a task and all its related data (chunks, instances, logs) |
| Method | Endpoint | What it does |
|---|---|---|
| DELETE | /tasks/chunk/:id |
Delete a single chunk |
| Method | Endpoint | What it does |
|---|---|---|
| POST | /tasks/instance |
Create a manually pinned instance |
| DELETE | /tasks/instance/:id |
Remove an instance from the calendar |
| PATCH | /tasks/instance/:id/pin |
Pin or unpin an instance |
| Method | Endpoint | What it does |
|---|---|---|
| POST | /tasks/log/:taskId |
Log a completed focus session |
| Method | Endpoint | What it does |
|---|---|---|
| GET | /tasks/preferences |
Get user availability hours and settings |
| POST | /tasks/preferences |
Update availability hours and settings |
| Method | Endpoint | What it does |
|---|---|---|
| POST | /scheduler/schedule |
Run the auto-scheduler. Accepts { timezone, daysToSchedule } |
| Method | Endpoint | What it does |
|---|---|---|
| POST | /ai/classify-task |
Break a task into chunks. Accepts { task_description, skill_level } |
| Technology | What it does here |
|---|---|
| React 19 | UI framework |
| TypeScript | Type safety across the entire frontend |
| Tailwind CSS | Custom design system with a "sketchbook" theme |
| Framer Motion | Page transitions and micro-animations |
| Recharts | Focus distribution, velocity, and energy charts |
| Supabase JS | Authentication (login, signup, session management) |
| Technology | What it does here |
|---|---|
| NestJS | REST API framework with dependency injection |
| TypeScript | Type safety across the entire backend |
| Supabase | PostgreSQL database, Row Level Security, auth token verification |
| Groq SDK | AI inference (Llama 3.3 70B) for task breakdown |
| Luxon | Timezone-aware date math in the scheduler |
- Node.js 18 or higher
- A Supabase project (free tier works)
- A Groq API key (for AI features)
git clone https://github.com/emanalytic/vellum.git
cd vellumcd backend
npm installCreate a .env file in the backend/ folder:
PORT=3000
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
API_KEY=your-groq-api-key
Start the backend:
npm run start:devcd ../frontend
npm installCreate a .env file in the frontend/ folder:
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key
VITE_API_URL=http://localhost:3000
Start the frontend:
npm run devThe application will be available at http://localhost:5173.
Contributions are welcome. Here is how to get started:
- Fork the repository and clone your fork locally.
- Create a branch for your change (
git checkout -b fix/your-change). - Make your changes. See the table below to find the right files.
- Test locally run both the backend (
npm run start:dev) and frontend (npm run dev) and make sure nothing is broken. - Commit with a clear message describing what you changed and why.
- Open a pull request against
main.
- Keep it simple. Plain code is better than clever code.
- If you add a new backend endpoint, add the matching call in
frontend/src/services/api.ts. - Run
npx tsc --noEmitin bothbackend/andfrontend/before pushing to catch type errors early.
Author: Eman Nisar (@emanalytic)
