]> git.taranathan.com Git - robotics-task-manager.git/commitdiff
sort tasks by date and add archival
authorTaran Nathan <moogoesmeow123@gmail.com>
Fri, 25 Sep 2026 16:50:11 +0000 (09:50 -0700)
committerTaran Nathan <moogoesmeow123@gmail.com>
Fri, 25 Sep 2026 17:38:27 +0000 (10:38 -0700)
15 files changed:
client/src/client.gleam
server/db/init.sql
server/src/server.gleam
server/src/sql.gleam
server/src/sql/archive_project.sql [new file with mode: 0644]
server/src/sql/create_task.sql
server/src/sql/create_user.sql
server/src/sql/delete_project.sql [new file with mode: 0644]
server/src/sql/delete_task.sql [new file with mode: 0644]
server/src/sql/find_user.sql
server/src/sql/find_username.sql [new file with mode: 0644]
server/src/sql/list_projects.sql
server/src/sql/list_tasks.sql
server/static/client.js
shared/src/shared.gleam

index f45687850f2eb70bdb67fb708aad3ddf70ec3064..53923e8d0b5aab30ffa8f81359a526f48b4f7854 100644 (file)
@@ -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),
     ),
   )
 }
index 5f044b2031007bbaaecbdf9d08e59983d19ae739..92900bbf262286432312c2aa9a8a728ecb59d88e 100644 (file)
@@ -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,
index 350a2e8dea5b35db0bbf68680d05efee200170be..95b76b509a9090ec6f519b79a30b24ce9cc824ac 100644 (file)
@@ -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),
index f6033d01a4183084d56c576da30d7fecb8802956..2d5ee9afe8b5b5492acab8c2ffd1e910a8e3dfd4 100644 (file)
+//// 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 (file)
index 0000000..2e0564c
--- /dev/null
@@ -0,0 +1,3 @@
+update project
+set archived = $1
+where id = $2
index b018059bec36bc6303619f315b99bb2e49311d17..12c089a699ee56e4bffe40dd3a195ecb708fce87 100644 (file)
@@ -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
index 20947f268462e452840a678c64de99ce144ed308..5145b277d4cc37b093e2aeb83ac4c1236bd127fc 100644 (file)
@@ -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 (file)
index 0000000..7eefb46
--- /dev/null
@@ -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 (file)
index 0000000..bfa6d55
--- /dev/null
@@ -0,0 +1,2 @@
+delete from task
+where id = $1
index 9c26aa1e618c527c0be33fe56250d463fbbb1380..61e013ebfd5f5a9f28d1fbacc57a355ab3c91457 100644 (file)
@@ -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 (file)
index 0000000..4499ccc
--- /dev/null
@@ -0,0 +1,3 @@
+select username
+from app_users
+where id = $1
index 062fff1f2856a38e1cef6f3618276b6aea3bff0a..40da26a7a833da969b66a3a8e1a9a8099ce6ca05 100644 (file)
@@ -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
index 68a1290470b4caf84e9adea9ccf0fe7b98599bb3..27b08074413c9f68b6fe1c39a932c14f23258328 100644 (file)
@@ -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
index 2ef563dd3e11dfbd61743ce71e13e2c832df2e03..84839b5727937bbb709fe89d69f1444bfb013a5b 100644 (file)
@@ -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() {
index 196f3f5196843c1b6ccbb8627226eda016a39034..ec8f0de56184779c5a5fdc50bddf451bd63b00b4 100644 (file)
@@ -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)),
   ])
 }