DeleteProject(Int)
LogOut
Nothing
+ ArchiveProject(Int)
+ UnarchiveProject(Int)
}
fn init(_flags: Option(Nil)) -> #(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(
]),
]),
],
- 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" })
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(
[
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),
),
)
}
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,
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
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),
)
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)
task.description,
task.assignee,
task.assignees,
- task.subteams,
+ list.map(task.subteams, to_sql_subteam),
task.due_date,
)
wisp.json_response("{\"ok\":true}", 201)
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,
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),
+//// 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)
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:,
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
}
--- /dev/null
+update project
+set archived = $1
+where id = $2
-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
-insert into app_user (username, password_hash)
+insert into app_users (username, password_hash)
values ($1, $2)
on conflict (username) do nothing
returning id
--- /dev/null
+delete from project
+where id = $1
--- /dev/null
+delete from task
+where id = $1
select id, username, password_hash
-from app_user
+from app_users
where username = $1
--- /dev/null
+select username
+from app_users
+where id = $1
-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
-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
function lowercase(string4) {
return string4.toLowerCase();
}
+function less_than(a, b) {
+ return a < b;
+}
function split(xs, pattern) {
return arrayToList(xs.split(pattern));
}
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);
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) {
} 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);
}
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;
}
// 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;
}
}
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));
+ });
});
});
});
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"),
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));
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),
]))
]))));
}
+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}}");
}
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")])),
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 {
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 {
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() {
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 {
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) {
#("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)),
])
}