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 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
+ [![PyPI version](https://img.shields.io/pypi/v/rainlog.svg)](https://pypi.org/project/rainlog/)
23
+ [![Python versions](https://img.shields.io/pypi/pyversions/rainlog.svg)](https://pypi.org/project/rainlog/)
24
+ [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
25
+ [![CI](https://ci.codeberg.org/api/badges/marvin8/rainlog/status.svg)](https://ci.codeberg.org/marvin8/rainlog)
26
+ [![Docs](https://img.shields.io/badge/docs-rainlog.marvin8.zone-teal)](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)
@@ -0,0 +1,65 @@
1
+ # rainlog
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/rainlog.svg)](https://pypi.org/project/rainlog/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/rainlog.svg)](https://pypi.org/project/rainlog/)
5
+ [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
6
+ [![CI](https://ci.codeberg.org/api/badges/marvin8/rainlog/status.svg)](https://ci.codeberg.org/marvin8/rainlog)
7
+ [![Docs](https://img.shields.io/badge/docs-rainlog.marvin8.zone-teal)](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()