feat(desktop): one relative date ladder across chat and the Inbox (#3769)

Slice 6 of #2216. Independent of #3642 — cut from `main`, no shared
files in conflict.

## Why

Five surfaces formatted the same thing five ways, and none of them
matched the writing standard's Today / Yesterday / weekday / date
progression.

| Surface | Before |
|---|---|
| Chat day divider | `Monday, March 31st` — ordinal suffix, which the
standard says to avoid |
| Inbox section header | `Yesterday`, but never `Today`; always printed
the year |
| Inbox list row | A third implementation |
| Inbox thread pane header | `Jul 8, 2026, 2:34 PM` — always absolute,
always with the year, never relative at any distance |
| Channel message header | `9:05 AM` — a bare clock, so a message from
last week has nothing to anchor it once its day divider scrolls away |

There were three separate date implementations doing this, which is the
symptom worth naming: **two different jobs were being solved ad hoc at
each call site.** A header that labels a *group* of items needs a
different label than an individual item's own timestamp.

## What

`shared/lib/datetime.ts` owns both ladders:

```
formatDayGroupLabel          formatItemTimestamp
(day divider, section header) (list row, message header)

Today       → Today           withTime:false   withTime:true
Yesterday   → Yesterday       2:34 PM          2:34 PM
2–6 days    → Monday          Yesterday        Yesterday at 2:34 PM
this year   → June 20         Monday           Monday at 2:34 PM
older       → June 20, 2025   Jun 20           Jun 20 at 2:34 PM
                              Jun 20, 2025     Jun 20, 2025 at 2:34 PM
```

## Two deliberate deviations from the standard

Both are documented at the definition, not just here.

**The oldest band keeps the day.** The standard collapses anything over
ten months to month-and-year (`Aug 2022`). A group label has to
*identify* its day — collapsing would give every day in a month the same
divider, so scrolling old history would show a run of identical headers
with no way to tell one day from the next. Only the year is conditional.
There's a test asserting three consecutive 2022 dates produce three
distinct labels.

**Roomy surfaces keep the time of day at every band.** `Yesterday at
9:05 AM`, not `Yesterday`. This is a chat and collaboration workspace
rather than a transactional product — where you read conversation, the
time is content, not chrome. Narrow list rows still drop it (`withTime:
false`) and rely on the existing hover tooltip, which stays the absolute
value. `withTime` is a surface decision, not a preference.

Today needs no date word in either mode: a bare clock already reads as
today, and "Today at 2:34 PM" is longer without saying more.

## Derived rather than captured

`MessageTimestamp` now takes only `createdAt` and derives both of its
labels, instead of receiving a pre-formatted `time` string. A relative
label captured when the message list was formatted would be frozen at
that wording; deriving it means each render recomputes.

This does not make it live — `MessageRow` is memoized, so a row already
on screen when the clock passes midnight keeps saying "Today" until
something re-renders it. The day divider above it has always had the
same property, and both correct themselves on the next message, scroll,
or navigation. Called out in the component doc so the next person
doesn't read "derived" as "reactive".

The memo comparator moved from `message.time` to `message.createdAt`.
Behavior-identical — `time` was a pure function of `createdAt` — but it
now names the prop the row actually reads.

The 36px continuation hover gutter stays clock-only. A relative label
doesn't fit in `w-9`.

## Middot between metadata segments

`managed by you 9:53 AM` ran two unrelated facts together as if they
were one phrase. Now `managed by you · 9:53 AM`.

- `aria-hidden` — punctuation for the eye only. The header already reads
as separate nodes to a screen reader, and `MessageAgentOwner` supplies
its own "Agent managed by" label.
- Grouped with the segment it precedes, so it can't wrap to the start of
a line on its own — as loose siblings in a `flex-wrap` row, an orphaned
divider is exactly what happens.
- No margin; spacing comes from the container gap.
- **No separator after the author name.** "Alice 9:53 AM" already reads
as a name followed by a time. Dividers go between metadata segments
only.

Middot is already the app's separator for this —
`MessageThreadSummaryRow`, the mention list, project rows, 46 files in
total.

Applied to the channel message header, channel system rows, and the
Inbox thread pane. Left-side Inbox activity rows deliberately unchanged.

## Verified

Screenshots taken through `just desktop-screenshot`:

- `#agents` — `nadia 🤖 managed by you · 10:20 AM`, and the `Today`
divider with clock-only rows
- Inbox thread pane — `alice 🤖 owner unavailable · 12:00 PM`

**Gap worth naming:** every mock channel message is same-day, so the
past-day labels (`Yesterday at 9:05 AM`, `Jun 20 at 2:34 PM`) are
covered by unit tests rather than by a rendered screenshot. Happy to add
a spec that seeds an older `created_at` if a reviewer wants to see them.

## Validation

- `pnpm check`, `pnpm typecheck` — clean
- Unit: **3800/3800**, including 17 new tests in
`shared/lib/datetime.test.mjs` and 4 in
`messageTimestampContract.test.mjs`

The datetime tests pin the things that are easy to regress:
Today/Yesterday as *calendar* boundaries rather than 24-hour windows (a
message 15 hours old across midnight is "Yesterday"; one 22 hours old on
the same day is "Today"), the weekday band bounded at both ends so a
future timestamp from clock skew never gets labelled with a past
weekday, no ordinals across all the tricky days
(1/2/3/11/12/13/21/22/23/31), the year omitted within the current year,
and compact labels staying ≤12 chars for a narrow row.

- Smoke E2E: **783 passed, 2 failed, 1 skipped**

Both failures are pre-existing and unrelated, confirmed by re-running
each against a clean tree:

1. `video-attachment.spec.ts:223` — fails deterministically on clean
`main`
2. `community-rail.spec.ts:797` (keyboard drag-and-drop reorder) — flaky
on clean `main`: 2/5 failures there vs 3/5 with this branch, i.e. noise

## Mobile

Mobile had the same divergence, so it moves with desktop rather than
drifting until the next pass.
`mobile/lib/features/channels/date_formatters.dart`:

| Before | After |
|---|---|
| `formatDayHeading` → Today / Yesterday / `Tuesday, March 31, 2026` |
Today / Yesterday / `Tuesday` / `March 31` / `March 31, 2025` |
| `formatThreadSummaryLastReplyTime` → `on May 19th` | `on May 19` |

Same two departures from the standard as desktop, documented at the
definition and cross-referenced to `datetime.ts` so the next person
editing one finds the other. Day comparison also moved to a rounded
start-of-day difference, so a DST transition counts as one calendar day
rather than zero — Dart's `Duration.inDays` truncates.

**Message timestamps stay clock-only on mobile.** Desktop message
headers now read `Yesterday at 9:05 AM`; mobile keeps `9:05 AM` at every
band. That's the compact side of the same surface split the desktop
change makes — a mobile timestamp sits inside a chat bubble on a narrow
screen with the day divider a short scroll away, where a date word costs
width it doesn't earn. Recorded as a decision at `formatMessageTime` so
it doesn't read as an oversight.

Mobile needs no middot work: message headers have no "managed by"
segment, and the mention suggestion list already uses `\u00b7`.

Validation: `dart format` clean, `flutter analyze` no issues, `flutter
test` **911 passed, 1 skipped** — 8 new day-heading tests covering the
weekday band, the year boundary, ordinals across
1/2/3/11/12/13/21/22/23/31, distinct labels for consecutive days in the
oldest band, and calendar-day rather than 24-hour bands.

## Out of scope

- **Search results.** `SearchResultItem.tsx` and `TopbarSearch.tsx`
hand-roll a `5m ago` elapsed format. That's a third *kind* of label —
elapsed rather than relative-calendar — and deciding whether search
should switch is a separate call.
- **`formatThreadSummaryLastReplyTime`** keeps its own "3 hours ago"
elapsed scale on both platforms; only its old-reply fallback lost the
ordinal (`on May 19th` → `on May 19`).
- **Mobile search.** `relativeTime` returns `7/31/2026` past a week,
matching the desktop search format that's also out of scope above. Both
should change together or not at all.

---------

Signed-off-by: Clay Delk <clay.delk@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clay Delk
2026-08-14 11:39:53 -04:00
committed by GitHub
parent 17977814d3
commit caa64b5e8f
15 changed files with 770 additions and 312 deletions

View File

@@ -4,11 +4,36 @@ import 'package:intl/intl.dart';
// Re-export shortPubkey so existing callers continue to compile.
export '../../shared/utils/string_utils.dart' show shortPubkey;
final _fullDateFormat = DateFormat('EEEE, MMMM d, y');
final _shortMonthFormat = DateFormat('MMM');
final _weekdayFormat = DateFormat('EEEE');
final _weekdayMonthDayFormat = DateFormat('EEEE, MMMM d');
final _monthDayYearFormat = DateFormat('MMMM d, y');
final _shortMonthDayFormat = DateFormat('MMM d');
final _messageTimeFormat = DateFormat('h:mm a', 'en_US');
/// Returns "Today", "Yesterday", or a full date like "Monday, March 31, 2026".
/// Days in a week, past which the weekday name stops being unambiguous.
const _weekdayBandDays = 7;
/// Label for a day divider: "Today", "Yesterday", "Monday",
/// "Tuesday, March 31", or "March 31, 2025".
///
/// ```
/// Today → "Today"
/// Yesterday → "Yesterday"
/// 2–6 days ago → "Monday"
/// older, this year → "Tuesday, March 31"
/// earlier years → "March 31, 2025"
/// ```
///
/// The weekday rides along within the current year — it still orients ("was
/// that a weekend?") well past the six-day band. Beyond a year it stops
/// earning its width, and the year takes its place.
///
/// Mirrors `formatDayGroupLabel` in `desktop/src/shared/lib/datetime.ts`,
/// including its two departures from the Block writing standard: the day is
/// kept in the oldest band rather than collapsing to month-and-year, because a
/// divider has to *identify* its day — collapsing would give every day in a
/// month the same header. And there is no ordinal suffix ("March 31", never
/// "March 31st"), which the standard does ask for.
///
/// [now] is exposed for testing; production callers should omit it.
String formatDayHeading(int unixSeconds, {@visibleForTesting DateTime? now}) {
@@ -17,21 +42,28 @@ String formatDayHeading(int unixSeconds, {@visibleForTesting DateTime? now}) {
isUtc: true,
).toLocal();
now ??= DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final messageDay = DateTime(date.year, date.month, date.day);
final dayDiff = _calendarDaysBetween(now, date);
if (today.year == messageDay.year &&
today.month == messageDay.month &&
today.day == messageDay.day) {
return 'Today';
if (dayDiff == 0) return 'Today';
if (dayDiff == 1) return 'Yesterday';
// Bounded below as well as above: a timestamp in the future (clock skew, or a
// relay ahead of this device) must not be labelled with a weekday that reads
// as the recent past.
if (dayDiff > 1 && dayDiff < _weekdayBandDays) {
return _weekdayFormat.format(date);
}
final yesterday = DateTime(now.year, now.month, now.day - 1);
if (yesterday.year == messageDay.year &&
yesterday.month == messageDay.month &&
yesterday.day == messageDay.day) {
return 'Yesterday';
}
return _fullDateFormat.format(date);
return date.year == now.year
? _weekdayMonthDayFormat.format(date)
: _monthDayYearFormat.format(date);
}
/// Whole calendar days from [date] to [now], in local time. Rounded rather than
/// truncated so a DST transition — a 23- or 25-hour day — still counts as one.
int _calendarDaysBetween(DateTime now, DateTime date) {
final startOfNow = DateTime(now.year, now.month, now.day);
final startOfDate = DateTime(date.year, date.month, date.day);
return (startOfNow.difference(startOfDate).inHours / 24).round();
}
/// Whether two unix-second timestamps fall on the same calendar day (local time).
@@ -65,7 +97,7 @@ String relativeTime(int unixSeconds) {
}
/// Returns desktop-parity thread activity copy such as "just now",
/// "3 hours ago", or "on May 19th".
/// "3 hours ago", or "on May 19".
String formatThreadSummaryLastReplyTime(
int unixSeconds, {
@visibleForTesting int? nowSeconds,
@@ -83,25 +115,20 @@ String formatThreadSummaryLastReplyTime(
unixSeconds * 1000,
isUtc: true,
).toLocal();
return 'on ${_shortMonthFormat.format(date)} '
'${date.day}${_ordinalSuffix(date.day)}';
// No ordinal suffix, per the writing standard.
return 'on ${_shortMonthDayFormat.format(date)}';
}
String _formatAgo(int value, String unit) =>
'$value $unit${value == 1 ? '' : 's'} ago';
String _ordinalSuffix(int day) {
final lastTwoDigits = day % 100;
if (lastTwoDigits >= 11 && lastTwoDigits <= 13) return 'th';
return switch (day % 10) {
1 => 'st',
2 => 'nd',
3 => 'rd',
_ => 'th',
};
}
/// Desktop-parity message clock time, e.g. "2:34 PM".
///
/// Deliberately clock-only at every band, unlike desktop's message header,
/// which reads "Yesterday at 2:34 PM". Mobile timestamps sit inside a chat
/// bubble on a narrow screen with the day divider a short scroll away, so this
/// is the compact side of that split — not an oversight. Change it only
/// alongside a layout that has room for a date.
String formatMessageTime(int unixSeconds) {
final date = DateTime.fromMillisecondsSinceEpoch(
unixSeconds * 1000,

View File

@@ -19,9 +19,59 @@ void main() {
expect(formatDayHeading(yesterday, now: now), 'Yesterday');
});
test('older date returns full formatted date', () {
final older = _ts(DateTime(2026, 3, 31, 12, 0));
expect(formatDayHeading(older, now: now), 'Tuesday, March 31, 2026');
test('two to six days back names the weekday alone', () {
expect(
formatDayHeading(_ts(DateTime(2026, 4, 21, 9)), now: now),
'Tuesday',
);
expect(
formatDayHeading(_ts(DateTime(2026, 4, 18, 9)), now: now),
'Saturday',
);
});
test(
'a week back switches to weekday + date, without the current year',
() {
// Seven days is the first day a bare weekday name stops being
// unambiguous, so the date joins it.
expect(
formatDayHeading(_ts(DateTime(2026, 4, 16, 9)), now: now),
'Thursday, April 16',
);
expect(
formatDayHeading(_ts(DateTime(2026, 3, 31, 12)), now: now),
'Tuesday, March 31',
);
},
);
test('an earlier year keeps the year', () {
expect(
formatDayHeading(_ts(DateTime(2025, 3, 31, 12)), now: now),
'March 31, 2025',
);
});
test('no divider carries an ordinal suffix', () {
// 1/2/3 and the 11/12/13 exceptions are where ordinals used to appear.
for (final day in [1, 2, 3, 11, 12, 13, 21, 22, 23, 31]) {
expect(
formatDayHeading(_ts(DateTime(2025, 1, day, 12)), now: now),
isNot(matches(RegExp(r'\d(st|nd|rd|th)'))),
);
}
});
test('the oldest band still tells consecutive days apart', () {
// The reason the day is kept rather than collapsed to "Jan 2022".
final labels = [3, 4, 5]
.map(
(day) =>
formatDayHeading(_ts(DateTime(2022, 1, day, 12)), now: now),
)
.toSet();
expect(labels, hasLength(3));
});
test('midnight boundary: 11:59 PM today vs 12:01 AM tomorrow', () {
@@ -29,11 +79,9 @@ void main() {
final earlyTomorrow = _ts(DateTime(2026, 4, 24, 0, 1));
expect(formatDayHeading(lateTonight, now: now), 'Today');
// Tomorrow relative to our fixed "now" is not today or yesterday.
expect(
formatDayHeading(earlyTomorrow, now: now),
'Friday, April 24, 2026',
);
// A future timestamp must not read as the recent past. The attached
// date keeps the weekday from being mistaken for last Friday.
expect(formatDayHeading(earlyTomorrow, now: now), 'Friday, April 24');
});
test('cross-month boundary: April 1 → March 31 is yesterday', () {
@@ -41,6 +89,26 @@ void main() {
final march31 = _ts(DateTime(2026, 3, 31, 20, 0));
expect(formatDayHeading(march31, now: april1), 'Yesterday');
});
test('bands are calendar days, not 24-hour windows', () {
// 15 hours old but across midnight, so "Yesterday"; 22 hours old on the
// same calendar day, so "Today".
final elevenPmYesterday = DateTime(2026, 4, 23, 2, 0);
expect(
formatDayHeading(
_ts(DateTime(2026, 4, 22, 23, 0)),
now: elevenPmYesterday,
),
'Yesterday',
);
expect(
formatDayHeading(
_ts(DateTime(2026, 4, 23, 1, 0)),
now: DateTime(2026, 4, 23, 23, 0),
),
'Today',
);
});
});
group('isSameDay', () {
@@ -79,23 +147,24 @@ void main() {
);
});
test('uses a short month and ordinal for older replies', () {
test('dates older replies with a short month and no ordinal', () {
final may19 = _ts(DateTime(2026, 5, 19, 12));
final may27 = _ts(DateTime(2026, 5, 27, 12));
final may1 = _ts(DateTime(2026, 5, 1, 12));
expect(
formatThreadSummaryLastReplyTime(
may19,
nowSeconds: _ts(DateTime(2026, 5, 27, 12)),
),
'on May 19th',
'on May 19',
);
// The 1st is where an ordinal is most tempting.
expect(
formatThreadSummaryLastReplyTime(
may27,
nowSeconds: _ts(DateTime(2026, 6, 4, 12)),
may1,
nowSeconds: _ts(DateTime(2026, 5, 27, 12)),
),
'on May 27th',
'on May 1',
);
});
});