Projects Feature Guide
Guide to the Projects module — lightweight containers that group Tasks (and optionally Habits) under a named outcome with an optional target date.
Overview
A Project is a user-owned bucket with a name, free-form note, and optional targetDateTime. Tasks and Habits each carry a nullable project_id foreign key, so any task or habit can be assigned to at most one project, and a project lists everything currently attached to it. Projects themselves have no state machine — they are either ACTIVE or soft-deleted (status = DELETED) — and the screen is primarily a read/edit list with linked-task previews. The dashboard surfaces a per-project status card (open/done counts this week, linked totals, traffic-light cardStatus) so the user can see which projects have momentum without opening each one.
The Work module has an unrelated concept it sometimes also calls “project” (WorkProjectStatus on a client track) — those are not stored in the Projects table and are out of scope for this guide.
Architecture
ProjectsViewModel subscribes to DomainEvent.Task* so completing, creating, rescheduling, or deleting a task on any screen forces the projects list to reload — the per-project task previews stay in sync without a manual refresh. The Web page does not subscribe; it reloads on its own button or route mount.
Data Models
Server entity: Projects (name varchar(200), note varchar(500), target_date_time nullable, user_id cascade, status enum default ACTIVE). Both Tasks.project and Habits.project are optional references with ReferenceOption.SET_NULL — deleting a project (soft or hard) leaves its children intact and unassigned.
ProjectDTO (server → client)
| Field | Type | Notes |
|---|---|---|
id | String (UUID) | |
name | String | Required on create, validated non-blank |
note | String | Defaults to empty string |
targetDateTime | String? | dd/MM/yyyy HH:mm (see formatDateTime) |
tasks | List<TaskDTO> | Populated by ProjectService.listActive / getByIdForUser via TaskService.listActiveTasksByProject — only ACTIVE tasks for the requesting user |
CreateProjectDTO requires name; UpdateProjectDTO is fully patch-style (any field omitted = leave unchanged). The server has no clearFields concept for projects — there is currently no way to clear targetDateTime once set via the PATCH endpoint (dto.targetDateTime?.let { … } only assigns when non-null).
ProjectDashboardDTO
Built by DashboardService.loadProjectsSlice from the active project list plus four aggregate queries per slice. cardStatus is a traffic light derived from tasksPendingThisWeek via cardStatusFromPending(pending, goodThreshold = 2, mediumThreshold = 6) → "good", "medium", or "bad".
| Field | Source |
|---|---|
linkedTasksCount | TaskService.countActiveTasksForProjects |
linkedHabitsCount | HabitService.countActiveHabitsForProjects |
tasksPendingThisWeek | TaskService.countPendingTasksForProjectsInWeekWithSmartFiltering |
tasksCompletedThisWeek | TaskService.countCompletedTasksForProjectsInWeek |
habitCompletionsThisWeek | HabitService.countHabitCompletionsForProjectsInWeek |
Each slice respects feature flags: if TASKS is off, task counts are zero; if HABITS is off, habit counts are zero; if PROJECTS is off, the slice returns an empty list.
API Endpoints
All endpoints are JWT-authenticated and scoped to the logged-in user. Soft delete only — DELETE flips status to DELETED and rows stop appearing in list/get.
| Method | Path | Description |
|---|---|---|
GET | /projects | List active projects (limit default 50, offset default 0). Each row is hydrated with its active tasks. Ordered by name ASC. |
GET | /projects/{id} | Get a single project (active only) with its active tasks. 404 if missing/soft-deleted/not owned. |
POST | /projects | Create from CreateProjectDTO. 400 if name is blank, 201 Created on success (response body is empty — clients reload to pick up the new row). |
PATCH | /projects/{id} | Update from UpdateProjectDTO. 200 on success, 404 if not found / not owned / already deleted. |
DELETE | /projects/{id} | Soft-delete (status = DELETED). 200 on success, 404 if no such row for the user (deletes work even on already-deleted projects since ownership lookup ignores status). |
All mutations write to history_track via insertHistoryTrack so deletes are auditable.
State Machine
There is no “complete” or “archive” state; the only lifecycle is create → (edit)* → soft delete. The Status enum is shared with other entities (Tasks, Habits, …) but only ACTIVE and DELETED are used here.
Client Behavior
The desktop ProjectsScreenDestination keeps the project list and a parallel TasksViewModel open so the linked-task card can perform full task operations (check, edit, reschedule, add new task with projectId prefilled) inline. The tasksByProjectId map shown in the UI is derived from project.tasks returned by the server — not from a separate tasks fetch — so revalidation always goes through loadProjects().
On Web (projects/page.tsx) the task list is built differently: the page fetches all tasks via tasksApi.getAll({ limit: 500 }) and groups them by projectId client-side, because the page also wants to show overdue/priority context that lives only on the full task list. There is no event-bus reload — refresh is manual.
Key Files
| File | Purpose |
|---|---|
server/.../database/entities/Project.kt | Exposed Projects table + Project entity |
server/.../models/projects/ProjectDTO.kt | ProjectDTO, CreateProjectDTO, UpdateProjectDTO |
server/.../database/converters/ProjectConverters.kt | Project.toProjectDTO() (entity → DTO; does not include tasks) |
server/.../routing/ProjectRouting.kt | REST endpoints (/projects) |
server/.../repository/ProjectRepository.kt | Thin pass-through over ProjectService |
server/.../service/ProjectService.kt | Active list / getById / create / update / soft delete; joins active tasks via TaskService |
server/.../service/DashboardService.kt (loadProjectsSlice, ~line 1045) | Per-project weekly aggregates + cardStatus |
server/.../models/dashboard/ProjectDashboardDTO.kt | Dashboard payload |
shared/.../models/Project.kt | KMP Project, CreateProjectRequest, UpdateProjectRequest |
shared/.../services/projects/ProjectApiService.kt | KMP Ktor HTTP client wrapping ApiOutcome |
shared/.../viewmodel/ProjectsViewModel.kt | Shared (Android/iOS) view model — event-bus driven reload |
shared/.../ui/components/ProjectBadgeRow.kt | Inline ”📁 ProjectName” chip shown on task/habit list rows |
composeApp/.../ui/viewmodels/ProjectsViewModel.kt | Desktop variant of the VM (uses viewModelScope) |
composeApp/.../ui/screens/ProjectsScreen.kt | Desktop projects list, stat cards, linked-tasks panel |
composeApp/.../ui/composables/NewEditProjectDialog.kt | Create / edit dialog (desktop) |
composeApp/.../ui/navigation/ProjectsScreenDestination.kt | Desktop nav destination — wires ProjectsViewModel + TasksViewModel + TagsViewModel |
web-frontend/src/app/[locale]/projects/page.tsx | Web projects list + create/edit modal |
web-frontend/src/lib/api/projects.ts | Web Axios client |
web-frontend/src/components/common/ProjectNameBadge.tsx | Web project chip used on task / habit / dashboard rows |
web-frontend/src/components/dashboard/ProjectsDashboardSection.tsx | Web dashboard card list |
Troubleshooting
Linked-task counts on the projects screen are stale
- Desktop reloads on
DomainEvent.Task*events — confirm whoever mutated the task emitted the event (e.g.TaskViewModel.changeCheckTaskshould publishTaskCompleted). Without the event the screen keeps the prior snapshot. - Web does not subscribe to the event bus; the user must hit Refresh or re-enter the route.
Project’s targetDateTime won’t clear
UpdateProjectDTO.targetDateTimeisString?and the service only writes when the value is non-null. There is currently no equivalent of Tasks’clearFields— sendingnullleaves the existing value in place. Either pick a sentinel date or extend the DTO before exposing a “clear target” affordance.
Project disappears but its tasks remain in lists
- That is intentional:
Tasks.project/Habits.projectuseReferenceOption.SET_NULL, so soft-deleting a project leaves its tasks and habits in place without a project assignment. TheprojectNamechip on those rows simply vanishes.
Dashboard card shows bad status even though I just completed everything
cardStatusis computed fromtasksPendingThisWeekagainst fixed thresholds (<=2good,<=6medium, else bad). It does not factor in completions. If the week still has many smart-filtered pending tasks, the card stays red regardless of recent done counts.
POST /projects returns 400
- The only validation is
name.isBlank(). Trim client-side before sending — the desktop / shared VMs already do this.
GET /projects/{id} returns 404 right after a delete
- Expected — soft-deleted projects (
status = DELETED) are excluded from both list and getById. Use a fresh ID or restore the row directly in the DB if recovery is needed (there is no restore endpoint).
Related Docs
- Tasks — the primary children of a project;
projectIdfiltering lives onGET /tasks - Habits — also carry
project_id(SET_NULL); same lifecycle independence applies - Dashboard — how
ProjectDashboardDTOitems render in the projects strip - Domain & models — shared domain model reference