Compare commits

...

2 commits

Author SHA1 Message Date
a41611a86f Allow updating task status.
All checks were successful
Check / Backend (pull_request) Successful in 7m28s
Check / Frontend (pull_request) Successful in 1m58s
2025-10-27 21:14:09 -07:00
4552c347c6 Create a plan for implementing projects and also create the backend API. (#19)
Some checks failed
Check / Backend (push) Successful in 6m55s
Check / Frontend (push) Successful in 2m2s
Docker Build / Build and Push Backend Image (push) Failing after 11m21s
Docker Build / Build and Push Frontend Image (push) Successful in 5m44s
Creates projects to organize tasks under. (Note the migration also contains the tables for creating folders for projects as well because adding foreign keys is a PITA in sqlite apparently).

Reviewed-on: #19
Co-authored-by: Drew Galbraith <drew@tiramisu.one>
Co-committed-by: Drew Galbraith <drew@tiramisu.one>
2025-10-28 04:13:12 +00:00
19 changed files with 1128 additions and 19 deletions

View file

@ -51,7 +51,9 @@ jobs:
cargo test --locked -- --skip api cargo test --locked -- --skip api
working-directory: backend working-directory: backend
- name: "Integration Tests" - name: "Integration Tests"
run: cargo test --locked --test api run: |
mkdir -p reports/store
cargo test --locked --test api
working-directory: backend working-directory: backend
check-frontend: check-frontend:

2
backend/.gitignore vendored
View file

@ -1,5 +1,7 @@
target target
coverage coverage
reports
*.db *.db
*.db-shm *.db-shm

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE projects \n SET title=$1, color=$2, folder_id=$3, status=$4, sort_order=$5, updated_at=$6, completed_at=$7 \n WHERE id=$8",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "3fbe5a29facacd03360f017a3ab078c9ae067aafcaa2e7ca29d597c0a74ac48d"
}

View file

@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT MAX(sort_order) as max_order FROM projects;",
"describe": {
"columns": [
{
"name": "max_order",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
true
]
},
"hash": "93c31908b7409b034b143447cd8a1635f191f0abd4d547835c0f05aa403f96e0"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM projects WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "a5ba908419fb3e456bdd2daca41ba06cc3212ffffb8520fc7dbbcc8b60ada314"
}

2
backend/Cargo.lock generated
View file

@ -3500,9 +3500,11 @@ dependencies = [
"bitflags 2.9.3", "bitflags 2.9.3",
"bytes", "bytes",
"http 1.3.1", "http 1.3.1",
"http-body 1.0.1",
"pin-project-lite", "pin-project-lite",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]

View file

@ -12,7 +12,7 @@ chrono = { version = "0.4.41", features = ["serde"] }
serde = "1.0.219" serde = "1.0.219"
sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio", "uuid", "chrono", "migrate"] } sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio", "uuid", "chrono", "migrate"] }
tokio = { version = "1.47.1", features = ["rt-multi-thread", "tracing"] } tokio = { version = "1.47.1", features = ["rt-multi-thread", "tracing"] }
tower-http = { version = "0.6.0", features = ["cors"] } tower-http = { version = "0.6.0", features = ["cors", "trace"] }
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = "0.3.19" tracing-subscriber = "0.3.19"
uuid = { version = "1.18.0", features = ["serde", "v4"] } uuid = { version = "1.18.0", features = ["serde", "v4"] }

View file

@ -0,0 +1,17 @@
ALTER TABLE tasks RENAME TO tasks_old;
CREATE TABLE tasks (
id UUID PRIMARY KEY NOT NULL,
title VARCHAR NOT NULL,
description TEXT,
status TEXT CHECK(status IN ('todo', 'done', 'backlog')) DEFAULT 'todo',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP
);
INSERT INTO tasks SELECT id, title, description, status, created_at, updated_at, completed_at FROM tasks_old;
DROP TABLE tasks_old;
DROP TABLE projects;
DROP TABLE folders;

View file

