File size: 17,173 Bytes
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
"""harness/windows.py β€” the DATE WINDOW vocabulary (CG-7, owner item 2, 2026-07-26).

A filter condition on a measure reads:

    Where [Sales] [in the last 90 days] [>] [5,000]

The middle bracket is this module. It turns a window SPEC β€” a small JSON object a saved view can
persist β€” into a concrete `(date_from, date_to)` pair of ISO dates.

WHY IT IS ITS OWN MODULE, AND WHY IT TAKES `today` AS AN ARGUMENT
-----------------------------------------------------------------------------------------------
Two engines must agree on what "this quarter" means: this one (which compiles the SQL) and
`customer-grid/windows.ts` (which renders the label the user reads and the bounds the client
engine would use). If they disagree by one day, the grid shows a number the label denies, and
nothing errors. `aios-web/verify_windows.py` holds them in lock-step over a fixed set of probe
dates, exactly as verify_filter_engine.py does for the filter tree.

`today` is a PARAMETER, never `date.today()` read inside. Three reasons, all learned:
  - a gate cannot compare two engines on "now" β€” the two runs are milliseconds apart and land on
    different sides of midnight roughly once every few thousand runs, which is the worst kind of
    flake because it looks like a real divergence
  - a window resolved during a request must not shift between the count query and the row query
  - the tenant's day boundary is the tenant's, not the server's

DESIGN NOTES THAT ARE EASY TO GET WRONG
-----------------------------------------------------------------------------------------------
  - Bounds are INCLUSIVE at both ends and are DATES, not timestamps. `store_query` casts both
    sides to TIMESTAMP and appends 23:59:59 to the upper bound, so a bare date is correct there;
    the client compares the 10-char ISO prefix, so a bare date is correct there too.
  - "Last N days" INCLUDES today. Airtable's "the past week" means the last 7 days up to and
    including now, not the 7 days before yesterday. Off-by-one here silently drops today's
    orders from every "recent" filter β€” invisible until someone asks why a sale they just
    entered is missing.
  - Week starts MONDAY (ISO 8601). Stated rather than defaulted: Python's weekday() is
    Monday=0 and JavaScript's getDay() is Sunday=0, so the two engines disagree unless one of
    them is explicitly corrected. That correction is the single most likely divergence in this
    file, and the gate probes a Sunday and a Monday for exactly that reason.
  - A `custom` window with only one bound is legal and means open-ended on the other side.
  - An UNRESOLVABLE window returns None rather than a default. A window that quietly becomes
    "all time" would widen a filter while still reporting an authoritative count.
"""
import datetime as _dt

#: Every window kind the UI may offer. Mirrors WINDOW_KINDS in customer-grid/windows.ts;
#: verify_windows.py asserts the two lists are identical, because a kind the client can emit and
#: the server cannot resolve is a filter that silently stops narrowing.
WINDOW_KINDS = (
    "all_time",
    "today",
    "yesterday",
    "this_week",
    "last_week",
    "this_month",
    "last_month",
    "this_quarter",
    "last_quarter",
    "this_year",
    "last_year",
    "ytd",
    "ytd_last_year",
    "ltm",
    "past_week",
    "past_month",
    "past_year",
    "last_n_days",
    "next_n_days",
    "custom",
)

#: Kinds that carry an integer `n`.
N_KINDS = frozenset({"last_n_days", "next_n_days"})

#: How each kind reads in a sentence. The UI renders these; they are here so the label and the
#: arithmetic cannot drift apart in the one place a user would never think to check.
WINDOW_LABELS = {
    "all_time": "all time",
    "today": "today",
    "yesterday": "yesterday",
    "this_week": "this week",
    "last_week": "last week",
    "this_month": "this month",
    "last_month": "last month",
    "this_quarter": "this quarter",
    "last_quarter": "last quarter",
    "this_year": "this year",
    "last_year": "last year",
    "ytd": "year to date",
    # "last year to date" (LYTD), not "year to date, last year": the longer phrasing CLIPPED to
    # "year to date, last ye" in the 146px window select β€” and this vocabulary is CLOSED, so
    # the rule for it is that it stays readable rather than that it fits. Seen in a live
    # screenshot of the owner's own comparison; every assertion in that run was green.
    "ytd_last_year": "last year to date",
    "ltm": "the last 12 months",
    "past_week": "the past week",
    "past_month": "the past month",
    "past_year": "the past year",
    "last_n_days": "the last {n} days",
    "next_n_days": "the next {n} days",
    "custom": "a custom range",
}

