]> git.taranathan.com Git - robotics-task-manager.git/commitdiff
sort by subteam and person search
authorTaran Nathan <moogoesmeow123@gmail.com>
Fri, 25 Sep 2026 03:47:28 +0000 (20:47 -0700)
committerTaran Nathan <moogoesmeow123@gmail.com>
Fri, 25 Sep 2026 16:14:28 +0000 (09:14 -0700)
client/src/client.gleam
server/db/init.sql
server/src/server.gleam
server/src/sql.gleam
server/static/client.js
shared/src/shared.gleam

index a47dc990c586cb99d0ebe8369a8caa6a008c866a..4e14378d5977e11ad98392a3f66af08cf07b1efc 100644 (file)
@@ -3,6 +3,7 @@ import gleam/json
 import gleam/list
 import gleam/option.{type Option, None, Some}
 import gleam/result
+import gleam/string
 import lustre
 import lustre/attribute
 import lustre/effect.{type Effect}
@@ -20,6 +21,10 @@ pub fn main() -> Nil {
   Nil
 }
 
+fn join_subteams(subteams: List(String)) -> String {
+  string.join(subteams, ", ")
+}
+
 type Model {
   Model(
     page: String,
@@ -34,7 +39,10 @@ type Model {
     task_description: String,
     task_assignee: String,
     task_assignees: String,
+    task_subteams: List(String),
     task_due_date: String,
+    subteam_filter: String,
+    task_search: String,
   )
 }
 
@@ -56,6 +64,9 @@ pub type Message {
   TaskAssigneeChanged(String)
   TaskAssigneesChanged(String)
   TaskDueDateChanged(String)
+  TaskSubteamChanged(String, Bool)
+  SubteamFilterChanged(String)
+  TaskSearchChanged(String)
   InvitePasswordChanged(String)
   DeleteTask(Int)
   DeleteProject(Int)
@@ -84,7 +95,10 @@ fn init(_flags: Option(Nil)) -> #(Model, Effect(Message)) {
       task_description: "",
       task_assignee: "",
       task_assignees: "",
+      task_subteams: [],
       task_due_date: "",
+      subteam_filter: "all",
+      task_search: "",
     )
 
   case page {
@@ -151,6 +165,21 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) {
       Model(..model, task_due_date: value),
       effect.none(),
     )
+    TaskSubteamChanged(subteam, checked) -> #(
+      Model(
+        ..model,
+        task_subteams: toggle_subteam(model.task_subteams, subteam, checked),
+      ),
+      effect.none(),
+    )
+    SubteamFilterChanged(value) -> #(
+      Model(..model, subteam_filter: value),
+      effect.none(),
+    )
+    TaskSearchChanged(value) -> #(
+      Model(..model, task_search: value),
+      effect.none(),
+    )
     LoginSubmitted(fields) -> auth_request(model, fields, "/api/login", "/")
     RegisterSubmitted(fields) ->
       auth_request(model, fields, "/api/register", "/")
@@ -176,6 +205,7 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) {
           field(fields, "description"),
           field(fields, "assignee"),
           field(fields, "assignees"),
+          model.task_subteams,
           field(fields, "due_date"),
         )
         |> shared.new_task_json
@@ -186,6 +216,7 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) {
           task_description: "",
           task_assignee: "",
           task_assignees: "",
+          task_subteams: [],
           task_due_date: "",
         ),
         rsvp.post(
@@ -251,6 +282,7 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) {
         task_description: "",
         task_assignee: "",
         task_assignees: "",
+        task_subteams: [],
         task_due_date: "",
         invite_password: "",
       ),
@@ -326,6 +358,38 @@ fn remove_project(model: Model, project_id: Int) -> Model {
   }
 }
 