@ -0,0 +1,39 @@
CREATE TABLE folders (
id UUID PRIMARY KEY NOT NULL,
title VARCHAR NOT NULL,
sort_order INTEGER NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE projects (
id UUID PRIMARY KEY NOT NULL,
title VARCHAR NOT NULL,
color VARCHAR(7) NOT NULL, -- hex code
folder_id UUID,
sort_order INTEGER NOT NULL UNIQUE,
status TEXT CHECK(status IN ('active', 'done', 'backlog')) DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
FOREIGN KEY(folder_id) REFERENCES folders(id)
);
ALTER TABLE tasks RENAME TO tasks_old;
-- Add up migration script here
CREATE TABLE tasks (
id UUID PRIMARY KEY NOT NULL,
title VARCHAR NOT NULL,
description TEXT,
status TEXT CHECK(status IN ('todo', 'done', 'backlog')) DEFAULT 'todo',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
project_id UUID,
FOREIGN KEY(project_id) REFERENCES projects(id)
);
INSERT INTO tasks SELECT *, NULL as project_id FROM tasks_old;
DROP TABLE tasks_old;

View file

@ -32,6 +32,7 @@ async fn main() {
let app = Router::new() let app = Router::new()
.route("/health", get(health)) .route("/health", get(health))
.nest("/api/tasks", services::create_task_router()) .nest("/api/tasks", services::create_task_router())
.nest("/api/projects", services::create_project_router())
.layer(cors) .layer(cors)
.with_state(pool); .with_state(pool);

View file

@ -1,3 +1,5 @@
mod project;
mod task; mod task;
pub use project::*;
pub use task::*; pub use task::*;

View file

@ -0,0 +1,122 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{Pool, Row, Sqlite};
use uuid::Uuid;
use crate::services::AppError;
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, sqlx::Type)]
#[serde(rename_all = "snake_case")]
#[sqlx(rename_all = "lowercase")]
pub enum ProjectStatus {
Active,
Done,
Backlog,
}
#[derive(sqlx::FromRow, Serialize)]
pub struct ProjectModel {
pub id: Uuid,
pub title: String,
pub color: String,
pub folder_id: Option<Uuid>,
pub sort_order: i64,
pub status: ProjectStatus,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
}
impl ProjectModel {
async fn get_new_sort_order(pool: &Pool<Sqlite>) -> anyhow::Result<i64> {
let result = sqlx::query!("SELECT MAX(sort_order) as max_order FROM projects;")
.fetch_one(pool)
.await?;
match result.max_order {
Some(i) => Ok(i + 1000),
None => Ok(0),
}
}
pub async fn insert(
pool: &Pool<Sqlite>,
title: String,
color: String,
) -> anyhow::Result<ProjectModel, AppError> {
if title.is_empty() {
return Err(AppError::Unprocessable(
"Title must not be empty".to_string(),
));
}
if color.len() != 7 || color.chars().next() != Some('#') {
return Err(AppError::Unprocessable(
"Color is not a valid hex".to_string(),
));
}
let id = Uuid::new_v4();
let sort_order = ProjectModel::get_new_sort_order(pool).await?;
let result = sqlx::query_as(
"INSERT INTO projects(id, title, color, sort_order) VALUES($1, $2, $3, $4) RETURNING *",
)
.bind(id)
.bind(title)
.bind(color)
.bind(sort_order)
.fetch_one(pool)
.await?;
Ok(result)
}
pub async fn get_by_id(
pool: &Pool<Sqlite>,
id: Uuid,
) -> anyhow::Result<ProjectModel, AppError> {
sqlx::query_as("SELECT * FROM projects WHERE id = $1")
.bind(id)
.fetch_one(pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => AppError::NotFound,
e => e.into(),
})
}
pub async fn list(pool: &Pool<Sqlite>) -> anyhow::Result<Vec<ProjectModel>> {
sqlx::query_as("SELECT * FROM projects;")
.fetch_all(pool)
.await
.map_err(|e| e.into())
}
pub async fn update(self, pool: &Pool<Sqlite>) -> anyhow::Result<ProjectModel, AppError> {
let now: DateTime<Utc> = Utc::now();
let _ = sqlx::query!(
"UPDATE projects
SET title=$1, color=$2, folder_id=$3, status=$4, sort_order=$5, updated_at=$6, completed_at=$7
WHERE id=$8",
self.title,
self.color,
self.folder_id,
self.status,
self.sort_order,
now,
self.completed_at,
self.id,
)
.execute(pool)
.await?;
ProjectModel::get_by_id(pool, self.id).await
}
pub async fn delete(pool: &Pool<Sqlite>, id: Uuid) -> anyhow::Result<()> {
sqlx::query!("DELETE FROM projects WHERE id = $1", id)
.execute(pool)
.await?;
Ok(())
}
}

View file

