Fix timezone handling in date displays across the app#2682
Fix timezone handling in date displays across the app#2682MohamedBassem wants to merge 2 commits intokarakeep-app:mainfrom
Conversation
Replace raw toLocaleString()/toDateString() calls with a new FormattedDate client component that uses date-fns format() inside useEffect, ensuring dates are always rendered in the user's local timezone and avoiding SSR hydration mismatches. Affected pages: backup settings, broken links, feed settings, and search query explainer tooltip. Also updates the useRelativeTime hook to use date-fns format() instead of toLocaleString(). https://claude.ai/code/session_012VvxvYngyFZkuqJEk39iqr
ApiKeySettings is a server component, so formatDistanceToNow was computed on the server clock. Replace with a RelativeTime client component that computes relative time on the client and shows the localized absolute timestamp on hover. https://claude.ai/code/session_012VvxvYngyFZkuqJEk39iqr
WalkthroughThis pull request introduces shared date formatting components ( Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes 🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 898e1ccc7f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (date) { | ||
| setFormatted(format(date, formatStr)); | ||
| } |
There was a problem hiding this comment.
Reset formatted state when date prop is cleared
FormattedDate only updates state when date is truthy, so when a row re-renders from a real timestamp to null/undefined (which is valid for callers like crawledAt and lastFetchedAt), the previous formatted value stays visible instead of clearing. That leaves stale timestamps in the UI after data refreshes or optimistic updates. The effect should explicitly clear formatted when date is absent.
Useful? React with 👍 / 👎.
Greptile SummaryThis PR introduces two new client-side components (
Confidence Score: 4/5Safe to merge after fixing the stale-state bug in FormattedDate. One P1 bug in FormattedDate: when date changes to null/undefined the formatted string is not cleared, causing stale dates to show for nullable fields like crawledAt and lastFetchedAt. The fix is a one-liner else branch in the effect. apps/web/components/ui/formatted-date.tsx — null-reset handling in useEffect
|
| Filename | Overview |
|---|---|
| apps/web/components/ui/formatted-date.tsx | New client-side date formatting component; has a bug where formatted state is not cleared when date prop changes to null/undefined. |
| apps/web/components/ui/relative-time.tsx | New thin client wrapper around useRelativeTime hook; straightforward and correct. |
| apps/web/lib/hooks/relative-time.ts | Swaps toLocaleString() for date-fns format(); loses browser-locale awareness in the tooltip date string. |
| apps/web/app/settings/broken-links/page.tsx | Replaces toLocaleString() with FormattedDate; correctness depends on the null-reset fix in FormattedDate. |
| apps/web/components/settings/FeedSettings.tsx | Replaces toLocaleString() with FormattedDate for lastFetchedAt; depends on the null-reset fix since lastFetchedAt can be null. |
| apps/web/components/settings/BackupSettings.tsx | Replaces toLocaleString() with FormattedDate; createdAt is always a Date here so the null-reset bug doesn't apply. |
| apps/web/components/settings/ApiKeySettings.tsx | Replaces inline formatDistanceToNow calls with RelativeTime component; clean and correct. |
| apps/web/components/dashboard/search/QueryExplainerTooltip.tsx | Replaces toDateString() with FormattedDate using PPP format; dates here are always non-null so no null-reset concern. |
Prompt To Fix All With AI
This is a comment left during a code review.
Path: apps/web/components/ui/formatted-date.tsx
Line: 22-25
Comment:
**Stale date when prop transitions to null/undefined**
The `if (date)` guard means `formatted` is never reset when `date` changes from a valid `Date` to `null` or `undefined`. Any component that re-renders with a null date (e.g. `b.crawledAt` on the broken-links page, or `feed.lastFetchedAt` in FeedSettings) will continue showing the previously formatted string instead of rendering empty.
```suggestion
useEffect(() => {
if (date) {
setFormatted(format(date, formatStr));
} else {
setFormatted("");
}
}, [date, formatStr]);
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: apps/web/lib/hooks/relative-time.ts
Line: 14
Comment:
**`format()` defaults to English locale**
`date-fns` `format` uses English locale by default unless a `locale` option is passed. The previous `date.toLocaleString()` automatically respected the browser's locale, so non-English users will now see English month/AM-PM names in the tooltip. Consider passing a locale, or reverting to `toLocaleString()` just for this tooltip string since timezone correctness isn't required here (it's a tooltip, not the primary display).
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "Fix API keys page rendering server-side ..." | Re-trigger Greptile
| useEffect(() => { | ||
| if (date) { | ||
| setFormatted(format(date, formatStr)); | ||
| } |
There was a problem hiding this comment.
Stale date when prop transitions to null/undefined
The if (date) guard means formatted is never reset when date changes from a valid Date to null or undefined. Any component that re-renders with a null date (e.g. b.crawledAt on the broken-links page, or feed.lastFetchedAt in FeedSettings) will continue showing the previously formatted string instead of rendering empty.
| useEffect(() => { | |
| if (date) { | |
| setFormatted(format(date, formatStr)); | |
| } | |
| useEffect(() => { | |
| if (date) { | |
| setFormatted(format(date, formatStr)); | |
| } else { | |
| setFormatted(""); | |
| } | |
| }, [date, formatStr]); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/components/ui/formatted-date.tsx
Line: 22-25
Comment:
**Stale date when prop transitions to null/undefined**
The `if (date)` guard means `formatted` is never reset when `date` changes from a valid `Date` to `null` or `undefined`. Any component that re-renders with a null date (e.g. `b.crawledAt` on the broken-links page, or `feed.lastFetchedAt` in FeedSettings) will continue showing the previously formatted string instead of rendering empty.
```suggestion
useEffect(() => {
if (date) {
setFormatted(format(date, formatStr));
} else {
setFormatted("");
}
}, [date, formatStr]);
```
How can I resolve this? If you propose a fix, please make it concise.| setState({ | ||
| fromNow: formatDistanceToNow(date, { addSuffix: true }), | ||
| localCreatedAt: date.toLocaleString(), | ||
| localCreatedAt: format(date, "PP, p"), |
There was a problem hiding this comment.
format() defaults to English locale
date-fns format uses English locale by default unless a locale option is passed. The previous date.toLocaleString() automatically respected the browser's locale, so non-English users will now see English month/AM-PM names in the tooltip. Consider passing a locale, or reverting to toLocaleString() just for this tooltip string since timezone correctness isn't required here (it's a tooltip, not the primary display).
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/lib/hooks/relative-time.ts
Line: 14
Comment:
**`format()` defaults to English locale**
`date-fns` `format` uses English locale by default unless a `locale` option is passed. The previous `date.toLocaleString()` automatically respected the browser's locale, so non-English users will now see English month/AM-PM names in the tooltip. Consider passing a locale, or reverting to `toLocaleString()` just for this tooltip string since timezone correctness isn't required here (it's a tooltip, not the primary display).
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
apps/web/components/ui/relative-time.tsx (1)
7-7: Prefer<time>with a non-empty pre-hydration fallbackAt Line 7, rendering empty text until the effect runs leaves a blank timestamp cell initially. A semantic
<time>plus a stable fallback improves UX/accessibility.Suggested refactor
export default function RelativeTime({ date }: { date: Date }) { const { fromNow, localCreatedAt } = useRelativeTime(date); - return <span title={localCreatedAt}>{fromNow}</span>; + return ( + <time dateTime={date.toISOString()} title={localCreatedAt || undefined}> + {fromNow || "—"} + </time> + ); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/components/ui/relative-time.tsx` at line 7, The RelativeTime component currently renders an empty span until the client effect computes the relative text; change the element to a semantic <time> with a stable, non-empty pre-hydration fallback and proper attributes: render <time dateTime={createdAt} title={localCreatedAt}> using a fallback string (e.g., formatted absolute time like localCreatedAt or a minimal placeholder like "just now") derived from the incoming props, then let your existing effect/update logic (the useEffect/state that sets fromNow) replace the content after hydrate; ensure dateTime uses the ISO timestamp and title keeps localCreatedAt for accessibility.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/web/components/ui/formatted-date.tsx`:
- Around line 22-26: The useEffect in the formatted-date component only updates
formatted value when date is truthy, leaving stale text when date becomes
nullish; modify the effect in the component (the useEffect that calls
setFormatted and references date and formatStr) to setFormatted to an empty
string (or null) when date is falsy, otherwise setFormatted(format(date,
formatStr)), so the UI clears previous output whenever date becomes null or
undefined.
In `@apps/web/lib/hooks/relative-time.ts`:
- Line 14: Update the relative-time hook to accept an optional locale parameter
and pass it into date-fns format so dates render in the user's language: change
the hook signature (e.g., in apps/web/lib/hooks/relative-time.ts) to accept
locale?: Locale and call format(date, "PP, p", { locale }) for localCreatedAt
(and any other format calls). Thread the locale from calling components
(BookmarkPreview, AllHighlights) by reading it from
useTranslation().i18n.language and mapping to the appropriate date-fns Locale
object before passing it into the hook.
---
Nitpick comments:
In `@apps/web/components/ui/relative-time.tsx`:
- Line 7: The RelativeTime component currently renders an empty span until the
client effect computes the relative text; change the element to a semantic
<time> with a stable, non-empty pre-hydration fallback and proper attributes:
render <time dateTime={createdAt} title={localCreatedAt}> using a fallback
string (e.g., formatted absolute time like localCreatedAt or a minimal
placeholder like "just now") derived from the incoming props, then let your
existing effect/update logic (the useEffect/state that sets fromNow) replace the
content after hydrate; ensure dateTime uses the ISO timestamp and title keeps
localCreatedAt for accessibility.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 8f2e318c-47f1-4a03-9668-2cac073effe0
📒 Files selected for processing (8)
apps/web/app/settings/broken-links/page.tsxapps/web/components/dashboard/search/QueryExplainerTooltip.tsxapps/web/components/settings/ApiKeySettings.tsxapps/web/components/settings/BackupSettings.tsxapps/web/components/settings/FeedSettings.tsxapps/web/components/ui/formatted-date.tsxapps/web/components/ui/relative-time.tsxapps/web/lib/hooks/relative-time.ts
| useEffect(() => { | ||
| if (date) { | ||
| setFormatted(format(date, formatStr)); | ||
| } | ||
| }, [date, formatStr]); |
There was a problem hiding this comment.
Clear stale output when date becomes nullish.
Right now, formatting runs only when date is truthy, so a previous value can remain visible after date becomes null/undefined. This can show incorrect timestamps in updated rows.
Proposed fix
useEffect(() => {
- if (date) {
- setFormatted(format(date, formatStr));
- }
+ setFormatted(date ? format(date, formatStr) : "");
}, [date, formatStr]);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| useEffect(() => { | |
| if (date) { | |
| setFormatted(format(date, formatStr)); | |
| } | |
| }, [date, formatStr]); | |
| useEffect(() => { | |
| setFormatted(date ? format(date, formatStr) : ""); | |
| }, [date, formatStr]); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/web/components/ui/formatted-date.tsx` around lines 22 - 26, The
useEffect in the formatted-date component only updates formatted value when date
is truthy, leaving stale text when date becomes nullish; modify the effect in
the component (the useEffect that calls setFormatted and references date and
formatStr) to setFormatted to an empty string (or null) when date is falsy,
otherwise setFormatted(format(date, formatStr)), so the UI clears previous
output whenever date becomes null or undefined.
| setState({ | ||
| fromNow: formatDistanceToNow(date, { addSuffix: true }), | ||
| localCreatedAt: date.toLocaleString(), | ||
| localCreatedAt: format(date, "PP, p"), |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
In date-fns v3.6.0, what locale does format(date, "PP, p") use when no locale is passed, and how should locale be provided for per-user i18n?
💡 Result:
- Default locale: In date-fns v3.6.0,
format(date, "PP, p")uses the library’s default localeenUS(English – United States) when you don’t pass alocaleoption. (tessl.io) - Per-user i18n: date-fns locales are not auto-detected; you should pick the user’s locale in your app (from profile/settings,
Accept-Language,navigator.language, etc.), import the matchingdate-fns/localeobject, and pass it via the options argument:format(date, "PP, p", { locale: userLocale }). date-fns explicitly recommends using a small wrapper so you don’t have to pass it everywhere. (app.unpkg.com)
Citations:
- 1: https://tessl.io/registry/tessl/npm-date-fns/files/docs/i18n.md
- 2: https://app.unpkg.com/date-fns%403.6.0/files/docs/i18n.md
🏁 Script executed:
# First, let's examine the file in question
cat -n apps/web/lib/hooks/relative-time.tsRepository: karakeep-app/karakeep
Length of output: 714
🏁 Script executed:
# Check if there's a git history to see what changed
cd apps/web/lib/hooks && git log --oneline -5 relative-time.ts 2>/dev/null || echo "Git history not available"Repository: karakeep-app/karakeep
Length of output: 133
🏁 Script executed:
# Find all usages of useRelativeTime hook
rg "useRelativeTime" apps/web --type ts --type tsx -A 2 -B 2Repository: karakeep-app/karakeep
Length of output: 92
🏁 Script executed:
# Check for i18n setup in the app
fd -t f "i18n" apps/web --type ts --type tsx --type js --type jsx | head -20Repository: karakeep-app/karakeep
Length of output: 234
🏁 Script executed:
# Look for locale context or provider
rg "locale|Locale|i18n|IntlProvider" apps/web --type ts --type tsx -l | head -20Repository: karakeep-app/karakeep
Length of output: 92
🏁 Script executed:
# Find usages of useRelativeTime hook
rg "useRelativeTime" apps/web -A 2 -B 2Repository: karakeep-app/karakeep
Length of output: 3536
🏁 Script executed:
# Look for i18n setup or locale context
rg "locale|Locale|i18n" apps/web -l | head -30Repository: karakeep-app/karakeep
Length of output: 1741
🏁 Script executed:
# Check app root for i18n config
cat apps/web/package.json | grep -A 5 -B 5 "i18n\|intl\|locale" || echo "No i18n found in package.json"Repository: karakeep-app/karakeep
Length of output: 987
🏁 Script executed:
# Check for locale context or provider in app structure
fd "context\|provider" apps/web --extension ts --extension tsx | head -20Repository: karakeep-app/karakeep
Length of output: 47
🏁 Script executed:
# Look at the recent commit to understand what changed
git show 898e1ccc --stat 2>/dev/null | head -30 || echo "Cannot show commit details"Repository: karakeep-app/karakeep
Length of output: 1630
🏁 Script executed:
# Check i18n client setup
cat -n apps/web/lib/i18n/client.ts 2>/dev/null || cat -n apps/web/lib/i18n/client.js 2>/dev/null || echo "Not found, searching for i18n files..."Repository: karakeep-app/karakeep
Length of output: 1202
🏁 Script executed:
# Search for i18n directory structure
find apps/web/lib/i18n -type f 2>/dev/null | head -20Repository: karakeep-app/karakeep
Length of output: 972
🏁 Script executed:
# Look for date-fns locale usage elsewhere in the codebase
rg "locale.*date-fns\|date-fns.*locale\|import.*locale.*from.*date-fns" apps/webRepository: karakeep-app/karakeep
Length of output: 47
🏁 Script executed:
# Check BookmarkPreview to see how useTranslation is used
cat -n apps/web/components/dashboard/preview/BookmarkPreview.tsx | head -50Repository: karakeep-app/karakeep
Length of output: 2090
🏁 Script executed:
# Look for next-i18next config
cat -n apps/web/next-i18next.config.* 2>/dev/null || find apps/web -name "*i18next*config*" -type fRepository: karakeep-app/karakeep
Length of output: 47
Pass i18n locale to date-fns formatting to respect user language settings
At Line 14, format(date, "PP, p") defaults to English (enUS) when no locale is provided. With the app supporting 20+ languages through next-i18next, dates display in English for all users regardless of their selected language.
Components using this hook (BookmarkPreview, AllHighlights) already have access to i18n context via useTranslation() but cannot pass the locale to the hook. Accept an optional locale parameter in the hook and thread it from calling components.
Proposed fix
-import { format, formatDistanceToNow } from "date-fns";
+import { format, formatDistanceToNow, type Locale } from "date-fns";
-export default function useRelativeTime(date: Date) {
+export default function useRelativeTime(date: Date, locale?: Locale) {
const [state, setState] = useState({
fromNow: "",
localCreatedAt: "",
});
// This is to avoid hydration errors when server and clients are in different timezones
useEffect(() => {
setState({
- fromNow: formatDistanceToNow(date, { addSuffix: true }),
- localCreatedAt: format(date, "PP, p"),
+ fromNow: formatDistanceToNow(date, { addSuffix: true, locale }),
+ localCreatedAt: format(date, "PP, p", { locale }),
});
- }, [date]);
+ }, [date, locale]);
return state;
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/web/lib/hooks/relative-time.ts` at line 14, Update the relative-time
hook to accept an optional locale parameter and pass it into date-fns format so
dates render in the user's language: change the hook signature (e.g., in
apps/web/lib/hooks/relative-time.ts) to accept locale?: Locale and call
format(date, "PP, p", { locale }) for localCreatedAt (and any other format
calls). Thread the locale from calling components (BookmarkPreview,
AllHighlights) by reading it from useTranslation().i18n.language and mapping to
the appropriate date-fns Locale object before passing it into the hook.
Summary
This PR fixes timezone-related issues in date displays throughout the application by introducing client-side date formatting components that respect the user's local timezone, replacing server-side formatting that could cause hydration mismatches and incorrect timezone display.
Key Changes
FormattedDatecomponent: A client-side component that formats dates usingdate-fnswith the user's local timezone. Returns an empty string during SSR to prevent hydration mismatches.RelativeTimecomponent: A client-side wrapper around the existinguseRelativeTimehook for displaying relative time with a tooltip showing the full formatted date.useRelativeTimehook: Changed fromtoLocaleString()toformat(date, "PP, p")for consistent date formatting usingdate-fns.ApiKeySettings.tsx: ReplacedformatDistanceToNowcalls withRelativeTimecomponentbroken-links/page.tsx: ReplacedtoLocaleString()calls withFormattedDatecomponentQueryExplainerTooltip.tsx: ReplacedtoDateString()calls withFormattedDatecomponent using "PPP" formatBackupSettings.tsx: ReplacedtoLocaleString()withFormattedDatecomponentFeedSettings.tsx: ReplacedtoLocaleString()withFormattedDatecomponentImplementation Details
"use client"to ensure client-side renderingFormattedDateusesuseStateanduseEffectto defer formatting until the client, preventing SSR/hydration issuesdate-fnsinstead of native JavaScript date methodshttps://claude.ai/code/session_012VvxvYngyFZkuqJEk39iqr