From f76bb35efad7473b68f14309f2a710a9e27d27dd Mon Sep 17 00:00:00 2001 From: Taran Nathan Date: Fri, 25 Sep 2026 09:50:11 -0700 Subject: [PATCH] sort tasks by date and add archival --- client/src/client.gleam | 72 +++- server/db/init.sql | 5 +- server/src/server.gleam | 57 ++- server/src/sql.gleam | 538 +++++++++++++++++++++++------ server/src/sql/archive_project.sql | 3 + server/src/sql/create_task.sql | 12 +- server/src/sql/create_user.sql | 2 +- server/src/sql/delete_project.sql | 2 + server/src/sql/delete_task.sql | 2 + server/src/sql/find_user.sql | 2 +- server/src/sql/find_username.sql | 3 + server/src/sql/list_projects.sql | 4 +- server/src/sql/list_tasks.sql | 11 +- server/static/client.js | 132 ++++++- shared/src/shared.gleam | 12 +- 15 files changed, 716 insertions(+), 141 deletions(-) create mode 100644 server/src/sql/archive_project.sql create mode 100644 server/src/sql/delete_project.sql create mode 100644 server/src/sql/delete_task.sql create mode 100644 server/src/sql/find_username.sql diff --git a/client/src/client.gleam b/client/src/client.gleam index f456878..53923e8 100644 --- a/client/src/client.gleam +++ b/client/src/client.gleam @@ -72,6 +72,8 @@ pub type Message { DeleteProject(Int) LogOut Nothing + ArchiveProject(Int) + UnarchiveProject(Int) } fn init(_flags: Option(Nil)) -> #(Model, Effect(Message)) { @@ -262,6 +264,24 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { }), ), ) + ArchiveProject(project_id) -> #( + model, + rsvp.get( + "/api/archive/" <> int.to_string(project_id), + rsvp.expect_any_response(fn(result) { + Saved(result |> result.map(fn(response) { response.body })) + }), + ), + ) + UnarchiveProject(project_id) -> #( + model, + rsvp.get( + "/api/unarchive/" <> int.to_string(project_id), + rsvp.expect_any_response(fn(result) { + Saved(result |> result.map(fn(response) { response.body })) + }), + ), + ) Authenticated(Ok(_)) -> #( Model(..model, page: "dashboard"), rsvp.get( @@ -588,12 +608,48 @@ fn dashboard_view(model: Model) -> Element(Message) { ]), ]), ], - list.map(board.projects, fn(project) { project_view(model, project) }), + project_sections(model, board.projects), ), ) } } +fn project_sections( + model: Model, + projects: List(shared.Project), +) -> List(Element(Message)) { + let active = + list.filter(projects, fn(project) { !project.archived }) + |> list.map(fn(project) { project_view(model, project) }) + let archived = + list.filter(projects, fn(project) { project.archived }) + |> list.map(fn(project) { project_view(model, project) }) + + case archived { + [] -> active + _ -> + list.append(active, [ + html.section( + [ + attribute.class("archived-projects"), + attribute.styles([ + #("margin-top", "42px"), + #("padding-top", "20px"), + #("border-top", "2px solid #d9cfc2"), + ]), + ], + [ + html.h2([], [html.text("Archived projects")]), + html.p([], [ + html.text("Projects kept for reference and no longer active."), + ]), + ], + ), + ..archived + ]) + } +} + fn project_view(model: Model, project: shared.Project) -> Element(Message) { let tasks = list.filter(project.tasks, fn(task) { task_matches(model, task) }) let backlog = list.filter(tasks, fn(task) { task.status == "todo" }) @@ -609,6 +665,16 @@ fn project_view(model: Model, project: shared.Project) -> Element(Message) { html.button([event.on_click(DeleteProject(project.id))], [ html.text("Delete project"), ]), + case project.archived { + True -> + html.button([event.on_click(UnarchiveProject(project.id))], [ + html.text("Unarchive project"), + ]) + False -> + html.button([event.on_click(ArchiveProject(project.id))], [ + html.text("Archive project"), + ]) + }, ], list.append( [ @@ -677,7 +743,9 @@ fn kanban_column(title: String, tasks: List(shared.Task)) -> Element(Message) { html.text(title <> " · " <> int.to_string(list.length(tasks))), ]), ], - list.map(tasks, task_view), + tasks + |> list.sort(fn(a, b) { string.compare(a.due_date, b.due_date) }) + |> list.map(task_view), ), ) } diff --git a/server/db/init.sql b/server/db/init.sql index 5f044b2..92900bb 100644 --- a/server/db/init.sql +++ b/server/db/init.sql @@ -9,9 +9,12 @@ create table if not exists project ( id serial primary key, name text not null, description text not null default '', - created_at timestamptz not null default now() + created_at timestamptz not null default now(), + archived boolean not null default false ); +alter table project add column if not exists archived boolean not null default false; + create table if not exists task ( id serial primary key, project_id int not null references project(id) on delete cascade, diff --git a/server/src/server.gleam b/server/src/server.gleam index 350a2e8..95b76b5 100644 --- a/server/src/server.gleam +++ b/server/src/server.gleam @@ -91,10 +91,38 @@ fn handle_request(db: Database, request: Request) -> Response { http.Delete, ["api", "tasks", task_id] -> delete_task(db, req, task_id) http.Delete, ["api", "projects", project_id] -> delete_project(db, req, project_id) + http.Get, ["api", "archive", project_id] -> + archive_project(db, req, project_id) + http.Get, ["api", "unarchive", project_id] -> + unarchive_project(db, req, project_id) _, _ -> wisp.not_found() } } +fn archive_project(db: Database, req: Request, project_id: String) -> Response { + case current_user(db, req), int.parse(project_id) { + Ok(_), Ok(id) -> { + let assert Ok(_) = sql.archive_project(db.connection, True, id) + wisp.json_response("{\"ok\":true}", 201) + } + _, _ -> wisp.json_response("{\"error\":\"invalid request\"}", 400) + } +} + +fn unarchive_project( + db: Database, + req: Request, + project_id: String, +) -> Response { + case current_user(db, req), int.parse(project_id) { + Ok(_), Ok(id) -> { + let assert Ok(_) = sql.archive_project(db.connection, False, id) + wisp.json_response("{\"ok\":true}", 201) + } + _, _ -> wisp.json_response("{\"error\":\"invalid request\"}", 400) + } +} + fn middleware(req: Request, next: fn(Request) -> Response) -> Response { use <- wisp.log_request(req) use <- wisp.rescue_crashes @@ -121,11 +149,12 @@ fn api_dashboard(db: Database, req: Request) -> Response { Ok(username) -> { let projects = rows([], sql.list_projects(db.connection)) - |> list.map(fn(project: sql.Project) { + |> list.map(fn(project: sql.ListProjectsRow) { shared.Project( id: project.id, name: project.name, description: project.description, + archived: project.archived, tasks: rows([], sql.list_tasks(db.connection, project.id)) |> list.map(to_shared_task), ) @@ -170,7 +199,7 @@ fn register(db: Database, req: Request, body: String) -> Response { password_hash(credentials.password), ) { - Ok(pog.Returned(_, [user_id])) -> start_session(db, req, user_id) + Ok(pog.Returned(_, [user])) -> start_session(db, req, user.id) Error(error) -> registration_error(error) Ok(_) -> wisp.json_response("{\"error\":\"username unavailable\"}", 409) @@ -272,7 +301,7 @@ fn add_task( task.description, task.assignee, task.assignees, - task.subteams, + list.map(task.subteams, to_sql_subteam), task.due_date, ) wisp.json_response("{\"ok\":true}", 201) @@ -313,16 +342,16 @@ fn json_body(req: Request, next: fn(String) -> Response) -> Response { fn current_user(db: Database, req: Request) -> Result(String, Nil) { use token <- result.try(wisp.get_cookie(req, "session", wisp.Signed)) case sql.find_session(db.connection, token) { - Ok(pog.Returned(_, [user_id])) -> - case sql.find_username(db.connection, user_id) { - Ok(pog.Returned(_, [username])) -> Ok(username) + Ok(pog.Returned(_, [session])) -> + case sql.find_username(db.connection, session.user_id) { + Ok(pog.Returned(_, [user])) -> Ok(user.username) _ -> Error(Nil) } _ -> Error(Nil) } } -fn to_shared_task(task: sql.Task) -> shared.Task { +fn to_shared_task(task: sql.ListTasksRow) -> shared.Task { shared.Task( id: task.id, project_id: task.project_id, @@ -331,11 +360,23 @@ fn to_shared_task(task: sql.Task) -> shared.Task { status: task.status, assignee: task.assignee, assignees: task.assignees, - subteams: task.subteams, + subteams: string.split(task.subteams, ","), due_date: task.due_date, ) } +fn to_sql_subteam(value: String) -> sql.Subteam { + case value { + "electrical" -> sql.Electrical + "software" -> sql.Software + "CAD" -> sql.Cad + "machining" -> sql.Machining + "operations" -> sql.Operations + "none" -> sql.None + _ -> sql.None + } +} + fn rows( default: List(a), value: Result(pog.Returned(a), pog.QueryError), diff --git a/server/src/sql.gleam b/server/src/sql.gleam index f6033d0..2d5ee9a 100644 --- a/server/src/sql.gleam +++ b/server/src/sql.gleam @@ -1,94 +1,407 @@ +//// This module contains the code to run the sql queries defined in +//// `./src/sql`. +//// > 🐿️ This module was generated automatically using v4.7.0 of +//// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). +//// + import gleam/dynamic/decode -import gleam/string import pog -pub type User { - User(id: Int, username: String, password_hash: String) +/// Runs the `archive_project` query +/// defined in `./src/sql/archive_project.sql`. +/// +/// > 🐿️ This function was generated automatically using v4.7.0 of +/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). +/// +pub fn archive_project( + db: pog.Connection, + archived: Bool, + id: Int, +) -> Result(pog.Returned(Nil), pog.QueryError) { + let decoder = decode.map(decode.dynamic, fn(_) { Nil }) + + "update project +set archived = $1 +where id = $2 +" + |> pog.query + |> pog.parameter(pog.bool(archived)) + |> pog.parameter(pog.int(id)) + |> pog.returning(decoder) + |> pog.execute(db) } -pub type Project { - Project(id: Int, name: String, description: String) +/// A row you get from running the `create_project` query +/// defined in `./src/sql/create_project.sql`. +/// +/// > 🐿️ This type definition was generated automatically using v4.7.0 of the +/// > [squirrel package](https://github.com/giacomocavalieri/squirrel). +/// +pub type CreateProjectRow { + CreateProjectRow(id: Int, name: String, description: String) } -pub type Task { - Task( - id: Int, - project_id: Int, - title: String, - description: String, - status: String, - assignee: String, - assignees: String, - subteams: List(String), - due_date: String, - ) +/// Runs the `create_project` query +/// defined in `./src/sql/create_project.sql`. +/// +/// > 🐿️ This function was generated automatically using v4.7.0 of +/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). +/// +pub fn create_project( + db: pog.Connection, + arg_1: String, + arg_2: String, +) -> Result(pog.Returned(CreateProjectRow), pog.QueryError) { + let decoder = { + use id <- decode.field(0, decode.int) + use name <- decode.field(1, decode.string) + use description <- decode.field(2, decode.string) + decode.success(CreateProjectRow(id:, name:, description:)) + } + + "insert into project (name, description) +values ($1, $2) +returning id, name, description +" + |> pog.query + |> pog.parameter(pog.text(arg_1)) + |> pog.parameter(pog.text(arg_2)) + |> pog.returning(decoder) + |> pog.execute(db) } -pub fn create_user( +/// Runs the `create_session` query +/// defined in `./src/sql/create_session.sql`. +/// +/// > 🐿️ This function was generated automatically using v4.7.0 of +/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). +/// +pub fn create_session( db: pog.Connection, - username: String, - password_hash: String, -) { - "insert into app_users (username, password_hash) values ($1, $2) returning id" + arg_1: String, + arg_2: Int, +) -> Result(pog.Returned(Nil), pog.QueryError) { + let decoder = decode.map(decode.dynamic, fn(_) { Nil }) + + "insert into session (token, user_id, expires_at) +values ($1, $2, now() + interval '30 days') +" |> pog.query - |> pog.parameter(pog.text(username)) - |> pog.parameter(pog.text(password_hash)) - |> pog.returning(decode.field(0, decode.int, decode.success)) + |> pog.parameter(pog.text(arg_1)) + |> pog.parameter(pog.int(arg_2)) + |> pog.returning(decoder) |> pog.execute(db) } -pub fn find_user(db: pog.Connection, username: String) { - "select id, username, password_hash from app_users where username = $1" +/// A row you get from running the `create_task` query +/// defined in `./src/sql/create_task.sql`. +/// +/// > 🐿️ This type definition was generated automatically using v4.7.0 of the +/// > [squirrel package](https://github.com/giacomocavalieri/squirrel). +/// +pub type CreateTaskRow { + CreateTaskRow(id: Int) +} + +/// Runs the `create_task` query +/// defined in `./src/sql/create_task.sql`. +/// +/// > 🐿️ This function was generated automatically using v4.7.0 of +/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). +/// +pub fn create_task( + db: pog.Connection, + arg_1: Int, + arg_2: String, + arg_3: String, + arg_4: String, + arg_5: String, + arg_6: List(Subteam), + arg_7: String, +) -> Result(pog.Returned(CreateTaskRow), pog.QueryError) { + let decoder = { + use id <- decode.field(0, decode.int) + decode.success(CreateTaskRow(id:)) + } + + "insert into task ( + project_id, + title, + description, + assignee, + assignees, + subteams, + due_date +) +values ($1, $2, $3, $4, $5, $6::subteam[], nullif($7, '')::date) +returning id +" |> pog.query - |> pog.parameter(pog.text(username)) - |> pog.returning({ + |> pog.parameter(pog.int(arg_1)) + |> pog.parameter(pog.text(arg_2)) + |> pog.parameter(pog.text(arg_3)) + |> pog.parameter(pog.text(arg_4)) + |> pog.parameter(pog.text(arg_5)) + |> pog.parameter(pog.array(fn(value) { subteam_encoder(value) }, arg_6)) + |> pog.parameter(pog.text(arg_7)) + |> pog.returning(decoder) + |> pog.execute(db) +} + +/// A row you get from running the `create_user` query +/// defined in `./src/sql/create_user.sql`. +/// +/// > 🐿️ This type definition was generated automatically using v4.7.0 of the +/// > [squirrel package](https://github.com/giacomocavalieri/squirrel). +/// +pub type CreateUserRow { + CreateUserRow(id: Int) +} + +/// Runs the `create_user` query +/// defined in `./src/sql/create_user.sql`. +/// +/// > 🐿️ This function was generated automatically using v4.7.0 of +/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). +/// +pub fn create_user( + db: pog.Connection, + arg_1: String, + arg_2: String, +) -> Result(pog.Returned(CreateUserRow), pog.QueryError) { + let decoder = { use id <- decode.field(0, decode.int) - use username <- decode.field(1, decode.string) - use password_hash <- decode.field(2, decode.string) - decode.success(User(id:, username:, password_hash:)) - }) + decode.success(CreateUserRow(id:)) + } + + "insert into app_users (username, password_hash) +values ($1, $2) +on conflict (username) do nothing +returning id +" + |> pog.query + |> pog.parameter(pog.text(arg_1)) + |> pog.parameter(pog.text(arg_2)) + |> pog.returning(decoder) |> pog.execute(db) } -pub fn find_username(db: pog.Connection, user_id: Int) { - "select username from app_users where id = $1" +/// Runs the `delete_project` query +/// defined in `./src/sql/delete_project.sql`. +/// +/// > 🐿️ This function was generated automatically using v4.7.0 of +/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). +/// +pub fn delete_project( + db: pog.Connection, + id: Int, +) -> Result(pog.Returned(Nil), pog.QueryError) { + let decoder = decode.map(decode.dynamic, fn(_) { Nil }) + + "delete from project +where id = $1 +" |> pog.query - |> pog.parameter(pog.int(user_id)) - |> pog.returning(decode.field(0, decode.string, decode.success)) + |> pog.parameter(pog.int(id)) + |> pog.returning(decoder) |> pog.execute(db) } -pub fn list_projects(db: pog.Connection) { - "select id, name, description from project order by created_at desc, id desc" +/// Runs the `delete_task` query +/// defined in `./src/sql/delete_task.sql`. +/// +/// > 🐿️ This function was generated automatically using v4.7.0 of +/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). +/// +pub fn delete_task( + db: pog.Connection, + id: Int, +) -> Result(pog.Returned(Nil), pog.QueryError) { + let decoder = decode.map(decode.dynamic, fn(_) { Nil }) + + "delete from task +where id = $1 +" + |> pog.query + |> pog.parameter(pog.int(id)) + |> pog.returning(decoder) + |> pog.execute(db) +} + +/// A row you get from running the `find_session` query +/// defined in `./src/sql/find_session.sql`. +/// +/// > 🐿️ This type definition was generated automatically using v4.7.0 of the +/// > [squirrel package](https://github.com/giacomocavalieri/squirrel). +/// +pub type FindSessionRow { + FindSessionRow(user_id: Int) +} + +/// Runs the `find_session` query +/// defined in `./src/sql/find_session.sql`. +/// +/// > 🐿️ This function was generated automatically using v4.7.0 of +/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). +/// +pub fn find_session( + db: pog.Connection, + token: String, +) -> Result(pog.Returned(FindSessionRow), pog.QueryError) { + let decoder = { + use user_id <- decode.field(0, decode.int) + decode.success(FindSessionRow(user_id:)) + } + + "select user_id +from session +where token = $1 and expires_at > now() +" |> pog.query - |> pog.returning({ + |> pog.parameter(pog.text(token)) + |> pog.returning(decoder) + |> pog.execute(db) +} + +/// A row you get from running the `find_user` query +/// defined in `./src/sql/find_user.sql`. +/// +/// > 🐿️ This type definition was generated automatically using v4.7.0 of the +/// > [squirrel package](https://github.com/giacomocavalieri/squirrel). +/// +pub type FindUserRow { + FindUserRow(id: Int, username: String, password_hash: String) +} + +/// Runs the `find_user` query +/// defined in `./src/sql/find_user.sql`. +/// +/// > 🐿️ This function was generated automatically using v4.7.0 of +/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). +/// +pub fn find_user( + db: pog.Connection, + username: String, +) -> Result(pog.Returned(FindUserRow), pog.QueryError) { + let decoder = { use id <- decode.field(0, decode.int) - use name <- decode.field(1, decode.string) - use description <- decode.field(2, decode.string) - decode.success(Project(id:, name:, description:)) - }) + use username <- decode.field(1, decode.string) + use password_hash <- decode.field(2, decode.string) + decode.success(FindUserRow(id:, username:, password_hash:)) + } + + "select id, username, password_hash +from app_users +where username = $1 +" + |> pog.query + |> pog.parameter(pog.text(username)) + |> pog.returning(decoder) |> pog.execute(db) } -pub fn create_project(db: pog.Connection, name: String, description: String) { - "insert into project (name, description) values ($1, $2) returning id, name, description" +/// A row you get from running the `find_username` query +/// defined in `./src/sql/find_username.sql`. +/// +/// > 🐿️ This type definition was generated automatically using v4.7.0 of the +/// > [squirrel package](https://github.com/giacomocavalieri/squirrel). +/// +pub type FindUsernameRow { + FindUsernameRow(username: String) +} + +/// Runs the `find_username` query +/// defined in `./src/sql/find_username.sql`. +/// +/// > 🐿️ This function was generated automatically using v4.7.0 of +/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). +/// +pub fn find_username( + db: pog.Connection, + id: Int, +) -> Result(pog.Returned(FindUsernameRow), pog.QueryError) { + let decoder = { + use username <- decode.field(0, decode.string) + decode.success(FindUsernameRow(username:)) + } + + "select username +from app_users +where id = $1 +" |> pog.query - |> pog.parameter(pog.text(name)) - |> pog.parameter(pog.text(description)) - |> pog.returning({ + |> pog.parameter(pog.int(id)) + |> pog.returning(decoder) + |> pog.execute(db) +} + +/// A row you get from running the `list_projects` query +/// defined in `./src/sql/list_projects.sql`. +/// +/// > 🐿️ This type definition was generated automatically using v4.7.0 of the +/// > [squirrel package](https://github.com/giacomocavalieri/squirrel). +/// +pub type ListProjectsRow { + ListProjectsRow(id: Int, name: String, description: String, archived: Bool) +} + +/// Runs the `list_projects` query +/// defined in `./src/sql/list_projects.sql`. +/// +/// > 🐿️ This function was generated automatically using v4.7.0 of +/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). +/// +pub fn list_projects( + db: pog.Connection, +) -> Result(pog.Returned(ListProjectsRow), pog.QueryError) { + let decoder = { use id <- decode.field(0, decode.int) use name <- decode.field(1, decode.string) use description <- decode.field(2, decode.string) - decode.success(Project(id:, name:, description:)) - }) + use archived <- decode.field(3, decode.bool) + decode.success(ListProjectsRow(id:, name:, description:, archived:)) + } + + "select id, name, description, archived +from project +order by archived asc, created_at desc, id desc +" + |> pog.query + |> pog.returning(decoder) |> pog.execute(db) } -pub fn list_tasks(db: pog.Connection, project_id: Int) { - "select id, project_id, title, description, status, assignee, assignees, coalesce(array_to_string(subteams, ','), ''), coalesce(due_date::text, '') from task where project_id = $1 order by case status when 'doing' then 0 when 'todo' then 1 else 2 end, created_at desc" - |> pog.query - |> pog.parameter(pog.int(project_id)) - |> pog.returning({ +/// A row you get from running the `list_tasks` query +/// defined in `./src/sql/list_tasks.sql`. +/// +/// > 🐿️ This type definition was generated automatically using v4.7.0 of the +/// > [squirrel package](https://github.com/giacomocavalieri/squirrel). +/// +pub type ListTasksRow { + ListTasksRow( + id: Int, + project_id: Int, + title: String, + description: String, + status: String, + assignee: String, + assignees: String, + subteams: String, + due_date: String, + ) +} + +/// Runs the `list_tasks` query +/// defined in `./src/sql/list_tasks.sql`. +/// +/// > 🐿️ This function was generated automatically using v4.7.0 of +/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). +/// +pub fn list_tasks( + db: pog.Connection, + project_id: Int, +) -> Result(pog.Returned(ListTasksRow), pog.QueryError) { + let decoder = { use id <- decode.field(0, decode.int) use project_id <- decode.field(1, decode.int) use title <- decode.field(2, decode.string) @@ -98,7 +411,7 @@ pub fn list_tasks(db: pog.Connection, project_id: Int) { use assignees <- decode.field(6, decode.string) use subteams <- decode.field(7, decode.string) use due_date <- decode.field(8, decode.string) - decode.success(Task( + decode.success(ListTasksRow( id:, project_id:, title:, @@ -106,70 +419,81 @@ pub fn list_tasks(db: pog.Connection, project_id: Int) { status:, assignee:, assignees:, - subteams: string.split(subteams, ","), + subteams:, due_date:, )) - }) - |> pog.execute(db) -} + } -pub fn create_task( - db: pog.Connection, - project_id: Int, - title: String, - description: String, - assignee: String, - assignees: String, - subteams: List(String), - due_date: String, -) { - "insert into task (project_id, title, description, assignee, assignees, subteams, due_date) values ($1, $2, $3, $4, $5, $6::subteam[], nullif($7, '')::date) returning id" + "select + id, + project_id, + title, + description, + status, + assignee, + assignees, + coalesce(array_to_string(subteams, ','), '') as subteams, + coalesce(due_date::text, '') as due_date +from task +where project_id = $1 +order by + case status when 'doing' then 0 when 'todo' then 1 else 2 end, + created_at desc +" |> pog.query |> pog.parameter(pog.int(project_id)) - |> pog.parameter(pog.text(title)) - |> pog.parameter(pog.text(description)) - |> pog.parameter(pog.text(assignee)) - |> pog.parameter(pog.text(assignees)) - |> pog.parameter(pog.array(pog.text, subteams)) - |> pog.parameter(pog.text(due_date)) - |> pog.returning(decode.field(0, decode.int, decode.success)) + |> pog.returning(decoder) |> pog.execute(db) } -pub fn update_task_status(db: pog.Connection, status: String, task_id: Int) { - "update task set status = $1 where id = $2" - |> pog.query - |> pog.parameter(pog.text(status)) - |> pog.parameter(pog.int(task_id)) - |> pog.execute(db) -} +/// Runs the `update_task_status` query +/// defined in `./src/sql/update_task_status.sql`. +/// +/// > 🐿️ This function was generated automatically using v4.7.0 of +/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). +/// +pub fn update_task_status( + db: pog.Connection, + status: String, + id: Int, +) -> Result(pog.Returned(Nil), pog.QueryError) { + let decoder = decode.map(decode.dynamic, fn(_) { Nil }) -pub fn delete_task(db: pog.Connection, task_id: Int) { - "delete from task where id = $1" + "update task +set status = $1 +where id = $2 +" |> pog.query - |> pog.parameter(pog.int(task_id)) + |> pog.parameter(pog.text(status)) + |> pog.parameter(pog.int(id)) + |> pog.returning(decoder) |> pog.execute(db) } -pub fn delete_project(db: pog.Connection, project_id: Int) { - "delete from project where id = $1" - |> pog.query - |> pog.parameter(pog.int(project_id)) - |> pog.execute(db) -} +// --- Enums ------------------------------------------------------------------- -pub fn create_session(db: pog.Connection, token: String, user_id: Int) { - "insert into session (token, user_id, expires_at) values ($1, $2, now() + interval '30 days')" - |> pog.query - |> pog.parameter(pog.text(token)) - |> pog.parameter(pog.int(user_id)) - |> pog.execute(db) +/// Corresponds to the Postgres `subteam` enum. +/// +/// > 🐿️ This type definition was generated automatically using v4.7.0 of the +/// > [squirrel package](https://github.com/giacomocavalieri/squirrel). +/// +pub type Subteam { + None + Operations + Machining + Cad + Software + Electrical } -pub fn find_session(db: pog.Connection, token: String) { - "select user_id from session where token = $1 and expires_at > now()" - |> pog.query - |> pog.parameter(pog.text(token)) - |> pog.returning(decode.field(0, decode.int, decode.success)) - |> pog.execute(db) +fn subteam_encoder(subteam) -> pog.Value { + case subteam { + None -> "none" + Operations -> "operations" + Machining -> "machining" + Cad -> "CAD" + Software -> "software" + Electrical -> "electrical" + } + |> pog.text } diff --git a/server/src/sql/archive_project.sql b/server/src/sql/archive_project.sql new file mode 100644 index 0000000..2e0564c --- /dev/null +++ b/server/src/sql/archive_project.sql @@ -0,0 +1,3 @@ +update project +set archived = $1 +where id = $2 diff --git a/server/src/sql/create_task.sql b/server/src/sql/create_task.sql index b018059..12c089a 100644 --- a/server/src/sql/create_task.sql +++ b/server/src/sql/create_task.sql @@ -1,3 +1,11 @@ -insert into task (project_id, title, description, assignee, assignees, due_date) -values ($1, $2, $3, $4, $5, nullif($6, '')::date) +insert into task ( + project_id, + title, + description, + assignee, + assignees, + subteams, + due_date +) +values ($1, $2, $3, $4, $5, $6::subteam[], nullif($7, '')::date) returning id diff --git a/server/src/sql/create_user.sql b/server/src/sql/create_user.sql index 20947f2..5145b27 100644 --- a/server/src/sql/create_user.sql +++ b/server/src/sql/create_user.sql @@ -1,4 +1,4 @@ -insert into app_user (username, password_hash) +insert into app_users (username, password_hash) values ($1, $2) on conflict (username) do nothing returning id diff --git a/server/src/sql/delete_project.sql b/server/src/sql/delete_project.sql new file mode 100644 index 0000000..7eefb46 --- /dev/null +++ b/server/src/sql/delete_project.sql @@ -0,0 +1,2 @@ +delete from project +where id = $1 diff --git a/server/src/sql/delete_task.sql b/server/src/sql/delete_task.sql new file mode 100644 index 0000000..bfa6d55 --- /dev/null +++ b/server/src/sql/delete_task.sql @@ -0,0 +1,2 @@ +delete from task +where id = $1 diff --git a/server/src/sql/find_user.sql b/server/src/sql/find_user.sql index 9c26aa1..61e013e 100644 --- a/server/src/sql/find_user.sql +++ b/server/src/sql/find_user.sql @@ -1,3 +1,3 @@ select id, username, password_hash -from app_user +from app_users where username = $1 diff --git a/server/src/sql/find_username.sql b/server/src/sql/find_username.sql new file mode 100644 index 0000000..4499ccc --- /dev/null +++ b/server/src/sql/find_username.sql @@ -0,0 +1,3 @@ +select username +from app_users +where id = $1 diff --git a/server/src/sql/list_projects.sql b/server/src/sql/list_projects.sql index 062fff1..40da26a 100644 --- a/server/src/sql/list_projects.sql +++ b/server/src/sql/list_projects.sql @@ -1,3 +1,3 @@ -select id, name, description +select id, name, description, archived from project -order by created_at desc, id desc +order by archived asc, created_at desc, id desc diff --git a/server/src/sql/list_tasks.sql b/server/src/sql/list_tasks.sql index 68a1290..27b0807 100644 --- a/server/src/sql/list_tasks.sql +++ b/server/src/sql/list_tasks.sql @@ -1,4 +1,13 @@ -select id, project_id, title, description, status, assignee, assignees, coalesce(due_date::text, '') +select + id, + project_id, + title, + description, + status, + assignee, + assignees, + coalesce(array_to_string(subteams, ','), '') as subteams, + coalesce(due_date::text, '') as due_date from task where project_id = $1 order by diff --git a/server/static/client.js b/server/static/client.js index 2ef563d..84839b5 100644 --- a/server/static/client.js +++ b/server/static/client.js @@ -1571,6 +1571,9 @@ function pop_codeunit(str) { function lowercase(string4) { return string4.toLowerCase(); } +function less_than(a, b) { + return a < b; +} function split(xs, pattern) { return arrayToList(xs.split(pattern)); } @@ -1908,6 +1911,19 @@ var Direction$Leading$const = new Leading; class Trailing extends CustomType { } var Direction$Trailing$const = new Trailing; +function compare2(a, b) { + let $ = a === b; + if ($) { + return Order$Eq$const; + } else { + let $1 = less_than(a, b); + if ($1) { + return Order$Lt$const; + } else { + return Order$Gt$const; + } + } +} function split2(x, substring) { if (substring === "") { return graphemes(x); @@ -2264,7 +2280,7 @@ function singleton_list(item) { var GT = /* @__PURE__ */ Order$Gt(); var LT = /* @__PURE__ */ Order$Lt(); var EQ = /* @__PURE__ */ Order$Eq(); -function compare2(a, b) { +function compare3(a, b) { if (a.name === b.name) { return EQ; } else if (a.name < b.name) { @@ -2459,7 +2475,7 @@ function prepare(attributes) { } else { let _pipe = attributes; let _pipe$1 = sort(_pipe, (a, b) => { - return compare2(b, a); + return compare3(b, a); }); return merge(_pipe$1, empty_list); } @@ -3952,7 +3968,7 @@ function diff_attributes(loop$controlled, loop$path, loop$events, loop$old, loop let remaining_old = old.tail; let next = new$5.head; let remaining_new = new$5.tail; - let $ = compare2(prev, next); + let $ = compare3(prev, next); if ($ instanceof Lt) { if (prev instanceof Event2) { let name2 = prev.name; @@ -7101,11 +7117,12 @@ function delete$2(url, body2, handler) { } // build/dev/javascript/shared/shared.mjs class Project extends CustomType { - constructor(id, name2, description, tasks) { + constructor(id, name2, description, archived, tasks) { super(); this.id = id; this.name = name2; this.description = description; + this.archived = archived; this.tasks = tasks; } } @@ -7187,8 +7204,10 @@ function project_decoder() { return field("id", int2, (id) => { return field("name", string3, (name2) => { return field("description", string3, (description) => { - return field("tasks", list2(task_decoder()), (tasks) => { - return success(new Project(id, name2, description, tasks)); + return field("archived", bool, (archived) => { + return field("tasks", list2(task_decoder()), (tasks) => { + return success(new Project(id, name2, description, archived, tasks)); + }); }); }); }); @@ -7399,6 +7418,18 @@ var Message$LogOut$const = new LogOut; class Nothing extends CustomType { } var Message$Nothing$const = new Nothing; +class ArchiveProject extends CustomType { + constructor($0) { + super(); + this[0] = $0; + } +} +class UnarchiveProject extends CustomType { + constructor($0) { + super(); + this[0] = $0; + } +} function subteam_checkbox(selected, value3, label2) { return label(toList([ class$("subteam-option"), @@ -7544,7 +7575,13 @@ function kanban_column(title2, tasks) { h3(List$Empty$const, toList([ text3(title2 + " · " + to_string(length(tasks))) ])) - ]), map2(tasks, task_view))); + ]), (() => { + let _pipe = tasks; + let _pipe$1 = sort(_pipe, (a2, b) => { + return compare2(a2.due_date, b.due_date); + }); + return map2(_pipe$1, task_view); + })())); } function task_matches(model, task) { let search2 = lowercase(trim(model.task_search)); @@ -7570,7 +7607,15 @@ function project_view(model, project) { text3("PROJECT"), h2(List$Empty$const, toList([text3(project.name)])), p(List$Empty$const, toList([text3(project.description)])), - button(toList([on_click(new DeleteProject(project.id))]), toList([text3("Delete project")])) + button(toList([on_click(new DeleteProject(project.id))]), toList([text3("Delete project")])), + (() => { + let $ = project.archived; + if ($) { + return button(toList([on_click(new UnarchiveProject(project.id))]), toList([text3("Unarchive project")])); + } else { + return button(toList([on_click(new ArchiveProject(project.id))]), toList([text3("Archive project")])); + } + })() ]), append(toList([ div(toList([class$("kanban")]), toList([ kanban_column("To do", backlog), @@ -7617,6 +7662,41 @@ function project_view(model, project) { ])) ])))); } +function project_sections(model, projects) { + let _block; + let _pipe = filter(projects, (project) => { + return !project.archived; + }); + _block = map2(_pipe, (project) => { + return project_view(model, project); + }); + let active = _block; + let _block$1; + let _pipe$1 = filter(projects, (project) => { + return project.archived; + }); + _block$1 = map2(_pipe$1, (project) => { + return project_view(model, project); + }); + let archived = _block$1; + if (archived instanceof Empty) { + return active; + } else { + return append(active, prepend(section(toList([ + class$("archived-projects"), + styles(toList([ + ["margin-top", "42px"], + ["padding-top", "20px"], + ["border-top", "2px solid #d9cfc2"] + ])) + ]), toList([ + h2(List$Empty$const, toList([text3("Archived projects")])), + p(List$Empty$const, toList([ + text3("Projects kept for reference and no longer active.") + ])) + ])), archived)); + } +} function styles2() { return style2(List$Empty$const, "body{margin:0;background:#f4f0e8;color:#28231e;font-family:Inter,ui-sans-serif,system-ui,sans-serif}.page{max-width:1120px;margin:auto;padding:28px 22px 72px}.auth{max-width:430px;margin:10vh auto;padding:42px;background:#fff;border:1px solid #e2d9cc;box-shadow:0 20px 60px #2d241414}.auth h1{font-size:clamp(2.4rem,8vw,4.8rem);line-height:.95;letter-spacing:-.06em;margin:14px 0}.auth form{display:grid;gap:12px;margin:26px 0 14px}.auth a{color:rgb(255,100,0);font-weight:700}.eyebrow{font-size:.72rem;letter-spacing:.16em;color:rgb(255,100,0);font-weight:800}header{display:flex;justify-content:space-between;gap:24px;align-items:start;margin-bottom:42px}header h1{font-size:clamp(2.7rem,7vw,5.5rem);line-height:.9;letter-spacing:-.07em;margin:12px 0}.hero{padding:42px;background:linear-gradient(120deg,#29241f,#594538);color:#fff;margin-bottom:20px;font-size:clamp(1.5rem,3vw,2.5rem);font-weight:800;letter-spacing:-.04em}.panel{background:#fff;border:1px solid #e2d9cc;padding:24px;margin:18px 0;box-shadow:0 12px 30px #2d24140b}.panel h2{margin:5px 0 8px;letter-spacing:-.03em}.panel>p{color:#796f64}.kanban{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:22px}.kanban-column{background:#f7f3ed;border:1px solid #ebe1d4;padding:13px}.kanban-column h3{font-size:.8rem;letter-spacing:.08em;text-transform:uppercase;color:#796f64;margin:0 0 5px}.task{display:flex;flex-direction:column;gap:13px;background:#fff;border:1px solid #e8dfd4;padding:16px;margin:9px 0;box-shadow:0 4px 12px #2d24140a;min-width:0}.task-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.task-title{font-size:1rem;line-height:1.25;overflow-wrap:anywhere}.delete-task{flex:0 0 auto;padding:5px 8px;font-size:.68rem;background:#f0ebe3;color:#796f64}.delete-task:hover{background:#e8dfd4;color:#28231e}.task-description{margin:0;color:#796f64;line-height:1.45;overflow-wrap:anywhere}.task-details{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:11px 14px;padding-top:12px;border-top:1px solid #eee6dc;color:#4e463e;font-size:.8rem}.task-person,.task-due{display:flex;flex-direction:column;gap:4px;min-width:0;overflow-wrap:anywhere}.task-details small{color:#000000;font-size:.68rem;font-weight:800;letter-spacing:.06em;text-transform:uppercase}.task-due{grid-column:1/-1}.task-footer{display:flex;align-items:center;justify-content:space-between;gap:10px;padding-top:2px}.task-status{padding:5px 8px;background:#f0ebe3;color:#796f64;font-size:.68rem;font-weight:800;letter-spacing:.06em;text-transform:uppercase}.task-actions{display:flex;gap:6px;margin-left:auto}.task-actions form{margin:0}.move-task{width:31px;height:29px;padding:0;font-size:1.05rem;line-height:1}.move-task:hover{background:#902e1d}form{display:flex;gap:10px;flex-wrap:wrap;margin-top:22px}input:not([type=\"checkbox\"]){min-width:150px;flex:1;padding:12px 13px;border:1px solid #d9cfc2;background:#fffcf8;color:#28231e;font:inherit}input[type=\"checkbox\"]{min-width:16px;width:16px;height:16px;flex:0 0 16px;margin:0;padding:0}button{border:0;background:rgb(255,100,0);color:#fff;padding:12px 17px;font:inherit;font-weight:800;cursor:pointer}button:hover{background:#902e1d}.error{color:rgb(255,100,0);font-weight:700}a{color:inherit;text-decoration:none}@media(max-width:800px){.kanban{grid-template-columns:1fr 1fr}}@media(max-width:650px){.kanban{grid-template-columns:1fr}.task-details{grid-template-columns:1fr}.task-due{grid-column:auto}header{display:block}.hero{padding:28px}}"); } @@ -7691,9 +7771,7 @@ function dashboard_view(model) { button(List$Empty$const, toList([text3("Add project")])) ])) ])) - ]), map2(board.projects, (project) => { - return project_view(model, project); - }))); + ]), project_sections(model, board.projects))); } else { return main(toList([class$("auth")]), toList([ h1(List$Empty$const, toList([text3("Loading Iron Claw")])), @@ -7833,7 +7911,7 @@ function remove_task(model, task_id) { let tasks = filter(project.tasks, (task) => { return task.id !== task_id; }); - return new Project(project.id, project.name, project.description, tasks); + return new Project(project.id, project.name, project.description, project.archived, tasks); }); return new Model(model.page, new Some(new Dashboard(board.username, projects)), model.error, model.username, model.password, model.invite_password, model.project_name, model.project_description, model.task_title, model.task_description, model.task_assignee, model.task_assignees, model.task_subteams, model.task_due_date, model.subteam_filter, model.task_search); } else { @@ -7853,7 +7931,7 @@ function update_task_status(model, task_id, status) { return task; } }); - return new Project(project.id, project.name, project.description, tasks); + return new Project(project.id, project.name, project.description, project.archived, tasks); }); return new Model(model.page, new Some(new Dashboard(board.username, projects)), model.error, model.username, model.password, model.invite_password, model.project_name, model.project_description, model.task_title, model.task_description, model.task_assignee, model.task_assignees, model.task_subteams, model.task_due_date, model.subteam_filter, model.task_search); } else { @@ -8123,8 +8201,34 @@ function update2(model, message2) { return Message$Nothing$const; })) ]; - } else { + } else if (message2 instanceof Nothing) { return [model, none()]; + } else if (message2 instanceof ArchiveProject) { + let project_id = message2[0]; + return [ + model, + get3("/api/archive/" + to_string(project_id), expect_any_response((result) => { + return new Saved((() => { + let _pipe = result; + return map4(_pipe, (response) => { + return response.body; + }); + })()); + })) + ]; + } else { + let project_id = message2[0]; + return [ + model, + get3("/api/unarchive/" + to_string(project_id), expect_any_response((result) => { + return new Saved((() => { + let _pipe = result; + return map4(_pipe, (response) => { + return response.body; + }); + })()); + })) + ]; } } function current_path() { diff --git a/shared/src/shared.gleam b/shared/src/shared.gleam index 196f3f5..ec8f0de 100644 --- a/shared/src/shared.gleam +++ b/shared/src/shared.gleam @@ -2,7 +2,13 @@ import gleam/dynamic/decode import gleam/json pub type Project { - Project(id: Int, name: String, description: String, tasks: List(Task)) + Project( + id: Int, + name: String, + description: String, + archived: Bool, + tasks: List(Task), + ) } pub type Task { @@ -56,8 +62,9 @@ fn project_decoder() -> decode.Decoder(Project) { use id <- decode.field("id", decode.int) use name <- decode.field("name", decode.string) use description <- decode.field("description", decode.string) + use archived <- decode.field("archived", decode.bool) use tasks <- decode.field("tasks", decode.list(task_decoder())) - decode.success(Project(id:, name:, description:, tasks:)) + decode.success(Project(id:, name:, description:, archived:, tasks:)) } fn task_decoder() -> decode.Decoder(Task) { @@ -114,6 +121,7 @@ fn project_json(value: Project) -> json.Json { #("id", json.int(value.id)), #("name", json.string(value.name)), #("description", json.string(value.description)), + #("archived", json.bool(value.archived)), #("tasks", json.array(value.tasks, task_json)), ]) } -- 2.47.3