feat: add event backdating with collapsible datetime picker

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 <noreply@anthropic.com>
This commit is contained in:
2026-01-01 10:40:01 +00:00
parent 82def73188
commit abf78ec98a
7 changed files with 328 additions and 65 deletions

View File

@@ -55,6 +55,26 @@ from animaltrack.web.templates.actions import (
tag_end_form, 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 # APIRouter for multi-file route organization
ar = APIRouter() ar = APIRouter()
@@ -153,8 +173,8 @@ async def animal_cohort(request: Request):
auth = request.scope.get("auth") auth = request.scope.get("auth")
actor = auth.username if auth else "unknown" actor = auth.username if auth else "unknown"
# Create cohort # Create cohort - use form ts_utc if provided for backdating
ts_utc = int(time.time() * 1000) ts_utc = _parse_ts_utc(form.get("ts_utc"))
service = _create_animal_service(db) service = _create_animal_service(db)
try: try:
@@ -289,8 +309,8 @@ async def hatch_recorded(request: Request):
auth = request.scope.get("auth") auth = request.scope.get("auth")
actor = auth.username if auth else "unknown" actor = auth.username if auth else "unknown"
# Record hatch # Record hatch - use form ts_utc if provided for backdating
ts_utc = int(time.time() * 1000) ts_utc = _parse_ts_utc(form.get("ts_utc"))
service = _create_animal_service(db) service = _create_animal_service(db)
try: try:
@@ -433,8 +453,8 @@ async def animal_promote(request: Request):
auth = request.scope.get("auth") auth = request.scope.get("auth")
actor = auth.username if auth else "unknown" actor = auth.username if auth else "unknown"
# Promote animal # Promote animal - use form ts_utc if provided for backdating
ts_utc = int(time.time() * 1000) ts_utc = _parse_ts_utc(form.get("ts_utc"))
service = _create_animal_service(db) service = _create_animal_service(db)
try: try:
@@ -530,14 +550,8 @@ async def animal_tag_add(request: Request):
confirmed = form.get("confirmed", "") == "true" confirmed = form.get("confirmed", "") == "true"
nonce = form.get("nonce") nonce = form.get("nonce")
# Get timestamp - use provided or current # Get timestamp - use provided or current (supports backdating)
ts_utc_str = form.get("ts_utc", "0") ts_utc = _parse_ts_utc(form.get("ts_utc"))
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)
# resolved_ids can be multiple values # resolved_ids can be multiple values
resolved_ids = form.getlist("resolved_ids") resolved_ids = form.getlist("resolved_ids")
@@ -761,14 +775,8 @@ async def animal_tag_end(request: Request):
confirmed = form.get("confirmed", "") == "true" confirmed = form.get("confirmed", "") == "true"
nonce = form.get("nonce") nonce = form.get("nonce")
# Get timestamp - use provided or current # Get timestamp - use provided or current (supports backdating)
ts_utc_str = form.get("ts_utc", "0") ts_utc = _parse_ts_utc(form.get("ts_utc"))
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)
# resolved_ids can be multiple values # resolved_ids can be multiple values
resolved_ids = form.getlist("resolved_ids") resolved_ids = form.getlist("resolved_ids")
@@ -966,14 +974,8 @@ async def animal_attrs(request: Request):
confirmed = form.get("confirmed", "") == "true" confirmed = form.get("confirmed", "") == "true"
nonce = form.get("nonce") nonce = form.get("nonce")
# Get timestamp - use provided or current # Get timestamp - use provided or current (supports backdating)
ts_utc_str = form.get("ts_utc", "0") ts_utc = _parse_ts_utc(form.get("ts_utc"))
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)
# resolved_ids can be multiple values # resolved_ids can be multiple values
resolved_ids = form.getlist("resolved_ids") resolved_ids = form.getlist("resolved_ids")
@@ -1203,14 +1205,8 @@ async def animal_outcome(request: Request):
except ValueError: except ValueError:
pass pass
# Get timestamp - use provided or current # Get timestamp - use provided or current (supports backdating)
ts_utc_str = form.get("ts_utc", "0") ts_utc = _parse_ts_utc(form.get("ts_utc"))
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)
# resolved_ids can be multiple values # resolved_ids can be multiple values
resolved_ids = form.getlist("resolved_ids") resolved_ids = form.getlist("resolved_ids")
@@ -1452,14 +1448,8 @@ async def animal_status_correct(req: Request):
confirmed = form.get("confirmed", "") == "true" confirmed = form.get("confirmed", "") == "true"
nonce = form.get("nonce") nonce = form.get("nonce")
# Get timestamp - use provided or current # Get timestamp - use provided or current (supports backdating)
ts_utc_str = form.get("ts_utc", "0") ts_utc = _parse_ts_utc(form.get("ts_utc"))
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)
# resolved_ids can be multiple values # resolved_ids can be multiple values
resolved_ids = form.getlist("resolved_ids") resolved_ids = form.getlist("resolved_ids")