@ -1,3 +1,4 @@
mod projects;
mod tasks; mod tasks;
use axum::{ use axum::{
@ -26,11 +27,14 @@ pub struct ErrorJson {
impl IntoResponse for AppError { impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> axum::response::Response {
match self { match self {
Self::InternalError(anyhow_error) => ( Self::InternalError(anyhow_error) => {
StatusCode::INTERNAL_SERVER_ERROR, error!(err = ?anyhow_error, "Internal Server Error");
format!("Something went wrong {}", anyhow_error), (
) StatusCode::INTERNAL_SERVER_ERROR,
.into_response(), format!("Something went wrong {}", anyhow_error),
)
.into_response()
}
Self::JsonExtractError(rej) => ( Self::JsonExtractError(rej) => (
StatusCode::UNPROCESSABLE_ENTITY, StatusCode::UNPROCESSABLE_ENTITY,
Json(ErrorJson { Json(ErrorJson {
@ -85,5 +89,7 @@ impl From<sqlx::Error> for AppError {
} }
} }
pub use projects::*;
use serde::Serialize; use serde::Serialize;
pub use tasks::*; pub use tasks::*;
use tracing::error;

View file

@ -0,0 +1,106 @@
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
routing::{get, post},
};
use axum_extra::extract::WithRejection;
use serde::Deserialize;
use sqlx::{Pool, Sqlite};
use uuid::Uuid;
use crate::models::{ProjectModel, ProjectStatus};
use super::AppError;
pub fn create_project_router() -> Router<Pool<Sqlite>> {
Router::new()
.route("/", post(create_project).get(list_projects))
.route(
"/{project_id}",
get(get_project).put(update_project).delete(delete_project),
)
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CreateProjectRequest {
title: String,
color: String,
}
pub async fn create_project(
State(pool): State<Pool<Sqlite>>,
WithRejection(Json(input), _): WithRejection<Json<CreateProjectRequest>, AppError>,
) -> Result<(StatusCode, Json<ProjectModel>), AppError> {
Ok((
StatusCode::CREATED,
Json(ProjectModel::insert(&pool, input.title, input.color).await?),
))
}
pub async fn list_projects(
State(pool): State<Pool<Sqlite>>,
) -> Result<(StatusCode, Json<Vec<ProjectModel>>), AppError> {
Ok((StatusCode::OK, Json(ProjectModel::list(&pool).await?)))
}
pub async fn get_project(
State(pool): State<Pool<Sqlite>>,
WithRejection(Path(project_id), _): WithRejection<Path<Uuid>, AppError>,
) -> Result<(StatusCode, Json<ProjectModel>), AppError> {
Ok((
StatusCode::OK,
Json(ProjectModel::get_by_id(&pool, project_id).await?),
))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UpdateProjectRequest {
title: Option<String>,
color: Option<String>,
status: Option<ProjectStatus>,
}
pub async fn update_project(
State(pool): State<Pool<Sqlite>>,
WithRejection(Path(project_id), _): WithRejection<Path<Uuid>, AppError>,
WithRejection(Json(input), _): WithRejection<Json<UpdateProjectRequest>, AppError>,
) -> Result<(StatusCode, Json<ProjectModel>), AppError> {
let mut project = ProjectModel::get_by_id(&pool, project_id).await?;
if let Some(new_title) = input.title {
if new_title.len() == 0 {
return Err(AppError::Unprocessable(
"Title must not be empty".to_string(),
));
}
project.title = new_title;
}
if let Some(color) = input.color {
if color.len() != 7 || color.chars().next() != Some('#') {
return Err(AppError::Unprocessable(
"Color is not a valid hex".to_string(),
));
}
project.color = color;
}
if let Some(status) = input.status {
project.status = status;
}
Ok((StatusCode::OK, Json(project.update(&pool).await?)))
}
pub async fn delete_project(
State(pool): State<Pool<Sqlite>>,
WithRejection(Path(project_id), _): WithRejection<Path<Uuid>, AppError>,
) -> Result<StatusCode, AppError> {
ProjectModel::get_by_id(&pool, project_id).await?;
ProjectModel::delete(&pool, project_id).await?;
Ok(StatusCode::NO_CONTENT)
}

View file

@ -1,9 +1,15 @@
use std::path::Path;
use axum::{Router, routing::get}; use axum::{Router, routing::get};
use axum_test::{TestServer, TestServerConfig, Transport}; use axum_test::{TestServer, TestServerConfig, Transport};
use hurl::report::html::{Testcase, write_report};
use hurl::runner::{self, RunnerOptions, Value, VariableSet}; use hurl::runner::{self, RunnerOptions, Value, VariableSet};
use hurl::util::logger::LoggerOptionsBuilder; use hurl::util::logger::LoggerOptionsBuilder;
use hurl_core::input::Input;
use tower_http::trace::TraceLayer;
async fn create_app() -> Router { async fn create_app() -> Router {
tracing_subscriber::fmt::try_init();
use backend::database::{DatabaseConfig, create_pool}; use backend::database::{DatabaseConfig, create_pool};
use backend::services; use backend::services;
@ -24,6 +30,8 @@ async fn create_app() -> Router {
Router::new() Router::new()
.route("/health", get(health)) .route("/health", get(health))
.nest("/api/tasks", services::create_task_router()) .nest("/api/tasks", services::create_task_router())
.nest("/api/projects", services::create_project_router())
.layer(TraceLayer::new_for_http())
.with_state(pool) .with_state(pool)
} }
@ -49,11 +57,14 @@ async fn run_hurl_test(hurl_file_path: &str) {
let logger_opts = LoggerOptionsBuilder::new().build(); let logger_opts = LoggerOptionsBuilder::new().build();
let result = runner::run(&content, None, &runner_opts, &variables, &logger_opts).unwrap(); let result = runner::run(&content, None, &runner_opts, &variables, &logger_opts).unwrap();
assert!( let input = Input::new(hurl_file_path);
result.success, let test_case = Testcase::from(&result, &input);
"Hurl test failed for {}: {:?}", test_case
hurl_file_path, result .write_html(&content, &result.entries, Path::new("reports/store"), &[])
); .expect("Failed to write html files");
write_report(Path::new("reports/"), &vec![test_case]).expect("Failed to write report");
assert!(result.success, "Hurl test failed for {}", hurl_file_path);
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@ -75,3 +86,8 @@ async fn test_update_tasks_api() {
async fn test_delete_tasks_api() { async fn test_delete_tasks_api() {
run_hurl_test("./tests/api/delete_tasks.hurl").await; run_hurl_test("./tests/api/delete_tasks.hurl").await;
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_projects_api() {
run_hurl_test("./tests/api/projects.hurl").await;
}

View file

@ -0,0 +1,387 @@
# Project API Tests
# Test: Create a new project with all fields (POST /api/projects)
POST {{host}}/api/projects
Content-Type: application/json
{
"title": "Test Project Alpha",
"color": "#1976d2"
}
HTTP 201
[Captures]
project_id: jsonpath "$.id"
[Asserts]
jsonpath "$.title" == "Test Project Alpha"
jsonpath "$.color" == "#1976d2"
jsonpath "$.status" == "active"
jsonpath "$.folder_id" == null
jsonpath "$.sort_order" == 0
jsonpath "$.id" exists
jsonpath "$.created_at" exists
jsonpath "$.updated_at" exists
# Test: Create another new project
POST {{host}}/api/projects
Content-Type: application/json
{
"title": "Test Project Beta",
"color": "#ffffff"
}
HTTP 201
[Captures]
project_id_beta: jsonpath "$.id"
[Asserts]
jsonpath "$.title" == "Test Project Beta"
jsonpath "$.color" == "#ffffff"
jsonpath "$.status" == "active"
jsonpath "$.folder_id" == null
jsonpath "$.sort_order" == 1000
jsonpath "$.id" exists
jsonpath "$.created_at" exists
jsonpath "$.updated_at" exists
POST {{host}}/api/projects
Content-Type: application/json
{
"title": "Missing Color"
}
HTTP 422
[Asserts]
jsonpath "$.error" exists
# Test: Create project with invalid data (missing title)
POST {{host}}/api/projects
Content-Type: application/json
{
"color": "#ffffff"
}
HTTP 422
[Asserts]
jsonpath "$.error" exists
# Test: Create project with invalid data (invalid color)
POST {{host}}/api/projects
Content-Type: application/json
{
"title": "Invalid Color",
"color": "#ffffffasdf"
}
HTTP 422
[Asserts]
jsonpath "$.error" exists
# Test: Get a specific project by ID (GET /api/projects/{id})
GET {{host}}/api/projects/{{project_id}}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{project_id}}"
jsonpath "$.title" == "Test Project Alpha"
jsonpath "$.color" == "#1976d2"
jsonpath "$.status" == "active"
jsonpath "$.folder_id" == null
jsonpath "$.created_at" exists
jsonpath "$.updated_at" exists
# Test: Get a minimal project by ID
GET {{host}}/api/projects/{{project_id_beta}}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{project_id_beta}}"
jsonpath "$.title" == "Test Project Beta"
jsonpath "$.color" == "#ffffff"
jsonpath "$.status" == "active"
# Test: Get non-existent project
GET {{host}}/api/projects/00000000-0000-0000-0000-000000000000
HTTP 404
[Asserts]
jsonpath "$.error" exists
# Test: Get project with invalid UUID format
GET {{host}}/api/projects/invalid-uuid
HTTP 400
[Asserts]
jsonpath "$.error" exists
# Test: List all projects (GET /api/projects)
GET {{host}}/api/projects
HTTP 200
[Captures]
initial_count: jsonpath "$" count
[Asserts]
jsonpath "$" isCollection
jsonpath "$[*].id" contains "{{project_id}}"
jsonpath "$[*].id" contains "{{project_id_beta}}"
jsonpath "$[*].title" contains "Test Project Alpha"
jsonpath "$[*].title" contains "Test Project Beta"
# Test: Verify all projects have required fields
GET {{host}}/api/projects
HTTP 200
[Asserts]
jsonpath "$[?(@.id=='{{project_id}}')].title" exists
jsonpath "$[?(@.id=='{{project_id}}')].status" exists
jsonpath "$[?(@.id=='{{project_id}}')].created_at" exists
jsonpath "$[?(@.id=='{{project_id}}')].updated_at" exists
jsonpath "$[?(@.id=='{{project_id}}')].sort_order" exists
# Test: Update project title only (PUT /api/projects/{id})
PUT {{host}}/api/projects/{{project_id}}
Content-Type: application/json
{
"title": "Updated Project Alpha"
}
HTTP 200
[Captures]
first_updated_at: jsonpath "$.updated_at"
[Asserts]
jsonpath "$.id" == "{{project_id}}"
jsonpath "$.title" == "Updated Project Alpha"
jsonpath "$.color" == "#1976d2"
jsonpath "$.status" == "active"
# Test: Update status to done
PUT {{host}}/api/projects/{{project_id}}
Content-Type: application/json
{
"status": "done"
}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{project_id}}"
jsonpath "$.status" == "done"
# Test: Update status to backlog
PUT {{host}}/api/projects/{{project_id}}
Content-Type: application/json
{
"status": "backlog"
}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{project_id}}"
jsonpath "$.status" == "backlog"
# Test: Update color
PUT {{host}}/api/projects/{{project_id}}
Content-Type: application/json
{
"color": "#388e3c"
}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{project_id}}"
jsonpath "$.color" == "#388e3c"
# Test: Update multiple fields together
PUT {{host}}/api/projects/{{project_id}}
Content-Type: application/json
{
"title": "Completely Updated Project",
"color": "#d32f2f",
"status": "active"
}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{project_id}}"
jsonpath "$.title" == "Completely Updated Project"
jsonpath "$.color" == "#d32f2f"
jsonpath "$.status" == "active"
# Test: Update non-existent project
PUT {{host}}/api/projects/00000000-0000-0000-0000-000000000000
Content-Type: application/json
{
"title": "This should fail"
}
HTTP 404
[Asserts]
jsonpath "$.error" exists
# Test: Update with invalid UUID format
PUT {{host}}/api/projects/invalid-uuid-format
Content-Type: application/json
{
"title": "This should also fail"
}
HTTP 400
[Asserts]
jsonpath "$.error" exists
# Test: Update with empty title
PUT {{host}}/api/projects/{{project_id_beta}}
Content-Type: application/json
{
"title": ""
}
HTTP 422
[Asserts]
jsonpath "$.error" exists
# Test: Update with invalid status
PUT {{host}}/api/projects/{{project_id_beta}}
Content-Type: application/json
{
"status": "invalid_status"
}
HTTP 422
[Asserts]
jsonpath "$.error" exists
# Test: Update with invalid color format
PUT {{host}}/api/projects/{{project_id_beta}}
Content-Type: application/json
{
"color": "invalid-color"
}
HTTP 422
[Asserts]
jsonpath "$.error" exists
# Setup: Create a project to delete
POST {{host}}/api/projects
Content-Type: application/json
{
"title": "Project to Delete",
"color": "#222222"
}
HTTP 201
[Captures]
delete_project_id: jsonpath "$.id"
# Test: Delete project successfully (DELETE /api/projects/{id})
DELETE {{host}}/api/projects/{{delete_project_id}}
HTTP 204
# Test: Verify project is deleted (should return 404)
GET {{host}}/api/projects/{{delete_project_id}}
HTTP 404
[Asserts]
jsonpath "$.error" exists
# Setup: Create another project for additional delete tests
POST {{host}}/api/projects
Content-Type: application/json
{
"title": "Another Project to Delete",
"color": "#333333"
}
HTTP 201
[Captures]
another_delete_project_id: jsonpath "$.id"
# Test: Verify project exists before deletion
GET {{host}}/api/projects/{{another_delete_project_id}}
HTTP 200
[Asserts]
jsonpath "$.title" == "Another Project to Delete"
# Test: Delete the project
DELETE {{host}}/api/projects/{{another_delete_project_id}}
HTTP 204
# Test: Confirm project no longer exists
GET {{host}}/api/projects/{{another_delete_project_id}}
HTTP 404
[Asserts]
jsonpath "$.error" exists
# Test: Delete non-existent project
DELETE {{host}}/api/projects/00000000-0000-0000-0000-000000000000
HTTP 404
[Asserts]
jsonpath "$.error" exists
# Test: Delete with invalid UUID format
DELETE {{host}}/api/projects/invalid-uuid-format
HTTP 400
[Asserts]
jsonpath "$.error" exists
# Test: Multiple deletions of same project (idempotency test)
POST {{host}}/api/projects
Content-Type: application/json
{
"title": "Project for Idempotency Test",
"color": "#234567"
}
HTTP 201
[Captures]
idempotent_project_id: jsonpath "$.id"
# First deletion should succeed
DELETE {{host}}/api/projects/{{idempotent_project_id}}
HTTP 204
# Second deletion should return 404 (project already gone)
DELETE {{host}}/api/projects/{{idempotent_project_id}}
HTTP 404
[Asserts]
jsonpath "$.error" exists
# Test: List shows remaining projects after deletions
GET {{host}}/api/projects
HTTP 200
[Asserts]
jsonpath "$[*].id" contains "{{project_id}}"
jsonpath "$[*].id" contains "{{project_id_beta}}"
jsonpath "$[*].id" not contains "{{delete_project_id}}"
jsonpath "$[*].id" not contains "{{another_delete_project_id}}"
jsonpath "$[*].id" not contains "{{idempotent_project_id}}"
# Cleanup: Delete remaining test projects
DELETE {{host}}/api/projects/{{project_id}}
HTTP 204
DELETE {{host}}/api/projects/{{project_id_beta}}
HTTP 204
# Test: Verify all test projects are cleaned up
GET {{host}}/api/projects
HTTP 200
[Asserts]
jsonpath "$[*].id" not contains "{{project_id}}"
jsonpath "$[*].id" not contains "{{project_id_beta}}"

View file

@ -6,7 +6,6 @@ import {
InputLabel, InputLabel,
Select, Select,
MenuItem, MenuItem,
Chip,
Stack, Stack,
Paper, Paper,
Table, Table,
@ -46,7 +45,7 @@ interface TaskListProps {
} }
export function TaskList({ className, initialTasks }: TaskListProps) { export function TaskList({ className, initialTasks }: TaskListProps) {
const { tasks, loading, error, deleteTask } = useTasks({ const { tasks, loading, error, updateTask, deleteTask } = useTasks({
autoFetch: !initialTasks, autoFetch: !initialTasks,
initialData: initialTasks, initialData: initialTasks,
}) })
@ -217,6 +216,13 @@ export function TaskList({ className, initialTasks }: TaskListProps) {
} }
} }
const handleStatusChange = async (taskId: string, newStatus: TaskStatus) => {
const result = await updateTask(taskId, { status: newStatus })
if (!result) {
console.error('Failed to update task status')
}
}
const isAllSelected = const isAllSelected =
filteredAndSortedTasks.length > 0 && filteredAndSortedTasks.length > 0 &&
filteredAndSortedTasks.every(task => selectedTaskIds.has(task.id)) filteredAndSortedTasks.every(task => selectedTaskIds.has(task.id))
@ -447,12 +453,139 @@ export function TaskList({ className, initialTasks }: TaskListProps) {
</TableCell> </TableCell>
)} )}
<TableCell> <TableCell>
<Chip <Select
label={task.status} value={task.status}
color={getStatusColor(task.status)} onChange={e =>
handleStatusChange(
task.id,
e.target.value as TaskStatus
)
}
size="small" size="small"
variant="outlined" variant="outlined"
/> MenuProps={{
PaperProps: {
sx: {
padding: '4px',
minWidth: '100px',
'& .MuiList-root': {
padding: 0,
},
},
},
}}
sx={{
minWidth: 90,
height: '24px',
borderRadius: '16px',
backgroundColor:
getStatusColor(task.status) === 'primary'
? 'primary.main'
: getStatusColor(task.status) === 'success'
? 'success.main'
: 'action.disabled',
color:
getStatusColor(task.status) === 'default'
? 'text.primary'
: 'white',
fontSize: '0.75rem',
'& .MuiOutlinedInput-notchedOutline': {
border: 'none',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
border: 'none',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
border: 'none',
},
'& .MuiSelect-select': {
padding: '2px 12px 2px 12px',
paddingRight: '24px !important',
display: 'flex',
alignItems: 'center',
height: '20px',
lineHeight: '20px',
},
'& .MuiSelect-icon': {
right: '4px',
color:
getStatusColor(task.status) === 'default'
? 'text.primary'
: 'white',
fontSize: '1rem',
},
}}
>
<MenuItem
value={TaskStatus.Todo}
sx={{
backgroundColor: 'primary.main',
color: 'white',
margin: '2px 4px',
borderRadius: '12px',
fontSize: '0.75rem',
minHeight: '24px',
padding: '2px 8px',
'&:hover': {
backgroundColor: 'primary.dark',
},
'&.Mui-selected': {
backgroundColor: 'primary.main',
'&:hover': {
backgroundColor: 'primary.dark',
},
},
}}
>
Todo
</MenuItem>
<MenuItem
value={TaskStatus.Done}
sx={{
backgroundColor: 'success.main',
color: 'white',
margin: '2px 4px',
borderRadius: '12px',
fontSize: '0.75rem',
minHeight: '24px',
padding: '2px 8px',
'&:hover': {
backgroundColor: 'success.dark',
},
'&.Mui-selected': {
backgroundColor: 'success.main',
'&:hover': {
backgroundColor: 'success.dark',
},
},
}}
>
Done
</MenuItem>
<MenuItem
value={TaskStatus.Backlog}
sx={{
backgroundColor: 'action.disabled',
color: 'text.primary',
margin: '2px 4px',
borderRadius: '12px',
fontSize: '0.75rem',
minHeight: '24px',
padding: '2px 8px',
'&:hover': {
backgroundColor: 'action.hover',
},
'&.Mui-selected': {
backgroundColor: 'action.disabled',
'&:hover': {
backgroundColor: 'action.hover',
},
},
}}
>
Backlog
</MenuItem>
</Select>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Tooltip <Tooltip

View file

@ -1,5 +1,10 @@
import { useState, useCallback, useEffect } from 'react' import { useState, useCallback, useEffect } from 'react'
import type { Task, CreateTaskRequest, ApiError } from '~/types/task' import type {
Task,
CreateTaskRequest,
UpdateTaskRequest,
ApiError,
} from '~/types/task'
import { TaskStatus } from '~/types/task' import { TaskStatus } from '~/types/task'
import { apiClient } from '~/services/api' import { apiClient } from '~/services/api'
@ -13,6 +18,7 @@ interface UseTasksState {
interface UseTasksActions { interface UseTasksActions {
fetchTasks: () => Promise<void> fetchTasks: () => Promise<void>
createTask: (data: CreateTaskRequest) => Promise<Task | null> createTask: (data: CreateTaskRequest) => Promise<Task | null>
updateTask: (id: string, data: UpdateTaskRequest) => Promise<Task | null>
deleteTask: (id: string) => Promise<boolean> deleteTask: (id: string) => Promise<boolean>
refreshTasks: () => Promise<void> refreshTasks: () => Promise<void>
clearError: () => void clearError: () => void
@ -91,6 +97,30 @@ export function useTasks(
[] []
) )
const updateTask = useCallback(
async (id: string, data: UpdateTaskRequest): Promise<Task | null> => {
try {
const updatedTask = await apiClient.updateTask(id, data)
// Update the task in the local state immediately
setState(prev => ({
...prev,
tasks: prev.tasks.map(task => (task.id === id ? updatedTask : task)),
}))
return updatedTask
} catch (error) {
const apiError = error as ApiError
setState(prev => ({
...prev,
error: apiError.message,
}))
return null
}
},
[]
)
const deleteTask = useCallback(async (id: string): Promise<boolean> => { const deleteTask = useCallback(async (id: string): Promise<boolean> => {
try { try {
await apiClient.deleteTask(id) await apiClient.deleteTask(id)
@ -176,6 +206,7 @@ export function useTasks(
...state, ...state,
fetchTasks, fetchTasks,
createTask, createTask,
updateTask,
deleteTask, deleteTask,
refreshTasks, refreshTasks,
clearError, clearError,

199
plan/02_PROJECTS/plan.md Normal file
View file

@ -0,0 +1,199 @@
# Project-Based Organization Implementation Plan
## Phase Overview
This plan implements project-based organization with a clean folder/project hierarchy:
- **Folders**: Organizational containers for projects (can be reordered)
- **Projects**: Work containers that hold tasks (can belong to folders or exist at root)
- **Tasks**: Belong to zero or one project (never directly to folders)
## Backend Implementation
### 1. Database Schema Changes
- **New Tables:**
- `folders` table: id, name, color, sort_order, created_at, updated_at
- `projects` table: id, name, description, color, due_date, status, folder_id (nullable), sort_order, created_at, updated_at
- **Schema Updates:**
- Add `project_id` field to existing `tasks` table (nullable foreign key)
- Database indexes for efficient querying
### 2. Backend Models & Services
- **FolderModel** (backend/src/models/folder.rs):
- CRUD operations for folders
- Reordering methods for sort_order management
- **ProjectModel** (backend/src/models/project.rs):
- CRUD operations with folder relationship
- Methods for reordering within folders and at root level
- Query methods for folder contents
- **Updated TaskModel** (backend/src/models/task.rs):
- Add project_id field and foreign key constraint
- Project relationship methods
- **API Services:**
- folders.rs - Folder CRUD and reordering endpoints
- projects.rs - Project CRUD, folder assignment, reordering
- Updated tasks.rs - Project filtering and assignment
### 3. Database Migrations
- Create folders table
- Create projects table with folder foreign key
- Add project_id to tasks table
- Set up proper indexes and constraints
## Frontend Implementation
### 1. Data Models & Types
- **Folder Types** (frontend/app/types/folder.ts):
- Folder interface, CreateFolderRequest, UpdateFolderRequest
- **Project Types** (frontend/app/types/project.ts):
- Project interface with folder_id field
- ProjectStatus enum, CRUD request types
- **Updated Task Types** (frontend/app/types/task.ts):
- Add project_id field to Task interface
### 2. Sidebar & Navigation
- **ProjectSidebar** (frontend/app/components/ProjectSidebar.tsx):
- Hierarchical display: Folders → Projects → (Root Projects)
- Drag-and-drop for both folder and project reordering
- Inline creation forms for folders and projects
- Collapsible folder sections
- **FolderSection** (frontend/app/components/FolderSection.tsx):
- Individual folder with contained projects
- Folder editing and project management
### 3. Project Management
- **ProjectForm** (frontend/app/components/ProjectForm.tsx):
- Create/edit projects with folder assignment
- 16-color palette, due dates, status selection
- **FolderForm** (frontend/app/components/FolderForm.tsx):
- Simple folder creation/editing (name, color)
### 4. Updated Task Components
- **Enhanced TaskList** with Project column
- **Updated TaskForm** with project selection dropdown
- Project filtering throughout the UI
### 5. Routes & State Management
- **New Routes:**
- `/projects` - Projects overview
- `/projects/{id}` - Project detail page
- `/folders/{id}` - Folder contents view
- **State Hooks:**
- `useFolders.ts`, `useProjects.ts`, updated `useTasks.ts`
- **API Integration:**
- Folder and project API clients
## Implementation Order
### Phase 1: Backend Foundation
1. Create folders and projects tables (migrations)
2. Implement FolderModel and ProjectModel
3. Add project_id to TaskModel
4. Create API endpoints for folders and projects
5. Unit tests for new functionality
### Phase 2: Frontend Core
1. Implement folder and project types
2. Create basic ProjectSidebar (no drag-drop yet)
3. Add project column to TaskList
4. Update TaskForm with project selection
5. Basic project/folder CRUD forms
### Phase 3: Advanced Features
1. Drag-and-drop reordering in sidebar
2. Project and folder detail pages
3. Hierarchical project creation flows
4. Project filtering and search
5. Mobile responsive sidebar
### Phase 4: Polish & Integration
1. Project color theming throughout UI
2. Performance optimization
3. Enhanced UX for project management
4. Integration testing
5. Migration tools for existing data
## Data Relationships
```
folders (1) → (many) projects
projects (1) → (many) tasks
folders (no direct relationship) tasks
```
This clean separation ensures tasks only belong to projects, while folders serve purely as organizational containers for projects.
## Technical Implementation Details
### Database Schema
#### Folders Table
```sql
CREATE TABLE folders (
id UUID PRIMARY KEY NOT NULL,
name VARCHAR NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
#### Projects Table
```sql
CREATE TABLE projects (
id UUID PRIMARY KEY NOT NULL,
name VARCHAR NOT NULL,
description TEXT,
color VARCHAR(7), -- hex color code
due_date DATE,
status TEXT CHECK(status IN ('active', 'done', 'backlog')) DEFAULT 'active',
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
#### Tasks Table Update
```sql
ALTER TABLE tasks ADD COLUMN project_id UUID REFERENCES projects(id) ON DELETE SET NULL;
```
### Color Palette
16-color palette for folders and projects:
- Primary Blues: #1976d2, #0288d1, #0097a7
- Greens: #388e3c, #689f38, #7cb342
- Oranges/Reds: #f57c00, #ff5722, #d32f2f
- Purples: #7b1fa2, #512da8, #303f9f
- Neutrals: #455a64, #616161, #757575, #424242
### API Endpoints
#### Folders
- `GET /api/folders` - List all folders with projects
- `POST /api/folders` - Create folder
- `PUT /api/folders/{id}` - Update folder
- `DELETE /api/folders/{id}` - Delete folder
- `PUT /api/folders/reorder` - Reorder folders
#### Projects
- `GET /api/projects` - List all projects
- `GET /api/projects?folder_id={id}` - List projects in folder
- `POST /api/projects` - Create project
- `PUT /api/projects/{id}` - Update project
- `DELETE /api/projects/{id}` - Delete project
- `PUT /api/projects/reorder` - Reorder projects within folder/root
#### Tasks (Updated)
- `GET /api/tasks?project_id={id}` - List tasks in project
- Task CRUD operations include project_id field
## Migration Strategy
1. Existing tasks remain unassigned (project_id = NULL)
2. Users can create folders and projects
3. Users can assign existing tasks to projects
4. No data loss during migration
5. Backwards compatibility maintained
## Testing Strategy
- Unit tests for all model methods
- API integration tests with Hurl
- Frontend component tests
- End-to-end project workflow tests