├── .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 |
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