Record planting day and show harvest dates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-06 10:07:21 +01:00
parent 07057e6c3d
commit 83a9cb6dcc
11 changed files with 282 additions and 44 deletions

93
app.py
View file

@ -21,12 +21,25 @@ APP_DIR = Path(__file__).resolve().parent
STATIC_DIR = APP_DIR / "static"
ITEM_ID = re.compile(r"^[a-z0-9][a-z0-9-]{0,79}$")
MAX_BODY = 2048
SEASONS = ("spring", "summer", "fall", "winter")
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def valid_game_date(value: object) -> bool:
def whole(number: object, low: int, high: int) -> bool:
return isinstance(number, int) and not isinstance(number, bool) and low <= number <= high
return (
isinstance(value, dict)
and whole(value.get("year"), 1, 2)
and value.get("season") in SEASONS
and whole(value.get("day"), 1, 28)
)
class Store:
def __init__(self, path: str | Path):
self.path = str(path)
@ -53,8 +66,32 @@ class Store:
value TEXT NOT NULL
);
INSERT OR IGNORE INTO metadata (key, value) VALUES ('revision', '0');
-- A NULL date means the crop was planted before dates were recorded.
CREATE TABLE IF NOT EXISTS plantings (
item_id TEXT PRIMARY KEY,
year INTEGER,
season TEXT,
day INTEGER,
updated_at TEXT NOT NULL,
updated_by TEXT NOT NULL
);
"""
)
# Version 1.4 stored planting as an undated "planted-<crop>" check.
db.execute(
"""
INSERT OR IGNORE INTO plantings (item_id, updated_at, updated_by)
SELECT substr(item_id, 9), updated_at, updated_by FROM checks
WHERE item_id LIKE 'planted-%' AND checked = 1
"""
)
if db.execute("DELETE FROM checks WHERE item_id LIKE 'planted-%'").rowcount:
self._bump_revision(db)
@staticmethod
def _bump_revision(db: sqlite3.Connection) -> int:
db.execute("UPDATE metadata SET value = CAST(value AS INTEGER) + 1 WHERE key = 'revision'")
return int(db.execute("SELECT value FROM metadata WHERE key = 'revision'").fetchone()[0])
def snapshot(self) -> dict:
with closing(self.connect()) as db, db:
@ -67,11 +104,47 @@ class Store:
latest_row = db.execute(
"SELECT updated_at, updated_by FROM checks ORDER BY updated_at DESC, rowid DESC LIMIT 1"
).fetchone()
plantings = db.execute(
"SELECT item_id, year, season, day FROM plantings ORDER BY item_id"
).fetchall()
return {
"revision": revision,
"updatedAt": latest_row["updated_at"] if latest_row else None,
"updatedBy": latest_row["updated_by"] if latest_row else None,
"checks": {row["item_id"]: bool(row["checked"]) for row in rows},
"plantings": {
row["item_id"]: {"year": row["year"], "season": row["season"], "day": row["day"]}
for row in plantings
},
}
def set_planting(self, item_id: str, planted: dict | None, user: str) -> dict:
changed_at = utc_now()
with closing(self.connect()) as db, db:
db.execute("BEGIN IMMEDIATE")
if planted is None:
db.execute("DELETE FROM plantings WHERE item_id = ?", (item_id,))
else:
db.execute(
"""
INSERT INTO plantings (item_id, year, season, day, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(item_id) DO UPDATE SET
year = excluded.year,
season = excluded.season,
day = excluded.day,
updated_at = excluded.updated_at,
updated_by = excluded.updated_by
""",
(item_id, planted["year"], planted["season"], planted["day"], changed_at, user),
)
revision = self._bump_revision(db)
return {
"itemId": item_id,
"planted": planted,
"revision": revision,
"updatedAt": changed_at,
"updatedBy": user,
}
def set_check(self, item_id: str, checked: bool, user: str) -> dict:
@ -89,12 +162,7 @@ class Store:
""",
(item_id, int(checked), changed_at, user),
)
db.execute(
"UPDATE metadata SET value = CAST(value AS INTEGER) + 1 WHERE key = 'revision'"
)
revision = int(
db.execute("SELECT value FROM metadata WHERE key = 'revision'").fetchone()[0]
)
revision = self._bump_revision(db)
return {
"itemId": item_id,
"checked": checked,
@ -153,7 +221,7 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self) -> None:
path = urlparse(self.path).path
if path != "/api/checks":
if path not in ("/api/checks", "/api/plantings"):
self.error_json("Not found", HTTPStatus.NOT_FOUND)
return
if self.headers.get_content_type() != "application/json":
@ -173,10 +241,19 @@ class Handler(BaseHTTPRequestHandler):
self.error_json("Invalid JSON", HTTPStatus.BAD_REQUEST)
return
item_id = payload.get("itemId") if isinstance(payload, dict) else None
checked = payload.get("checked") if isinstance(payload, dict) else None
if not isinstance(item_id, str) or not ITEM_ID.fullmatch(item_id):
self.error_json("Invalid itemId", HTTPStatus.BAD_REQUEST)
return
if path == "/api/plantings":
planted = payload.get("planted")
if planted is not None and not valid_game_date(planted):
self.error_json("planted must be null or a {year, season, day} date", HTTPStatus.BAD_REQUEST)
return
if planted is not None:
planted = {key: planted[key] for key in ("year", "season", "day")}
self.json_response(self.server.store.set_planting(item_id, planted, self.authenticated_user()))
return
checked = payload.get("checked")
if not isinstance(checked, bool):
self.error_json("checked must be boolean", HTTPStatus.BAD_REQUEST)
return