Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
70 changes: 70 additions & 0 deletions e2e/home.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { expect, test } from "@playwright/test";

test("homepage hydrates without chat date mismatches", async ({
browser,
page,
}) => {
const { baseURL, extraHTTPHeaders, locale } = test.info().project.use;

if (typeof baseURL !== "string") {
throw new Error("Expected Playwright baseURL to be configured");
}

const pageErrors: string[] = [];
const consoleErrors: string[] = [];

page.on("pageerror", (error) => {
pageErrors.push(error.message);
});

page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});

await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", {
name: "Find a home for your food scraps, wherever you are",
})
).toBeVisible();

const serverContext = await browser.newContext({
baseURL,
extraHTTPHeaders,
javaScriptEnabled: false,
locale,
});
const serverPage = await serverContext.newPage();
await serverPage.goto("/");
const serverDayLabels = await serverPage
Comment on lines +33 to +41
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JS-disabled serverContext hard-codes baseURL and doesn’t copy over the project’s locale / extraHTTPHeaders (see playwright.shared.ts), so the server-rendered snapshot could be in a different locale than the hydrated page. This can make the hydration regression test flaky or miss issues. Consider reusing the configured baseURL and passing the same locale/extraHTTPHeaders into browser.newContext(...).

Copilot uses AI. Check for mistakes.
.getByTestId("chat-day-label")
.allTextContents();
const serverTimestamps = await serverPage
.getByTestId("chat-message-timestamp")
.allTextContents();
await serverContext.close();

await page.waitForLoadState("networkidle");
await page.waitForTimeout(2_000);
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid using a fixed waitForTimeout(2_000) here; it makes the test slower and can still be flaky under load. Prefer waiting on the observable hydration outcome (e.g., expect(page.getByTestId("chat-day-label").first()).toHaveText("Yesterday") / toHaveText("Today"), or an expect.poll on allTextContents()) so the test proceeds as soon as the UI updates.

Suggested change
await page.waitForTimeout(2_000);
await expect
.poll(async () => page.getByTestId("chat-day-label").allTextContents())
.toEqual(["Yesterday", "Today"]);

Copilot uses AI. Check for mistakes.

const hydratedDayLabels = await page
.getByTestId("chat-day-label")
.allTextContents();
const hydratedTimestamps = await page
.getByTestId("chat-message-timestamp")
.allTextContents();

