From a03cdb4c142c68d5f5250b5cffd03c73649410d3 Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:14:04 +0200 Subject: [PATCH] feat(timeline): five stacked boxes before the first post, now two The timeline surface put five separate bordered containers above the feed: page header, search field, composer, bulk-select row, then posts. Every feed product that works shows a composer and then posts. Stacked containers with no hierarchy between them is what "a bunch of random elements thrown together" looks like. Three changes, none of which remove a capability: - Header: dropped the 36px icon tile and the description line. It said "Your personal timeline and story" to someone who had just clicked Timeline - a second source of truth for a fact the nav already states, costing ~100px above every feed. Now the name and the surface's actions. The `description` and `icon` props are gone rather than left unrendered, so nothing can pass a value that nothing displays. - Post search: collapsed to an icon, expands on demand, and stays open whenever a search is active so results never appear with no visible cause. Deliberately NOT deleted: the global command palette searches pages and entities, not post text, so this is the only way to find a post. I checked before touching it. - Timestamps: "about 23 hours ago" -> "23h". The header was reaching past the timeline's own formatter for the generic date-fns one, which renders prose inside a metadata line at three times the width. No " ago" suffix, since the position after the handle already says it is an age, and the exact time stays one hover away in the existing title/dateTime attributes. Dates inside the current year drop the year; older ones keep it, so a year-old post can never read as recent. getTimeAgo also stopped being able to render a negative age - a clock skew or an optimistic post stamped microseconds ahead now reads "now" instead of "-1m" - and returns empty rather than "Invalid Date" for an unparseable value. Full suite: 266 suites, 2553 passed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5 --- .../unit/services/timeline-time-ago.test.ts | 75 ++++++++++++++++ src/app/(authenticated)/community/page.tsx | 1 - src/app/(authenticated)/home/page.tsx | 1 - src/app/(authenticated)/timeline/page.tsx | 1 - src/components/timeline/PostHeader.tsx | 5 +- src/components/timeline/SocialTimeline.tsx | 4 - src/components/timeline/TimelineLayout.tsx | 27 +++--- .../timeline/TimelineSearchControls.tsx | 87 ++++++++++++++++--- src/services/timeline/formatters/index.ts | 53 ++++++++--- 9 files changed, 207 insertions(+), 47 deletions(-) create mode 100644 __tests__/unit/services/timeline-time-ago.test.ts diff --git a/__tests__/unit/services/timeline-time-ago.test.ts b/__tests__/unit/services/timeline-time-ago.test.ts new file mode 100644 index 000000000..c24099bbc --- /dev/null +++ b/__tests__/unit/services/timeline-time-ago.test.ts @@ -0,0 +1,75 @@ +/** + * A post's age is a glance, not a sentence. + * + * The post header rendered "about 23 hours ago" — date-fns with `addSuffix` — + * three times the width of "23h" for the same fact, reading as prose inside a + * metadata line. Every feed product converged on the compact form for the same + * reason: in a feed, the age competes with the post. + * + * The timeline already had its own formatter for this; the header just reached + * past it for the generic one. These tests pin the format so a future edit + * cannot quietly widen it again. + */ + +import { getTimeAgo } from '@/services/timeline/formatters'; + +const at = (msAgo: number) => new Date(Date.now() - msAgo).toISOString(); +const SEC = 1000; +const MIN = 60 * SEC; +const HOUR = 60 * MIN; +const DAY = 24 * HOUR; + +describe('getTimeAgo', () => { + it('says "now" for something that just happened', () => { + expect(getTimeAgo(at(0))).toBe('now'); + expect(getTimeAgo(at(3 * SEC))).toBe('now'); + }); + + it('never renders a negative age', () => { + // Clock skew, or an optimistic post stamped microseconds in the future. + // "now" is honest; "-1m" is a bug on screen. + expect(getTimeAgo(new Date(Date.now() + 5 * SEC).toISOString())).toBe('now'); + }); + + it('counts seconds, then minutes, then hours, then days', () => { + expect(getTimeAgo(at(30 * SEC))).toBe('30s'); + expect(getTimeAgo(at(5 * MIN))).toBe('5m'); + expect(getTimeAgo(at(59 * MIN))).toBe('59m'); + expect(getTimeAgo(at(23 * HOUR))).toBe('23h'); + expect(getTimeAgo(at(6 * DAY))).toBe('6d'); + }); + + it('carries no " ago" suffix', () => { + // The position after the handle already says it is an age. + for (const ms of [30 * SEC, 5 * MIN, 23 * HOUR, 6 * DAY]) { + expect(getTimeAgo(at(ms))).not.toMatch(/ago/); + } + }); + + it('switches to an absolute date past a week', () => { + const out = getTimeAgo(at(30 * DAY)); + expect(out).not.toMatch(/\d+[smhd]$/); + expect(out).toMatch(/\d/); + }); + + it('keeps the year on older posts so they cannot read as recent', () => { + const twoYearsAgo = new Date(); + twoYearsAgo.setFullYear(twoYearsAgo.getFullYear() - 2); + expect(getTimeAgo(twoYearsAgo.toISOString())).toMatch( + String(twoYearsAgo.getFullYear()) + ); + }); + + it('drops the year within the current year', () => { + // Pick a date in this year that is safely more than a week old. + const now = new Date(); + const earlier = new Date(now.getFullYear(), 0, 2); + if (now.getTime() - earlier.getTime() > 8 * DAY) { + expect(getTimeAgo(earlier.toISOString())).not.toMatch(String(now.getFullYear())); + } + }); + + it('returns empty for an unparseable timestamp rather than "Invalid Date"', () => { + expect(getTimeAgo('not a date')).toBe(''); + }); +}); diff --git a/src/app/(authenticated)/community/page.tsx b/src/app/(authenticated)/community/page.tsx index 854640fab..a0f38220f 100644 --- a/src/app/(authenticated)/community/page.tsx +++ b/src/app/(authenticated)/community/page.tsx @@ -16,7 +16,6 @@ export default function CommunityPage() { return ( - {timestamp ? formatRelativeTime(timestamp) : ''} + {timestamp ? getTimeAgo(timestamp) : ''} {/* Visibility Indicator */} diff --git a/src/components/timeline/SocialTimeline.tsx b/src/components/timeline/SocialTimeline.tsx index 4aba1effc..3dfd64107 100644 --- a/src/components/timeline/SocialTimeline.tsx +++ b/src/components/timeline/SocialTimeline.tsx @@ -14,7 +14,6 @@ import { TIMELINE_COPY, TIMELINE_SURFACE } from '@/config/timeline'; export interface SocialTimelineProps { title: string; - description: string; icon: LucideIcon; mode: 'timeline' | 'community' | 'following'; timelineOwnerId?: string; @@ -38,7 +37,6 @@ export interface SocialTimelineProps { export default function SocialTimeline({ title, - description, icon: Icon, mode, timelineOwnerId, @@ -269,8 +267,6 @@ export default function SocialTimeline({ return ( ) => void; onLoadMore: () => void; @@ -36,8 +33,6 @@ export interface TimelineLayoutProps { */ export default function TimelineLayout({ title, - description, - icon: Icon, feed, onEventUpdate, onLoadMore, @@ -55,16 +50,20 @@ export default function TimelineLayout({
+ {/* + The header names the surface and gets out of the way. + + It used to carry a 36px icon tile, a 20px bold title AND a + description line — roughly 100px of chrome above every feed, saying + "Your personal timeline and story" to someone who just clicked + "Timeline". The nav already answers where you are; a subtitle + restating the title is a second source of truth for the same fact + and pushes the first post below the fold. + + Now: the name, and whatever actions belong to this surface. + */}
-
-
- -
-
-

{title}

- {description &&

{description}

} -
-
+

{title}

{additionalHeaderContent && (
{additionalHeaderContent}
)} diff --git a/src/components/timeline/TimelineSearchControls.tsx b/src/components/timeline/TimelineSearchControls.tsx index eb7554735..6d8ab3394 100644 --- a/src/components/timeline/TimelineSearchControls.tsx +++ b/src/components/timeline/TimelineSearchControls.tsx @@ -1,11 +1,12 @@ /** * TimelineSearchControls Component * - * Renders search input and controls for timeline feeds. + * Post search for timeline feeds — collapsed to an icon until asked for. */ 'use client'; +import { useEffect, useRef, useState } from 'react'; import Button from '@/components/ui/Button'; import { Search, Loader2, X } from 'lucide-react'; import { TIMELINE_SURFACE } from '@/config/timeline'; @@ -22,6 +23,23 @@ interface TimelineSearchControlsProps { searchTotal: number | null; } +/** + * Search is a thing you go and do, not a thing that sits on the page. + * + * This used to render a permanent full-width search field with its own border, + * stacked above the composer. The timeline surface was five separate bordered + * boxes before the first post — page header, search, composer, bulk-select, + * then the feed — which is what "a bunch of random elements thrown together" + * looks like. Every feed product shows one composer and then posts. + * + * Collapsed it is a single icon button; expanded it is the field it always + * was. Nothing was removed, because nothing else in the app searches posts — + * the global command palette covers pages and entities, not post text — so + * deleting this would have taken away the only way to find a post. + * + * Stays open whenever a search is active, so results never appear with no + * visible sign of what produced them. + */ export function TimelineSearchControls({ searchQuery, onSearchQueryChange, @@ -33,16 +51,61 @@ export function TimelineSearchControls({ searchResultsCount, searchTotal, }: TimelineSearchControlsProps) { + const [expanded, setExpanded] = useState(false); + const inputRef = useRef(null); + + // An active search must never be invisible: if results are on screen, so is + // the field that asked for them. + const open = expanded || isSearchActive; + + useEffect(() => { + if (expanded) { + inputRef.current?.focus(); + } + }, [expanded]); + + const close = () => { + setExpanded(false); + if (isSearchActive) { + onClearSearch(); + } + }; + + if (!open) { + return ( +
+ +
+ ); + } + return (
onSearchQueryChange(e.target.value)} + onKeyDown={e => { + if (e.key === 'Escape') { + close(); + } + }} placeholder="Search posts" + aria-label="Search posts" className="w-full rounded-md border border-subtle bg-surface-page py-2 pl-9 pr-3 text-sm text-fg-primary placeholder:text-fg-secondary focus:border-interactive focus:outline-none focus:ring-2 focus:ring-ring/20" />
@@ -54,18 +117,16 @@ export function TimelineSearchControls({ )} {searching ? 'Searching' : 'Search'} - {isSearchActive && ( - - )} +
{searchError &&

{searchError}

} {isSearchActive && !searchError && ( diff --git a/src/services/timeline/formatters/index.ts b/src/services/timeline/formatters/index.ts index 1e3dfefe7..041484f27 100644 --- a/src/services/timeline/formatters/index.ts +++ b/src/services/timeline/formatters/index.ts @@ -168,29 +168,58 @@ export function formatAmount(event: TimelineEvent): string | undefined { } /** - * Get time ago string + * How old a post is, in the compact form a feed wants. + * + * `30s`, `5m`, `23h`, `6d`, then an absolute date — the convention every + * timeline product converged on, because in a feed the age is a glance, not a + * sentence. The post header was rendering "about 23 hours ago" (date-fns with + * `addSuffix`), which is three times the width for the same fact and reads as + * prose sitting inside a metadata line. + * + * No " ago" suffix: the position after the author's handle already says it is + * an age, and every character here competes with the post itself. Callers pair + * this with a `title`/`dateTime` carrying the exact timestamp, so precision is + * one hover away and nothing is actually lost. + * + * Dates inside the current year omit it (`Aug 1`); older ones keep it + * (`Aug 1, 2025`), so a year-old post can never be mistaken for a recent one. */ export function getTimeAgo(timestamp: string): string { - const now = new Date(); const eventTime = new Date(timestamp); - const diffMs = now.getTime() - eventTime.getTime(); - const diffMins = Math.floor(diffMs / (1000 * 60)); - const diffHours = Math.floor(diffMins / 60); - const diffDays = Math.floor(diffHours / 24); + if (Number.isNaN(eventTime.getTime())) { + return ''; + } - if (diffMins < 1) { - return 'Just now'; + const now = new Date(); + const diffSecs = Math.floor((now.getTime() - eventTime.getTime()) / 1000); + + // A clock skew or a just-written optimistic post can land microseconds in the + // future; "now" is honest there, "-1m" is not. + if (diffSecs < 60) { + return diffSecs < 5 ? 'now' : `${Math.max(diffSecs, 0)}s`; } + + const diffMins = Math.floor(diffSecs / 60); if (diffMins < 60) { - return `${diffMins}m ago`; + return `${diffMins}m`; } + + const diffHours = Math.floor(diffMins / 60); if (diffHours < 24) { - return `${diffHours}h ago`; + return `${diffHours}h`; } + + const diffDays = Math.floor(diffHours / 24); if (diffDays < 7) { - return `${diffDays}d ago`; + return `${diffDays}d`; } - return eventTime.toLocaleDateString(); + + const sameYear = eventTime.getFullYear() === now.getFullYear(); + return eventTime.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + ...(sameYear ? {} : { year: 'numeric' }), + }); } /**