├── .gitignore ├── src ├── web │ ├── ui │ │ ├── static_files │ │ │ ├── icon512.png │ │ │ ├── redo.svg │ │ │ ├── reload.js │ │ │ ├── undo.svg │ │ │ ├── new.svg │ │ │ ├── trash.svg │ │ │ ├── save.svg │ │ │ ├── manifest.json │ │ │ ├── spinner.svg │ │ │ ├── loading-spinner.js │ │ │ ├── mod.rs │ │ │ └── styles.css │ │ ├── manager │ │ │ ├── settings.rs │ │ │ ├── mod.rs │ │ │ ├── edit.rs │ │ │ ├── input-errors.js │ │ │ ├── new.rs │ │ │ └── render.rs │ │ ├── cache.rs │ │ ├── l10n │ │ │ ├── en.ftl │ │ │ └── fr.ftl │ │ ├── error.rs │ │ ├── template.rs │ │ ├── stats │ │ │ └── mod.rs │ │ ├── mod.rs │ │ ├── l10n.rs │ │ └── home.rs │ ├── api │ │ ├── chore │ │ │ ├── mod.rs │ │ │ ├── chores.rs │ │ │ ├── stats.rs │ │ │ └── chore.rs │ │ ├── parse_span.rs │ │ ├── health_check.rs │ │ ├── error.rs │ │ └── mod.rs │ └── mod.rs ├── main.rs ├── stats │ ├── utils.rs │ ├── mod.rs │ └── completion_delta.rs ├── cli.rs ├── logging.rs └── db │ ├── types.rs │ └── mod.rs ├── migrations ├── 20250319031221_redo.sql ├── 20250316202436_chores.sql └── 20250316205410_events.sql ├── .sqlx ├── query-0c0db6e37ea43fb1474eddbfc6ee940a9333a938269f2cc2f6f7e7efb48fead3.json ├── query-4be745fcf2a32eae17c1f9f72e25f54771afad6126c6c9146eb31b9f737d414c.json ├── query-0a2132ef042192335c020c57522744ce81865faee592a3044a78fcc401258271.json ├── query-162a9b9ab66f34a636563a2f1d6e0dce5aae5e37b52863ca59c507180a4ae289.json ├── query-65c48100a1705366d69b357125357804af72b3c3b304be2f330dfa04ae181600.json ├── query-bff19808271facdf32e0f4c99efa7af97f240ba15c4d6191380bbfc7cf84c614.json ├── query-62407e5b63767cec6927da07093e1f4ed083175562b3a969184dbfac69ce88c4.json ├── query-72d23eff68e456c9598c21968abb383a2b1cb39fb6292afacac09758eb3efc8a.json ├── query-6f4a81239ddb7b12167f12b7608281422082f529a008f5a3661b26b0c466a9a0.json ├── query-3df54589d36beb5e4133294baf630b6a152bdf980b1a4de8aedf2cd04048320d.json ├── query-512d7e98c8d3f4f8d0feee77c5269011b249d7fc98904dc53356bc1c013e40eb.json ├── query-36be54df6d8c43ba92434c11db946204697e25031ea9ec4c2b57e16288fa91ec.json ├── query-6ef2510c96f12970b0222e96242cd770db6b1dd8cb0d3a8982a1ae3c66bc69d2.json ├── query-195df27977a9611ebf742cba70430a377cb4d090944cdb9eefee1a8a11e1b274.json ├── query-4ac160adf0c9a1d5d3ba0def5cc61c5717498aff31026ceba25ac4acb920028e.json ├── query-21d08f547b7b844bc5ec7145bb6f6fcdf15bcc918b1fc0509159d1a103d99493.json └── query-b52c6d619f80f2d7318949bda4a64543064bb9912a5226d37bd90e5e7809a4cf.json ├── .github ├── dependabot.yml └── workflows │ ├── docker.yml │ └── build.yml ├── Dockerfile ├── deny.toml ├── Cargo.toml ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | *.db 3 | *.db-shm 4 | *.db-wal 5 | -------------------------------------------------------------------------------- /src/web/ui/static_files/icon512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamaluik/chordle/main/src/web/ui/static_files/icon512.png -------------------------------------------------------------------------------- /src/web/api/chore/mod.rs: -------------------------------------------------------------------------------- 1 | #[allow(clippy::module_inception)] 2 | mod chore; 3 | pub use chore::get_chore; 4 | 5 | mod chores; 6 | pub use chores::get_chores; 7 | 8 | mod stats; 9 | pub use stats::get_chore_stats; 10 | -------------------------------------------------------------------------------- /src/web/ui/static_files/redo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/web/ui/static_files/reload.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | // reload the page every 5 minutes so that we stay fresh 3 | // assume that cache-control is working and we are not getting 4 | // stale content at load time 5 | setTimeout(function () { 6 | location.reload(); 7 | }, 5 * 60 * 1000); 8 | })(); 9 | -------------------------------------------------------------------------------- /src/web/ui/static_files/undo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /migrations/20250319031221_redo.sql: -------------------------------------------------------------------------------- 1 | create table redo_events ( 2 | -- which chore the event happened for 3 | chore_id integer not null, 4 | -- the time the event occurred, in a zone-aware datetime format 5 | timestamp text not null, 6 | foreign key (chore_id) references chores (id) on delete cascade 7 | ); 8 | -------------------------------------------------------------------------------- /src/web/ui/static_files/new.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/web/ui/static_files/trash.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.sqlx/query-0c0db6e37ea43fb1474eddbfc6ee940a9333a938269f2cc2f6f7e7efb48fead3.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "delete from redo_events", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 0 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "0c0db6e37ea43fb1474eddbfc6ee940a9333a938269f2cc2f6f7e7efb48fead3" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-4be745fcf2a32eae17c1f9f72e25f54771afad6126c6c9146eb31b9f737d414c.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\ndelete from chores\nwhere id = ?\n ", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 1 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "4be745fcf2a32eae17c1f9f72e25f54771afad6126c6c9146eb31b9f737d414c" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-0a2132ef042192335c020c57522744ce81865faee592a3044a78fcc401258271.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\ninsert into events (chore_id, timestamp)\nvalues (?, ?)\n", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 2 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "0a2132ef042192335c020c57522744ce81865faee592a3044a78fcc401258271" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-162a9b9ab66f34a636563a2f1d6e0dce5aae5e37b52863ca59c507180a4ae289.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\ninsert into redo_events (chore_id, timestamp)\nvalues (?, ?)\n", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 2 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "162a9b9ab66f34a636563a2f1d6e0dce5aae5e37b52863ca59c507180a4ae289" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-65c48100a1705366d69b357125357804af72b3c3b304be2f330dfa04ae181600.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\ndelete from events\nwhere chore_id = ? and timestamp = ?\n", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 2 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "65c48100a1705366d69b357125357804af72b3c3b304be2f330dfa04ae181600" 12 | } 13 | -------------------------------------------------------------------------------- /migrations/20250316202436_chores.sql: -------------------------------------------------------------------------------- 1 | -- sqlite 2 | create table chores ( 3 | id integer not null primary key autoincrement, 4 | -- the human-friendly name of the chore 5 | name text not null, 6 | -- how often the chore should be done, in ISO8601 or "friendly" format 7 | -- (see https://docs.rs/jiff/latest/jiff/struct.Span.html#parsing-and-printing) 8 | interval text not null 9 | ); 10 | 11 | -------------------------------------------------------------------------------- /.sqlx/query-bff19808271facdf32e0f4c99efa7af97f240ba15c4d6191380bbfc7cf84c614.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\ndelete from redo_events\nwhere chore_id = ? and timestamp = ?\n", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 2 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "bff19808271facdf32e0f4c99efa7af97f240ba15c4d6191380bbfc7cf84c614" 12 | } 13 | -------------------------------------------------------------------------------- /src/web/ui/static_files/save.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.sqlx/query-62407e5b63767cec6927da07093e1f4ed083175562b3a969184dbfac69ce88c4.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\ninsert into events (chore_id, timestamp)\nvalues (?, ?)\n ", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 2 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "62407e5b63767cec6927da07093e1f4ed083175562b3a969184dbfac69ce88c4" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-72d23eff68e456c9598c21968abb383a2b1cb39fb6292afacac09758eb3efc8a.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nupdate chores\nset name = ?, interval = ?\nwhere id = ?\n ", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 3 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "72d23eff68e456c9598c21968abb383a2b1cb39fb6292afacac09758eb3efc8a" 12 | } 13 | -------------------------------------------------------------------------------- /migrations/20250316205410_events.sql: -------------------------------------------------------------------------------- 1 | -- sqlite 2 | create table events ( 3 | -- which chore the event happened for 4 | chore_id integer not null, 5 | -- the time the event occurred, in a zone-aware datetime format 6 | timestamp text not null, 7 | foreign key (chore_id) references chores (id) on delete cascade 8 | ); 9 | 10 | create index idx_events_chore_id_timestamp on events (chore_id, timestamp); 11 | -------------------------------------------------------------------------------- /src/web/api/parse_span.rs: -------------------------------------------------------------------------------- 1 | use axum::{extract::Query, http::StatusCode}; 2 | use jiff::Span; 3 | use serde::Deserialize; 4 | 5 | #[derive(Deserialize)] 6 | pub struct SpanReq { 7 | pub span: String, 8 | } 9 | 10 | pub async fn parse_span(Query(query): Query) -> StatusCode { 11 | if query.span.parse::().is_ok() { 12 | StatusCode::OK 13 | } else { 14 | StatusCode::BAD_REQUEST 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/web/ui/static_files/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Chordle", 3 | "icons": [ 4 | { 5 | "src": "/icon.png?s=192", 6 | "type": "image/png", 7 | "sizes": "192x192" 8 | }, 9 | { 10 | "src": "/icon.png?s=512", 11 | "type": "image/png", 12 | "sizes": "512x512" 13 | } 14 | ], 15 | "start_url": "/", 16 | "display": "standalone" 17 | } 18 | -------------------------------------------------------------------------------- /src/web/api/chore/chores.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | db::Chore, 3 | web::{AppState, api::error::ApiErrorResponse}, 4 | }; 5 | use axum::{Json, extract::State}; 6 | use color_eyre::eyre::WrapErr; 7 | 8 | pub async fn get_chores( 9 | State(state): State, 10 | ) -> Result>, ApiErrorResponse> { 11 | let chores = state 12 | .db 13 | .get_all_chores() 14 | .await 15 | .wrap_err("Failed to get all chores")?; 16 | Ok(Json(chores)) 17 | } 18 | -------------------------------------------------------------------------------- /src/web/api/health_check.rs: -------------------------------------------------------------------------------- 1 | use axum::{Json, extract::State}; 2 | use jiff::Timestamp; 3 | use serde::Serialize; 4 | 5 | use crate::web::AppState; 6 | 7 | #[derive(Serialize)] 8 | pub struct HealthCheck { 9 | pub uptime: String, 10 | } 11 | 12 | pub async fn health_check(State(state): State) -> Json { 13 | let now = Timestamp::now(); 14 | let uptime = now - *state.launch_time; 15 | let uptime = format!("{uptime:#}"); 16 | 17 | Json(HealthCheck { uptime }) 18 | } 19 | -------------------------------------------------------------------------------- /.sqlx/query-6f4a81239ddb7b12167f12b7608281422082f529a008f5a3661b26b0c466a9a0.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nselect count(*) as count\nfrom events\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "count", 8 | "ordinal": 0, 9 | "type_info": "Integer" 10 | } 11 | ], 12 | "parameters": { 13 | "Right": 0 14 | }, 15 | "nullable": [ 16 | false 17 | ] 18 | }, 19 | "hash": "6f4a81239ddb7b12167f12b7608281422082f529a008f5a3661b26b0c466a9a0" 20 | } 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "cargo" 9 | directory: "/" 10 | schedule: 11 | interval: "monthly" 12 | assignees: 13 | - "hamaluik" 14 | -------------------------------------------------------------------------------- /.sqlx/query-3df54589d36beb5e4133294baf630b6a152bdf980b1a4de8aedf2cd04048320d.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nselect count(*) as count\nfrom redo_events\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "count", 8 | "ordinal": 0, 9 | "type_info": "Integer" 10 | } 11 | ], 12 | "parameters": { 13 | "Right": 0 14 | }, 15 | "nullable": [ 16 | false 17 | ] 18 | }, 19 | "hash": "3df54589d36beb5e4133294baf630b6a152bdf980b1a4de8aedf2cd04048320d" 20 | } 21 | -------------------------------------------------------------------------------- /.sqlx/query-512d7e98c8d3f4f8d0feee77c5269011b249d7fc98904dc53356bc1c013e40eb.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\ninsert into chores (name, interval)\nvalues (?, ?)\nreturning id\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "id", 8 | "ordinal": 0, 9 | "type_info": "Integer" 10 | } 11 | ], 12 | "parameters": { 13 | "Right": 2 14 | }, 15 | "nullable": [ 16 | false 17 | ] 18 | }, 19 | "hash": "512d7e98c8d3f4f8d0feee77c5269011b249d7fc98904dc53356bc1c013e40eb" 20 | } 21 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::{Result, eyre::Context}; 2 | 3 | mod cli; 4 | mod db; 5 | mod logging; 6 | mod stats; 7 | mod web; 8 | 9 | #[tokio::main] 10 | async fn main() -> Result<()> { 11 | let cli = cli::cli(); 12 | 13 | logging::setup_logging(&cli).wrap_err_with(|| "Failed to setup logging")?; 14 | 15 | let db = db::Db::new(&cli.sqlite_db) 16 | .await 17 | .wrap_err_with(|| "Failed to connect to database")?; 18 | 19 | web::run(cli, db) 20 | .await 21 | .wrap_err_with(|| "Failed to run web server")?; 22 | 23 | Ok(()) 24 | } 25 | -------------------------------------------------------------------------------- /src/web/ui/static_files/spinner.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 6 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.sqlx/query-36be54df6d8c43ba92434c11db946204697e25031ea9ec4c2b57e16288fa91ec.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nselect chore_id, timestamp\nfrom events\norder by timestamp desc\nlimit 1\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "chore_id", 8 | "ordinal": 0, 9 | "type_info": "Integer" 10 | }, 11 | { 12 | "name": "timestamp", 13 | "ordinal": 1, 14 | "type_info": "Text" 15 | } 16 | ], 17 | "parameters": { 18 | "Right": 0 19 | }, 20 | "nullable": [ 21 | false, 22 | false 23 | ] 24 | }, 25 | "hash": "36be54df6d8c43ba92434c11db946204697e25031ea9ec4c2b57e16288fa91ec" 26 | } 27 | -------------------------------------------------------------------------------- /.sqlx/query-6ef2510c96f12970b0222e96242cd770db6b1dd8cb0d3a8982a1ae3c66bc69d2.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nselect chore_id, timestamp\nfrom redo_events\norder by timestamp desc\nlimit 1\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "chore_id", 8 | "ordinal": 0, 9 | "type_info": "Integer" 10 | }, 11 | { 12 | "name": "timestamp", 13 | "ordinal": 1, 14 | "type_info": "Text" 15 | } 16 | ], 17 | "parameters": { 18 | "Right": 0 19 | }, 20 | "nullable": [ 21 | false, 22 | false 23 | ] 24 | }, 25 | "hash": "6ef2510c96f12970b0222e96242cd770db6b1dd8cb0d3a8982a1ae3c66bc69d2" 26 | } 27 | -------------------------------------------------------------------------------- /.sqlx/query-195df27977a9611ebf742cba70430a377cb4d090944cdb9eefee1a8a11e1b274.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nselect chore_id, timestamp\nfrom events\nwhere chore_id = ?\norder by timestamp asc\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "chore_id", 8 | "ordinal": 0, 9 | "type_info": "Integer" 10 | }, 11 | { 12 | "name": "timestamp", 13 | "ordinal": 1, 14 | "type_info": "Text" 15 | } 16 | ], 17 | "parameters": { 18 | "Right": 1 19 | }, 20 | "nullable": [ 21 | false, 22 | false 23 | ] 24 | }, 25 | "hash": "195df27977a9611ebf742cba70430a377cb4d090944cdb9eefee1a8a11e1b274" 26 | } 27 | -------------------------------------------------------------------------------- /src/web/api/chore/stats.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | db::ChoreId, 3 | web::{AppState, api::error::ApiErrorResponse}, 4 | }; 5 | use axum::{ 6 | Json, 7 | extract::{Path, State}, 8 | http::StatusCode, 9 | response::{IntoResponse, Response}, 10 | }; 11 | use color_eyre::eyre::WrapErr; 12 | 13 | pub async fn get_chore_stats( 14 | State(state): State, 15 | Path(id): Path, 16 | ) -> Result { 17 | let stats = crate::stats::get_stats(&state.db, ChoreId(id)) 18 | .await 19 | .wrap_err_with(|| format!("Failed to get stats for chore {id}",))?; 20 | Ok(match stats { 21 | Some(stats) => Json(stats).into_response(), 22 | None => StatusCode::NOT_FOUND.into_response(), 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /src/web/api/chore/chore.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | db::ChoreId, 3 | web::{AppState, api::error::ApiErrorResponse}, 4 | }; 5 | use axum::{ 6 | Json, 7 | extract::{Path, State}, 8 | http::StatusCode, 9 | response::{IntoResponse, Response}, 10 | }; 11 | use color_eyre::eyre::WrapErr; 12 | 13 | pub async fn get_chore( 14 | Path(id): Path, 15 | State(state): State, 16 | ) -> Result { 17 | let chore = state 18 | .db 19 | .get_chore(ChoreId(id)) 20 | .await 21 | .wrap_err_with(|| format!("Failed to get chore {id}",))?; 22 | Ok(match chore { 23 | Some(chore) => (StatusCode::OK, Json(chore)).into_response(), 24 | None => (StatusCode::NOT_FOUND, Json(())).into_response(), 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /src/web/ui/manager/settings.rs: -------------------------------------------------------------------------------- 1 | use crate::web::ui::{MANAGER_URI, error::ErrorResponse, l10n::Lang}; 2 | use axum::{ 3 | Form, 4 | response::{IntoResponse, Redirect}, 5 | }; 6 | use axum_extra::extract::{CookieJar, cookie::Cookie}; 7 | use serde::Deserialize; 8 | 9 | #[derive(Deserialize)] 10 | pub struct LanguageForm { 11 | lang: String, 12 | } 13 | 14 | pub async fn change_language( 15 | jar: CookieJar, 16 | Form(form): Form, 17 | ) -> Result { 18 | let lang = Lang::from_str(&form.lang); 19 | let jar = jar.add( 20 | Cookie::build(("lang", lang.to_string())) 21 | .path("/") 22 | .http_only(true) 23 | .build(), 24 | ); 25 | 26 | Ok((jar, Redirect::to(MANAGER_URI))) 27 | } 28 | -------------------------------------------------------------------------------- /.sqlx/query-4ac160adf0c9a1d5d3ba0def5cc61c5717498aff31026ceba25ac4acb920028e.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nselect id, name, interval\nfrom chores\nwhere id = ?\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "id", 8 | "ordinal": 0, 9 | "type_info": "Integer" 10 | }, 11 | { 12 | "name": "name", 13 | "ordinal": 1, 14 | "type_info": "Text" 15 | }, 16 | { 17 | "name": "interval", 18 | "ordinal": 2, 19 | "type_info": "Text" 20 | } 21 | ], 22 | "parameters": { 23 | "Right": 1 24 | }, 25 | "nullable": [ 26 | false, 27 | false, 28 | false 29 | ] 30 | }, 31 | "hash": "4ac160adf0c9a1d5d3ba0def5cc61c5717498aff31026ceba25ac4acb920028e" 32 | } 33 | -------------------------------------------------------------------------------- /.sqlx/query-21d08f547b7b844bc5ec7145bb6f6fcdf15bcc918b1fc0509159d1a103d99493.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nselect id, name, interval\nfrom chores\norder by name asc\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "id", 8 | "ordinal": 0, 9 | "type_info": "Integer" 10 | }, 11 | { 12 | "name": "name", 13 | "ordinal": 1, 14 | "type_info": "Text" 15 | }, 16 | { 17 | "name": "interval", 18 | "ordinal": 2, 19 | "type_info": "Text" 20 | } 21 | ], 22 | "parameters": { 23 | "Right": 0 24 | }, 25 | "nullable": [ 26 | false, 27 | false, 28 | false 29 | ] 30 | }, 31 | "hash": "21d08f547b7b844bc5ec7145bb6f6fcdf15bcc918b1fc0509159d1a103d99493" 32 | } 33 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build image 2 | FROM rust AS build 3 | WORKDIR /usr/src 4 | 5 | RUN apt-get update && apt-get install -y musl-tools 6 | RUN rustup target add x86_64-unknown-linux-musl 7 | 8 | RUN USER=root cargo new chordle 9 | WORKDIR /usr/src/chordle 10 | COPY Cargo.toml Cargo.lock ./ 11 | ENV TZ=America/Edmonton 12 | RUN cargo build --release --target x86_64-unknown-linux-musl 13 | 14 | COPY build.rs ./ 15 | COPY src ./src 16 | COPY migrations ./migrations 17 | COPY .sqlx ./.sqlx 18 | RUN cargo build --release --target x86_64-unknown-linux-musl 19 | 20 | # Runtime image 21 | FROM alpine 22 | LABEL org.opencontainers.image.source="https://github.com/hamaluik/chordle" 23 | 24 | RUN apk add --no-cache tzdata 25 | ENV TZ=America/Edmonton 26 | ENV SQLITE_DB=/data/chordle.db 27 | ENV BIND=0.0.0.0:8080 28 | 29 | COPY --from=build /usr/src/chordle/target/x86_64-unknown-linux-musl/release/chordle /usr/bin/chordle 30 | ENTRYPOINT ["chordle"] 31 | CMD ["-v"] 32 | -------------------------------------------------------------------------------- /src/web/api/error.rs: -------------------------------------------------------------------------------- 1 | use axum::body::Body; 2 | use axum::http::{Response, StatusCode}; 3 | use axum::response::IntoResponse; 4 | use color_eyre::eyre::Error as EyreError; 5 | 6 | #[derive(Debug)] 7 | pub struct ApiErrorResponse; 8 | 9 | impl std::error::Error for ApiErrorResponse {} 10 | impl std::fmt::Display for ApiErrorResponse { 11 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 12 | write!(f, "An error occurred") 13 | } 14 | } 15 | 16 | impl IntoResponse for ApiErrorResponse { 17 | fn into_response(self) -> Response { 18 | Response::builder() 19 | .status(StatusCode::INTERNAL_SERVER_ERROR) 20 | .body(Body::empty()) 21 | .expect("Can build error response") 22 | } 23 | } 24 | 25 | impl From for ApiErrorResponse { 26 | fn from(err: EyreError) -> Self { 27 | tracing::error!("API Error: {:?}", err); 28 | Self 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | [graph] 2 | targets = [ 3 | ] 4 | all-features = false 5 | no-default-features = false 6 | 7 | [output] 8 | feature-depth = 1 9 | 10 | [advisories] 11 | ignore = [] 12 | 13 | [licenses] 14 | allow = [ 15 | "MIT", 16 | "Apache-2.0", 17 | "Apache-2.0 WITH LLVM-exception", 18 | "Unicode-3.0", 19 | "BSD-3-Clause", 20 | "Zlib", 21 | ] 22 | confidence-threshold = 0.8 23 | exceptions = [] 24 | 25 | [licenses.private] 26 | ignore = false 27 | registries = [] 28 | 29 | [bans] 30 | multiple-versions = "allow" 31 | wildcards = "warn" 32 | highlight = "all" 33 | workspace-default-features = "warn" 34 | external-default-features = "allow" 35 | allow = [] 36 | deny = [] 37 | skip = [] 38 | skip-tree = [] 39 | 40 | [sources] 41 | unknown-registry = "warn" 42 | unknown-git = "warn" 43 | allow-registry = ["https://github.com/rust-lang/crates.io-index"] 44 | allow-git = [] 45 | 46 | [sources.allow-org] 47 | github = [] 48 | gitlab = [] 49 | bitbucket = [] 50 | -------------------------------------------------------------------------------- /src/web/ui/manager/mod.rs: -------------------------------------------------------------------------------- 1 | use super::{error::ErrorResponse, l10n::Lang}; 2 | use crate::web::AppState; 3 | use axum::{extract::State, http::HeaderMap}; 4 | use axum_extra::extract::CookieJar; 5 | use color_eyre::Result; 6 | use maud::Markup; 7 | 8 | mod edit; 9 | mod new; 10 | mod render; 11 | mod settings; 12 | 13 | pub use edit::edit_chore; 14 | pub use new::new_chore; 15 | pub use settings::change_language; 16 | 17 | /// GET handler for the manager page 18 | pub async fn manager_home( 19 | State(app_state): State, 20 | headers: HeaderMap, 21 | jar: CookieJar, 22 | ) -> Result { 23 | let accept_language = headers 24 | .get("accept-language") 25 | .and_then(|value| value.to_str().ok()); 26 | let lang = Lang::from_accept_language_header_and_cookie(accept_language, &jar); 27 | 28 | render::render(lang, &app_state, Default::default()) 29 | .await 30 | .map_err(ErrorResponse::from) 31 | } 32 | -------------------------------------------------------------------------------- /src/web/ui/static_files/loading-spinner.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | const chore_forms = document.querySelectorAll('form.chore-form'); 3 | for (const form of chore_forms) { 4 | form.addEventListener('submit', function (event) { 5 | // remove all children from the form that don't have the spinner class 6 | let nonSpinners = []; 7 | let spinners = []; 8 | for (const child of form.children) { 9 | if (!child.classList.contains('spinner')) { 10 | nonSpinners.push(child); 11 | } 12 | else { 13 | spinners.push(child); 14 | } 15 | } 16 | 17 | for (const child of nonSpinners) { 18 | form.removeChild(child); 19 | } 20 | for (const spinner of spinners) { 21 | spinner.classList.remove('hidden'); 22 | } 23 | }); 24 | } 25 | })(); 26 | -------------------------------------------------------------------------------- /src/web/ui/cache.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | #[derive(Clone, Debug)] 4 | pub struct Cache { 5 | etags: HashMap, 6 | } 7 | 8 | impl Cache { 9 | pub fn new() -> Self { 10 | Self { 11 | etags: HashMap::new(), 12 | } 13 | } 14 | 15 | pub fn get_etag(&self, key: S) -> Option<&str> 16 | where 17 | S: AsRef, 18 | { 19 | self.etags.get(key.as_ref()).map(|val| val.as_str()) 20 | } 21 | 22 | pub fn etag_matches(&self, key: S1, etag: S2) -> bool 23 | where 24 | S1: AsRef, 25 | S2: AsRef, 26 | { 27 | self.etags 28 | .get(key.as_ref()) 29 | .is_some_and(|val| val == etag.as_ref()) 30 | } 31 | 32 | pub fn set_etag(&mut self, key: S1, etag: S2) 33 | where 34 | S1: Into, 35 | S2: Into, 36 | { 37 | self.etags.insert(key.into(), etag.into()); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/web/mod.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{Arc, RwLock}; 2 | 3 | use axum::Router; 4 | use color_eyre::Result; 5 | use jiff::Timestamp; 6 | use tokio::net::TcpListener; 7 | use ui::{cache::Cache, l10n::L10N}; 8 | 9 | use crate::{cli::Cli, db::Db}; 10 | 11 | mod api; 12 | mod ui; 13 | 14 | #[derive(Clone, Debug)] 15 | pub struct AppState { 16 | pub launch_time: Arc, 17 | pub db: Arc, 18 | pub cache: Arc>, 19 | pub l10n: Arc, 20 | } 21 | 22 | pub async fn run(cli: Cli, db: Db) -> Result<()> { 23 | let state = AppState { 24 | launch_time: Arc::new(Timestamp::now()), 25 | db: Arc::new(db), 26 | cache: Arc::new(RwLock::new(Cache::new())), 27 | l10n: Arc::new(L10N::new()), 28 | }; 29 | 30 | let app = Router::new() 31 | .merge(ui::routes()) 32 | .nest("/api", api::routes()) 33 | .with_state(state); 34 | 35 | tracing::info!("Starting chordle web server on {}", cli.bind); 36 | let listener = TcpListener::bind(cli.bind).await?; 37 | axum::serve(listener, app).await?; 38 | 39 | Ok(()) 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | workflow_run: 5 | workflows: ["Build"] 6 | branches: [main] 7 | types: 8 | - completed 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | build: 16 | name: Build Docker Image 17 | runs-on: ubuntu-latest 18 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 19 | permissions: 20 | contents: read 21 | packages: write 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Login to Docker Hub 25 | uses: docker/login-action@v3 26 | with: 27 | registry: ghcr.io 28 | username: ${{ github.actor }} 29 | password: ${{ secrets.GITHUB_TOKEN }} 30 | - name: Set up Docker Buildx 31 | uses: docker/setup-buildx-action@v3 32 | - name: Build and push 33 | uses: docker/build-push-action@v6 34 | with: 35 | context: . 36 | push: true 37 | tags: ghcr.io/hamaluik/chordle:latest 38 | cache-from: type=gha 39 | cache-to: type=gha,mode=max 40 | -------------------------------------------------------------------------------- /src/web/api/mod.rs: -------------------------------------------------------------------------------- 1 | use std::any::Any; 2 | 3 | use super::AppState; 4 | use axum::{ 5 | Router, 6 | body::Body, 7 | http::{Response, StatusCode}, 8 | routing::get, 9 | }; 10 | use tower_http::catch_panic::CatchPanicLayer; 11 | 12 | mod chore; 13 | mod error; 14 | mod health_check; 15 | mod parse_span; 16 | 17 | pub fn routes() -> Router { 18 | Router::new() 19 | .route("/health", get(health_check::health_check)) 20 | .route("/parse_span", get(parse_span::parse_span)) 21 | .route("/chore/{id}", get(chore::get_chore)) 22 | .route("/chore/{id}/stats", get(chore::get_chore_stats)) 23 | .route("/chores", get(chore::get_chores)) 24 | .layer(CatchPanicLayer::custom(handle_panic)) 25 | .fallback(handler_404) 26 | } 27 | 28 | fn handle_panic(_err: Box) -> Response { 29 | // err can be ignored because color_eyre will log it 30 | Response::builder() 31 | .status(StatusCode::INTERNAL_SERVER_ERROR) 32 | .body(Body::empty()) 33 | .expect("Internal Server Error response should be valid") 34 | } 35 | 36 | async fn handler_404() -> StatusCode { 37 | StatusCode::NOT_FOUND 38 | } 39 | -------------------------------------------------------------------------------- /.sqlx/query-b52c6d619f80f2d7318949bda4a64543064bb9912a5226d37bd90e5e7809a4cf.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nselect\n chores.id as \"id!\",\n chores.name as \"name!\",\n chores.interval as \"interval!\",\n events.timestamp\nfrom\n chores\nleft join\n (select\n chore_id,\n max(timestamp) as timestamp\n from\n events\n group by\n chore_id) as events\non chores.id = events.chore_id\n", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "id!", 8 | "ordinal": 0, 9 | "type_info": "Integer" 10 | }, 11 | { 12 | "name": "name!", 13 | "ordinal": 1, 14 | "type_info": "Text" 15 | }, 16 | { 17 | "name": "interval!", 18 | "ordinal": 2, 19 | "type_info": "Text" 20 | }, 21 | { 22 | "name": "timestamp", 23 | "ordinal": 3, 24 | "type_info": "Text" 25 | } 26 | ], 27 | "parameters": { 28 | "Right": 0 29 | }, 30 | "nullable": [ 31 | false, 32 | false, 33 | false, 34 | true 35 | ] 36 | }, 37 | "hash": "b52c6d619f80f2d7318949bda4a64543064bb9912a5226d37bd90e5e7809a4cf" 38 | } 39 | -------------------------------------------------------------------------------- /src/stats/utils.rs: -------------------------------------------------------------------------------- 1 | pub fn mean<'i, I>(iter: I) -> f64 2 | where 3 | I: Iterator, 4 | { 5 | let mut sum = 0.0; 6 | let mut count = 0; 7 | 8 | for value in iter { 9 | sum += value; 10 | count += 1; 11 | } 12 | 13 | if count == 0 { 14 | return 0.0; 15 | } 16 | 17 | sum / count as f64 18 | } 19 | 20 | pub fn median<'i, I>(iter: I) -> f64 21 | where 22 | I: Iterator, 23 | { 24 | let mut values: Vec = iter.cloned().collect(); 25 | values.sort_by(|a, b| a.partial_cmp(b).unwrap()); 26 | 27 | let len = values.len(); 28 | if len == 0 { 29 | return 0.0; 30 | } 31 | 32 | if len % 2 == 0 { 33 | (values[len / 2 - 1] + values[len / 2]) / 2.0 34 | } else { 35 | values[len / 2] 36 | } 37 | } 38 | 39 | pub fn variance<'i, I>(mean: f64, iter: I) -> f64 40 | where 41 | I: Iterator, 42 | { 43 | let mut sum_squared_diff = 0.0; 44 | let mut count = 0; 45 | 46 | for value in iter { 47 | let diff = value - mean; 48 | sum_squared_diff += diff * diff; 49 | count += 1; 50 | } 51 | 52 | if count == 0 { 53 | return 0.0; 54 | } 55 | 56 | sum_squared_diff / count as f64 57 | } 58 | -------------------------------------------------------------------------------- /src/web/ui/l10n/en.ftl: -------------------------------------------------------------------------------- 1 | days-ago-prefix =  2 | days-ago-number = { $days -> 3 | [-1] ∞ 4 | *[other] { $days } 5 | } 6 | days-ago-suffix = { $days -> 7 | [1] day ago 8 | *[other] days ago 9 | } 10 | due-today = (due today) 11 | due-ago = (due { $days -> 12 | [1] yesterday 13 | *[other] { $days } days ago 14 | }) 15 | due-in = (due { $days -> 16 | [1] tomorrow 17 | *[other] in { $days } days 18 | }) 19 | undo = Undo 20 | redo = Redo 21 | manage-chores = Manage Chores 22 | invalid-chore-name = Invalid chore name, must not be empty and ≤ 160 characters. 23 | invalid-interval = Invalid interval, see { $link } for formatting help. 24 | save = Save 25 | delete = Delete 26 | name = Name 27 | name-placeholder = e.g. "Clean the kitchen" 28 | interval = Interval 29 | history = History 30 | create = Create 31 | chore-created = Chore created successfully! 32 | failed-to-create-chore = Failed to create chore… 33 | new-chore = New Chore 34 | chores = Chores 35 | back-to-chores = ← Back to Chores 36 | chordle-source-code = chordle Source Code ↗ 37 | settings = Settings 38 | language = Language 39 | stats = Stats 40 | chore = Chore 41 | times-completed = Times Completed 42 | times-overdue = Times Overdue 43 | mean-days-overdue = Mean Days Overdue 44 | -------------------------------------------------------------------------------- /src/web/ui/error.rs: -------------------------------------------------------------------------------- 1 | use axum::body::Body; 2 | use axum::http::{Response, StatusCode}; 3 | use axum::response::IntoResponse; 4 | 5 | use super::l10n::Lang; 6 | 7 | #[derive(Debug)] 8 | pub struct ErrorResponse; 9 | 10 | impl std::error::Error for ErrorResponse {} 11 | impl std::fmt::Display for ErrorResponse { 12 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 13 | write!(f, "An error occurred") 14 | } 15 | } 16 | 17 | impl IntoResponse for ErrorResponse { 18 | fn into_response(self) -> Response { 19 | let page = super::template::page( 20 | Lang::En, 21 | "Error", 22 | maud::html! { 23 | main { 24 | h1 { "An error occurred" } 25 | p { "An error occurred while processing your request." } 26 | } 27 | }, 28 | ); 29 | 30 | Response::builder() 31 | .status(StatusCode::INTERNAL_SERVER_ERROR) 32 | .header("Content-Type", "text/html") 33 | .body(Body::from(page.into_string())) 34 | .expect("Can build error response") 35 | } 36 | } 37 | 38 | impl From for ErrorResponse { 39 | fn from(err: color_eyre::eyre::Error) -> Self { 40 | tracing::error!("Error: {:?}", err); 41 | Self 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "chordle" 3 | version = "1.0.0-alpha.1" 4 | authors = ["Kenton Hamaluik "] 5 | description = "A simple button-based chore tracker" 6 | license = "Apache-2.0" 7 | repository = "https://github.com/hamaluik/chordle.git" 8 | homepage = "https://githug.com/hamaluik/chordle" 9 | edition = "2024" 10 | 11 | [[bin]] 12 | name = "chordle" 13 | path = "src/main.rs" 14 | 15 | [dependencies] 16 | axum = "0.8.1" 17 | axum-extra = { version = "0.10.1", features = ["cookie"] } 18 | clap = { version = "4.5.32", features = ["derive", "cargo", "env", "unicode", "wrap_help"] } 19 | color-eyre = "0.6.3" 20 | fluent = "0.16.1" 21 | image = { version = "0.25.5", default-features = false, features = ["png", "ico"] } 22 | intl-memoizer = "0.5.2" 23 | jiff = { version = "0.2.4", features = ["serde"] } 24 | maud = { version = "0.27.0", features = ["axum"] } 25 | md5 = "0.7.0" 26 | serde = { version = "1.0.219", features = ["derive"] } 27 | sqlx = { version = "0.8.3", features = ["runtime-tokio", "sqlite", "migrate"] } 28 | tokio = { version = "1.44.1", features = ["full"] } 29 | tower-http = { version = "0.6.2", default-features = false, features = ["catch-panic"] } 30 | tracing = "0.1.41" 31 | tracing-subscriber = { version = "0.3.19", features = ["fmt"] } 32 | unic-langid = { version = "0.9.5", features = ["macros"] } 33 | 34 | [build-dependencies] 35 | fluent = "0.16.1" 36 | fluent-syntax = "0.11.1" 37 | 38 | -------------------------------------------------------------------------------- /src/web/ui/l10n/fr.ftl: -------------------------------------------------------------------------------- 1 | days-ago-prefix = il y a 2 | days-ago-number = { $days -> 3 | [-1] ∞ 4 | *[other] { $days } 5 | } 6 | days-ago-suffix = { $days -> 7 | [1] jour 8 | *[other] jours 9 | } 10 | due-today = (à rendre aujourd'hui) 11 | due-ago = (dû { $days -> 12 | [1] hier 13 | *[other] il y a { $days } jours 14 | }) 15 | due-in = ({ $days -> 16 | [1] à rendre demain 17 | *[other] dû dans { $days } jours 18 | }) 19 | undo = Défaire 20 | redo = Refaire 21 | manage-chores = Gérer les tâches ménagères 22 | invalid-chore-name = Nom de tâche non valide, ne doit pas être vide et ≤ 160 caractères. 23 | invalid-interval = Intervalle non valide, voir { $link } pour obtenir de l'aide sur le formatage. 24 | save = Sauvegarder 25 | delete = Supprimer 26 | name = Nom 27 | name-placeholder = par exemple « Nettoyer la cuisine » 28 | interval = Intervalle 29 | history = Histoire 30 | create = Créer 31 | chore-created = Tâche créée avec succès ! 32 | failed-to-create-chore = Échec de la création de la corvée… 33 | new-chore = Nouvelle corvée 34 | chores = Corvées 35 | back-to-chores = ← Retour aux tâches ménagères 36 | chordle-source-code = Code source de chordle ↗ 37 | settings = Paramètres 38 | language = Langue 39 | stats = Statistiques 40 | chore = Corvée 41 | times-completed = Temps terminés 42 | times-overdue = Temps en retard 43 | mean-days-overdue = Nombre moyen de jours de retard 44 | -------------------------------------------------------------------------------- /src/web/ui/template.rs: -------------------------------------------------------------------------------- 1 | use maud::{DOCTYPE, Markup, PreEscaped, html}; 2 | 3 | use crate::web::ui::STYLES_URI; 4 | 5 | use super::l10n::Lang; 6 | 7 | pub fn page(lang: Lang, title: &str, contents: Markup) -> Markup { 8 | html! { 9 | (DOCTYPE) 10 | html lang=(lang.to_string()) { 11 | head { 12 | meta charset="utf-8"; 13 | meta name="viewport" content="width=device-width, initial-scale=1"; 14 | (PreEscaped(r#""#)) 15 | link rel="icon" type="image/x-icon" sizes=""16x16 href="/icon.png?s=16&ico=true"; 16 | link rel="stylesheet" href=(STYLES_URI); 17 | 18 | @for s in &[180, 167, 152, 120, 114, 87, 80, 76, 58] { 19 | link rel="apple-touch-icon" sizes=(s) href=(format!("/icon.png?s={s}")); 20 | } 21 | meta name="apple-mobile-web-app-capable" content="yes"; 22 | meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"; 23 | link rel="manifest" href="/manifest.json"; 24 | 25 | title { (title) } 26 | } 27 | body { 28 | (contents) 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::{ColorChoice, Parser}; 2 | use std::{ 3 | net::{SocketAddr, ToSocketAddrs}, 4 | path::PathBuf, 5 | }; 6 | 7 | #[derive(Parser, Debug)] 8 | #[command(author = clap::crate_authors!(), version, about, long_about = None, help_template = "\ 9 | {before-help}{name} {version} 10 | by {author-with-newline}{about-with-newline} 11 | {usage-heading} {usage} 12 | 13 | {all-args}{after-help} 14 | ")] 15 | #[command(propagate_version = true)] 16 | pub struct Cli { 17 | #[arg(short, long, env, default_value_t = ColorChoice::Auto)] 18 | /// Control whether color is used in the output 19 | pub colour: ColorChoice, 20 | 21 | /// Enable debugging output 22 | /// 23 | /// Use multiple times to increase verbosity (e.g., -v, -vv, -vvv): 24 | #[arg(short, long, env, action = clap::ArgAction::Count)] 25 | pub verbose: u8, 26 | 27 | #[arg(short, long, env, default_value = "127.0.0.1:8080", value_parser = parse_socket_addr)] 28 | /// The address to bind to in the form of : 29 | /// 30 | /// To listen on all interfaces, use `0.0.0.0:` 31 | pub bind: SocketAddr, 32 | 33 | #[arg(short, long, env, default_value = "chordle.db")] 34 | /// The path to the SQLite database file 35 | /// 36 | /// This file will be created if it does not exist 37 | pub sqlite_db: PathBuf, 38 | } 39 | 40 | pub fn cli() -> Cli { 41 | Cli::parse() 42 | } 43 | 44 | fn parse_socket_addr(s: &str) -> Result { 45 | s.to_socket_addrs() 46 | .map_err(|e| e.to_string())? 47 | .next() 48 | .ok_or_else(|| format!("{}: no addresses found", s)) 49 | } 50 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 10 | cancel-in-progress: true 11 | 12 | env: 13 | CARGO_TERM_COLOR: always 14 | 15 | jobs: 16 | build_and_test: 17 | name: Build and Test 18 | runs-on: ubuntu-latest 19 | permissions: 20 | contents: read 21 | steps: 22 | - uses: actions/checkout@v4 23 | - run: rustup update stable && rustup default stable 24 | - name: Set up cargo cache 25 | uses: actions/cache@v3 26 | continue-on-error: false 27 | with: 28 | path: | 29 | ~/.cargo/bin/ 30 | ~/.cargo/registry/index/ 31 | ~/.cargo/registry/cache/ 32 | ~/.cargo/git/db/ 33 | target/ 34 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 35 | restore-keys: ${{ runner.os }}-cargo- 36 | - name: Lint 37 | run: | 38 | cargo fmt --all -- --check 39 | cargo clippy -- -D warnings 40 | - name: Install check tools 41 | run: | 42 | cargo install --locked cargo-deny || true 43 | cargo install --locked cargo-outdated || true 44 | - name: Check deny 45 | run: cargo deny check 46 | - name: Check outdated 47 | run: cargo outdated --exit-code 1 48 | - run: cargo test --verbose 49 | - run: cargo build --verbose --release 50 | - uses: actions/upload-artifact@v4 51 | with: 52 | name: chordle-x86_64-unknown-linux-gnu 53 | path: target/release/chordle 54 | 55 | -------------------------------------------------------------------------------- /src/logging.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::Cli; 2 | use color_eyre::Result; 3 | use jiff::Zoned; 4 | use std::io::IsTerminal; 5 | use tracing::level_filters::LevelFilter; 6 | use tracing_subscriber::{ 7 | Layer, Registry, filter, 8 | fmt::{format::Writer, time::FormatTime}, 9 | layer::SubscriberExt, 10 | util::SubscriberInitExt, 11 | }; 12 | 13 | pub fn setup_logging(cli: &Cli) -> Result<()> { 14 | let use_colours = match cli.colour { 15 | clap::ColorChoice::Never => false, 16 | clap::ColorChoice::Always => true, 17 | _ => std::io::stdout().is_terminal(), 18 | }; 19 | 20 | color_eyre::config::HookBuilder::new() 21 | .theme(if use_colours { 22 | color_eyre::config::Theme::dark() 23 | } else { 24 | color_eyre::config::Theme::new() 25 | }) 26 | .install() 27 | .expect("Failed to install `color_eyre`"); 28 | 29 | let log_level = match cli.verbose { 30 | 0 => LevelFilter::WARN, 31 | 1 => LevelFilter::INFO, 32 | 2 => LevelFilter::DEBUG, 33 | _ => LevelFilter::TRACE, 34 | }; 35 | 36 | let logs_filter = move |metadata: &tracing::Metadata<'_>| { 37 | log_level == LevelFilter::TRACE 38 | || (metadata.target().starts_with("chordle") && *metadata.level() <= log_level) 39 | }; 40 | 41 | let stdout_log = tracing_subscriber::fmt::layer() 42 | // .pretty() 43 | .with_ansi(use_colours) 44 | .with_timer(JiffLocal::default()) 45 | .with_target(false) 46 | .with_level(true) 47 | .with_writer(std::io::stdout) 48 | .with_filter(filter::filter_fn(logs_filter)); 49 | 50 | Registry::default().with(stdout_log).init(); 51 | Ok(()) 52 | } 53 | 54 | #[derive(Default)] 55 | struct JiffLocal {} 56 | 57 | impl FormatTime for JiffLocal { 58 | fn format_time(&self, w: &mut Writer<'_>) -> core::fmt::Result { 59 | let now = Zoned::now(); 60 | write!(w, "{now}") 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/stats/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::db::{ChoreId, Db}; 2 | use color_eyre::{Result, eyre::WrapErr}; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | pub mod completion_delta; 6 | pub mod utils; 7 | 8 | #[derive(Debug, Clone, Deserialize, Serialize)] 9 | pub struct ChoreStats { 10 | pub num_completed: usize, 11 | pub num_overdue: usize, 12 | pub num_completed_on_time_or_early: usize, 13 | pub mean_overdue_days: f64, 14 | pub median_overdue_days: f64, 15 | pub variance_overdue_days: f64, 16 | } 17 | 18 | pub async fn get_stats(db: &Db, chore_id: ChoreId) -> Result> { 19 | let chore = db 20 | .get_chore(chore_id) 21 | .await 22 | .wrap_err_with(|| format!("Failed to get chore {chore}", chore = chore_id.0))?; 23 | if chore.is_none() { 24 | return Ok(None); 25 | } 26 | let chore = chore.unwrap(); 27 | let events = db.get_chore_completions(chore_id).await.wrap_err_with(|| { 28 | format!( 29 | "Failed to get chore completions for chore {chore}", 30 | chore = chore_id.0 31 | ) 32 | })?; 33 | 34 | let deltas = completion_delta::calculate_completion_delta_days(&chore, events.iter()); 35 | 36 | fn filter_overdue(deltas: &[f64]) -> impl Iterator { 37 | deltas.iter().filter(|delta: &&f64| *delta >= &1.0) 38 | } 39 | 40 | let num_completed = events.len(); 41 | let num_overdue = filter_overdue(&deltas).count(); 42 | let num_completed_on_time_or_early = num_completed - num_overdue; 43 | 44 | let mean_overdue_days = utils::mean(filter_overdue(&deltas)); 45 | let median_overdue_days = utils::median(filter_overdue(&deltas)); 46 | let variance_overdue_days = utils::variance(mean_overdue_days, filter_overdue(&deltas)); 47 | 48 | Ok(Some(ChoreStats { 49 | num_completed, 50 | num_overdue, 51 | num_completed_on_time_or_early, 52 | mean_overdue_days, 53 | median_overdue_days, 54 | variance_overdue_days, 55 | })) 56 | } 57 | -------------------------------------------------------------------------------- /src/web/ui/manager/edit.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | db::Chore, 3 | web::{ 4 | AppState, 5 | ui::{error::ErrorResponse, l10n::Lang}, 6 | }, 7 | }; 8 | use axum::{Form, extract::State, http::HeaderMap}; 9 | use axum_extra::extract::CookieJar; 10 | use color_eyre::eyre::WrapErr; 11 | use jiff::Span; 12 | use maud::Markup; 13 | use serde::Deserialize; 14 | 15 | use super::render::RenderErrors; 16 | 17 | #[derive(Deserialize)] 18 | pub struct EditChoreForm { 19 | id: i64, 20 | name: String, 21 | interval: String, 22 | save: Option, 23 | delete: Option, 24 | } 25 | 26 | pub async fn edit_chore( 27 | headers: HeaderMap, 28 | jar: CookieJar, 29 | State(app_state): State, 30 | Form(form): Form, 31 | ) -> Result { 32 | let render_errors = if form.save.is_some() { 33 | handle_save(&app_state, &form) 34 | .await 35 | .wrap_err("Failed to handle chore save")? 36 | } else if form.delete.is_some() { 37 | handle_delete(&app_state, &form) 38 | .await 39 | .wrap_err("Failed to handle chore delete")?; 40 | None 41 | } else { 42 | None 43 | }; 44 | 45 | let accept_language = headers 46 | .get("accept-language") 47 | .and_then(|value| value.to_str().ok()); 48 | let lang = Lang::from_accept_language_header_and_cookie(accept_language, &jar); 49 | 50 | Ok(super::render::render(lang, &app_state, render_errors) 51 | .await 52 | .wrap_err("Failed to render edit chore page")?) 53 | } 54 | 55 | async fn handle_save( 56 | app_state: &AppState, 57 | form: &EditChoreForm, 58 | ) -> Result, ErrorResponse> { 59 | let name_is_valid = !form.name.is_empty() && form.name.len() <= 160; 60 | let interval: Option = form.interval.parse().ok(); 61 | let interval_is_valid = interval.is_some(); 62 | if !name_is_valid || !interval_is_valid { 63 | return Ok(Some(RenderErrors { 64 | edit_errors: Some((form.id.into(), !name_is_valid, !interval_is_valid)), 65 | ..Default::default() 66 | })); 67 | } 68 | let interval = interval.expect("interval is valid"); 69 | 70 | let chore = Chore { 71 | id: form.id.into(), 72 | name: form.name.clone(), 73 | interval, 74 | }; 75 | app_state 76 | .db 77 | .update_chore(chore) 78 | .await 79 | .wrap_err("Failed to update chore")?; 80 | 81 | Ok(None) 82 | } 83 | 84 | async fn handle_delete(app_state: &AppState, form: &EditChoreForm) -> Result<(), ErrorResponse> { 85 | app_state.db.delete_chore(form.id.into()).await?; 86 | Ok(()) 87 | } 88 | -------------------------------------------------------------------------------- /src/web/ui/manager/input-errors.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | // const abort_controller = new AbortController(); 3 | // const abort_signal = abort_controller.signal; 4 | // async function can_parse_span(span) { 5 | // abort_controller.abort(); 6 | // return fetch( 7 | // "/api/parse_span?span=" + encodeURIComponent(span), { 8 | // signal: abort_signal 9 | // }) 10 | // .then((res) => { 11 | // return res.ok; 12 | // }) 13 | // .catch((_) => { 14 | // return false; 15 | // }); 16 | // } 17 | 18 | async function check_validity(el) { 19 | let okay = true; 20 | if (el.classList.contains('name-field')) { 21 | if (el.validity.valueMissing) { 22 | // el.setCustomValidity("Chore names cannot be blank."); 23 | okay = false; 24 | } 25 | else if (el.value.trim().length === 0) { 26 | // el.setCustomValidity("Chore names cannot be just whitespace."); 27 | okay = false; 28 | } 29 | else if (el.value.trim().length > 160) { 30 | // el.setCustomValidity("Chore name is too long, max 160 characters."); 31 | okay = false; 32 | } 33 | } 34 | else if (el.classList.contains('interval-field')) { 35 | if (el.validity.valueMissing) { 36 | // el.setCustomValidity("Intervals cannot be blank."); 37 | okay = false; 38 | } 39 | else if (el.value.trim().length < 2) { 40 | // el.setCustomValidity("Intervals must have an amount and unit."); 41 | okay = false; 42 | } 43 | else if (el.value.trim().length > 160) { 44 | // el.setCustomValidity("Intervals cannot be longer than 160 characters."); 45 | okay = false; 46 | } 47 | // else if (!(await can_parse_span(el.value))) { 48 | // el.setCustomValidity("Invalid span, try something like '1w'"); 49 | // okay = false; 50 | // } 51 | } 52 | if (okay) { 53 | el.setCustomValidity(""); 54 | } 55 | el.reportValidity(); 56 | return okay; 57 | } 58 | 59 | let inputs = document.querySelectorAll('input[type=text]'); 60 | for (let i = 0; i < inputs.length; i++) { 61 | inputs[i].addEventListener('input', function () { 62 | this.classList.toggle('is-invalid', !check_validity(this)); 63 | }); 64 | inputs[i].addEventListener('change', function () { 65 | this.classList.toggle('is-invalid', !check_validity(this)); 66 | }); 67 | } 68 | })(); 69 | -------------------------------------------------------------------------------- /src/web/ui/stats/mod.rs: -------------------------------------------------------------------------------- 1 | use axum::{extract::State, http::HeaderMap}; 2 | use axum_extra::extract::CookieJar; 3 | use color_eyre::eyre::Context; 4 | use maud::{Markup, html}; 5 | 6 | use crate::{db::Chore, stats::ChoreStats, web::AppState}; 7 | 8 | use super::{error::ErrorResponse, l10n::Lang}; 9 | 10 | pub async fn stats_page( 11 | State(app_state): State, 12 | headers: HeaderMap, 13 | jar: CookieJar, 14 | ) -> Result { 15 | let accept_language = headers 16 | .get("accept-language") 17 | .and_then(|value| value.to_str().ok()); 18 | let lang = Lang::from_accept_language_header_and_cookie(accept_language, &jar); 19 | 20 | let chores = app_state 21 | .db 22 | .get_all_chores() 23 | .await 24 | .wrap_err("Failed to get all chores for stats page")?; 25 | let mut stats: Vec<(Chore, ChoreStats)> = Vec::with_capacity(chores.len()); 26 | for chore in chores.into_iter() { 27 | let chore_stats = crate::stats::get_stats(&app_state.db, chore.id) 28 | .await 29 | .wrap_err_with(|| format!("Failed to get stats for chore {id}", id = chore.id))?; 30 | if let Some(chore_stats) = chore_stats { 31 | stats.push((chore, chore_stats)); 32 | } else { 33 | tracing::warn!("Chore {id} has no stats", id = chore.id); 34 | } 35 | } 36 | stats.sort_by(|a, b| { 37 | b.1.num_completed 38 | .cmp(&a.1.num_completed) 39 | .then_with(|| b.1.num_overdue.cmp(&a.1.num_overdue)) 40 | .then_with(|| a.0.name.cmp(&b.0.name)) 41 | }); 42 | 43 | Ok(super::template::page( 44 | lang, 45 | "Stats", 46 | html! { 47 | main.stats { 48 | h1 { (app_state.l10n.translate(lang, "stats")) } 49 | table { 50 | thead { 51 | tr { 52 | th { (app_state.l10n.translate(lang, "chore")) } 53 | th { (app_state.l10n.translate(lang, "times-completed")) } 54 | th { (app_state.l10n.translate(lang, "times-overdue")) } 55 | th { (app_state.l10n.translate(lang, "mean-days-overdue")) } 56 | } 57 | } 58 | tbody { 59 | @for stat in stats.iter() { 60 | tr { 61 | td { (stat.0.name) } 62 | td { (stat.1.num_completed) } 63 | td { (stat.1.num_overdue) } 64 | td { (format!("{mean:.1} ± {var:.2}", mean=stat.1.mean_overdue_days, var=stat.1.variance_overdue_days.sqrt())) } 65 | } 66 | } 67 | } 68 | } 69 | } 70 | footer { 71 | { a href="/" { (app_state.l10n.translate(lang, "back-to-chores")) } } 72 | } 73 | }, 74 | )) 75 | } 76 | -------------------------------------------------------------------------------- /src/web/ui/mod.rs: -------------------------------------------------------------------------------- 1 | use std::any::Any; 2 | 3 | use axum::{ 4 | Router, 5 | body::Body, 6 | http::{Response, StatusCode}, 7 | response::IntoResponse, 8 | routing::{get, post}, 9 | }; 10 | use l10n::Lang; 11 | use maud::html; 12 | use tower_http::catch_panic::CatchPanicLayer; 13 | 14 | use super::AppState; 15 | 16 | pub mod cache; 17 | mod error; 18 | mod home; 19 | pub mod l10n; 20 | mod manager; 21 | mod static_files; 22 | mod stats; 23 | mod template; 24 | 25 | static HOME_URI: &str = "/"; 26 | static STATS_URI: &str = "/stats"; 27 | static EVENT_URI: &str = "/events/{chore_id}"; 28 | static UNDO_URI: &str = "/events/undo"; 29 | static REDO_URI: &str = "/events/redo"; 30 | static MANAGER_URI: &str = "/manager"; 31 | static MANAGER_EDIT_URI: &str = "/manager/edit"; 32 | static MANAGER_NEW_URI: &str = "/manager/new"; 33 | static MANAGER_LANGUAGE_URI: &str = "/manager/settings/language"; 34 | static STYLES_URI: &str = "/styles.css"; 35 | 36 | pub fn routes() -> Router { 37 | Router::new() 38 | .route(HOME_URI, get(home::home)) 39 | .route(STATS_URI, get(stats::stats_page)) 40 | .route(UNDO_URI, post(home::undo_event)) 41 | .route(REDO_URI, post(home::redo_event)) 42 | .route(EVENT_URI, post(home::record_event)) 43 | .route(MANAGER_URI, get(manager::manager_home)) 44 | .route(MANAGER_EDIT_URI, post(manager::edit_chore)) 45 | .route(MANAGER_NEW_URI, post(manager::new_chore)) 46 | .route(MANAGER_LANGUAGE_URI, post(manager::change_language)) 47 | .route(STYLES_URI, get(static_files::styles)) 48 | .route("/icons/{icon}", get(static_files::svg_icon)) 49 | .route("/manifest.json", get(static_files::manifest)) 50 | .route("/icon.png", get(static_files::app_icon)) 51 | .route("/favicon.ico", get(static_files::favicon)) 52 | .layer(CatchPanicLayer::custom(handle_panic)) 53 | .fallback(handler_404) 54 | } 55 | 56 | fn handle_panic(_err: Box) -> Response { 57 | // err can be ignored because color_eyre will log it 58 | let page = template::page( 59 | Lang::En, 60 | "Internal Server Error", 61 | html! { 62 | main { 63 | h1 { "Internal Server Error" } 64 | p { "Sorry bud." } 65 | } 66 | }, 67 | ); 68 | let page = page.into_string(); 69 | 70 | Response::builder() 71 | .status(StatusCode::INTERNAL_SERVER_ERROR) 72 | .header("Content-Type", "text/html; charset=utf-8") 73 | .header("Content-Length", page.len()) // String.len() is the number of bytes, not chars 74 | .body(Body::from(page)) 75 | .unwrap() 76 | } 77 | 78 | async fn handler_404() -> impl IntoResponse { 79 | ( 80 | StatusCode::NOT_FOUND, 81 | template::page( 82 | Lang::En, 83 | "404 Not Found", 84 | html! { 85 | main { 86 | h1 { "404 Not Found" } 87 | p { "The page you are looking for does not exist." } 88 | } 89 | }, 90 | ), 91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /src/web/ui/manager/new.rs: -------------------------------------------------------------------------------- 1 | use axum::{Form, extract::State, http::HeaderMap}; 2 | use axum_extra::extract::CookieJar; 3 | use color_eyre::eyre::Context; 4 | use jiff::{Span, civil::Date, tz::TimeZone}; 5 | use maud::Markup; 6 | use serde::Deserialize; 7 | 8 | use crate::web::{ 9 | AppState, 10 | ui::{error::ErrorResponse, l10n::Lang}, 11 | }; 12 | 13 | #[derive(Deserialize)] 14 | pub struct NewChoreForm { 15 | name: String, 16 | interval: String, 17 | history: Option, 18 | } 19 | 20 | pub async fn new_chore( 21 | headers: HeaderMap, 22 | jar: CookieJar, 23 | State(app_state): State, 24 | Form(form): Form, 25 | ) -> Result { 26 | let name_is_valid = !form.name.is_empty() && form.name.len() <= 160; 27 | let interval: Option = form.interval.parse().ok(); 28 | let interval_is_valid = interval.is_some(); 29 | 30 | let accept_language = headers 31 | .get("accept-language") 32 | .and_then(|value| value.to_str().ok()); 33 | let lang = Lang::from_accept_language_header_and_cookie(accept_language, &jar); 34 | 35 | if !name_is_valid || !interval_is_valid { 36 | return Ok(super::render::render( 37 | lang, 38 | &app_state, 39 | Some(super::render::RenderErrors { 40 | create_has_name_error: !name_is_valid, 41 | create_has_interval_error: !interval_is_valid, 42 | ..Default::default() 43 | }), 44 | ) 45 | .await?); 46 | } 47 | let interval = interval.expect("interval is valid"); 48 | let chore_id = match app_state.db.create_chore(&form.name, interval).await { 49 | Ok(id) => id, 50 | Err(e) => { 51 | tracing::warn!("Failed to create chore: {e:#?}"); 52 | return Ok(super::render::render( 53 | lang, 54 | &app_state, 55 | Some(super::render::RenderErrors { 56 | create_created_ok: Some(false), 57 | ..Default::default() 58 | }), 59 | ) 60 | .await?); 61 | } 62 | }; 63 | 64 | if let Some(history) = form.history { 65 | if let Ok(history) = history.parse::() { 66 | let history = history.to_zoned(TimeZone::system()).wrap_err_with(|| { 67 | format!( 68 | "Failed to create history timestamp for date: {history}", 69 | history = history 70 | ) 71 | })?; 72 | 73 | if let Err(e) = app_state 74 | .db 75 | .record_chore_event_when(chore_id, history) 76 | .await 77 | { 78 | tracing::warn!("Failed to record chore event when creating a new chore: {e:#?}"); 79 | } 80 | } else { 81 | tracing::warn!("Failed to parse history date, not recording event history: {history}"); 82 | } 83 | } 84 | 85 | Ok(super::render::render( 86 | lang, 87 | &app_state, 88 | Some(super::render::RenderErrors { 89 | create_created_ok: Some(true), 90 | ..Default::default() 91 | }), 92 | ) 93 | .await?) 94 | } 95 | -------------------------------------------------------------------------------- /src/stats/completion_delta.rs: -------------------------------------------------------------------------------- 1 | use crate::db::{Chore, Event}; 2 | use jiff::Unit; 3 | 4 | pub fn calculate_completion_delta_days(chore: &Chore, mut events: I) -> Vec 5 | where 6 | I: Iterator, 7 | I::Item: AsRef, 8 | { 9 | let mut delta_days = Vec::new(); 10 | 11 | let first_event = events.next(); 12 | if first_event.is_none() { 13 | return delta_days; 14 | } 15 | let mut previous_event_timestamp = first_event.unwrap().as_ref().timestamp.clone(); 16 | 17 | for event in events { 18 | let event = event.as_ref(); 19 | 20 | let expected_event_timestamp = previous_event_timestamp.saturating_add(chore.interval); 21 | let actual_event_timestamp = &event.timestamp; 22 | let delta = actual_event_timestamp.since(&expected_event_timestamp); 23 | match delta { 24 | Ok(delta) => { 25 | let delta = delta.total((Unit::Day, actual_event_timestamp)); 26 | match delta { 27 | Ok(delta) => delta_days.push(delta), 28 | Err(e) => { 29 | tracing::warn!( 30 | "Failed to calculate delta for chore {chore:?} and event {event:?}: {e:?}" 31 | ) 32 | } 33 | } 34 | } 35 | Err(e) => tracing::warn!( 36 | "Failed to calculate delta for chore {chore:?} and event {event:?}: {e:?}" 37 | ), 38 | } 39 | previous_event_timestamp = event.timestamp.clone(); 40 | } 41 | 42 | delta_days 43 | } 44 | 45 | #[cfg(test)] 46 | mod tests { 47 | use jiff::{Span, Timestamp, Zoned, tz::TimeZone}; 48 | 49 | use super::*; 50 | 51 | fn create_test_data() -> (Chore, Vec) { 52 | let chore = Chore { 53 | id: 1.into(), 54 | name: "Test Chore".to_string(), 55 | interval: Span::new().weeks(1), 56 | }; 57 | 58 | let start_date = Zoned::new( 59 | Timestamp::new(1735714800, 0) 60 | .expect("can construct timestamp for Jan 1, 2024 00:00:00"), 61 | TimeZone::UTC, 62 | ); 63 | 64 | let day_deltas = vec![0, 2, -3, 1, 0]; 65 | let mut last_event_timestamp = start_date.clone(); 66 | 67 | let mut events = vec![Event { 68 | chore_id: chore.id, 69 | timestamp: start_date, 70 | }]; 71 | for day_delta in day_deltas.into_iter() { 72 | let delta_span = Span::new().days(day_delta); 73 | let interval_span = chore 74 | .interval 75 | .checked_add((delta_span, &last_event_timestamp)) 76 | .expect("can add delta days to interval"); 77 | 78 | let timestamp = last_event_timestamp.saturating_add(interval_span); 79 | last_event_timestamp = timestamp.clone(); 80 | events.push(Event { 81 | chore_id: chore.id, 82 | timestamp, 83 | }); 84 | } 85 | 86 | (chore, events) 87 | } 88 | 89 | #[test] 90 | fn can_calculate_completion_delta_days() { 91 | let (chore, events) = create_test_data(); 92 | let delta_days: Vec = calculate_completion_delta_days(&chore, events.iter()) 93 | .into_iter() 94 | .map(|d| d as i64) 95 | .collect(); 96 | 97 | assert_eq!(delta_days, vec![0, 2, -3, 1, 0]); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/web/ui/l10n.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, fmt::Display, sync::RwLock}; 2 | 3 | use axum_extra::extract::CookieJar; 4 | use color_eyre::eyre::ContextCompat; 5 | use fluent::{FluentArgs, FluentResource, bundle::FluentBundle}; 6 | use unic_langid::langid; 7 | 8 | #[derive(Copy, Clone, Debug, Hash, Eq, PartialEq, Default)] 9 | pub enum Lang { 10 | #[default] 11 | En, 12 | Fr, 13 | } 14 | 15 | type TranslationType = FluentBundle; 16 | 17 | pub struct L10N { 18 | bundles: RwLock>, 19 | } 20 | 21 | impl std::fmt::Debug for L10N { 22 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 23 | f.debug_struct("L10N").finish() 24 | } 25 | } 26 | 27 | impl L10N { 28 | fn load_bundle(lang: Lang) -> TranslationType { 29 | let langid = match lang { 30 | Lang::En => langid!("en"), 31 | Lang::Fr => langid!("fr"), 32 | }; 33 | let mut bundle = FluentBundle::new_concurrent(vec![langid]); 34 | 35 | let resource = match lang { 36 | Lang::En => include_str!("./l10n/en.ftl"), 37 | Lang::Fr => include_str!("./l10n/fr.ftl"), 38 | }; 39 | let resource = 40 | FluentResource::try_new(resource.to_string()).expect("Failed to parse FTL resource"); 41 | bundle 42 | .add_resource(resource) 43 | .expect("Failed to add FTL resource"); 44 | 45 | bundle 46 | } 47 | 48 | pub fn new() -> L10N { 49 | let bundles: HashMap = [Lang::En, Lang::Fr] 50 | .map(|lang| (lang, Self::load_bundle(lang))) 51 | .into_iter() 52 | .collect(); 53 | 54 | let bundles = RwLock::new(bundles); 55 | L10N { bundles } 56 | } 57 | 58 | fn _translate>(&self, lang: Lang, key: S, args: Option<&FluentArgs>) -> String { 59 | let bundles = self.bundles.read().expect("Can read bundles"); 60 | 61 | let bundle = bundles 62 | .get(&lang) 63 | .wrap_err_with(|| "Language {lang:?} not found in bundles") 64 | .expect("Can get language bundle"); 65 | let key = key.as_ref(); 66 | let msg = bundle 67 | .get_message(key) 68 | .wrap_err_with(|| format!("Message `{key}` not found in bundle for language {lang:?}")) 69 | .expect("Can get message by key"); 70 | let pattern = msg 71 | .value() 72 | .wrap_err_with(|| format!("Message `{key}` in language {lang:?} has no pattern")) 73 | .expect("Can get pattern of message"); 74 | 75 | let mut errors = vec![]; 76 | let result = bundle.format_pattern(pattern, args, &mut errors); 77 | 78 | if !errors.is_empty() { 79 | tracing::error!( 80 | "L10N errors were encountered while translating `{key}` in {lang:?}: {:?}", 81 | errors 82 | ); 83 | } 84 | 85 | result.into_owned() 86 | } 87 | 88 | pub fn translate_with>(&self, lang: Lang, key: S, args: FluentArgs) -> String { 89 | self._translate(lang, key, Some(&args)) 90 | } 91 | 92 | pub fn translate>(&self, lang: Lang, key: S) -> String { 93 | self._translate(lang, key, None) 94 | } 95 | } 96 | 97 | impl Display for Lang { 98 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 99 | match self { 100 | Lang::En => write!(f, "en"), 101 | Lang::Fr => write!(f, "fr"), 102 | } 103 | } 104 | } 105 | 106 | impl Lang { 107 | pub fn from_accept_language_header_and_cookie(header: Option<&str>, jar: &CookieJar) -> Lang { 108 | if jar.get("lang").is_some() { 109 | match jar.get("lang").unwrap().value() { 110 | "en" => return Lang::En, 111 | "fr" => return Lang::Fr, 112 | _ => {} 113 | }; 114 | } 115 | 116 | if let Some(header) = header { 117 | for lang in header.split(',') { 118 | if let Some(lang) = lang.split(';').next() { 119 | if let Some(lang) = lang.split('-').next() { 120 | match lang.trim() { 121 | "*" | "en" => return Lang::En, 122 | "fr" => return Lang::Fr, 123 | _ => {} 124 | } 125 | } 126 | } 127 | } 128 | } 129 | 130 | Lang::En 131 | } 132 | 133 | pub fn from_str(s: &str) -> Lang { 134 | match s.to_lowercase().as_str() { 135 | "fr" => Lang::Fr, 136 | _ => Lang::En, 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/db/types.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt::Display, 3 | ops::{Deref, DerefMut}, 4 | }; 5 | 6 | use color_eyre::{Result, eyre::Context}; 7 | use jiff::{Span, Zoned}; 8 | use serde::{Deserialize, Serialize}; 9 | 10 | #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Copy)] 11 | /// The ID of a chore 12 | pub struct ChoreId(pub i64); 13 | 14 | impl Deref for ChoreId { 15 | type Target = i64; 16 | 17 | fn deref(&self) -> &Self::Target { 18 | &self.0 19 | } 20 | } 21 | 22 | impl DerefMut for ChoreId { 23 | fn deref_mut(&mut self) -> &mut Self::Target { 24 | &mut self.0 25 | } 26 | } 27 | 28 | impl Display for ChoreId { 29 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 30 | write!(f, "{}", self.0) 31 | } 32 | } 33 | 34 | pub type DbChoreId = i64; 35 | impl From for ChoreId { 36 | fn from(id: DbChoreId) -> Self { 37 | Self(id) 38 | } 39 | } 40 | impl From for DbChoreId { 41 | fn from(id: ChoreId) -> Self { 42 | id.0 43 | } 44 | } 45 | 46 | #[derive(Clone, Debug, Serialize, Deserialize)] 47 | pub struct Chore { 48 | /// The ID of the chore 49 | pub id: ChoreId, 50 | /// The name of the chore 51 | pub name: String, 52 | /// The interval in which this chore should be done 53 | pub interval: Span, 54 | } 55 | 56 | impl AsRef for Chore { 57 | fn as_ref(&self) -> &Chore { 58 | self 59 | } 60 | } 61 | 62 | pub struct DbChore { 63 | pub id: DbChoreId, 64 | pub name: String, 65 | pub interval: String, 66 | } 67 | impl From for DbChore { 68 | fn from(chore: Chore) -> Self { 69 | Self { 70 | id: chore.id.into(), 71 | name: chore.name, 72 | interval: chore.interval.to_string(), 73 | } 74 | } 75 | } 76 | impl TryFrom for Chore { 77 | type Error = color_eyre::eyre::Error; 78 | 79 | fn try_from(chore: DbChore) -> Result { 80 | Ok(Self { 81 | id: chore.id.into(), 82 | name: chore.name, 83 | interval: chore.interval.parse().wrap_err_with(|| { 84 | format!( 85 | "Failed to parse interval '{interval}' for chore {id}", 86 | id = chore.id, 87 | interval = chore.interval 88 | ) 89 | })?, 90 | }) 91 | } 92 | } 93 | 94 | #[derive(Clone, Debug, Serialize, Deserialize)] 95 | pub struct Event { 96 | pub chore_id: ChoreId, 97 | pub timestamp: Zoned, 98 | } 99 | 100 | impl AsRef for Event { 101 | fn as_ref(&self) -> &Event { 102 | self 103 | } 104 | } 105 | 106 | pub struct DbEvent { 107 | pub chore_id: DbChoreId, 108 | pub timestamp: String, 109 | } 110 | 111 | impl From for DbEvent { 112 | fn from(event: Event) -> Self { 113 | Self { 114 | chore_id: event.chore_id.into(), 115 | timestamp: event.timestamp.to_string(), 116 | } 117 | } 118 | } 119 | 120 | impl TryFrom for Event { 121 | type Error = color_eyre::eyre::Error; 122 | 123 | fn try_from(event: DbEvent) -> Result { 124 | Ok(Self { 125 | chore_id: event.chore_id.into(), 126 | timestamp: event.timestamp.parse().wrap_err_with(|| { 127 | format!( 128 | "Failed to parse timestamp '{timestamp}' for event with chore ID {id}", 129 | id = event.chore_id, 130 | timestamp = event.timestamp 131 | ) 132 | })?, 133 | }) 134 | } 135 | } 136 | 137 | #[derive(Debug, Clone, Deserialize, Serialize)] 138 | pub struct ChoreEvent { 139 | pub id: ChoreId, 140 | pub name: String, 141 | pub interval: Span, 142 | pub timestamp: Option, 143 | } 144 | 145 | #[derive(Clone, Debug)] 146 | pub struct DbChoreEvent { 147 | pub id: DbChoreId, 148 | pub name: String, 149 | pub interval: String, 150 | pub timestamp: Option, 151 | } 152 | 153 | impl TryFrom for ChoreEvent { 154 | type Error = color_eyre::eyre::Error; 155 | 156 | fn try_from(chore_event: DbChoreEvent) -> Result { 157 | Ok(Self { 158 | id: chore_event.id.into(), 159 | name: chore_event.name, 160 | interval: chore_event.interval.parse().wrap_err_with(|| { 161 | format!( 162 | "Failed to parse interval '{interval}' for chore {id}", 163 | id = chore_event.id, 164 | interval = chore_event.interval 165 | ) 166 | })?, 167 | timestamp: chore_event 168 | .timestamp 169 | .map(|timestamp| { 170 | timestamp.parse().wrap_err_with(|| { 171 | format!( 172 | "Failed to parse timestamp '{timestamp}' for chore {id}", 173 | id = chore_event.id, 174 | timestamp = timestamp 175 | ) 176 | }) 177 | }) 178 | .transpose()?, 179 | }) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # chordle 2 | 3 | A simple chores manager web application to keep track of your tasks, when you 4 | last did them, and what chores you should be focussing on today. 5 | 6 | ## Building 7 | 8 | To build the project, you will need to have the following installed: 9 | 10 | - [Rust](https://www.rust-lang.org/tools/install) 11 | 12 | Once you have Rust installed, you can build the project by running: 13 | 14 | ```sh 15 | cargo build --release 16 | ``` 17 | 18 | ## Running 19 | 20 | chordle is configured through several command line arguments. You can see the 21 | full list of options by running: 22 | 23 | ```sh 24 | $ chordle --help 25 | ``` 26 | 27 | Giving: 28 | 29 | ```plaintext 30 | chordle 1.0.0-alpha.1 31 | by Kenton Hamaluik 32 | A simple button-based chore tracker 33 | 34 | Usage: chordle [OPTIONS] 35 | 36 | Options: 37 | -c, --colour 38 | Control whether color is used in the output 39 | 40 | [env: COLOUR=] 41 | [default: auto] 42 | [possible values: auto, always, never] 43 | 44 | -v, --verbose... 45 | Enable debugging output 46 | 47 | Use multiple times to increase verbosity (e.g., -v, -vv, -vvv): 48 | 49 | [env: VERBOSE=] 50 | 51 | -b, --bind 52 | The address to bind to in the form of : 53 | 54 | To listen on all interfaces, use `0.0.0.0:` 55 | 56 | [env: BIND=] 57 | [default: 127.0.0.1:8080] 58 | 59 | -s, --sqlite-db 60 | The path to the SQLite database file 61 | 62 | This file will be created if it does not exist 63 | 64 | [env: SQLITE_DB=] 65 | [default: chordle.db] 66 | 67 | -h, --help 68 | Print help (see a summary with '-h') 69 | 70 | -V, --version 71 | Print version 72 | ``` 73 | 74 | ### Systemd Service 75 | 76 | If you want to run chordle as a service on a Linux system, you can use the 77 | following systemd service file: 78 | 79 | ```systemd 80 | [Unit] 81 | Description=Lich Service 82 | After=network.target 83 | 84 | [Service] 85 | Environment="BIND=0.0.0.0:8080" 86 | Environment="SQLITE_DB=/path/to/chordle.db" 87 | ExecStart=/path/to/chordle 88 | Restart=always 89 | RestartSec=5 90 | StandardOutput=journal 91 | StandardError=journal 92 | 93 | [Install] 94 | WantedBy=multi-user.target 95 | ``` 96 | 97 | Replace `/path/to/chordle` with the path to the chordle binary, and 98 | `/path/to/chordle.db` with the path to the SQLite database file you want to use. 99 | 100 | Save this file as `/etc/systemd/system/chordle.service`, then run: 101 | 102 | ```sh 103 | sudo systemctl daemon-reload 104 | sudo systemctl enable --now chordle 105 | ``` 106 | 107 | This will start the chordle service and enable it to start on boot. You can 108 | check the status of the service by running: 109 | 110 | ```sh 111 | sudo systemctl status chordle 112 | ``` 113 | 114 | You can also view the logs of the service by running: 115 | 116 | ```sh 117 | sudo journalctl -u chordle 118 | ``` 119 | 120 | ### Nginx Reverse Proxy Configuration 121 | 122 | If you want to run chordle behind an Nginx reverse proxy, you can use the 123 | following configuration: 124 | 125 | ```nginx 126 | server { 127 | listen 80; 128 | server_name chordle.mydomain.com; 129 | 130 | # permanent redirect to https 131 | return 301 https://$host$request_uri; 132 | } 133 | 134 | server { 135 | listen 443; 136 | server_name chord.mydomain.com; 137 | 138 | error_log /var/log/nginx/chordle.mydomain.com-error.log; 139 | access_log /var/log/nginx/chordle.mydomain.com-access.log; 140 | 141 | client_max_body_size 1024M; 142 | 143 | ssl_certificate /etc/nginx/ssl/chordle.mydomain.com.crt; 144 | ssl_certificate_key /etc/nginx/ssl/chordle.mydomain.com.key; 145 | 146 | ssl_protocols TLSv1.2 TLSv1.3; 147 | ssl_prefer_server_ciphers on; 148 | ssl_ciphers HIGH:!aNULL:!MD5; 149 | 150 | location / { 151 | proxy_pass http://localhost:8080; 152 | proxy_ssl_session_reuse off; 153 | proxy_set_header Host $host; 154 | proxy_cache_bypass $http_upgrade; 155 | proxy_redirect off; 156 | } 157 | } 158 | ``` 159 | 160 | ### Docker 161 | 162 | A docker image for chordle is available on ghcr.io. You can run it with the 163 | following command: 164 | 165 | ```sh 166 | docker run -d --name chordle -p 8080:8080 -v /path/to/data:/data ghcr.io/hamaluik/chordle:latest --bind 0.0.0.0:8080 --sqlite-db /data/chordle.db 167 | ``` 168 | 169 | Or the following docker-compose service: 170 | 171 | ```yaml 172 | --- 173 | services: 174 | chordle: 175 | container_name: chordle 176 | image: ghcr.io/hamaluik/chordle:latest 177 | restart: unless-stopped 178 | environment: 179 | - TZ=America/Edmonton 180 | - BIND=0.0.0.0:7777 181 | - SQLITE_DB=/data/chordle.db 182 | ports: 183 | - '7777:7777' 184 | volumes: 185 | - ./data:/data 186 | healthcheck: 187 | test: ["CMD", "curl", "-f", "http://localhost:7777/api/health"] 188 | interval: 60s 189 | timeout: 5s 190 | retries: 3 191 | start_period: 5s 192 | ``` 193 | 194 | 195 | ## License 196 | 197 | This project is licensed under the Apache-2.0 license, see the [LICENSE](LICENSE) 198 | file for more information. 199 | 200 | -------------------------------------------------------------------------------- /src/web/ui/static_files/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::web::AppState; 2 | use axum::{ 3 | body::Body, 4 | extract::{Path, Query, State}, 5 | http::HeaderMap, 6 | response::{IntoResponse, Response}, 7 | }; 8 | use serde::Deserialize; 9 | 10 | fn handle_matching_etag(id: S, headers: &HeaderMap, app_state: &AppState) -> Option 11 | where 12 | S: AsRef, 13 | { 14 | let id = id.as_ref(); 15 | let cache = app_state.cache.read().expect("Can get read lock on cache"); 16 | if headers 17 | .get("If-None-Match") 18 | .is_some_and(|val| val.to_str().is_ok_and(|val| cache.etag_matches(id, val))) 19 | { 20 | return Some( 21 | Response::builder() 22 | .status(304) 23 | .header("Last-Modified", env!("BUILD_TIME_LAST_MODIFIED")) 24 | .header( 25 | "ETag", 26 | format!( 27 | "\"{etag}\"", 28 | etag = cache.get_etag(id).expect("Can get etag for icon") 29 | ), 30 | ) 31 | .body(Body::empty()) 32 | .expect("Can build not modified response"), 33 | ); 34 | } 35 | None 36 | } 37 | 38 | pub async fn styles(headers: HeaderMap, State(app_state): State) -> impl IntoResponse { 39 | if let Some(response) = handle_matching_etag("styles.css", &headers, &app_state) { 40 | return response; 41 | } 42 | 43 | let css = include_str!("styles.css"); 44 | let etag = format!("{:x}", md5::compute(css)); 45 | { 46 | let mut cache = app_state 47 | .cache 48 | .write() 49 | .expect("Can get write lock on cache"); 50 | cache.set_etag("styles.css", etag.clone()); 51 | } 52 | 53 | Response::builder() 54 | .header("Content-Type", "text/css; charset=utf-8") 55 | .header("Content-Length", css.len()) 56 | .header("Last-Modified", env!("BUILD_TIME_LAST_MODIFIED")) 57 | .header("ETag", format!("\"{etag}\"")) 58 | .header("Cache-Control", "public, max-age=604800") 59 | .body(Body::from(css)) 60 | .expect("Can build styles response") 61 | } 62 | 63 | pub async fn svg_icon( 64 | Path(icon): Path, 65 | headers: HeaderMap, 66 | State(app_state): State, 67 | ) -> impl IntoResponse { 68 | if let Some(response) = handle_matching_etag(&icon, &headers, &app_state) { 69 | return response; 70 | } 71 | 72 | let icon_contents = match icon.as_str() { 73 | "undo.svg" => include_str!("undo.svg"), 74 | "redo.svg" => include_str!("redo.svg"), 75 | "new.svg" => include_str!("new.svg"), 76 | "save.svg" => include_str!("save.svg"), 77 | "trash.svg" => include_str!("trash.svg"), 78 | _ => return Response::builder().status(404).body(Body::empty()).unwrap(), 79 | }; 80 | 81 | let etag = format!("{:x}", md5::compute(&icon)); 82 | { 83 | let mut cache = app_state 84 | .cache 85 | .write() 86 | .expect("Can get write lock on cache"); 87 | cache.set_etag(icon, etag.clone()); 88 | } 89 | 90 | Response::builder() 91 | .header("Content-Type", "image/svg+xml; charset=utf-8") 92 | .header("Content-Length", icon_contents.len()) 93 | .header("Last-Modified", env!("BUILD_TIME_LAST_MODIFIED")) 94 | .header("ETag", format!("\"{etag}\"")) 95 | .header("Cache-Control", "public, max-age=604800") 96 | .body(Body::from(icon_contents)) 97 | .expect("Can build icon response") 98 | } 99 | 100 | #[derive(Deserialize, Debug)] 101 | pub struct IconQuery { 102 | pub s: Option, 103 | pub ico: Option, 104 | } 105 | 106 | pub async fn favicon(headers: HeaderMap, State(app_state): State) -> impl IntoResponse { 107 | let query = IconQuery { 108 | s: Some(16), 109 | ico: Some(true), 110 | }; 111 | app_icon(Query(query), headers, State(app_state)).await 112 | } 113 | 114 | pub async fn app_icon( 115 | Query(query): Query, 116 | headers: HeaderMap, 117 | State(app_state): State, 118 | ) -> impl IntoResponse { 119 | let etag_id = format!("favicon/{query:?}"); 120 | if let Some(response) = handle_matching_etag(&etag_id, &headers, &app_state) { 121 | return response; 122 | } 123 | 124 | let icon = include_bytes!("./icon512.png"); 125 | let (len, etag, body) = match query.s { 126 | Some(512) | None => { 127 | let len = icon.len(); 128 | ( 129 | len, 130 | format!("{:x}", md5::compute(icon)), 131 | Body::from(&icon[..]), 132 | ) 133 | } 134 | Some(s) => { 135 | let img = image::load_from_memory(icon).expect("Can load icon image"); 136 | let img = img.resize(s, s, image::imageops::FilterType::Lanczos3); 137 | let mut buf = std::io::Cursor::new(Vec::new()); 138 | 139 | let format = if query.ico.unwrap_or(false) { 140 | image::ImageFormat::Ico 141 | } else { 142 | image::ImageFormat::Png 143 | }; 144 | 145 | img.write_to(&mut buf, format) 146 | .expect("Can write resized icon to buffer"); 147 | let buf = buf.into_inner(); 148 | let len = buf.len(); 149 | (len, format!("{:x}", md5::compute(icon)), Body::from(buf)) 150 | } 151 | }; 152 | 153 | { 154 | let mut cache = app_state 155 | .cache 156 | .write() 157 | .expect("Can get write lock on cache"); 158 | cache.set_etag(etag_id, etag.clone()); 159 | } 160 | 161 | Response::builder() 162 | .header( 163 | "Content-Type", 164 | if query.ico.unwrap_or(false) { 165 | "image/x-icon" 166 | } else { 167 | "image/png" 168 | }, 169 | ) 170 | .header("Content-Length", len) 171 | .header("Last-Modified", env!("BUILD_TIME_LAST_MODIFIED")) 172 | .header("ETag", format!("\"{etag}\"")) 173 | .header("Cache-Control", "public, max-age=604800") 174 | .body(body) 175 | .expect("Can build icon response") 176 | } 177 | 178 | pub async fn manifest() -> impl IntoResponse { 179 | let manifest = include_str!("manifest.json"); 180 | Response::builder() 181 | .header("Content-Type", "application/json; charset=utf-8") 182 | .header("Content-Length", manifest.len()) 183 | .header("Last-Modified", env!("BUILD_TIME_LAST_MODIFIED")) 184 | .header("Cache-Control", "public, max-age=604800") 185 | .body(Body::from(manifest)) 186 | .expect("Can build manifest response") 187 | } 188 | -------------------------------------------------------------------------------- /src/web/ui/manager/render.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | db::{Chore, ChoreId}, 3 | web::{ 4 | AppState, 5 | ui::{ 6 | MANAGER_EDIT_URI, MANAGER_LANGUAGE_URI, MANAGER_NEW_URI, 7 | l10n::{L10N, Lang}, 8 | template, 9 | }, 10 | }, 11 | }; 12 | use color_eyre::{Result, eyre::Context}; 13 | use fluent::fluent_args; 14 | use maud::{Markup, PreEscaped, html}; 15 | 16 | fn render_chore( 17 | chore: &Chore, 18 | has_name_error: bool, 19 | has_interval_error: bool, 20 | lang: Lang, 21 | l10n: &L10N, 22 | ) -> Markup { 23 | html! { 24 | div.form-item { 25 | input type="text" form=(format!("chore-form-{id}", id=chore.id.0)) .name-field .is-invalid[has_name_error] name="name" value=(chore.name) placeholder=(l10n.translate(lang, "name-placeholder")) required minlength="1" maxlength="160"; 26 | span.form-item-error { (l10n.translate(lang, "invalid-chore-name")) } 27 | } 28 | div.form-item { 29 | input type="text" form=(format!("chore-form-{id}", id=chore.id.0)) .interval-field .is-invalid[has_interval_error] name="interval" value=(format!("{interval:#}", interval = chore.interval)) placeholder="2w 4d" required minlength="2" maxlength="160"; 30 | span.form-item-error { 31 | (PreEscaped(l10n.translate_with(lang, "invalid-interval", fluent_args![ 32 | "link" => r#"jiff::fmt::friendly ↗"#, 33 | ]))) 34 | } 35 | } 36 | div.form-item.form-item-button { 37 | button type="submit" 38 | form=(format!("chore-form-{id}", id=chore.id.0)) 39 | name="save" 40 | value="Save" 41 | alt=(l10n.translate(lang, "save")) 42 | title=(l10n.translate(lang, "save")) { 43 | img src="/icons/save.svg" alt=(l10n.translate(lang, "save")); 44 | } 45 | } 46 | div.form-item.form-item-button { 47 | button type="submit" 48 | form=(format!("chore-form-{id}", id=chore.id.0)) 49 | name="delete" 50 | value="Delete" 51 | alt=(l10n.translate(lang, "delete")) 52 | title=(l10n.translate(lang, "delete")) { 53 | img src="/icons/trash.svg" alt=(l10n.translate(lang, "delete")); 54 | } 55 | } 56 | hr; 57 | } 58 | } 59 | 60 | fn render_chore_forms(chores: I) -> Markup 61 | where 62 | I: Iterator, 63 | I::Item: AsRef, 64 | { 65 | html!( 66 | @for chore in chores { 67 | form id=(format!("chore-form-{id}", id=chore.as_ref().id.0)) method="post" action=(MANAGER_EDIT_URI) { 68 | input type="hidden" name="id" value=(chore.as_ref().id.0); 69 | } 70 | } 71 | ) 72 | } 73 | 74 | fn render_chores( 75 | chores: I, 76 | edit_errors: Option<(ChoreId, bool, bool)>, 77 | lang: Lang, 78 | l10n: &L10N, 79 | ) -> Markup 80 | where 81 | I: Iterator, 82 | I::Item: AsRef, 83 | { 84 | html!( 85 | div.chore-list { 86 | @for chore in chores { 87 | ({ 88 | let (name_error, interval_error) = match edit_errors { 89 | Some((id, name_error, interval_error)) if id == chore.as_ref().id => (name_error, interval_error), 90 | _ => (false, false), 91 | }; 92 | render_chore(chore.as_ref(), name_error, interval_error, lang, l10n) 93 | }) 94 | } 95 | } 96 | ) 97 | } 98 | 99 | fn render_new_chore( 100 | has_name_error: bool, 101 | has_interval_error: bool, 102 | created_ok: Option, 103 | lang: Lang, 104 | l10n: &L10N, 105 | ) -> Markup { 106 | html! { 107 | form method="post" action=(MANAGER_NEW_URI) { 108 | div.chore-list { 109 | div.form-item { 110 | label for="name" { (l10n.translate(lang, "name")) } 111 | input type="text" .name-field .is-invalid[has_name_error] name="name" placeholder=(l10n.translate(lang, "name-placeholder")) required minlength="1" maxlength="160"; 112 | span.form-item-error { (l10n.translate(lang, "invalid-chore-name")) } 113 | } 114 | div.form-item { 115 | label for="interval" { (l10n.translate(lang, "interval")) } 116 | input type="text" .interval-field .is-invalid[has_interval_error] name="interval" placeholder="2w 4d" required minlength="2" maxlength="160"; 117 | span.form-item-error { 118 | (PreEscaped(l10n.translate_with(lang, "invalid-interval", fluent_args![ 119 | "link" => r#"jiff::fmt::friendly ↗"#, 120 | ]))) 121 | } 122 | } 123 | div.form-item { 124 | label for="history" { (l10n.translate(lang, "history")) } 125 | input type="date" name="history" id="history"; 126 | } 127 | div.form-item { 128 | label for="submit" { (l10n.translate(lang, "create")) } 129 | button type="submit" alt=(l10n.translate(lang, "create")) title=(l10n.translate(lang, "create")) { 130 | img src="/icons/new.svg" alt=(l10n.translate(lang, "create")); 131 | } 132 | } 133 | @if let Some(created_ok) = created_ok { 134 | @if created_ok { 135 | p { (l10n.translate(lang, "chore-created"))} 136 | } 137 | @else { 138 | p { (l10n.translate(lang, "failed-to-create-chore"))} 139 | } 140 | } 141 | } 142 | } 143 | } 144 | } 145 | 146 | fn render_language_select_form(lang: Lang, l10n: &L10N) -> Markup { 147 | html! { 148 | form method="post" action=(MANAGER_LANGUAGE_URI) { 149 | div.language-select { 150 | div.form-item { 151 | label for="lang" { (l10n.translate(lang, "language")) } 152 | select name="lang" { 153 | option value="en" selected[lang == Lang::En] { "English" } 154 | option value="fr" selected[lang == Lang::Fr] { "Français" } 155 | } 156 | } 157 | div.form-item { 158 | label for="submit" { (l10n.translate(lang, "save")) } 159 | button type="submit" alt=(l10n.translate(lang, "save")) title=(l10n.translate(lang, "save")) { 160 | img src="/icons/save.svg" alt=(l10n.translate(lang, "save")); 161 | } 162 | } 163 | } 164 | } 165 | } 166 | } 167 | 168 | #[derive(Default)] 169 | pub struct RenderErrors { 170 | pub edit_errors: Option<(ChoreId, bool, bool)>, 171 | pub create_has_name_error: bool, 172 | pub create_has_interval_error: bool, 173 | pub create_created_ok: Option, 174 | } 175 | 176 | pub async fn render( 177 | lang: Lang, 178 | app_state: &AppState, 179 | errors: Option, 180 | ) -> Result { 181 | let chores = app_state 182 | .db 183 | .get_all_chores() 184 | .await 185 | .wrap_err("Failed to get chores")?; 186 | let errors = errors.unwrap_or_default(); 187 | 188 | Ok(template::page( 189 | lang, 190 | &app_state.l10n.translate(lang, "manage-chores"), 191 | html! { 192 | main.manager { 193 | h1 style="view-transition-name: manage-header" { 194 | (app_state.l10n.translate(lang, "manage-chores")) 195 | } 196 | fieldset { 197 | legend { (app_state.l10n.translate(lang, "new-chore")) } 198 | (render_new_chore( 199 | errors.create_has_name_error, 200 | errors.create_has_interval_error, 201 | errors.create_created_ok, 202 | lang, &app_state.l10n)) 203 | } 204 | fieldset { 205 | legend { (app_state.l10n.translate(lang, "chores")) } 206 | (render_chore_forms(chores.iter())) 207 | (render_chores(chores.iter(), errors.edit_errors, lang, &app_state.l10n)) 208 | } 209 | fieldset { 210 | legend { (app_state.l10n.translate(lang, "settings")) } 211 | (render_language_select_form(lang, &app_state.l10n)) 212 | } 213 | } 214 | footer { 215 | { a href="/" { (app_state.l10n.translate(lang, "back-to-chores")) } } 216 | { a href="https://github.com/hamaluik/chordle" alt=(app_state.l10n.translate(lang, "chordle-source-code")) target="_blank" { (app_state.l10n.translate(lang, "chordle-source-code")) } } 217 | } 218 | (PreEscaped(r#""#)) 221 | }, 222 | )) 223 | } 224 | -------------------------------------------------------------------------------- /src/web/ui/home.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | db::ChoreEvent, 3 | web::{ 4 | AppState, 5 | ui::{MANAGER_URI, REDO_URI, STATS_URI, UNDO_URI}, 6 | }, 7 | }; 8 | use axum::{ 9 | body::Body, 10 | extract::{Path, State}, 11 | http::HeaderMap, 12 | response::{IntoResponse, Redirect, Response}, 13 | }; 14 | use axum_extra::extract::CookieJar; 15 | use color_eyre::eyre::Context; 16 | use fluent::fluent_args; 17 | use jiff::{Span, SpanTotal, Unit, Zoned}; 18 | use maud::{Markup, PreEscaped, html}; 19 | 20 | use super::{ 21 | HOME_URI, 22 | error::ErrorResponse, 23 | l10n::{L10N, Lang}, 24 | }; 25 | 26 | pub async fn home( 27 | State(app_state): State, 28 | headers: HeaderMap, 29 | jar: CookieJar, 30 | ) -> Result { 31 | let chore_events = app_state 32 | .db 33 | .get_all_chore_events() 34 | .await 35 | .wrap_err("Failed to get all chores")?; 36 | let chore_events = sort_chores(chore_events); 37 | 38 | let can_undo = app_state 39 | .db 40 | .can_undo_chore_event() 41 | .await 42 | .wrap_err("Can check if undo is possible")?; 43 | let can_redo = app_state 44 | .db 45 | .can_redo_chore_event() 46 | .await 47 | .wrap_err("Can check if redo is possible")?; 48 | 49 | let accept_language = headers 50 | .get("accept-language") 51 | .and_then(|value| value.to_str().ok()); 52 | let lang = Lang::from_accept_language_header_and_cookie(accept_language, &jar); 53 | 54 | let page = super::template::page( 55 | lang, 56 | "Chordle", 57 | html! { 58 | main.home { 59 | div.chores { 60 | @for chore_event in chore_events { 61 | (render_chore(&chore_event, lang, &app_state.l10n)) 62 | } 63 | } 64 | } 65 | footer { 66 | div.undo-redo { 67 | @if can_undo { 68 | form action=(UNDO_URI) method="POST" { 69 | button type="submit" class="undo" { 70 | img src="/icons/undo.svg" alt=(app_state.l10n.translate(lang, "undo")); 71 | } 72 | } 73 | } 74 | @if can_redo { 75 | form action=(REDO_URI) method="POST" { 76 | button type="submit" class="redo" { 77 | img src="/icons/redo.svg" alt=(app_state.l10n.translate(lang, "redo")); 78 | } 79 | } 80 | } 81 | } 82 | div { 83 | a href=(STATS_URI) { 84 | (PreEscaped(r#""#)) 85 | (app_state.l10n.translate(lang, "stats")) 86 | } 87 | a href=(MANAGER_URI) { 88 | (PreEscaped(r#""#)) 89 | (app_state.l10n.translate(lang, "manage-chores")) 90 | } 91 | } 92 | } 93 | (PreEscaped(r#""#)); 97 | }, 98 | ); 99 | let body = page.into_string(); 100 | 101 | Ok(Response::builder() 102 | .header("Content-Type", "text/html; charset=utf-8") 103 | .header("Content-Length", body.len()) // String.len() returns bytes not chars 104 | .header("Cache-Control", "private, max-age=0, no-cache") 105 | .body(Body::from(body)) 106 | .expect("Can build home response")) 107 | } 108 | 109 | pub async fn record_event( 110 | State(app_state): State, 111 | Path(chore_id): Path, 112 | ) -> Result { 113 | app_state 114 | .db 115 | .record_chore_event(chore_id.into()) 116 | .await 117 | .wrap_err_with(|| format!("Failed to record event for chore with ID: {}", chore_id))?; 118 | Ok(Redirect::to(HOME_URI)) 119 | } 120 | 121 | pub async fn undo_event(State(app_state): State) -> Result { 122 | app_state 123 | .db 124 | .undo_chore_event() 125 | .await 126 | .wrap_err("Failed to undo event")?; 127 | Ok(Redirect::to(HOME_URI)) 128 | } 129 | 130 | pub async fn redo_event(State(app_state): State) -> Result { 131 | app_state 132 | .db 133 | .redo_chore_event() 134 | .await 135 | .wrap_err("Failed to redo event")?; 136 | Ok(Redirect::to(HOME_URI)) 137 | } 138 | 139 | #[tracing::instrument] 140 | fn time_until_next_chore(now: &Zoned, chore_event: &ChoreEvent) -> Span { 141 | if chore_event.timestamp.is_none() { 142 | return Span::new().microseconds(0); 143 | } 144 | let last_chore = chore_event.timestamp.as_ref().unwrap(); 145 | let interval = chore_event.interval; 146 | let next_chore = last_chore.saturating_add(interval); 147 | next_chore.since(now).expect("can calculate time since") 148 | } 149 | 150 | #[tracing::instrument] 151 | fn sort_chores(mut chores: Vec) -> Vec { 152 | let now = Zoned::now(); 153 | 154 | chores.sort_by(|a, b| { 155 | let dt_a = time_until_next_chore(&now, a) 156 | .total(Unit::Second) 157 | .expect("can calculate total seconds"); 158 | let dt_b = time_until_next_chore(&now, b) 159 | .total(Unit::Second) 160 | .expect("can calculate total seconds"); 161 | dt_a.total_cmp(&dt_b) 162 | }); 163 | chores 164 | } 165 | 166 | fn classify( 167 | now: &Zoned, 168 | next_due: &Zoned, 169 | interval: &Span, 170 | last_completed: &Option, 171 | ) -> &'static str { 172 | let is_daily = interval 173 | .total((Unit::Day, Zoned::now().date())) 174 | .expect("can calculate total days") 175 | < 1.0; 176 | 177 | if let Some(last_completed) = last_completed { 178 | if last_completed.date() == now.date() { 179 | return "chore-done"; 180 | } 181 | } 182 | 183 | let due_days = next_due 184 | .since(now) 185 | .ok() 186 | .map_or(0.0, |d| { 187 | d.total(SpanTotal::from(Unit::Day).days_are_24_hours()) 188 | .expect("can calculate total days") 189 | }) 190 | .ceil() as i64; 191 | 192 | if next_due < now || (due_days <= 1 && !is_daily) { 193 | "chore-due" 194 | } else if due_days <= 3 && !is_daily { 195 | "chore-due-soon" 196 | } else { 197 | "chore-due-later" 198 | } 199 | } 200 | 201 | #[tracing::instrument] 202 | fn render_chore(chore_event: &ChoreEvent, lang: Lang, l10n: &L10N) -> Markup { 203 | let now = Zoned::now(); 204 | let days_since_last = chore_event 205 | .timestamp 206 | .as_ref() 207 | .map(|t| { 208 | let days = now 209 | .since(t) 210 | .expect("can calculate time since") 211 | .total(SpanTotal::from(Unit::Day).days_are_24_hours()) 212 | .expect("can calculate total days"); 213 | days.floor() as i64 214 | }) 215 | .unwrap_or_else(|| -1); 216 | 217 | let next = time_until_next_chore(&now, chore_event); 218 | let class = classify( 219 | &now, 220 | &now.saturating_add(next), 221 | &chore_event.interval, 222 | &chore_event.timestamp, 223 | ); 224 | 225 | let next_days = next 226 | .total(SpanTotal::from(Unit::Day).days_are_24_hours()) 227 | .expect("can calculate total days") 228 | .ceil() as i64; 229 | 230 | let next = match next_days.cmp(&0) { 231 | std::cmp::Ordering::Equal => html! { (l10n.translate(lang, "due-today")) }, 232 | std::cmp::Ordering::Less => { 233 | html! { (l10n.translate_with(lang, "due-ago", fluent_args![ 234 | "days" => next_days.abs(), 235 | ])) } 236 | } 237 | std::cmp::Ordering::Greater => { 238 | html! { (l10n.translate_with(lang, "due-in", fluent_args![ 239 | "days" => next_days, 240 | ])) } 241 | } 242 | }; 243 | 244 | let days_since_last_prefix = l10n.translate_with( 245 | lang, 246 | "days-ago-prefix", 247 | fluent_args![ 248 | "days" => days_since_last, 249 | ], 250 | ); 251 | 252 | html! { 253 | div.chore style=(format!("view-transition-name: chore-event-{id}", id=chore_event.id)) { 254 | form action=(format!("/events/{id}", id=chore_event.id)) id=(format!("chore-form-{id}", id=chore_event.id)) class="chore-form" method="POST" { 255 | p.name { 256 | (chore_event.name) 257 | } 258 | @if days_since_last_prefix != "" { 259 | p.info { 260 | (l10n.translate_with(lang, "days-ago-prefix", fluent_args![ 261 | "days" => days_since_last, 262 | ])) 263 | } 264 | } 265 | button type="submit" class=(class) { 266 | (l10n.translate_with(lang, "days-ago-number", fluent_args![ 267 | "days" => days_since_last, 268 | ])) 269 | } 270 | p.info { 271 | (l10n.translate_with(lang, "days-ago-suffix", fluent_args![ 272 | "days" => days_since_last, 273 | ])) 274 | br; 275 | (next) 276 | } 277 | div.spinner.hidden role="status" { 278 | (PreEscaped(include_str!("./static_files/spinner.svg"))); 279 | } 280 | } 281 | } 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /src/db/mod.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::{Result, eyre::Context}; 2 | use jiff::{Span, Zoned}; 3 | use sqlx::{SqlitePool, sqlite::SqliteConnectOptions}; 4 | use std::path::Path; 5 | use types::DbChore; 6 | 7 | mod types; 8 | pub use types::{Chore, ChoreEvent, ChoreId, Event}; 9 | 10 | #[derive(Clone, Debug)] 11 | pub struct Db { 12 | pool: SqlitePool, 13 | } 14 | 15 | impl Db { 16 | pub async fn new(db_path: &Path) -> Result { 17 | let connection_options = SqliteConnectOptions::new() 18 | .filename(db_path) 19 | .create_if_missing(true) 20 | .foreign_keys(true) 21 | .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal); 22 | 23 | tracing::info!(db_path = ?db_path, "Connecting to database"); 24 | let pool = SqlitePool::connect_with(connection_options) 25 | .await 26 | .wrap_err_with(|| { 27 | format!( 28 | "Failed to connect to SQLite database {path}", 29 | path = db_path.display() 30 | ) 31 | })?; 32 | 33 | tracing::info!("Running migrations..."); 34 | sqlx::migrate!() 35 | .run(&pool) 36 | .await 37 | .wrap_err("Failed to run migrations")?; 38 | 39 | Ok(Db { pool }) 40 | } 41 | 42 | pub async fn get_chore(&self, id: ChoreId) -> Result> { 43 | let dbid: i64 = id.into(); 44 | 45 | let db_chore = sqlx::query_as!( 46 | types::DbChore, 47 | r#" 48 | select id, name, interval 49 | from chores 50 | where id = ? 51 | "#, 52 | dbid, 53 | ) 54 | .fetch_optional(&self.pool) 55 | .await 56 | .wrap_err("Failed to get chore")?; 57 | 58 | if let Some(db_chore) = db_chore { 59 | Ok(Some(db_chore.try_into()?)) 60 | } else { 61 | Ok(None) 62 | } 63 | } 64 | 65 | pub async fn create_chore(&self, name: &str, interval: Span) -> Result { 66 | let interval = interval.to_string(); 67 | 68 | let id: i64 = sqlx::query_scalar!( 69 | r#" 70 | insert into chores (name, interval) 71 | values (?, ?) 72 | returning id 73 | "#, 74 | name, 75 | interval, 76 | ) 77 | .fetch_one(&self.pool) 78 | .await 79 | .wrap_err("Failed to create chore")?; 80 | 81 | Ok(id.into()) 82 | } 83 | 84 | pub async fn update_chore(&self, chore: Chore) -> Result<()> { 85 | let db_chore: DbChore = chore.into(); 86 | 87 | sqlx::query!( 88 | r#" 89 | update chores 90 | set name = ?, interval = ? 91 | where id = ? 92 | "#, 93 | db_chore.name, 94 | db_chore.interval, 95 | db_chore.id, 96 | ) 97 | .execute(&self.pool) 98 | .await 99 | .wrap_err("Failed to update chore")?; 100 | 101 | Ok(()) 102 | } 103 | 104 | pub async fn delete_chore(&self, id: ChoreId) -> Result<()> { 105 | let dbid: i64 = id.into(); 106 | 107 | sqlx::query!( 108 | r#" 109 | delete from chores 110 | where id = ? 111 | "#, 112 | dbid, 113 | ) 114 | .execute(&self.pool) 115 | .await 116 | .wrap_err("Failed to delete chore")?; 117 | 118 | Ok(()) 119 | } 120 | 121 | pub async fn get_all_chores(&self) -> Result> { 122 | let chores = sqlx::query_as!( 123 | types::DbChore, 124 | r#" 125 | select id, name, interval 126 | from chores 127 | order by name asc 128 | "# 129 | ) 130 | .fetch_all(&self.pool) 131 | .await 132 | .wrap_err("Failed to get all chores")?; 133 | 134 | chores.into_iter().map(|chore| chore.try_into()).collect() 135 | } 136 | 137 | pub async fn get_all_chore_events(&self) -> Result> { 138 | // assert times in the query, see 139 | // https://docs.rs/sqlx/0.8.3/sqlx/macro.query_as.html#troubleshooting-error-mismatched-types 140 | // for more information 141 | let chores = sqlx::query_as!( 142 | types::DbChoreEvent, 143 | r#" 144 | select 145 | chores.id as "id!", 146 | chores.name as "name!", 147 | chores.interval as "interval!", 148 | events.timestamp 149 | from 150 | chores 151 | left join 152 | (select 153 | chore_id, 154 | max(timestamp) as timestamp 155 | from 156 | events 157 | group by 158 | chore_id) as events 159 | on chores.id = events.chore_id 160 | "# 161 | ) 162 | .fetch_all(&self.pool) 163 | .await 164 | .wrap_err("Failed to get all chores")?; 165 | 166 | chores.into_iter().map(|chore| chore.try_into()).collect() 167 | } 168 | 169 | pub async fn record_chore_event(&self, chore_id: ChoreId) -> Result<()> { 170 | let dbid: i64 = chore_id.into(); 171 | let timestamp = Zoned::now().to_string(); 172 | 173 | sqlx::query!( 174 | r#" 175 | insert into events (chore_id, timestamp) 176 | values (?, ?) 177 | "#, 178 | dbid, 179 | timestamp, 180 | ) 181 | .execute(&self.pool) 182 | .await 183 | .wrap_err("Failed to record chore event")?; 184 | 185 | sqlx::query!(r#"delete from redo_events"#,) 186 | .execute(&self.pool) 187 | .await 188 | .wrap_err("Failed to clear redo events")?; 189 | 190 | Ok(()) 191 | } 192 | 193 | pub async fn record_chore_event_when(&self, chore_id: ChoreId, timestamp: Zoned) -> Result<()> { 194 | let dbid: i64 = chore_id.into(); 195 | let timestamp = timestamp.to_string(); 196 | 197 | sqlx::query!( 198 | r#" 199 | insert into events (chore_id, timestamp) 200 | values (?, ?) 201 | "#, 202 | dbid, 203 | timestamp, 204 | ) 205 | .execute(&self.pool) 206 | .await 207 | .wrap_err("Failed to record chore event")?; 208 | 209 | sqlx::query!(r#"delete from redo_events"#,) 210 | .execute(&self.pool) 211 | .await 212 | .wrap_err("Failed to clear redo events")?; 213 | 214 | Ok(()) 215 | } 216 | 217 | pub async fn can_undo_chore_event(&self) -> Result { 218 | let can_undo = sqlx::query!( 219 | r#" 220 | select count(*) as count 221 | from events 222 | "# 223 | ) 224 | .fetch_one(&self.pool) 225 | .await 226 | .wrap_err("Failed to check if can undo chore event")?; 227 | 228 | Ok(can_undo.count > 0) 229 | } 230 | 231 | pub async fn undo_chore_event(&self) -> Result { 232 | let most_recent_chore_event = sqlx::query_as!( 233 | types::DbEvent, 234 | r#" 235 | select chore_id, timestamp 236 | from events 237 | order by timestamp desc 238 | limit 1 239 | "# 240 | ) 241 | .fetch_optional(&self.pool) 242 | .await 243 | .wrap_err("Failed to get most recent chore event")?; 244 | 245 | if most_recent_chore_event.is_none() { 246 | return Ok(false); 247 | } 248 | let most_recent_chore_event = most_recent_chore_event.unwrap(); 249 | 250 | let mut transaction = self 251 | .pool 252 | .begin() 253 | .await 254 | .wrap_err("Failed to start transaction")?; 255 | 256 | sqlx::query!( 257 | r#" 258 | delete from events 259 | where chore_id = ? and timestamp = ? 260 | "#, 261 | most_recent_chore_event.chore_id, 262 | most_recent_chore_event.timestamp, 263 | ) 264 | .execute(&mut *transaction) 265 | .await 266 | .wrap_err("Failed to delete most recent chore event")?; 267 | 268 | sqlx::query!( 269 | r#" 270 | insert into redo_events (chore_id, timestamp) 271 | values (?, ?) 272 | "#, 273 | most_recent_chore_event.chore_id, 274 | most_recent_chore_event.timestamp, 275 | ) 276 | .execute(&mut *transaction) 277 | .await 278 | .wrap_err("Failed to record redo event")?; 279 | 280 | transaction 281 | .commit() 282 | .await 283 | .wrap_err("Failed to commit undo transaction")?; 284 | 285 | Ok(true) 286 | } 287 | 288 | pub async fn can_redo_chore_event(&self) -> Result { 289 | let can_redo = sqlx::query!( 290 | r#" 291 | select count(*) as count 292 | from redo_events 293 | "# 294 | ) 295 | .fetch_one(&self.pool) 296 | .await 297 | .wrap_err("Failed to check if can redo chore event")?; 298 | 299 | Ok(can_redo.count > 0) 300 | } 301 | 302 | pub async fn redo_chore_event(&self) -> Result { 303 | let most_recent_redo_chore_event = sqlx::query_as!( 304 | types::DbEvent, 305 | r#" 306 | select chore_id, timestamp 307 | from redo_events 308 | order by timestamp desc 309 | limit 1 310 | "# 311 | ) 312 | .fetch_optional(&self.pool) 313 | .await 314 | .wrap_err("Failed to get most recent redo chore event")?; 315 | 316 | if most_recent_redo_chore_event.is_none() { 317 | return Ok(false); 318 | } 319 | let most_recent_redo_chore_event = most_recent_redo_chore_event.unwrap(); 320 | 321 | let mut transaction = self 322 | .pool 323 | .begin() 324 | .await 325 | .wrap_err("Failed to start transaction")?; 326 | 327 | sqlx::query!( 328 | r#" 329 | delete from redo_events 330 | where chore_id = ? and timestamp = ? 331 | "#, 332 | most_recent_redo_chore_event.chore_id, 333 | most_recent_redo_chore_event.timestamp, 334 | ) 335 | .execute(&mut *transaction) 336 | .await 337 | .wrap_err("Failed to delete most recent redo chore event")?; 338 | 339 | sqlx::query!( 340 | r#" 341 | insert into events (chore_id, timestamp) 342 | values (?, ?) 343 | "#, 344 | most_recent_redo_chore_event.chore_id, 345 | most_recent_redo_chore_event.timestamp, 346 | ) 347 | .execute(&mut *transaction) 348 | .await 349 | .wrap_err("Failed to record redo event")?; 350 | 351 | transaction 352 | .commit() 353 | .await 354 | .wrap_err("Failed to commit redo transaction")?; 355 | 356 | Ok(true) 357 | } 358 | 359 | pub async fn get_chore_completions(&self, chore_id: ChoreId) -> Result> { 360 | let dbid: i64 = chore_id.into(); 361 | 362 | let events = sqlx::query_as!( 363 | types::DbEvent, 364 | r#" 365 | select chore_id, timestamp 366 | from events 367 | where chore_id = ? 368 | order by timestamp asc 369 | "#, 370 | dbid, 371 | ) 372 | .fetch_all(&self.pool) 373 | .await 374 | .wrap_err_with(|| format!("Failed to get chore events for chore {dbid}"))?; 375 | events.into_iter().map(|event| event.try_into()).collect() 376 | } 377 | } 378 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | 204 | -------------------------------------------------------------------------------- /src/web/ui/static_files/styles.css: -------------------------------------------------------------------------------- 1 | /* TODO: switch everything to em and ch units where appropriate */ 2 | 3 | * { 4 | box-sizing: border-box; 5 | min-height: 0; 6 | min-width: 0; 7 | } 8 | 9 | :root { 10 | /* Light mode variables */ 11 | --color-primary: #3a86ff; 12 | --color-overdue: #ef476f; 13 | --color-due-soon: #ffd166; 14 | --color-due-later: #118ab2; 15 | --color-done: #06d6a0; 16 | --color-background: #f8f9fa; 17 | --color-surface: #ffffff; 18 | --color-text: #212529; 19 | --color-text-light: #6c757d; 20 | --color-error: #dc3545; 21 | --color-button-primary: #3a86ff; 22 | --color-button-danger: #ef476f; 23 | --color-button-secondary: #e9ecef; 24 | --border-radius: 12px; 25 | --shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 26 | --transition: all 0.3s ease; 27 | --border-color: rgba(0, 0, 0, 0.05); 28 | --input-border: #ced4da; 29 | --fieldset-border: #dee2e6; 30 | } 31 | 32 | @media (prefers-color-scheme: dark) { 33 | :root { 34 | /* Dark mode variables */ 35 | --color-primary: #4d9aff; 36 | --color-overdue: #ff5d8f; 37 | --color-due-soon: #ffdc86; 38 | --color-due-later: #25b0d8; 39 | --color-done: #08f5b8; 40 | --color-background: #121212; 41 | --color-surface: #1e1e1e; 42 | --color-text: #e9ecef; 43 | --color-text-light: #adb5bd; 44 | --color-error: #f8556d; 45 | --color-button-primary: #4d9aff; 46 | --color-button-danger: #ff5d8f; 47 | --color-button-secondary: #343a40; 48 | --shadow: 0 4px 6px rgba(0, 0, 0, 0.3); 49 | --border-color: rgba(255, 255, 255, 0.05); 50 | --input-border: #495057; 51 | --fieldset-border: #343a40; 52 | } 53 | } 54 | 55 | @view-transition { 56 | navigation: auto; 57 | } 58 | 59 | body { 60 | display: flex; 61 | flex-direction: column; 62 | align-items: center; 63 | justify-content: flex-start; 64 | min-height: 100vh; 65 | font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 66 | margin: 0; 67 | padding: 16px; 68 | width: 100%; 69 | background-color: var(--color-background); 70 | color: var(--color-text); 71 | line-height: 1.5; 72 | transition: background-color 0.3s ease, color 0.3s ease; 73 | } 74 | 75 | main { 76 | flex: 1; 77 | display: flex; 78 | flex-direction: column; 79 | align-items: center; 80 | justify-content: center; 81 | width: min(100%, 960px); 82 | padding: 24px 0; 83 | } 84 | 85 | /* Home page styles */ 86 | main.home .chores { 87 | display: grid; 88 | grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); 89 | gap: 20px; 90 | width: 100%; 91 | padding: 16px; 92 | } 93 | 94 | main.home .chores .chore { 95 | border-radius: var(--border-radius); 96 | background-color: var(--color-surface); 97 | box-shadow: var(--shadow); 98 | transition: var(--transition); 99 | overflow: hidden; 100 | } 101 | 102 | main.home .chores .chore:hover { 103 | transform: translateY(-4px); 104 | box-shadow: 0 10px 15px rgba(0, 0, 0, 0.2); 105 | } 106 | 107 | main.home .chores .chore form { 108 | display: flex; 109 | flex-direction: column; 110 | align-items: center; 111 | justify-content: space-between; 112 | height: 100%; 113 | padding: 16px 12px; 114 | } 115 | 116 | main.home .spinner { 117 | width: 100%; 118 | height: 100%; 119 | display: flex; 120 | justify-content: center; 121 | align-items: center; 122 | } 123 | 124 | main.home .spinner .spinner-svg { 125 | width: 64px; 126 | color: var(--color-primary); 127 | } 128 | 129 | .hidden { 130 | display: none !important; 131 | } 132 | 133 | main.home .chore .name { 134 | font-size: 12pt; 135 | font-weight: 600; 136 | max-width: 100%; 137 | padding: 0; 138 | margin: 0; 139 | text-align: center; 140 | color: var(--color-text); 141 | } 142 | 143 | main.home .chores button[type="submit"] { 144 | font-size: 24px; 145 | width: 64px; 146 | height: 64px; 147 | border-radius: 50%; 148 | border: none; 149 | font-weight: bold; 150 | transition: var(--transition); 151 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 152 | } 153 | 154 | main.home .chores button[type="submit"]:hover { 155 | transform: scale(1.05); 156 | } 157 | 158 | main.home .chore .info { 159 | font-size: 8pt; 160 | color: var(--color-text-light); 161 | max-width: 100%; 162 | padding: 0; 163 | margin: 0; 164 | text-align: center; 165 | } 166 | 167 | /* Manager page styles */ 168 | main.manager { 169 | width: min(100%, 800px); 170 | padding: 24px 16px; 171 | } 172 | 173 | main.manager h1 { 174 | font-size: 28px; 175 | font-weight: 700; 176 | margin: 0 0 24px 0; 177 | color: var(--color-text); 178 | text-align: center; 179 | } 180 | 181 | main.manager fieldset { 182 | border: 1px solid var(--fieldset-border); 183 | border-radius: var(--border-radius); 184 | padding: 20px; 185 | margin-bottom: 32px; 186 | width: 100%; 187 | background-color: var(--color-surface); 188 | box-shadow: var(--shadow); 189 | } 190 | 191 | main.manager legend { 192 | font-size: 18px; 193 | font-weight: 600; 194 | padding: 0 10px; 195 | color: var(--color-text); 196 | } 197 | 198 | main.manager .chore-list { 199 | display: grid; 200 | grid-template-columns: 4fr 1fr auto auto; 201 | gap: 1ch; 202 | } 203 | 204 | main.manager .form-item { 205 | margin: 0; 206 | padding: 0; 207 | display: flex; 208 | flex-direction: column; 209 | align-items: stretch; 210 | justify-content: flex-start; 211 | } 212 | 213 | main.manager .language-select { 214 | display: grid; 215 | grid-template-columns: 1fr auto; 216 | gap: 1ch; 217 | } 218 | 219 | main.manager .language-select select { 220 | flex: 1; 221 | } 222 | 223 | main.manager select { 224 | padding: 0.5ch 1ch; 225 | border: 1px solid var(--input-border); 226 | border-radius: 6px; 227 | font-size: 10pt; 228 | background-color: var(--color-surface); 229 | color: var(--color-text); 230 | transition: var(--transition); 231 | cursor: pointer; 232 | } 233 | 234 | main.manager input[type="date"] { 235 | -webkit-min-logical-width: calc(100% - 16px); 236 | } 237 | 238 | main.manager input[type="text"], 239 | main.manager input[type="date"] { 240 | width: 100%; 241 | flex: 1; 242 | padding: 0.5ch 1ch; 243 | border: 1px solid var(--input-border); 244 | border-radius: 6px; 245 | font-size: 10pt; 246 | background-color: var(--color-surface); 247 | color: var(--color-text); 248 | transition: var(--transition); 249 | } 250 | 251 | main.manager input[type="text"]:focus, 252 | main.manager input[type="date"]:focus { 253 | outline: none; 254 | border-color: var(--color-primary); 255 | box-shadow: 0 0 0 3px rgba(58, 134, 255, 0.2); 256 | } 257 | 258 | main.manager input[type="text"]:invalid, 259 | main.manager input[type="date"]:invalid { 260 | outline: 1px solid var(--color-error); 261 | } 262 | 263 | main.manager button[type="submit"], 264 | main.manager input[type="submit"] { 265 | padding: 8px 16px; 266 | border: none; 267 | border-radius: 6px; 268 | font-size: 14px; 269 | font-weight: 500; 270 | cursor: pointer; 271 | transition: var(--transition); 272 | background-color: var(--color-button-primary); 273 | } 274 | 275 | main.manager button img { 276 | height: 24px; 277 | } 278 | 279 | main.manager .chore-list hr { 280 | display: none; 281 | } 282 | 283 | main.manager button[type="submit"][value="Save"] { 284 | background-color: var(--color-button-primary); 285 | } 286 | 287 | main.manager button[type="submit"][value="Delete"] { 288 | background-color: var(--color-button-danger); 289 | } 290 | 291 | main.manager input[type="submit"][value="Create"] { 292 | background-color: var(--color-button-primary); 293 | } 294 | 295 | main.manager button[type="submit"]:hover, 296 | main.manager input[type="submit"]:hover { 297 | transform: translateY(-2px); 298 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 299 | } 300 | 301 | .is-invalid+.form-item-error { 302 | display: block !important; 303 | } 304 | 305 | .form-item-error { 306 | display: none; 307 | color: var(--color-error); 308 | font-size: 12px; 309 | margin-top: 6px; 310 | } 311 | 312 | main.manager .form-item-error a { 313 | color: var(--color-primary); 314 | text-decoration: none; 315 | } 316 | 317 | main.manager .form-item-error a:hover { 318 | text-decoration: underline; 319 | } 320 | 321 | footer { 322 | display: flex; 323 | flex-direction: row; 324 | justify-content: space-between; 325 | padding: 16px 0; 326 | font-size: 14px; 327 | view-transition-name: footer; 328 | align-items: center; 329 | width: min(100%, 960px); 330 | margin: 8px 0 0 0; 331 | border-top: 1px solid var(--border-color); 332 | } 333 | 334 | footer .undo-redo { 335 | display: flex; 336 | flex-direction: row; 337 | gap: 12px; 338 | } 339 | 340 | footer .undo-redo button { 341 | aspect-ratio: 1; 342 | background-color: var(--color-surface); 343 | border: 1px solid var(--border-color); 344 | border-radius: 8px; 345 | padding: 8px; 346 | transition: var(--transition); 347 | } 348 | 349 | footer .undo-redo button:hover { 350 | background-color: var(--border-color); 351 | } 352 | 353 | footer .undo-redo img { 354 | filter: brightness(0.85); 355 | } 356 | 357 | @media (prefers-color-scheme: dark) { 358 | footer .undo-redo img { 359 | filter: invert(1) brightness(0.85); 360 | } 361 | } 362 | 363 | footer a { 364 | color: var(--color-primary); 365 | text-decoration: none; 366 | font-weight: 500; 367 | padding: 8px 16px; 368 | border-radius: 6px; 369 | transition: var(--transition); 370 | display: inline-flex; 371 | align-items: center; 372 | gap: 0.5ch; 373 | } 374 | 375 | footer a:hover { 376 | background-color: rgba(58, 134, 255, 0.1); 377 | } 378 | 379 | input[type=submit], 380 | button, 381 | input[type=button] { 382 | cursor: pointer; 383 | display: inline-flex; 384 | align-items: center; 385 | justify-content: center; 386 | } 387 | 388 | .chore-due { 389 | background-color: var(--color-overdue); 390 | color: white; 391 | } 392 | 393 | .chore-due-soon { 394 | background-color: var(--color-due-soon); 395 | color: black; 396 | } 397 | 398 | .chore-due-later { 399 | background-color: var(--color-due-later); 400 | color: white; 401 | } 402 | 403 | .chore-done { 404 | background-color: var(--color-done); 405 | color: white; 406 | } 407 | 408 | main.stats table { 409 | max-width: 60ch; 410 | border-collapse: collapse; 411 | } 412 | 413 | main.stats thead { 414 | border-bottom: 2px solid var(--border-color); 415 | } 416 | 417 | main.stats table th, 418 | main.stats table td { 419 | max-width: 10ch; 420 | } 421 | 422 | main.stats table th { 423 | color: var(--color-text); 424 | font-weight: 600; 425 | text-align: left; 426 | border-bottom: 1px solid var(--border-color); 427 | } 428 | 429 | main.stats table th:first-child, 430 | main.stats table td:first-child { 431 | text-align: left; 432 | border-right: 1px solid var(--border-color); 433 | max-width: 20ch; 434 | } 435 | 436 | main.stats table th, 437 | main.stats table td { 438 | padding: 0.5ch 1ch; 439 | text-align: right; 440 | } 441 | 442 | main.stats table tr:nth-child(even) { 443 | background-color: var(--color-surface); 444 | } 445 | 446 | /* Responsive styles */ 447 | @media (max-width: 600px) { 448 | main.home .chores { 449 | grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); 450 | gap: 16px; 451 | padding: 8px; 452 | } 453 | 454 | main.home .chores button[type="submit"] { 455 | width: 56px; 456 | height: 56px; 457 | font-size: 20px; 458 | } 459 | 460 | main.manager fieldset { 461 | padding: 16px 12px; 462 | } 463 | 464 | main.manager input[type="submit"] { 465 | width: 100%; 466 | } 467 | 468 | main.manager .chore-list { 469 | display: grid; 470 | grid-template-columns: 1fr 1fr; 471 | gap: 1ch; 472 | } 473 | 474 | main.manager .chore-list :not(.form-item-button) { 475 | grid-column: span 2; 476 | } 477 | 478 | main.manager .chore-list hr { 479 | display: block; 480 | grid-column: span 2; 481 | color: transparent; 482 | border: none; 483 | outline: none; 484 | appearance: none; 485 | } 486 | 487 | main.manager .language-select { 488 | grid-template-columns: 1fr; 489 | } 490 | } 491 | --------------------------------------------------------------------------------