MAX_N = 3650          # ten years; a bound, not a business rule

# --- the DATE-VALUE anchors (owner item 3, 2026-07-26) ----------------------------------------
# The second half of a date CONDITION, as distinct from a measure's window:
#
#     Where [Last order] [is before] [one month ago]
#                          ^ op        ^ THIS
#
# A window answers "over what period do I sum"; an anchor answers "which single date am I
# comparing against". Both live in this module for one reason: they are the same two-engine
# contract, resolved from the same `today`, and a gate that holds one in lock-step and not the
# other would leave half the sentence free to drift.

#: Anchor modes, in the order the picker offers them. `exact` is the historical behaviour β€” a
#: rule with NO mode is an `exact` rule whose value is an ISO date, which is what every view
#: saved before this change carries.
ANCHOR_MODES = (
    "today",
    "yesterday",
    "one_week_ago",
    "one_month_ago",
    "n_days_ago",
    "exact",
)

#: Modes that carry NO value. ⚠ This set is load-bearing far outside this module: a rule whose
#: value is blank normally reads as INACTIVE, and an inactive rule is IGNORED β€” which WIDENS the
#: result under a count nobody would doubt. Every activeness check (`isRuleActive` in TS,
#: `filter_sql.is_rule_active`, and the validator's value handling) has to know these four are
#: active with an empty value.
ANCHOR_VALUE_FREE = frozenset({"today", "yesterday", "one_week_ago", "one_month_ago"})

ANCHOR_LABELS = {
    "today": "today",
    "yesterday": "yesterday",
    "one_week_ago": "one week ago",
    "one_month_ago": "one month ago",
    "n_days_ago": "{n} days ago",
    "exact": "an exact date",
}


def _iso(d):
    return d.isoformat()


def _month_start(d):
    return d.replace(day=1)


def _month_end(d):
    return _next_month(d.replace(day=1)) - _dt.timedelta(days=1)


def _next_month(d):
    return (d.replace(day=28) + _dt.timedelta(days=4)).replace(day=1)


