mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -18,6 +18,10 @@ import type {
|
||||
HomeFeedResponse,
|
||||
RelayEvent,
|
||||
} from "@/shared/api/types";
|
||||
import {
|
||||
formatDayGroupLabel,
|
||||
formatItemTimestamp,
|
||||
} from "@/shared/lib/datetime";
|
||||
import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
|
||||
|
||||
export type InboxFilter =
|
||||
@@ -86,6 +90,7 @@ export type InboxReply = {
|
||||
*/
|
||||
signerPubkey?: string;
|
||||
tags?: string[][];
|
||||
/** Clock time only, for the hover gutter on continuation rows. */
|
||||
timeLabel?: string;
|
||||
};
|
||||
|
||||
@@ -103,11 +108,6 @@ export type InboxGroup = {
|
||||
|
||||
type InboxChannel = Pick<Channel, "channelType" | "id" | "name">;
|
||||
|
||||
const listTimeFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
const fullTimeFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
@@ -116,31 +116,6 @@ const fullTimeFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
const shortDateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const shortDateWithYearFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const weekdayFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
weekday: "long",
|
||||
});
|
||||
|
||||
function startOfDay(value: Date) {
|
||||
return new Date(value.getFullYear(), value.getMonth(), value.getDate());
|
||||
}
|
||||
|
||||
function diffInDays(from: Date, to: Date) {
|
||||
return Math.round(
|
||||
(startOfDay(from).getTime() - startOfDay(to).getTime()) / 86_400_000,
|
||||
);
|
||||
}
|
||||
|
||||
function tagValue(item: FeedItem, name: string) {
|
||||
return item.tags.find((tag) => tag[0] === name)?.[1]?.trim() || null;
|
||||
}
|
||||
@@ -445,23 +420,7 @@ export function findInboxItemByEventId(
|
||||
}
|
||||
|
||||
function formatInboxTimestamp(unixSeconds: number) {
|
||||
const date = new Date(unixSeconds * 1_000);
|
||||
const now = new Date();
|
||||
const dayDiff = diffInDays(now, date);
|
||||
|
||||
if (dayDiff === 0) {
|
||||
return listTimeFormatter.format(date);
|
||||
}
|
||||
|
||||
if (dayDiff === 1) {
|
||||
return "Yesterday";
|
||||
}
|
||||
|
||||
if (now.getFullYear() === date.getFullYear()) {
|
||||
return shortDateFormatter.format(date);
|
||||
}
|
||||
|
||||
return shortDateWithYearFormatter.format(date);
|
||||
return formatItemTimestamp(unixSeconds);
|
||||
}
|
||||
|
||||
export function formatInboxFullTimestamp(unixSeconds: number) {
|
||||
@@ -480,21 +439,14 @@ export function relayEventFromFeedItem(item: FeedItem): RelayEvent {
|
||||
};
|
||||
}
|
||||
|
||||
export function groupInboxItems(items: InboxItem[]): InboxGroup[] {
|
||||
export function groupInboxItems(
|
||||
items: InboxItem[],
|
||||
nowSeconds = Date.now() / 1_000,
|
||||
): InboxGroup[] {
|
||||
const groups = new Map<string, InboxItem[]>();
|
||||
const now = new Date();
|
||||
|
||||
for (const item of items) {
|
||||
const date = new Date(item.latestActivityAt * 1_000);
|
||||
const dayDiff = diffInDays(now, date);
|
||||
const label =
|
||||
dayDiff === 0
|
||||
? "Today"
|
||||
: dayDiff === 1
|
||||
? "Yesterday"
|
||||
: dayDiff < 7
|
||||
? weekdayFormatter.format(date)
|
||||
: shortDateWithYearFormatter.format(date);
|
||||
const label = formatDayGroupLabel(item.latestActivityAt, nowSeconds);
|
||||
|
||||
const current = groups.get(label) ?? [];
|
||||
current.push(item);
|
||||
|
||||
@@ -4,10 +4,12 @@ import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys";
|
||||
import type { InboxContextMessage } from "@/features/home/lib/inbox";
|
||||
import { toTimelineMessage } from "@/features/home/lib/inboxViewHelpers";
|
||||
import { formatTimeWithoutDayPeriod } from "@/features/messages/lib/dateFormatters";
|
||||
import { formatItemTimestamp } from "@/shared/lib/datetime";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAuthPubkey";
|
||||
import { MessageActionBar } from "@/features/messages/ui/MessageActionBar";
|
||||
import { MessageAgentOwner } from "@/features/messages/ui/MessageAgentOwner";
|
||||
import { MessageMetaSeparator } from "@/features/messages/ui/MessageHeader";
|
||||
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
|
||||
import { UnreadDivider } from "@/features/messages/ui/UnreadDivider";
|
||||
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
|
||||
@@ -94,6 +96,21 @@ export function InboxMessageRow({
|
||||
const hoverTimestampLabel = formatTimeWithoutDayPeriod(
|
||||
message.timeLabel ?? message.fullTimestampLabel,
|
||||
);
|
||||
// Derived here rather than plumbed in with the message: the thread pane has no
|
||||
// day divider to supply the date, and deriving on render means a row does not
|
||||
// keep saying "Today" after midnight. `fullTimestampLabel` stays the absolute
|
||||
// value behind the hover title.
|
||||
const timestampLabel = formatItemTimestamp(message.createdAt, {
|
||||
withTime: true,
|
||||
});
|
||||
const timestampNode = (
|
||||
<p
|
||||
className="shrink-0 text-xs font-normal tabular-nums text-muted-foreground/55"
|
||||
title={message.fullTimestampLabel}
|
||||
>
|
||||
{timestampLabel}
|
||||
</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative px-2">
|
||||
@@ -192,14 +209,24 @@ export function InboxMessageRow({
|
||||
</span>
|
||||
</UserProfilePopover>
|
||||
{message.isAgent ? (
|
||||
<MessageAgentOwner
|
||||
ownerLabel={message.ownerLabel}
|
||||
ownerPubkey={message.ownerPubkey}
|
||||
/>
|
||||
) : null}
|
||||
<p className="shrink-0 text-xs font-normal tabular-nums text-muted-foreground/55">
|
||||
{message.fullTimestampLabel}
|
||||
</p>
|
||||
<>
|
||||
<MessageAgentOwner
|
||||
ownerLabel={message.ownerLabel}
|
||||
ownerPubkey={message.ownerPubkey}
|
||||
/>
|
||||
{/*
|
||||
Grouped with the timestamp so the divider never wraps to the
|
||||
start of a line on its own. Gap matches the container's, so
|
||||
spacing reads the same either side of the divider.
|
||||
*/}
|
||||
<span className="inline-flex min-w-0 items-center gap-x-2">
|
||||
<MessageMetaSeparator />
|
||||
{timestampNode}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
timestampNode
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
formatDayHeading,
|
||||
formatShortMonthDayOrdinal,
|
||||
formatShortMonthDay,
|
||||
formatThreadSummaryLastReplyTime,
|
||||
formatTimeWithoutDayPeriod,
|
||||
startOfLocalDaySeconds,
|
||||
@@ -13,66 +12,16 @@ function localUnixSeconds(year, monthIndex, day) {
|
||||
return new Date(year, monthIndex, day, 12).getTime() / 1_000;
|
||||
}
|
||||
|
||||
function weekday(date) {
|
||||
return new Intl.DateTimeFormat("en-US", { weekday: "long" }).format(date);
|
||||
}
|
||||
|
||||
function month(date) {
|
||||
return new Intl.DateTimeFormat("en-US", { month: "long" }).format(date);
|
||||
}
|
||||
|
||||
test("formatShortMonthDayOrdinal formats month before ordinal day", () => {
|
||||
assert.equal(
|
||||
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 19)),
|
||||
"May 19th",
|
||||
);
|
||||
test("formatShortMonthDay abbreviates the month and omits the ordinal", () => {
|
||||
assert.equal(formatShortMonthDay(localUnixSeconds(2026, 4, 19)), "May 19");
|
||||
assert.equal(formatShortMonthDay(localUnixSeconds(2026, 4, 1)), "May 1");
|
||||
});
|
||||
|
||||
test("formatShortMonthDayOrdinal handles ordinal suffixes", () => {
|
||||
assert.equal(
|
||||
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 1)),
|
||||
"May 1st",
|
||||
);
|
||||
assert.equal(
|
||||
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 2)),
|
||||
"May 2nd",
|
||||
);
|
||||
assert.equal(
|
||||
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 3)),
|
||||
"May 3rd",
|
||||
);
|
||||
assert.equal(
|
||||
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 4)),
|
||||
"May 4th",
|
||||
);
|
||||
assert.equal(
|
||||
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 11)),
|
||||
"May 11th",
|
||||
);
|
||||
assert.equal(
|
||||
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 12)),
|
||||
"May 12th",
|
||||
);
|
||||
assert.equal(
|
||||
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 13)),
|
||||
"May 13th",
|
||||
);
|
||||
assert.equal(
|
||||
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 21)),
|
||||
"May 21st",
|
||||
);
|
||||
assert.equal(
|
||||
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 22)),
|
||||
"May 22nd",
|
||||
);
|
||||
assert.equal(
|
||||
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 23)),
|
||||
"May 23rd",
|
||||
);
|
||||
assert.equal(
|
||||
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 31)),
|
||||
"May 31st",
|
||||
);
|
||||
test("no day carries an ordinal suffix", () => {
|
||||
for (const day of [1, 2, 3, 4, 11, 12, 13, 21, 22, 23, 31]) {
|
||||
const label = formatShortMonthDay(localUnixSeconds(2026, 4, day));
|
||||
assert.doesNotMatch(label, /\d(?:st|nd|rd|th)\b/, `ordinal in "${label}"`);
|
||||
}
|
||||
});
|
||||
|
||||
test("formatTimeWithoutDayPeriod removes AM/PM suffixes", () => {
|
||||
@@ -108,31 +57,11 @@ test("formatThreadSummaryLastReplyTime expands relative units", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("formatThreadSummaryLastReplyTime uses ordinal dates for older replies", () => {
|
||||
test("formatThreadSummaryLastReplyTime dates older replies without an ordinal", () => {
|
||||
const now = localUnixSeconds(2026, 5, 15);
|
||||
const replyAt = localUnixSeconds(2026, 4, 19);
|
||||
|
||||
assert.equal(formatThreadSummaryLastReplyTime(replyAt, now), "on May 19th");
|
||||
});
|
||||
|
||||
test("formatDayHeading omits the year for current-year dates", () => {
|
||||
const now = new Date();
|
||||
const date = new Date(now.getFullYear(), (now.getMonth() + 6) % 12, 19, 12);
|
||||
|
||||
assert.equal(
|
||||
formatDayHeading(date.getTime() / 1_000),
|
||||
`${weekday(date)}, ${month(date)} 19th`,
|
||||
);
|
||||
});
|
||||
|
||||
test("formatDayHeading includes the year for other years", () => {
|
||||
const year = new Date().getFullYear() - 1;
|
||||
const date = new Date(year, 4, 19, 12);
|
||||
|
||||
assert.equal(
|
||||
formatDayHeading(date.getTime() / 1_000),
|
||||
`${weekday(date)}, May 19th, ${year}`,
|
||||
);
|
||||
assert.equal(formatThreadSummaryLastReplyTime(replyAt, now), "on May 19");
|
||||
});
|
||||
|
||||
test("startOfLocalDaySeconds collapses a day's timestamps to one value", () => {
|
||||
|
||||
@@ -4,9 +4,17 @@
|
||||
* - `formatTime` — short clock time ("2:34 PM"), used in message rows.
|
||||
* - `formatFullDateTime` — verbose string for tooltips
|
||||
* ("Wednesday, April 2, 2026 at 2:34 PM").
|
||||
* - `formatDayHeading` — label for day dividers / sticky headers.
|
||||
* Returns "Today", "Yesterday", or a date like "Monday, March 31st".
|
||||
* - `isSameDay` — compare two unix-second timestamps.
|
||||
*
|
||||
* Relative labels ("Today", "Yesterday", "June 20", "Yesterday at 9:05 AM") are
|
||||
* not here: chat and the Inbox share them from `shared/lib/datetime.ts`. What
|
||||
* stays in this file is the absolute end of the range — a bare clock time, the
|
||||
* verbose tooltip string, and same-day comparison.
|
||||
*
|
||||
* `formatTime` is for places with only enough room for a clock: the hover gutter
|
||||
* that replaces the avatar on continuation rows. A message header uses the
|
||||
* relative ladder instead, because the day divider that supplies its date
|
||||
* scrolls away while the messages under it stay on screen.
|
||||
*/
|
||||
|
||||
const TIME_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
@@ -25,16 +33,9 @@ const FULL_DATE_TIME_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
const WEEKDAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
weekday: "long",
|
||||
});
|
||||
|
||||
const LONG_MONTH_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
month: "long",
|
||||
});
|
||||
|
||||
const SHORT_MONTH_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
const SHORT_MONTH_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
/** Short clock time, e.g. "2:34 PM". */
|
||||
@@ -52,34 +53,6 @@ export function formatFullDateTime(unixSeconds: number): string {
|
||||
return FULL_DATE_TIME_FORMATTER.format(new Date(unixSeconds * 1_000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-friendly day label for dividers and sticky headers.
|
||||
* Returns "Today", "Yesterday", a current-year date like "Monday, March 31st",
|
||||
* or a prior-year date like "Monday, March 31st, 2025".
|
||||
*/
|
||||
export function formatDayHeading(unixSeconds: number): string {
|
||||
const date = new Date(unixSeconds * 1_000);
|
||||
const now = new Date();
|
||||
|
||||
if (isSameDayDate(date, now)) {
|
||||
return "Today";
|
||||
}
|
||||
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
if (isSameDayDate(date, yesterday)) {
|
||||
return "Yesterday";
|
||||
}
|
||||
|
||||
const dateLabel = `${WEEKDAY_FORMATTER.format(date)}, ${formatMonthDayOrdinal(
|
||||
date,
|
||||
LONG_MONTH_FORMATTER,
|
||||
)}`;
|
||||
return date.getFullYear() === now.getFullYear()
|
||||
? dateLabel
|
||||
: `${dateLabel}, ${date.getFullYear()}`;
|
||||
}
|
||||
|
||||
/** True when two unix-second timestamps fall on the same calendar day (local time). */
|
||||
export function isSameDay(a: number, b: number): boolean {
|
||||
return isSameDayDate(new Date(a * 1_000), new Date(b * 1_000));
|
||||
@@ -97,17 +70,14 @@ export function startOfLocalDaySeconds(unixSeconds: number): number {
|
||||
return Math.floor(date.getTime() / 1_000);
|
||||
}
|
||||
|
||||
/** Short month + ordinal day, e.g. "May 19th". */
|
||||
export function formatShortMonthDayOrdinal(unixSeconds: number): string {
|
||||
return formatMonthDayOrdinal(
|
||||
new Date(unixSeconds * 1_000),
|
||||
SHORT_MONTH_FORMATTER,
|
||||
);
|
||||
/** Short month + day, e.g. "May 19". No ordinal suffix, per the writing standard. */
|
||||
export function formatShortMonthDay(unixSeconds: number): string {
|
||||
return SHORT_MONTH_DAY_FORMATTER.format(new Date(unixSeconds * 1_000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative thread-summary timestamp with expanded units, e.g. "3 hours ago",
|
||||
* falling back to "on May 19th" for older replies.
|
||||
* falling back to "on May 19" for older replies.
|
||||
*/
|
||||
export function formatThreadSummaryLastReplyTime(
|
||||
unixSeconds: number,
|
||||
@@ -120,7 +90,7 @@ export function formatThreadSummaryLastReplyTime(
|
||||
if (diff < 86_400) return formatAgo(Math.floor(diff / 3_600), "hour");
|
||||
if (diff < 604_800) return formatAgo(Math.floor(diff / 86_400), "day");
|
||||
|
||||
return `on ${formatShortMonthDayOrdinal(unixSeconds)}`;
|
||||
return `on ${formatShortMonthDay(unixSeconds)}`;
|
||||
}
|
||||
|
||||
function isSameDayDate(a: Date, b: Date): boolean {
|
||||
@@ -131,33 +101,6 @@ function isSameDayDate(a: Date, b: Date): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function formatMonthDayOrdinal(
|
||||
date: Date,
|
||||
monthFormatter: Intl.DateTimeFormat,
|
||||
): string {
|
||||
return `${monthFormatter.format(date)} ${date.getDate()}${ordinalSuffix(
|
||||
date.getDate(),
|
||||
)}`;
|
||||
}
|
||||
|
||||
function formatAgo(value: number, unit: string): string {
|
||||
return `${value} ${unit}${value === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
|
||||
function ordinalSuffix(day: number): string {
|
||||
const lastTwoDigits = day % 100;
|
||||
if (lastTwoDigits >= 11 && lastTwoDigits <= 13) {
|
||||
return "th";
|
||||
}
|
||||
|
||||
switch (day % 10) {
|
||||
case 1:
|
||||
return "st";
|
||||
case 2:
|
||||
return "nd";
|
||||
case 3:
|
||||
return "rd";
|
||||
default:
|
||||
return "th";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,14 +17,28 @@ export function MessageAgentOwner({
|
||||
<span className="sr-only">
|
||||
{ownerLabel ? "Agent managed by" : "Agent; owner unavailable"}
|
||||
</span>
|
||||
{/*
|
||||
* Icon and label sit directly in this baseline row rather than in a nested
|
||||
* flex wrapper, so the label's own baseline is what aligns with the author
|
||||
* name beside it. Both branches share the icon for the same reason: two
|
||||
* wrappers meant two alignment rules and the "owner unavailable" variant
|
||||
* had drifted a pixel off the other one.
|
||||
*
|
||||
* `self-center` keeps the icon out of baseline alignment, so the label —
|
||||
* not the icon's box — sets this chip's baseline. Centred on the line box
|
||||
* the glyph's ink still rides ~1.6px above the text's cap band, reading as
|
||||
* a couple of pixels too high; 0.125em drops its optical centre onto that
|
||||
* band. In em so it holds under Cmd +/- zoom, and as a transform so it
|
||||
* shifts nothing else in the row.
|
||||
*/}
|
||||
<Bot
|
||||
aria-hidden="true"
|
||||
className="h-3.5 w-3.5 shrink-0 translate-y-[0.125em] self-center"
|
||||
/>
|
||||
{ownerPubkey && ownerLabel ? (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-flex shrink-0 items-baseline gap-1 leading-4"
|
||||
>
|
||||
<Bot className="relative -top-px h-3.5 w-3.5 self-center" />
|
||||
<span>managed by</span>
|
||||
<span aria-hidden="true" className="shrink-0">
|
||||
managed by
|
||||
</span>
|
||||
<UserProfilePopover
|
||||
pubkey={ownerPubkey}
|
||||
@@ -37,12 +51,8 @@ export function MessageAgentOwner({
|
||||
</UserProfilePopover>
|
||||
</>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-flex min-w-0 items-center gap-1"
|
||||
>
|
||||
<Bot className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">owner unavailable</span>
|
||||
<span aria-hidden="true" className="min-w-0 truncate">
|
||||
owner unavailable
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type * as React from "react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
@@ -23,6 +23,60 @@ export function MessageHeaderRow({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Divider between two pieces of message-header metadata.
|
||||
*
|
||||
* The header runs several independent facts together on one line, and without a
|
||||
* divider they read as one phrase: an agent message came out as "managed by You
|
||||
* 9:53 AM". A middot is what the rest of the app already uses for this
|
||||
* (`MessageThreadSummaryRow`, project rows, the mention list).
|
||||
*
|
||||
* `aria-hidden` because the divider is 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.
|
||||
*
|
||||
* No horizontal margin: `MessageHeaderRow` is a flex row with `gap-x-1.5`, so
|
||||
* spacing comes from the container. Adding margin here double-spaces it.
|
||||
*/
|
||||
export function MessageMetaSeparator() {
|
||||
return (
|
||||
<span aria-hidden="true" className="text-xs text-muted-foreground/40">
|
||||
·
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders divider-separated header metadata segments in order, skipping empty
|
||||
* slots. Each divider is grouped with the segment it precedes so the two wrap
|
||||
* together — as loose siblings in a flex-wrap row, a divider can end up alone
|
||||
* at the start of the second line.
|
||||
*/
|
||||
export function MessageMetaSegments({
|
||||
segments,
|
||||
}: {
|
||||
segments: ReadonlyArray<{ key: string; node: React.ReactNode }>;
|
||||
}) {
|
||||
const present = segments.filter((slot) => Boolean(slot.node));
|
||||
return (
|
||||
<>
|
||||
{present.map(({ key, node }, index) =>
|
||||
index === 0 ? (
|
||||
<React.Fragment key={key}>{node}</React.Fragment>
|
||||
) : (
|
||||
<span
|
||||
className="inline-flex min-w-0 items-baseline gap-x-1.5"
|
||||
key={key}
|
||||
>
|
||||
<MessageMetaSeparator />
|
||||
{node}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type MessageAuthorTextProps = {
|
||||
as?: "div" | "h3" | "span";
|
||||
children: React.ReactNode;
|
||||
|
||||
@@ -52,7 +52,11 @@ import { editMessage } from "@/shared/api/tauri";
|
||||
import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages";
|
||||
import { toast } from "sonner";
|
||||
import { MessageAgentOwner } from "./MessageAgentOwner";
|
||||
import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader";
|
||||
import {
|
||||
MessageAuthorText,
|
||||
MessageHeaderRow,
|
||||
MessageMetaSegments,
|
||||
} from "./MessageHeader";
|
||||
import { MessageTimestamp } from "./MessageTimestamp";
|
||||
import { SentFromThreadLine } from "./SentFromThreadLine";
|
||||
import { WaveMessageAttachment } from "./WaveMessageAttachment";
|
||||
@@ -523,7 +527,6 @@ export const MessageRow = React.memo(
|
||||
className="opacity-0 transition-opacity group-hover/message:opacity-100 group-focus-within/message:opacity-100"
|
||||
createdAt={message.createdAt}
|
||||
hideDayPeriod
|
||||
time={message.time}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -627,11 +630,19 @@ export const MessageRow = React.memo(
|
||||
|
||||
const inlineMetadataNode = (
|
||||
<div className="flex shrink-0 items-baseline gap-2 text-xs">
|
||||
<MessageTimestamp createdAt={message.createdAt} time={message.time} />
|
||||
<MessageTimestamp createdAt={message.createdAt} />
|
||||
{statusMetadataNode}
|
||||
</div>
|
||||
);
|
||||
|
||||
const personaNode =
|
||||
message.personaDisplayName &&
|
||||
message.personaDisplayName !== message.author ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{message.personaDisplayName}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
const continuationMetadataNode =
|
||||
isDisplayedAsContinuation && statusMetadataNode ? (
|
||||
<div className="mt-0.5 flex items-baseline gap-2 text-xs">
|
||||
@@ -657,14 +668,14 @@ export const MessageRow = React.memo(
|
||||
) : (
|
||||
authorNode
|
||||
)}
|
||||
{agentOwnerNode}
|
||||
{inlineMetadataNode}
|
||||
{message.personaDisplayName &&
|
||||
message.personaDisplayName !== message.author ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{message.personaDisplayName}
|
||||
</span>
|
||||
) : null}
|
||||
{/* Author is not a segment: "Alice 9:53 AM" needs no divider. */}
|
||||
<MessageMetaSegments
|
||||
segments={[
|
||||
{ key: "owner", node: agentOwnerNode },
|
||||
{ key: "timestamp", node: inlineMetadataNode },
|
||||
{ key: "persona", node: personaNode },
|
||||
]}
|
||||
/>
|
||||
</MessageHeaderRow>
|
||||
);
|
||||
const bodyContainerClass = isDisplayedAsContinuation
|
||||
@@ -932,7 +943,9 @@ export const MessageRow = React.memo(
|
||||
prev.message.ownerLabel === next.message.ownerLabel &&
|
||||
prev.message.avatarUrl === next.message.avatarUrl &&
|
||||
prev.message.accent === next.message.accent &&
|
||||
prev.message.time === next.message.time &&
|
||||
// The header timestamp and hover gutter both derive from createdAt (the
|
||||
// old `time` prop was the same value pre-formatted; this row reads neither).
|
||||
prev.message.createdAt === next.message.createdAt &&
|
||||
prev.message.depth === next.message.depth &&
|
||||
prev.message.kind === next.message.kind &&
|
||||
prev.message.pending === next.message.pending &&
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
formatFullDateTime,
|
||||
formatTime,
|
||||
formatTimeWithoutDayPeriod,
|
||||
} from "@/features/messages/lib/dateFormatters";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { formatItemTimestamp } from "@/shared/lib/datetime";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -12,18 +14,40 @@ import {
|
||||
|
||||
const TIMESTAMP_TOOLTIP_DELAY_MS = 500;
|
||||
|
||||
/**
|
||||
* The timestamp beside a message author, and the clock that fades in over the
|
||||
* avatar gutter on continuation rows.
|
||||
*
|
||||
* Both labels are derived from `createdAt` here rather than taken as a
|
||||
* pre-formatted string, so the wording is recomputed on each render instead of
|
||||
* being frozen at the time the message list was formatted. Note 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 the same property, and both correct themselves on the
|
||||
* next message, scroll, or navigation.
|
||||
*
|
||||
* The two modes carry different information on purpose:
|
||||
*
|
||||
* - Header (default) — the full relative label ("Yesterday at 9:05 AM"). The day
|
||||
* divider above the group says which day it is, but a divider scrolls out of
|
||||
* view while its messages stay on screen, so a bare clock time on a row from
|
||||
* last week has nothing to anchor it.
|
||||
* - `hideDayPeriod` — clock only, minus the AM/PM marker. This renders in a
|
||||
* 36px-wide gutter where the avatar would be, so it has room for "9:05" and
|
||||
* nothing more.
|
||||
*/
|
||||
export function MessageTimestamp({
|
||||
className,
|
||||
createdAt,
|
||||
hideDayPeriod = false,
|
||||
time,
|
||||
}: {
|
||||
className?: string;
|
||||
createdAt: number;
|
||||
hideDayPeriod?: boolean;
|
||||
time: string;
|
||||
}) {
|
||||
const displayTime = hideDayPeriod ? formatTimeWithoutDayPeriod(time) : time;
|
||||
const displayTime = hideDayPeriod
|
||||
? formatTimeWithoutDayPeriod(formatTime(createdAt))
|
||||
: formatItemTimestamp(createdAt, { withTime: true });
|
||||
|
||||
return (
|
||||
<TooltipProvider
|
||||
|
||||
@@ -34,7 +34,11 @@ import {
|
||||
toInlineName,
|
||||
} from "../lib/systemEventCopy";
|
||||
import { MessageAgentOwner } from "./MessageAgentOwner";
|
||||
import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader";
|
||||
import {
|
||||
MessageAuthorText,
|
||||
MessageHeaderRow,
|
||||
MessageMetaSeparator,
|
||||
} from "./MessageHeader";
|
||||
import { MessageTimestamp } from "./MessageTimestamp";
|
||||
import {
|
||||
MembershipAvatarStack,
|
||||
@@ -895,15 +899,20 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
|
||||
{description.title}
|
||||
</MessageAuthorText>
|
||||
{displayedIdentityIsAgent ? (
|
||||
<MessageAgentOwner
|
||||
ownerLabel={displayedOwnerLabel}
|
||||
ownerPubkey={displayedOwnerPubkey}
|
||||
/>
|
||||
) : null}
|
||||
<MessageTimestamp
|
||||
createdAt={message.createdAt}
|
||||
time={message.time}
|
||||
/>
|
||||
<>
|
||||
<MessageAgentOwner
|
||||
ownerLabel={displayedOwnerLabel}
|
||||
ownerPubkey={displayedOwnerPubkey}
|
||||
/>
|
||||
{/* Grouped with the timestamp so the two wrap together. */}
|
||||
<span className="inline-flex min-w-0 items-baseline gap-x-1.5">
|
||||
<MessageMetaSeparator />
|
||||
<MessageTimestamp createdAt={message.createdAt} />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<MessageTimestamp createdAt={message.createdAt} />
|
||||
)}
|
||||
</MessageHeaderRow>
|
||||
<p className="-mt-0.5 text-sm leading-snug text-foreground">
|
||||
{description.action}
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from "react";
|
||||
import { VList } from "virtua";
|
||||
import type { VListHandle } from "virtua";
|
||||
|
||||
import { formatDayHeading } from "@/features/messages/lib/dateFormatters";
|
||||
import { formatDayGroupLabel } from "@/shared/lib/datetime";
|
||||
import {
|
||||
buildTimelineDayGroups,
|
||||
buildTimelineItems,
|
||||
@@ -346,13 +346,13 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
data-day-label={
|
||||
group.headingTimestamp === null
|
||||
? undefined
|
||||
: formatDayHeading(group.headingTimestamp)
|
||||
: formatDayGroupLabel(group.headingTimestamp)
|
||||
}
|
||||
data-testid="message-timeline-day-group"
|
||||
key={group.key}
|
||||
>
|
||||
{hideDayDividers || group.headingTimestamp === null ? null : (
|
||||
<DayDivider label={formatDayHeading(group.headingTimestamp)} />
|
||||
<DayDivider label={formatDayGroupLabel(group.headingTimestamp)} />
|
||||
)}
|
||||
{group.items.map((item) => (
|
||||
<TimelineRowShell item={item} key={getTimelineItemKey(item)}>
|
||||
@@ -507,7 +507,7 @@ function VirtualizedTimelineRows({
|
||||
const renderedDividerPillTop = (
|
||||
divider: (typeof dayDividerItems)[number],
|
||||
) => {
|
||||
const label = formatDayHeading(divider.item.headingTimestamp);
|
||||
const label = formatDayGroupLabel(divider.item.headingTimestamp);
|
||||
const source = [
|
||||
...scroller.querySelectorAll<HTMLElement>(
|
||||
'[data-testid="message-timeline-day-divider"]',
|
||||
@@ -564,11 +564,11 @@ function VirtualizedTimelineRows({
|
||||
pinnedLabel.style.transform = `translateY(${nextTranslateY}px)`;
|
||||
}
|
||||
const nextLabel = activeDivider
|
||||
? formatDayHeading(activeDivider.item.headingTimestamp)
|
||||
? formatDayGroupLabel(activeDivider.item.headingTimestamp)
|
||||
: null;
|
||||
const incomingLabel =
|
||||
nextDivider && nextTranslateY < 0
|
||||
? formatDayHeading(nextDivider.item.headingTimestamp)
|
||||
? formatDayGroupLabel(nextDivider.item.headingTimestamp)
|
||||
: null;
|
||||
const activeSourcePill = sourcePills.find(
|
||||
(pill) => pill.parentElement?.dataset.dayLabel === nextLabel,
|
||||
@@ -762,7 +762,7 @@ function VirtualizedTimelineRows({
|
||||
return <div key={virtualizedItemKey(item)}>{item.content}</div>;
|
||||
}
|
||||
if (item.kind === "day-divider") {
|
||||
const dayLabel = formatDayHeading(item.headingTimestamp);
|
||||
const dayLabel = formatDayGroupLabel(item.headingTimestamp);
|
||||
return (
|
||||
<div
|
||||
className="relative flex flex-col before:absolute before:inset-x-0 before:top-1/2 before:h-px before:-translate-y-1/2 before:bg-border/35 before:content-['']"
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
/**
|
||||
* MessageTimestamp renders through Radix tooltip primitives, which need a DOM to
|
||||
* mount. These assertions read the source instead, because the thing worth
|
||||
* pinning is which formatter each mode reaches for — the labels themselves are
|
||||
* covered by `shared/lib/datetime.test.mjs`.
|
||||
*/
|
||||
const source = readFileSync(
|
||||
new URL("./MessageTimestamp.tsx", import.meta.url),
|
||||
"utf8",
|
||||
).replace(/\s+/g, " ");
|
||||
|
||||
test("a message header shows the relative label, including the time", () => {
|
||||
// Regression guard: this used to render a bare clock time, so a message from
|
||||
// last week read "9:05 AM" once its day divider scrolled out of view.
|
||||
assert.match(
|
||||
source,
|
||||
/: formatItemTimestamp\(createdAt, \{ withTime: true \}\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("the continuation gutter stays clock-only", () => {
|
||||
// 36px of width (w-9). A relative label would not fit.
|
||||
assert.match(
|
||||
source,
|
||||
/hideDayPeriod \? formatTimeWithoutDayPeriod\(formatTime\(createdAt\)\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("both labels derive from createdAt, not a pre-formatted prop", () => {
|
||||
// A captured string would keep saying "Today" after midnight.
|
||||
assert.doesNotMatch(source, /time: string/);
|
||||
assert.doesNotMatch(source, /\btime\b(?!\w)[^;]*?=\s*\{/);
|
||||
});
|
||||
|
||||
test("the tooltip still carries the unabbreviated timestamp", () => {
|
||||
assert.match(source, /formatFullDateTime\(createdAt\)/);
|
||||
});
|
||||
185
desktop/src/shared/lib/datetime.test.mjs
Normal file
185
desktop/src/shared/lib/datetime.test.mjs
Normal file
@@ -0,0 +1,185 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { formatDayGroupLabel, formatItemTimestamp } from "./datetime.ts";
|
||||
|
||||
/** Local-time unix seconds, so the tests read in the same zone the code uses. */
|
||||
function at(year, monthIndex, day, hour = 12, minute = 0) {
|
||||
return new Date(year, monthIndex, day, hour, minute).getTime() / 1_000;
|
||||
}
|
||||
|
||||
const NOW = at(2026, 6, 30, 14, 30); // Thu Jul 30 2026, 2:30 PM local
|
||||
|
||||
test("the same calendar day reads Today", () => {
|
||||
assert.equal(formatDayGroupLabel(at(2026, 6, 30, 9, 5), NOW), "Today");
|
||||
// Just after midnight and just before it still count as the same day.
|
||||
assert.equal(formatDayGroupLabel(at(2026, 6, 30, 0, 1), NOW), "Today");
|
||||
assert.equal(formatDayGroupLabel(at(2026, 6, 30, 23, 59), NOW), "Today");
|
||||
});
|
||||
|
||||
test("the previous calendar day reads Yesterday", () => {
|
||||
assert.equal(formatDayGroupLabel(at(2026, 6, 29, 23, 59), NOW), "Yesterday");
|
||||
assert.equal(formatDayGroupLabel(at(2026, 6, 29, 0, 0), NOW), "Yesterday");
|
||||
});
|
||||
|
||||
test("Yesterday is a calendar boundary, not a 24-hour window", () => {
|
||||
// 15 hours earlier, but the day rolled over: yesterday, not Today.
|
||||
const lateLastNight = at(2026, 6, 29, 23, 30);
|
||||
assert.equal(formatDayGroupLabel(lateLastNight, NOW), "Yesterday");
|
||||
// 22 hours earlier and still the same calendar day: Today.
|
||||
const earlyToday = at(2026, 6, 30, 0, 30);
|
||||
assert.equal(formatDayGroupLabel(earlyToday, NOW), "Today");
|
||||
});
|
||||
|
||||
test("two to six days back reads as the weekday alone", () => {
|
||||
assert.equal(formatDayGroupLabel(at(2026, 6, 28), NOW), "Tuesday");
|
||||
assert.equal(formatDayGroupLabel(at(2026, 6, 27), NOW), "Monday");
|
||||
assert.equal(formatDayGroupLabel(at(2026, 6, 24), NOW), "Friday");
|
||||
});
|
||||
|
||||
test("seven days back leaves the weekday band, so it can't repeat a name", () => {
|
||||
// Same weekday name as today — a weekday label here would be ambiguous.
|
||||
assert.equal(formatDayGroupLabel(at(2026, 6, 23), NOW), "Thursday, July 23");
|
||||
});
|
||||
|
||||
test("older dates in the current year omit the year", () => {
|
||||
assert.equal(formatDayGroupLabel(at(2026, 5, 20), NOW), "Saturday, June 20");
|
||||
assert.equal(formatDayGroupLabel(at(2026, 0, 3), NOW), "Saturday, January 3");
|
||||
});
|
||||
|
||||
test("dates in earlier years include the year", () => {
|
||||
assert.equal(formatDayGroupLabel(at(2025, 5, 20), NOW), "June 20, 2025");
|
||||
assert.equal(formatDayGroupLabel(at(2022, 7, 22), NOW), "August 22, 2022");
|
||||
});
|
||||
|
||||
test("old dates keep the day, so consecutive dividers stay distinguishable", () => {
|
||||
// The standard would collapse these to "Aug 2022"; a group label must
|
||||
// identify its own day.
|
||||
const labels = [
|
||||
formatDayGroupLabel(at(2022, 7, 21), NOW),
|
||||
formatDayGroupLabel(at(2022, 7, 22), NOW),
|
||||
formatDayGroupLabel(at(2022, 7, 23), NOW),
|
||||
];
|
||||
assert.equal(new Set(labels).size, 3, `labels repeated: ${labels}`);
|
||||
});
|
||||
|
||||
test("no label carries an ordinal suffix", () => {
|
||||
const probes = [
|
||||
at(2026, 6, 30),
|
||||
at(2026, 6, 29),
|
||||
at(2026, 6, 27),
|
||||
at(2026, 5, 1),
|
||||
at(2026, 5, 2),
|
||||
at(2026, 5, 3),
|
||||
at(2026, 5, 11),
|
||||
at(2026, 5, 12),
|
||||
at(2026, 5, 13),
|
||||
at(2026, 5, 21),
|
||||
at(2026, 5, 22),
|
||||
at(2026, 5, 23),
|
||||
at(2025, 5, 20),
|
||||
];
|
||||
for (const probe of probes) {
|
||||
assert.doesNotMatch(
|
||||
formatDayGroupLabel(probe, NOW),
|
||||
/\d(?:st|nd|rd|th)\b/,
|
||||
`ordinal suffix in ${formatDayGroupLabel(probe, NOW)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("a future timestamp is not labelled with a past weekday", () => {
|
||||
// Clock skew, or a relay running ahead. "Tuesday" would read as last Tuesday.
|
||||
const nextWeek = at(2026, 7, 4);
|
||||
assert.equal(formatDayGroupLabel(nextWeek, NOW), "Tuesday, August 4");
|
||||
// Still same-day, so Today remains correct for small skew.
|
||||
assert.equal(formatDayGroupLabel(at(2026, 6, 30, 23, 0), NOW), "Today");
|
||||
});
|
||||
|
||||
test("the label follows the current clock, not a captured one", () => {
|
||||
const event = at(2026, 6, 30, 9, 0);
|
||||
assert.equal(formatDayGroupLabel(event, NOW), "Today");
|
||||
// Same event, read a day later.
|
||||
assert.equal(formatDayGroupLabel(event, at(2026, 6, 31, 9, 0)), "Yesterday");
|
||||
});
|
||||
|
||||
// ── formatItemTimestamp ─────────────────────────────────────────────────────
|
||||
|
||||
test("today is a bare clock time in both modes", () => {
|
||||
const today = at(2026, 6, 30, 9, 5);
|
||||
assert.equal(formatItemTimestamp(today, { nowSeconds: NOW }), "9:05 AM");
|
||||
assert.equal(
|
||||
formatItemTimestamp(today, { withTime: true, nowSeconds: NOW }),
|
||||
"9:05 AM",
|
||||
);
|
||||
});
|
||||
|
||||
test("compact mode drops the time outside today", () => {
|
||||
const opts = { nowSeconds: NOW };
|
||||
assert.equal(formatItemTimestamp(at(2026, 6, 29, 9, 5), opts), "Yesterday");
|
||||
assert.equal(formatItemTimestamp(at(2026, 6, 27, 9, 5), opts), "Monday");
|
||||
assert.equal(formatItemTimestamp(at(2026, 5, 20, 9, 5), opts), "Sat, Jun 20");
|
||||
assert.equal(
|
||||
formatItemTimestamp(at(2025, 5, 20, 9, 5), opts),
|
||||
"Jun 20, 2025",
|
||||
);
|
||||
});
|
||||
|
||||
test("roomy mode keeps the time at every band, joined with 'at'", () => {
|
||||
const opts = { withTime: true, nowSeconds: NOW };
|
||||
assert.equal(
|
||||
formatItemTimestamp(at(2026, 6, 29, 9, 5), opts),
|
||||
"Yesterday at 9:05 AM",
|
||||
);
|
||||
assert.equal(
|
||||
formatItemTimestamp(at(2026, 6, 27, 14, 34), opts),
|
||||
"Monday at 2:34 PM",
|
||||
);
|
||||
assert.equal(
|
||||
formatItemTimestamp(at(2026, 5, 20, 14, 34), opts),
|
||||
"Sat, Jun 20 at 2:34 PM",
|
||||
);
|
||||
assert.equal(
|
||||
formatItemTimestamp(at(2025, 5, 20, 14, 34), opts),
|
||||
"Jun 20, 2025 at 2:34 PM",
|
||||
);
|
||||
});
|
||||
|
||||
test("the year is omitted within the current year in both modes", () => {
|
||||
for (const withTime of [false, true]) {
|
||||
const label = formatItemTimestamp(at(2026, 0, 3, 9, 5), {
|
||||
withTime,
|
||||
nowSeconds: NOW,
|
||||
});
|
||||
assert.doesNotMatch(label, /2026/, `current year leaked into "${label}"`);
|
||||
}
|
||||
});
|
||||
|
||||
test("no item label carries an ordinal suffix", () => {
|
||||
for (const withTime of [false, true]) {
|
||||
for (const day of [1, 2, 3, 11, 12, 13, 21, 22, 23, 31]) {
|
||||
const label = formatItemTimestamp(at(2026, 0, day, 9, 5), {
|
||||
withTime,
|
||||
nowSeconds: NOW,
|
||||
});
|
||||
assert.doesNotMatch(
|
||||
label,
|
||||
/\d(?:st|nd|rd|th)\b/,
|
||||
`ordinal in "${label}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("compact labels stay short enough for a narrow list row", () => {
|
||||
for (const probe of [
|
||||
at(2026, 6, 30, 14, 34),
|
||||
at(2026, 6, 29),
|
||||
at(2026, 6, 27),
|
||||
at(2026, 5, 20),
|
||||
at(2025, 5, 20),
|
||||
]) {
|
||||
const label = formatItemTimestamp(probe, { nowSeconds: NOW });
|
||||
assert.ok(label.length <= 12, `"${label}" is ${label.length} chars`);
|
||||
}
|
||||
});
|
||||
175
desktop/src/shared/lib/datetime.ts
Normal file
175
desktop/src/shared/lib/datetime.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Relative date labels shared by the chat timeline and the Inbox.
|
||||
*
|
||||
* Both surfaces group items by calendar day and label the group. They used to
|
||||
* do it independently — chat via `messages/lib/dateFormatters.formatDayHeading`,
|
||||
* the Inbox inline inside `groupInboxItems` — and the two drifted apart: chat
|
||||
* appended an ordinal ("Monday, March 31st") while the Inbox always printed the
|
||||
* year ("Jul 8, 2026", even for a date three weeks ago).
|
||||
*
|
||||
* The ladder follows the Block writing standard for relative dates, with one
|
||||
* deliberate deviation noted on `formatDayGroupLabel`.
|
||||
*/
|
||||
|
||||
const WEEKDAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
weekday: "long",
|
||||
});
|
||||
|
||||
const WEEKDAY_MONTH_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const MONTH_DAY_YEAR_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const SHORT_WEEKDAY_SHORT_MONTH_DAY_FORMATTER = new Intl.DateTimeFormat(
|
||||
"en-US",
|
||||
{
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
},
|
||||
);
|
||||
|
||||
const SHORT_MONTH_DAY_YEAR_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const TIME_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
/** Days in a week, past which the weekday name stops being unambiguous. */
|
||||
const WEEKDAY_BAND_DAYS = 7;
|
||||
|
||||
/**
|
||||
* Label for a group of items that share a calendar day — a chat day divider or
|
||||
* an Inbox section header.
|
||||
*
|
||||
* ```
|
||||
* Today → "Today"
|
||||
* Yesterday → "Yesterday"
|
||||
* 2–6 days ago → "Monday"
|
||||
* older, this year → "Saturday, June 20"
|
||||
* earlier years → "June 20, 2025"
|
||||
* ```
|
||||
*
|
||||
* The weekday rides along within the current year — feedback was that it still
|
||||
* orients ("was that a weekend?") well past the six-day band. Beyond a year it
|
||||
* stops earning its width: nobody maps "Friday" to anything a year later, and
|
||||
* the year itself needs the room.
|
||||
*
|
||||
* Deliberate deviation from the standard: the standard collapses anything over
|
||||
* ten months old to month and year ("Aug 2022"). A group label has to *identify*
|
||||
* its day — collapsing would give every day in a month the same header, so
|
||||
* scrolling old history would show a run of identical dividers with no way to
|
||||
* tell one day from the next. The day is kept and only the year is conditional.
|
||||
*
|
||||
* No ordinal suffix ("June 20", never "June 20th"), per the standard.
|
||||
*
|
||||
* `nowSeconds` is injectable so the relative bands are testable; it must stay a
|
||||
* parameter rather than a captured constant, because a label rendered before
|
||||
* midnight has to say "Yesterday" once the day rolls over.
|
||||
*/
|
||||
export function formatDayGroupLabel(
|
||||
unixSeconds: number,
|
||||
nowSeconds = Date.now() / 1_000,
|
||||
): string {
|
||||
const date = new Date(unixSeconds * 1_000);
|
||||
const now = new Date(nowSeconds * 1_000);
|
||||
const 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 machine) must not be labelled with a weekday that reads
|
||||
// as the recent past.
|
||||
if (dayDiff > 1 && dayDiff < WEEKDAY_BAND_DAYS) {
|
||||
return WEEKDAY_FORMATTER.format(date);
|
||||
}
|
||||
|
||||
return date.getFullYear() === now.getFullYear()
|
||||
? WEEKDAY_MONTH_DAY_FORMATTER.format(date)
|
||||
: MONTH_DAY_YEAR_FORMATTER.format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for a single item's timestamp — an Inbox list row, or a message in the
|
||||
* Inbox thread pane.
|
||||
*
|
||||
* ```
|
||||
* withTime: false (narrow rows) withTime: true (roomy rows)
|
||||
* Today → "2:34 PM" Today → "2:34 PM"
|
||||
* Yest. → "Yesterday" Yest. → "Yesterday at 2:34 PM"
|
||||
* 2–6d → "Monday" 2–6d → "Monday at 2:34 PM"
|
||||
* year → "Sat, Jun 20" year → "Sat, Jun 20 at 2:34 PM"
|
||||
* older → "Jun 20, 2025" older → "Jun 20, 2025 at 2:34 PM"
|
||||
* ```
|
||||
*
|
||||
* The weekday stays through the current year (abbreviated, matching the month)
|
||||
* and drops once the year appears — see `formatDayGroupLabel` for why.
|
||||
*
|
||||
* Today needs no date word in either mode: a bare clock time already reads as
|
||||
* today, and "Today at 2:34 PM" is longer without saying more.
|
||||
*
|
||||
* `withTime` is a surface decision, not a preference. Somewhere you read
|
||||
* conversation, the time is part of the content, so pass `true`. In a narrow
|
||||
* list row it costs more width than it earns and the full timestamp is a hover
|
||||
* away, so pass `false`.
|
||||
*
|
||||
* Months are abbreviated here but spelled out in `formatDayGroupLabel` — a
|
||||
* day divider is a roomy header of its own, an item label shares a row with a
|
||||
* name, a channel, and a preview.
|
||||
*/
|
||||
export function formatItemTimestamp(
|
||||
unixSeconds: number,
|
||||
{
|
||||
withTime = false,
|
||||
nowSeconds = Date.now() / 1_000,
|
||||
}: { withTime?: boolean; nowSeconds?: number } = {},
|
||||
): string {
|
||||
const date = new Date(unixSeconds * 1_000);
|
||||
const now = new Date(nowSeconds * 1_000);
|
||||
const dayDiff = calendarDaysBetween(now, date);
|
||||
const time = TIME_FORMATTER.format(date);
|
||||
|
||||
if (dayDiff === 0) return time;
|
||||
|
||||
let dayLabel: string;
|
||||
if (dayDiff === 1) {
|
||||
dayLabel = "Yesterday";
|
||||
} else if (dayDiff > 1 && dayDiff < WEEKDAY_BAND_DAYS) {
|
||||
dayLabel = WEEKDAY_FORMATTER.format(date);
|
||||
} else {
|
||||
dayLabel =
|
||||
date.getFullYear() === now.getFullYear()
|
||||
? SHORT_WEEKDAY_SHORT_MONTH_DAY_FORMATTER.format(date)
|
||||
: SHORT_MONTH_DAY_YEAR_FORMATTER.format(date);
|
||||
}
|
||||
|
||||
return withTime ? `${dayLabel} at ${time}` : dayLabel;
|
||||
}
|
||||
|
||||
/** Local midnight of the calendar day containing `date`. */
|
||||
function startOfLocalDay(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole calendar days from `date` to `now`, in local time. Rounded rather than
|
||||
* floored so a DST transition — a 23- or 25-hour day — still counts as one day.
|
||||
*/
|
||||
function calendarDaysBetween(now: Date, date: Date): number {
|
||||
return Math.round(
|
||||
(startOfLocalDay(now).getTime() - startOfLocalDay(date).getTime()) /
|
||||
86_400_000,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user