use actix_identity::Identity;
use actix_web::{HttpRequest, HttpResponse, Responder, get, post, web};
use maud::{Markup, html};
use serde::Deserialize;
use uuid::Uuid;
use crate::auth::current_user;
use crate::db::Database;
use crate::db::tickets::TicketDetails;
use crate::models::{Tag, Ticket, User};
use crate::view::{page_or_partial, render_error};
#[derive(Deserialize, Default)]
pub struct BoardQuery {
pub tag: Option<String>,
pub view: Option<String>,
}
/// Which slice of the task list a visitor is looking at.
#[derive(Clone, Copy)]
enum TaskView {
Open,
Closed,
}
impl TaskView {
fn from_query(view: Option<&str>) -> Self {
match view {
Some("closed") => Self::Closed,
_ => Self::Open,
}
}
fn is_closed(self) -> bool {
matches!(self, Self::Closed)
}
fn label(self) -> &'static str {
match self {
Self::Open => "Open",
Self::Closed => "Closed",
}
}
}
/// Builds a link that preserves the active view and optional tag filter.
fn view_href(view: TaskView, tag: Option<&str>) -> String {
let mut params: Vec<String> = Vec::new();
if let Some(tag) = tag {
params.push(format!("tag={tag}"));
}
if view.is_closed() {
params.push("view=closed".to_string());
}
if params.is_empty() {
"/".to_string()
} else {
format!("/?{}", params.join("&"))
}
}
fn form_values(fields: &std::collections::HashMap<String, Vec<String>>, key: &str) -> Vec<String> {
fields.get(key).cloned().unwrap_or_default()
}
fn form_value(fields: &std::collections::HashMap<String, Vec<String>>, key: &str) -> String {
fields
.get(key)
.and_then(|values| values.first())
.cloned()
.unwrap_or_default()
}
fn tag_pill(tag: &Tag) -> Markup {
html! { span class="tag" { "#" (tag.name) } }
}
fn ticket_card(details: &TicketDetails, viewer_id: Option<&str>) -> Markup {
let closed = details.closed_as.is_some();
let owner = viewer_id == Some(details.ticket.creator_id.as_str());
html! {
article class=(if closed { "task is-closed" } else { "task" }) {
div class="task-body" {
h3 class="task-title" { (details.ticket.title) }
@if !details.ticket.description.is_empty() {
p class="task-note" { (details.ticket.description) }
}
@if !details.tags.is_empty() {
div class="task-tags" {
@for tag in &details.tags { (tag_pill(tag)) }
}
}
}
div class="task-side" {
@if let Some(outcome) = details.closed_as.as_deref() {
span class=(if outcome == "done" { "task-outcome is-done" } else { "task-outcome" }) {
@if outcome == "done" { "Done" } @else { "Not planned" }
}
}
@if owner {
@if closed {
form method="POST" action=(format!("/tickets/{}/reopen", details.ticket.id)) {
button class="btn btn-ghost btn-sm" type="submit" { "Reopen" }
}
} @else {
form method="POST" action=(format!("/tickets/{}/close", details.ticket.id)) {
input type="hidden" name="outcome" value="done";
button class="btn btn-primary btn-sm" type="submit" { "Done" }
}
form method="POST" action=(format!("/tickets/{}/close", details.ticket.id)) {
input type="hidden" name="outcome" value="not_planned";
button class="btn btn-ghost btn-sm" type="submit" { "Not planned" }
}
}
}
}
}
}
}
fn render_guest_home(done_count: u64) -> Markup {
html! {
section class="guest-landing" {
p class="eyebrow" { "Private task list" }
h1 { "Keep track of what matters." }
p class="guest-intro" { "A simple place for personal tasks. Your list stays private; only the number of tickets done is shared here." }
div class="guest-count" {
strong { (done_count) }
span { "tickets done" }
}
div class="guest-actions" {
a class="btn btn-primary" href="https://me.silenlocatelli.ch" { "Visit me.silenlocatelli.ch" }
a class="text-link" href="/auth/login" { "Log in" }
}
}
}
}
fn render_tasks_page(
user: &User,
done_count: u64,
tickets: &[TicketDetails],
selected_tag: Option<&str>,
view: TaskView,
) -> Markup {
let mut tags: Vec<&Tag> = Vec::new();
for ticket in tickets {
for tag in &ticket.tags {
if !tags.iter().any(|found| found.id == tag.id) {
tags.push(tag);
}
}
}
let mut open: Vec<&TicketDetails> = Vec::new();
let mut closed: Vec<&TicketDetails> = Vec::new();
for ticket in tickets {
if selected_tag.is_some_and(|id| !ticket.tags.iter().any(|tag| tag.id == id)) {
continue;
}
if ticket.closed_as.is_none() {
open.push(ticket);
} else {
closed.push(ticket);
}
}
let items: &[&TicketDetails] = if view.is_closed() { &closed } else { &open };
html! {
header class="page-head" {
div class="page-title" {
h1 { "Tasks" }
p class="page-meta" {
span { (open.len()) " open" }
span class="dot" { "·" }
span { (done_count) " done" }
}
}
a class="btn btn-primary" href="/tickets/new" { "New task" }
}
nav class="view-tabs" aria-label="Task views" {
a
class=(if view.is_closed() { "view-tab" } else { "view-tab is-active" })
href=(view_href(TaskView::Open, selected_tag))
{
"Open" span class="view-count" { (open.len()) }
}
a
class=(if view.is_closed() { "view-tab is-active" } else { "view-tab" })
href=(view_href(TaskView::Closed, selected_tag))
{
"Closed" span class="view-count" { (closed.len()) }
}
}
@if !tags.is_empty() {
nav class="tag-filters" aria-label="Filter tasks by tag" {
a
class=(if selected_tag.is_none() { "tag-filter is-active" } else { "tag-filter" })
href=(view_href(view, None))
{ "All tags" }
@for tag in &tags {
a
class=(if selected_tag == Some(tag.id.as_str()) { "tag-filter is-active" } else { "tag-filter" })
href=(view_href(view, Some(tag.id.as_str())))
{ "#" (tag.name) }
}
}
}
@if items.is_empty() {
div class="empty-state" {
p {
@if selected_tag.is_some() {
"No " (view.label().to_lowercase()) " tasks with this tag."
} @else if view.is_closed() {
"Nothing closed yet."
} @else {
"Nothing open. Add a task to get started."
}
}
@if !view.is_closed() && selected_tag.is_none() {
a class="text-link" href="/tickets/new" { "Add a task" }
}
}
} @else {
div class="task-list" {
@for task in items { (ticket_card(task, Some(&user.id))) }
}
}
}
}
fn render_new_task_page() -> Markup {
html! {
header class="page-head" {
div class="page-title" {
p class="eyebrow" { "New" }
h1 { "Add a task" }
}
a class="text-link" href="/" { "Cancel" }
}
form method="POST" action="/tickets" class="stack-form" {
div class="field" {
label for="ticket-title" { "Task" }
input
class="input"
id="ticket-title"
name="title"
maxlength="120"
required
autofocus
placeholder="What needs doing?";
}
div class="field" {
label for="ticket-description" { "Details" }
textarea
class="textarea"
id="ticket-description"
name="description"
rows="4"
placeholder="Optional context…"
{}
}
div class="field" {
label for="new-tags" { "Tags" }
input
class="input"
id="new-tags"
name="new_tags"
maxlength="300"
placeholder="home, writing";
p class="field-hint" { "Separate tags with commas. Everything here stays private to you." }
}
div class="form-actions" {
a class="btn btn-outline" href="/" { "Cancel" }
button class="btn btn-primary" type="submit" { "Add task" }
}
}
}
}
#[get("/")]
pub async fn home(
req: HttpRequest,
identity: Option<Identity>,
db: web::Data<Database>,
query: web::Query<BoardQuery>,
) -> HttpResponse {
let user = current_user(identity, &db).await;
let content = if let Some(user) = user.as_ref() {
let tickets = db
.visible_tickets(Some(&user.id))
.await
.unwrap_or_else(|error| {
log::error!("Failed to list private tasks: {error}");
Vec::new()
});
let done_count = db.done_ticket_count(&user.id).await.unwrap_or_default();
let view = TaskView::from_query(query.view.as_deref());
render_tasks_page(user, done_count, &tickets, query.tag.as_deref(), view)
} else {
let done_count = db.all_done_ticket_count().await.unwrap_or_else(|error| {
log::error!("Failed to count done tickets: {error}");
0
});
render_guest_home(done_count)
};
let body = page_or_partial(
&req,
content,
user.as_ref().map(|u| u.username.as_str()),
"Tasks",
)
.into_string();
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(body)
}
#[get("/tickets")]
pub async fn index(identity: Option<Identity>, db: web::Data<Database>) -> HttpResponse {
if current_user(identity, &db).await.is_none() {
return HttpResponse::SeeOther()
.insert_header(("Location", "/"))
.finish();
}
HttpResponse::SeeOther()
.insert_header(("Location", "/"))
.finish()
}
#[get("/tickets/new")]
pub async fn new_ticket_page(
req: HttpRequest,
identity: Option<Identity>,
db: web::Data<Database>,
) -> HttpResponse {
let Some(user) = current_user(identity, &db).await else {
return HttpResponse::SeeOther()
.insert_header(("Location", "/auth/login"))
.finish();
};
let body = page_or_partial(
&req,
render_new_task_page(),
Some(&user.username),
"New task",
)
.into_string();
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(body)
}
#[post("/tickets")]
pub async fn create(
identity: Option<Identity>,
db: web::Data<Database>,
payload: web::Bytes,
) -> impl Responder {
let Some(user): Option<User> = current_user(identity, &db).await else {
return HttpResponse::SeeOther()
.insert_header(("Location", "/"))
.finish();
};
let Ok(fields) = super::form::parse_form(&payload) else {
return HttpResponse::BadRequest().body(render_error("Invalid form data.").into_string());
};
let title = form_value(&fields, "title");
let title = title.trim();
if title.is_empty() || title.chars().count() > 120 {
return HttpResponse::BadRequest()
.body(render_error("Tasks need a title of 1–120 characters.").into_string());
}
let description = form_value(&fields, "description");
if description.chars().count() > 2000 {
return HttpResponse::BadRequest()
.body(render_error("Task details must be at most 2,000 characters.").into_string());
}
let tag_names = form_values(&fields, "new_tags")
.join(",")
.split(',')
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();
if tag_names.iter().any(|name| name.chars().count() > 40) {
return HttpResponse::BadRequest()
.body(render_error("Tags must be at most 40 characters.").into_string());
}
let mut tag_names = tag_names;
tag_names.sort();
tag_names.dedup();
let ticket = Ticket {
id: Uuid::new_v4().to_string(),
title: title.to_string(),
description: description.trim().to_string(),
creator_id: user.id,
created_at: chrono::Utc::now().to_rfc3339(),
};
match db.create_ticket(&ticket, &tag_names).await {
Ok(()) => HttpResponse::SeeOther()
.insert_header(("Location", "/"))
.finish(),
Err(error) => {
log::error!("Failed to create private task: {error}");
HttpResponse::InternalServerError()
.body(render_error("Could not add this task.").into_string())
}
}
}
#[post("/tickets/{id}/close")]
pub async fn close(
identity: Option<Identity>,
db: web::Data<Database>,
path: web::Path<String>,
payload: web::Bytes,
) -> HttpResponse {
let Some(user) = current_user(identity, &db).await else {
return HttpResponse::SeeOther()
.insert_header(("Location", "/"))
.finish();
};
let Ok(fields) = super::form::parse_form(&payload) else {
return HttpResponse::BadRequest().finish();
};
let outcome = form_value(&fields, "outcome");
if !matches!(outcome.as_str(), "done" | "not_planned") {
return HttpResponse::BadRequest()
.body(render_error("Choose Done or Not planned.").into_string());
}
match db
.close_ticket(&path.into_inner(), &user.id, &outcome)
.await
{
Ok(true | false) => HttpResponse::SeeOther()
.insert_header(("Location", "/"))
.finish(),
Err(error) => {
log::error!("Failed to close task: {error}");
HttpResponse::InternalServerError().finish()
}
}
}
#[post("/tickets/{id}/reopen")]
pub async fn reopen(
identity: Option<Identity>,
db: web::Data<Database>,
path: web::Path<String>,
) -> HttpResponse {
let Some(user) = current_user(identity, &db).await else {
return HttpResponse::SeeOther()
.insert_header(("Location", "/"))
.finish();
};
match db.reopen_ticket(&path.into_inner(), &user.id).await {
Ok(true | false) => HttpResponse::SeeOther()
.insert_header(("Location", "/"))
.finish(),
Err(error) => {
log::error!("Failed to reopen task: {error}");
HttpResponse::InternalServerError().finish()
}
}
}