Skip to content

fix(session-load): one sample row whose timestamp is not a date stops the session opening — or plots it 27,000 years wide (#237) - #240

Merged
tylerkron merged 5 commits into
mainfrom
fix/237-session-load-timestamp-range
Sep 5, 2026
Merged

tylerkron merged 5 commits into
mainfrom
fix/237-session-load-timestamp-range

Conversation

@tylerkron

@tylerkron tylerkron commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Not merging — for review.

closes #237

What a user hits

You open a saved session and it does not open. The session viewer throws
ArgumentOutOfRangeException and the whole load is abandoned — every channel, every
sample — because one row in that session holds a timestamp value that is not a date.

Samples.TimestampTicks is a plain SQLite INTEGER with no CHECK, so it holds
anything a long can, while new DateTime(long) accepts only
0 .. DateTime.MaxValue.Ticks. SessionDataRepository converted the stored value at
two sites without checking it — LoadInitialSession (Phase 1) and LoadSampledData
(Phase 2).

Reachability is the argument #231 makes for Samples.Color and no stronger: no
current write path can produce such a value, because every producer starts from a
DateTime. The exposure is a database file that predates the current schema —
SQLite does not re-validate rows already sitting in an existing file, and both apps
moved to SQLite in 2025-08, so genuinely older stores exist. This is hardening on the
same footing as #231, not a live crash report. A_session_database_can_hold_a_tick_value_no_date_can_represent
builds such a file rather than assuming one is possible.

The failure mode the issue ruled out, which is real

Running the new tests against unchanged main first turned up a second outcome the
issue explicitly denied ("there is no silent-wrong-render variant — new DateTime
either succeeds or throws"). There is one, and which one you get depends on which
end of the range the damaged row sits at
, because both sites convert the session's
MINIMUM tick value and nothing else:

damaged row is it the session minimum? what happens
below the range (negative) yes ArgumentOutOfRangeException, session will not open
above the range (long.MaxValue) no, whenever any healthy row exists nothing throws. It is plotted at 858434381285477.6 ms — about 27,000 years — from the origin

The second one is worse for being quiet: it stretches the X axis so far that the real
data collapses to a single vertical line at zero. The session "loads", and is
unreadable. A_sample_above_the_range_is_silently_plotted_27000_years_out pins it, and
it is the reason the remedy below is skip rather than clamp.

What happens to the bad row now — please read this bit

It is skipped. The sample is dropped from the graph, from the point lists, from
TotalSampleCount, and from the single-timestamp value spread. That is a data-loss
decision, so it is not silent: one
_appLogger.Warning per load names the session and the offending tick bounds, e.g.

Session 1: sample timestamps outside the range a date can represent (stored ticks
-1..9223372036854775807); those samples are skipped and the rest of the session is loaded.

Skipping was chosen over clamping deliberately. The plot's X axis is a delta from the
session's first timestamp, so clamping the origin would shift every point on the
graph — trading a load that fails loudly for one that succeeds and is wrong, which is
exactly the second row of the table above. A healthy session is never warned about
(A_healthy_session_is_not_warned_about).

How

One shared range, applied where the rows enter the load rather than where they are
converted — a readableQuery in Phase 1, two extra SQL predicates in Phase 2, and the
same predicate on the single-timestamp fallback (see the round-1 section below). Two
sites throwing the same way wanted one guard, not two try/catches. Filtering at the
query rather than at the conversion also settles three things a try/catch around
new DateTime would have left open:

  • Channel discovery. It keys off the session's first timestamp, so a damaged row
    sorting below the real data would be that timestamp — and only the channel on that
    row would be discovered. Every other channel's samples are then dropped, because a
    point list is seeded only for the channels found there. Losing three channels out of
    four is a data loss the crash was hiding
    (A_damaged_row_does_not_shrink_the_discovered_channel_set).
  • The sample count. It is what the caller compares against INITIAL_LOAD_POINTS to
    decide whether the expensive full-range load runs; rows that will never be drawn must
    not drive that decision.
  • minTicks + i * tickStep, the overflow the issue notes at the end. With both
    operands now bounded by DateTime.MaxValue.Ticks the sum cannot leave long, so it
    is gone by construction rather than by a second guard.

Detecting the damage costs one extra indexed row-read on a healthy load: the session's
tick bounds are two ordered LIMIT 1 reads off IX_Samples_LoggingSessionID_TimestampTicks
(the same "instant via composite index" idiom the first-timestamp read here has always
used), not a second COUNT, which would have added a whole index pass to every healthy
load to detect a row almost no load has. Only a damaged session pays for a third read.

Samples.TimestampTicks is now the only thing filtered — no config knob, no lenient
mode, no validation layer.

A third path, found in Qodo round 1

Skipping the row from the two loads was not enough, and the reviewer was right to
push on it. Both viewers have a third path: when a session is past
INITIAL_LOAD_POINTS and LoadSampledData finds no time range to sample across, they
fall back to LoadSingleTickValueSpread, which draws each channel as a MIN..MAX
vertical segment aggregated over the whole session — and that aggregation was keyed on
the session id alone. The damaged row's value was therefore still the segment's
extreme: skipped on one path and drawn on another.

This change is also what made that path more reachable, which is why the fix belongs
here rather than in a follow-up — excluding unreadable rows from the bounds is exactly
what makes a damaged session's readable rows share one timestamp, and therefore what
sends the caller down that branch.

The_single_timestamp_fallback_does_not_bring_a_skipped_row_back_as_a_channel_extreme
walks the whole chain rather than calling the fallback directly, because "the fallback
is reached" is half the claim: it asserts the count is over the cap, asserts
LoadSampledData returns null, and only then checks the spread. Its 100,001 rows go in
with one recursive-CTE insert, so it still runs in about a second. Verified failing with
only the new filter reverted — the spread came back with two points, 1.0 and
999999.0, instead of one.

Relationship to #233 (open, unmerged)

Same convention, deliberately different remedy, and no overlap in files.

#233 introduced ChannelSeriesColor.ParseOrFallback — one place that normalises a
distrusted stored value so a damaged row cannot abort the session load. This follows
that shape: one place that decides what an untrustworthy stored value becomes, applied
at every site on the path, never throwing. Where it differs is the remedy, and the
difference is in the data, not in taste: a fallback colour is cosmetically wrong and
harmless, whereas a fallback timestamp moves the plot origin and shifts every point.
So colour falls back and timestamps are skipped. #233's guard is also silent because it
runs per-sample on the live path; this one warns because it runs once per session load.

Deliberately out of scope

  • DataSample.cs:84 — IDataSample.Timestamp => new(TimestampTicks), the third
    site the issue names. It is an explicit interface implementation with no in-repo
    caller
    (grep IDataSample finds only the declaration), and it is not on the session
    load path. Guarding it now would be speculative.
  • PlotLogger.cs:274 — FirstTime ??= new DateTime(dataSample.TimestampTicks) has
    the same shape but sits on the live stream path: both ILogger.Log call sites in
    LoggingManager are behind a CurrentMode != LoggingMode.Stream guard, so it only
    ever sees a sample the device just produced, whose ticks came from a DateTime. It is
    also in a file fix(session): a legacy row's missing channel colour aborts the session load — or silently erases the channel #233 is currently rewriting.

Verification

  • 14 of the 18 new test cases fail against unchanged origin/main — 10 throwing
    ArgumentOutOfRangeException out of the load (7 at SessionDataRepository.cs:184,
    3 at :268), and 4 failing on an assertion having thrown nothing at all: the silent
    variant. Measured by checking the merge base out, applying only the final test file to
    it and running it — re-measured after the round-1 test was added, since adding a
    test moves the denominator. The 4 that pass unfixed are meant to: the reachability
    demonstration, the no-false-alarm guard, and the two range edges.
  • There were no guarding tests to strengthen. SessionDataRepository had no test
    file at all before this; that absence is itself part of the finding.
  • Zero ticks is a date (DateTime.MinValue) and so is DateTime.MaxValue.Ticks.
    Both are kept, which is why the guard is a range test and not a "is it positive?"
    test — The_edges_of_the_representable_range_are_kept covers each.
  • dotnet build Daqifi.Avalonia.Desktop — green.
  • dotnet restore + dotnet build Daqifi.Avalonia.iOS -c Release -r iossimulator-arm64 --no-restore — green.
  • dotnet test — 485 passed, 0 failed (18 of them new, so 467 pre-existing).
  • Warnings: no first-party warning added. Measured against my own merge base rather
    than a quoted constant, never against a remembered number: clean --no-incremental
    builds of both heads on both branches in the same worktree, warning lines normalised
    (third_party/ and SDK-pack warnings excluded, MSBuild node prefixes stripped) and the
    resulting sets diffed. Desktop 126 → 126 and iOS Release 126 → 126 unique first-party
    warnings, with an empty added set and an empty removed set on each.
    Those are unique
    path + code + message tuples; the raw build lines the two heads print (193 desktop,
    194 iOS) count the same warning once per build pass. Diffing with line numbers kept
    shows exactly one difference across both heads: a pre-existing CS8604 in this file
    moves from line 164 to 232 — the nullable Color that Session load throws on a sample row with a null or blank Color #231 is about.
  • packages.lock.json unchanged; no -r reached restore.
  • The Android head does not build on this Mac (workload absent) — left to CI.

Size

SessionDataRepository.cs is +155 / −51, but only +19 of that is code: +79 net is
comment and XML doc, +6 net is blank lines. The four repeated ADO.NET
CreateParameter/ParameterName/Value/Add blocks are folded into one AddParameter
helper, which is what pays for the guard; the rest of the diff is the 473-line test file
that did not exist. SessionDataRepository had no test file at all before this.

🤖 Generated with Claude Code

tylerkron and others added 2 commits September 4, 2026 14:22
…237)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…stead of failing the load (#237)

`Samples.TimestampTicks` is a plain SQLite INTEGER with no CHECK, so it can
hold anything a long can, while `new DateTime(long)` accepts only
0..DateTime.MaxValue.Ticks. `SessionDataRepository` converted the value at two
sites without checking it, and a saved session holding one such row would not
open.

Running the new tests against the unchanged code turned up a second failure
mode the issue had ruled out. Both sites convert only the session MINIMUM, so
which failure you get depends on which end the damaged row sits at: a negative
row IS the minimum and throws, but an above-range row is not, so nothing throws
— it is quietly plotted ~27,000 years from the origin, which stretches the X
axis until the real data is a vertical line at zero.

Both are fixed by excluding unreadable rows from the queries the session is
read through, so the damaged row is gone before anything converts or plots it.
That also puts the exclusion ahead of channel discovery, which keys off the
first timestamp: a damaged row sorting below the real data would otherwise BE
that timestamp, and only its own channel would be discovered.

Rows are skipped, not clamped — a clamped origin would shift every delta-time
on the plot — and one warning per load names the session and the offending
tick bounds.

Also folds the file's four repeated ADO.NET parameter blocks into one helper,
which is what keeps the change net-negative outside the tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Skip invalid timestamps when loading saved sessions

🐞 Bug fix 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Skip sample rows whose stored ticks fall outside the representable DateTime range.
• Preserve channel discovery, counts, and plotting using only readable timestamps.
• Warn once per damaged load and cover both loading phases with regression tests.
Diagram

graph TD
    A[("SQLite samples")] --> B["Tick range filter"] --> C["Readable rows"] --> D["Channel discovery"] --> E["Initial plot"]
    C --> F["Sampled plot"]
    B -->|"invalid bounds"| G["Warning log"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Add a database CHECK constraint
  • ➕ Prevents newly written out-of-range tick values at the storage boundary.
  • ➕ Makes the timestamp invariant explicit in the schema.
  • ➖ Does not repair or make existing legacy rows readable without a data migration.
  • ➖ Current application write paths already originate from valid DateTime values.
  • ➖ A migration must still decide whether to delete, transform, or quarantine damaged rows.
2. Clamp invalid timestamps
  • ➕ Retains every sample row and avoids conversion exceptions.
  • ➕ Requires relatively simple normalization logic.
  • ➖ Invents timestamps with no reliable relationship to the original data.
  • ➖ Can shift the plot origin or place samples at extreme positions, producing misleading graphs.
  • ➖ Silently preserves the unreadable-session failure as incorrect visualization.

Recommendation: Query-time filtering is the safest immediate approach because it handles existing databases, preserves all readable session data, and avoids inventing timestamps. A future CHECK constraint could complement this defense, but cannot replace it for legacy stores; clamping should remain rejected because it produces misleading plots.

Files changed (2) +547 / -51

Bug fix (1) +147 / -51
SessionDataRepository.csFilter unreadable timestamps before session discovery and plotting +147/-51

Filter unreadable timestamps before session discovery and plotting

• Restricts initial and sampled session queries to DateTime-compatible tick values, uses the first readable timestamp as the plot origin, and excludes invalid rows from channel discovery and sample counts. Detects unfiltered timestamp bounds to emit one warning per initial load, handles fully damaged sessions as empty, and centralizes ADO.NET parameter creation.

Daqifi.Avalonia/Daqifi.Desktop/Loggers/SessionDataRepository.cs

Tests (1) +400 / -0
SessionTimestampRangeTests.csCover malformed timestamp behavior across both session-loading phases +400/-0

Cover malformed timestamp behavior across both session-loading phases

• Adds SQLite-backed regression tests that inject tick values outside the DateTime range and verify they are skipped without aborting session loads. Coverage includes exact boundaries, channel discovery, sample counts, warning behavior, empty damaged sessions, and initial and full-range plotting.

Daqifi.Avalonia.Tests/Loggers/SessionTimestampRangeTests.cs

@qodo-code-review

qodo-code-review Bot commented Sep 4, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Fallback reintroduces skipped samples ✓ Resolved 🐞 Bug ≡ Correctness
Description
When more than 100,000 readable samples share one timestamp, the filtered Phase 2 returns null and
callers invoke LoadSingleTickValueSpread, whose unfiltered aggregation includes values from
out-of-range timestamp rows. A damaged row on a discovered channel can therefore distort the graph
instead of being skipped as intended.
Code

Daqifi.Avalonia/Daqifi.Desktop/Loggers/SessionDataRepository.cs[R344-345]

+                WHERE LoggingSessionID = @id
+                  AND TimestampTicks BETWEEN @minReadable AND @maxReadable";
Relevance

●●● Strong

PR #26 accepted this fallback path; consistent bounds are needed to prevent skipped unreadable rows
from reappearing.

PR-#26

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new bounds predicate makes LoadSampledData return null when all readable rows have one
timestamp. Both viewers then use the single-tick fallback, but that fallback filters only by session
ID before computing per-channel MIN/MAX across all rows, so an out-of-range row's value is plotted
at X=0; past PR #26 confirms this fallback is the required path for large single-timestamp sessions.

Daqifi.Avalonia/Daqifi.Desktop/Loggers/SessionDataRepository.cs[341-363]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/SessionDataRepository.cs[469-494]
Daqifi.Avalonia/Daqifi.Desktop/Loggers/DatabaseLogger.cs[364-383]
Daqifi.Avalonia/Views/Mobile/LoggedSessionsMobileViewModel.cs[423-448]
PR-#26

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The sampled-data bounds now exclude invalid timestamps, but the single-timestamp fallback aggregates values from every session row. If a large session's readable rows all share one timestamp, out-of-range rows can therefore contribute plotted minima or maxima.

## Issue Context
Both desktop and mobile callers invoke `LoadSingleTickValueSpread` when the filtered `LoadSampledData` returns null. Apply the same inclusive readable-tick filter to the spread aggregation, and add a regression test using more than `INITIAL_LOAD_POINTS` readable same-timestamp rows plus an invalid-timestamp row with an extreme value.

## Fix Focus Areas
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SessionDataRepository.cs[344-345]
- Daqifi.Avalonia/Daqifi.Desktop/Loggers/SessionDataRepository.cs[469-480]
- Daqifi.Avalonia.Tests/Loggers/SessionTimestampRangeTests.cs[300-310]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
Review mode: ⚖️ Balanced: The push changes runtime session-loading behavior to filter invalid timestamps, including a data-loss and fallback-path decision with meaningful correctness risk.

Grey Divider

Tip of the day
💡 Did you know, you can keep summaries lean with Finding overflow, which tucks the rest behind 'View more'

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

tylerkron and others added 3 commits September 4, 2026 14:53
…name the boundary theory for what it covers

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…w back as a channel extreme (#237)

Qodo review round 1. Skipping unreadable rows from the two loads was not
enough on its own: both viewers have a third path. When a session is past
INITIAL_LOAD_POINTS and LoadSampledData finds no time range to sample across,
they fall back to LoadSingleTickValueSpread, which draws each channel as a
MIN..MAX vertical segment aggregated over the whole session — keyed on the
session id alone. A damaged row's VALUE was therefore still the segment's
extreme: skipped on one path, drawn on another.

This change is also what made that path more reachable, which is why the fix
belongs here rather than in a follow-up. Excluding unreadable rows from the
bounds is exactly what makes a damaged session's readable rows share one
timestamp, and therefore what sends the caller down that branch.

The regression test exercises the whole chain — over the cap, no time range,
then the fallback — rather than calling the fallback directly, since "the
fallback is reached" is half the claim. Its 100,001 rows go in with one
recursive-CTE insert, so it still runs in about a second. Verified failing
with only the new filter reverted: the spread came back with two points,
1.0 and 999999.0, instead of one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd-1 test was added (13/17 -> 14/18)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit bce7240

@tylerkron

Copy link
Copy Markdown
Contributor Author

Qodo-clean, CI green — ready for review at bce724022736ca2cd1a73efaf3355515f910c6b2.

Shepherd pass. I pushed nothing, so the head is still the SHA both Qodo and CI actually ran on. The verification behind this note is CI's on bce7240, not a local re-build — I read run 33919548534's own logs (its headSha is bce7240) rather than trusting the numbers in the description: 6 SUCCESS + 1 skipped, Android and iOS included.

Qodo

Round 1 (up to ce571ad) raised one High finding, Fallback reintroduces skipped samples, and it arrived only as an inline review thread — the summary read Bugs (0) the whole time. Round 2 re-ran on bce7240: 🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0), with that finding struck through as Resolved. Both surfaces re-checked twice, ≥3 minutes apart, against that same SHA — summary comment unchanged, and reviewThreads filtered on the qodo author with isResolved: false returns 0.

Re-derived rather than taken on trust

Every figure below I measured myself; none is quoted from the description.

  • +155 / −51, of which only +19 is code. Classifying every added/removed line: code +61 / −42 = +19, doc + comment +86 / −7 = +79, blank +8 / −2 = +6. 19 + 79 + 6 = 104 = 155 − 51.
  • 18 new cases — 13 [Fact] plus two [Theory] carrying 3 and 2 [InlineData].
  • 14 of 18 fail against unchanged origin/main. Re-measured on a throwaway branch at the merge base a74933d with only the test file checked out onto it: Failed: 14, Passed: 4, Total: 18. The 10/4 split holds too — exactly 10 throw ArgumentOutOfRangeException, and the other 4 fail an assertion having thrown nothing. The silent variant reproduces verbatim: delta-time 858434381285477.6 ms is not from a healthy row.
  • The suite count closes from both ends, out of CI's own logs. Merge base a74933d: Passed: 467. This head: Passed: 485. 485 − 467 = 18, matching the case count above.
  • No first-party warning added. Rather than rebuild locally, I diffed the set of unique first-party warnings (third_party/ excluded, line/col dropped) straight out of CI's logs at the merge base and at this head, per job: Desktop macOS 126 → 126, iOS 126 → 126, Linux library job 127 → 127 — added 0, removed 0 on all three.
  • fix(session): a legacy row's missing channel colour aborts the session load — or silently erases the channel #233 re-verified as a pair, against both branches' current heads. Three-dot git diff main... confirms fix(session): a legacy row's missing channel colour aborts the session load — or silently erases the channel #233 touches only ChannelSeriesColor.cs, PlotLogger.cs, PlotModelFactory.cs and its own test file — no overlap with SessionDataRepository.cs. Merging 2204306 into this head is clean and the merged tree runs 503 tests, 0 failures. It also leaves PlotLogger.cs:274's new DateTime(ticks) untouched, so there is no semantic interaction either.

The skip is the part worth arguing about, so I checked it rather than the diff

  • The dropped row is genuinely unrepresentable, not merely unusual. The bounds are exactly DateTime.MinValue.Ticks .. DateTime.MaxValue.Ticks — precisely what new DateTime(long) accepts. Tick 0 and MaxValue.Ticks are both kept, each with its own test.
  • "All three sites" holds, and I looked for a fourth. Note that grepping new DateTime( alone misses the target-typed new(TimestampTicks) in DataSample.cs:84 — correctly out of scope. The one call I'd flag for a reader is DatabaseLogger.cs:875, a fourth raw-SQL seek over TimestampTicks; it is the zoom-refinement path, bounded by a window derived from the now-readable first timestamp and doing no tick→date conversion, so this change makes it safer rather than newly exposed.
  • ⚠️ The user is told only in a log file. _appLogger.Warning reaches NLog's DAQifiAppLog.log and a Sentry breadcrumb; the app has no UI surface for app-log warnings at all. "It is not silent" is true of the log, not of the screen. That's a fair call for a hardening fix — and it is strictly more than Session load throws on a sample row with a null or blank Color #231's silent colour fallback does — but it is worth deciding deliberately rather than inheriting. PlotModel.Subtitle is the obvious surface if a visible note is wanted.
  • A session of nothing but damaged rows degrades acceptably. Mobile says "This session has no data to plot." Desktop lands in the shared empty state, which reads "Select a session to view data" — inaccurate for a session you just clicked, but main reaches that identical state today (the throw is swallowed by DisplayLoggingSession's catch), so it is not a regression.

The collapsed graph, rendered instead of asserted

"The real data collapses to a line at x=0" is the one claim a reviewer cannot check from the diff, so I staged it. Both heads booted headless through tools/parity-audit/AvaloniaCapture against a throwaway DAQIFI_DATA_DIR seeded with one session: a 400-point sine wave over 2 s, plus a single row at long.MaxValue. (Temporary harness edit, reverted.)

Before — unchanged main. The X axis runs to 8.5e14 ms and the whole session is one vertical line at the origin. It "loads", and is unreadable:

before

After — this head. Same database, same seeded damage; the damaged row is skipped and the session reads normally across 0–2000 ms:

after

One incidental thing the render turned up, cosmetic and not blocking: the header and session card still read SAMPLES 401 on the fixed head while 400 points are drawn. That number is LoggingSession.SampleCount, backfilled by LoggingManager.BackfillMissingSampleCounts as a raw COUNT(*), not the repository's filtered TotalSampleCount. No load decision is affected — the Phase-2 gate uses the filtered count — but the chart and the count now disagree by exactly the number of skipped rows.

Not merging.

@tylerkron
tylerkron added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit 9e186f7 Sep 5, 2026
7 checks passed
@tylerkron
tylerkron deleted the fix/237-session-load-timestamp-range branch September 5, 2026 14:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Session load throws on a sample row whose TimestampTicks is out of DateTime range — the #231 column PR #233 does not cover

1 participant