Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions __tests__/unit/services/timeline-time-ago.test.ts
Original file line number Diff line number Diff line change
@@ -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('');
});
});
1 change: 0 additions & 1 deletion src/app/(authenticated)/community/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ export default function CommunityPage() {
return (
<SocialTimeline
title="Community"
description="Public posts and updates from the OrangeCat community"
icon={Globe}
mode="community"
showShareButton={false}
Expand Down
1 change: 0 additions & 1 deletion src/app/(authenticated)/home/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ export default function HomePage() {
return (
<SocialTimeline
title="Home"
description="Updates from the people and projects you follow"
icon={Home}
mode="following"
showShareButton={false}
Expand Down
1 change: 0 additions & 1 deletion src/app/(authenticated)/timeline/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@ function TimelineContent() {
<>
<SocialTimeline
title="My Timeline"
description="Your personal timeline and story"
icon={BookOpen}
mode="timeline"
showShareButton={true}
Expand Down
5 changes: 4 additions & 1 deletion src/components/timeline/PostHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import Link from 'next/link';
import { MoreHorizontal, Lock, Users, Pencil, Trash2 } from 'lucide-react';
import { TimelineDisplayEvent } from '@/types/timeline';
import { formatRelativeTime } from '@/utils/dates';
// The timeline has its own compact age format; the generic one reads as prose
// ("about 23 hours ago") inside a metadata line.
import { getTimeAgo } from '@/services/timeline/formatters';
import { TIMELINE_SURFACE } from '@/config/timeline';
import { CAT_USERNAME } from '@/config/cat-identity';
import { normalizeUsername } from '@/config/usernames';
Expand Down Expand Up @@ -121,7 +124,7 @@ export function PostHeader({
className="text-fg-secondary text-sm hover:underline"
title={timestamp ? new Date(timestamp).toLocaleString() : undefined}
>
{timestamp ? formatRelativeTime(timestamp) : ''}
{timestamp ? getTimeAgo(timestamp) : ''}
</time>

{/* Visibility Indicator */}
Expand Down
4 changes: 0 additions & 4 deletions src/components/timeline/SocialTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@

export interface SocialTimelineProps {
title: string;
description: string;
icon: LucideIcon;
mode: 'timeline' | 'community' | 'following';
timelineOwnerId?: string;
Expand All @@ -38,7 +37,6 @@

export default function SocialTimeline({
title,
description,
icon: Icon,
mode,
timelineOwnerId,
Expand Down Expand Up @@ -90,7 +88,7 @@
<div className="text-center">
<h2 className="text-2xl font-semibold text-fg-primary mb-4">Please sign in</h2>
<p className="text-fg-secondary mb-6">You need to be signed in to view this page.</p>
<Button onClick={() => (window.location.href = '/auth')}>Sign In</Button>

Check warning on line 91 in src/components/timeline/SocialTimeline.tsx

View workflow job for this annotation

GitHub Actions / build-and-smoke

Do not use `window.location.href` to navigate to internal Next.js pages. Use `redirect()` in the render phase, or `useRouter().push()` in Client Components' event handlers instead. See: https://nextjs.org/docs/messages/no-location-assign-relative-destination
</div>
</div>
);
Expand Down Expand Up @@ -269,8 +267,6 @@
return (
<TimelineLayout
title={title}
description={description}
icon={Icon}
feed={activeFeed}
onEventUpdate={handleEventUpdate}
onLoadMore={handleLoadMore}
Expand Down
27 changes: 13 additions & 14 deletions src/components/timeline/TimelineLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
import { TimelineFeedResponse, TimelineDisplayEvent } from '@/types/timeline';
import TimelineComponent from './TimelineComponent';
import { cn } from '@/lib/utils';
import { TIMELINE_SURFACE } from '@/config/timeline';

export interface TimelineLayoutProps {
title: string;
description: string;
icon: LucideIcon;
feed: TimelineFeedResponse;
onEventUpdate: (eventId: string, updates: Partial<TimelineDisplayEvent>) => void;
onLoadMore: () => void;
Expand Down Expand Up @@ -36,8 +33,6 @@ export interface TimelineLayoutProps {
*/
export default function TimelineLayout({
title,
description,
icon: Icon,
feed,
onEventUpdate,
onLoadMore,
Expand All @@ -55,16 +50,20 @@ export default function TimelineLayout({
<div className={TIMELINE_SURFACE.page}>
<div className={TIMELINE_SURFACE.rail}>
<div className={TIMELINE_SURFACE.feed}>
{/*
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.
*/}
<div className={cn(TIMELINE_SURFACE.header)}>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-surface-raised text-fg-primary">
<Icon className="w-5 h-5" />
</div>
<div>
<h1 className="text-xl font-bold text-fg-primary">{title}</h1>
{description && <p className="text-sm text-fg-secondary">{description}</p>}
</div>
</div>
<h1 className="text-lg font-semibold tracking-display text-fg-primary">{title}</h1>
{additionalHeaderContent && (
<div className="flex items-center gap-2">{additionalHeaderContent}</div>
)}
Expand Down
87 changes: 74 additions & 13 deletions src/components/timeline/TimelineSearchControls.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -33,16 +51,61 @@ export function TimelineSearchControls({
searchResultsCount,
searchTotal,
}: TimelineSearchControlsProps) {
const [expanded, setExpanded] = useState(false);
const inputRef = useRef<HTMLInputElement>(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 (
<div className="flex justify-end px-4 py-2">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setExpanded(true)}
className={TIMELINE_SURFACE.chip}
aria-label="Search posts"
aria-expanded={false}
>
<Search className="w-4 h-4" />
</Button>
</div>
);
}

return (
<div className="border-b border-subtle bg-surface-page px-4 py-3">
<form onSubmit={onSearch} className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="w-4 h-4 text-fg-tertiary absolute left-3 top-1/2 -translate-y-1/2" />
<input
ref={inputRef}
type="text"
value={searchQuery}
onChange={e => 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"
/>
</div>
Expand All @@ -54,18 +117,16 @@ export function TimelineSearchControls({
)}
{searching ? 'Searching' : 'Search'}
</Button>
{isSearchActive && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={onClearSearch}
className={TIMELINE_SURFACE.chip}
>
<X className="w-4 h-4 mr-1" />
Clear
</Button>
)}
<Button
type="button"
size="sm"
variant="ghost"
onClick={close}
className={TIMELINE_SURFACE.chip}
aria-label={isSearchActive ? 'Clear search' : 'Close search'}
>
<X className="w-4 h-4" />
</Button>
</form>
{searchError && <p className="text-sm text-status-negative mt-2">{searchError}</p>}
{isSearchActive && !searchError && (
Expand Down
53 changes: 41 additions & 12 deletions src/services/timeline/formatters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
});
}

/**
Expand Down
Loading