Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions tests/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import datetime

import pytest

from use_agent import report, storage

_NOW = datetime.datetime(2026, 6, 30, 6, 0, tzinfo=datetime.UTC)
Expand Down Expand Up @@ -144,7 +146,9 @@ def test_render_full_html(tmp_path):
)
store.close()

html = report.render(db, days=7, now=_NOW)
# Rows are stamped at record time (real now); render with a
# matching now so the trailing window includes them.
html = report.render(db, days=7, now=datetime.datetime.now().astimezone())
assert html.startswith('<!DOCTYPE html>')
assert '1 unwanted emails handled for you' in html
assert 'outreach@growthpartners.io' in html
Expand All @@ -156,6 +160,32 @@ def test_render_missing_db_is_empty_report(tmp_path):
assert '0 unwanted emails handled for you' in html


def test_render_defaults_to_previous_full_week(tmp_path):
# Tue Jun 30 2026 -> previous Mon-Sun is Jun 22 through Jun 28.
now = datetime.datetime(2026, 6, 30, 6, 0).astimezone()
html = report.render(tmp_path / 'nope.db', now=now)
assert f'Jun 22 {report._EN_DASH} Jun 28, 2026' in html


def test_render_rejects_inverted_window(tmp_path):
now = datetime.datetime(2026, 6, 30, 6, 0).astimezone()
with pytest.raises(ValueError, match='must not be after'):
report.render(tmp_path / 'nope.db', days=0, now=now)
future = now.date() + datetime.timedelta(days=1)
with pytest.raises(ValueError, match='must not be after'):
report.render(tmp_path / 'nope.db', since=future, now=now)


def test_offenders_unescape_double_escaped_sender():
rows = [
_row(sender='jim jachetta &lt;jim@vidovation.com&gt;'),
_row(sender='jim jachetta &lt;jim@vidovation.com&gt;'),
]
offender = _ctx(rows)['offenders'][0]
assert offender['name'] == 'jim jachetta'
assert offender['addr'] == 'jim@vidovation.com'


