DEV Community

Dmitry Lukashov
Dmitry Lukashov

Posted on Fully Autonomous

Choose the reporting timezone before grouping campaign events

An AI assistant can group an event export by the first ten characters of its timestamp and produce a neat daily table. That table can still use the wrong day boundary.

This small exercise makes the boundary explicit before asking an assistant to summarize a campaign. It counts four fictional event rows, not unique users, qualified leads, or purchases. Nothing connects to an ad account or changes a campaign.

Four timestamps, three possible tables

Save this UTF-8 fixture as events.csv:

event,occurred_at
E01,2026-10-28T20:59:00Z
E02,2026-10-28T21:00:00Z
E03,2026-10-29T00:15:00+03:00
E04,2026-10-29T22:15:00+03:00
Enter fullscreen mode Exit fullscreen mode

The first two timestamps use UTC (Z). The other two include +03:00. For this exercise, the reporting timezone is Europe/Moscow. Choose the timezone specified by your real report; this example does not imply every campaign uses Moscow time.

E01 is one minute before midnight in that timezone. E02 is exactly midnight, so it belongs to the next reporting day. E03 and E04 belong to that next day too.

These are the results of three different grouping rules:

Grouping rule October 28 October 29
First ten characters of each supplied string 2 2
Convert every timestamp to UTC, then take its date 3 1
Convert every timestamp to Europe/Moscow, then take its date 1 3

All three tables count four rows. A total-count check would not reveal the different day boundaries. For the stated reporting contract, the last row is the expected result.

Convert before taking the date

The following complete script uses Python's standard library. Save it as group_events.py beside events.csv:

import csv
import json
from collections import Counter
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo


REPORT_TZ_NAME = "Europe/Moscow"
REPORT_TZ = ZoneInfo(REPORT_TZ_NAME)


def group_events(rows):
    counts = Counter()
    assignments = []
    for row in rows:
        raw = row["occurred_at"]
        stamp = datetime.fromisoformat(raw.replace("Z", "+00:00"))
        if stamp.utcoffset() is None:
            raise ValueError(row["event"] + ": explicit UTC offset required")
        report_day = stamp.astimezone(REPORT_TZ).date().isoformat()
        counts[report_day] += 1
        assignments.append({"event": row["event"], "report_date": report_day})
    return {
        "report_timezone": REPORT_TZ_NAME,
        "counts": dict(sorted(counts.items())),
        "assignments": assignments,
    }


if __name__ == "__main__":
    try:
        with Path(__file__).with_name("events.csv").open(
            encoding="utf-8", newline=""
        ) as stream:
            report = group_events(list(csv.DictReader(stream)))
    except (ValueError, KeyError) as error:
        print(json.dumps({"error": str(error)}))
        raise SystemExit(2)
    print(json.dumps(report, indent=2))
Enter fullscreen mode Exit fullscreen mode

Run it with:

python3 group_events.py
Enter fullscreen mode Exit fullscreen mode

The output includes the timezone, the counts, and an assignment for each input row:

{
  "report_timezone": "Europe/Moscow",
  "counts": {
    "2026-10-28": 1,
    "2026-10-29": 3
  },
  "assignments": [
    {"event": "E01", "report_date": "2026-10-28"},
    {"event": "E02", "report_date": "2026-10-29"},
    {"event": "E03", "report_date": "2026-10-29"},
    {"event": "E04", "report_date": "2026-10-29"}
  ]
}
Enter fullscreen mode Exit fullscreen mode

The output above uses more compact spacing for the assignment objects; the JSON values are the same as the script's output. Keeping those assignments makes it possible to inspect which event crossed a date boundary instead of comparing totals alone.

The fixture and script were executed locally on Python 3.9.6. The four assignments and the three grouping alternatives in the table were checked. No external language model response was measured in this run; the script provides a reference result for checking one.

zoneinfo requires Python 3.9 or later and available IANA timezone data. It uses the system database or the tzdata package, as described in Python's official zoneinfo documentation. The local run had the required data. A machine without it may raise ZoneInfoNotFoundError; silently replacing the chosen zone with the computer's local timezone would change the contract.

Reject an unspecified offset

Change E03 to this value:

E03,2026-10-29T00:15:00
Enter fullscreen mode Exit fullscreen mode

The timestamp now has no UTC offset. The script stops with exit code 2 and prints:

{"error": "E03: explicit UTC offset required"}
Enter fullscreen mode Exit fullscreen mode

It does not print partial daily counts. Guessing that the value means UTC, Moscow time, or the operator's laptop time would produce a result without a stated source rule. Python distinguishes these naive timestamps from aware timestamps in its datetime documentation.

If the source system documents an offset-free value as belonging to a particular timezone, handle that as a separate input contract. This short example deliberately refuses to infer it.

Check that a different representation preserves the instant

Restore the original fixture. Then replace E02's timestamp with 2026-10-29T00:00:00+03:00.

That is the same instant as 2026-10-28T21:00:00Z. The script must return the same counts and assignments. A program that groups by the string's date would move the row simply because its representation changed.

Both this mutation and the missing-offset mutation were executed locally. One checks an invariant; the other checks a refusal condition.

Use the reference when reviewing an AI summary

Give an assistant the original CSV and a specific instruction:

Count event rows by calendar date in Europe/Moscow.
Require an explicit UTC offset or Z on every timestamp.
Convert each instant to Europe/Moscow before extracting its date.
Do not group by a substring or by the timestamp's original timezone.
If any timestamp has no offset or cannot be parsed, report the error;
do not return partial daily counts and do not guess a timezone.
Return the reporting timezone, daily counts, and each event's assigned date.
Do not infer unique users, leads, purchases, or campaign performance.
Enter fullscreen mode Exit fullscreen mode

Compare the individual assignments as well as the counts. Then repeat the two mutations. A well-written explanation should not conceal a changed grouping rule or an invented offset.

This is a narrowly scoped fixture, not a production analytics pipeline. It assumes the displayed CSV schema and already deduplicated event rows. It does not check export completeness, attribution windows, delayed events, refunds, event definitions, or duplicate identifiers. Those require their own acceptance cases before a daily table can support a business conclusion.

Prepared with an AI agent. The displayed fixture, code, reference values, and two input mutations were checked locally. All event data is synthetic.

Top comments (0)