From ed8a5dae9cd2691c1e81b1fc7cbb3856ac4ea130 Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Sun, 31 May 2026 22:02:32 -0500 Subject: [PATCH] feat: ability to hide underdue tasks --- README.md | 54 ++++- Taskfile | 6 + migrations/demo.sql | 15 ++ .../each_user/0002_frequency_targets.sql | 36 ++++ migrations/each_user/0003_user-settings.sql | 7 + src/main.rs | 1 + src/web/auth.rs | 13 +- src/web/home.rs | 187 ++++++++++++------ src/web/mod.rs | 16 +- src/web/settings.rs | 29 +++ src/web/task.rs | 38 ++-- static/alpine_3.15.12.min.js | 5 + static/alpinejs.min.js | 5 - static/home.css | 39 ++-- ...=> htmx-ext-response-targets_2.0.4.min.js} | 0 static/{htmx.min.js => htmx_2.0.7.min.js} | 0 16 files changed, 341 insertions(+), 110 deletions(-) create mode 100644 migrations/each_user/0002_frequency_targets.sql create mode 100644 migrations/each_user/0003_user-settings.sql create mode 100644 src/web/settings.rs create mode 100644 static/alpine_3.15.12.min.js delete mode 100644 static/alpinejs.min.js rename static/{htmx-ext-response-targets.js => htmx-ext-response-targets_2.0.4.min.js} (100%) rename static/{htmx.min.js => htmx_2.0.7.min.js} (100%) diff --git a/README.md b/README.md index 4ad0f04..830f8b2 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,53 @@ -# Rust web stack template +# Entretien -My favorite webapp stack right now +

It means "maintenance" in French.

- +

I can't actually pronounce it correctly.

