feat: ability to hide underdue tasks

This commit is contained in:
Robert Perce 2026-05-31 22:02:32 -05:00
parent a7c74feb63
commit ed8a5dae9c
16 changed files with 341 additions and 110 deletions

View file

@ -1,5 +1,53 @@
# Rust web stack template
# Entretien
My favorite webapp stack right now
<p style="font-size: small">It means "maintenance" in French.</p>
<p style="font-size: small">I can't actually pronounce it correctly.</p>
---
A task list for a relatively stable set of fundamentally-repeating task list, e.g., house cleaning and maintenance.
Live at https://entretien.dukeceph.xyz/. Log in with `demo` as both the username and password to check it out!
Contact me if you want an account there.
Features:
* Supports daily, weekly, fortnightly, monthly, seasonal, semiannual, and yearly target cadences.
* Sorts in-section by least-recently-done first.
* Allows for bulk completion-entry.
Eventual planned features:
* Review functionality wherein tasks' target cadences can be compared to their actual cadences.
* Beeminder-consumable completion-stats view.
### Development / self-hosting
1. Clone the repo.
2. Build for your system with `./Taskfile _cargo build --release`.
3. Deploy the binary from `./target/release/entretien` to wherever you want that's in PATH
(or use it from here if you want)
4. In the working directory that you want the server to save its databases in,
1. Create a user for yourself with `entretien set-password YOUR_USERNAME`. This will create a `users.db` file.
2. Run `mkdir dbs`.
3. Copy the `hashed_static` directory from the code repository.
5. Run `entretien serve [port]` from that working directory. The default port is 3000.
Feel free to contact me if you need help.
### Example systemd service file
```
[Unit]
Description=Entretien
After=network.target
[Service]
Type=simple
WorkingDirectory=/var/local/entretien/
ExecStart=/usr/bin/entretien serve --port 6234
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```

View file

@ -1,5 +1,11 @@
#!/usr/bin/env bash
fetch_3p_scripts() {
curl "https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" -o "static/htmx-ext-response-targets_2.0.4.min.js"
curl "https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" -o "static/htmx_2.0.7.min.js"
curl "https://cdn.jsdelivr.net/npm/alpinejs@3.15.12/dist/cdn.min.js" -o "static/alpine_3.15.12.min.js"
}
playwright:local() {
bash e2e/Taskfile playwright:local "$@"
}

View file

@ -23,3 +23,18 @@ insert into tasks (description, frequency_id) values
('Wash dish rack', 6),
('Oil hinges', 7),
('Wash patio', 7);
insert into completions (task_id, datestamp) values
(1, date('now', '-7 days')),
(2, date('now', '-3 days')),
(3, date('now', '-11 days')),
(4, date('now', '-3 days')),
(6, date('now', '-13 days')),
(7, date('now', '-18 days')),
(8, date('now', '-35 days')),
(9, date('now', '-22 days')),
(10, date('now', '-43 days')),
(12, date('now', '-69 days')),
(13, date('now', '-43 days')),
(15, date('now', '-95 days')),
(17, date('now', '-204 days'));

View file

@ -0,0 +1,36 @@
-- foreign_keys can only up/down outside of transactions
-- so we first pre-commit the one started by sqlx...
COMMIT TRANSACTION;
-- turn off foreign keys...
PRAGMA foreign_keys=OFF;
-- start our own transaction...
BEGIN TRANSACTION;
create table if not exists __replace_frequencies (
id integer primary key autoincrement,
description text not null default '',
target_period number not null
);
insert into __replace_frequencies (id, description, target_period) values
(1, 'daily', 1),
(2, 'weekly', 7),
(3, 'fortnightly', 14),
(4, 'monthly', 30),
(5, 'seasonally', 91),
(6, 'semiannually', 182),
(7, 'yearly', 365);
drop table frequencies;
alter table __replace_frequencies rename to frequencies;
PRAGMA foreign_key_check;
-- commit our own transaction...
COMMIT TRANSACTION;
-- put our own pragmas back...
PRAGMA foreign_keys=ON;
-- and start a dummy transaction so sqlx's COMMIT doesn't explode
BEGIN TRANSACTION;

View file

@ -0,0 +1,7 @@
create table if not exists settings (
id integer primary key,
show_underdue boolean not null default true
);
insert into settings (id) values (1) on conflict (id) do nothing;

View file