def test_render_escapes_sender(tmp_path):
db = tmp_path / 'actions.db'
store = storage.Store(db, query_target='inbox')
Expand All @@ -169,6 +199,6 @@ def test_render_escapes_sender(tmp_path):
message_id='m1',
)
store.close()
html = report.render(db, days=7, now=_NOW)
html = report.render(db, days=7, now=datetime.datetime.now().astimezone())
assert '<script>x</script>' not in html
assert '&lt;script&gt;' in html
7 changes: 5 additions & 2 deletions use_agent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,11 @@ def _build_parser() -> argparse.ArgumentParser:
report_p.add_argument(
'--days',
type=int,
default=7,
help='last N calendar days, today inclusive; default 7',
default=None,
help=(
'last N calendar days, today inclusive; default is the '
'previous full calendar week (Mon-Sun)'
),
)
report_p.add_argument(
'--since',
Expand Down
72 changes: 47 additions & 25 deletions use_agent/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@
``report.html.j2`` template expects, and renders standalone
inline-styled HTML suitable for emailing.

The window is filtered on ``processed_at`` (when use-agent acted),
defaulting to the last 7 calendar days (today inclusive, from local
midnight). Timestamps are grouped in the local
timezone so "Activity by day" lines up with the reader's calendar.
The window is filtered on ``processed_at`` (when use-agent acted).
With no explicit ``days``/``since`` it covers the previous full
calendar week (last Monday through Sunday); ``days`` selects a
trailing window ending today instead. Timestamps are grouped in the
local timezone so "Activity by day" lines up with the reader's
calendar.
"""

import collections
import datetime
import email.utils
import html
import logging
import pathlib
import sqlite3
Expand Down Expand Up @@ -52,30 +55,39 @@
def render(
db_path: pathlib.Path,
*,
days: int = 7,
days: int | None = None,
since: datetime.date | None = None,
now: datetime.datetime | None = None,
) -> str:
"""Render the weekly report to a standalone HTML string.

``days`` selects the last N calendar days (today inclusive), so
the window starts at local midnight and the "Activity by day"
chart has exactly N rows. ``since`` overrides ``days`` when
given. ``now`` is injectable for deterministic tests; it
defaults to the current local time.
With neither ``days`` nor ``since`` the window is the previous
full calendar week (last Monday through Sunday). ``days`` selects
a trailing window of the last N calendar days ending today;
``since`` starts the window on a specific date and ends today.
Either override makes "Activity by day" span exactly that window.
``now`` is injectable for deterministic tests; it defaults to the
current local time.
"""
end = now or datetime.datetime.now().astimezone()
now = now or datetime.datetime.now().astimezone()
today = now.astimezone().date()
if since is not None:
since_date = since
start_date, end_date = since, today
elif days is not None:
start_date = today - datetime.timedelta(days=days - 1)
end_date = today
else:
since_date = end.astimezone().date() - datetime.timedelta(
days=days - 1
)
this_monday = today - datetime.timedelta(days=today.weekday())
start_date = this_monday - datetime.timedelta(days=7)
end_date = this_monday - datetime.timedelta(days=1)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if start_date > end_date:
raise ValueError('report start date must not be after end date')
start = datetime.datetime.combine(
since_date, datetime.time.min
start_date, datetime.time.min
).astimezone()
rows = _fetch_rows(db_path, start)
context = _build_context(rows, start=start, end=end)
end = datetime.datetime.combine(end_date, datetime.time.max).astimezone()
rows = _fetch_rows(db_path, start, end)
context = _build_context(rows, start=start, end=end, generated=now)
return (
_environment()
.get_template(config.REPORT_TEMPLATE.name)
Expand All @@ -92,9 +104,11 @@ def _environment() -> jinja2.Environment:


def _fetch_rows(
db_path: pathlib.Path, start: datetime.datetime
db_path: pathlib.Path,
start: datetime.datetime,
end: datetime.datetime,
) -> list[dict[str, typing.Any]]:
"""Return action rows with ``processed_at`` at or after ``start``.
"""Return action rows with ``processed_at`` within ``[start, end]``.

A missing database or table yields an empty report rather than an
error — the user may simply not have run the agent yet.
Expand All @@ -106,8 +120,11 @@ def _fetch_rows(
try:
cursor = conn.execute(
'SELECT * FROM actions WHERE processed_at >= ? '
'ORDER BY processed_at',
(start.astimezone(datetime.UTC).isoformat(),),
'AND processed_at <= ? ORDER BY processed_at',
(
start.astimezone(datetime.UTC).isoformat(),
end.astimezone(datetime.UTC).isoformat(),
),
)
return [dict(r) for r in cursor.fetchall()]
except sqlite3.OperationalError as exc:
Expand All @@ -124,6 +141,7 @@ def _build_context(
*,
start: datetime.datetime,
end: datetime.datetime,
generated: datetime.datetime | None = None,
) -> dict[str, typing.Any]:
total = len(rows)
return {
Expand All @@ -141,7 +159,7 @@ def _build_context(
'spam folder periodically, auto-responds to cold outreach, '
'unsubscribes you from bulk marketing, and clears it out.'
),
'generatedAt': _format_stamp(end),
'generatedAt': _format_stamp(generated or end),
}


Expand Down Expand Up @@ -253,8 +271,12 @@ def _offenders(
) -> list[dict[str, typing.Any]]:
grouped: dict[str, dict[str, typing.Any]] = {}
for r in rows:
name, addr = email.utils.parseaddr(r['sender'] or '')
key = (addr or r['sender'] or '').lower()
# Some senders arrive already HTML-escaped ('&lt;a@b.com&gt;');
# unescape first so parseaddr sees real angle brackets and the
# template's autoescape encodes them exactly once.
sender = html.unescape(r['sender'] or '')
name, addr = email.utils.parseaddr(sender)
key = (addr or sender).lower()
if not key:
continue
entry = grouped.setdefault(
Expand Down
Loading