https://bugs.documentfoundation.org/show_bug.cgi?id=172582

--- Comment #5 from [email protected] ---
The bibisect boundaries in Comment 4 are the release tags — good = `87b77fad…`
is
**LibreOffice 7.2.1.2**, bad = `cdeefe45…` is **LibreOffice 7.5.9.2** — so the
regression is somewhere in the 7.3–7.5 range. It was introduced by:

    commit 5ce6de864380f1eabbd78656ff6cc31920c534d2
    Author: Eike Rathke <[email protected]>
    Date:   2022-10-24
    Related: tdf#136615 Do not round a DateTime clock format into the next day

First shipped in **LibreOffice 7.5.0**. It is a one-file change in
`svl/source/numbers/zformat.cxx`, function
`SvNumberformat::ImpGetDateTimeOutput()` —
the exact code path that renders a cell with a combined date+time number format
such
as `DD/MM/YYYY HH:MM:SS`.

**Before (7.2.x — correct).** For a clock format (`nCntPost == 0`) the
fractional day
was rounded to whole seconds, and a roll-over guard reconciled the date and the
time:

    nCntPost = rInfo.nCntPost;                         // 0 for HH:MM:SS
    double fTime = (fNumber - floor(fNumber)) * 86400.0;
    fTime = rtl::math::round( fTime, int(nCntPost) );  // round to 0 dp ->
whole seconds
    if (fTime >= 86400.0) {                            // 86400.0 -> guard
fires
        fTime -= 86400.0;
        fNumber = floor(fNumber + 0.5) + fTime;        // advance to next-day
00:00:00
    }
    rCal.setLocalDateTime( fNumber );

    46132.9999999999:  fTime = 0.9999999999 * 86400 = 86399.99999136
                       round(…, 0) = 86400.0  ->  >= 86400  ->  fNumber :=
46133.0
                       => 21/04/2026 00:00:00   (date and time agree)

**After (7.5.0+ — the regression).** A new `nFirstRounding` rounds a clock
format at
7 decimals instead of 0, so the roll-over guard no longer fires:

    nCntPost = rInfo.nCntPost;                         // still 0
    // For clock format (not []) do not round up to seconds and thus days.
    nFirstRounding = (rInfo.bThousand ? nCntPost : kTimeSignificantRound);  //
= 7
    double fTime = (fNumber - floor(fNumber)) * 86400.0;
    fTime = rtl::math::round( fTime, int(nFirstRounding) );  // round to 7 dp
    if (fTime >= 86400.0) { … }                        // NOT taken any more
    rCal.setLocalDateTime( fNumber );                  // fNumber still
46132.9999999999

    46132.9999999999:  fTime = round(86399.99999136, 7) = 86399.9999914  ->  <
86400
                       guard skipped, fNumber unchanged.
                       rCal.setLocalDateTime(46132.9999999999):
                           fM = 46132.9999999999 * 86400000 = 3985891199999.914
                           round(fM) = 3985891200000 = 46133.0  ->  date =
21/04/2026 (rolls up)
                       tools::Time::GetClock(46132.9999999999, …) truncates ->
23:59:59 (no roll-up)
                       => 21/04/2026 23:59:59   (date and time disagree)

So the **date** is still taken from `setLocalDateTime()`, which rounds to the
nearest
millisecond and therefore rolls up to the next day, while the **time** is now
derived
from the un-adjusted `fNumber` and truncated. The two halves of the same cell
end up
almost 24 hours apart.