def _quarter_start(d):
    return _dt.date(d.year, 3 * ((d.month - 1) // 3) + 1, 1)


def _days_in_month(y, m):
    return (_dt.date(y + (m == 12), 1 if m == 12 else m + 1, 1) - _dt.timedelta(days=1)).day


def _shift_months(d, n):
    """`d` moved `n` months, CLAMPING the day to the target month's length.

    Jan 31 back one month is Dec 31, but Mar 31 back one month is Feb 28 (or 29) β€” there is no
    Feb 31 to land on. Both engines must clamp identically or "the past month" differs by up to
    three days for a third of the calendar; the gate probes a 31st and a leap day for exactly it.
    """
    total = (d.year * 12 + (d.month - 1)) + n
    y, m = divmod(total, 12)
    m += 1
    return _dt.date(y, m, min(d.day, _days_in_month(y, m)))


def _parse_date(v):
    """Accept 'YYYY-MM-DD' (and tolerate a longer ISO timestamp by taking its date part)."""
    if isinstance(v, _dt.date):
        return v
    s = str(v or "").strip()[:10]
    if not s:
        return None
    try:
        return _dt.date.fromisoformat(s)
    except ValueError:
        return None


def normalize(spec):
    """Coerce an untrusted window spec to `{kind, n?, from?, to?}` or None.

    Fail-closed on the KIND (an unknown kind is not a window), tolerant on the rest β€” the same
    split `clean_filter_tree` uses, so one malformed window cannot cost a user their saved view.
    """
    if not isinstance(spec, dict):
        return None
    kind = spec.get("kind")
    if kind not in WINDOW_KINDS:
        return None
    out = {"kind": kind}
    if kind in N_KINDS:
        try:
            n = int(spec.get("n"))
        except (TypeError, ValueError):
            return None
        if n < 1 or n > MAX_N:
            return None
        out["n"] = n
    if kind == "custom":
        f, t = _parse_date(spec.get("from")), _parse_date(spec.get("to"))
        if f is None and t is None:
            return None                    # a custom range with no bounds is not a window
        if f is not None and t is not None and f > t:
            f, t = t, f                    # the builder cannot enforce order; the engine can
        if f is not None:
            out["from"] = _iso(f)
        if t is not None:
            out["to"] = _iso(t)
    return out


def resolve(spec, today):
    """Window spec + the tenant's today -> `(date_from, date_to)`, both inclusive ISO dates.

    Either side may be None, meaning open-ended. Returns None when the spec is not a window at
    all β€” callers MUST treat that as "this condition cannot be evaluated" and refuse, never as
    "no window", which would silently widen the result to all time.
    """
    spec = normalize(spec)
    if spec is None:
        return None
    kind = spec["kind"]
    d = _parse_date(today)
    if d is None:
        raise ValueError("resolve() needs an explicit `today` β€” see this module's docstring")

    if kind == "all_time":
        return (None, None)
    if kind == "today":
        return (_iso(d), _iso(d))
    if kind == "yesterday":
        y = d - _dt.timedelta(days=1)
        return (_iso(y), _iso(y))

    # ISO 8601: the week starts MONDAY. Python's weekday() is already Monday=0; the TS mirror
    # has to correct getDay(), which is Sunday=0. The gate probes both a Sunday and a Monday.
    if kind == "this_week":
        start = d - _dt.timedelta(days=d.weekday())
        return (_iso(start), _iso(start + _dt.timedelta(days=6)))
    if kind == "last_week":
        start = d - _dt.timedelta(days=d.weekday() + 7)
        return (_iso(start), _iso(start + _dt.timedelta(days=6)))

    if kind == "this_month":
        return (_iso(_month_start(d)), _iso(_month_end(d)))
    if kind == "last_month":
        prev = _month_start(d) - _dt.timedelta(days=1)
        return (_iso(_month_start(prev)), _iso(prev))

    if kind == "this_quarter":
        qs = _quarter_start(d)
        qe = _month_end(_dt.date(qs.year, qs.month + 2, 1))
        return (_iso(qs), _iso(qe))
    if kind == "last_quarter":
        prev_end = _quarter_start(d) - _dt.timedelta(days=1)
        qs = _quarter_start(prev_end)
        return (_iso(qs), _iso(prev_end))

    if kind == "this_year":
        return (_iso(_dt.date(d.year, 1, 1)), _iso(_dt.date(d.year, 12, 31)))
    if kind == "last_year":
        return (_iso(_dt.date(d.year - 1, 1, 1)), _iso(_dt.date(d.year - 1, 12, 31)))
    if kind == "ytd":
        # year-to-date ENDS TODAY, unlike this_year which runs to Dec 31. The distinction is the
        # whole reason both exist: comparing "this year" against last year double-counts the
        # months that have not happened yet.
        return (_iso(_dt.date(d.year, 1, 1)), _iso(d))
    if kind == "ytd_last_year":
        # SAME PERIOD last year β€” Jan 1 LY through today's month/day LY. This is the honest
        # partner of `ytd`, and it is not a nicety: it mirrors `core.periods.ytd_last_year`
        # EXACTLY (including the Feb 29 -> Feb 28 clamp), which is what `pool()` computes the
        # `revenue_ly` column from. `Sales[ytd] < Sales[ytd_last_year]` therefore reproduces the
        # retired `at_risk > 0` condition rather than approximating it. Comparing `ytd` against
        # `last_year` instead would pit seven months against twelve.
        return (_iso(_dt.date(d.year - 1, 1, 1)), _iso(_shift_months(d, -12)))
    if kind == "ltm":
        # the last twelve months INCLUDING today: 365 days back, both ends inclusive
        return (_iso(d - _dt.timedelta(days=364)), _iso(d))

    # "The past week/month/year" are ROLLING and end TODAY β€” Airtable's wording, and NOT the
    # calendar kinds above: `last_week` is the previous Monday-Sunday, `past_week` is the seven
    # days ending today. Both are offered because a user means different things by them, and
    # picking one to serve both would silently answer the other question.
    #   past_week  = the same rule as last_n_days(7) β€” one period back, PLUS ONE DAY, so today
    #                is included and the span is exactly 7 days rather than 8.
    #   past_year  agrees with `ltm` except across a leap day, where the calendar shift keeps the
    #              same month/day and the 364-day subtraction cannot.
    if kind == "past_week":
        return (_iso(d - _dt.timedelta(days=6)), _iso(d))
    if kind == "past_month":
        return (_iso(_shift_months(d, -1) + _dt.timedelta(days=1)), _iso(d))
    if kind == "past_year":
        return (_iso(_shift_months(d, -12) + _dt.timedelta(days=1)), _iso(d))

    if kind == "last_n_days":
        # INCLUSIVE of today β€” "the last 7 days" is today and the 6 before it, not the 7 before
        # yesterday. Getting this wrong drops today's orders from every recent-activity filter.
        return (_iso(d - _dt.timedelta(days=spec["n"] - 1)), _iso(d))
    if kind == "next_n_days":
        return (_iso(d), _iso(d + _dt.timedelta(days=spec["n"] - 1)))

    if kind == "custom":
        return (spec.get("from"), spec.get("to"))
    raise AssertionError(f"unhandled window kind {kind!r}")   # unreachable; WINDOW_KINDS is closed


def label(spec):
    """How a window reads in the condition sentence ('in the last 90 days')."""
    spec = normalize(spec)
    if spec is None:
        return "an invalid range"
    return WINDOW_LABELS[spec["kind"]].format(n=spec.get("n"))


def resolve_anchor(mode, value, today):
    """A date condition's right-hand side -> ONE inclusive ISO date, or None.

    `mode` is an entry of ANCHOR_MODES; None/'' means `exact`, which is what every view saved
    before anchors existed carries (op + an ISO date in `value`). `value` supplies the number for
    `n_days_ago` and the date for `exact`, and is ignored by the four value-free modes.

    Returns None when the anchor cannot be resolved β€” an unknown mode, a non-numeric N, a value
    that is not a date. The caller must then treat the CONDITION as unanswerable and match
    nothing. It must NOT fall through to "no condition": that widens the result while the count
    beside it still looks authoritative, which is the whole reason this returns None rather than
    a best guess (same contract as `resolve()` above).

    `n_days_ago` accepts n = 0 where a WINDOW requires n >= 1. A zero-day window is empty and
    could only be a mistake; "0 days ago" is today, which is a date a person can mean.
    """
    m = mode or "exact"
    if m not in ANCHOR_MODES:
        return None
    d = _parse_date(today)
    if d is None:
        raise ValueError("resolve_anchor() needs an explicit `today` β€” see this module's "
                         "docstring on why the clock is never read here")
    if m == "today":
        return _iso(d)
    if m == "yesterday":
        return _iso(d - _dt.timedelta(days=1))
    if m == "one_week_ago":
        # A week ago is a DATE (today minus 7), not the past-week RANGE. The two read almost
        # identically in English and mean different things in a comparison.
        return _iso(d - _dt.timedelta(days=7))
    if m == "one_month_ago":
        return _iso(_shift_months(d, -1))
    if m == "n_days_ago":
        s = str("" if value is None else value).strip()
        # `[0-9]` not `\d`: a Python \d matches every Unicode decimal digit, so a fullwidth
        # '30' would resolve here and be rejected by the TS mirror's Number() β€” the exact
        # two-engine divergence filter_sql.to_num was rewritten to avoid.
        if not s or not all("0" <= ch <= "9" for ch in s):
            return None
        n = int(s)
        # No `n < 0` guard: the digit test above already excludes a sign, so it would be
        # unreachable. The negative control proved that β€” removing it changed nothing, which is
        # how dead code hides in a defensive-looking line. The CAP is the live guard.
        if n > MAX_N:
            return None
        return _iso(d - _dt.timedelta(days=n))
    parsed = _parse_date(value)                      # exact
    return None if parsed is None else _iso(parsed)


def anchor_label(mode, value):
    """How an anchor reads in the condition sentence ('one month ago', '30 days ago')."""
    m = mode or "exact"
    if m not in ANCHOR_MODES:
        return "an invalid date"
    if m == "n_days_ago":
        return ANCHOR_LABELS[m].format(n=str("" if value is None else value).strip() or "N")
    if m == "exact":
        return str("" if value is None else value).strip() or ANCHOR_LABELS[m]
    return ANCHOR_LABELS[m]