feat: ability to hide underdue tasks
This commit is contained in:
parent
a7c74feb63
commit
ed8a5dae9c
16 changed files with 341 additions and 110 deletions
54
README.md
54
README.md
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
|
||||||
6
Taskfile
6
Taskfile
|
|
@ -1,5 +1,11 @@
|
||||||
#!/usr/bin/env bash
|
#!/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() {
|
playwright:local() {
|
||||||
bash e2e/Taskfile playwright:local "$@"
|
bash e2e/Taskfile playwright:local "$@"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,3 +23,18 @@ insert into tasks (description, frequency_id) values
|
||||||
('Wash dish rack', 6),
|
('Wash dish rack', 6),
|
||||||
('Oil hinges', 7),
|
('Oil hinges', 7),
|
||||||
('Wash patio', 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'));
|
||||||
|
|
|
||||||
36
migrations/each_user/0002_frequency_targets.sql
Normal file
36
migrations/each_user/0002_frequency_targets.sql
Normal 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;
|
||||||
7
migrations/each_user/0003_user-settings.sql
Normal file
7
migrations/each_user/0003_user-settings.sql
Normal 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;
|
||||||
|
|
||||||
|
|
@ -182,6 +182,7 @@ async fn serve(
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.merge(web::home::router())
|
.merge(web::home::router())
|
||||||
.merge(web::task::router())
|
.merge(web::task::router())
|
||||||
|
.merge(web::settings::router())
|
||||||
.route_layer(login_required!(Backend, login_url = "/login"))
|
.route_layer(login_required!(Backend, login_url = "/login"))
|
||||||
.merge(web::auth::router())
|
.merge(web::auth::router())
|
||||||
.nest_service(
|
.nest_service(
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ pub fn router() -> Router<AppState> {
|
||||||
mod post {
|
mod post {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[axum::debug_handler]
|
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
mut auth_session: AuthSession,
|
mut auth_session: AuthSession,
|
||||||
State(mut state): State<AppState>,
|
State(mut state): State<AppState>,
|
||||||
|
|
@ -85,15 +84,9 @@ mod get {
|
||||||
// link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest")));
|
// link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest")));
|
||||||
title { "Entretien" }
|
title { "Entretien" }
|
||||||
meta name="viewport" content="width=device-width";
|
meta name="viewport" content="width=device-width";
|
||||||
@if cfg!(debug_assertions) {
|
script defer src=(format!("/static/{}", asset!("htmx_2.0.7.min.js"))) {}
|
||||||
script src=(format!("/static/{}", asset!("htmx.min.js"))) defer {}
|
script defer src=(format!("/static/{}", asset!("htmx-ext-response-targets_2.0.4.min.js"))) {}
|
||||||
script src=(format!("/static/{}", asset!("htmx-ext-response-targets.js"))) defer {}
|
script defer src=(format!("/static/{}", asset!("alpine_3.15.12.min.js"))) {}
|
||||||
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 {}
|
|
||||||
}
|
|
||||||
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css")));
|
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css")));
|
||||||
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("login.css")));
|
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("login.css")));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
187
src/web/home.rs
187
src/web/home.rs
|
|
@ -32,19 +32,17 @@ mod get {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize, Debug, Clone)]
|
#[derive(serde::Serialize, Debug, Clone)]
|
||||||
struct TaskWithSince {
|
struct TaskDisplayData {
|
||||||
task: Task,
|
id: DbId,
|
||||||
since: f64,
|
href: String,
|
||||||
|
description: String,
|
||||||
|
frequency: String,
|
||||||
|
datestamp: String,
|
||||||
|
since: String, //stringified float to store infinities
|
||||||
|
target: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::ops::Deref for TaskWithSince {
|
fn since(datestamp: &Option<String>) -> Result<String, AppError> {
|
||||||
type Target = Task;
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
&self.task
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn since(datestamp: &Option<String>) -> Result<f64, AppError> {
|
|
||||||
if let Some(datestamp) = datestamp {
|
if let Some(datestamp) = datestamp {
|
||||||
let now = Zoned::now();
|
let now = Zoned::now();
|
||||||
let then =
|
let then =
|
||||||
|
|
@ -52,10 +50,11 @@ mod get {
|
||||||
return Ok(now
|
return Ok(now
|
||||||
.since(&then)?
|
.since(&then)?
|
||||||
.total(SpanTotal::from(Unit::Day).days_are_24_hours())?
|
.total(SpanTotal::from(Unit::Day).days_are_24_hours())?
|
||||||
.floor());
|
.floor()
|
||||||
|
.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(f64::INFINITY)
|
Ok(String::from("Infinity"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn home(
|
pub async fn home(
|
||||||
|
|
@ -83,14 +82,30 @@ mod get {
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.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()
|
.into_iter()
|
||||||
.map(|task| TaskWithSince {
|
.map(|task| TaskDisplayData {
|
||||||
since: since(&task.datestamp).unwrap(),
|
id: task.id,
|
||||||
task: task,
|
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(
|
.fold(
|
||||||
HashMap::<String, Vec<TaskWithSince>>::new(),
|
HashMap::<String, Vec<TaskDisplayData>>::new(),
|
||||||
|mut map, each| {
|
|mut map, each| {
|
||||||
map.entry(each.frequency.clone())
|
map.entry(each.frequency.clone())
|
||||||
.or_insert_with(Vec::new)
|
.or_insert_with(Vec::new)
|
||||||
|
|
@ -98,26 +113,45 @@ mod get {
|
||||||
map
|
map
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
for (_, list) in &mut by_freq {
|
|
||||||
list.sort_by(|a, b| b.since.partial_cmp(&a.since).unwrap())
|
let freq_tasks: Vec<(String, &Vec<TaskDisplayData>)> = freq_tasks
|
||||||
}
|
.into_iter()
|
||||||
let frequencies = vec![
|
.filter_map(|freq| {
|
||||||
"daily",
|
task_map
|
||||||
"weekly",
|
.get(&freq.description)
|
||||||
"fortnightly",
|
.map(|tasks| (freq.description, tasks))
|
||||||
"monthly",
|
})
|
||||||
"seasonally",
|
.collect();
|
||||||
"semiannually",
|
|
||||||
"yearly",
|
let show_underdue: bool = sqlx::query_scalar!("select show_underdue from settings")
|
||||||
]
|
.fetch_one(pool)
|
||||||
.into_iter()
|
.await?;
|
||||||
.filter(|k| by_freq.contains_key(&k.to_string()));
|
|
||||||
|
|
||||||
Ok(layout.render(
|
Ok(layout.render(
|
||||||
"Home",
|
"Home",
|
||||||
Some(vec![asset!("home.css")]),
|
Some(vec![asset!("home.css")]),
|
||||||
html! {
|
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 {
|
.col {
|
||||||
label for="new_task" { "new task" }
|
label for="new_task" { "new task" }
|
||||||
input id="new_task" name="description" x-model="description";
|
input id="new_task" name="description" x-model="description";
|
||||||
|
|
@ -138,7 +172,7 @@ mod get {
|
||||||
.error {}
|
.error {}
|
||||||
}
|
}
|
||||||
|
|
||||||
form #bulk-complete
|
form #bulk-complete x-show="$store.show.bulk_complete"
|
||||||
hx-ext="response-targets"
|
hx-ext="response-targets"
|
||||||
x-data=(json!({ "regex": "", "date": "", "ct": 0 }))
|
x-data=(json!({ "regex": "", "date": "", "ct": 0 }))
|
||||||
hx-post="/task/bulk_completion"
|
hx-post="/task/bulk_completion"
|
||||||
|
|
@ -160,28 +194,71 @@ mod get {
|
||||||
.error {}
|
.error {}
|
||||||
}
|
}
|
||||||
|
|
||||||
.table {
|
table x-data=(json!({
|
||||||
@for frequency in frequencies {
|
"visible_frequencies": [],
|
||||||
h1 .span { (frequency) }
|
"all_tasks": freq_tasks
|
||||||
.th { "task" }
|
}))
|
||||||
.th { "since" }
|
x-init="
|
||||||
.th .span { "last completed" }
|
all_tasks = all_tasks.map(([f, tasks]) => (
|
||||||
|
[f, tasks.map(task => ({ ...task, since: parseFloat(task.since) }))]
|
||||||
@for task in by_freq.get(frequency).unwrap() {
|
))
|
||||||
span {
|
"
|
||||||
a href=(format!("/task/{}", task.id)) { (PreEscaped(markdown::to_html(&task.description))) }
|
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" }
|
||||||
}
|
}
|
||||||
span .since id=(format!("since-{}", task.id)) {
|
template x-for="task in tasks" ":key"="task.id" {
|
||||||
(task.since.to_string())
|
tr x-data="{ datestamp: new Date().toISOString().split('T')[0] }"
|
||||||
}
|
x-show="$store.show.underdue_tasks || task.since >= task.target"
|
||||||
span .datestamp {
|
"x-transition.opacity.duration.200ms"
|
||||||
(task.datestamp.as_ref().map_or("never", |v| v))
|
{
|
||||||
}
|
td { a x-bind:href="task.href" x-html="task.description"; }
|
||||||
form hx-post="/task/completion" hx-target="previous .datestamp" hx-vals=(json!({
|
td x-text="task.since === Infinity ? '∞' : task.since" x-bind:id="'since-' + task.id";
|
||||||
"task_id": task.id,
|
td x-text="task.datestamp" .datestamp;
|
||||||
"datestamp": Zoned::now().date().strftime("%F").to_string(),
|
td { button
|
||||||
})) hx-on::after-on-load=(format!("document.getElementById('since-{}').innerText = 0", task.id)) {
|
x-bind:id="'complete-' + task.id"
|
||||||
button { "Complete" }
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ use axum::extract::FromRequestParts;
|
||||||
use cache_bust::asset;
|
use cache_bust::asset;
|
||||||
use http::request::Parts;
|
use http::request::Parts;
|
||||||
use maud::{DOCTYPE, Markup, html};
|
use maud::{DOCTYPE, Markup, html};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
use super::models::user::{AuthSession, User};
|
use super::models::user::{AuthSession, User};
|
||||||
use super::{AppError, AppState};
|
use super::{AppError, AppState};
|
||||||
|
|
@ -10,6 +11,7 @@ use super::{AppError, AppState};
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod home;
|
pub mod home;
|
||||||
|
pub mod settings;
|
||||||
pub mod task;
|
pub mod task;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|
@ -52,15 +54,10 @@ impl Layout {
|
||||||
// link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest")));
|
// link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest")));
|
||||||
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css")));
|
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css")));
|
||||||
meta name="viewport" content="width=device-width";
|
meta name="viewport" content="width=device-width";
|
||||||
@if cfg!(debug_assertions) {
|
meta name="htmx-config" content=(json!({"responseTargetUnsetsError": "false"}));
|
||||||
script src=(format!("/static/{}", asset!("htmx.min.js"))) defer {}
|
script defer src=(format!("/static/{}", asset!("htmx_2.0.7.min.js"))) {}
|
||||||
script src=(format!("/static/{}", asset!("htmx-ext-response-targets.js"))) defer {}
|
script defer src=(format!("/static/{}", asset!("htmx-ext-response-targets_2.0.4.min.js"))) {}
|
||||||
script src=(format!("/static/{}", asset!("alpinejs.min.js"))) defer {}
|
script defer src=(format!("/static/{}", asset!("alpine_3.15.12.min.js"))) {}
|
||||||
} @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 {}
|
|
||||||
}
|
|
||||||
@if let Some(hrefs) = css {
|
@if let Some(hrefs) = css {
|
||||||
@for href in hrefs {
|
@for href in hrefs {
|
||||||
link rel="stylesheet" type="text/css" href=(format!("/static/{}", href));
|
link rel="stylesheet" type="text/css" href=(format!("/static/{}", href));
|
||||||
|
|
@ -72,7 +69,6 @@ impl Layout {
|
||||||
(content)
|
(content)
|
||||||
|
|
||||||
}
|
}
|
||||||
script type="text/javascript" { "htmx.config.responseTargetUnsetsError = false" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
src/web/settings.rs
Normal file
29
src/web/settings.rs
Normal 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("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,13 +18,13 @@ use crate::{AppError, AppState, DbId};
|
||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/task/completion", post(self::post::task_completion))
|
|
||||||
.route(
|
.route(
|
||||||
"/task/bulk_completion",
|
"/task/bulk_completion",
|
||||||
post(self::post::bulk_task_completion),
|
post(self::post::bulk_task_completion),
|
||||||
)
|
)
|
||||||
.route("/task/description", post(self::post::task_description))
|
.route("/task/description", post(self::post::task_description))
|
||||||
.route("/task/{task_id}", get(self::get::task))
|
.route("/task/{task_id}", get(self::get::task))
|
||||||
|
.route("/task/{task_id}/complete", post(self::post::task_complete))
|
||||||
.route("/task", post(self::post::task))
|
.route("/task", post(self::post::task))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,7 +61,7 @@ mod get {
|
||||||
Some(vec![asset!("task.css")]),
|
Some(vec![asset!("task.css")]),
|
||||||
html! {
|
html! {
|
||||||
header { a href="/" { "Home" } }
|
header { a href="/" { "Home" } }
|
||||||
main {
|
main hx-ext="response-targets" {
|
||||||
h1 x-data="{'edit':false}" {
|
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; })" {
|
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)))
|
(PreEscaped(markdown::to_html(&task.description)))
|
||||||
|
|
@ -75,16 +75,19 @@ mod get {
|
||||||
x-on:blur="edit = false";
|
x-on:blur="edit = false";
|
||||||
}
|
}
|
||||||
form
|
form
|
||||||
hx-post="/task/completion"
|
hx-post=(format!("/task/{task_id}/complete"))
|
||||||
hx-vals=(json!({"task_id": task_id}))
|
|
||||||
hx-target="#completions"
|
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 {
|
.col {
|
||||||
label for="when" { "when" }
|
label for="when" { "when" }
|
||||||
input id="when" name="datestamp" type="date";
|
input id="when" name="datestamp" type="date" x-bind="when";
|
||||||
|
.errors {}
|
||||||
}
|
}
|
||||||
#error {}
|
button x-bind:disabled="when.length == 0" { "add completion" }
|
||||||
button { "add completion" }
|
|
||||||
}
|
}
|
||||||
#completions {
|
#completions {
|
||||||
@if completions.is_empty() {
|
@if completions.is_empty() {
|
||||||
|
|
@ -135,28 +138,35 @@ mod post {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct PostTaskCompletionBody {
|
pub struct PostTaskCompleteBody {
|
||||||
task_id: DbId,
|
|
||||||
datestamp: String,
|
datestamp: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn task_completion(
|
pub async fn task_complete(
|
||||||
auth_session: AuthSession,
|
auth_session: AuthSession,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Form(payload): Form<PostTaskCompletionBody>,
|
Path(task_id): Path<DbId>,
|
||||||
|
Form(payload): Form<PostTaskCompleteBody>,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
let user = auth_session.user.unwrap();
|
let user = auth_session.user.unwrap();
|
||||||
let pool = &state.db(&user).pool;
|
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!(
|
sqlx::query!(
|
||||||
"insert into completions (task_id, datestamp) values (?, ?)",
|
"insert into completions (task_id, datestamp) values (?, ?)",
|
||||||
payload.task_id,
|
task_id,
|
||||||
payload.datestamp
|
payload.datestamp
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(html! { div { (payload.datestamp) }})
|
Ok((StatusCode::OK, html! { div { (payload.datestamp) }}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|
|
||||||
5
static/alpine_3.15.12.min.js
vendored
Normal file
5
static/alpine_3.15.12.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
5
static/alpinejs.min.js
vendored
5
static/alpinejs.min.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -1,3 +1,11 @@
|
||||||
|
#controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
#new-task,
|
#new-task,
|
||||||
#bulk-complete {
|
#bulk-complete {
|
||||||
border: 1px solid var(--line-color);
|
border: 1px solid var(--line-color);
|
||||||
|
|
@ -24,25 +32,30 @@
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table {
|
main {
|
||||||
display: grid;
|
width: 100%;
|
||||||
grid-template-columns: auto repeat(3, max-content);
|
max-width: 600px;
|
||||||
gap: 0.5rem;
|
margin: auto;
|
||||||
align-items: center;
|
}
|
||||||
|
|
||||||
h1 {
|
table {
|
||||||
margin-top: 1rem;
|
text-align: left;
|
||||||
grid-column: 1 / -1;
|
border-spacing: 0.5rem;
|
||||||
|
border-collapse: separate;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
col.content {
|
||||||
|
width: min-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frequency th {
|
||||||
|
padding-top: 1rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.th {
|
.heading th {
|
||||||
font-size: x-small;
|
font-size: x-small;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-variant-caps: small-caps;
|
font-variant-caps: small-caps;
|
||||||
}
|
}
|
||||||
|
|
||||||
.th.span {
|
|
||||||
grid-column: 3 / -1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue