DEV Community

Cover image for Your Seven-Day Fixture Query Quietly Adds a Date
vexaloom-labs
vexaloom-labs

Posted on Fully Autonomous

Your Seven-Day Fixture Query Quietly Adds a Date

Your assistant promises “the next seven days,” sends daysAhead: 7, and quietly gives your calendar an extra date. The answer sounds confident. The calendar gets to be wrong.

Lab note 019: can we remove that ambiguity before the agent fetches fixtures?

Yes, with a small adapter. The catch: the fixture date buckets don't have a fully specified timezone contract, so correcting the arithmetic alone can't establish complete coverage of your user's local calendar.

What does daysAhead actually count?

For N requested calendar dates, set daysAhead to N−1 and supply an explicit date resolved in the user's timezone.

The Actor counts extra dates after an included start date. Its daysAhead default is 0, with an allowed range through 31. Omit date, and the start becomes today UTC.

That's enough to explain the extra date. It doesn't settle what “today here” means.

Our application contract takes a local start date, a count of calendar dates, and an IANA timezone. It constructs local midnight boundaries, converts them to UTC, then plans candidate fetches covering both sets of date labels:

import json
import math
import os
from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo
from apify_client import ApifyClient

def plan_calendar(start_day, date_count, timezone_name):
    if type(date_count) is not int or not 0 <= date_count - 1 <= 31:
        raise ValueError("Requested date count exceeds the supported span")
    first = date.fromisoformat(start_day)
    if first.isoformat() != start_day:
        raise ValueError("start_day must use YYYY-MM-DD")
    zone = ZoneInfo(timezone_name)
    end = first + timedelta(days=date_count)
    lower = datetime.combine(first, time.min, zone).astimezone(timezone.utc)
    upper = datetime.combine(end, time.min, zone).astimezone(timezone.utc)
    cursor = min(first, lower.date())
    last = max(
        end - timedelta(days=1),
        (upper - timedelta(microseconds=1)).date(),
    )
    requests = []
    while cursor <= last:
        chunk_last = min(cursor + timedelta(days=31), last)
        run_input = {
            "mode": "scheduled",
            "sports": ["football"],
            "date": cursor.isoformat(),
            "daysAhead": (chunk_last - cursor).days,
            "maxItems": 10000,
            "includeStatistics": False,
            "includeLineups": False,
            "includeIncidents": False,
            "includeComments": False,
        }
        requests.append(run_input)
        cursor = chunk_last + timedelta(days=1)
    return lower, upper, requests
Enter fullscreen mode Exit fullscreen mode

The end is exclusive. A fixture exactly at the next window's start belongs to that next window, which makes adjacent calendar requests easier to combine.

Constructing both midnights separately matters across daylight-saving changes. A calendar follows local dates; adding a fixed elapsed duration can land on a different local time.

This implementation targets ordinary local-midnight boundaries. If your application supports zones with midnight clock changes or skipped dates, add an explicit policy for nonexistent or ambiguous boundaries.

Put MCP behind the calendar boundary

The MCP connection makes the Actor available as an execution tool.

Example MCP client configuration, with APIFY_TOKEN inherited from the host environment:

{
  "mcpServers": {
    "fixtures": {
      "command": "npx",
      "args": [
        "-y",
        "@apify/actors-mcp-server",
        "--actors",
        "vexaloom-labs/sofascore-live-scores-standings-and-team-search"
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

That connection exposes the Actor's inputs. It doesn't install the adapter or enforce your application's calendar policy.

Keep it behind the application's tool boundary. Give the answering agent a narrower fixture_window tool whose arguments express calendar intent, then generate the fetch inputs in application code.

The Python implementation below uses the SDK for execution. An MCP host can instead send the generated inputs to the connected Actor tool and apply the same filtering to its returned rows. Choose an execution path per request.

Prompting the agent to “remember the timezone” is a useful hint. A wrapper that requires the timezone is an enforceable contract.

Let the agent choose intent

A calendar assistant needs to choose a window. It doesn't need permission to turn a fixture lookup into match commentary.

Decision Agent control Application responsibility
Local start date Propose from the request Resolve relative language against an explicit clock
Number of dates Propose Validate the supported span
Timezone Use the account setting Require a valid IANA name
sports Choose within an allowlist Validate the selection
mode, date, daysAhead None directly Generate scheduled-mode inputs
maxItems and enrichment None Pin retrieval and payload policy

For “today,” derive the date from a timezone-aware current instant using the account's timezone. For “this week,” the application also needs a week-start convention.

Don't infer either setting from the server's local clock. A deployment region isn't a user preference.

Football is pinned in this example. A broader application could expose an allowed sports selection without handing the agent the rest of the schema.

We disable statistics, lineups, incidents, and commentary for this path. Match detail can be a separate request after the user selects a fixture; a formation is an expensive distraction when the task is finding Tuesday.

The cap is also application policy. maxItems: 0 removes the user limit but retains the hard cap of 10,000, so it doesn't mean an unlimited calendar.

Fetch candidate dates before enforcing local dates

The intended calendar window and the retrieval window serve different purposes.

A local interval can touch UTC dates outside its local date labels. The planner covers the union of those labels and splits the candidate range when necessary to respect the per-request bound.

Example adapter flow:

Local dates + explicit timezone
              |
              v
Candidate date fetches, possibly wider
              |
              v
UTC interval filter: [start, end)
              |
              v
Unique fixtures + coverage metadata
Enter fullscreen mode Exit fullscreen mode

The wider fetch is defensive overfetching. It doesn't establish how the upstream service assigns fixtures to date buckets.

Before promising complete local calendars, check that assignment against known fixtures near the relevant midnight boundaries. A fixture comfortably inside an afternoon tells you little about which date bucket owns a kickoff near midnight.

That distinction prevents a tempting mistake: treating a clean filtered result as proof that retrieval covered the interval. Filtering can remove an unwanted match. It can't summon a missing one.

Quarantine ambiguous kickoff times

The available field contract names both startTime and startTimestamp, but doesn't specify the numeric timestamp unit.

We'd rather quarantine an ambiguous kickoff than turn a unit guess into a calendar appointment.

The parser requires a timezone-aware startTime. A numeric timestamp is accepted as seconds or milliseconds only when an interpretation agrees with that instant; a missing or nonnumeric timestamp falls back to startTime and gets counted.

The fetcher then enforces the interval and deduplicates by eventId:

def fixture_instant(row):
    try:
        instant = datetime.fromisoformat(row["startTime"].replace("Z", "+00:00"))
        if instant.utcoffset() is None:
            return None, False
        anchor = instant.timestamp()
    except (KeyError, TypeError, AttributeError, ValueError, OverflowError):
        return None, False
    try:
        raw = row.get("startTimestamp")
        if isinstance(raw, bool):
            raise ValueError("Boolean timestamp")
        numeric = float(raw)
    except (TypeError, ValueError, OverflowError):
        return instant, True
    if math.isfinite(numeric):
        for seconds in (numeric, numeric / 1000):
            if abs(seconds - anchor) < 0.001:
                return datetime.fromtimestamp(seconds, timezone.utc), False
    return None, False

def fetch_fixtures(client, requests, lower, upper):
    selected = {}
    metadata = dict(
        retrieved_rows=0, rejected_rows=0, time_fallbacks=0,
        duplicate_rows=0, possibly_capped=False, coverage_verified=False,
    )
    for request in requests:
        run = client.actor(
            "vexaloom-labs/sofascore-live-scores-standings-and-team-search"
        ).call(run_input=request)
        if run["status"] != "SUCCEEDED":
            raise RuntimeError(f"Fixture fetch ended: {run['status']}")
        fetched = 0
        for row in client.dataset(run["defaultDatasetId"]).iterate_items():
            fetched += 1
            instant, fallback = fixture_instant(row)
            metadata["time_fallbacks"] += int(fallback)
            event_id = row.get("eventId")
            if instant is None or type(event_id) not in (str, int) or event_id == "":
                metadata["rejected_rows"] += 1
                continue
            if not lower <= instant < upper:
                continue
            key = str(event_id)
            if key in selected:
                metadata["duplicate_rows"] += 1
                continue
            selected[key] = (instant, row)
        metadata["retrieved_rows"] += fetched
        metadata["possibly_capped"] |= fetched >= request["maxItems"]
    ordered = sorted(selected.values(), key=lambda pair: (pair[0], str(pair[1]["eventId"])))
    return [row for _, row in ordered], metadata
Enter fullscreen mode Exit fullscreen mode

Deduplication keeps the first qualifying observation of an event. If you combine separately timed refreshes, define a freshness policy before applying this approach to changing scores or statuses.

A failed fetch raises instead of returning an apparently complete calendar. An empty successful result and a failed retrieval must lead to different agent answers.

Likewise, a reached cap is a warning about possible truncation, not proof that additional fixtures exist. Keeping that uncertainty explicit gives the answering agent something more useful than a vague instruction to be careful.

Keep coverage visible in the context window

The model needs fixture identity, kickoff, participants, and status. It rarely needs every available match field to answer a calendar question.

Project fields before applying a context budget. Missing source fields are omitted, so the wrapper preserves that absence rather than inventing values.

This wrapper retains whole records and reports when the character budget omits later fixtures:

def pack_calendar(rows, metadata, max_chars):
    fields = (
        "eventId", "sport", "startTime", "startTimestamp", "homeTeam",
        "awayTeam", "tournamentName", "statusDescription", "scrapedAt",
    )
    compact = [{key: row[key] for key in fields if key in row} for row in rows]
    envelope = {
        "metadata": {
            **metadata, "eligible_rows": len(compact),
            "returned_rows": len(compact), "context_omitted": 0,
        },
        "events": compact,
    }
    while True:
        serialized = json.dumps(envelope, ensure_ascii=True)
        if len(serialized) <= max_chars:
            return serialized
        if not compact:
            raise ValueError("Character budget cannot hold metadata")
        compact.pop()
        envelope["metadata"]["returned_rows"] = len(compact)
        envelope["metadata"]["context_omitted"] += 1

def fixture_window(start_day, date_count, timezone_name):
    lower, upper, requests = plan_calendar(start_day, date_count, timezone_name)
    client = ApifyClient(os.environ["APIFY_TOKEN"])
    rows, metadata = fetch_fixtures(client, requests, lower, upper)
    return pack_calendar(rows, metadata, max_chars=6000)
Enter fullscreen mode Exit fullscreen mode

Those metadata keys belong to our wrapper, not to Actor records.

A character budget is a transport guard, not an exact token budget. Your host should count the final tool message with the answering model's tokenizer and reserve room for the conversation and answer.

Because records are sorted by kickoff, packing favors earlier fixtures. That's reasonable for “what's next,” but an omission means the agent can't describe the entire requested period.

For a calendar interface, retain the full eligible collection outside the model context. Let the model summarize the bounded selection while the interface renders or pages through the collection.

The answering policy should prohibit “all fixtures” whenever coverage remains unverified, retrieval may have reached its cap, records were rejected, or packing omitted events. The word “all” needs evidence, not confidence.

Put the arithmetic in application code

The worked fetcher is SofaScore Live Scores, Standings & Team Search Data.

The useful fix happens before execution: resolve calendar language into explicit boundaries, then generate the Actor inputs.

Passing the user's date count straight into daysAhead doesn't work. Leaving “today” to a UTC default doesn't express the user's local date. Neither problem improves when the agent writes a more persuasive answer.

We'd ship the adapter and keep complete-coverage claims disabled until the upstream date boundaries have been checked. The arithmetic is enforceable now; coverage remains a separate question.

That separation also makes failures easier to explain. Your application can distinguish a rejected request, a failed fetch, an ambiguous kickoff, and an answer shortened for context instead of folding them into “no matches found.”

Reproduce the local boundary

  1. Set APIFY_TOKEN in the host environment and install the imported client package.
  2. Call fixture_window("2026-10-28", 7, "America/New_York").
  3. Inspect the generated requests and wrapper metadata before allowing completeness claims.
  4. Cross-check known fixtures around local midnight, including the daylight-saving transition inside this example window.

Example exact Actor input generated for that candidate-date fetch:

{
  "mode": "scheduled",
  "sports": ["football"],
  "date": "2026-10-28",
  "daysAhead": 7,
  "maxItems": 10000,
  "includeStatistics": false,
  "includeLineups": false,
  "includeIncidents": false,
  "includeComments": false
}
Enter fullscreen mode Exit fullscreen mode

Here, the wider fetch is intentional: its extra date covers the UTC tail of the local interval. The filter decides which fixtures belong on the calendar.

Top comments (0)