diff --git a/tests/test_report.py b/tests/test_report.py index 82266da..8a565e4 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -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) @@ -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('') assert '1 unwanted emails handled for you' in html assert 'outreach@growthpartners.io' in html @@ -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 <jim@vidovation.com>'), + _row(sender='jim jachetta <jim@vidovation.com>'), + ] + 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') @@ -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 '' not in html assert '<script>' in html diff --git a/use_agent/cli.py b/use_agent/cli.py index 50891c0..583e11c 100644 --- a/use_agent/cli.py +++ b/use_agent/cli.py @@ -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', diff --git a/use_agent/report.py b/use_agent/report.py index 9195e2b..7fabde9 100644 --- a/use_agent/report.py +++ b/use_agent/report.py @@ -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 @@ -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) + 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) @@ -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. @@ -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: @@ -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 { @@ -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), } @@ -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 ('<a@b.com>'); + # 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(