@ -182,6 +182,7 @@ async fn serve(
let app = Router::new()
.merge(web::home::router())
.merge(web::task::router())
.merge(web::settings::router())
.route_layer(login_required!(Backend, login_url = "/login"))
.merge(web::auth::router())
.nest_service(

View file

@ -29,7 +29,6 @@ pub fn router() -> Router<AppState> {
mod post {
use super::*;
#[axum::debug_handler]
pub async fn login(
mut auth_session: AuthSession,
State(mut state): State<AppState>,
@ -85,15 +84,9 @@ mod get {
// link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest")));
title { "Entretien" }
meta name="viewport" content="width=device-width";
@if cfg!(debug_assertions) {
script src=(format!("/static/{}", asset!("htmx.min.js"))) defer {}
script src=(format!("/static/{}", asset!("htmx-ext-response-targets.js"))) defer {}
script src=(format!("/static/{}", asset!("alpinejs.min.js"))) defer {}
} @else {
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" defer {}
script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" defer {}
script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {}
}
script defer src=(format!("/static/{}", asset!("htmx_2.0.7.min.js"))) {}
script defer src=(format!("/static/{}", asset!("htmx-ext-response-targets_2.0.4.min.js"))) {}
script defer src=(format!("/static/{}", asset!("alpine_3.15.12.min.js"))) {}
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css")));
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("login.css")));
}

View file

@ -32,19 +32,17 @@ mod get {
}
#[derive(serde::Serialize, Debug, Clone)]
struct TaskWithSince {
task: Task,
since: f64,
struct TaskDisplayData {
id: DbId,
href: String,
description: String,
frequency: String,
datestamp: String,
since: String, //stringified float to store infinities
target: i64,
}
impl std::ops::Deref for TaskWithSince {
type Target = Task;
fn deref(&self) -> &Self::Target {
&self.task
}
}
fn since(datestamp: &Option<String>) -> Result<f64, AppError> {
fn since(datestamp: &Option<String>) -> Result<String, AppError> {
if let Some(datestamp) = datestamp {
let now = Zoned::now();
let then =
@ -52,10 +50,11 @@ mod get {
return Ok(now
.since(&then)?
.total(SpanTotal::from(Unit::Day).days_are_24_hours())?
.floor());
.floor()
.to_string());
}
Ok(f64::INFINITY)
Ok(String::from("Infinity"))
}
pub async fn home(
@ -83,14 +82,30 @@ mod get {
.fetch_all(pool)
.await?;
let mut by_freq = last_completions
let freq_tasks = sqlx::query!(
"select description, target_period from frequencies order by target_period asc"
)
.fetch_all(pool)
.await?;
let freq_targets: HashMap<String, i64> = freq_tasks
.iter()
.map(|freq| (freq.description.clone(), freq.target_period))
.collect();
let task_map = last_completions
.into_iter()
.map(|task| TaskWithSince {
since: since(&task.datestamp).unwrap(),
task: task,
.map(|task| TaskDisplayData {
id: task.id,
href: format!("/task/{}", task.id),
description: PreEscaped(markdown::to_html(&task.description)).into_string(),
since: since(&task.datestamp).unwrap().to_string(),
datestamp: task.datestamp.unwrap_or("never".to_string()),
target: *freq_targets.get(&task.frequency).unwrap(),
frequency: task.frequency,
})
.fold(
HashMap::<String, Vec<TaskWithSince>>::new(),
HashMap::<String, Vec<TaskDisplayData>>::new(),
|mut map, each| {
map.entry(each.frequency.clone())
.or_insert_with(Vec::new)
@ -98,26 +113,45 @@ mod get {
map
},
);
for (_, list) in &mut by_freq {
list.sort_by(|a, b| b.since.partial_cmp(&a.since).unwrap())
}
let frequencies = vec![
"daily",
"weekly",
"fortnightly",
"monthly",
"seasonally",
"semiannually",
"yearly",
]
let freq_tasks: Vec<(String, &Vec<TaskDisplayData>)> = freq_tasks
.into_iter()
.filter(|k| by_freq.contains_key(&k.to_string()));
.filter_map(|freq| {
task_map
.get(&freq.description)
.map(|tasks| (freq.description, tasks))
})
.collect();
let show_underdue: bool = sqlx::query_scalar!("select show_underdue from settings")
.fetch_one(pool)
.await?;
Ok(layout.render(
"Home",
Some(vec![asset!("home.css")]),
html! {
form #new-task hx-post="/task" x-data=(json!({"description": ""})) hx-on::after-on-load="window.location.reload()" hx-target-error="find .error" {
script { (PreEscaped(format!("
document.addEventListener('alpine:init', () => {{
Alpine.store('show', {{
new_task: false,
bulk_complete: false,
underdue_tasks: {show_underdue},
}});
Alpine.store('toggle', (key) => {{
Alpine.store('show')[key] = !Alpine.store('show')[key];
}});
}})"
)))}
#controls x-data hx-swap="none" {
button x-on:click="$store.toggle('new_task')" { "new-task" };
button x-on:click="$store.toggle('bulk_complete')" { "bulk-complete" }
button
x-on:click="$store.toggle('underdue_tasks')"
x-text="`${$store.show.underdue_tasks ? 'hide' : 'show'} underdue tasks`"
hx-post="/settings/show_underdue/toggle";
}
form #new-task x-show="$store.show.new_task" hx-post="/task" x-data=(json!({"description": ""})) hx-on::after-on-load="window.location.reload()" hx-target-error="find .error" {
.col {
label for="new_task" { "new task" }
input id="new_task" name="description" x-model="description";
@ -138,7 +172,7 @@ mod get {
.error {}
}
form #bulk-complete
form #bulk-complete x-show="$store.show.bulk_complete"
hx-ext="response-targets"
x-data=(json!({ "regex": "", "date": "", "ct": 0 }))
hx-post="/task/bulk_completion"
@ -160,28 +194,71 @@ mod get {
.error {}
}
.table {
@for frequency in frequencies {
h1 .span { (frequency) }
.th { "task" }
.th { "since" }
.th .span { "last completed" }
@for task in by_freq.get(frequency).unwrap() {
span {
a href=(format!("/task/{}", task.id)) { (PreEscaped(markdown::to_html(&task.description))) }
table x-data=(json!({
"visible_frequencies": [],
"all_tasks": freq_tasks
}))
x-init="
all_tasks = all_tasks.map(([f, tasks]) => (
[f, tasks.map(task => ({ ...task, since: parseFloat(task.since) }))]
))
"
x-effect="
if ($store.show.underdue_tasks) {
visible_frequencies = all_tasks.map(([freq]) => freq);
} else {
visible_frequencies = all_tasks.filter(([freq, tasks]) => (
tasks.some(task => task.since >= task.target)
)).map(([freq]) => freq);
}
visible_frequencies = new Set(visible_frequencies);
"
{
colgroup {
col .stretch;
col width="0*" .content;
col width="0*" .content;
col width="0*";
}
template x-for="[frequency, tasks] in all_tasks"" :key"="frequency" {
tbody
x-effect="tasks.sort((a, b) => b.since - a.since)"
x-show="visible_frequencies.has(frequency)"
"x-transition.opacity.duration.200ms"
{
tr .frequency { th x-text="frequency" colspan="4"; }
tr .heading {
th { "task" }
th { "since" }
th colspan="2" { "last completed" }
}
template x-for="task in tasks" ":key"="task.id" {
tr x-data="{ datestamp: new Date().toISOString().split('T')[0] }"
x-show="$store.show.underdue_tasks || task.since >= task.target"
"x-transition.opacity.duration.200ms"
{
td { a x-bind:href="task.href" x-html="task.description"; }
td x-text="task.since === Infinity ? '∞' : task.since" x-bind:id="'since-' + task.id";
td x-text="task.datestamp" .datestamp;
td { button
x-bind:id="'complete-' + task.id"
x-on:click="
htmx.ajax('POST', `/task/${task.id}/complete`, {
'swap': 'none',
'values': { datestamp },
}).then(() => {
all_tasks = all_tasks.map(([desc, tasks]) => (
[desc, tasks.map(tk => tk.id === task.id
? { ...tk, since: 0, datestamp }
: tk
)]
))
})
" {
"Complete"
}
span .since id=(format!("since-{}", task.id)) {
(task.since.to_string())
}
span .datestamp {
(task.datestamp.as_ref().map_or("never", |v| v))
}
form hx-post="/task/completion" hx-target="previous .datestamp" hx-vals=(json!({
"task_id": task.id,
"datestamp": Zoned::now().date().strftime("%F").to_string(),
})) hx-on::after-on-load=(format!("document.getElementById('since-{}').innerText = 0", task.id)) {
button { "Complete" }
}
}
}

View file

@ -3,6 +3,7 @@ use axum::extract::FromRequestParts;
use cache_bust::asset;
use http::request::Parts;
use maud::{DOCTYPE, Markup, html};
use serde_json::json;
use super::models::user::{AuthSession, User};
use super::{AppError, AppState};
@ -10,6 +11,7 @@ use super::{AppError, AppState};
pub mod auth;
pub mod home;
pub mod settings;
pub mod task;
#[derive(Debug)]
@ -52,15 +54,10 @@ impl Layout {
// link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest")));
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css")));
meta name="viewport" content="width=device-width";
@if cfg!(debug_assertions) {
script src=(format!("/static/{}", asset!("htmx.min.js"))) defer {}
script src=(format!("/static/{}", asset!("htmx-ext-response-targets.js"))) defer {}
script src=(format!("/static/{}", asset!("alpinejs.min.js"))) defer {}
} @else {
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" defer {}
script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" defer {}
script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {}
}
meta name="htmx-config" content=(json!({"responseTargetUnsetsError": "false"}));
script defer src=(format!("/static/{}", asset!("htmx_2.0.7.min.js"))) {}
script defer src=(format!("/static/{}", asset!("htmx-ext-response-targets_2.0.4.min.js"))) {}
script defer src=(format!("/static/{}", asset!("alpine_3.15.12.min.js"))) {}
@if let Some(hrefs) = css {
@for href in hrefs {
link rel="stylesheet" type="text/css" href=(format!("/static/{}", href));
@ -72,7 +69,6 @@ impl Layout {
(content)
}
script type="text/javascript" { "htmx.config.responseTargetUnsetsError = false" }
}
}
}

29
src/web/settings.rs Normal file
View file

@ -0,0 +1,29 @@
use axum::{Router, extract::State, response::IntoResponse, routing::post};
use crate::models::user::AuthSession;
use crate::{AppError, AppState};
pub fn router() -> Router<AppState> {
Router::new().route(
"/settings/show_underdue/toggle",
post(self::post::toggle_show_underdue),
)
}
mod post {
use super::*;
pub async fn toggle_show_underdue(
auth_session: AuthSession,
State(state): State<AppState>,
) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
sqlx::query!("update settings set show_underdue = NOT show_underdue")
.execute(pool)
.await?;
Ok("")
}
}

View file

@ -18,13 +18,13 @@ use crate::{AppError, AppState, DbId};
pub fn router() -> Router<AppState> {
Router::new()
.route("/task/completion", post(self::post::task_completion))
.route(
"/task/bulk_completion",
post(self::post::bulk_task_completion),
)
.route("/task/description", post(self::post::task_description))
.route("/task/{task_id}", get(self::get::task))
.route("/task/{task_id}/complete", post(self::post::task_complete))
.route("/task", post(self::post::task))
}
@ -61,7 +61,7 @@ mod get {
Some(vec![asset!("task.css")]),
html! {
header { a href="/" { "Home" } }
main {
main hx-ext="response-targets" {
h1 x-data="{'edit':false}" {
span x-show="!edit" x-on:click="edit = true; $nextTick(() => { $refs.edit.focus(); $refs.edit.selectionStart = $refs.edit.selectionEnd = $refs.edit.value.length; })" {
(PreEscaped(markdown::to_html(&task.description)))
@ -75,16 +75,19 @@ mod get {
x-on:blur="edit = false";
}
form
hx-post="/task/completion"
hx-vals=(json!({"task_id": task_id}))
hx-post=(format!("/task/{task_id}/complete"))
hx-target="#completions"
hx-swap="afterbegin" {
hx-target-error="find .errors"
hx-on::after-request="console.log(event)"
hx-swap="afterbegin"
x-data=(json!({ "when": "" }))
{
.col {
label for="when" { "when" }
input id="when" name="datestamp" type="date";
input id="when" name="datestamp" type="date" x-bind="when";
.errors {}
}
#error {}
button { "add completion" }
button x-bind:disabled="when.length == 0" { "add completion" }
}
#completions {
@if completions.is_empty() {
@ -135,28 +138,35 @@ mod post {
}
#[derive(Deserialize)]
pub struct PostTaskCompletionBody {
task_id: DbId,
pub struct PostTaskCompleteBody {
datestamp: String,
}
pub async fn task_completion(
pub async fn task_complete(
auth_session: AuthSession,
State(state): State<AppState>,
Form(payload): Form<PostTaskCompletionBody>,
Path(task_id): Path<DbId>,
Form(payload): Form<PostTaskCompleteBody>,
) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
if payload.datestamp.len() == 0 {
return Ok((
StatusCode::BAD_REQUEST,
PreEscaped("'when' must not be empty".to_string()),
));
}
sqlx::query!(
"insert into completions (task_id, datestamp) values (?, ?)",
payload.task_id,
task_id,
payload.datestamp
)
.execute(pool)
.await?;
Ok(html! { div { (payload.datestamp) }})
Ok((StatusCode::OK, html! { div { (payload.datestamp) }}))
}
#[derive(Deserialize)]

5
static/alpine_3.15.12.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,3 +1,11 @@
#controls {
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
padding: 0 0.5rem;
}
#new-task,
#bulk-complete {
border: 1px solid var(--line-color);
@ -24,25 +32,30 @@
font-family: monospace;
}
.table {
display: grid;
grid-template-columns: auto repeat(3, max-content);
gap: 0.5rem;
align-items: center;
main {
width: 100%;
max-width: 600px;
margin: auto;
}
h1 {
margin-top: 1rem;
grid-column: 1 / -1;
table {
text-align: left;
border-spacing: 0.5rem;
border-collapse: separate;
width: 100%;
col.content {
width: min-content;
}
.frequency th {
padding-top: 1rem;
font-weight: bold;
}
.th {
.heading th {
font-size: x-small;
font-weight: bold;
font-variant-caps: small-caps;
}
.th.span {
grid-column: 3 / -1;
}
}