View File

@@ -24,6 +24,26 @@ from animaltrack.services.feed import FeedService, ValidationError
from animaltrack.web.templates import page from animaltrack.web.templates import page
from animaltrack.web.templates.feed import feed_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 # APIRouter for multi-file route organization
ar = APIRouter() ar = APIRouter()
@@ -152,8 +172,8 @@ async def feed_given(request: Request):
selected_feed_type_code=feed_type_code, selected_feed_type_code=feed_type_code,
) )
# Get current timestamp # Get timestamp - use provided or current (supports backdating)
ts_utc = int(time.time() * 1000) ts_utc = _parse_ts_utc(form.get("ts_utc"))
# Create feed service # Create feed service
event_store = EventStore(db) event_store = EventStore(db)
@@ -324,8 +344,8 @@ async def feed_purchased(request: Request):
"Price cannot be negative", "Price cannot be negative",
) )
# Get current timestamp # Get timestamp - use provided or current (supports backdating)
ts_utc = int(time.time() * 1000) ts_utc = _parse_ts_utc(form.get("ts_utc"))
# Create feed service # Create feed service
event_store = EventStore(db) event_store = EventStore(db)

View File

@@ -24,6 +24,26 @@ from animaltrack.services.animal import AnimalService, ValidationError
from animaltrack.web.templates import page from animaltrack.web.templates import page
from animaltrack.web.templates.move import diff_panel, move_form 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 # APIRouter for multi-file route organization
ar = APIRouter() ar = APIRouter()
@@ -122,14 +142,8 @@ async def animal_move(request: Request):
confirmed = form.get("confirmed", "") == "true" confirmed = form.get("confirmed", "") == "true"
nonce = form.get("nonce") nonce = form.get("nonce")
# Get timestamp - use provided or current # Get timestamp - use provided or current (supports backdating)
ts_utc_str = form.get("ts_utc", "0") ts_utc = _parse_ts_utc(form.get("ts_utc"))
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)
# resolved_ids can be multiple values # resolved_ids can be multiple values
resolved_ids = form.getlist("resolved_ids") resolved_ids = form.getlist("resolved_ids")

View File