+ +--- + +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 +``` diff --git a/Taskfile b/Taskfile index 937b63a..9cefb8a 100755 --- a/Taskfile +++ b/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 "$@" } diff --git a/migrations/demo.sql b/migrations/demo.sql index 080f10c..c8b792c 100644 --- a/migrations/demo.sql +++ b/migrations/demo.sql @@ -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')); diff --git a/migrations/each_user/0002_frequency_targets.sql b/migrations/each_user/0002_frequency_targets.sql new file mode 100644 index 0000000..973680b --- /dev/null +++ b/migrations/each_user/0002_frequency_targets.sql @@ -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; diff --git a/migrations/each_user/0003_user-settings.sql b/migrations/each_user/0003_user-settings.sql new file mode 100644 index 0000000..2450dcf --- /dev/null +++ b/migrations/each_user/0003_user-settings.sql @@ -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; + diff --git a/src/main.rs b/src/main.rs index f63f703..da19049 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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( diff --git a/src/web/auth.rs b/src/web/auth.rs index e5934fb..530551f 100644 --- a/src/web/auth.rs +++ b/src/web/auth.rs @@ -29,7 +29,6 @@ pub fn router() -> Router { mod post { use super::*; - #[axum::debug_handler] pub async fn login( mut auth_session: AuthSession, State(mut state): State, @@ -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"))); } diff --git a/src/web/home.rs b/src/web/home.rs index 680959b..378aa29 100644 --- a/src/web/home.rs +++ b/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) -> Result { + fn since(datestamp: &Option) -> Result { 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 = 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::>::new(), + HashMap::>::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)> = 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" + } + } + } } } } diff --git a/src/web/mod.rs b/src/web/mod.rs index 53a6d37..ed4b4d5 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -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" } } } } diff --git a/src/web/settings.rs b/src/web/settings.rs new file mode 100644 index 0000000..4c3b43e --- /dev/null +++ b/src/web/settings.rs @@ -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 { + 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, + ) -> Result { + 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("") + } +} diff --git a/src/web/task.rs b/src/web/task.rs index 13e6bdf..512a2ca 100644 --- a/src/web/task.rs +++ b/src/web/task.rs @@ -18,13 +18,13 @@ use crate::{AppError, AppState, DbId}; pub fn router() -> Router { 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, - Form(payload): Form, + Path(task_id): Path, + Form(payload): Form, ) -> Result { 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)] diff --git a/static/alpine_3.15.12.min.js b/static/alpine_3.15.12.min.js new file mode 100644 index 0000000..ab371ef --- /dev/null +++ b/static/alpine_3.15.12.min.js @@ -0,0 +1,5 @@ +(()=>{var ee=!1,re=!1,W=[],ne=-1,ie=!1;function Ve(t){Dn(t)}function Ue(){ie=!0}function qe(){ie=!1,We()}function Dn(t){W.includes(t)||W.push(t),We()}function Ke(t){let e=W.indexOf(t);e!==-1&&e>ne&&W.splice(e,1)}function We(){if(!re&&!ee){if(ie)return;ee=!0,queueMicrotask(In)}}function In(){ee=!1,re=!0;for(let t=0;tt.effect(e,{scheduler:r=>{oe?Ve(r):r()}}),se=t.raw}function ae(t){R=t}function Ye(t){let e=()=>{};return[n=>{let i=R(n);return t._x_effects||(t._x_effects=new Set,t._x_runEffects=()=>{t._x_effects.forEach(o=>o())}),t._x_effects.add(i),e=()=>{i!==void 0&&(t._x_effects.delete(i),j(i))},i},()=>{e()}]}function St(t,e){let r=!0,n,i,o=R(()=>{let s=t(),a=JSON.stringify(s);if(!r&&(typeof s=="object"||s!==n)){let c=typeof n=="object"?JSON.parse(i):n;queueMicrotask(()=>{e(s,c)})}n=s,i=a,r=!1});return()=>j(o)}async function Xe(t){Ue();try{await t(),await Promise.resolve()}finally{qe()}}var Ze=[],Qe=[],tr=[];function er(t){tr.push(t)}function et(t,e){typeof e=="function"?(t._x_cleanups||(t._x_cleanups=[]),t._x_cleanups.push(e)):(e=t,Qe.push(e))}function At(t){Ze.push(t)}function Ot(t,e,r){t._x_attributeCleanups||(t._x_attributeCleanups={}),t._x_attributeCleanups[e]||(t._x_attributeCleanups[e]=[]),t._x_attributeCleanups[e].push(r)}function ce(t,e){t._x_attributeCleanups&&Object.entries(t._x_attributeCleanups).forEach(([r,n])=>{(e===void 0||e.includes(r))&&(n.forEach(i=>i()),delete t._x_attributeCleanups[r])})}function rr(t){for(t._x_effects?.forEach(Ke);t._x_cleanups?.length;)t._x_cleanups.pop()()}var le=new MutationObserver(pe),ue=!1;function ut(){le.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ue=!0}function fe(){kn(),le.disconnect(),ue=!1}var lt=[];function kn(){let t=le.takeRecords();lt.push(()=>t.length>0&&pe(t));let e=lt.length;queueMicrotask(()=>{if(lt.length===e)for(;lt.length>0;)lt.shift()()})}function m(t){if(!ue)return t();fe();let e=t();return ut(),e}var de=!1,vt=[];function nr(){de=!0}function ir(){de=!1,pe(vt),vt=[]}function pe(t){if(de){vt=vt.concat(t);return}let e=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),t[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||e.push(s)}})),t[o].type==="attributes")){let s=t[o].target,a=t[o].attributeName,c=t[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{ce(s,o)}),n.forEach((o,s)=>{Ze.forEach(a=>a(s,o))});for(let o of r)e.some(s=>s.contains(o))||Qe.forEach(s=>s(o));for(let o of e)o.isConnected&&tr.forEach(s=>s(o));e=null,r=null,n=null,i=null}function Ct(t){return P(F(t))}function N(t,e,r){return t._x_dataStack=[e,...F(r||t)],()=>{t._x_dataStack=t._x_dataStack.filter(n=>n!==e)}}function F(t){return t._x_dataStack?t._x_dataStack:typeof ShadowRoot=="function"&&t instanceof ShadowRoot?F(t.host):t.parentNode?F(t.parentNode):[]}function P(t){return new Proxy({objects:t},$n)}function or(t,e){return t===null||t===Object.prototype?null:Object.prototype.hasOwnProperty.call(t,e)?t:or(Object.getPrototypeOf(t),e)}var $n={ownKeys({objects:t}){return Array.from(new Set(t.flatMap(e=>Object.keys(e))))},has({objects:t},e){return e==Symbol.unscopables?!1:t.some(r=>Object.prototype.hasOwnProperty.call(r,e)||Reflect.has(r,e))},get({objects:t},e,r){return e=="toJSON"?Ln:Reflect.get(t.find(n=>Reflect.has(n,e))||{},e,r)},set({objects:t},e,r,n){let i;for(let s of t)if(i=or(s,e),i)break;i||(i=t[t.length-1]);let o=Object.getOwnPropertyDescriptor(i,e);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,e,r)}};function Ln(){return Reflect.ownKeys(this).reduce((e,r)=>(e[r]=Reflect.get(this,r),e),{})}function rt(t){let e=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(t,c,o):e(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(t)}function Tt(t,e=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return t(this.initialValue,()=>jn(n,i),s=>me(n,i,s),i,o)}};return e(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function jn(t,e){return e.split(".").reduce((r,n)=>r[n],t)}function me(t,e,r){if(typeof e=="string"&&(e=e.split(".")),e.length===1)t[e[0]]=r;else{if(e.length===0)throw error;return t[e[0]]||(t[e[0]]={}),me(t[e[0]],e.slice(1),r)}}var sr={};function x(t,e){sr[t]=e}function H(t,e){let r=Fn(e);return Object.entries(sr).forEach(([n,i])=>{Object.defineProperty(t,`$${n}`,{get(){return i(e,r)},enumerable:!1})}),t}function Fn(t){let[e,r]=he(t),n={interceptor:Tt,...e};return et(t,r),n}function ar(t,e,r,...n){try{return r(...n)}catch(i){nt(i,t,e)}}function nt(...t){return cr(...t)}var cr=Bn;function lr(t){cr=t}function Bn(t,e,r=void 0){t=Object.assign(t??{message:"No error message given."},{el:e,expression:r}),console.warn(`Alpine Expression Error: ${t.message} + +${r?'Expression: "'+r+`" + +`:""}`,e),setTimeout(()=>{throw t},0)}var it=!0;function Mt(t){let e=it;it=!1;let r=t();return it=e,r}function T(t,e,r={}){let n;return _(t,e)(i=>n=i,r),n}function _(...t){return ur(...t)}var ur=()=>{};function fr(t){ur=t}var dr;function pr(t){dr=t}function mr(t,e){let r={};H(r,t);let n=[r,...F(t)],i=typeof e=="function"?zn(n,e):Vn(n,e,t);return ar.bind(null,t,e,i)}function zn(t,e){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{if(!it){ft(r,e,P([n,...t]),i);return}let s=e.apply(P([n,...t]),i);ft(r,s)}}var _e={};function Hn(t,e){if(_e[t])return _e[t];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(async()=>{ ${t} })()`:t,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${t}`}),s}catch(s){return nt(s,e,t),Promise.resolve()}})();return _e[t]=o,o}function Vn(t,e,r){let n=Hn(e,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=P([o,...t]);if(typeof n=="function"){let l=n.call(a,n,c).catch(u=>nt(u,r,e));n.finished?(ft(i,n.result,c,s,r),n.result=void 0):l.then(u=>{ft(i,u,c,s,r)}).catch(u=>nt(u,r,e)).finally(()=>n.result=void 0)}}}function ft(t,e,r,n,i){if(it&&typeof e=="function"){let o=e.apply(r,n);o instanceof Promise?o.then(s=>ft(t,s,r,n)).catch(s=>nt(s,i,e)):t(o)}else typeof e=="object"&&e instanceof Promise?e.then(o=>t(o)):t(e)}function hr(...t){return dr(...t)}function _r(t,e,r={}){let n={};H(n,t);let i=[n,...F(t)],o=P([r.scope??{},...i]),s=r.params??[];if(e.includes("await")){let a=Object.getPrototypeOf(async function(){}).constructor,c=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e;return new a(["scope"],`with (scope) { let __result = ${c}; return __result }`).call(r.context,o)}else{let a=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(()=>{ ${e} })()`:e,l=new Function(["scope"],`with (scope) { let __result = ${a}; return __result }`).call(r.context,o);return typeof l=="function"&&it?l.apply(o,s):l}}var ye="x-";function O(t=""){return ye+t}function gr(t){ye=t}var Rt={};function p(t,e){return Rt[t]=e,{before(r){if(!Rt[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${t}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,t)}}}function xr(t){return Object.keys(Rt).includes(t)}function pt(t,e,r){if(e=Array.from(e),t._x_virtualDirectives){let o=Object.entries(t._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=be(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),e=e.concat(o)}let n={};return e.map(wr((o,s)=>n[o]=s)).filter(Sr).map(qn(n,r)).sort(Kn).map(o=>Un(t,o))}function be(t){return Array.from(t).map(wr()).filter(e=>!Sr(e))}var ge=!1,dt=new Map,yr=Symbol();function br(t){ge=!0;let e=Symbol();yr=e,dt.set(e,[]);let r=()=>{for(;dt.get(e).length;)dt.get(e).shift()();dt.delete(e)},n=()=>{ge=!1,r()};t(r),n()}function he(t){let e=[],r=a=>e.push(a),[n,i]=Ye(t);return e.push(i),[{Alpine:B,effect:n,cleanup:r,evaluateLater:_.bind(_,t),evaluate:T.bind(T,t)},()=>e.forEach(a=>a())]}function Un(t,e){let r=()=>{},n=Rt[e.type]||r,[i,o]=he(t);Ot(t,e.original,o);let s=()=>{t._x_ignore||t._x_ignoreSelf||(n.inline&&n.inline(t,e,i),n=n.bind(n,t,e,i),ge?dt.get(yr).push(n):n())};return s.runCleanups=o,s}var Nt=(t,e)=>({name:r,value:n})=>(r.startsWith(t)&&(r=r.replace(t,e)),{name:r,value:n}),Pt=t=>t;function wr(t=()=>{}){return({name:e,value:r})=>{let{name:n,value:i}=Er.reduce((o,s)=>s(o),{name:e,value:r});return n!==e&&t(n,e),{name:n,value:i}}}var Er=[];function ot(t){Er.push(t)}function Sr({name:t}){return vr().test(t)}var vr=()=>new RegExp(`^${ye}([^:^.]+)\\b`);function qn(t,e){return({name:r,value:n})=>{r===n&&(n="");let i=r.match(vr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=e||t[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var xe="DEFAULT",G=["ignore","ref","id","data","anchor","bind","init","for","model","modelable","transition","show","if",xe,"teleport"];function Kn(t,e){let r=G.indexOf(t.type)===-1?xe:t.type,n=G.indexOf(e.type)===-1?xe:e.type;return G.indexOf(r)-G.indexOf(n)}function J(t,e,r={},n={}){return t.dispatchEvent(new CustomEvent(e,{detail:r,bubbles:!0,composed:!0,cancelable:!0,...n}))}function D(t,e){if(typeof ShadowRoot=="function"&&t instanceof ShadowRoot){Array.from(t.children).forEach(i=>D(i,e));return}let r=!1;if(e(t,()=>r=!0),r)return;let n=t.firstElementChild;for(;n;)D(n,e,!1),n=n.nextElementSibling}function E(t,...e){console.warn(`Alpine Warning: ${t}`,...e)}var Ar=!1;function Or(){Ar&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Ar=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `