diff --git a/solstone/apps/entities/routes.py b/solstone/apps/entities/routes.py index 37f579b87..a55b966e9 100644 --- a/solstone/apps/entities/routes.py +++ b/solstone/apps/entities/routes.py @@ -63,10 +63,12 @@ from solstone.think.entities import ( count_observations, delete_detected_entity, detach_facet_entity, + entity_last_active_day, entity_last_active_ts, entity_memory_path, entity_slug, is_valid_entity_type, + last_active_day_for_ts, load_all_facet_relationships, load_all_journal_entities, load_detected_entities_recent, @@ -144,6 +146,7 @@ def get_facet_entities_data(facet_name: str) -> dict: entity["has_voiceprint"] = metadata["has_voiceprint"] # Add computed activity timestamp for frontend sorting/display entity["last_active_ts"] = entity_last_active_ts(entity) + entity["last_active_day"] = entity_last_active_day(entity) # Load detected entities directly from files (excludes attached names/akas) detected = load_detected_entities_recent(facet_name) @@ -805,6 +808,7 @@ def get_entity(facet_name: str, entity_id: str) -> Any: entity["has_voiceprint"] = metadata["has_voiceprint"] # Add computed activity timestamp for frontend display entity["last_active_ts"] = entity_last_active_ts(entity) + entity["last_active_day"] = entity_last_active_day(entity) # Ensure id is set if "id" not in entity: @@ -1294,6 +1298,7 @@ def _build_facet_relationships( # Compute last_active_ts for this relationship rel_active_ts = entity_last_active_ts(relationship) facet_rel["last_active_ts"] = rel_active_ts + facet_rel["last_active_day"] = entity_last_active_day(relationship) # Only count observations and activity from non-detached relationships if not is_detached: @@ -1348,6 +1353,9 @@ def get_journal_entities_data() -> dict: "facets": facet_relationships, "total_observation_count": total_observation_count, "last_active_ts": latest_active_ts, + "last_active_day": ( + last_active_day_for_ts(latest_active_ts) if latest_active_ts else None + ), } entities.append(enriched) @@ -1407,6 +1415,9 @@ def get_journal_entity(entity_id: str) -> Any: "facets": facet_relationships, "total_observation_count": total_observation_count, "last_active_ts": latest_active_ts, + "last_active_day": ( + last_active_day_for_ts(latest_active_ts) if latest_active_ts else None + ), } return jsonify({"entity": enriched}) diff --git a/solstone/apps/entities/workspace.html b/solstone/apps/entities/workspace.html index 934b6f88b..61c3c5a36 100644 --- a/solstone/apps/entities/workspace.html +++ b/solstone/apps/entities/workspace.html @@ -1716,18 +1716,10 @@ function formatDateShort(dateStr) { return short; } -// Format timestamp (epoch ms) to relative date -function formatTimestamp(epochMs) { - if (!epochMs) return null; - const date = new Date(epochMs); - const dateStr = date.toISOString().slice(0, 10).replace(/-/g, ''); - return formatDateShort(dateStr); -} - -// Get display date for entity using pre-computed last_active_ts from server +// Get display date for entity using server-derived journal-local day (YYYYMMDD) function getEntityDisplayDate(entity) { if (entity.last_active_ts) { - return formatTimestamp(entity.last_active_ts); + return formatDateShort(entity.last_active_day); } return null; } @@ -2077,7 +2069,7 @@ function renderJournalDetailView(entity) { if (facet.last_active_ts) { const date = document.createElement('span'); - date.textContent = formatTimestamp(facet.last_active_ts); + date.textContent = formatDateShort(facet.last_active_day); meta.appendChild(date); } diff --git a/solstone/think/entities/__init__.py b/solstone/think/entities/__init__.py index e59ca0287..05b97b3e0 100644 --- a/solstone/think/entities/__init__.py +++ b/solstone/think/entities/__init__.py @@ -37,10 +37,12 @@ from solstone.think.entities.core import ( ENTITY_TYPES, MAX_ENTITY_SLUG_LENGTH, EntityDict, + entity_last_active_day, entity_last_active_ts, entity_slug, get_identity_names, is_valid_entity_type, + last_active_day_for_ts, ) # Errors @@ -143,7 +145,9 @@ __all__ = [ "ENTITY_TYPES", "MAX_ENTITY_SLUG_LENGTH", "EntityDict", + "entity_last_active_day", "entity_last_active_ts", + "last_active_day_for_ts", "entity_slug", "get_identity_names", "is_valid_entity_type", diff --git a/solstone/think/entities/core.py b/solstone/think/entities/core.py index a3ab28dc4..cb3cc4b4b 100644 --- a/solstone/think/entities/core.py +++ b/solstone/think/entities/core.py @@ -135,6 +135,34 @@ def entity_last_active_ts(entity: EntityDict) -> int: return DEFAULT_ACTIVITY_TS +def last_active_day_for_ts(ts_ms: int) -> str: + """Convert a last-active epoch (ms) to a journal-local day string (YYYYMMDD). + + Journal days bucket on local time (datetime.now()-based, e.g. + solstone.think.utils.day_path), so an instant is derived in local time here + too. This keeps a "now" timestamp on today's journal day instead of slipping + to the next UTC day for evening-Americas edits. + """ + return datetime.fromtimestamp(ts_ms / 1000).strftime("%Y%m%d") + + +def entity_last_active_day(entity: EntityDict) -> str: + """Get the entity's last-active day as a journal-local YYYYMMDD string. + + Day-basis sibling of entity_last_active_ts(). Returns last_seen verbatim when + it is already a valid journal-day string (avoids a lossy epoch round-trip); + otherwise derives the day from entity_last_active_ts() on the local basis. + """ + last_seen = entity.get("last_seen") + if last_seen and isinstance(last_seen, str) and len(last_seen) == 8: + try: + datetime.strptime(last_seen, "%Y%m%d") + return last_seen + except ValueError: + pass # Malformed, fall through to epoch derivation + return last_active_day_for_ts(entity_last_active_ts(entity)) + + def is_valid_entity_type(etype: str) -> bool: """Validate entity type: alphanumeric and spaces only, at least 3 characters.""" if not etype or len(etype.strip()) < 3: diff --git a/tests/baselines/api/entities/journal-entities.json b/tests/baselines/api/entities/journal-entities.json index ad9e328b2..943f46d70 100644 --- a/tests/baselines/api/entities/journal-entities.json +++ b/tests/baselines/api/entities/journal-entities.json @@ -6,6 +6,7 @@ "facets": [], "id": "alice_johnson", "is_principal": false, + "last_active_day": null, "last_active_ts": 0, "name": "Alice Johnson", "total_observation_count": 0, @@ -17,6 +18,7 @@ "facets": [], "id": "bob_smith", "is_principal": false, + "last_active_day": null, "last_active_ts": 0, "name": "Bob Smith", "total_observation_count": 0, @@ -32,6 +34,7 @@ "description": "Container platform", "emoji": "๐Ÿงช", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "test-facet", @@ -42,6 +45,7 @@ ], "id": "docker", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Docker", "total_observation_count": 0, @@ -57,6 +61,7 @@ "description": "Database system", "emoji": "๐Ÿงช", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "test-facet", @@ -67,6 +72,7 @@ ], "id": "postgresql", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "PostgreSQL", "total_observation_count": 0, @@ -82,6 +88,7 @@ "description": "Lead developer on the project", "emoji": "๐Ÿงช", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "test-facet", @@ -92,6 +99,7 @@ ], "id": "john_smith", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "John Smith", "total_observation_count": 2, @@ -107,6 +115,7 @@ "description": "Main client", "emoji": "๐Ÿงช", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "test-facet", @@ -117,6 +126,7 @@ ], "id": "acme_corp", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Acme Corp", "total_observation_count": 0, @@ -132,6 +142,7 @@ "description": "Partner company", "emoji": "๐Ÿงช", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "test-facet", @@ -142,6 +153,7 @@ ], "id": "tech_solutions_inc", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Tech Solutions Inc", "total_observation_count": 0, @@ -157,6 +169,7 @@ "description": "Performance improvement initiative", "emoji": "๐Ÿงช", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "test-facet", @@ -167,6 +180,7 @@ ], "id": "api_optimization", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "API Optimization", "total_observation_count": 0, @@ -182,6 +196,7 @@ "description": "Primary IDE", "emoji": "๐Ÿงช", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "test-facet", @@ -192,6 +207,7 @@ ], "id": "visual_studio_code", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Visual Studio Code", "total_observation_count": 0, @@ -207,6 +223,7 @@ "description": "Project manager", "emoji": "๐Ÿงช", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "test-facet", @@ -217,6 +234,7 @@ ], "id": "jane_doe", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Jane Doe", "total_observation_count": 0, @@ -232,6 +250,7 @@ "description": "QA engineer", "emoji": "๐Ÿงช", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "test-facet", @@ -242,6 +261,7 @@ ], "id": "bob_wilson", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Bob Wilson", "total_observation_count": 0, @@ -257,6 +277,7 @@ "description": "UI/UX overhaul project", "emoji": "๐Ÿงช", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "test-facet", @@ -267,6 +288,7 @@ ], "id": "dashboard_redesign", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Dashboard Redesign", "total_observation_count": 0, @@ -282,6 +304,7 @@ "description": "CTO and co-founder of Montague Tech. Visionary full-stack engineer.", "emoji": "โš”๏ธ", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "montague", @@ -295,6 +318,7 @@ "description": "Co-lead of the Verona Platform joint venture from Montague Tech.", "emoji": "๐ŸŒน", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "verona", @@ -305,6 +329,7 @@ ], "id": "romeo_montague", "is_principal": true, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Romeo Montague", "total_observation_count": 6, @@ -320,6 +345,7 @@ "description": "CTO of Capulet Industries. Secret collaborator on the Verona Platform.", "emoji": "โš”๏ธ", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "montague", @@ -333,6 +359,7 @@ "description": "Co-lead of the Verona Platform joint venture from Capulet Industries.", "emoji": "๐ŸŒน", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "verona", @@ -346,6 +373,7 @@ "description": "Lead software architect at Capulet Industries.", "emoji": "๐Ÿฐ", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "capulet", @@ -356,6 +384,7 @@ ], "id": "juliet_capulet", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Juliet Capulet", "total_observation_count": 9, @@ -371,6 +400,7 @@ "description": "Core distributed mesh routing technology powering Montague Tech's API platform.", "emoji": "โš”๏ธ", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "montague", @@ -381,6 +411,7 @@ ], "id": "mesh_routing", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Mesh Routing", "total_observation_count": 3, @@ -396,6 +427,7 @@ "description": "Encrypted messaging app โ€” the original collaboration project between Romeo and Juliet.", "emoji": "โš”๏ธ", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "montague", @@ -409,6 +441,7 @@ "description": "Encrypted messaging app built by Romeo and Juliet for secure collaboration.", "emoji": "๐ŸŒน", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "verona", @@ -419,6 +452,7 @@ ], "id": "balcony_app", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Balcony App", "total_observation_count": 2, @@ -434,6 +468,7 @@ "description": "Family tech startup specializing in API development.", "emoji": "โš”๏ธ", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "montague", @@ -444,6 +479,7 @@ ], "id": "montague_tech", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Montague Tech", "total_observation_count": 3, @@ -459,6 +495,7 @@ "description": "Former colleague who left for Verona Ventures. Maintains industry contacts.", "emoji": "โš”๏ธ", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "montague", @@ -469,6 +506,7 @@ ], "id": "rosaline_prince", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Rosaline Prince", "total_observation_count": 2, @@ -484,6 +522,7 @@ "description": "Junior developer on the mesh routing team. Romeo's mentee.", "emoji": "โš”๏ธ", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "montague", @@ -494,6 +533,7 @@ ], "id": "balthasar_davi", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Balthasar Davi", "total_observation_count": 2, @@ -509,6 +549,7 @@ "description": "Romeo's best friend. Freelance security consultant.", "emoji": "โš”๏ธ", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "montague", @@ -519,6 +560,7 @@ ], "id": "mercutio_escalus", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Mercutio Escalus", "total_observation_count": 3, @@ -534,6 +576,7 @@ "description": "Romeo's cousin. DevOps lead at Montague Tech.", "emoji": "โš”๏ธ", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "montague", @@ -544,6 +587,7 @@ ], "id": "benvolio_montague", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Benvolio Montague", "total_observation_count": 3, @@ -559,6 +603,7 @@ "description": "Senior architect and trusted advisor. Mediating the Montague-Capulet technical partnership.", "emoji": "โš”๏ธ", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "montague", @@ -572,6 +617,7 @@ "description": "Professor at Verona Tech University. Mentor and advisor to both Romeo and Juliet.", "emoji": "๐ŸŒน", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "verona", @@ -582,6 +628,7 @@ ], "id": "friar_lawrence", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Friar Lawrence", "total_observation_count": 4, @@ -597,6 +644,7 @@ "description": "The unified cross-company API gateway combining Montague routing with Capulet schema translation.", "emoji": "โš”๏ธ", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "montague", @@ -610,6 +658,7 @@ "description": "Secret cross-company unified API gateway project.", "emoji": "๐ŸŒน", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "verona", @@ -620,6 +669,7 @@ ], "id": "verona_platform", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Verona Platform", "total_observation_count": 3, @@ -635,6 +685,7 @@ "description": "Translation layer enabling Capulet's enterprise schemas to work with Montague's mesh routing.", "emoji": "โš”๏ธ", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "montague", @@ -645,6 +696,7 @@ ], "id": "schema_bridge", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Schema Bridge", "total_observation_count": 2, @@ -660,6 +712,7 @@ "description": "VC partner observing Montague Tech board. Interested in the Verona Platform potential.", "emoji": "โš”๏ธ", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "montague", @@ -670,6 +723,7 @@ ], "id": "prince_escalus", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Prince Escalus", "total_observation_count": 2, @@ -685,6 +739,7 @@ "description": "Venture capital firm backing both Montague Tech and Capulet Industries.", "emoji": "โš”๏ธ", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "montague", @@ -695,6 +750,7 @@ ], "id": "verona_ventures", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Verona Ventures", "total_observation_count": 2, @@ -710,6 +766,7 @@ "description": "", "emoji": "๐Ÿš€", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "full-featured", @@ -720,6 +777,7 @@ ], "id": "first_test_entity", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "First test entity", "total_observation_count": 0, @@ -735,6 +793,7 @@ "description": "", "emoji": "๐Ÿš€", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "full-featured", @@ -745,6 +804,7 @@ ], "id": "second_test_entity", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Second test entity", "total_observation_count": 0, @@ -760,6 +820,7 @@ "description": "", "emoji": "๐Ÿš€", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "full-featured", @@ -770,6 +831,7 @@ ], "id": "third_test_entity_with_description", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Third test entity with description", "total_observation_count": 0, @@ -785,6 +847,7 @@ "description": "Family enterprise software company. Enterprise API solutions.", "emoji": "๐Ÿฐ", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "capulet", @@ -795,6 +858,7 @@ ], "id": "capulet_industries", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Capulet Industries", "total_observation_count": 0, @@ -810,6 +874,7 @@ "description": "Juliet's cousin. VP of Engineering at Capulet Industries.", "emoji": "๐Ÿฐ", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "capulet", @@ -820,6 +885,7 @@ ], "id": "tybalt_capulet", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Tybalt Capulet", "total_observation_count": 4, @@ -835,6 +901,7 @@ "description": "Juliet's trusted executive assistant and office manager.", "emoji": "๐Ÿฐ", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "capulet", @@ -845,6 +912,7 @@ ], "id": "nurse_angela", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Nurse Angela", "total_observation_count": 2, @@ -860,6 +928,7 @@ "description": "VC investor courting Capulet Industries for a strategic partnership.", "emoji": "๐Ÿฐ", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "capulet", @@ -870,6 +939,7 @@ ], "id": "paris_duke", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "Paris Duke", "total_observation_count": 2, diff --git a/tests/baselines/api/entities/journal-entity-detail.json b/tests/baselines/api/entities/journal-entity-detail.json index 215c8a0ea..d8f5ae9c8 100644 --- a/tests/baselines/api/entities/journal-entity-detail.json +++ b/tests/baselines/api/entities/journal-entity-detail.json @@ -9,6 +9,7 @@ "description": "", "emoji": "๐Ÿš€", "has_voiceprint": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "last_seen": null, "name": "full-featured", @@ -19,6 +20,7 @@ ], "id": "first_test_entity", "is_principal": false, + "last_active_day": "20251231", "last_active_ts": 1767225600000, "name": "First test entity", "total_observation_count": 0, diff --git a/tests/test_entities.py b/tests/test_entities.py index 9a994bda2..fb64acd98 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -20,12 +20,14 @@ from solstone.think.entities import ( detach_facet_entity, detected_entities_path, ensure_entity_memory, + entity_last_active_day, entity_last_active_ts, entity_memory_path, entity_slug, find_matching_entity, get_identity_names, iter_detected_entity_names_since, + last_active_day_for_ts, load_all_attached_entities, load_all_facet_relationships, load_all_journal_entities, @@ -144,6 +146,67 @@ def test_entity_last_active_ts_zero_timestamps(): assert ts == DEFAULT_ACTIVITY_TS +@pytest.fixture +def tz_americas_evening(monkeypatch): + """Pin process tz to US Eastern so a UTC-next-day instant is the same local day. + + Journal days bucket on local time, so the derived last-active day must follow + local time โ€” this is the evening-Americas case that produced "Tomorrow". + """ + import os + import time + + original_tz = os.environ.get("TZ") + monkeypatch.setenv("TZ", "America/New_York") + time.tzset() + yield + if original_tz is None: + os.environ.pop("TZ", None) + else: + os.environ["TZ"] = original_tz + time.tzset() + + +def test_last_active_day_for_ts_uses_local_journal_day(tz_americas_evening): + """Evening-Americas instant renders the local journal day, not the next UTC day.""" + from datetime import datetime, timezone + + # 2026-01-15 23:30 America/New_York == 2026-01-16 04:30 UTC. + instant = datetime(2026, 1, 16, 4, 30, tzinfo=timezone.utc) + ts_ms = int(instant.timestamp() * 1000) + + assert last_active_day_for_ts(ts_ms) == "20260115" # local journal day (the fix) + assert last_active_day_for_ts(ts_ms) != "20260116" # NOT the next UTC day (the bug) + + +def test_entity_last_active_day_from_updated_at_local_basis(tz_americas_evening): + """entity_last_active_day derives the local journal day from the updated_at epoch.""" + from datetime import datetime, timezone + + instant = datetime(2026, 1, 16, 4, 30, tzinfo=timezone.utc) + ts_ms = int(instant.timestamp() * 1000) + + assert entity_last_active_day({"updated_at": ts_ms}) == "20260115" + + +def test_entity_last_active_day_returns_last_seen_verbatim(): + """A valid last_seen journal-day string is returned without an epoch round-trip.""" + entity = {"last_seen": "20260115", "updated_at": 1700000000000} + assert entity_last_active_day(entity) == "20260115" + + +def test_entity_last_active_day_malformed_last_seen_falls_through( + tz_americas_evening, +): + """Malformed last_seen falls through to the epoch-derived local day.""" + from datetime import datetime, timezone + + instant = datetime(2026, 1, 16, 4, 30, tzinfo=timezone.utc) + ts_ms = int(instant.timestamp() * 1000) + entity = {"last_seen": "invalid", "updated_at": ts_ms} + assert entity_last_active_day(entity) == "20260115" + + def test_entity_last_active_ts_negative_timestamps(): """Test that negative timestamps are treated as missing.""" entity = {