This also explains the threshold Takenori Yasuda reports in Comment 2
(~`0.99999999422`, i.e. about half a millisecond before midnight): that is
exactly the
point at which `setLocalDateTime()`'s round-to-millisecond — `round(fraction ×
86 400 000)`
— crosses `x.5` ms and rounds the **date** up to the next day, whereas the
7-decimal
rounding of the seconds never reaches a full day.

The commit is a follow-up to tdf#136615 ("Re-consider date/time parts
calculation for 
functions and formatting"). Its direct trigger was a case Eike Rathke himself
raised in
that thread: the combined format

    =TEXT(44858.9999999306;"yyyy-mm-dd hh:mm:ss")

was giving `2022-10-25 00:00:00` instead of `2022-10-24 23:59:59`, whereas the
`.000`
variant correctly gave `2022-10-24 23:59:59.994`. In LibreOffice's model,
`hh:mm:ss` is
a **clock** format that should *not* round up into the next unit (and hence not
into the
next day), while `[hh]:mm:ss` is a **duration** format that does round. The
patch was
intended to make the combined date+time clock format stop rolling into the next
day.

So fixing the bug can be done by making the date and time halves use the *same*
convention. 
Either (a) derive the date from the same non-rounded value the time is derived
from, so a 
near-midnight value stays on the current day as `20/04/2026 23:59:59`; or (b)
round the whole
serial to the display precision once, up front, and derive both halves from
that single value,
giving `21/04/2026 00:00:00`. Either is acceptable; the current mixed behaviour
is not.

On the other hand, someone handling pure time within a 24 hour "circle", might
want the behaviour that all
values greater than the maximum value, are rounded to the maximum value, so
option (a) is defendable
when only showing time, but once dates are included, it is inconsistent with
itself: when the time 
reaches a certain threshold, the rounding rule changes. The bug is relatively
obvious with date/time 
data exported from other systems, because it generated an almost-24-hour error;
but if (a) is followed
 it may instead trigger future frustration for someone to whom an
almost-1-second error is unacceptable (e.g.
a strictly monotone sub-second timeseries that suddenly develops duplicate
values).


So the choice between (a) and (b) is
not a matter of taste; it is fixed by the number space the value actually lives
in.

A bare *time-of-day* is a cyclic quantity: it lives on the circle ℝ / 86400ℤ
(clock
arithmetic, i.e. modular arithmetic), in which the mark "86400 s" and the mark
"0 s" are the
*same point*. On a bounded, cyclic space there are exactly two self-consistent
ways to treat
a value that rounds past the top:

    wrap-around (modular):  23:59:59.9999 -> 00:00:00   carry wraps, as in
uint8: 255 + 1 = 0
    saturating   (clamp):   23:59:59.9999 -> 23:59:59   value pinned below the
wall, as in saturating uint8: 255 + 1 = 255

For a *time-only* field with no date, saturating is the sensible choice:
wrapping to
00:00:00 would move "almost midnight" to the *start* of the dial and destroy
the magnitude.
This is the legitimate core of the intuition that an almost-full-day value
should not reset
to zero.

But a *date+time* serial is **not** a point on that circle. It is a point on
the **real
line** ℝ (days since the epoch): the date is its integer part, the time its
fractional part.
ℝ is neither bounded nor cyclic — there is no wall and no wrap. Rounding is
therefore
performed once, on ℝ, and the carry flows naturally out of the seconds and into
the integer
part, i.e. into the date. That is exactly option (b):

    round(46132.9999999999 × 86400) / 86400 = 46133.0  ->  21/04/2026 00:00:00

— unambiguous, with no boundary paradox.

Option (a) is precisely the error of importing the *saturating* rule of the
bounded circle
into a value that lives on ℝ. It pins the time below the day wall (23:59:59)
even though the
number has no wall — which is the very "the rounding rule changes at a
threshold"
self-inconsistency noted above. The apparent day boundary is an artefact of
*displaying only
the fractional part*; it is not a boundary of the underlying number.

So LibreOffice's present behaviour is a category error: it rounds the **date**
on ℝ
(`setLocalDateTime`, carry allowed) but rounds the **time** on the circle ℝ /
86400ℤ (carry
forbidden). The two halves of one value are rounded in two different number
systems. The fix
is to choose a single system for the whole value; and since the value
demonstrably lives on
ℝ, that system is ℝ — option (b).

-- 
You are receiving this mail because:
You are the assignee for the bug.

Reply via email to