From abf78ec98aef66749d040ebc82b0f63217ebb6e0 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Thu, 1 Jan 2026 10:40:01 +0000 Subject: [PATCH] feat: add event backdating with collapsible datetime picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ability to specify custom date/time when recording events, enabling historical data entry. Forms show "Now - Set custom date" with a collapsible datetime picker that converts to milliseconds. - Add event_datetime_field() component in templates/actions.py - Add datetime picker to all event forms (cohort, hatch, outcome, tag, attrs, move, feed) - Add _parse_ts_utc() helper to parse form timestamp or use current - Add tests for backdating functionality 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/animaltrack/web/routes/actions.py | 82 +++++++------- src/animaltrack/web/routes/feed.py | 28 ++++- src/animaltrack/web/routes/move.py | 30 ++++-- src/animaltrack/web/templates/actions.py | 113 +++++++++++++++++-- src/animaltrack/web/templates/feed.py | 5 + src/animaltrack/web/templates/move.py | 4 +- tests/test_web_actions.py | 131 +++++++++++++++++++++++ 7 files changed, 328 insertions(+), 65 deletions(-) diff --git a/src/animaltrack/web/routes/actions.py b/src/animaltrack/web/routes/actions.py index 8e5d6d8..ea6c72e 100644 --- a/src/animaltrack/web/routes/actions.py +++ b/src/animaltrack/web/routes/actions.py @@ -55,6 +55,26 @@ from animaltrack.web.templates.actions import ( tag_end_form, ) + +def _parse_ts_utc(form_value: str | None) -> int: + """Parse ts_utc from form, defaulting to current time if empty or zero. + + Args: + form_value: The ts_utc value from form data. + + Returns: + Timestamp in milliseconds. Returns current time if form_value is + None, empty, or "0". + """ + if not form_value or form_value == "0": + return int(time.time() * 1000) + try: + ts = int(form_value) + return ts if ts > 0 else int(time.time() * 1000) + except (ValueError, TypeError): + return int(time.time() * 1000) + + # APIRouter for multi-file route organization ar = APIRouter() @@ -153,8 +173,8 @@ async def animal_cohort(request: Request): auth = request.scope.get("auth") actor = auth.username if auth else "unknown" - # Create cohort - ts_utc = int(time.time() * 1000) + # Create cohort - use form ts_utc if provided for backdating + ts_utc = _parse_ts_utc(form.get("ts_utc")) service = _create_animal_service(db) try: @@ -289,8 +309,8 @@ async def hatch_recorded(request: Request): auth = request.scope.get("auth") actor = auth.username if auth else "unknown" - # Record hatch - ts_utc = int(time.time() * 1000) + # Record hatch - use form ts_utc if provided for backdating + ts_utc = _parse_ts_utc(form.get("ts_utc")) service = _create_animal_service(db) try: @@ -433,8 +453,8 @@ async def animal_promote(request: Request): auth = request.scope.get("auth") actor = auth.username if auth else "unknown" - # Promote animal - ts_utc = int(time.time() * 1000) + # Promote animal - use form ts_utc if provided for backdating + ts_utc = _parse_ts_utc(form.get("ts_utc")) service = _create_animal_service(db) try: @@ -530,14 +550,8 @@ async def animal_tag_add(request: Request): confirmed = form.get("confirmed", "") == "true" nonce = form.get("nonce") - # Get timestamp - use provided or current - ts_utc_str = form.get("ts_utc", "0") - try: - ts_utc = int(ts_utc_str) - if ts_utc == 0: - ts_utc = int(time.time() * 1000) - except ValueError: - ts_utc = int(time.time() * 1000) + # Get timestamp - use provided or current (supports backdating) + ts_utc = _parse_ts_utc(form.get("ts_utc")) # resolved_ids can be multiple values resolved_ids = form.getlist("resolved_ids") @@ -761,14 +775,8 @@ async def animal_tag_end(request: Request): confirmed = form.get("confirmed", "") == "true" nonce = form.get("nonce") - # Get timestamp - use provided or current - ts_utc_str = form.get("ts_utc", "0") - try: - ts_utc = int(ts_utc_str) - if ts_utc == 0: - ts_utc = int(time.time() * 1000) - except ValueError: - ts_utc = int(time.time() * 1000) + # Get timestamp - use provided or current (supports backdating) + ts_utc = _parse_ts_utc(form.get("ts_utc")) # resolved_ids can be multiple values resolved_ids = form.getlist("resolved_ids") @@ -966,14 +974,8 @@ async def animal_attrs(request: Request): confirmed = form.get("confirmed", "") == "true" nonce = form.get("nonce") - # Get timestamp - use provided or current - ts_utc_str = form.get("ts_utc", "0") - try: - ts_utc = int(ts_utc_str) - if ts_utc == 0: - ts_utc = int(time.time() * 1000) - except ValueError: - ts_utc = int(time.time() * 1000) + # Get timestamp - use provided or current (supports backdating) + ts_utc = _parse_ts_utc(form.get("ts_utc")) # resolved_ids can be multiple values resolved_ids = form.getlist("resolved_ids") @@ -1203,14 +1205,8 @@ async def animal_outcome(request: Request): except ValueError: pass - # Get timestamp - use provided or current - ts_utc_str = form.get("ts_utc", "0") - try: - ts_utc = int(ts_utc_str) - if ts_utc == 0: - ts_utc = int(time.time() * 1000) - except ValueError: - ts_utc = int(time.time() * 1000) + # Get timestamp - use provided or current (supports backdating) + ts_utc = _parse_ts_utc(form.get("ts_utc")) # resolved_ids can be multiple values resolved_ids = form.getlist("resolved_ids") @@ -1452,14 +1448,8 @@ async def animal_status_correct(req: Request): confirmed = form.get("confirmed", "") == "true" nonce = form.get("nonce") - # Get timestamp - use provided or current - ts_utc_str = form.get("ts_utc", "0") - try: - ts_utc = int(ts_utc_str) - if ts_utc == 0: - ts_utc = int(time.time() * 1000) - except ValueError: - ts_utc = int(time.time() * 1000) + # Get timestamp - use provided or current (supports backdating) + ts_utc = _parse_ts_utc(form.get("ts_utc")) # resolved_ids can be multiple values resolved_ids = form.getlist("resolved_ids") diff --git a/src/animaltrack/web/routes/feed.py b/src/animaltrack/web/routes/feed.py index 1e20462..73863dd 100644 --- a/src/animaltrack/web/routes/feed.py +++ b/src/animaltrack/web/routes/feed.py @@ -24,6 +24,26 @@ from animaltrack.services.feed import FeedService, ValidationError from animaltrack.web.templates import page from animaltrack.web.templates.feed import feed_page + +def _parse_ts_utc(form_value: str | None) -> int: + """Parse ts_utc from form, defaulting to current time if empty or zero. + + Args: + form_value: The ts_utc value from form data. + + Returns: + Timestamp in milliseconds. Returns current time if form_value is + None, empty, or "0". + """ + if not form_value or form_value == "0": + return int(time.time() * 1000) + try: + ts = int(form_value) + return ts if ts > 0 else int(time.time() * 1000) + except (ValueError, TypeError): + return int(time.time() * 1000) + + # APIRouter for multi-file route organization ar = APIRouter() @@ -152,8 +172,8 @@ async def feed_given(request: Request): selected_feed_type_code=feed_type_code, ) - # Get current timestamp - ts_utc = int(time.time() * 1000) + # Get timestamp - use provided or current (supports backdating) + ts_utc = _parse_ts_utc(form.get("ts_utc")) # Create feed service event_store = EventStore(db) @@ -324,8 +344,8 @@ async def feed_purchased(request: Request): "Price cannot be negative", ) - # Get current timestamp - ts_utc = int(time.time() * 1000) + # Get timestamp - use provided or current (supports backdating) + ts_utc = _parse_ts_utc(form.get("ts_utc")) # Create feed service event_store = EventStore(db) diff --git a/src/animaltrack/web/routes/move.py b/src/animaltrack/web/routes/move.py index 406c56f..315f6d9 100644 --- a/src/animaltrack/web/routes/move.py +++ b/src/animaltrack/web/routes/move.py @@ -24,6 +24,26 @@ from animaltrack.services.animal import AnimalService, ValidationError from animaltrack.web.templates import page from animaltrack.web.templates.move import diff_panel, move_form + +def _parse_ts_utc(form_value: str | None) -> int: + """Parse ts_utc from form, defaulting to current time if empty or zero. + + Args: + form_value: The ts_utc value from form data. + + Returns: + Timestamp in milliseconds. Returns current time if form_value is + None, empty, or "0". + """ + if not form_value or form_value == "0": + return int(time.time() * 1000) + try: + ts = int(form_value) + return ts if ts > 0 else int(time.time() * 1000) + except (ValueError, TypeError): + return int(time.time() * 1000) + + # APIRouter for multi-file route organization ar = APIRouter() @@ -122,14 +142,8 @@ async def animal_move(request: Request): confirmed = form.get("confirmed", "") == "true" nonce = form.get("nonce") - # Get timestamp - use provided or current - ts_utc_str = form.get("ts_utc", "0") - try: - ts_utc = int(ts_utc_str) - if ts_utc == 0: - ts_utc = int(time.time() * 1000) - except ValueError: - ts_utc = int(time.time() * 1000) + # Get timestamp - use provided or current (supports backdating) + ts_utc = _parse_ts_utc(form.get("ts_utc")) # resolved_ids can be multiple values resolved_ids = form.getlist("resolved_ids") diff --git a/src/animaltrack/web/templates/actions.py b/src/animaltrack/web/templates/actions.py index dcd552e..9e65b0a 100644 --- a/src/animaltrack/web/templates/actions.py +++ b/src/animaltrack/web/templates/actions.py @@ -4,12 +4,13 @@ from collections.abc import Callable from typing import Any -from fasthtml.common import H2, H3, Div, Form, Hidden, Option, P, Span +from fasthtml.common import H2, H3, Div, Form, Hidden, Input, Option, P, Script, Span from monsterui.all import ( Alert, AlertT, Button, ButtonT, + FormLabel, LabelInput, LabelSelect, LabelTextArea, @@ -20,6 +21,97 @@ from animaltrack.models.animals import Animal from animaltrack.models.reference import Location, Species from animaltrack.selection.validation import SelectionDiff +# ============================================================================= +# Event Datetime Picker Component +# ============================================================================= + + +def event_datetime_field(field_id: str = "event_datetime") -> Div: + """Create a collapsible datetime picker for backdating events. + + When collapsed, shows "Now" and the event will use the current time. + When expanded, allows user to pick a specific date/time which is + converted to milliseconds and stored in the hidden ts_utc field. + + Args: + field_id: Unique ID prefix for the datetime field elements. + + Returns: + Div containing the datetime picker with toggle functionality. + """ + toggle_id = f"{field_id}_toggle" + picker_id = f"{field_id}_picker" + input_id = f"{field_id}_input" + + # JavaScript for toggle and conversion + script = f""" + (function() {{ + var toggle = document.getElementById('{toggle_id}'); + var picker = document.getElementById('{picker_id}'); + var input = document.getElementById('{input_id}'); + var tsField = document.querySelector('input[name="ts_utc"]'); + + if (!toggle || !picker || !input) return; + + toggle.addEventListener('click', function(e) {{ + e.preventDefault(); + if (picker.style.display === 'none') {{ + picker.style.display = 'block'; + toggle.textContent = 'Use current time'; + }} else {{ + picker.style.display = 'none'; + toggle.textContent = 'Set custom date'; + input.value = ''; + if (tsField) tsField.value = '0'; + }} + }}); + + input.addEventListener('change', function() {{ + if (tsField && input.value) {{ + var date = new Date(input.value); + tsField.value = date.getTime().toString(); + }} else if (tsField) {{ + tsField.value = '0'; + }} + }}); + }})(); + """ + + return Div( + FormLabel("Event Time"), + Div( + P( + Span("Now", cls="text-stone-400"), + " - ", + Span( + "Set custom date", + id=toggle_id, + cls="text-blue-400 hover:text-blue-300 cursor-pointer underline", + ), + cls="text-sm", + ), + Div( + Input( + id=input_id, + name=f"{field_id}_value", + type="datetime-local", + cls="uk-input w-full mt-2", + ), + P( + "Select date/time for this event (leave empty for current time)", + cls="text-xs text-stone-500 mt-1", + ), + id=picker_id, + style="display: none;", + ), + cls="mt-1", + ), + Hidden(name="ts_utc", value="0"), + Script(script), + cls="space-y-1", + ) + + # ============================================================================= # Cohort Creation Form # ============================================================================= @@ -160,6 +252,8 @@ def cohort_form( id="origin", name="origin", ), + # Event datetime picker (for backdating) + event_datetime_field("cohort_datetime"), # Hidden nonce for idempotency Hidden(name="nonce", value=str(ULID())), # Submit button @@ -276,6 +370,8 @@ def hatch_form( cls="text-xs text-stone-400 mt-1", ), ), + # Event datetime picker (for backdating) + event_datetime_field("hatch_datetime"), # Hidden nonce for idempotency Hidden(name="nonce", value=str(ULID())), # Submit button @@ -477,10 +573,11 @@ def tag_add_form( name="tag", placeholder="Enter tag name", ), + # Event datetime picker (for backdating) + event_datetime_field("tag_add_datetime"), # Hidden fields for selection context *resolved_id_fields, Hidden(name="roster_hash", value=roster_hash), - Hidden(name="ts_utc", value=str(ts_utc or 0)), Hidden(name="confirmed", value=""), Hidden(name="nonce", value=str(ULID())), # Submit button @@ -665,10 +762,11 @@ def tag_end_form( P("No active tags on selected animals", cls="text-sm text-stone-400"), cls="p-3 bg-slate-800 rounded-md", ), + # Event datetime picker (for backdating) + event_datetime_field("tag_end_datetime"), # Hidden fields for selection context *resolved_id_fields, Hidden(name="roster_hash", value=roster_hash), - Hidden(name="ts_utc", value=str(ts_utc or 0)), Hidden(name="confirmed", value=""), Hidden(name="nonce", value=str(ULID())), # Submit button @@ -877,10 +975,11 @@ def attrs_form( id="repro_status", name="repro_status", ), + # Event datetime picker (for backdating) + event_datetime_field("attrs_datetime"), # Hidden fields for selection context *resolved_id_fields, Hidden(name="roster_hash", value=roster_hash), - Hidden(name="ts_utc", value=str(ts_utc or 0)), Hidden(name="confirmed", value=""), Hidden(name="nonce", value=str(ULID())), # Submit button @@ -1144,10 +1243,11 @@ def outcome_form( rows=2, placeholder="Any additional notes...", ), + # Event datetime picker (for backdating) + event_datetime_field("outcome_datetime"), # Hidden fields for selection context *resolved_id_fields, Hidden(name="roster_hash", value=roster_hash), - Hidden(name="ts_utc", value=str(ts_utc or 0)), Hidden(name="confirmed", value=""), Hidden(name="nonce", value=str(ULID())), # Submit button @@ -1364,10 +1464,11 @@ def status_correct_form( rows=2, placeholder="Any additional notes...", ), + # Event datetime picker (for backdating) + event_datetime_field("status_correct_datetime"), # Hidden fields for selection context *resolved_id_fields, Hidden(name="roster_hash", value=roster_hash), - Hidden(name="ts_utc", value=str(ts_utc or 0)), Hidden(name="confirmed", value=""), Hidden(name="nonce", value=str(ULID())), # Submit button diff --git a/src/animaltrack/web/templates/feed.py b/src/animaltrack/web/templates/feed.py index ae9084f..e7a0462 100644 --- a/src/animaltrack/web/templates/feed.py +++ b/src/animaltrack/web/templates/feed.py @@ -16,6 +16,7 @@ from monsterui.all import ( from ulid import ULID from animaltrack.models.reference import FeedType, Location +from animaltrack.web.templates.actions import event_datetime_field def feed_page( @@ -202,6 +203,8 @@ def give_feed_form( name="notes", placeholder="Optional notes", ), + # Event datetime picker (for backdating) + event_datetime_field("feed_given_datetime"), # Hidden nonce Hidden(name="nonce", value=str(ULID())), # Submit button @@ -298,6 +301,8 @@ def purchase_feed_form( name="notes", placeholder="Optional notes", ), + # Event datetime picker (for backdating) + event_datetime_field("feed_purchase_datetime"), # Hidden nonce Hidden(name="nonce", value=str(ULID())), # Submit button diff --git a/src/animaltrack/web/templates/move.py b/src/animaltrack/web/templates/move.py index 608291b..d304ed5 100644 --- a/src/animaltrack/web/templates/move.py +++ b/src/animaltrack/web/templates/move.py @@ -10,6 +10,7 @@ from ulid import ULID from animaltrack.models.reference import Location from animaltrack.selection.validation import SelectionDiff +from animaltrack.web.templates.actions import event_datetime_field def move_form( @@ -102,11 +103,12 @@ def move_form( id="to_location_id", name="to_location_id", ), + # Event datetime picker (for backdating) + event_datetime_field("move_datetime"), # Hidden fields for selection context *resolved_id_fields, Hidden(name="roster_hash", value=roster_hash), Hidden(name="from_location_id", value=from_location_id or ""), - Hidden(name="ts_utc", value=str(ts_utc or 0)), Hidden(name="resolver_version", value="v1"), Hidden(name="confirmed", value=""), Hidden(name="nonce", value=str(ULID())), diff --git a/tests/test_web_actions.py b/tests/test_web_actions.py index 264716b..e9224ee 100644 --- a/tests/test_web_actions.py +++ b/tests/test_web_actions.py @@ -1552,3 +1552,134 @@ class TestStatusCorrectValidation: ) assert resp.status_code == 403 + + +# ============================================================================= +# Backdating Tests +# ============================================================================= + + +class TestParseTsUtcHelper: + """Tests for the _parse_ts_utc helper function.""" + + def test_parse_ts_utc_returns_current_for_none(self): + """Returns current time when value is None.""" + import time + + from animaltrack.web.routes.actions import _parse_ts_utc + + before = int(time.time() * 1000) + result = _parse_ts_utc(None) + after = int(time.time() * 1000) + + assert before <= result <= after + + def test_parse_ts_utc_returns_current_for_zero(self): + """Returns current time when value is '0'.""" + import time + + from animaltrack.web.routes.actions import _parse_ts_utc + + before = int(time.time() * 1000) + result = _parse_ts_utc("0") + after = int(time.time() * 1000) + + assert before <= result <= after + + def test_parse_ts_utc_returns_current_for_empty(self): + """Returns current time when value is empty string.""" + import time + + from animaltrack.web.routes.actions import _parse_ts_utc + + before = int(time.time() * 1000) + result = _parse_ts_utc("") + after = int(time.time() * 1000) + + assert before <= result <= after + + def test_parse_ts_utc_returns_provided_value(self): + """Returns the provided timestamp when valid.""" + from animaltrack.web.routes.actions import _parse_ts_utc + + # Use a past timestamp + past_ts = 1700000000000 # Some time in 2023 + + result = _parse_ts_utc(str(past_ts)) + + assert result == past_ts + + def test_parse_ts_utc_returns_current_for_invalid(self): + """Returns current time when value is invalid.""" + import time + + from animaltrack.web.routes.actions import _parse_ts_utc + + before = int(time.time() * 1000) + result = _parse_ts_utc("not-a-number") + after = int(time.time() * 1000) + + assert before <= result <= after + + +class TestBackdatingCohort: + """Tests for backdating cohort creation.""" + + def test_cohort_uses_provided_timestamp(self, client, seeded_db, location_strip1_id): + """POST uses provided ts_utc for backdating.""" + # Use a past timestamp (Feb 13, 2025 in ms) + backdated_ts = 1739404800000 + + resp = client.post( + "/actions/animal-cohort", + data={ + "species": "duck", + "location_id": location_strip1_id, + "count": "2", + "life_stage": "adult", + "sex": "female", + "origin": "purchased", + "ts_utc": str(backdated_ts), + "nonce": "test-backdate-cohort-1", + }, + ) + + assert resp.status_code == 200 + + # Verify the event was created with the backdated timestamp + event_row = seeded_db.execute( + "SELECT ts_utc FROM events WHERE type = 'AnimalCohortCreated' ORDER BY id DESC LIMIT 1" + ).fetchone() + assert event_row is not None + assert event_row[0] == backdated_ts + + def test_cohort_uses_current_time_when_ts_utc_zero(self, client, seeded_db, location_strip1_id): + """POST uses current time when ts_utc is 0.""" + import time + + before = int(time.time() * 1000) + + resp = client.post( + "/actions/animal-cohort", + data={ + "species": "duck", + "location_id": location_strip1_id, + "count": "2", + "life_stage": "adult", + "sex": "female", + "origin": "purchased", + "ts_utc": "0", + "nonce": "test-backdate-cohort-2", + }, + ) + + after = int(time.time() * 1000) + + assert resp.status_code == 200 + + # Verify the event was created with a current timestamp + event_row = seeded_db.execute( + "SELECT ts_utc FROM events WHERE type = 'AnimalCohortCreated' ORDER BY id DESC LIMIT 1" + ).fetchone() + assert event_row is not None + assert before <= event_row[0] <= after