mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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>
139 lines
5.1 KiB
Dart
139 lines
5.1 KiB
Dart
import 'package:flutter/foundation.dart';
|
||
import 'package:intl/intl.dart';
|
||
|
||
// Re-export shortPubkey so existing callers continue to compile.
|
||
export '../../shared/utils/string_utils.dart' show shortPubkey;
|
||
|
||
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');
|
||
|
||
/// 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}) {
|
||
final date = DateTime.fromMillisecondsSinceEpoch(
|
||
unixSeconds * 1000,
|
||
isUtc: true,
|
||
).toLocal();
|
||
now ??= DateTime.now();
|
||
final dayDiff = _calendarDaysBetween(now, date);
|
||
|
||
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);
|
||
}
|
||
|
||
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).
|
||
bool isSameDay(int a, int b) {
|
||
final dtA = DateTime.fromMillisecondsSinceEpoch(
|
||
a * 1000,
|
||
isUtc: true,
|
||
).toLocal();
|
||
final dtB = DateTime.fromMillisecondsSinceEpoch(
|
||
b * 1000,
|
||
isUtc: true,
|
||
).toLocal();
|
||
return dtA.year == dtB.year && dtA.month == dtB.month && dtA.day == dtB.day;
|
||
}
|
||
|
||
/// Returns a compact relative time string like "just now", "5m ago", "3h ago",
|
||
/// "2d ago", or a short date for older timestamps.
|
||
String relativeTime(int unixSeconds) {
|
||
final now = DateTime.now();
|
||
final time = DateTime.fromMillisecondsSinceEpoch(
|
||
unixSeconds * 1000,
|
||
isUtc: true,
|
||
).toLocal();
|
||
final diff = now.difference(time);
|
||
|
||
if (diff.inMinutes < 1) return 'just now';
|
||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||
if (diff.inDays < 7) return '${diff.inDays}d ago';
|
||
return '${time.month}/${time.day}/${time.year}';
|
||
}
|
||
|
||
/// Returns desktop-parity thread activity copy such as "just now",
|
||
/// "3 hours ago", or "on May 19".
|
||
String formatThreadSummaryLastReplyTime(
|
||
int unixSeconds, {
|
||
@visibleForTesting int? nowSeconds,
|
||
}) {
|
||
nowSeconds ??= DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||
var diff = nowSeconds - unixSeconds;
|
||
if (diff < 0) diff = 0;
|
||
|
||
if (diff < 60) return 'just now';
|
||
if (diff < 3600) return _formatAgo(diff ~/ 60, 'minute');
|
||
if (diff < 86400) return _formatAgo(diff ~/ 3600, 'hour');
|
||
if (diff < 604800) return _formatAgo(diff ~/ 86400, 'day');
|
||
|
||
final date = DateTime.fromMillisecondsSinceEpoch(
|
||
unixSeconds * 1000,
|
||
isUtc: true,
|
||
).toLocal();
|
||
// No ordinal suffix, per the writing standard.
|
||
return 'on ${_shortMonthDayFormat.format(date)}';
|
||
}
|
||
|
||
String _formatAgo(int value, String unit) =>
|
||
'$value $unit${value == 1 ? '' : 's'} ago';
|
||
|
||
/// 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,
|
||
isUtc: true,
|
||
).toLocal();
|
||
return _messageTimeFormat.format(date);
|
||
}
|