@@ -4,12 +4,13 @@
from collections.abc import Callable from collections.abc import Callable
from typing import Any 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 ( from monsterui.all import (
Alert, Alert,
AlertT, AlertT,
Button, Button,
ButtonT, ButtonT,
FormLabel,
LabelInput, LabelInput,
LabelSelect, LabelSelect,
LabelTextArea, LabelTextArea,
@@ -20,6 +21,97 @@ from animaltrack.models.animals import Animal
from animaltrack.models.reference import Location, Species from animaltrack.models.reference import Location, Species
from animaltrack.selection.validation import SelectionDiff 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 # Cohort Creation Form
# ============================================================================= # =============================================================================
@@ -160,6 +252,8 @@ def cohort_form(
id="origin", id="origin",
name="origin", name="origin",
), ),
# Event datetime picker (for backdating)
event_datetime_field("cohort_datetime"),
# Hidden nonce for idempotency # Hidden nonce for idempotency
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
@@ -276,6 +370,8 @@ def hatch_form(
cls="text-xs text-stone-400 mt-1", cls="text-xs text-stone-400 mt-1",
), ),
), ),
# Event datetime picker (for backdating)
event_datetime_field("hatch_datetime"),
# Hidden nonce for idempotency # Hidden nonce for idempotency
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
@@ -477,10 +573,11 @@ def tag_add_form(
name="tag", name="tag",
placeholder="Enter tag name", placeholder="Enter tag name",
), ),
# Event datetime picker (for backdating)
event_datetime_field("tag_add_datetime"),
# Hidden fields for selection context # Hidden fields for selection context
*resolved_id_fields, *resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash), Hidden(name="roster_hash", value=roster_hash),
Hidden(name="ts_utc", value=str(ts_utc or 0)),
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
@@ -665,10 +762,11 @@ def tag_end_form(
P("No active tags on selected animals", cls="text-sm text-stone-400"), P("No active tags on selected animals", cls="text-sm text-stone-400"),
cls="p-3 bg-slate-800 rounded-md", cls="p-3 bg-slate-800 rounded-md",
), ),
# Event datetime picker (for backdating)
event_datetime_field("tag_end_datetime"),
# Hidden fields for selection context # Hidden fields for selection context
*resolved_id_fields, *resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash), Hidden(name="roster_hash", value=roster_hash),
Hidden(name="ts_utc", value=str(ts_utc or 0)),
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
@@ -877,10 +975,11 @@ def attrs_form(
id="repro_status", id="repro_status",
name="repro_status", name="repro_status",
), ),
# Event datetime picker (for backdating)
event_datetime_field("attrs_datetime"),
# Hidden fields for selection context # Hidden fields for selection context
*resolved_id_fields, *resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash), Hidden(name="roster_hash", value=roster_hash),
Hidden(name="ts_utc", value=str(ts_utc or 0)),
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
@@ -1144,10 +1243,11 @@ def outcome_form(
rows=2, rows=2,
placeholder="Any additional notes...", placeholder="Any additional notes...",
), ),
# Event datetime picker (for backdating)
event_datetime_field("outcome_datetime"),
# Hidden fields for selection context # Hidden fields for selection context
*resolved_id_fields, *resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash), Hidden(name="roster_hash", value=roster_hash),
Hidden(name="ts_utc", value=str(ts_utc or 0)),
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
@@ -1364,10 +1464,11 @@ def status_correct_form(
rows=2, rows=2,
placeholder="Any additional notes...", placeholder="Any additional notes...",
), ),
# Event datetime picker (for backdating)
event_datetime_field("status_correct_datetime"),
# Hidden fields for selection context # Hidden fields for selection context
*resolved_id_fields, *resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash), Hidden(name="roster_hash", value=roster_hash),
Hidden(name="ts_utc", value=str(ts_utc or 0)),
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button

View File

@@ -16,6 +16,7 @@ from monsterui.all import (
from ulid import ULID from ulid import ULID
from animaltrack.models.reference import FeedType, Location from animaltrack.models.reference import FeedType, Location
from animaltrack.web.templates.actions import event_datetime_field
def feed_page( def feed_page(
@@ -202,6 +203,8 @@ def give_feed_form(
name="notes", name="notes",
placeholder="Optional notes", placeholder="Optional notes",
), ),
# Event datetime picker (for backdating)
event_datetime_field("feed_given_datetime"),
# Hidden nonce # Hidden nonce
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button
@@ -298,6 +301,8 @@ def purchase_feed_form(
name="notes", name="notes",
placeholder="Optional notes", placeholder="Optional notes",
), ),
# Event datetime picker (for backdating)
event_datetime_field("feed_purchase_datetime"),
# Hidden nonce # Hidden nonce
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),
# Submit button # Submit button

View File

@@ -10,6 +10,7 @@ from ulid import ULID
from animaltrack.models.reference import Location from animaltrack.models.reference import Location
from animaltrack.selection.validation import SelectionDiff from animaltrack.selection.validation import SelectionDiff
from animaltrack.web.templates.actions import event_datetime_field
def move_form( def move_form(
@@ -102,11 +103,12 @@ def move_form(
id="to_location_id", id="to_location_id",
name="to_location_id", name="to_location_id",
), ),
# Event datetime picker (for backdating)
event_datetime_field("move_datetime"),
# Hidden fields for selection context # Hidden fields for selection context
*resolved_id_fields, *resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash), Hidden(name="roster_hash", value=roster_hash),
Hidden(name="from_location_id", value=from_location_id or ""), 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="resolver_version", value="v1"),
Hidden(name="confirmed", value=""), Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())), Hidden(name="nonce", value=str(ULID())),

View File

@@ -1552,3 +1552,134 @@ class TestStatusCorrectValidation:
) )
assert resp.status_code == 403 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