import { startOfDay, isToday, isYesterday, format } from 'date-fns';
export interface ChatMessage {
id: string;
senderId: string;
senderName: string;
body: string;
sentAt: string; // ISO timestamp
}
export interface GroupedMessage extends ChatMessage {
senderChange: boolean;
}
export interface MessageGroup {
dayKey: string;
label: string;
messages: GroupedMessage[];
}
function formatDayLabel(date: Date): string {
if (isToday(date)) return 'Today';
if (isYesterday(date)) return 'Yesterday';
return format(date, 'EEEE, MMMM d');
}
export function groupMessagesByDay(messages: ChatMessage[]): MessageGroup[] {
const groups: MessageGroup[] = [];
for (const message of messages) {
const day = startOfDay(new Date(message.sentAt));
const dayKey = day.toISOString();
let current = groups[groups.length - 1];
if (!current || current.dayKey !== dayKey) {
current = { dayKey, label: formatDayLabel(day), messages: [] };
groups.push(current);
}
const prev = current.messages[current.messages.length - 1];
const senderChange = !prev || prev.senderId !== message.senderId;
current.messages.push({ ...message, senderChange });
}
return groups;
}
import { useMemo } from 'react';
import { ChatMessage, MessageGroup, groupMessagesByDay } from './groupMessagesByDay';
export function useGroupedMessages(messages: ChatMessage[]): MessageGroup[] {
return useMemo(() => groupMessagesByDay(messages), [messages]);
}
import React from 'react';
import { format } from 'date-fns';
import { useGroupedMessages } from './useGroupedMessages';
import { ChatMessage, GroupedMessage } from './groupMessagesByDay';
interface MessageListProps {
messages: ChatMessage[];
currentUserId: string;
}
function formatTime(iso: string): string {
return format(new Date(iso), 'h:mm a');
}
function DayDivider({ label }: { label: string }) {
return (
<div className="day-divider" role="separator">
<span>{label}</span>
</div>
);
}
function MessageBubble({ message, isOwn }: { message: GroupedMessage; isOwn: boolean }) {
const showHeader = message.senderChange && !isOwn;
return (
<div className={isOwn ? 'bubble bubble--own' : 'bubble'}>
{showHeader && <span className="bubble__author">{message.senderName}</span>}
<p className="bubble__body">{message.body}</p>
<time className="bubble__time">{formatTime(message.sentAt)}</time>
</div>
);
}
export function MessageList({ messages, currentUserId }: MessageListProps) {
const groups = useGroupedMessages(messages);
return (
<div className="message-list">
{groups.map((group) => (
<section key={group.dayKey} className="message-group">
<DayDivider label={group.label} />
{group.messages.map((message) => (
<MessageBubble
key={message.id}
message={message}
isOwn={message.senderId === currentUserId}
/>
))}
</section>
))}
</div>
);
}
This snippet shows how a chat UI turns a flat, chronologically ordered array of messages into a day-grouped structure that renders as dated sections. The grouping logic is kept out of the components entirely and lives in a pure selector, which is the key idea: view components stay dumb and cheap, while the potentially expensive transformation is computed once and reused.
In groupMessagesByDay selector, the raw ChatMessage[] is folded into an ordered array of MessageGroup objects. Each message is bucketed by a stable dayKey derived from startOfDay, and formatDayLabel renders human labels like Today and Yesterday using date-fns helpers rather than raw dates. Because the input is already sorted, the reducer can append to the current group when the key matches and otherwise open a new group, giving O(n) grouping with no re-sorting. The selector also carries a senderChange flag per message so the view can collapse consecutive messages from the same author into a visual cluster.
The useGroupedMessages hook wraps the selector in useMemo, keyed on the messages reference. This matters because grouping runs on every render otherwise; memoizing means it only recomputes when the message list actually changes identity. This is why upstream state should replace the array immutably rather than mutate it in place, or the memo will go stale.
In MessageList component, the hook output drives rendering. Each MessageGroup becomes a section with a sticky DayDivider, and messages map to MessageBubble, which uses senderChange and isOwn to decide whether to show the avatar and author name. Keeping formatting concerns (formatTime) at the leaf keeps the selector focused purely on structure.
The trade-off is that the selector recomputes the entire structure when any message changes; for very large histories a windowed or incremental approach would be better, but for typical chat pane sizes this pattern is simple, testable, and fast. It is the go-to approach when a component needs derived, grouped data without coupling that logic to rendering.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler
from sklearn.linear_model import LogisticRegression
standard_pipeline = Pipeline([
('scaler', StandardScaler()),
Scaling and normalization choices for different model families
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
module EmailNormalization
extend ActiveSupport::Concern
included do
attr_accessor :soft_warnings
Soft Validation: Normalize + Validate Email
import React from "react";
type FallbackProps = {
error: Error;
reset: () => void;
};
React Error Boundary + error reporting hook
Share this code
Here's the card — post it anywhere.