+fn task_matches(model: Model, task: shared.Task) -> Bool {
+  let search = string.lowercase(string.trim(model.task_search))
+  let people = string.lowercase(task.assignee <> " " <> task.assignees)
+  let search_matches = search == "" || string.contains(people, search)
+  let subteam_matches =
+    model.subteam_filter == "all"
+    || list.contains(task.subteams, model.subteam_filter)
+  search_matches && subteam_matches
+}
+
+fn toggle_subteam(
+  selected: List(String),
+  subteam: String,
+  checked: Bool,
+) -> List(String) {
+  case checked {
+    True ->
+      case subteam {
+        "none" -> ["none"]
+        _ ->
+          case list.contains(selected, subteam) {
+            True -> list.filter(selected, fn(item) { item != "none" })
+            False -> [
+              subteam,
+              ..list.filter(selected, fn(item) { item != "none" })
+            ]
+          }
+      }
+    False -> list.filter(selected, fn(item) { item != subteam })
+  }
+}
+
 fn auth_request(
   model: Model,
   fields: List(#(String, String)),
@@ -476,6 +540,44 @@ fn dashboard_view(model: Model) -> Element(Message) {
             html.section([attribute.class("hero")], [
               html.text("wassup"),
             ]),
+            html.section([attribute.class("panel")], [
+              html.h2([], [html.text("Find tasks")]),
+              html.input([
+                attribute.class("task-search"),
+                attribute.styles([
+                  #("width", "100%"),
+                  #("box-sizing", "border-box"),
+                ]),
+                attribute.placeholder("Search owner or assignees"),
+                event.on_input(TaskSearchChanged),
+              ]),
+              html.select(
+                [
+                  attribute.class("subteam-filter"),
+                  attribute.styles([
+                    #("width", "100%"),
+                    #("box-sizing", "border-box"),
+                    #("padding", "12px 13px"),
+                    #("border", "1px solid #d9cfc2"),
+                    #("border-radius", "0"),
+                    #("background", "#fffcf8"),
+                    #("color", "#28231e"),
+                    #("font", "inherit"),
+                    #("appearance", "auto"),
+                  ]),
+                  event.on_change(SubteamFilterChanged),
+                ],
+                [
+                  html.option([attribute.value("all")], "All subteams"),
+                  html.option([attribute.value("none")], "No subteam"),
+                  html.option([attribute.value("electrical")], "Electrical"),
+                  html.option([attribute.value("software")], "Software"),
+                  html.option([attribute.value("CAD")], "CAD"),
+                  html.option([attribute.value("machining")], "Machining"),
+                  html.option([attribute.value("operations")], "Operations"),
+                ],
+              ),
+            ]),
             html.section([attribute.class("panel")], [
               html.h2([], [html.text("New project")]),
               html.form([event.on_submit(ProjectSubmitted)], [
@@ -491,10 +593,11 @@ fn dashboard_view(model: Model) -> Element(Message) {
   }
 }
 
-fn project_view(_model: Model, project: shared.Project) -> Element(Message) {
-  let backlog = list.filter(project.tasks, fn(task) { task.status == "todo" })
-  let doing = list.filter(project.tasks, fn(task) { task.status == "doing" })
-  let done = list.filter(project.tasks, fn(task) { task.status == "done" })
+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" })
+  let doing = list.filter(tasks, fn(task) { task.status == "doing" })
+  let done = list.filter(tasks, fn(task) { task.status == "done" })
   html.article(
     [attribute.class("panel")],
     list.append(
@@ -522,6 +625,7 @@ fn project_view(_model: Model, project: shared.Project) -> Element(Message) {
                 #("display", "grid"),
                 #("grid-template-columns", "repeat(4, minmax(0, 1fr))"),
                 #("align-items", "start"),
+                #("gap", "10px"),
               ]),
               event.on_submit(fn(fields) { TaskSubmitted(project.id, fields) }),
             ],
@@ -530,6 +634,7 @@ fn project_view(_model: Model, project: shared.Project) -> Element(Message) {
               text_input("assignee", "Owner"),
               text_input("assignees", "Assignees"),
               text_input("due_date", "Due (MM/DD/YY)"),
+              subteam_checkboxes(model.task_subteams),
               html.textarea(
                 [
                   attribute.class("task-description-input"),
@@ -576,6 +681,75 @@ fn kanban_column(title: String, tasks: List(shared.Task)) -> Element(Message) {
   )
 }
 
+fn subteam_checkboxes(selected: List(String)) -> Element(Message) {
+  html.fieldset(
+    [
+      attribute.class("subteam-picker"),
+      attribute.styles([
+        #("grid-column", "1 / -1"),
+        #("display", "block"),
+        #("margin", "0"),
+        #("padding", "12px"),
+        #("border", "1px solid #d9cfc2"),
+        #("background", "#fffcf8"),
+      ]),
+    ],
+    [
+      html.legend([], [html.text("Subteams")]),
+      html.div(
+        [
+          attribute.class("subteam-options"),
+          attribute.styles([
+            #("display", "grid"),
+            #("grid-template-columns", "repeat(3, minmax(0, 1fr))"),
+            #("column-gap", "32px"),
+            #("row-gap", "12px"),
+            #("align-items", "center"),
+          ]),
+        ],
+        [
+          subteam_checkbox(selected, "none", "No subteam"),
+          subteam_checkbox(selected, "electrical", "Electrical"),
+          subteam_checkbox(selected, "software", "Software"),
+          subteam_checkbox(selected, "CAD", "CAD"),
+          subteam_checkbox(selected, "machining", "Machining"),
+          subteam_checkbox(selected, "operations", "Operations"),
+        ],
+      ),
+    ],
+  )
+}
+
+fn subteam_checkbox(
+  selected: List(String),
+  value: String,
+  label: String,
+) -> Element(Message) {
+  html.label(
+    [
+      attribute.class("subteam-option"),
+      attribute.styles([
+        #("display", "flex"),
+        #("align-items", "center"),
+        #("gap", "8px"),
+        #("color", "#4e463e"),
+        #("font-size", ".82rem"),
+        #("line-height", "1.2"),
+        #("cursor", "pointer"),
+      ]),
+    ],
+    [
+      html.input([
+        attribute.type_("checkbox"),
+        attribute.value(value),
+        attribute.checked(list.contains(selected, value)),
+        event.on_check(fn(checked) { TaskSubteamChanged(value, checked) }),
+      ]),
+      html.text(label),
+    ],
+  )
+}
+
 fn task_view(task: shared.Task) -> Element(Message) {
   html.div([attribute.class("task")], [
     html.div([attribute.class("task-header")], [
@@ -603,6 +777,10 @@ fn task_view(task: shared.Task) -> Element(Message) {
         html.small([], [html.text("Assignees")]),
         html.text(if_blank(task.assignees, "None")),
       ]),
+      html.div([attribute.class("task-person")], [
+        html.small([], [html.text("Subteams")]),
+        html.text(if_blank(join_subteams(task.subteams), "No subteam")),
+      ]),
       html.div([attribute.class("task-due")], [
         html.small([], [html.text("Due")]),
         html.text(case task.due_date {
@@ -668,7 +846,7 @@ fn text_input(name: String, placeholder: String) -> Element(Message) {
 fn styles() -> Element(Message) {
   html.style(
     [],
-    "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{min-width:150px;flex:1;padding:12px 13px;border:1px solid #d9cfc2;background:#fffcf8;color:#28231e;font:inherit}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}}",
+    "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}}",
   )
 }
 
index af1fa9d69756fd913d5620c9c7cb3f114cba45d8..5f044b2031007bbaaecbdf9d08e59983d19ae739 100644 (file)
@@ -20,11 +20,13 @@ create table if not exists task (
   status text not null default 'todo' check (status in ('todo', 'doing', 'done')),
   assignee text not null default '',
   assignees text not null default '',
+  subteams subteam[] not null default '{}'::subteam[],
   due_date date,
   created_at timestamptz not null default now()
 );
 
 alter table task add column if not exists assignees text not null default '';
+alter table task add column if not exists subteams subteam[] not null default '{}'::subteam[];
 
 create table if not exists session (
   token text primary key,
index 2cfde07e9e425851f2b4ef3ed6fbf2f393df2b20..350a2e8dea5b35db0bbf68680d05efee200170be 100644 (file)
@@ -23,6 +23,7 @@ const signup_password = "cremebrulee"
 pub fn main() -> Nil {
   wisp.configure_logger()
   let assert Ok(db) = database.start()
+  let assert Ok(_) = create_subteam_type(db)
   let assert Ok(schema) = simplifile.read("./db/init.sql")
   let assert Ok(_) = initialize_schema(db, schema)
   let secret =
@@ -42,6 +43,18 @@ pub fn main() -> Nil {
   process.sleep_forever()
 }
 
+fn create_subteam_type(db: Database) -> Result(Nil, pog.QueryError) {
+  case
+    "create type subteam as enum ('electrical', 'software', 'CAD', 'machining', 'operations', 'none')"
+    |> pog.query
+    |> pog.execute(db.connection)
+  {
+    Ok(_) -> Ok(Nil)
+    Error(pog.PostgresqlError("42710", _, _)) -> Ok(Nil)
+    Error(error) -> Error(error)
+  }
+}
+
 fn initialize_schema(
   db: Database,
   schema: String,
@@ -259,6 +272,7 @@ fn add_task(
           task.description,
           task.assignee,
           task.assignees,
+          task.subteams,
           task.due_date,
         )
       wisp.json_response("{\"ok\":true}", 201)
@@ -317,6 +331,7 @@ fn to_shared_task(task: sql.Task) -> shared.Task {
     status: task.status,
     assignee: task.assignee,
     assignees: task.assignees,
+    subteams: task.subteams,
     due_date: task.due_date,
   )
 }
index 165e4732f429f828c5ca4a49c4a1d6b166a87906..f6033d01a4183084d56c576da30d7fecb8802956 100644 (file)
@@ -1,4 +1,5 @@
 import gleam/dynamic/decode
+import gleam/string
 import pog
 
 pub type User {
@@ -18,6 +19,7 @@ pub type Task {
     status: String,
     assignee: String,
     assignees: String,
+    subteams: List(String),
     due_date: String,
   )
 }
@@ -83,7 +85,7 @@ pub fn create_project(db: pog.Connection, name: String, description: String) {
 }
 
 pub fn list_tasks(db: pog.Connection, project_id: Int) {
-  "select id, project_id, title, description, status, assignee, assignees, 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"
+  "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({
@@ -94,7 +96,8 @@ pub fn list_tasks(db: pog.Connection, project_id: Int) {
     use status <- decode.field(4, decode.string)
     use assignee <- decode.field(5, decode.string)
     use assignees <- decode.field(6, decode.string)
-    use due_date <- decode.field(7, decode.string)
+    use subteams <- decode.field(7, decode.string)
+    use due_date <- decode.field(8, decode.string)
     decode.success(Task(
       id:,
       project_id:,
@@ -103,6 +106,7 @@ pub fn list_tasks(db: pog.Connection, project_id: Int) {
       status:,
       assignee:,
       assignees:,
+      subteams: string.split(subteams, ","),
       due_date:,
     ))
   })
@@ -116,15 +120,17 @@ pub fn create_task(
   description: String,
   assignee: String,
   assignees: String,
+  subteams: List(String),
   due_date: String,
 ) {
-  "insert into task (project_id, title, description, assignee, assignees, due_date) values ($1, $2, $3, $4, $5, nullif($6, '')::date) returning 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.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.execute(db)
index cfce598af054dbc81c53303525f85aedb899bcf5..86e9b76718b0ef3e4c58dbf81ef22cef6f43a350 100644 (file)
@@ -805,6 +805,24 @@ function reverse_and_prepend(loop$prefix, loop$suffix) {
 function reverse(list) {
   return reverse_and_prepend(list, List$Empty$const);
 }
+function contains(loop$list, loop$elem) {
+  while (true) {
+    let list = loop$list;
+    let elem = loop$elem;
+    if (list instanceof Empty) {
+      return false;
+    } else {
+      let first$1 = list.head;
+      if (isEqual(first$1, elem)) {
+        return true;
+      } else {
+        let rest$1 = list.tail;
+        loop$list = rest$1;
+        loop$elem = elem;
+      }
+    }
+  }
+}
 function filter_loop(loop$list, loop$fun, loop$acc) {
   while (true) {
     let list = loop$list;
@@ -1324,6 +1342,7 @@ class Decoder extends CustomType {
 var float2 = /* @__PURE__ */ new Decoder(decode_float);
 var int2 = /* @__PURE__ */ new Decoder(decode_int);
 var string3 = /* @__PURE__ */ new Decoder(decode_string);
+var bool = /* @__PURE__ */ new Decoder(decode_bool);
 function run(data, decoder) {
   let $ = decoder.function(data);
   let maybe_invalid_data = $[0];
@@ -1502,9 +1521,27 @@ function success(data) {
     return [data, List$Empty$const];
   });
 }
+function decode_error(expected, found) {
+  return toList([
+    new DecodeError(expected, classify_dynamic(found), List$Empty$const)
+  ]);
+}
 function field(field_name, field_decoder, next) {
   return subfield(toList([field_name]), field_decoder, next);
 }
+function decode_bool(data) {
+  let $ = isEqual(identity(true), data);
+  if ($) {
+    return [true, List$Empty$const];
+  } else {
+    let $1 = isEqual(identity(false), data);
+    if ($1) {
+      return [false, List$Empty$const];
+    } else {
+      return [false, decode_error("Bool", data)];
+    }
+  }
+}
 
 // build/dev/javascript/gleam_stdlib/gleam_stdlib.mjs
 function identity(x) {
@@ -1540,6 +1577,9 @@ function split(xs, pattern) {
 function string_codeunit_slice(str, from2, length2) {
   return str.slice(from2, from2 + length2);
 }
+function contains_string(haystack, needle) {
+  return haystack.indexOf(needle) >= 0;
+}
 function starts_with(haystack, needle) {
   return haystack.startsWith(needle);
 }
@@ -1557,6 +1597,12 @@ var unicode_whitespaces = [
 ].join("");
 var trim_start_regex = /* @__PURE__ */ new RegExp(`^[${unicode_whitespaces}]*`);
 var trim_end_regex = /* @__PURE__ */ new RegExp(`[${unicode_whitespaces}]*$`);
+function trim_start(string4) {
+  return string4.replace(trim_start_regex, "");
+}
+function trim_end(string4) {
+  return string4.replace(trim_end_regex, "");
+}
 function classify_dynamic(data) {
   if (typeof data === "string") {
     return "String";
@@ -1889,6 +1935,36 @@ function concat_loop(loop$strings, loop$accumulator) {
 function concat2(strings) {
   return concat_loop(strings, "");
 }
+function join_loop(loop$strings, loop$separator, loop$accumulator) {
+  while (true) {
+    let strings = loop$strings;
+    let separator = loop$separator;
+    let accumulator = loop$accumulator;
+    if (strings instanceof Empty) {
+      return accumulator;
+    } else {
+      let string4 = strings.head;
+      let strings$1 = strings.tail;
+      loop$strings = strings$1;
+      loop$separator = separator;
+      loop$accumulator = accumulator + separator + string4;
+    }
+  }
+}
+function join(strings, separator) {
+  if (strings instanceof Empty) {
+    return "";
+  } else {
+    let first$1 = strings.head;
+    let rest = strings.tail;
+    return join_loop(rest, separator, first$1);
+  }
+}
+function trim(string4) {
+  let _pipe = string4;
+  let _pipe$1 = trim_start(_pipe);
+  return trim_end(_pipe$1);
+}
 
 // build/dev/javascript/gleam_stdlib/gleam/result.mjs
 function map4(result, fun) {
@@ -1945,6 +2021,14 @@ function object(entries) {
 function identity2(x) {
   return x;
 }
+function array(list3) {
+  const array2 = [];
+  while (List$isNonEmpty(list3)) {
+    array2.push(List$NonEmpty$first(list3));
+    list3 = List$NonEmpty$rest(list3);
+  }
+  return array2;
+}
 function decode(string4) {
   try {
     const result = JSON.parse(string4);
@@ -2067,9 +2151,20 @@ function to_string2(json) {
 function string4(input) {
   return identity2(input);
 }
+function bool2(input) {
+  return identity2(input);
+}
 function object2(entries) {
   return object(entries);
 }
+function preprocessed_array(from2) {
+  return array(from2);
+}
+function array2(entries, inner_type) {
+  let _pipe = entries;
+  let _pipe$1 = map2(_pipe, inner_type);
+  return preprocessed_array(_pipe$1);
+}
 // build/dev/javascript/gleam_erlang/gleam/erlang/process.mjs
 class Normal extends CustomType {
 }
@@ -2378,6 +2473,13 @@ function attribute2(name, value) {
 function property2(name, value) {
   return property(name, value);
 }
+function boolean_attribute(name, value) {
+  if (value) {
+    return attribute2(name, "");
+  } else {
+    return property2(name, bool2(false));
+  }
+}
 function class$(name) {
   return attribute2("class", name);
 }
@@ -2425,6 +2527,9 @@ function styles(properties) {
 function href(url) {
   return attribute2("href", url);
 }
+function checked(is_checked) {
+  return boolean_attribute("checked", is_checked);
+}
 function name(element_name) {
   return attribute2("name", element_name);
 }
@@ -2437,6 +2542,9 @@ function rows(value) {
 function type_(control_type) {
   return attribute2("type", control_type);
 }
+function value(control_value) {
+  return attribute2("value", control_value);
+}
 
 // build/dev/javascript/lustre/lustre/effect.mjs
 class Effect extends CustomType {
@@ -2496,9 +2604,9 @@ function get_or_compute(map5, key, compute) {
 function has_key(map5, key) {
   return map5 && map5.has(key);
 }
-function insert2(map5, key, value) {
+function insert2(map5, key, value2) {
   map5 ??= new Map;
-  map5.set(key, value);
+  map5.set(key, value2);
   return map5;
 }
 function remove(map5, key) {
@@ -2727,8 +2835,8 @@ function unsafe_raw_html(namespace, tag, attributes, inner_html) {
 function memo2(dependencies, view) {
   return memo("", dependencies, view);
 }
-function ref(value) {
-  return identity3(value);
+function ref(value2) {
+  return identity3(value2);
 }
 function map6(element3, f) {
   return map5(element3, f);
@@ -2783,12 +2891,27 @@ function strong(attrs, children) {
 function button(attrs, children) {
   return element2("button", attrs, children);
 }
+function fieldset(attrs, children) {
+  return element2("fieldset", attrs, children);
+}
 function form(attrs, children) {
   return element2("form", attrs, children);
 }
 function input(attrs) {
   return element2("input", attrs, empty_list);
 }
+function label(attrs, children) {
+  return element2("label", attrs, children);
+}
+function legend(attrs, children) {
+  return element2("legend", attrs, children);
+}
+function option(attrs, label2) {
+  return element2("option", attrs, toList([text2(label2)]));
+}
+function select(attrs, children) {
+  return element2("select", attrs, children);
+}
 function textarea(attrs, content) {
   return element2("textarea", prepend(property2("value", string4(content)), attrs), toList([text2(content)]));
 }
@@ -2924,11 +3047,11 @@ class Emit extends CustomType {
   }
 }
 class Provide extends CustomType {
-  constructor(kind, key, value) {
+  constructor(kind, key, value2) {
     super();
     this.kind = kind;
     this.key = key;
-    this.value = value;
+    this.value = value2;
   }
 }
 class Subscribe extends CustomType {
@@ -2952,25 +3075,25 @@ class Batch extends CustomType {
     this.messages = messages;
   }
 }
-var ServerMessage$isBatch = (value) => value instanceof Batch;
+var ServerMessage$isBatch = (value2) => value2 instanceof Batch;
 class AttributeChanged extends CustomType {
-  constructor(kind, name2, value) {
+  constructor(kind, name2, value2) {
     super();
     this.kind = kind;
     this.name = name2;
-    this.value = value;
+    this.value = value2;
   }
 }
-var ServerMessage$isAttributeChanged = (value) => value instanceof AttributeChanged;
+var ServerMessage$isAttributeChanged = (value2) => value2 instanceof AttributeChanged;
 class PropertyChanged extends CustomType {
-  constructor(kind, name2, value) {
+  constructor(kind, name2, value2) {
     super();
     this.kind = kind;
     this.name = name2;
-    this.value = value;
+    this.value = value2;
   }
 }
-var ServerMessage$isPropertyChanged = (value) => value instanceof PropertyChanged;
+var ServerMessage$isPropertyChanged = (value2) => value2 instanceof PropertyChanged;
 class EventFired extends CustomType {
   constructor(kind, path, name2, event2) {
     super();
@@ -2980,16 +3103,16 @@ class EventFired extends CustomType {
     this.event = event2;
   }
 }
-var ServerMessage$isEventFired = (value) => value instanceof EventFired;
+var ServerMessage$isEventFired = (value2) => value2 instanceof EventFired;
 class ContextProvided extends CustomType {
-  constructor(kind, key, value) {
+  constructor(kind, key, value2) {
     super();
     this.kind = kind;
     this.key = key;
-    this.value = value;
+    this.value = value2;
   }
 }
-var ServerMessage$isContextProvided = (value) => value instanceof ContextProvided;
+var ServerMessage$isContextProvided = (value2) => value2 instanceof ContextProvided;
 var mount_kind = 0;
 var reconcile_kind = 1;
 var emit_kind = 2;
@@ -3005,8 +3128,8 @@ function reconcile(patch, memos) {
 function emit(name2, data) {
   return new Emit(emit_kind, name2, data);
 }
-function provide(key, value) {
-  return new Provide(provide_kind, key, value);
+function provide(key, value2) {
+  return new Provide(provide_kind, key, value2);
 }
 function subscribe(key) {
   return new Subscribe(subscribe_kind, key);
@@ -3582,21 +3705,21 @@ class ClientDispatchedMessage extends CustomType {
     this.message = message;
   }
 }
-var Message$isClientDispatchedMessage = (value) => value instanceof ClientDispatchedMessage;
+var Message$isClientDispatchedMessage = (value2) => value2 instanceof ClientDispatchedMessage;
 class ClientRegisteredCallback extends CustomType {
   constructor(callback) {
     super();
     this.callback = callback;
   }
 }
-var Message$isClientRegisteredCallback = (value) => value instanceof ClientRegisteredCallback;
+var Message$isClientRegisteredCallback = (value2) => value2 instanceof ClientRegisteredCallback;
 class ClientDeregisteredCallback extends CustomType {
   constructor(callback) {
     super();
     this.callback = callback;
   }
 }
-var Message$isClientDeregisteredCallback = (value) => value instanceof ClientDeregisteredCallback;
+var Message$isClientDeregisteredCallback = (value2) => value2 instanceof ClientDeregisteredCallback;
 class EffectDispatchedMessage extends CustomType {
   constructor(message) {
     super();
@@ -3604,7 +3727,7 @@ class EffectDispatchedMessage extends CustomType {
   }
 }
 var Message$EffectDispatchedMessage = (message) => new EffectDispatchedMessage(message);
-var Message$isEffectDispatchedMessage = (value) => value instanceof EffectDispatchedMessage;
+var Message$isEffectDispatchedMessage = (value2) => value2 instanceof EffectDispatchedMessage;
 class EffectEmitEvent extends CustomType {
   constructor(name2, data) {
     super();
@@ -3613,16 +3736,16 @@ class EffectEmitEvent extends CustomType {
   }
 }
 var Message$EffectEmitEvent = (name2, data) => new EffectEmitEvent(name2, data);
-var Message$isEffectEmitEvent = (value) => value instanceof EffectEmitEvent;
+var Message$isEffectEmitEvent = (value2) => value2 instanceof EffectEmitEvent;
 class EffectProvidedValue extends CustomType {
-  constructor(key, value) {
+  constructor(key, value2) {
     super();
     this.key = key;
-    this.value = value;
+    this.value = value2;
   }
 }
-var Message$EffectProvidedValue = (key, value) => new EffectProvidedValue(key, value);
-var Message$isEffectProvidedValue = (value) => value instanceof EffectProvidedValue;
+var Message$EffectProvidedValue = (key, value2) => new EffectProvidedValue(key, value2);
+var Message$isEffectProvidedValue = (value2) => value2 instanceof EffectProvidedValue;
 class EffectRequestedContextSubscription extends CustomType {
   constructor(key, decoder) {
     super();
@@ -3631,7 +3754,7 @@ class EffectRequestedContextSubscription extends CustomType {
   }
 }
 var Message$EffectRequestedContextSubscription = (key, decoder) => new EffectRequestedContextSubscription(key, decoder);
-var Message$isEffectRequestedContextSubscription = (value) => value instanceof EffectRequestedContextSubscription;
+var Message$isEffectRequestedContextSubscription = (value2) => value2 instanceof EffectRequestedContextSubscription;
 class EffectRemovedContextSubscription extends CustomType {
   constructor(key) {
     super();
@@ -3639,11 +3762,11 @@ class EffectRemovedContextSubscription extends CustomType {
   }
 }
 var Message$EffectRemovedContextSubscription = (key) => new EffectRemovedContextSubscription(key);
-var Message$isEffectRemovedContextSubscription = (value) => value instanceof EffectRemovedContextSubscription;
+var Message$isEffectRemovedContextSubscription = (value2) => value2 instanceof EffectRemovedContextSubscription;
 class SystemRequestedShutdown extends CustomType {
 }
 var Message$SystemRequestedShutdown$const = new SystemRequestedShutdown;
-var Message$isSystemRequestedShutdown = (value) => value instanceof SystemRequestedShutdown;
+var Message$isSystemRequestedShutdown = (value2) => value2 instanceof SystemRequestedShutdown;
 
 // build/dev/javascript/lustre/lustre/runtime/app.mjs
 class App extends CustomType {
@@ -4703,7 +4826,7 @@ var insertBefore = (parent, node, reference) => parent.insertBefore(node, refere
 var moveBefore = SUPPORTS_MOVE_BEFORE ? (parent, node, reference) => parent.moveBefore(node, reference) : insertBefore;
 var removeChild = (parent, child2) => parent.removeChild(child2);
 var getAttribute = (node, name2) => node.getAttribute(name2);
-var setAttribute = (node, name2, value) => node.setAttribute(name2, value);
+var setAttribute = (node, name2, value2) => node.setAttribute(name2, value2);
 var removeAttribute = (node, name2) => node.removeAttribute(name2);
 var addEventListener = (node, name2, handler, options) => node.addEventListener(name2, handler, options);
 var removeEventListener = (node, name2, handler) => node.removeEventListener(name2, handler);
@@ -4992,14 +5115,14 @@ class Reconciler {
     const {
       kind,
       name: name2,
-      value,
+      value: value2,
       prevent_default: prevent,
       debounce: debounceDelay,
       throttle: throttleDelay
     } = attribute3;
     switch (kind) {
       case attribute_kind: {
-        const valueOrDefault = value ?? "";
+        const valueOrDefault = value2 ?? "";
         if (name2 === "virtual:defaultValue") {
           node.defaultValue = valueOrDefault;
           return;
@@ -5017,7 +5140,7 @@ class Reconciler {
         break;
       }
       case property_kind:
-        node[name2] = value;
+        node[name2] = value2;
         break;
       case event_kind: {
         if (handlers.has(name2)) {
@@ -5116,8 +5239,8 @@ var syncedBooleanAttribute = (name2) => {
 };
 var syncedAttribute = (name2) => {
   return {
-    added(node, value) {
-      node[name2] = value;
+    added(node, value2) {
+      node[name2] = value2;
     }
   };
 };
@@ -5320,17 +5443,17 @@ var virtualiseAttributes = (node) => {
 };
 var INPUT_ELEMENTS = ["input", "select", "textarea"];
 var virtualiseInputEvents = (tag, node) => {
-  const value = node.value;
-  const checked = node.checked;
-  if (tag === "input" && node.type === "checkbox" && !checked)
+  const value2 = node.value;
+  const checked2 = node.checked;
+  if (tag === "input" && node.type === "checkbox" && !checked2)
     return;
-  if (tag === "input" && node.type === "radio" && !checked)
+  if (tag === "input" && node.type === "radio" && !checked2)
     return;
-  if (node.type !== "checkbox" && node.type !== "radio" && !value)
+  if (node.type !== "checkbox" && node.type !== "radio" && !value2)
     return;
   queueMicrotask(() => {
-    node.value = value;
-    node.checked = checked;
+    node.value = value2;
+    node.checked = checked2;
     node.dispatchEvent(new Event("input", { bubbles: true }));
     node.dispatchEvent(new Event("change", { bubbles: true }));
     if (globalThis.document.activeElement !== node) {
@@ -5408,22 +5531,22 @@ class Runtime {
     const target = this.root.host ?? this.root;
     target.dispatchEvent(new LustreEvent(event3, data));
   }
-  provide(key, value) {
+  provide(key, value2) {
     if (!this.#contexts.has(key)) {
-      this.#contexts.set(key, { value, subscribers: [] });
+      this.#contexts.set(key, { value: value2, subscribers: [] });
     } else {
       const context = this.#contexts.get(key);
-      if (isEqual2(context.value, value)) {
+      if (isEqual2(context.value, value2)) {
         return;
       }
-      context.value = value;
+      context.value = value2;
       for (let i = context.subscribers.length - 1;i >= 0; i--) {
         const [subscriber, unsubscribe2] = context.subscribers[i];
         if (!subscriber) {
           context.subscribers.splice(i, 1);
           continue;
         }
-        subscriber(value, unsubscribe2);
+        subscriber(value2, unsubscribe2);
       }
     }
   }
@@ -5432,12 +5555,12 @@ class Runtime {
       return;
     this.#contextSubscriptions.get(key)?.();
     const target = this.root.host ?? this.root;
-    target.dispatchEvent(new ContextRequestEvent(key, (value, unsubscribe2) => {
+    target.dispatchEvent(new ContextRequestEvent(key, (value2, unsubscribe2) => {
       const previousUnsubscribe = this.#contextSubscriptions.get(key);
       if (previousUnsubscribe !== unsubscribe2) {
         previousUnsubscribe?.();
       }
-      const decoded = run(value, decoder);
+      const decoded = run(value2, decoder);
       this.#contextSubscriptions.set(key, unsubscribe2);
       if (Result$isOk(decoded)) {
         this.dispatch(Result$Ok$0(decoded), true);
@@ -5475,7 +5598,7 @@ class Runtime {
     emit: (event3, data) => this.emit(event3, data),
     select: () => {},
     root: () => this.root,
-    provide: (key, value) => this.provide(key, value),
+    provide: (key, value2) => this.provide(key, value2),
     subscribe: (key, decoder) => this.subscribe(key, decoder),
     unsubscribe: (key) => this.unsubscribe(key)
   };
@@ -5637,13 +5760,13 @@ class Runtime2 {
       const { name: name2, data } = message;
       this.broadcast(emit(name2, data));
     } else if (Message$isEffectProvidedValue(message)) {
-      const { key, value } = message;
+      const { key, value: value2 } = message;
       const existing = get(this.#providers, key);
-      if (Result$isOk(existing) && isEqual2(Result$Ok$0(existing), value)) {
+      if (Result$isOk(existing) && isEqual2(Result$Ok$0(existing), value2)) {
         return;
       }
-      this.#providers = insert(this.#providers, key, value);
-      this.broadcast(provide(key, value));
+      this.#providers = insert(this.#providers, key, value2);
+      this.broadcast(provide(key, value2));
     } else if (Message$isEffectRequestedContextSubscription(message)) {
       const { key, decoder } = message;
       this.broadcast(subscribe(key));
@@ -5685,15 +5808,15 @@ class Runtime2 {
       this.#model = model;
       return this.#view(model);
     } else if (ServerMessage$isAttributeChanged(message)) {
-      const { name: name2, value } = message;
-      const result = this.#handle_attribute_change(name2, value);
+      const { name: name2, value: value2 } = message;
+      const result = this.#handle_attribute_change(name2, value2);
       if (!Result$isOk(result)) {
         return this.#vdom;
       }
       return this.#dispatch(Result$Ok$0(result));
     } else if (ServerMessage$isPropertyChanged(message)) {
-      const { name: name2, value } = message;
-      const result = this.#handle_properties_change(name2, value);
+      const { name: name2, value: value2 } = message;
+      const result = this.#handle_properties_change(name2, value2);
       if (!Result$isOk(result)) {
         return this.#vdom;
       }
@@ -5708,12 +5831,12 @@ class Runtime2 {
       const { message: message2 } = Result$Ok$0(result);
       return this.#dispatch(message2);
     } else if (ServerMessage$isContextProvided(message)) {
-      const { key, value } = message;
+      const { key, value: value2 } = message;
       let result = get(this.#config.contexts, key);
       if (!Result$isOk(result)) {
         return this.#vdom;
       }
-      result = run(value, Result$Ok$0(result));
+      result = run(value2, Result$Ok$0(result));
       if (!Result$isOk(result)) {
         return this.#vdom;
       }
@@ -5726,34 +5849,34 @@ class Runtime2 {
     this.#model = model;
     return this.#view(this.#model);
   }
-  #handle_attribute_change(name2, value) {
+  #handle_attribute_change(name2, value2) {
     const result = get(this.#config.attributes, name2);
     if (!Result$isOk(result)) {
       return result;
     }
-    return Result$Ok$0(result)(value);
+    return Result$Ok$0(result)(value2);
   }
-  #handle_properties_change(name2, value) {
+  #handle_properties_change(name2, value2) {
     const result = get(this.#config.properties, name2);
     if (!Result$isOk(result)) {
       return result;
     }
-    return Result$Ok$0(result)(value);
+    return Result$Ok$0(result)(value2);
   }
   #handle_effect(effect) {
     const dispatch2 = (message) => this.send(Message$EffectDispatchedMessage(message));
     const emit2 = (name2, data) => this.send(Message$EffectEmitEvent(name2, data));
-    const select = () => {
+    const select2 = () => {
       return;
     };
     const internals = () => {
       return;
     };
-    const provide2 = (key, value) => this.send(Message$EffectProvidedValue(key, value));
+    const provide2 = (key, value2) => this.send(Message$EffectProvidedValue(key, value2));
     const subscribe2 = (key, decoder) => this.send(Message$EffectRequestedContextSubscription(key, decoder));
     const unsubscribe2 = (key) => this.send(Message$EffectRemovedContextSubscription(key));
     globalThis.queueMicrotask(() => {
-      perform(effect, dispatch2, emit2, select, internals, provide2, subscribe2, unsubscribe2);
+      perform(effect, dispatch2, emit2, select2, internals, provide2, subscribe2, unsubscribe2);
     });
   }
 }
@@ -5801,16 +5924,26 @@ function on_click(message) {
   return on("click", success(message));
 }
 function on_input(message) {
-  return on("input", subfield(toList(["target", "value"]), string3, (value) => {
-    return success(message(value));
+  return on("input", subfield(toList(["target", "value"]), string3, (value2) => {
+    return success(message(value2));
+  }));
+}
+function on_change(message) {
+  return on("change", subfield(toList(["target", "value"]), string3, (value2) => {
+    return success(message(value2));
+  }));
+}
+function on_check(message) {
+  return on("change", subfield(toList(["target", "checked"]), bool, (checked2) => {
+    return success(message(checked2));
   }));
 }
 function formdata_decoder() {
   let string_value_decoder = field(0, string3, (key) => {
     return field(1, one_of(map3(string3, (var0) => {
       return new Ok(var0);
-    }), toList([success(error_nil)])), (value) => {
-      let _pipe2 = value;
+    }), toList([success(error_nil)])), (value2) => {
+      let _pipe2 = value2;
       let _pipe$12 = map4(_pipe2, (_capture) => {
         return new$5(key, _capture);
       });
@@ -5838,21 +5971,21 @@ class PromiseLayer {
   constructor(promise) {
     this.promise = promise;
   }
-  static wrap(value) {
-    return value instanceof Promise ? new PromiseLayer(value) : value;
+  static wrap(value2) {
+    return value2 instanceof Promise ? new PromiseLayer(value2) : value2;
   }
-  static unwrap(value) {
-    return value instanceof PromiseLayer ? value.promise : value;
+  static unwrap(value2) {
+    return value2 instanceof PromiseLayer ? value2.promise : value2;
   }
 }
-function resolve(value) {
-  return Promise.resolve(PromiseLayer.wrap(value));
+function resolve(value2) {
+  return Promise.resolve(PromiseLayer.wrap(value2));
 }
 function then_await(promise, fn) {
-  return promise.then((value) => fn(PromiseLayer.unwrap(value)));
+  return promise.then((value2) => fn(PromiseLayer.unwrap(value2)));
 }
 function map_promise(promise, fn) {
-  return promise.then((value) => PromiseLayer.wrap(fn(PromiseLayer.unwrap(value))));
+  return promise.then((value2) => PromiseLayer.wrap(fn(PromiseLayer.unwrap(value2))));
 }
 function rescue(promise, fn) {
   return promise.catch((error) => fn(error));
@@ -6652,8 +6785,8 @@ function from_uri(uri) {
     });
   });
 }
-function set_header(request, key2, value2) {
-  let headers = key_set(request.headers, lowercase(key2), value2);
+function set_header(request, key2, value3) {
+  let headers = key_set(request.headers, lowercase(key2), value3);
   return new Request(request.method, headers, request.body, request.scheme, request.host, request.port, request.path, request.query);
 }
 function set_body(req, body2) {
@@ -6768,7 +6901,7 @@ var from_relative_url = (url_string) => {
   return new Ok(uri);
 };
 var uri_from_url = (url) => {
-  const optional = (value2) => value2 ? new Some(value2) : new None;
+  const optional = (value3) => value3 ? new Some(value3) : new None;
   return new Uri(optional(url.protocol?.slice(0, -1)), new None, optional(url.hostname), optional(url.port && Number(url.port)), url.pathname, optional(url.search?.slice(1)), optional(url.hash?.slice(1)));
 };
 
@@ -6977,7 +7110,7 @@ class Project extends CustomType {
   }
 }
 class Task extends CustomType {
-  constructor(id, project_id, title2, description, status, assignee, assignees, due_date) {
+  constructor(id, project_id, title2, description, status, assignee, assignees, subteams, due_date) {
     super();
     this.id = id;
     this.project_id = project_id;
@@ -6986,6 +7119,7 @@ class Task extends CustomType {
     this.status = status;
     this.assignee = assignee;
     this.assignees = assignees;
+    this.subteams = subteams;
     this.due_date = due_date;
   }
 }
@@ -7012,12 +7146,13 @@ class NewProject extends CustomType {
   }
 }
 class NewTask extends CustomType {
-  constructor(title2, description, assignee, assignees, due_date) {
+  constructor(title2, description, assignee, assignees, subteams, due_date) {
     super();
     this.title = title2;
     this.description = description;
     this.assignee = assignee;
     this.assignees = assignees;
+    this.subteams = subteams;
     this.due_date = due_date;
   }
 }
@@ -7035,8 +7170,10 @@ function task_decoder() {
           return field("status", string3, (status) => {
             return field("assignee", string3, (assignee) => {
               return field("assignees", string3, (assignees) => {
-                return field("due_date", string3, (due_date) => {
-                  return success(new Task(id, project_id, title2, description, status, assignee, assignees, due_date));
+                return field("subteams", list2(string3), (subteams) => {
+                  return field("due_date", string3, (due_date) => {
+                    return success(new Task(id, project_id, title2, description, status, assignee, assignees, subteams, due_date));
+                  });
                 });
               });
             });
@@ -7064,36 +7201,37 @@ function dashboard_decoder() {
     });
   });
 }
-function credentials_json(value2) {
+function credentials_json(value3) {
   return object2(toList([
-    ["username", string4(value2.username)],
-    ["password", string4(value2.password)],
-    ["invite_password", string4(value2.invite_password)]
+    ["username", string4(value3.username)],
+    ["password", string4(value3.password)],
+    ["invite_password", string4(value3.invite_password)]
   ]));
 }
-function new_project_json(value2) {
+function new_project_json(value3) {
   return object2(toList([
-    ["name", string4(value2.name)],
-    ["description", string4(value2.description)]
+    ["name", string4(value3.name)],
+    ["description", string4(value3.description)]
   ]));
 }
-function new_task_json(value2) {
+function new_task_json(value3) {
   return object2(toList([
-    ["title", string4(value2.title)],
-    ["description", string4(value2.description)],
-    ["assignee", string4(value2.assignee)],
-    ["assignees", string4(value2.assignees)],
-    ["due_date", string4(value2.due_date)]
+    ["title", string4(value3.title)],
+    ["description", string4(value3.description)],
+    ["assignee", string4(value3.assignee)],
+    ["assignees", string4(value3.assignees)],
+    ["subteams", array2(value3.subteams, string4)],
+    ["due_date", string4(value3.due_date)]
   ]));
 }
-function status_change_json(value2) {
-  return object2(toList([["status", string4(value2.status)]]));
+function status_change_json(value3) {
+  return object2(toList([["status", string4(value3.status)]]));
 }
 // build/dev/javascript/client/client.mjs
 var FILEPATH = "src/client.gleam";
 
 class Model extends CustomType {
-  constructor(page, dashboard, error, username, password, invite_password, project_name, project_description, task_title, task_description, task_assignee, task_assignees, task_due_date) {
+  constructor(page, dashboard, error, username, password, invite_password, project_name, project_description, task_title, task_description, task_assignee, task_assignees, task_subteams, task_due_date, subteam_filter, task_search) {
     super();
     this.page = page;
     this.dashboard = dashboard;
@@ -7107,7 +7245,10 @@ class Model extends CustomType {
     this.task_description = task_description;
     this.task_assignee = task_assignee;
     this.task_assignees = task_assignees;
+    this.task_subteams = task_subteams;
     this.task_due_date = task_due_date;
+    this.subteam_filter = subteam_filter;
+    this.task_search = task_search;
   }
 }
 
@@ -7215,6 +7356,25 @@ class TaskDueDateChanged extends CustomType {
     this[0] = $0;
   }
 }
+class TaskSubteamChanged extends CustomType {
+  constructor($0, $1) {
+    super();
+    this[0] = $0;
+    this[1] = $1;
+  }
+}
+class SubteamFilterChanged extends CustomType {
+  constructor($0) {
+    super();
+    this[0] = $0;
+  }
+}
+class TaskSearchChanged extends CustomType {
+  constructor($0) {
+    super();
+    this[0] = $0;
+  }
+}
 class InvitePasswordChanged extends CustomType {
   constructor($0) {
     super();
@@ -7239,14 +7399,70 @@ var Message$LogOut$const = new LogOut;
 class Nothing extends CustomType {
 }
 var Message$Nothing$const = new Nothing;
+function subteam_checkbox(selected, value3, label2) {
+  return label(toList([
+    class$("subteam-option"),
+    styles(toList([
+      ["display", "flex"],
+      ["align-items", "center"],
+      ["gap", "8px"],
+      ["color", "#4e463e"],
+      ["font-size", ".82rem"],
+      ["line-height", "1.2"],
+      ["cursor", "pointer"]
+    ]))
+  ]), toList([
+    input(toList([
+      type_("checkbox"),
+      value(value3),
+      checked(contains(selected, value3)),
+      on_check((checked2) => {
+        return new TaskSubteamChanged(value3, checked2);
+      })
+    ])),
+    text3(label2)
+  ]));
+}
+function subteam_checkboxes(selected) {
+  return fieldset(toList([
+    class$("subteam-picker"),
+    styles(toList([
+      ["grid-column", "1 / -1"],
+      ["display", "block"],
+      ["margin", "0"],
+      ["padding", "12px"],
+      ["border", "1px solid #d9cfc2"],
+      ["background", "#fffcf8"]
+    ]))
+  ]), toList([
+    legend(List$Empty$const, toList([text3("Subteams")])),
+    div(toList([
+      class$("subteam-options"),
+      styles(toList([
+        ["display", "grid"],
+        ["grid-template-columns", "repeat(3, minmax(0, 1fr))"],
+        ["column-gap", "32px"],
+        ["row-gap", "12px"],
+        ["align-items", "center"]
+      ]))
+    ]), toList([
+      subteam_checkbox(selected, "none", "No subteam"),
+      subteam_checkbox(selected, "electrical", "Electrical"),
+      subteam_checkbox(selected, "software", "Software"),
+      subteam_checkbox(selected, "CAD", "CAD"),
+      subteam_checkbox(selected, "machining", "Machining"),
+      subteam_checkbox(selected, "operations", "Operations")
+    ]))
+  ]));
+}
 function text_input(name2, placeholder2) {
   return input(toList([name(name2), placeholder(placeholder2)]));
 }
-function status_button(task_id, status, label) {
+function status_button(task_id, status, label2) {
   return button(toList([
     class$("move-task"),
     on_click(new StatusSubmitted(task_id, toList([["status", status]])))
-  ]), toList([text3(label)]));
+  ]), toList([text3(label2)]));
 }
 function right_status_button(task) {
   let $ = task.status;
@@ -7268,12 +7484,15 @@ function left_status_button(task) {
     return none2();
   }
 }
-function if_blank(value2, fallback) {
-  let $ = value2 === "";
+function join_subteams(subteams) {
+  return join(subteams, ", ");
+}
+function if_blank(value3, fallback) {
+  let $ = value3 === "";
   if ($) {
     return fallback;
   } else {
-    return value2;
+    return value3;
   }
 }
 function task_view(task) {
@@ -7298,6 +7517,10 @@ function task_view(task) {
         small(List$Empty$const, toList([text3("Assignees")])),
         text3(if_blank(task.assignees, "None"))
       ])),
+      div(toList([class$("task-person")]), toList([
+        small(List$Empty$const, toList([text3("Subteams")])),
+        text3(if_blank(join_subteams(task.subteams), "No subteam"))
+      ])),
       div(toList([class$("task-due")]), toList([
         small(List$Empty$const, toList([text3("Due")])),
         text3((() => {
@@ -7323,14 +7546,24 @@ function kanban_column(title2, tasks) {
     ]))
   ]), map2(tasks, task_view)));
 }
-function project_view(_, project) {
-  let backlog = filter(project.tasks, (task) => {
+function task_matches(model, task) {
+  let search2 = lowercase(trim(model.task_search));
+  let people = lowercase(task.assignee + " " + task.assignees);
+  let search_matches = search2 === "" || contains_string(people, search2);
+  let subteam_matches = model.subteam_filter === "all" || contains(task.subteams, model.subteam_filter);
+  return search_matches && subteam_matches;
+}
+function project_view(model, project) {
+  let tasks = filter(project.tasks, (task) => {
+    return task_matches(model, task);
+  });
+  let backlog = filter(tasks, (task) => {
     return task.status === "todo";
   });
-  let doing = filter(project.tasks, (task) => {
+  let doing = filter(tasks, (task) => {
     return task.status === "doing";
   });
-  let done = filter(project.tasks, (task) => {
+  let done = filter(tasks, (task) => {
     return task.status === "done";
   });
   return article(toList([class$("panel")]), append(toList([
@@ -7350,7 +7583,8 @@ function project_view(_, project) {
       styles(toList([
         ["display", "grid"],
         ["grid-template-columns", "repeat(4, minmax(0, 1fr))"],
-        ["align-items", "start"]
+        ["align-items", "start"],
+        ["gap", "10px"]
       ])),
       on_submit((fields) => {
         return new TaskSubmitted(project.id, fields);
@@ -7360,6 +7594,7 @@ function project_view(_, project) {
       text_input("assignee", "Owner"),
       text_input("assignees", "Assignees"),
       text_input("due_date", "Due (MM/DD/YY)"),
+      subteam_checkboxes(model.task_subteams),
       textarea(toList([
         class$("task-description-input"),
         styles(toList([
@@ -7383,7 +7618,7 @@ function project_view(_, project) {
   ]))));
 }
 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{min-width:150px;flex:1;padding:12px 13px;border:1px solid #d9cfc2;background:#fffcf8;color:#28231e;font:inherit}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}}");
+  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}}");
 }
 function error_view(error) {
   if (error instanceof Some) {
@@ -7407,6 +7642,42 @@ function dashboard_view(model) {
         button(toList([on_click(Message$LogOut$const)]), toList([text3("Sign out")]))
       ])),
       section(toList([class$("hero")]), toList([text3("wassup")])),
+      section(toList([class$("panel")]), toList([
+        h2(List$Empty$const, toList([text3("Find tasks")])),
+        input(toList([
+          class$("task-search"),
+          styles(toList([["width", "100%"], ["box-sizing", "border-box"]])),
+          placeholder("Search owner or assignees"),
+          on_input((var0) => {
+            return new TaskSearchChanged(var0);
+          })
+        ])),
+        select(toList([
+          class$("subteam-filter"),
+          styles(toList([
+            ["width", "100%"],
+            ["box-sizing", "border-box"],
+            ["padding", "12px 13px"],
+            ["border", "1px solid #d9cfc2"],
+            ["border-radius", "0"],
+            ["background", "#fffcf8"],
+            ["color", "#28231e"],
+            ["font", "inherit"],
+            ["appearance", "auto"]
+          ])),
+          on_change((var0) => {
+            return new SubteamFilterChanged(var0);
+          })
+        ]), toList([
+          option(toList([value("all")]), "All subteams"),
+          option(toList([value("none")]), "No subteam"),
+          option(toList([value("electrical")]), "Electrical"),
+          option(toList([value("software")]), "Software"),
+          option(toList([value("CAD")]), "CAD"),
+          option(toList([value("machining")]), "Machining"),
+          option(toList([value("operations")]), "Operations")
+        ]))
+      ])),
       section(toList([class$("panel")]), toList([
         h2(List$Empty$const, toList([text3("New project")])),
         form(toList([
@@ -7548,7 +7819,7 @@ function remove_project(model, project_id) {
     let projects = filter(board.projects, (project) => {
       return project.id !== project_id;
     });
-    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_due_date);
+    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 model;
   }
@@ -7563,7 +7834,7 @@ function remove_task(model, task_id) {
       });
       return new Project(project.id, project.name, project.description, 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_due_date);
+    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 model;
   }
@@ -7576,14 +7847,14 @@ function update_task_status(model, task_id, status) {
       let tasks = map2(project.tasks, (task) => {
         let $1 = task.id === task_id;
         if ($1) {
-          return new Task(task.id, task.project_id, task.title, task.description, status, task.assignee, task.assignees, task.due_date);
+          return new Task(task.id, task.project_id, task.title, task.description, status, task.assignee, task.assignees, task.subteams, task.due_date);
         } else {
           return task;
         }
       });
       return new Project(project.id, project.name, project.description, 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_due_date);
+    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 model;
   }
@@ -7612,18 +7883,40 @@ function auth_request(model, fields, url, _) {
     }))
   ];
 }
+function toggle_subteam(selected, subteam, checked2) {
+  if (checked2) {
+    if (subteam === "none") {
+      return toList(["none"]);
+    } else {
+      let $ = contains(selected, subteam);
+      if ($) {
+        return filter(selected, (item) => {
+          return item !== "none";
+        });
+      } else {
+        return prepend(subteam, filter(selected, (item) => {
+          return item !== "none";
+        }));
+      }
+    }
+  } else {
+    return filter(selected, (item) => {
+      return item !== subteam;
+    });
+  }
+}
 function update2(model, message2) {
   if (message2 instanceof DashboardLoaded) {
     let $ = message2[0];
     if ($ instanceof Ok) {
-      let value2 = $[0];
+      let value3 = $[0];
       return [
-        new Model("dashboard", new Some(value2), Option$None$const, 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_due_date),
+        new Model("dashboard", new Some(value3), Option$None$const, 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),
         none()
       ];
     } else {
       return [
-        new Model("login", model.dashboard, new Some("Please sign in to continue."), 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_due_date),
+        new Model("login", model.dashboard, new Some("Please sign in to continue."), 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),
         none()
       ];
     }
@@ -7640,7 +7933,7 @@ function update2(model, message2) {
     _block = new_project_json(_pipe);
     let body2 = _block;
     return [
-      new Model(model.page, model.dashboard, model.error, model.username, model.password, model.invite_password, "", "", model.task_title, model.task_description, model.task_assignee, model.task_assignees, model.task_due_date),
+      new Model(model.page, model.dashboard, model.error, model.username, model.password, model.invite_password, "", "", 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),
       post("/api/projects", body2, expect_any_response((result) => {
         return new Saved((() => {
           let _pipe$1 = result;
@@ -7654,11 +7947,11 @@ function update2(model, message2) {
     let project_id = message2[0];
     let fields = message2[1];
     let _block;
-    let _pipe = new NewTask(field2(fields, "title"), field2(fields, "description"), field2(fields, "assignee"), field2(fields, "assignees"), field2(fields, "due_date"));
+    let _pipe = new NewTask(field2(fields, "title"), field2(fields, "description"), field2(fields, "assignee"), field2(fields, "assignees"), model.task_subteams, field2(fields, "due_date"));
     _block = new_task_json(_pipe);
     let body2 = _block;
     return [
-      new Model(model.page, model.dashboard, model.error, model.username, model.password, model.invite_password, model.project_name, model.project_description, "", "", "", "", ""),
+      new Model(model.page, model.dashboard, model.error, model.username, model.password, model.invite_password, model.project_name, model.project_description, "", "", "", "", List$Empty$const, "", model.subteam_filter, model.task_search),
       post("/api/projects/" + to_string(project_id) + "/tasks", body2, expect_any_response((result) => {
         return new Saved((() => {
           let _pipe$1 = result;
@@ -7691,14 +7984,14 @@ function update2(model, message2) {
     let $ = message2[0];
     if ($ instanceof Ok) {
       return [
-        new Model("dashboard", model.dashboard, 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_due_date),
+        new Model("dashboard", model.dashboard, 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),
         get3("/api/dashboard", expect_json(dashboard_decoder(), (var0) => {
           return new DashboardLoaded(var0);
         }))
       ];
     } else {
       return [
-        new Model(model.page, model.dashboard, new Some("Invalid username or password."), 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_due_date),
+        new Model(model.page, model.dashboard, new Some("Invalid username or password."), 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),
         none()
       ];
     }
@@ -7706,75 +7999,94 @@ function update2(model, message2) {
     let $ = message2[0];
     if ($ instanceof Ok) {
       return [
-        new Model(model.page, model.dashboard, model.error, model.username, model.password, "", "", "", "", "", "", "", ""),
+        new Model(model.page, model.dashboard, model.error, model.username, model.password, "", "", "", "", "", "", "", List$Empty$const, "", model.subteam_filter, model.task_search),
         get3("/api/dashboard", expect_json(dashboard_decoder(), (var0) => {
           return new DashboardLoaded(var0);
         }))
       ];
     } else {
       return [
-        new Model(model.page, model.dashboard, new Some("The request could not be saved."), 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_due_date),
+        new Model(model.page, model.dashboard, new Some("The request could not be saved."), 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),
         none()
       ];
     }
   } else if (message2 instanceof UsernameChanged) {
-    let value2 = message2[0];
+    let value3 = message2[0];
     return [
-      new Model(model.page, model.dashboard, model.error, value2, 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_due_date),
+      new Model(model.page, model.dashboard, model.error, value3, 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),
       none()
     ];
   } else if (message2 instanceof PasswordChanged) {
-    let value2 = message2[0];
+    let value3 = message2[0];
     return [
-      new Model(model.page, model.dashboard, model.error, model.username, value2, model.invite_password, model.project_name, model.project_description, model.task_title, model.task_description, model.task_assignee, model.task_assignees, model.task_due_date),
+      new Model(model.page, model.dashboard, model.error, model.username, value3, 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),
       none()
     ];
   } else if (message2 instanceof ProjectNameChanged) {
-    let value2 = message2[0];
+    let value3 = message2[0];
     return [
-      new Model(model.page, model.dashboard, model.error, model.username, model.password, model.invite_password, value2, model.project_description, model.task_title, model.task_description, model.task_assignee, model.task_assignees, model.task_due_date),
+      new Model(model.page, model.dashboard, model.error, model.username, model.password, model.invite_password, value3, 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),
       none()
     ];
   } else if (message2 instanceof ProjectDescriptionChanged) {
-    let value2 = message2[0];
+    let value3 = message2[0];
     return [
-      new Model(model.page, model.dashboard, model.error, model.username, model.password, model.invite_password, model.project_name, value2, model.task_title, model.task_description, model.task_assignee, model.task_assignees, model.task_due_date),
+      new Model(model.page, model.dashboard, model.error, model.username, model.password, model.invite_password, model.project_name, value3, 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),
       none()
     ];
   } else if (message2 instanceof TaskTitleChanged) {
-    let value2 = message2[0];
+    let value3 = message2[0];
     return [
-      new Model(model.page, model.dashboard, model.error, model.username, model.password, model.invite_password, model.project_name, model.project_description, value2, model.task_description, model.task_assignee, model.task_assignees, model.task_due_date),
+      new Model(model.page, model.dashboard, model.error, model.username, model.password, model.invite_password, model.project_name, model.project_description, value3, model.task_description, model.task_assignee, model.task_assignees, model.task_subteams, model.task_due_date, model.subteam_filter, model.task_search),
       none()
     ];
   } else if (message2 instanceof TaskDescriptionChanged) {
-    let value2 = message2[0];
+    let value3 = message2[0];
     return [
-      new Model(model.page, model.dashboard, model.error, model.username, model.password, model.invite_password, model.project_name, model.project_description, model.task_title, value2, model.task_assignee, model.task_assignees, model.task_due_date),
+      new Model(model.page, model.dashboard, model.error, model.username, model.password, model.invite_password, model.project_name, model.project_description, model.task_title, value3, model.task_assignee, model.task_assignees, model.task_subteams, model.task_due_date, model.subteam_filter, model.task_search),
       none()
     ];
   } else if (message2 instanceof TaskAssigneeChanged) {
-    let value2 = message2[0];
+    let value3 = message2[0];
     return [
-      new Model(model.page, model.dashboard, model.error, model.username, model.password, model.invite_password, model.project_name, model.project_description, model.task_title, model.task_description, value2, model.task_assignees, model.task_due_date),
+      new Model(model.page, model.dashboard, model.error, model.username, model.password, model.invite_password, model.project_name, model.project_description, model.task_title, model.task_description, value3, model.task_assignees, model.task_subteams, model.task_due_date, model.subteam_filter, model.task_search),
       none()
     ];
   } else if (message2 instanceof TaskAssigneesChanged) {
-    let value2 = message2[0];
+    let value3 = message2[0];
     return [
-      new Model(model.page, model.dashboard, model.error, model.username, model.password, model.invite_password, model.project_name, model.project_description, model.task_title, model.task_description, model.task_assignee, value2, model.task_due_date),
+      new Model(model.page, model.dashboard, model.error, model.username, model.password, model.invite_password, model.project_name, model.project_description, model.task_title, model.task_description, model.task_assignee, value3, model.task_subteams, model.task_due_date, model.subteam_filter, model.task_search),
       none()
     ];
   } else if (message2 instanceof TaskDueDateChanged) {
-    let value2 = message2[0];
+    let value3 = message2[0];
+    return [
+      new Model(model.page, model.dashboard, 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, value3, model.subteam_filter, model.task_search),
+      none()
+    ];
+  } else if (message2 instanceof TaskSubteamChanged) {
+    let subteam = message2[0];
+    let checked2 = message2[1];
+    return [
+      new Model(model.page, model.dashboard, 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, toggle_subteam(model.task_subteams, subteam, checked2), model.task_due_date, model.subteam_filter, model.task_search),
+      none()
+    ];
+  } else if (message2 instanceof SubteamFilterChanged) {
+    let value3 = message2[0];
+    return [
+      new Model(model.page, model.dashboard, 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, value3, model.task_search),
+      none()
+    ];
+  } else if (message2 instanceof TaskSearchChanged) {
+    let value3 = message2[0];
     return [
-      new Model(model.page, model.dashboard, 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, value2),
+      new Model(model.page, model.dashboard, 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, value3),
       none()
     ];
   } else if (message2 instanceof InvitePasswordChanged) {
-    let value2 = message2[0];
+    let value3 = message2[0];
     return [
-      new Model(model.page, model.dashboard, model.error, model.username, model.password, value2, model.project_name, model.project_description, model.task_title, model.task_description, model.task_assignee, model.task_assignees, model.task_due_date),
+      new Model(model.page, model.dashboard, model.error, model.username, model.password, value3, 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),
       none()
     ];
   } else if (message2 instanceof DeleteTask) {
@@ -7805,7 +8117,7 @@ function update2(model, message2) {
     ];
   } else if (message2 instanceof LogOut) {
     return [
-      new Model("login", model.dashboard, 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_due_date),
+      new Model("login", model.dashboard, 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),
       get3("/api/logout", expect_ok_response((_) => {
         return Message$Nothing$const;
       }))
@@ -7830,7 +8142,7 @@ function init(_) {
     _block = "dashboard";
   }
   let page = _block;
-  let model = new Model(page, Option$None$const, Option$None$const, "", "", "", "", "", "", "", "", "", "");
+  let model = new Model(page, Option$None$const, Option$None$const, "", "", "", "", "", "", "", "", "", List$Empty$const, "", "all", "");
   if (page === "dashboard") {
     return [
       model,
@@ -7853,7 +8165,7 @@ function main2() {
   let app = application(init, update2, view);
   let $ = start4(app, "#app", Option$None$const);
   if (!($ instanceof Ok)) {
-    throw makeError("let_assert", FILEPATH, "client", 19, "main", "Pattern match failed, no pattern matched the value.", { value: $, start: 439, end: 489, pattern_start: 450, pattern_end: 455 });
+    throw makeError("let_assert", FILEPATH, "client", 20, "main", "Pattern match failed, no pattern matched the value.", { value: $, start: 459, end: 509, pattern_start: 470, pattern_end: 475 });
   }
   return;
 }
index 96e4d6e4d64b8a9ab72261add56e30b8331a39f5..196f3f5196843c1b6ccbb8627226eda016a39034 100644 (file)
@@ -14,6 +14,7 @@ pub type Task {
     status: String,
     assignee: String,
     assignees: String,
+    subteams: List(String),
     due_date: String,
   )
 }
@@ -36,6 +37,7 @@ pub type NewTask {
     description: String,
     assignee: String,
     assignees: String,
+    subteams: List(String),
     due_date: String,
   )
 }
@@ -66,8 +68,9 @@ fn task_decoder() -> decode.Decoder(Task) {
   use status <- decode.field("status", decode.string)
   use assignee <- decode.field("assignee", decode.string)
   use assignees <- decode.field("assignees", decode.string)
+  use subteams <- decode.field("subteams", decode.list(decode.string))
   use due_date <- decode.field("due_date", decode.string)
-  decode.success(Task(id:, project_id:, title:, description:, status:, assignee:, assignees:, due_date:))
+  decode.success(Task(id:, project_id:, title:, description:, status:, assignee:, assignees:, subteams:, due_date:))
 }
 
 pub fn credentials_decoder() -> decode.Decoder(Credentials) {
@@ -88,8 +91,9 @@ pub fn new_task_decoder() -> decode.Decoder(NewTask) {
   use description <- decode.field("description", decode.string)
   use assignee <- decode.field("assignee", decode.string)
   use assignees <- decode.field("assignees", decode.string)
+  use subteams <- decode.field("subteams", decode.list(decode.string))
   use due_date <- decode.field("due_date", decode.string)
-  decode.success(NewTask(title:, description:, assignee:, assignees:, due_date:))
+  decode.success(NewTask(title:, description:, assignee:, assignees:, subteams:, due_date:))
 }
 
 pub fn status_change_decoder() -> decode.Decoder(StatusChange) {
@@ -123,6 +127,7 @@ fn task_json(value: Task) -> json.Json {
     #("status", json.string(value.status)),
     #("assignee", json.string(value.assignee)),
     #("assignees", json.string(value.assignees)),
+    #("subteams", json.array(value.subteams, json.string)),
     #("due_date", json.string(value.due_date)),
   ])
 }
@@ -148,6 +153,7 @@ pub fn new_task_json(value: NewTask) -> json.Json {
     #("description", json.string(value.description)),
     #("assignee", json.string(value.assignee)),
     #("assignees", json.string(value.assignees)),
+    #("subteams", json.array(value.subteams, json.string)),
     #("due_date", json.string(value.due_date)),
   ])
 }