Skip to Content
FeaturesProjectsProjects Feature Guide

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)

FieldTypeNotes
idString (UUID)
nameStringRequired on create, validated non-blank
noteStringDefaults to empty string
targetDateTimeString?dd/MM/yyyy HH:mm (see formatDateTime)
tasksList<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".

FieldSource
linkedTasksCountTaskService.countActiveTasksForProjects
linkedHabitsCountHabitService.countActiveHabitsForProjects
tasksPendingThisWeekTaskService.countPendingTasksForProjectsInWeekWithSmartFiltering
tasksCompletedThisWeekTaskService.countCompletedTasksForProjectsInWeek
habitCompletionsThisWeekHabitService.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.

MethodPathDescription
GET/projectsList 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/projectsCreate 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

FilePurpose
server/.../database/entities/Project.ktExposed Projects table + Project entity
server/.../models/projects/ProjectDTO.ktProjectDTO, CreateProjectDTO, UpdateProjectDTO
server/.../database/converters/ProjectConverters.ktProject.toProjectDTO() (entity → DTO; does not include tasks)
server/.../routing/ProjectRouting.ktREST endpoints (/projects)
server/.../repository/ProjectRepository.ktThin pass-through over ProjectService
server/.../service/ProjectService.ktActive 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.ktDashboard payload
shared/.../models/Project.ktKMP Project, CreateProjectRequest, UpdateProjectRequest
shared/.../services/projects/ProjectApiService.ktKMP Ktor HTTP client wrapping ApiOutcome
shared/.../viewmodel/ProjectsViewModel.ktShared (Android/iOS) view model — event-bus driven reload
shared/.../ui/components/ProjectBadgeRow.ktInline ”📁 ProjectName” chip shown on task/habit list rows
composeApp/.../ui/viewmodels/ProjectsViewModel.ktDesktop variant of the VM (uses viewModelScope)
composeApp/.../ui/screens/ProjectsScreen.ktDesktop projects list, stat cards, linked-tasks panel
composeApp/.../ui/composables/NewEditProjectDialog.ktCreate / edit dialog (desktop)
composeApp/.../ui/navigation/ProjectsScreenDestination.ktDesktop nav destination — wires ProjectsViewModel + TasksViewModel + TagsViewModel
web-frontend/src/app/[locale]/projects/page.tsxWeb projects list + create/edit modal
web-frontend/src/lib/api/projects.tsWeb Axios client
web-frontend/src/components/common/ProjectNameBadge.tsxWeb project chip used on task / habit / dashboard rows
web-frontend/src/components/dashboard/ProjectsDashboardSection.tsxWeb 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.changeCheckTask should publish TaskCompleted). 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.targetDateTime is String? and the service only writes when the value is non-null. There is currently no equivalent of Tasks’ clearFields — sending null leaves 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.project use ReferenceOption.SET_NULL, so soft-deleting a project leaves its tasks and habits in place without a project assignment. The projectName chip on those rows simply vanishes.

Dashboard card shows bad status even though I just completed everything

  • cardStatus is computed from tasksPendingThisWeek against fixed thresholds (<=2 good, <=6 medium, 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).
  • Tasks — the primary children of a project; projectId filtering lives on GET /tasks
  • Habits — also carry project_id (SET_NULL); same lifecycle independence applies
  • Dashboard — how ProjectDashboardDTO items render in the projects strip
  • Domain & models — shared domain model reference