---
name: "scheduled_report_generation"
description: "Build a recurring report pipeline: collect inputs, aggregate over daily/weekly/monthly windows, template the body, deliver via email/chat/file, and stay silent on no-change days. Triggers on: scheduled report, periodic summary, weekly digest, daily status report."
metadata: { "includeInPrompt": true }
---

# Scheduled Report Generation

## Purpose

Turn raw events into a report that arrives on schedule, reads the same way every time, and does not arrive at all when nothing changed. This owns the pipeline: collect, aggregate, template, deliver, silence check. Nothing else.

Not this skill: cron-discipline owns job safety (overlap locks, idempotency, retry policy) and metric-definitions owns what to measure (definitions, naming, traps). Point there when the problem is scheduling mechanics or metric design. This skill starts once the metrics exist and the job runs.

## Workflow

1. **Collect.** List every input the report needs and the source each one comes from: an API, a log file, a database query, a JSON export. Fetch raw events only, not someone else's summary. Record the fetch timestamp in the report footer so stale inputs are visible.
2. **Pick a window.** Daily fits fast-moving numbers (traffic, signups, errors). Weekly fits noisy metrics that need smoothing (retention, revenue, engagement). Monthly fits slow totals (costs, growth, quarterly rollups). Match the window to how fast the number actually moves. A daily report on a metric that moves monthly is noise.
3. **Aggregate.** Filter events into the window, sum and count, find the top contributors. Keep the math dumb: counts, sums, averages per day, top N. If a number needs a footnote, it needs metric-definitions first.
4. **Template.** Write one template string with named slots (period, counts, totals, top items). The same template renders every run. Never hand-write the body per run; that is how formats drift.
5. **Silence check.** Compare this run's key figures against the last delivered report. If every figure matches, skip delivery and log "silent: no change" with the run time. A report that says nothing new trains readers to ignore all of them. Exceptions: the first run ever, and any run where the reader asked for confirmation of flat data.
6. **Deliver.** One channel per audience. Email for records people file away. Chat post for numbers a team acts on today. File drop for archives and audits. Write the output, then update the state file so the next silence check has something to compare against.

## Output Contract

Every run produces exactly one of:

- A delivered report: rendered text body plus the channel it went to, plus the state file updated with this run's key figures.
- A silence record: `silent: no change` with run time and the figures that matched. Nothing sent to any channel.

Both outcomes get logged. A silent day that nobody can see looks identical to a broken job.

## Runnable Example

Save this as `report.py` and run it with a data file. Stdlib only, no installs.

```python
"""report.py: minimal recurring-report pipeline (stdlib only).

Usage:
    python3 report.py data.json --window daily|weekly|monthly [--out report.txt]

Reads events like: [{"date": "2026-10-07", "name": "signups", "value": 42}, ...]
Keeps state in state.json next to the script. Applies the stay-silent rule.
"""
import argparse, json, datetime, sys

WINDOWS = {"daily": 1, "weekly": 7, "monthly": 30}

TEMPLATE = (
    "{title}\n"
    "Period: {start} to {end} ({window})\n"
    "Events: {count}\n"
    "Total: {total}\n"
    "Avg per day: {avg_per_day:.1f}\n"
    "Top item: {top_item}\n"
    "Generated: {generated}\n"
)

def aggregate(events, days, end):
    start = end - datetime.timedelta(days=days)
    in_window = [
        e for e in events
        if start <= datetime.date.fromisoformat(e["date"]) <= end
    ]
    total = sum(e["value"] for e in in_window)
    by_name = {}
    for e in in_window:
        by_name[e["name"]] = by_name.get(e["name"], 0) + e["value"]
    top = max(by_name, key=by_name.get) if by_name else "none"
    return {
        "count": len(in_window),
        "total": total,
        "avg_per_day": round(total / days, 1) if days else 0,
        "top_item": top,
        "start": str(start),
        "end": str(end),
    }

def main():
    p = argparse.ArgumentParser()
    p.add_argument("data")
    p.add_argument("--window", default="daily", choices=WINDOWS)
    p.add_argument("--out")
    p.add_argument("--title", default="Periodic Report")
    p.add_argument("--state", default="state.json")
    args = p.parse_args()

    with open(args.data) as f:
        events = json.load(f)
    end = max(datetime.date.fromisoformat(e["date"]) for e in events)
    agg = aggregate(events, WINDOWS[args.window], end)

    key_figures = {"window": args.window, "count": agg["count"],
                   "total": agg["total"], "top_item": agg["top_item"]}
    try:
        with open(args.state) as f:
            prev = json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        prev = {}

    if prev.get("last") == key_figures:
        print("SILENT: no change since last report. Skipping delivery.")
        return 0

    body = TEMPLATE.format(title=args.title, window=args.window,
                           generated=datetime.datetime.now().isoformat(timespec="seconds"),
                           **agg)
    if args.out:
        with open(args.out, "w") as f:
            f.write(body)
        print(f"Report written to {args.out}")
    else:
        print(body, end="")
    with open(args.state, "w") as f:
        json.dump({"last": key_figures}, f, indent=2)
    return 0

if __name__ == "__main__":
    sys.exit(main())
```

Example data file `data.json`:

```json
[
  {"date": "2026-10-07", "name": "signups", "value": 42},
  {"date": "2026-10-07", "name": "sales", "value": 5},
  {"date": "2026-10-06", "name": "signups", "value": 10},
  {"date": "2026-09-30", "name": "signups", "value": 88}
]
```

Run: `python3 report.py data.json --window weekly`. Run it twice; the second run prints the silent line.

## Operating Rules

1. The silence rule is the default. Delivering a no-change report needs an explicit reason, not a shrug.
2. One report, one window, one audience. A report that tries to serve everyone reads like it serves no one.
3. Templates are code. Review the template when numbers look wrong before blaming the inputs.
4. State file goes with the report, in the same directory or job workspace. If the state is lost, the next run reports, not silences, and that is correct.
5. Job mechanics (locks, retries, overlap) belong to cron-discipline. Metric definitions belong to metric-definitions. Do not re-teach either here.
6. Every command, snippet, and behavior in this file was executed and verified on 2026-10-07. Anything time-sensitive should be re-verified on the reader's machine.