expect(serverDayLabels).toEqual(["Thu, May 1", "Fri, May 2"]);
expect(hydratedDayLabels).toEqual(["Yesterday", "Today"]);
expect(hydratedTimestamps).toEqual(serverTimestamps);
expect(
pageErrors.some((message) => message.includes("Minified React error #418"))
).toBeFalsy();
expect(
consoleErrors.some((message) =>
message.includes("Minified React error #418")
)
).toBeFalsy();
});
32 changes: 23 additions & 9 deletions e2e/listings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,10 @@ test("listing edit saves and restores seeded business fields", async ({
email: HOST_EMAIL,
redirectTo: BUSINESS_LISTING_EDIT_PATH,
});
await expect(page.getByTestId("listing-write-form")).toBeVisible();
const descriptionInput = page.locator("#description");
const visibilityInput = page.locator("#visibility");
const listingWriteForm = page.getByTestId("listing-write-form");
await expect(listingWriteForm).toBeVisible();
const descriptionInput = listingWriteForm.locator("#description").first();
const visibilityInput = listingWriteForm.locator("#visibility");
const originalDescription = await descriptionInput.inputValue();
const originalVisibility = await visibilityInput.inputValue();
const updatedDescription =
Expand All @@ -105,20 +106,33 @@ test("listing edit saves and restores seeded business fields", async ({
await updateNavigation;

await page.goto(BUSINESS_LISTING_EDIT_PATH);
await expect(page.locator("#description")).toHaveValue(updatedDescription);
await expect(page.locator("#visibility")).toHaveValue(updatedVisibility);
await expect(listingWriteForm.locator("#description").first()).toHaveValue(
updatedDescription
);
await expect(listingWriteForm.locator("#visibility")).toHaveValue(
updatedVisibility
);

await page.locator("#description").fill(originalDescription);
await page.locator("#visibility").selectOption(originalVisibility);
await listingWriteForm
.locator("#description")
.first()
.fill(originalDescription);
await listingWriteForm
.locator("#visibility")
.selectOption(originalVisibility);

await Promise.all([
page.waitForURL(/\/listings\/demo-inner-west-cafe\?status=updated$/),
page.getByTestId("listing-write-submit").click(),
]);

await page.goto(BUSINESS_LISTING_EDIT_PATH);
await expect(page.locator("#description")).toHaveValue(originalDescription);
await expect(page.locator("#visibility")).toHaveValue(originalVisibility);
await expect(listingWriteForm.locator("#description").first()).toHaveValue(
originalDescription
);
await expect(listingWriteForm.locator("#visibility")).toHaveValue(
originalVisibility
);
});

test("residential listing edit leaves avatar management on the profile page", async ({
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@
"supabase:diff": "supabase db diff",
"i18n:check": "node scripts/check-i18n-messages.mjs",
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
"check": "npm run i18n:check && npm run format:check",
"check": "npm run i18n:check && npm run format:check && npm run test:unit",
"test:unit": "node --test --experimental-strip-types src/utils/dateUtils.test.ts",
"test:e2e": "playwright test",
"pretest:e2e:prod": "if [ -d .next ]; then echo '.next exists, skipping build'; else npm run build; fi",
"pretest:e2e:prod": "npm run build",
"test:e2e:prod": "playwright test --config=playwright.prod.config.ts",
"test:e2e:debug": "playwright test --headed --workers=1",
"format": "prettier --write .",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export default async function ListingPage({
}) {
const { slug } = await params;
const { user, listing } = await getListingData(slug);
const referenceNow = new Date().toISOString();

if (!listing) {
notFound();
Expand All @@ -70,7 +71,7 @@ export default async function ListingPage({

return (
<StyledMain>
<ListingRead user={user} listing={listing} />
<ListingRead user={user} listing={listing} referenceNow={referenceNow} />
</StyledMain>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export default async function ChatsPage({ params }: ChatsPageProps) {
const { threadId: threadIdSegments } = await params;
const threadId = threadIdSegments?.[0] ?? null;
const redirectPath = threadId ? `/chats/${threadId}` : "/chats";
const referenceNow = new Date().toISOString();

const supabase = await createClient();
const {
Expand Down Expand Up @@ -198,6 +199,7 @@ export default async function ChatsPage({ params }: ChatsPageProps) {
initialThreads={typedThreads}
initialThreadId={threadId}
selectedThread={selectedThread}
referenceNow={referenceNow}
/>
);
}
11 changes: 10 additions & 1 deletion src/components/ChatMessage/ChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,23 @@ const Timestamp = styled.p`
function ChatMessage({
direction,
message,
locale,
timeZone,
}: {
direction: ChatDirection;
message: { content: string; created_at: string };
locale: string;
timeZone: string;
}) {
return (
<ChatMessageContainer $direction={direction}>
<ChatBubble $direction={direction}>{message.content}</ChatBubble>
<Timestamp>{formatTimestamp(message.created_at)}</Timestamp>
<Timestamp data-testid="chat-message-timestamp">
{formatTimestamp(message.created_at, {
locale,
timeZone,
})}
</Timestamp>
</ChatMessageContainer>
);
}
Expand Down
3 changes: 3 additions & 0 deletions src/components/ChatPageClient/ChatPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,13 @@ export default function ChatPageClient({
initialThreads,
initialThreadId,
selectedThread,
referenceNow,
}: {
user: User;
initialThreads: ChatThreadListItem[];
initialThreadId?: string | null;
selectedThread?: ChatThreadView | null;
referenceNow: string;
}) {
const t = useTranslations("Chat");
const { setTabBarProps } = useTabBar();
Expand Down Expand Up @@ -105,6 +107,7 @@ export default function ChatPageClient({
user={user}
listing={selectedThread.listing}
existingThread={selectedThread}
referenceNow={referenceNow}
/>
) : (
<ChatWindowEmptyState>
Expand Down
73 changes: 68 additions & 5 deletions src/components/ChatWindow/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,17 @@ import {
markChatThreadRead,
sendChatMessage,
} from "@/components/ChatWindow/chatWindowController";
import { formatWeekday } from "@/utils/dateUtils";
import { DEMO_CHAT_REFERENCE_TIME } from "@/data/demo/threads";
import {
CHAT_RENDER_TIME_ZONE,
formatWeekday,
getChatDateKey,
} from "@/utils/dateUtils";

import { styled } from "next-yak";
import { useUnreadMessages } from "@/contexts/UnreadMessagesContext";
import { useInlineMutation } from "@/hooks/useInlineMutation";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import type {
ChatListing,
ChatMessageRecord,
Expand All @@ -36,9 +41,29 @@ type ChatWindowProps = {
listing: ChatListing | DemoListing;
existingThread?: ChatThreadRecord | ChatThreadView | null;
isDemo?: boolean;
referenceNow?: string;
};

const DIRECTIONS_FOR_DEMO = ["sent", "received"] as const;
type ChatRenderOptions = {
locale: string;
now?: string;
timeZone: string;
useRelativeDayLabels: boolean;
};

const defaultChatRenderOptions: ChatRenderOptions = {
locale: "en",
now: undefined,
timeZone: CHAT_RENDER_TIME_ZONE,
useRelativeDayLabels: false,
};
Comment on lines +65 to +70
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For non-demo chats, defaultChatRenderOptions.timeZone is set to CHAT_RENDER_TIME_ZONE (currently hard-coded to "UTC"). This changes timestamp rendering from the user's local timezone (previous implicit behavior) to UTC across the app, which is likely a user-visible regression. If the goal is only to avoid SSR/hydration mismatches, consider keeping the initial render deterministic (e.g., UTC) but then switching to the client-resolved timezone after mount, or threading an explicit user/account timezone through instead of forcing UTC globally.

Copilot uses AI. Check for mistakes.

function getClientTimeZone() {
return (
Intl.DateTimeFormat().resolvedOptions().timeZone ?? CHAT_RENDER_TIME_ZONE
);
}

const StyledChatWindow = styled.div`
height: 100%;
Expand Down Expand Up @@ -115,8 +140,10 @@ const ChatWindow = memo(function ChatWindow({
listing,
existingThread = null,
isDemo = false,
referenceNow,
}: ChatWindowProps) {
const t = useTranslations();
const locale = useLocale();
const supabase = useMemo(() => (isDemo ? null : createClient()), [isDemo]);
const { setUnreadCount, markThreadAsRead } = useUnreadMessages();
const realListing = isDemo ? null : (listing as ChatListing);
Expand All @@ -130,6 +157,24 @@ const ChatWindow = memo(function ChatWindow({
const [messages, setMessages] = useState<ChatMessageRecord[]>(
getThreadMessages(existingThread)
);
const [clientTimeZone, setClientTimeZone] = useState<string | null>(null);
const chatRenderOptions = useMemo<ChatRenderOptions>(
() =>
isDemo
? {
locale,
now: DEMO_CHAT_REFERENCE_TIME,
timeZone: CHAT_RENDER_TIME_ZONE,
useRelativeDayLabels: clientTimeZone !== null,
}
: {
...defaultChatRenderOptions,
locale,
now: referenceNow,
timeZone: clientTimeZone ?? CHAT_RENDER_TIME_ZONE,
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the non-demo branch, chatRenderOptions doesn’t set useRelativeDayLabels, so it remains false (from defaultChatRenderOptions) even after the client timezone is known. This changes the previous formatWeekday behavior (no Today/Yesterday in real chats). If you want relative labels without SSR/hydration mismatches, consider enabling them only after mount (e.g. when clientTimeZone !== null).

Suggested change
timeZone: clientTimeZone ?? CHAT_RENDER_TIME_ZONE,
timeZone: clientTimeZone ?? CHAT_RENDER_TIME_ZONE,
useRelativeDayLabels: clientTimeZone !== null,

Copilot uses AI. Check for mistakes.
},
[clientTimeZone, isDemo, locale, referenceNow]
);

function resolveChatErrorMessage(errorMessage: string | null) {
if (!errorMessage) {
Expand All @@ -150,6 +195,10 @@ const ChatWindow = memo(function ChatWindow({
return errorMessage;
}

useEffect(() => {
setClientTimeZone(getClientTimeZone());
}, []);

useEffect(() => {
setThreadId(existingThread?.id ?? null);
setMessages(getThreadMessages(existingThread));
Expand Down Expand Up @@ -335,16 +384,28 @@ const ChatWindow = memo(function ChatWindow({
{messages.map((chatMessage, index) => {
const showDateHeader =
index === 0 ||
new Date(chatMessage.created_at).toDateString() !==
new Date(messages[index - 1].created_at).toDateString();
getChatDateKey(chatMessage.created_at, {
timeZone: chatRenderOptions.timeZone,
}) !==
getChatDateKey(messages[index - 1].created_at, {
timeZone: chatRenderOptions.timeZone,
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

showDateHeader recomputes getChatDateKey(...) (which uses Intl.DateTimeFormat(...).formatToParts) for both the current and previous message on every render. For long threads this becomes a hot path. Consider computing the current/previous date keys once per iteration (store in locals), or precomputing an array of date keys with useMemo when messages/timeZone change, then comparing adjacent entries.

Copilot uses AI. Check for mistakes.
});
const showInitiationHeader = index === 0;

return (
<Day key={isDemo ? index : chatMessage.id}>
{showDateHeader || showInitiationHeader ? (
<DayHeader>
{showDateHeader && (
<h3>{formatWeekday(chatMessage.created_at)}</h3>
<h3 data-testid="chat-day-label">
{formatWeekday(chatMessage.created_at, {
locale: chatRenderOptions.locale,
now: chatRenderOptions.now,
timeZone: chatRenderOptions.timeZone,
useRelativeDayLabels:
chatRenderOptions.useRelativeDayLabels,
})}
</h3>
)}
{showInitiationHeader && (
<p>
Expand Down Expand Up @@ -373,6 +434,8 @@ const ChatWindow = memo(function ChatWindow({
: "received"
}
message={chatMessage}
locale={chatRenderOptions.locale}
timeZone={chatRenderOptions.timeZone}
/>
</Day>
);
Expand Down
3 changes: 3 additions & 0 deletions src/components/ListingChatDrawer/ListingChatDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ type ListingChatDrawerProps = {
isChatDrawerOpen: boolean;
setIsChatDrawerOpen: (open: boolean) => void;
existingThread: ChatThreadRecord | null;
referenceNow?: string;
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

referenceNow is optional here, but ChatWindow uses it to keep “now” stable between SSR and hydration. When it’s undefined (e.g., from callers that haven’t been updated), formatWeekday will fall back to new Date() during render and can still trigger hydration mismatches. Consider requiring referenceNow (or only rendering ChatWindow once a stable reference time is available) to keep the intended determinism.

Suggested change
referenceNow?: string;
referenceNow: string;

Copilot uses AI. Check for mistakes.
};

type SharedDrawerProps = {
Expand Down Expand Up @@ -90,6 +91,7 @@ export default function ListingChatDrawer({
isChatDrawerOpen,
setIsChatDrawerOpen,
existingThread,
referenceNow,
}: ListingChatDrawerProps) {
const { isDesktop, hasTouch } = useDeviceContext();

Expand Down Expand Up @@ -143,6 +145,7 @@ export default function ListingChatDrawer({
user={user}
listing={listing}
existingThread={existingThread}
referenceNow={referenceNow}
/>
</StyledDrawerContent>
</Drawer.Portal>
Expand Down
Loading
Loading