feat: ability to hide underdue tasks
This commit is contained in:
parent
a7c74feb63
commit
ed8a5dae9c
16 changed files with 341 additions and 110 deletions
52
README.md
52
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
|
||||
|
||||
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 "$@"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
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()
|
||||
.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(
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
}
|
||||
|
|
|
|||
187
src/web/home.rs
187
src/web/home.rs
|
|
@ -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",
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|k| by_freq.contains_key(&k.to_string()));
|
||||
|
||||
let freq_tasks: Vec<(String, &Vec<TaskDisplayData>)> = freq_tasks
|
||||
.into_iter()
|
||||
.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" }
|
||||
}
|
||||
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" }
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
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> {
|
||||
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
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,
|
||||
#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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue