rainlog 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rainlog-1.0.0/PKG-INFO +84 -0
- rainlog-1.0.0/README.md +65 -0
- rainlog-1.0.0/pyproject.toml +101 -0
- rainlog-1.0.0/src/rainlog/__init__.py +20 -0
- rainlog-1.0.0/src/rainlog/cli_commands.py +34 -0
- rainlog-1.0.0/src/rainlog/db_helpers.py +222 -0
- rainlog-1.0.0/src/rainlog/tui.py +809 -0
rainlog-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: rainlog
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A collection of CLI tools to record weather related data
|
|
5
|
+
Author: marvin8
|
|
6
|
+
Author-email: marvin8 <marvin8@tuta.io>
|
|
7
|
+
License: AGPL-3.0-or-later
|
|
8
|
+
Requires-Dist: cyclopts~=4.20.0
|
|
9
|
+
Requires-Dist: platformdirs~=4.10.0
|
|
10
|
+
Requires-Dist: rich~=15.0.0
|
|
11
|
+
Requires-Dist: textual~=8.2.7
|
|
12
|
+
Requires-Dist: typing-extensions~=4.15.0
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Project-URL: Documentation, https://marvin8.codeberg.page/rainlog/
|
|
15
|
+
Project-URL: Issues, https://codeberg.org/marvin8/rainlog/issues
|
|
16
|
+
Project-URL: Source, https://codeberg.org/marvin8/rainlog
|
|
17
|
+
Project-URL: Changelog, https://codeberg.org/marvin8/rainlog/src/branch/main/CHANGELOG.md
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# rainlog
|
|
21
|
+
|
|
22
|
+
[](https://pypi.org/project/rainlog/)
|
|
23
|
+
[](https://pypi.org/project/rainlog/)
|
|
24
|
+
[](https://www.gnu.org/licenses/agpl-3.0)
|
|
25
|
+
[](https://ci.codeberg.org/marvin8/rainlog)
|
|
26
|
+
[](https://rainlog.marvin8.zone)
|
|
27
|
+
|
|
28
|
+
A terminal-based rain logger. Record daily rainfall totals in a local SQLite database and explore history through an interactive TUI.
|
|
29
|
+
|
|
30
|
+
**License:** [AGPL-3.0-or-later](https://www.gnu.org/licenses/agpl-3.0.html)
|
|
31
|
+
|
|
32
|
+
**Python:** 3.11–3.14
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
From the repository root (with [uv](https://docs.astral.sh/uv/) recommended):
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uv sync
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or install the package into your environment:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv pip install .
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The console entry point is **`rainlog`**.
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
Run the TUI:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
rainlog
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
| Key | Action |
|
|
59
|
+
|-----|--------|
|
|
60
|
+
| `n` | Add a rain record |
|
|
61
|
+
| `e` | Edit a record (use `s` to select a bar first in daily view) |
|
|
62
|
+
| `←` / `→` | Scroll history back / forward |
|
|
63
|
+
| `g` | Cycle grouping (daily / weekly / monthly / yearly) |
|
|
64
|
+
| `m` | Toggle chart mode (rainfall ↔ soil-moisture index) |
|
|
65
|
+
| `+` / `-` | More / fewer bars |
|
|
66
|
+
| `a` | Auto bar count |
|
|
67
|
+
| `q` | Quit |
|
|
68
|
+
|
|
69
|
+
## Data storage
|
|
70
|
+
|
|
71
|
+
- By default, data lives in `rainlog.sqlite` in `~/.local/share/rainlog/`.
|
|
72
|
+
- Use **`--db-dir`** to put the database in another directory (the filename stays `rainlog.sqlite`).
|
|
73
|
+
- Each row stores the **rain total in millimetres** for a 24-hour block ending at **09:00** local time on the given calendar day.
|
|
74
|
+
|
|
75
|
+
## Development
|
|
76
|
+
|
|
77
|
+
Tests and automation are driven by **Nox**; list sessions with `nox -l` and run the ones you need (e.g. tests).
|
|
78
|
+
|
|
79
|
+
## Links
|
|
80
|
+
|
|
81
|
+
- **Documentation:** [rainlog.marvin8.zone](https://rainlog.marvin8.zone/)
|
|
82
|
+
- **Source:** [codeberg.org/marvin8/rainlog](https://codeberg.org/marvin8/rainlog)
|
|
83
|
+
- **Issues:** [codeberg.org/marvin8/rainlog/issues](https://codeberg.org/marvin8/rainlog/issues)
|
|
84
|
+
- **Changelog:** [CHANGELOG.md](https://codeberg.org/marvin8/rainlog/src/branch/main/CHANGELOG.md)
|
rainlog-1.0.0/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# rainlog
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/rainlog/)
|
|
4
|
+
[](https://pypi.org/project/rainlog/)
|
|
5
|
+
[](https://www.gnu.org/licenses/agpl-3.0)
|
|
6
|
+
[](https://ci.codeberg.org/marvin8/rainlog)
|
|
7
|
+
[](https://rainlog.marvin8.zone)
|
|
8
|
+
|
|
9
|
+
A terminal-based rain logger. Record daily rainfall totals in a local SQLite database and explore history through an interactive TUI.
|
|
10
|
+
|
|
11
|
+
**License:** [AGPL-3.0-or-later](https://www.gnu.org/licenses/agpl-3.0.html)
|
|
12
|
+
|
|
13
|
+
**Python:** 3.11–3.14
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
From the repository root (with [uv](https://docs.astral.sh/uv/) recommended):
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
uv sync
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or install the package into your environment:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uv pip install .
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The console entry point is **`rainlog`**.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
Run the TUI:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
rainlog
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
| Key | Action |
|
|
40
|
+
|-----|--------|
|
|
41
|
+
| `n` | Add a rain record |
|
|
42
|
+
| `e` | Edit a record (use `s` to select a bar first in daily view) |
|
|
43
|
+
| `←` / `→` | Scroll history back / forward |
|
|
44
|
+
| `g` | Cycle grouping (daily / weekly / monthly / yearly) |
|
|
45
|
+
| `m` | Toggle chart mode (rainfall ↔ soil-moisture index) |
|
|
46
|
+
| `+` / `-` | More / fewer bars |
|
|
47
|
+
| `a` | Auto bar count |
|
|
48
|
+
| `q` | Quit |
|
|
49
|
+
|
|
50
|
+
## Data storage
|
|
51
|
+
|
|
52
|
+
- By default, data lives in `rainlog.sqlite` in `~/.local/share/rainlog/`.
|
|
53
|
+
- Use **`--db-dir`** to put the database in another directory (the filename stays `rainlog.sqlite`).
|
|
54
|
+
- Each row stores the **rain total in millimetres** for a 24-hour block ending at **09:00** local time on the given calendar day.
|
|
55
|
+
|
|
56
|
+
## Development
|
|
57
|
+
|
|
58
|
+
Tests and automation are driven by **Nox**; list sessions with `nox -l` and run the ones you need (e.g. tests).
|
|
59
|
+
|
|
60
|
+
## Links
|
|
61
|
+
|
|
62
|
+
- **Documentation:** [rainlog.marvin8.zone](https://rainlog.marvin8.zone/)
|
|
63
|
+
- **Source:** [codeberg.org/marvin8/rainlog](https://codeberg.org/marvin8/rainlog)
|
|
64
|
+
- **Issues:** [codeberg.org/marvin8/rainlog/issues](https://codeberg.org/marvin8/rainlog/issues)
|
|
65
|
+
- **Changelog:** [CHANGELOG.md](https://codeberg.org/marvin8/rainlog/src/branch/main/CHANGELOG.md)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.11.0,<0.12.0"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rainlog"
|
|
7
|
+
description = "A collection of CLI tools to record weather related data"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "marvin8", email = "marvin8@tuta.io" },
|
|
11
|
+
]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
license = {text = "AGPL-3.0-or-later"}
|
|
14
|
+
version = "1.0.0"
|
|
15
|
+
dependencies = [
|
|
16
|
+
"cyclopts~=4.20.0",
|
|
17
|
+
"platformdirs~=4.10.0",
|
|
18
|
+
"rich~=15.0.0",
|
|
19
|
+
"textual~=8.2.7",
|
|
20
|
+
"typing-extensions~=4.15.0",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = [
|
|
25
|
+
"bump-my-version~=1.4.1",
|
|
26
|
+
"complexipy~=6.0.0",
|
|
27
|
+
"git-cliff~=2.13.1",
|
|
28
|
+
"mkdocs~=1.6.1",
|
|
29
|
+
"mkdocs-material~=9.7.6",
|
|
30
|
+
"mkdocstrings~=1.0.4",
|
|
31
|
+
"mkdocstrings-python~=2.0.5",
|
|
32
|
+
"mike~=2.2.0",
|
|
33
|
+
"nox-uv~=0.8.0",
|
|
34
|
+
"prek~=0.4.5",
|
|
35
|
+
"pytest~=9.1.1",
|
|
36
|
+
"pytest-asyncio~=1.4.0",
|
|
37
|
+
"pytest-cov~=7.1.0",
|
|
38
|
+
"ruff~=0.15.20",
|
|
39
|
+
"ty~=0.0.55",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[tool.uv]
|
|
43
|
+
constraint-dependencies = [
|
|
44
|
+
"filelock>=3.20.3", # Addresses vulnerability GHSA-qmgc-5h2g-mvrw
|
|
45
|
+
"requests>=2.33.0", # Addresses vulnerability GHSA-gc5v-m9x4-r6x2
|
|
46
|
+
"urllib3>=2.7.0", # Addresses vulnerabilities GHSA-mf9v-mfxr-j63j and GHSA-qccp-gfcp-xxvc
|
|
47
|
+
"virtualenv>=20.36.1", # Addresses vulnerability GHSA-597g-3phw-6986
|
|
48
|
+
"pydantic-settings>=2.14.2", # Addresses vulnerability GHSA-4xgf-cpjx-pc3j
|
|
49
|
+
"pygments>=2.20.0", # Addresses vulnerability GHSA-5239-wwwm-4pmq
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[project.urls]
|
|
53
|
+
Documentation = "https://marvin8.codeberg.page/rainlog/"
|
|
54
|
+
Issues = "https://codeberg.org/marvin8/rainlog/issues"
|
|
55
|
+
Source = "https://codeberg.org/marvin8/rainlog"
|
|
56
|
+
Changelog = "https://codeberg.org/marvin8/rainlog/src/branch/main/CHANGELOG.md"
|
|
57
|
+
|
|
58
|
+
[project.scripts]
|
|
59
|
+
rainlog = "rainlog.cli_commands:app"
|
|
60
|
+
|
|
61
|
+
[tool.bumpversion]
|
|
62
|
+
commit = true
|
|
63
|
+
tag = true
|
|
64
|
+
tag_name = "{new_version}"
|
|
65
|
+
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
|
66
|
+
serialize = ["{major}.{minor}.{patch}"]
|
|
67
|
+
message = ":wrench: bump: version {current_version} → {new_version}"
|
|
68
|
+
pre_commit_hooks = ["uv sync --all-groups", "uv export --format pylock.toml -o pylock.toml --quiet", "git add uv.lock", "git add pylock.toml"]
|
|
69
|
+
|
|
70
|
+
[[tool.bumpversion.files]]
|
|
71
|
+
filename = "pyproject.toml"
|
|
72
|
+
search = 'version = "{current_version}"'
|
|
73
|
+
replace = 'version = "{new_version}"'
|
|
74
|
+
|
|
75
|
+
[tool.pytest.ini_options]
|
|
76
|
+
testpaths = [
|
|
77
|
+
"tests",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
[tool.complexipy]
|
|
81
|
+
paths = ["src"]
|
|
82
|
+
|
|
83
|
+
[tool.deptry.per_rule_ignores]
|
|
84
|
+
DEP002 = [
|
|
85
|
+
"h11", # Addresses vulnerability in transient dependency GHSA-vqfr-h8mv-ghfj
|
|
86
|
+
"h2", # Addresses vulnerability in transient dependency GHSA-847f-9342-265h
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
[tool.zaojun]
|
|
90
|
+
cache = true
|
|
91
|
+
groups = true
|
|
92
|
+
min-age = 7
|
|
93
|
+
check-licenses = true
|
|
94
|
+
|
|
95
|
+
[[tool.zaojun.license-ignore]]
|
|
96
|
+
package = "git-cliff"
|
|
97
|
+
reason = "Manually checked, it is licensed as Apache-2.0 OR MIT"
|
|
98
|
+
|
|
99
|
+
[[tool.zaojun.license-ignore]]
|
|
100
|
+
package = "typing-extensions"
|
|
101
|
+
reason = "PSF-2.0 is acceptable"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Package wide variables."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
from datetime import timezone
|
|
6
|
+
from importlib.metadata import version
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Final
|
|
9
|
+
|
|
10
|
+
from platformdirs import user_data_dir
|
|
11
|
+
|
|
12
|
+
__version__: Final[str] = version(str(__package__))
|
|
13
|
+
|
|
14
|
+
__package_name__: Final[str] = str(__package__)
|
|
15
|
+
__display_name__: Final[str] = "rainlog"
|
|
16
|
+
|
|
17
|
+
DEFAULT_DB_FILE_NAME: Final[str] = "rainlog.sqlite"
|
|
18
|
+
DEFAULT_DB_DIR: Final[Path] = Path(user_data_dir("rainlog"))
|
|
19
|
+
UTC = timezone(offset=timedelta(hours=0))
|
|
20
|
+
LOCAL_TZ = datetime.now(tz=UTC).astimezone().tzinfo
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Main methods to interact with rain data."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from cyclopts import App
|
|
7
|
+
from cyclopts import Parameter
|
|
8
|
+
|
|
9
|
+
from rainlog import DEFAULT_DB_DIR
|
|
10
|
+
from rainlog.db_helpers import Database
|
|
11
|
+
from rainlog.tui import RainTuiApp
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@Parameter(name="*")
|
|
15
|
+
@dataclass
|
|
16
|
+
class Common:
|
|
17
|
+
"""Class for common db-path parameter."""
|
|
18
|
+
|
|
19
|
+
db_dir: Path = DEFAULT_DB_DIR
|
|
20
|
+
"Path to database file"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
app = App(result_action="return_value")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.default
|
|
27
|
+
@app.command()
|
|
28
|
+
def tui(common: Common | None = None) -> None:
|
|
29
|
+
"""Launch the interactive TUI for browsing rain history."""
|
|
30
|
+
if common is None:
|
|
31
|
+
common = Common()
|
|
32
|
+
with Database(common.db_dir) as database:
|
|
33
|
+
rain_app = RainTuiApp(database=database)
|
|
34
|
+
rain_app.run()
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Classes and methods around working with the history database."""
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from typing_extensions import Self
|
|
9
|
+
|
|
10
|
+
from rainlog import DEFAULT_DB_FILE_NAME
|
|
11
|
+
from rainlog import LOCAL_TZ
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GraphGrouping(str, Enum):
|
|
15
|
+
"""Provides possible values for grouping of graphs."""
|
|
16
|
+
|
|
17
|
+
daily = "daily"
|
|
18
|
+
weekly = "weekly"
|
|
19
|
+
monthly = "monthly"
|
|
20
|
+
yearly = "yearly"
|
|
21
|
+
annually = "annually"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Database:
|
|
25
|
+
"""Implements helper methods to add and retrieve data from database."""
|
|
26
|
+
|
|
27
|
+
def __init__(self: Self, db_dir: Path) -> None:
|
|
28
|
+
"""Create database connection. Also creates database, directory, and table(s) if they don't exist yet."""
|
|
29
|
+
db_dir.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
self.db_connection = sqlite3.connect(database=db_dir / DEFAULT_DB_FILE_NAME)
|
|
31
|
+
|
|
32
|
+
# Make sure DB tables exist
|
|
33
|
+
self.db_connection.execute(
|
|
34
|
+
"CREATE TABLE IF NOT EXISTS rain_daily (date INT NOT NULL UNIQUE PRIMARY KEY, rain REAL)"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
self.db_connection.commit()
|
|
38
|
+
|
|
39
|
+
def __enter__(self: Self) -> Self:
|
|
40
|
+
"""Return self to support use as a context manager."""
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
def __exit__(self: Self, *_: object) -> None:
|
|
44
|
+
"""Close the database connection on context manager exit."""
|
|
45
|
+
self.db_connection.close()
|
|
46
|
+
|
|
47
|
+
def add_rain_record(self: Self, date: datetime, amount: float) -> None:
|
|
48
|
+
"""Add a record / measurement of rain to the DB."""
|
|
49
|
+
self.db_connection.execute(
|
|
50
|
+
"INSERT INTO rain_daily (date, rain) VALUES (?,?)",
|
|
51
|
+
(date.timestamp(), amount),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
self.db_connection.commit()
|
|
55
|
+
|
|
56
|
+
def update_rain_record(self: Self, date: datetime, amount: float) -> None:
|
|
57
|
+
"""Update a record / measurement of rain in the DB."""
|
|
58
|
+
self.db_connection.execute(
|
|
59
|
+
"UPDATE rain_daily set rain = :rain WHERE date = :ts",
|
|
60
|
+
{"rain": amount, "ts": date.timestamp()},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
self.db_connection.commit()
|
|
64
|
+
|
|
65
|
+
def get_single_day_rain(self: Self, date: datetime) -> float | None:
|
|
66
|
+
"""Return amount of rain for that day as a float or False if no rain
|
|
67
|
+
record was found for that particular date.
|
|
68
|
+
"""
|
|
69
|
+
cursor = self.db_connection.cursor()
|
|
70
|
+
cursor.execute(
|
|
71
|
+
"SELECT rain FROM rain_daily WHERE date = ?",
|
|
72
|
+
(date.timestamp(),),
|
|
73
|
+
)
|
|
74
|
+
cursor_data = cursor.fetchone()
|
|
75
|
+
if cursor_data:
|
|
76
|
+
return float(cursor_data[0])
|
|
77
|
+
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
def get_rain(
|
|
81
|
+
self: Self,
|
|
82
|
+
history_size: int,
|
|
83
|
+
group: GraphGrouping,
|
|
84
|
+
offset: int = 0,
|
|
85
|
+
) -> list[tuple[str, float]]:
|
|
86
|
+
"""Get 'history_size' number of rain records, skipping 'offset' most-recent groups."""
|
|
87
|
+
return_list: list[tuple[str, float]] = []
|
|
88
|
+
group_id = None
|
|
89
|
+
group_sum = 0.0
|
|
90
|
+
groups_skipped = 0
|
|
91
|
+
|
|
92
|
+
for row in self.db_connection.execute("SELECT date, rain FROM rain_daily ORDER BY date DESC"):
|
|
93
|
+
current_group_id = Database._determine_group(
|
|
94
|
+
group=group,
|
|
95
|
+
group_date=datetime.fromtimestamp(row[0]).astimezone(tz=LOCAL_TZ),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if not group_id:
|
|
99
|
+
group_id = current_group_id
|
|
100
|
+
|
|
101
|
+
if current_group_id == group_id:
|
|
102
|
+
group_sum += row[1]
|
|
103
|
+
else:
|
|
104
|
+
if groups_skipped >= offset:
|
|
105
|
+
return_list.append((group_id, group_sum))
|
|
106
|
+
else:
|
|
107
|
+
groups_skipped += 1
|
|
108
|
+
group_id = current_group_id
|
|
109
|
+
group_sum = row[1]
|
|
110
|
+
|
|
111
|
+
if len(return_list) >= history_size:
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
if len(return_list) < history_size and group_id and groups_skipped >= offset:
|
|
115
|
+
return_list.append((group_id, group_sum))
|
|
116
|
+
|
|
117
|
+
return return_list
|
|
118
|
+
|
|
119
|
+
def get_moisture_index(
|
|
120
|
+
self: Self,
|
|
121
|
+
history_size: int,
|
|
122
|
+
group: GraphGrouping,
|
|
123
|
+
offset: int = 0,
|
|
124
|
+
decay: float = 0.85,
|
|
125
|
+
) -> list[tuple[str, float]]:
|
|
126
|
+
"""Compute soil moisture index via exponential decay and return paginated groups.
|
|
127
|
+
|
|
128
|
+
Applies moisture = moisture * decay + rain sequentially over all records in
|
|
129
|
+
ascending date order (initial moisture = 0). Groups results via _determine_group,
|
|
130
|
+
keeping the last moisture value per group (end-of-period moisture). Returns at most
|
|
131
|
+
history_size groups in descending order, skipping the offset most-recent groups.
|
|
132
|
+
"""
|
|
133
|
+
current_moisture = 0.0
|
|
134
|
+
group_moisture: dict[str, float] = {}
|
|
135
|
+
previous_record_date: datetime | None = None
|
|
136
|
+
|
|
137
|
+
for row in self.db_connection.execute("SELECT date, rain FROM rain_daily ORDER BY date ASC"):
|
|
138
|
+
record_date = datetime.fromtimestamp(row[0]).astimezone(tz=LOCAL_TZ)
|
|
139
|
+
if previous_record_date is not None:
|
|
140
|
+
elapsed_days = (record_date.date() - previous_record_date.date()).days
|
|
141
|
+
if elapsed_days > 1:
|
|
142
|
+
current_moisture *= decay ** (elapsed_days - 1)
|
|
143
|
+
current_moisture = current_moisture * decay + row[1]
|
|
144
|
+
previous_record_date = record_date
|
|
145
|
+
group_label = Database._determine_group(
|
|
146
|
+
group=group,
|
|
147
|
+
group_date=record_date,
|
|
148
|
+
)
|
|
149
|
+
group_moisture[group_label] = current_moisture
|
|
150
|
+
|
|
151
|
+
descending_groups = list(group_moisture.items())
|
|
152
|
+
descending_groups.reverse()
|
|
153
|
+
return descending_groups[offset : offset + history_size]
|
|
154
|
+
|
|
155
|
+
def get_current_streak(self: Self) -> tuple[str, int]:
|
|
156
|
+
"""Return the type and length of the current consecutive wet or dry streak.
|
|
157
|
+
|
|
158
|
+
Walks backward from the most recent record. Returns ('dry', 0) for an empty DB.
|
|
159
|
+
"""
|
|
160
|
+
streak_type: str | None = None
|
|
161
|
+
streak_count = 0
|
|
162
|
+
|
|
163
|
+
for row in self.db_connection.execute("SELECT rain FROM rain_daily ORDER BY date DESC"):
|
|
164
|
+
rain = row[0]
|
|
165
|
+
row_type = "wet" if rain > 0 else "dry"
|
|
166
|
+
|
|
167
|
+
if streak_type is None:
|
|
168
|
+
streak_type = row_type
|
|
169
|
+
streak_count = 1
|
|
170
|
+
elif row_type == streak_type:
|
|
171
|
+
streak_count += 1
|
|
172
|
+
else:
|
|
173
|
+
break
|
|
174
|
+
|
|
175
|
+
if streak_type is None:
|
|
176
|
+
return ("dry", 0)
|
|
177
|
+
|
|
178
|
+
return (streak_type, streak_count)
|
|
179
|
+
|
|
180
|
+
def get_most_recent_date(self: Self) -> datetime | None:
|
|
181
|
+
"""Return the datetime of the most recent rain record, or None if the DB is empty."""
|
|
182
|
+
cursor = self.db_connection.cursor()
|
|
183
|
+
cursor.execute("SELECT MAX(date) FROM rain_daily")
|
|
184
|
+
row = cursor.fetchone()
|
|
185
|
+
if row and row[0] is not None:
|
|
186
|
+
return datetime.fromtimestamp(row[0]).astimezone(tz=LOCAL_TZ)
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
def get_earliest_date(self: Self) -> datetime | None:
|
|
190
|
+
"""Return the datetime of the earliest rain record, or None if the DB is empty."""
|
|
191
|
+
cursor = self.db_connection.cursor()
|
|
192
|
+
cursor.execute("SELECT MIN(date) FROM rain_daily")
|
|
193
|
+
row = cursor.fetchone()
|
|
194
|
+
if row and row[0] is not None:
|
|
195
|
+
return datetime.fromtimestamp(row[0]).astimezone(tz=LOCAL_TZ)
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def _determine_group(group: str, group_date: datetime) -> str:
|
|
200
|
+
"""Determine group value for grouping of data."""
|
|
201
|
+
match group:
|
|
202
|
+
case GraphGrouping.daily:
|
|
203
|
+
format_for_grouping = "%Y-%m-%d"
|
|
204
|
+
case GraphGrouping.weekly:
|
|
205
|
+
format_for_grouping = "%Y-%W"
|
|
206
|
+
case GraphGrouping.monthly:
|
|
207
|
+
format_for_grouping = "%Y-%m"
|
|
208
|
+
case GraphGrouping.yearly | GraphGrouping.annually:
|
|
209
|
+
format_for_grouping = "%Y"
|
|
210
|
+
case _:
|
|
211
|
+
raise ValueError(f"Unrecognized value for {group=}")
|
|
212
|
+
|
|
213
|
+
group_id: str = group_date.strftime(format_for_grouping)
|
|
214
|
+
|
|
215
|
+
if group == "weekly":
|
|
216
|
+
year_part, week_part = group_id.split("-", 1)
|
|
217
|
+
group_id = f"{year_part}-{week_part}"
|
|
218
|
+
|
|
219
|
+
if group_id is None:
|
|
220
|
+
raise ValueError(f"Database._determine_group({group=}, {group_date=}) -> {group_id=}")
|
|
221
|
+
|
|
222
|
+
return group_id
|
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
"""Interactive TUI for browsing rain history."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import ClassVar
|
|
11
|
+
|
|
12
|
+
from rich.console import RenderableType
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
from textual.app import App
|
|
15
|
+
from textual.app import ComposeResult
|
|
16
|
+
from textual.binding import Binding
|
|
17
|
+
from textual.containers import Horizontal
|
|
18
|
+
from textual.containers import Vertical
|
|
19
|
+
from textual.screen import ModalScreen
|
|
20
|
+
from textual.widget import Widget
|
|
21
|
+
from textual.widgets import Button
|
|
22
|
+
from textual.widgets import Footer
|
|
23
|
+
from textual.widgets import Input
|
|
24
|
+
from textual.widgets import Label
|
|
25
|
+
from textual.widgets import Switch
|
|
26
|
+
|
|
27
|
+
from rainlog import LOCAL_TZ
|
|
28
|
+
from rainlog.db_helpers import Database
|
|
29
|
+
from rainlog.db_helpers import GraphGrouping
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ChartMode(str, Enum):
|
|
33
|
+
"""Chart display mode: rainfall amounts or soil moisture index."""
|
|
34
|
+
|
|
35
|
+
rainfall = "rainfall"
|
|
36
|
+
moisture = "moisture"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
DAYS_PER_GROUP: dict[GraphGrouping, int] = {
|
|
40
|
+
GraphGrouping.daily: 1,
|
|
41
|
+
GraphGrouping.weekly: 7,
|
|
42
|
+
GraphGrouping.monthly: 30,
|
|
43
|
+
GraphGrouping.yearly: 365,
|
|
44
|
+
GraphGrouping.annually: 365,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
GROUPING_CYCLE: list[GraphGrouping] = [
|
|
48
|
+
GraphGrouping.daily,
|
|
49
|
+
GraphGrouping.weekly,
|
|
50
|
+
GraphGrouping.monthly,
|
|
51
|
+
GraphGrouping.yearly,
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
_MIN_BAR_WIDTH = 3
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _compute_auto_bar_count(chart_width: int) -> int:
|
|
58
|
+
"""Return the number of bars that fit in chart_width terminal columns."""
|
|
59
|
+
return max(7, (chart_width - 4) // (_MIN_BAR_WIDTH + 1))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _parse_date_input(text: str) -> datetime | None:
|
|
63
|
+
"""Parse a YYYY-MM-DD string into a LOCAL_TZ datetime; None on failure."""
|
|
64
|
+
try:
|
|
65
|
+
return datetime.strptime(text.strip(), "%Y-%m-%d").replace(tzinfo=LOCAL_TZ)
|
|
66
|
+
except ValueError:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_amount_input(text: str) -> float | None:
|
|
71
|
+
"""Parse a non-negative float string; None on failure or negative value."""
|
|
72
|
+
try:
|
|
73
|
+
value = float(text.strip())
|
|
74
|
+
except ValueError:
|
|
75
|
+
return None
|
|
76
|
+
return value if value >= 0 else None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class AddRainResult:
|
|
81
|
+
"""Payload returned by AddRainModal on successful submission."""
|
|
82
|
+
|
|
83
|
+
date: datetime
|
|
84
|
+
amount: float
|
|
85
|
+
backfill: bool
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class AddRainModal(ModalScreen[AddRainResult | None]):
|
|
89
|
+
"""Modal form for adding a new rain record."""
|
|
90
|
+
|
|
91
|
+
DEFAULT_CSS = """
|
|
92
|
+
AddRainModal {
|
|
93
|
+
align: center middle;
|
|
94
|
+
}
|
|
95
|
+
AddRainModal > Vertical {
|
|
96
|
+
width: 44;
|
|
97
|
+
height: auto;
|
|
98
|
+
padding: 1 2;
|
|
99
|
+
border: thick $primary;
|
|
100
|
+
}
|
|
101
|
+
AddRainModal .field-error {
|
|
102
|
+
border: solid red;
|
|
103
|
+
}
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
BINDINGS: ClassVar[list[Binding]] = [
|
|
107
|
+
Binding("escape", "cancel", "Cancel"),
|
|
108
|
+
Binding("ctrl+s", "submit", "Save"),
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
def compose(self) -> ComposeResult:
|
|
112
|
+
"""Render the add-rain form."""
|
|
113
|
+
today = datetime.now(tz=LOCAL_TZ).strftime("%Y-%m-%d")
|
|
114
|
+
with Vertical():
|
|
115
|
+
yield Label("Add Rain Record")
|
|
116
|
+
yield Label("Date (YYYY-MM-DD)")
|
|
117
|
+
yield Input(value=today, id="date_input")
|
|
118
|
+
yield Label("Amount (mm)")
|
|
119
|
+
yield Input(placeholder="0.0", id="amount_input")
|
|
120
|
+
yield Label("Back-fill zeros to last record")
|
|
121
|
+
yield Switch(id="backfill_switch")
|
|
122
|
+
yield Button("Save", id="save_btn", variant="primary")
|
|
123
|
+
|
|
124
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
125
|
+
"""Forward Save button press to submit action."""
|
|
126
|
+
if event.button.id == "save_btn":
|
|
127
|
+
self.action_submit()
|
|
128
|
+
|
|
129
|
+
def action_cancel(self) -> None:
|
|
130
|
+
"""Dismiss without saving."""
|
|
131
|
+
self.dismiss(None)
|
|
132
|
+
|
|
133
|
+
def action_submit(self) -> None:
|
|
134
|
+
"""Validate inputs and dismiss with result, or mark invalid fields."""
|
|
135
|
+
date_input = self.query_one("#date_input", Input)
|
|
136
|
+
amount_input = self.query_one("#amount_input", Input)
|
|
137
|
+
backfill_switch = self.query_one("#backfill_switch", Switch)
|
|
138
|
+
|
|
139
|
+
date_input.remove_class("field-error")
|
|
140
|
+
amount_input.remove_class("field-error")
|
|
141
|
+
|
|
142
|
+
parsed_date = _parse_date_input(date_input.value)
|
|
143
|
+
parsed_amount = _parse_amount_input(amount_input.value)
|
|
144
|
+
|
|
145
|
+
if parsed_date is None:
|
|
146
|
+
date_input.add_class("field-error")
|
|
147
|
+
return
|
|
148
|
+
if parsed_amount is None:
|
|
149
|
+
amount_input.add_class("field-error")
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
self.dismiss(AddRainResult(date=parsed_date, amount=parsed_amount, backfill=backfill_switch.value))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class EditRainResult:
|
|
157
|
+
"""Payload returned by EditRainModal on successful submission."""
|
|
158
|
+
|
|
159
|
+
date: datetime
|
|
160
|
+
amount: float
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class EditRainModal(ModalScreen[EditRainResult | None]):
|
|
164
|
+
"""Modal form for editing an existing rain record."""
|
|
165
|
+
|
|
166
|
+
DEFAULT_CSS = """
|
|
167
|
+
EditRainModal {
|
|
168
|
+
align: center middle;
|
|
169
|
+
}
|
|
170
|
+
EditRainModal > Vertical {
|
|
171
|
+
width: 44;
|
|
172
|
+
height: auto;
|
|
173
|
+
padding: 1 2;
|
|
174
|
+
border: thick $primary;
|
|
175
|
+
}
|
|
176
|
+
EditRainModal .field-error {
|
|
177
|
+
border: solid red;
|
|
178
|
+
}
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
BINDINGS: ClassVar[list[Binding]] = [
|
|
182
|
+
Binding("escape", "cancel", "Cancel"),
|
|
183
|
+
Binding("ctrl+s", "submit", "Save"),
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
def __init__(self, prefill_date: str = "", prefill_amount: str = "") -> None:
|
|
187
|
+
"""Initialise with optional pre-filled values from a selected bar."""
|
|
188
|
+
super().__init__()
|
|
189
|
+
self._prefill_date = prefill_date
|
|
190
|
+
self._prefill_amount = prefill_amount
|
|
191
|
+
|
|
192
|
+
def compose(self) -> ComposeResult:
|
|
193
|
+
"""Render the edit-rain form."""
|
|
194
|
+
with Vertical():
|
|
195
|
+
yield Label("Edit Rain Record")
|
|
196
|
+
yield Label("Date (YYYY-MM-DD)")
|
|
197
|
+
yield Input(value=self._prefill_date, placeholder="YYYY-MM-DD", id="date_input")
|
|
198
|
+
yield Label("Amount (mm)")
|
|
199
|
+
yield Input(value=self._prefill_amount, placeholder="0.0", id="amount_input")
|
|
200
|
+
yield Button("Save", id="save_btn", variant="primary")
|
|
201
|
+
|
|
202
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
203
|
+
"""Forward Save button press to submit action."""
|
|
204
|
+
if event.button.id == "save_btn":
|
|
205
|
+
self.action_submit()
|
|
206
|
+
|
|
207
|
+
def action_cancel(self) -> None:
|
|
208
|
+
"""Dismiss without saving."""
|
|
209
|
+
self.dismiss(None)
|
|
210
|
+
|
|
211
|
+
def action_submit(self) -> None:
|
|
212
|
+
"""Validate and dismiss with result, or mark invalid fields."""
|
|
213
|
+
date_input = self.query_one("#date_input", Input)
|
|
214
|
+
amount_input = self.query_one("#amount_input", Input)
|
|
215
|
+
|
|
216
|
+
date_input.remove_class("field-error")
|
|
217
|
+
amount_input.remove_class("field-error")
|
|
218
|
+
|
|
219
|
+
parsed_date = _parse_date_input(date_input.value)
|
|
220
|
+
parsed_amount = _parse_amount_input(amount_input.value)
|
|
221
|
+
|
|
222
|
+
if parsed_date is None:
|
|
223
|
+
date_input.add_class("field-error")
|
|
224
|
+
return
|
|
225
|
+
if parsed_amount is None:
|
|
226
|
+
amount_input.add_class("field-error")
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
self.dismiss(EditRainResult(date=parsed_date, amount=parsed_amount))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def calculate_bar_heights(values: list[float], max_height: int) -> list[int]:
|
|
233
|
+
"""Scale a list of rain values to bar heights in terminal character rows."""
|
|
234
|
+
if not values or max(values) == 0:
|
|
235
|
+
return [0] * len(values)
|
|
236
|
+
max_value = max(values)
|
|
237
|
+
return [round(value / max_value * max_height) for value in values]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _bar_color(value: float, max_value: float, max_intensity: float = 1.0) -> str:
|
|
241
|
+
"""Return an RGB color string scaling from light sky blue to dark navy with rain intensity."""
|
|
242
|
+
if max_value == 0:
|
|
243
|
+
return "rgb(135,206,250)"
|
|
244
|
+
intensity = min(max_intensity, value / max_value)
|
|
245
|
+
red = int(135 * (1 - intensity))
|
|
246
|
+
green = int(206 * (1 - intensity))
|
|
247
|
+
blue = int(250 - 111 * intensity)
|
|
248
|
+
return f"rgb({red},{green},{blue})"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _format_rain_value(value: float, bar_width: int) -> str:
|
|
252
|
+
"""Format a rain value centred in bar_width characters, dropping the decimal if too narrow."""
|
|
253
|
+
if bar_width < 2:
|
|
254
|
+
return " " * bar_width
|
|
255
|
+
candidates = [f"{value:.1f}", f"{round(value)!s}"]
|
|
256
|
+
for candidate in candidates:
|
|
257
|
+
if len(candidate) <= bar_width:
|
|
258
|
+
return candidate.center(bar_width)
|
|
259
|
+
return candidates[-1][:bar_width]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _tentative_bar_color(value: float, max_value: float) -> str:
|
|
263
|
+
"""Return an RGB color string scaling from light amber to dark orange with rain intensity."""
|
|
264
|
+
if max_value == 0:
|
|
265
|
+
return "rgb(255,200,100)"
|
|
266
|
+
intensity = min(1.0, value / max_value)
|
|
267
|
+
red = int(255 - 55 * intensity)
|
|
268
|
+
green = int(200 - 80 * intensity)
|
|
269
|
+
blue = int(100 * (1 - intensity))
|
|
270
|
+
return f"rgb({red},{green},{blue})"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _compute_tentative_entries(
|
|
274
|
+
last_date: datetime,
|
|
275
|
+
today: datetime,
|
|
276
|
+
group: GraphGrouping,
|
|
277
|
+
) -> list[tuple[str, float]]:
|
|
278
|
+
"""Return synthetic 0-rain entries for the gap from last_date+1 to today, most-recent first.
|
|
279
|
+
|
|
280
|
+
Each calendar day in the gap is mapped to its group label; duplicate labels
|
|
281
|
+
(e.g. several days in the same week) are deduplicated while preserving DESC order.
|
|
282
|
+
"""
|
|
283
|
+
seen: dict[str, None] = {}
|
|
284
|
+
current = today
|
|
285
|
+
while current.date() > last_date.date():
|
|
286
|
+
label = Database._determine_group(group=group, group_date=current)
|
|
287
|
+
seen[label] = None
|
|
288
|
+
current -= timedelta(days=1)
|
|
289
|
+
return [(label, 0.0) for label in seen]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _compute_tentative_moisture_entries(
|
|
293
|
+
last_date: datetime,
|
|
294
|
+
today: datetime,
|
|
295
|
+
last_moisture: float,
|
|
296
|
+
group: GraphGrouping,
|
|
297
|
+
decay: float = 0.85,
|
|
298
|
+
) -> list[tuple[str, float]]:
|
|
299
|
+
"""Return synthetic moisture entries for the gap from last_date+1 to today, most-recent first.
|
|
300
|
+
|
|
301
|
+
Applies exponential decay for each day in the gap (rain = 0). Groups by the specified
|
|
302
|
+
grouping, keeping the last (most decayed) moisture value per group. Returns entries
|
|
303
|
+
in descending order (today's group first).
|
|
304
|
+
"""
|
|
305
|
+
current_moisture = last_moisture
|
|
306
|
+
current_date = last_date + timedelta(days=1)
|
|
307
|
+
group_moisture: dict[str, float] = {}
|
|
308
|
+
|
|
309
|
+
while current_date.date() <= today.date():
|
|
310
|
+
current_moisture *= decay
|
|
311
|
+
group_label = Database._determine_group(group=group, group_date=current_date)
|
|
312
|
+
group_moisture[group_label] = current_moisture
|
|
313
|
+
current_date += timedelta(days=1)
|
|
314
|
+
|
|
315
|
+
descending_entries = list(group_moisture.items())
|
|
316
|
+
descending_entries.reverse()
|
|
317
|
+
return descending_entries
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class BarChartWidget(Widget):
|
|
321
|
+
"""Vertical bar chart rendered with Unicode block characters."""
|
|
322
|
+
|
|
323
|
+
DEFAULT_CSS = """
|
|
324
|
+
BarChartWidget {
|
|
325
|
+
width: 1fr;
|
|
326
|
+
height: 1fr;
|
|
327
|
+
}
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
def __init__(self) -> None:
|
|
331
|
+
"""Initialise with empty dataset."""
|
|
332
|
+
super().__init__()
|
|
333
|
+
self._data: list[tuple[str, float]] = []
|
|
334
|
+
self._tentative_labels: set[str] = set()
|
|
335
|
+
self._selected_index: int | None = None
|
|
336
|
+
|
|
337
|
+
def set_data(
|
|
338
|
+
self,
|
|
339
|
+
data: list[tuple[str, float]],
|
|
340
|
+
tentative_labels: set[str],
|
|
341
|
+
selected_index: int | None = None,
|
|
342
|
+
) -> None:
|
|
343
|
+
"""Replace chart data and trigger a repaint."""
|
|
344
|
+
self._data = data
|
|
345
|
+
self._tentative_labels = tentative_labels
|
|
346
|
+
self._selected_index = selected_index
|
|
347
|
+
self.refresh()
|
|
348
|
+
|
|
349
|
+
def _append_chart_rows(
|
|
350
|
+
self,
|
|
351
|
+
result: Text,
|
|
352
|
+
bar_entries: list[tuple[int, str]],
|
|
353
|
+
bar_width: int,
|
|
354
|
+
chart_height: int,
|
|
355
|
+
) -> None:
|
|
356
|
+
"""Append one character row per chart row to result.
|
|
357
|
+
|
|
358
|
+
bar_entries is a list of (height, color) per bar.
|
|
359
|
+
"""
|
|
360
|
+
for row in range(chart_height, 0, -1):
|
|
361
|
+
for index, (bar_height, color) in enumerate(bar_entries):
|
|
362
|
+
if index > 0:
|
|
363
|
+
result.append(" ")
|
|
364
|
+
if bar_height >= row:
|
|
365
|
+
result.append("█" * bar_width, style=color)
|
|
366
|
+
else:
|
|
367
|
+
result.append(" " * bar_width)
|
|
368
|
+
result.append("\n")
|
|
369
|
+
|
|
370
|
+
def _append_value_row(
|
|
371
|
+
self,
|
|
372
|
+
result: Text,
|
|
373
|
+
values: list[float],
|
|
374
|
+
colors: list[str],
|
|
375
|
+
bar_width: int,
|
|
376
|
+
) -> None:
|
|
377
|
+
"""Append a row showing each bar's rainfall amount in its bar colour."""
|
|
378
|
+
for index, (value, color) in enumerate(zip(values, colors, strict=True)):
|
|
379
|
+
if index > 0:
|
|
380
|
+
result.append(" ")
|
|
381
|
+
result.append(_format_rain_value(value, bar_width), style=color)
|
|
382
|
+
result.append("\n")
|
|
383
|
+
|
|
384
|
+
def _compute_colors(
|
|
385
|
+
self,
|
|
386
|
+
values: list[float],
|
|
387
|
+
tentative_flags: list[bool],
|
|
388
|
+
max_value: float,
|
|
389
|
+
) -> tuple[list[str], list[str]]:
|
|
390
|
+
"""Return (bar_colors, value_colors) for each bar.
|
|
391
|
+
|
|
392
|
+
Selected bar is white; tentative bars use the amber palette; others use
|
|
393
|
+
the blue palette. Value colors use a brightness floor (max_intensity=0.5)
|
|
394
|
+
so dark bars remain legible on black terminals.
|
|
395
|
+
"""
|
|
396
|
+
bar_colors: list[str] = []
|
|
397
|
+
value_colors: list[str] = []
|
|
398
|
+
for index, (value, flag) in enumerate(zip(values, tentative_flags, strict=True)):
|
|
399
|
+
if index == self._selected_index:
|
|
400
|
+
bar_colors.append("rgb(255,255,255)")
|
|
401
|
+
value_colors.append("rgb(255,255,255)")
|
|
402
|
+
elif flag:
|
|
403
|
+
bar_colors.append(_tentative_bar_color(value, max_value))
|
|
404
|
+
value_colors.append(_tentative_bar_color(value, max_value))
|
|
405
|
+
else:
|
|
406
|
+
bar_colors.append(_bar_color(value, max_value))
|
|
407
|
+
value_colors.append(_bar_color(value, max_value, max_intensity=0.5))
|
|
408
|
+
return bar_colors, value_colors
|
|
409
|
+
|
|
410
|
+
def render(self) -> RenderableType:
|
|
411
|
+
"""Draw bars scaled to the current widget height and width, coloured by rain intensity."""
|
|
412
|
+
if not self._data:
|
|
413
|
+
return Text("No data")
|
|
414
|
+
|
|
415
|
+
chart_height = max(1, self.size.height - 3)
|
|
416
|
+
labels = [label for label, _ in self._data]
|
|
417
|
+
values = [rain for _, rain in self._data]
|
|
418
|
+
|
|
419
|
+
bar_count = len(values)
|
|
420
|
+
bar_width = max(1, (self.size.width - 4) // bar_count - 1)
|
|
421
|
+
|
|
422
|
+
heights = calculate_bar_heights(values, chart_height)
|
|
423
|
+
max_value = max(values)
|
|
424
|
+
tentative_flags = [label in self._tentative_labels for label in labels]
|
|
425
|
+
colors, value_colors = self._compute_colors(values, tentative_flags, max_value)
|
|
426
|
+
|
|
427
|
+
bar_entries = list(zip(heights, colors, strict=True))
|
|
428
|
+
result = Text()
|
|
429
|
+
self._append_chart_rows(result, bar_entries, bar_width, chart_height)
|
|
430
|
+
self._append_value_row(result, values, value_colors, bar_width)
|
|
431
|
+
label_line = " ".join(label[-bar_width:].ljust(bar_width) for label in labels)
|
|
432
|
+
result.append(label_line)
|
|
433
|
+
|
|
434
|
+
return result
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
class StatsPanel(Widget):
|
|
438
|
+
"""Sidebar showing period total, daily average, and current streak."""
|
|
439
|
+
|
|
440
|
+
DEFAULT_CSS = """
|
|
441
|
+
StatsPanel {
|
|
442
|
+
width: auto;
|
|
443
|
+
height: 1fr;
|
|
444
|
+
padding: 1 2;
|
|
445
|
+
}
|
|
446
|
+
"""
|
|
447
|
+
|
|
448
|
+
def __init__(self) -> None:
|
|
449
|
+
"""Initialise with zero stats."""
|
|
450
|
+
super().__init__()
|
|
451
|
+
self._period_total = 0.0
|
|
452
|
+
self._daily_average = 0.0
|
|
453
|
+
self._streak: tuple[str, int] = ("dry", 0)
|
|
454
|
+
self._selected_entry: tuple[str, float] | None = None
|
|
455
|
+
self._chart_mode: ChartMode = ChartMode.rainfall
|
|
456
|
+
self._group: GraphGrouping = GraphGrouping.daily
|
|
457
|
+
|
|
458
|
+
def update_stats( # noqa: PLR0913
|
|
459
|
+
self,
|
|
460
|
+
period_total: float,
|
|
461
|
+
daily_average: float,
|
|
462
|
+
streak: tuple[str, int],
|
|
463
|
+
selected_entry: tuple[str, float] | None = None,
|
|
464
|
+
chart_mode: ChartMode = ChartMode.rainfall,
|
|
465
|
+
group: GraphGrouping = GraphGrouping.daily,
|
|
466
|
+
) -> None:
|
|
467
|
+
"""Replace all stats and trigger a repaint."""
|
|
468
|
+
self._period_total = period_total
|
|
469
|
+
self._daily_average = daily_average
|
|
470
|
+
self._streak = streak
|
|
471
|
+
self._selected_entry = selected_entry
|
|
472
|
+
self._chart_mode = chart_mode
|
|
473
|
+
self._group = group
|
|
474
|
+
self.refresh()
|
|
475
|
+
|
|
476
|
+
def render(self) -> RenderableType:
|
|
477
|
+
"""Render mode/grouping header then mode-appropriate stats."""
|
|
478
|
+
streak_type, streak_count = self._streak
|
|
479
|
+
result = Text()
|
|
480
|
+
|
|
481
|
+
mode_label = "Moisture" if self._chart_mode == ChartMode.moisture else "Rain"
|
|
482
|
+
group_label = self._group.value.capitalize()
|
|
483
|
+
result.append(f"Mode: {mode_label}\n", style="dim")
|
|
484
|
+
result.append(f"Group: {group_label}\n\n", style="dim")
|
|
485
|
+
|
|
486
|
+
if self._selected_entry is not None:
|
|
487
|
+
label, amount = self._selected_entry
|
|
488
|
+
result.append("Selected\n", style="bold")
|
|
489
|
+
result.append(f" {label} {amount:.1f} mm\n\n")
|
|
490
|
+
|
|
491
|
+
if self._chart_mode == ChartMode.moisture:
|
|
492
|
+
result.append(
|
|
493
|
+
f"Current index\n"
|
|
494
|
+
f" {self._period_total:.1f} mm\n\n"
|
|
495
|
+
f"Period average\n"
|
|
496
|
+
f" {self._daily_average:.1f} mm\n\n"
|
|
497
|
+
f"Streak\n"
|
|
498
|
+
f" {streak_count} {streak_type} days"
|
|
499
|
+
)
|
|
500
|
+
else:
|
|
501
|
+
result.append(
|
|
502
|
+
f"Period total\n"
|
|
503
|
+
f" {self._period_total:.1f} mm\n\n"
|
|
504
|
+
f"Daily average\n"
|
|
505
|
+
f" {self._daily_average:.1f} mm\n\n"
|
|
506
|
+
f"Streak\n"
|
|
507
|
+
f" {streak_count} {streak_type} days"
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
return result
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
class RainTuiApp(App[None]):
|
|
514
|
+
"""Interactive TUI for browsing rain history."""
|
|
515
|
+
|
|
516
|
+
BINDINGS: ClassVar[list[Binding]] = [
|
|
517
|
+
Binding("left", "scroll_back", "Scroll back"),
|
|
518
|
+
Binding("right", "scroll_forward", "Scroll fwd"),
|
|
519
|
+
Binding("g", "cycle_group", "Cycle group"),
|
|
520
|
+
Binding("+", "increase_size", "More bars"),
|
|
521
|
+
Binding("-", "decrease_size", "Fewer bars"),
|
|
522
|
+
Binding("a", "reset_auto_bars", "Auto bars"),
|
|
523
|
+
Binding("n", "open_add_modal", "Add record"),
|
|
524
|
+
Binding("e", "open_edit_modal", "Edit record"),
|
|
525
|
+
Binding("s", "toggle_select_mode", "Select bar"),
|
|
526
|
+
Binding("m", "toggle_chart_mode", "Toggle moisture"),
|
|
527
|
+
Binding("escape", "exit_select_mode", "Exit select"),
|
|
528
|
+
Binding("q", "quit", "Quit"),
|
|
529
|
+
]
|
|
530
|
+
|
|
531
|
+
def __init__(self, database: Database) -> None:
|
|
532
|
+
"""Initialise app with a database connection."""
|
|
533
|
+
super().__init__()
|
|
534
|
+
self._database = database
|
|
535
|
+
self._group = GraphGrouping.daily
|
|
536
|
+
self._bar_mode: str = "auto"
|
|
537
|
+
self._manual_bar_count: int = 30
|
|
538
|
+
self._offset = 0
|
|
539
|
+
self._select_mode: bool = False
|
|
540
|
+
self._selected_index: int | None = None
|
|
541
|
+
self._chart_mode: ChartMode = ChartMode.rainfall
|
|
542
|
+
|
|
543
|
+
@property
|
|
544
|
+
def _bar_count(self) -> int:
|
|
545
|
+
"""Current bar count: auto-computed from chart width, or stored manual value."""
|
|
546
|
+
if self._bar_mode == "auto":
|
|
547
|
+
chart_width = self.query_one(BarChartWidget).size.width
|
|
548
|
+
return _compute_auto_bar_count(chart_width)
|
|
549
|
+
return self._manual_bar_count
|
|
550
|
+
|
|
551
|
+
def compose(self) -> ComposeResult:
|
|
552
|
+
"""Build the two-column layout."""
|
|
553
|
+
with Horizontal():
|
|
554
|
+
yield BarChartWidget()
|
|
555
|
+
yield StatsPanel()
|
|
556
|
+
yield Footer()
|
|
557
|
+
|
|
558
|
+
def on_mount(self) -> None:
|
|
559
|
+
"""Load initial data after the UI is ready."""
|
|
560
|
+
self.call_after_refresh(self._refresh_data)
|
|
561
|
+
|
|
562
|
+
def on_resize(self) -> None:
|
|
563
|
+
"""Recompute bar count on terminal resize when in auto mode."""
|
|
564
|
+
self.call_after_refresh(self._refresh_data)
|
|
565
|
+
|
|
566
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: # noqa: ARG002
|
|
567
|
+
"""Conditionally disable/hide bindings based on app state."""
|
|
568
|
+
if action == "toggle_select_mode":
|
|
569
|
+
return self._group == GraphGrouping.daily and self._chart_mode == ChartMode.rainfall
|
|
570
|
+
if action == "exit_select_mode":
|
|
571
|
+
return self._select_mode
|
|
572
|
+
return True
|
|
573
|
+
|
|
574
|
+
def _apply_auto_bar_count(self) -> None:
|
|
575
|
+
"""Sync manual bar count cache with auto-computed width; no-op in manual mode."""
|
|
576
|
+
if self._bar_mode == "auto":
|
|
577
|
+
chart_width = self.query_one(BarChartWidget).size.width
|
|
578
|
+
self._manual_bar_count = _compute_auto_bar_count(chart_width)
|
|
579
|
+
|
|
580
|
+
def _merge_tentative_entries(
|
|
581
|
+
self,
|
|
582
|
+
data: list[tuple[str, float]],
|
|
583
|
+
) -> tuple[list[tuple[str, float]], set[str]]:
|
|
584
|
+
"""Prepend synthetic entries for any gap since the last DB record.
|
|
585
|
+
|
|
586
|
+
In rainfall mode, synthetic entries carry 0.0 rain. In moisture mode, synthetic
|
|
587
|
+
entries show the decaying moisture index for each day since the last record.
|
|
588
|
+
Returns (merged_data, tentative_labels). Returns (data, empty set) unchanged
|
|
589
|
+
when scrolled past the present, when the DB is empty, or when it is up to date.
|
|
590
|
+
"""
|
|
591
|
+
if self._offset != 0:
|
|
592
|
+
return data, set()
|
|
593
|
+
last_date = self._database.get_most_recent_date()
|
|
594
|
+
if last_date is None:
|
|
595
|
+
return data, set()
|
|
596
|
+
today = datetime.now(tz=LOCAL_TZ)
|
|
597
|
+
if today.date() <= last_date.date():
|
|
598
|
+
return data, set()
|
|
599
|
+
|
|
600
|
+
if self._chart_mode == ChartMode.moisture:
|
|
601
|
+
last_moisture = data[0][1] if data else 0.0
|
|
602
|
+
synthetic = _compute_tentative_moisture_entries(
|
|
603
|
+
last_date=last_date,
|
|
604
|
+
today=today,
|
|
605
|
+
last_moisture=last_moisture,
|
|
606
|
+
group=self._group,
|
|
607
|
+
)
|
|
608
|
+
else:
|
|
609
|
+
synthetic = _compute_tentative_entries(last_date, today, self._group)
|
|
610
|
+
|
|
611
|
+
tentative_labels = {label for label, _ in synthetic}
|
|
612
|
+
real_labels = {label for label, _ in data}
|
|
613
|
+
entries_to_prepend = [(label, value) for label, value in synthetic if label not in real_labels]
|
|
614
|
+
return (entries_to_prepend + data)[: self._bar_count], tentative_labels
|
|
615
|
+
|
|
616
|
+
def _decay_current_index_to_today(
|
|
617
|
+
self,
|
|
618
|
+
current_index: float,
|
|
619
|
+
data: list[tuple[str, float]],
|
|
620
|
+
tentative_labels: set[str],
|
|
621
|
+
decay: float = 0.85,
|
|
622
|
+
) -> float:
|
|
623
|
+
"""Decay current_index to today when the most-recent bar is a real (non-tentative) entry.
|
|
624
|
+
|
|
625
|
+
When viewing weekly/monthly/yearly groupings and today falls in the same period as
|
|
626
|
+
the last DB record, _merge_tentative_entries adds no tentative entries. The bar shows
|
|
627
|
+
the end-of-period moisture value, which may be several days stale. This method applies
|
|
628
|
+
the remaining decay so the stats panel "Current index" always reflects today's estimate.
|
|
629
|
+
Only active when offset is 0 (viewing the present window).
|
|
630
|
+
"""
|
|
631
|
+
if not data or data[0][0] in tentative_labels or self._offset != 0:
|
|
632
|
+
return current_index
|
|
633
|
+
last_date = self._database.get_most_recent_date()
|
|
634
|
+
if last_date is None:
|
|
635
|
+
return current_index
|
|
636
|
+
today = datetime.now(tz=LOCAL_TZ)
|
|
637
|
+
elapsed_days = (today.date() - last_date.date()).days
|
|
638
|
+
if elapsed_days > 0:
|
|
639
|
+
current_index *= decay**elapsed_days
|
|
640
|
+
return current_index
|
|
641
|
+
|
|
642
|
+
def _refresh_data(self) -> None:
|
|
643
|
+
"""Re-query the DB and push results to both panels."""
|
|
644
|
+
streak = self._database.get_current_streak()
|
|
645
|
+
|
|
646
|
+
if self._chart_mode == ChartMode.moisture:
|
|
647
|
+
data = self._database.get_moisture_index(
|
|
648
|
+
history_size=self._bar_count,
|
|
649
|
+
group=self._group,
|
|
650
|
+
offset=self._offset,
|
|
651
|
+
)
|
|
652
|
+
data, tentative_labels = self._merge_tentative_entries(data)
|
|
653
|
+
current_index = data[0][1] if data else 0.0
|
|
654
|
+
current_index = self._decay_current_index_to_today(current_index, data, tentative_labels)
|
|
655
|
+
period_average = sum(moisture for _, moisture in data) / len(data) if data else 0.0
|
|
656
|
+
self.query_one(BarChartWidget).set_data(data, tentative_labels, self._selected_index)
|
|
657
|
+
self.query_one(StatsPanel).update_stats(
|
|
658
|
+
period_total=current_index,
|
|
659
|
+
daily_average=period_average,
|
|
660
|
+
streak=streak,
|
|
661
|
+
selected_entry=None,
|
|
662
|
+
chart_mode=self._chart_mode,
|
|
663
|
+
group=self._group,
|
|
664
|
+
)
|
|
665
|
+
else:
|
|
666
|
+
data = self._database.get_rain(
|
|
667
|
+
history_size=self._bar_count,
|
|
668
|
+
group=self._group,
|
|
669
|
+
offset=self._offset,
|
|
670
|
+
)
|
|
671
|
+
data, tentative_labels = self._merge_tentative_entries(data)
|
|
672
|
+
period_total = sum(rain for _, rain in data)
|
|
673
|
+
total_days = len(data) * DAYS_PER_GROUP[self._group]
|
|
674
|
+
daily_average = period_total / total_days if total_days > 0 else 0.0
|
|
675
|
+
selected_entry: tuple[str, float] | None = None
|
|
676
|
+
if self._selected_index is not None and self._selected_index < len(data):
|
|
677
|
+
selected_entry = data[self._selected_index]
|
|
678
|
+
self.query_one(BarChartWidget).set_data(data, tentative_labels, self._selected_index)
|
|
679
|
+
self.query_one(StatsPanel).update_stats(
|
|
680
|
+
period_total=period_total,
|
|
681
|
+
daily_average=daily_average,
|
|
682
|
+
streak=streak,
|
|
683
|
+
selected_entry=selected_entry,
|
|
684
|
+
chart_mode=self._chart_mode,
|
|
685
|
+
group=self._group,
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
def action_scroll_back(self) -> None:
|
|
689
|
+
"""In select mode: move cursor left (newer bar). Otherwise: scroll history back."""
|
|
690
|
+
if self._select_mode:
|
|
691
|
+
if self._selected_index is not None:
|
|
692
|
+
self._selected_index = max(0, self._selected_index - 1)
|
|
693
|
+
self._refresh_data()
|
|
694
|
+
return
|
|
695
|
+
self._offset += 1
|
|
696
|
+
self._refresh_data()
|
|
697
|
+
|
|
698
|
+
def action_scroll_forward(self) -> None:
|
|
699
|
+
"""In select mode: move cursor right (older bar). Otherwise: scroll history forward."""
|
|
700
|
+
if self._select_mode:
|
|
701
|
+
if self._selected_index is not None:
|
|
702
|
+
bar_count = len(self.query_one(BarChartWidget)._data)
|
|
703
|
+
self._selected_index = min(bar_count - 1, self._selected_index + 1)
|
|
704
|
+
self._refresh_data()
|
|
705
|
+
return
|
|
706
|
+
if self._offset > 0:
|
|
707
|
+
self._offset -= 1
|
|
708
|
+
self._refresh_data()
|
|
709
|
+
|
|
710
|
+
def action_cycle_group(self) -> None:
|
|
711
|
+
"""Cycle grouping; exit select mode if active."""
|
|
712
|
+
self._select_mode = False
|
|
713
|
+
self._selected_index = None
|
|
714
|
+
current_index = GROUPING_CYCLE.index(self._group)
|
|
715
|
+
self._group = GROUPING_CYCLE[(current_index + 1) % len(GROUPING_CYCLE)]
|
|
716
|
+
self._offset = 0
|
|
717
|
+
self._refresh_data()
|
|
718
|
+
|
|
719
|
+
def action_toggle_select_mode(self) -> None:
|
|
720
|
+
"""Enter or exit bar-selection mode (daily grouping only)."""
|
|
721
|
+
self._select_mode = not self._select_mode
|
|
722
|
+
self._selected_index = 0 if self._select_mode else None
|
|
723
|
+
self._refresh_data()
|
|
724
|
+
|
|
725
|
+
def action_exit_select_mode(self) -> None:
|
|
726
|
+
"""Exit select mode and clear the cursor."""
|
|
727
|
+
self._select_mode = False
|
|
728
|
+
self._selected_index = None
|
|
729
|
+
self._refresh_data()
|
|
730
|
+
|
|
731
|
+
def action_toggle_chart_mode(self) -> None:
|
|
732
|
+
"""Toggle between rainfall amounts and soil moisture index views."""
|
|
733
|
+
chart_mode_cycle = [ChartMode.rainfall, ChartMode.moisture]
|
|
734
|
+
current_index = chart_mode_cycle.index(self._chart_mode)
|
|
735
|
+
self._chart_mode = chart_mode_cycle[(current_index + 1) % len(chart_mode_cycle)]
|
|
736
|
+
if self._chart_mode == ChartMode.moisture:
|
|
737
|
+
self._select_mode = False
|
|
738
|
+
self._selected_index = None
|
|
739
|
+
self._refresh_data()
|
|
740
|
+
|
|
741
|
+
def action_increase_size(self) -> None:
|
|
742
|
+
"""Switch to manual mode and add one bar (max 365)."""
|
|
743
|
+
current_count = self._bar_count
|
|
744
|
+
self._bar_mode = "manual"
|
|
745
|
+
self._manual_bar_count = min(365, current_count + 1)
|
|
746
|
+
self._refresh_data()
|
|
747
|
+
|
|
748
|
+
def action_decrease_size(self) -> None:
|
|
749
|
+
"""Switch to manual mode and remove one bar (min 7)."""
|
|
750
|
+
current_count = self._bar_count
|
|
751
|
+
self._bar_mode = "manual"
|
|
752
|
+
self._manual_bar_count = max(7, current_count - 1)
|
|
753
|
+
self._refresh_data()
|
|
754
|
+
|
|
755
|
+
def action_reset_auto_bars(self) -> None:
|
|
756
|
+
"""Switch back to auto bar count and recalculate."""
|
|
757
|
+
self._bar_mode = "auto"
|
|
758
|
+
self._apply_auto_bar_count()
|
|
759
|
+
self._refresh_data()
|
|
760
|
+
|
|
761
|
+
def action_open_add_modal(self) -> None:
|
|
762
|
+
"""Open the add-rain modal."""
|
|
763
|
+
self.push_screen(AddRainModal(), self._handle_add_rain_result)
|
|
764
|
+
|
|
765
|
+
def action_open_edit_modal(self) -> None:
|
|
766
|
+
"""Open edit modal; pre-populate from selected bar if in select mode."""
|
|
767
|
+
prefill_date = ""
|
|
768
|
+
prefill_amount = ""
|
|
769
|
+
if self._select_mode and self._selected_index is not None:
|
|
770
|
+
data = self.query_one(BarChartWidget)._data
|
|
771
|
+
if self._selected_index < len(data):
|
|
772
|
+
label, amount = data[self._selected_index]
|
|
773
|
+
prefill_date = label # label is YYYY-MM-DD in daily grouping
|
|
774
|
+
prefill_amount = f"{amount:.1f}"
|
|
775
|
+
self.push_screen(
|
|
776
|
+
EditRainModal(prefill_date=prefill_date, prefill_amount=prefill_amount),
|
|
777
|
+
self._handle_edit_rain_result,
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
def _handle_edit_rain_result(self, result: EditRainResult | None) -> None:
|
|
781
|
+
"""Update the DB record and refresh."""
|
|
782
|
+
if result is None:
|
|
783
|
+
return
|
|
784
|
+
rain_period_end = result.date.replace(hour=9, minute=0, second=0, microsecond=0)
|
|
785
|
+
self._database.update_rain_record(date=rain_period_end, amount=result.amount)
|
|
786
|
+
self._refresh_data()
|
|
787
|
+
|
|
788
|
+
def _handle_add_rain_result(self, result: AddRainResult | None) -> None:
|
|
789
|
+
"""Write the new record to the DB and refresh."""
|
|
790
|
+
if result is None:
|
|
791
|
+
return
|
|
792
|
+
rain_period_end = result.date.replace(hour=9, minute=0, second=0, microsecond=0)
|
|
793
|
+
earliest_before_insert = self._database.get_earliest_date()
|
|
794
|
+
try:
|
|
795
|
+
self._database.add_rain_record(date=rain_period_end, amount=result.amount)
|
|
796
|
+
except sqlite3.IntegrityError:
|
|
797
|
+
self.notify(
|
|
798
|
+
f"A record for {rain_period_end.strftime('%Y-%m-%d')} already exists — use edit (e) to update it.",
|
|
799
|
+
severity="error",
|
|
800
|
+
)
|
|
801
|
+
return
|
|
802
|
+
if result.backfill and earliest_before_insert is not None:
|
|
803
|
+
back_fill_date = rain_period_end - timedelta(days=1)
|
|
804
|
+
while not isinstance(self._database.get_single_day_rain(date=back_fill_date), float):
|
|
805
|
+
if back_fill_date < earliest_before_insert:
|
|
806
|
+
break
|
|
807
|
+
self._database.add_rain_record(date=back_fill_date, amount=0.0)
|
|
808
|
+
back_fill_date = back_fill_date - timedelta(days=1)
|
|
809
|
+
self._refresh_data()
|