diff --git a/LOG.md b/LOG.md index 3dbe2e7..109a793 100644 --- a/LOG.md +++ b/LOG.md @@ -2,6 +2,15 @@ ## 2026-05-29 +- Investigated Google Search Console crawlability concerns for the live site. +- Checked live `robots.txt`, `sitemap.xml`, homepage, sample post pages, and backend post API with a Googlebot user agent; public post URLs in the sitemap returned 200 with no `X-Robots-Tag` or meta robots block. +- Confirmed the non-www production host `blog.wypark.me` is the relevant host; its home/archive initial HTML currently contains no `/posts/` links because public lists are client-side rendered. +- Identified crawlability risk areas: home/archive rely on client-side post-list fetching, local builds generate a static-only sitemap when the backend API is unreachable, sitemap `lastmod` values can be timezone-skewed when backend timestamps omit an offset, and canonical URLs are not currently emitted. +- Validation: `npm run build` passed. As expected without a local backend, build-time sitemap generation logged `fetch failed` and produced only static sitemap entries in the local `.next` output. +- Converted home and archive pages to server-rendered public post lists through a dedicated `src/api/publicPosts.ts` fetch helper so first HTML now includes discoverable `/posts/` links. +- Added centralized site metadata utilities, canonical metadata for public pages/posts, dynamic sitemap generation, KST-aware API date parsing for `lastmod`, and robots/site URL reuse. +- Validation: `npm run lint` passed and `npm run build` passed. A local production server with `NEXT_PUBLIC_API_URL=https://blogserver.wypark.me` returned 22 `/posts/` links on `/`, 204 on `/archive`, canonical URLs for both pages, and sitemap output with 104 URLs including 102 post URLs. +- Recommended next task: deploy the SEO crawlability fix, then resubmit `https://blog.wypark.me/sitemap.xml` and a representative post URL in Google Search Console URL Inspection. - Redesigned the app shell into a macOS-inspired blog OS with pastel desktop background tokens, translucent menu bar, quick-launch Dock, and reusable window surfaces. - Applied the window treatment to the home dashboard, post reader, archive, category view, auth screens, chess puzzle view, and admin management shells. - Restored the missing local `chess.js` install in `node_modules` so build verification could run; no dependency version changes were intended. diff --git a/src/api/publicPosts.ts b/src/api/publicPosts.ts new file mode 100644 index 0000000..36635b7 --- /dev/null +++ b/src/api/publicPosts.ts @@ -0,0 +1,68 @@ +import { ApiResponse, Post, PostListResponse } from '@/types'; + +export const PUBLIC_POSTS_REVALIDATE_SECONDS = 300; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'; + +type PublicPostListParams = { + page?: number; + size?: number; + keyword?: string; + category?: string; + tag?: string; + sort?: string; +}; + +const buildPostListUrl = (params: PublicPostListParams = {}) => { + const searchParams = new URLSearchParams(); + + Object.entries({ + ...params, + sort: params.sort || 'createdAt,desc', + }).forEach(([key, value]) => { + if (value !== undefined && value !== '') { + searchParams.set(key, String(value)); + } + }); + + return `${API_URL}/api/posts?${searchParams.toString()}`; +}; + +export const fetchPublicPosts = async ( + params: PublicPostListParams = {}, +): Promise => { + try { + const response = await fetch(buildPostListUrl(params), { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + next: { revalidate: PUBLIC_POSTS_REVALIDATE_SECONDS }, + }); + + if (!response.ok) return null; + + const json = (await response.json()) as ApiResponse; + return json.data; + } catch (error) { + console.error('Public posts fetch error:', error); + return null; + } +}; + +export const fetchPublicPost = async (slug: string): Promise => { + try { + const response = await fetch(`${API_URL}/api/posts/${encodeURIComponent(slug)}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + next: { revalidate: 60 }, + }); + + if (!response.ok) return null; + + const json = (await response.json()) as ApiResponse; + return json.data; + } catch (error) { + console.error('Public post fetch error:', error); + return null; + } +}; + diff --git a/src/app/archive/page.tsx b/src/app/archive/page.tsx index e0a8d21..f86563d 100644 --- a/src/app/archive/page.tsx +++ b/src/app/archive/page.tsx @@ -1,61 +1,76 @@ -'use client'; - -import { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { format } from 'date-fns'; +import type { Metadata } from 'next'; import Link from 'next/link'; -import { Archive, Calendar, ChevronRight, FileText, Loader2 } from 'lucide-react'; -import { getPosts } from '@/api/posts'; +import { Archive, Calendar, ChevronRight, FileText } from 'lucide-react'; +import { + fetchPublicPosts, +} from '@/api/publicPosts'; import EmptyState from '@/components/ui/EmptyState'; import StatusBadge from '@/components/ui/StatusBadge'; import Surface from '@/components/ui/Surface'; import WindowSurface from '@/components/ui/WindowSurface'; +import { SITE_NAME } from '@/lib/site'; import { Post, PostListResponse } from '@/types'; -const getTotalElements = (data?: PostListResponse) => { +export const dynamic = 'force-dynamic'; +export const revalidate = 300; + +export const metadata: Metadata = { + title: 'Archive', + alternates: { + canonical: '/archive', + }, + openGraph: { + title: `Archive | ${SITE_NAME}`, + url: '/archive', + siteName: SITE_NAME, + type: 'website', + }, +}; + +const getTotalElements = (data?: PostListResponse | null) => { return data?.page?.totalElements ?? data?.totalElements ?? 0; }; -export default function ArchivePage() { - const { data, isLoading } = useQuery({ - queryKey: ['posts', 'all'], - queryFn: () => getPosts({ page: 0, size: 1000 }), - staleTime: 1000 * 60 * 5, +const formatDate = (value: string) => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + + return new Intl.DateTimeFormat('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(date); +}; + +const groupPostsByMonth = (posts: Post[]) => { + const groups: Record> = {}; + + posts.forEach((post) => { + const date = new Date(post.createdAt); + if (Number.isNaN(date.getTime())) return; + + const year = date.getFullYear().toString(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + + groups[year] ??= {}; + groups[year][month] ??= []; + groups[year][month].push(post); }); - const archiveGroups = useMemo(() => { - if (!data?.content) return {}; + return groups; +}; - const groups: { [year: string]: { [month: string]: Post[] } } = {}; - - data.content.forEach((post) => { - const date = new Date(post.createdAt); - const year = date.getFullYear().toString(); - const month = (date.getMonth() + 1).toString().padStart(2, '0'); - - if (!groups[year]) groups[year] = {}; - if (!groups[year][month]) groups[year][month] = []; - - groups[year][month].push(post); - }); - - return groups; - }, [data]); - - const sortedYears = useMemo(() => { - return Object.keys(archiveGroups).sort((a, b) => Number(b) - Number(a)); - }, [archiveGroups]); +const sortPostsNewestFirst = (posts: Post[]) => { + return [...posts].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); +}; +export default async function ArchivePage() { + const data = await fetchPublicPosts({ page: 0, size: 1000, sort: 'createdAt,desc' }); + const posts = data?.content || []; + const archiveGroups = groupPostsByMonth(posts); + const sortedYears = Object.keys(archiveGroups).sort((a, b) => Number(b) - Number(a)); const totalPosts = getTotalElements(data); - if (isLoading) { - return ( -
- -
- ); - } - return (
{sortedMonths.map((month) => { - const posts = months[month]; - const sortedPosts = [...posts].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + const monthPosts = months[month]; + const sortedPosts = sortPostsNewestFirst(monthPosts); return (

{month}월 - {posts.length} + {monthPosts.length}

@@ -117,9 +132,9 @@ export default function ArchivePage() {
{post.categoryName || '미분류'} - · + - {format(new Date(post.createdAt), 'yyyy.MM.dd')} + {formatDate(post.createdAt)}
@@ -138,7 +153,7 @@ export default function ArchivePage() {
) : ( } className="py-20" /> diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2e54bd9..1de0f1d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,7 @@ import './globals.css'; import Providers from './providers'; import DesktopShell from '@/components/layout/DesktopShell'; import { THEME_INIT_SCRIPT } from '@/components/theme/theme'; +import { DEFAULT_DESCRIPTION, SITE_NAME, SITE_URL } from '@/lib/site'; const pretendard = localFont({ src: './fonts/PretendardVariable.woff2', @@ -14,8 +15,23 @@ const pretendard = localFont({ }); export const metadata: Metadata = { - title: 'WYPark Blog', - description: '개발 블로그', + metadataBase: new URL(SITE_URL), + title: { + default: SITE_NAME, + template: `%s | ${SITE_NAME}`, + }, + description: DEFAULT_DESCRIPTION, + alternates: { + canonical: '/', + }, + openGraph: { + title: SITE_NAME, + description: DEFAULT_DESCRIPTION, + url: '/', + siteName: SITE_NAME, + type: 'website', + locale: 'ko_KR', + }, }; export default function RootLayout({ @@ -54,3 +70,4 @@ export default function RootLayout({ ); } + diff --git a/src/app/page.tsx b/src/app/page.tsx index 1389323..1ea272d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,27 +1,52 @@ -'use client'; - -import { Suspense, type ReactNode } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { useSearchParams } from 'next/navigation'; +import type { Metadata } from 'next'; +import type { ReactNode } from 'react'; import Link from 'next/link'; import { Archive, ChevronRight, Clock, - Loader2, Search, TrendingUp, } from 'lucide-react'; -import { getPosts } from '@/api/posts'; +import { + fetchPublicPosts, +} from '@/api/publicPosts'; import EmptyState from '@/components/ui/EmptyState'; import StatusBadge from '@/components/ui/StatusBadge'; import Surface from '@/components/ui/Surface'; import WindowSurface from '@/components/ui/WindowSurface'; +import { DEFAULT_DESCRIPTION, SITE_NAME } from '@/lib/site'; import { Post, PostListResponse } from '@/types'; +export const dynamic = 'force-dynamic'; +export const revalidate = 300; + +export const metadata: Metadata = { + title: { + absolute: SITE_NAME, + }, + description: DEFAULT_DESCRIPTION, + alternates: { + canonical: '/', + }, + openGraph: { + title: SITE_NAME, + description: DEFAULT_DESCRIPTION, + url: '/', + siteName: SITE_NAME, + type: 'website', + }, +}; + +type HomePageProps = { + searchParams?: Promise>; +}; + const isNoticePost = (post: Post) => { const categoryName = post.categoryName || ''; - return categoryName === '공지' || categoryName === '怨듭?' || categoryName.toLowerCase() === 'notice'; + const normalizedName = categoryName.toLowerCase(); + + return categoryName === '공지' || normalizedName === 'notice' || normalizedName === 'announcement'; }; const formatDate = (value?: string) => { @@ -50,18 +75,23 @@ const getSummary = (content?: string, maxLength = 118) => { .slice(0, maxLength); }; -const getTotalElements = (data?: PostListResponse) => { +const getTotalElements = (data?: PostListResponse | null) => { return data?.page?.totalElements ?? data?.totalElements ?? 0; }; +const getSearchKeyword = async (searchParams?: HomePageProps['searchParams']) => { + const resolvedSearchParams = searchParams ? await searchParams : {}; + const keyword = resolvedSearchParams.keyword; + + return Array.isArray(keyword) ? keyword[0] || '' : keyword || ''; +}; + function SearchResults({ keyword, data, - isLoading, }: { keyword: string; - data?: PostListResponse; - isLoading: boolean; + data?: PostListResponse | null; }) { const searchResults = data?.content || []; const searchTotalElements = getTotalElements(data); @@ -85,11 +115,7 @@ function SearchResults({

- {isLoading ? ( -
- -
- ) : searchResults.length > 0 ? ( + {searchResults.length > 0 ? (
{searchResults.map((post) => ( @@ -209,78 +235,51 @@ function PostListPanel({
- {posts.length > 0 ? ( -
- {posts.map((post, index) => ( - - ))} -
- ) : ( - - )} + {posts.length > 0 ? ( +
+ {posts.map((post, index) => ( + + ))} +
+ ) : ( + + )}
); } -function HomeContent() { - const searchParams = useSearchParams(); - const keyword = searchParams.get('keyword') || ''; - - const { data: noticesData, isLoading: isNoticesLoading } = useQuery({ - queryKey: ['posts', 'notices'], - queryFn: () => getPosts({ category: '공지', size: 3, sort: 'createdAt,desc' }), - retry: 0, - }); - - const { data: latestData, isLoading: isLatestLoading } = useQuery({ - queryKey: ['posts', 'latest'], - queryFn: () => getPosts({ size: 8, sort: 'createdAt,desc' }), - retry: 0, - }); - - const { data: popularData, isLoading: isPopularLoading } = useQuery({ - queryKey: ['posts', 'popular'], - queryFn: () => getPosts({ size: 8, sort: 'viewCount,desc' }), - retry: 0, - }); - - const { data: searchData, isLoading: isSearchLoading } = useQuery({ - queryKey: ['posts', 'search', keyword], - queryFn: () => getPosts({ keyword, size: 20 }), - enabled: !!keyword, - retry: 0, - }); - - const notices = noticesData?.content || []; - const latestList = (latestData?.content || []).filter((post) => !isNoticePost(post)).slice(0, 5); - const popularList = (popularData?.content || []).filter((post) => !isNoticePost(post)).slice(0, 5); - const isHomeLoading = !keyword && (isNoticesLoading || isLatestLoading || isPopularLoading); +export default async function Home({ searchParams }: HomePageProps) { + const keyword = await getSearchKeyword(searchParams); if (keyword) { + const searchData = await fetchPublicPosts({ keyword, size: 20 }); + return (
- +
); } - if (isHomeLoading) { - return ( -
- -
- ); - } + const [noticesData, latestData, popularData] = await Promise.all([ + fetchPublicPosts({ category: '공지', size: 3, sort: 'createdAt,desc' }), + fetchPublicPosts({ size: 8, sort: 'createdAt,desc' }), + fetchPublicPosts({ size: 8, sort: 'viewCount,desc' }), + ]); + + const notices = noticesData?.content || []; + const latestList = (latestData?.content || []).filter((post) => !isNoticePost(post)).slice(0, 5); + const popularList = (popularData?.content || []).filter((post) => !isNoticePost(post)).slice(0, 5); return (
-

WYPark Blog

+

{SITE_NAME}

); } - -export default function Home() { - return ( - - - - )} - > - - - ); -} diff --git a/src/app/posts/[slug]/page.tsx b/src/app/posts/[slug]/page.tsx index 993f586..d22fcb4 100644 --- a/src/app/posts/[slug]/page.tsx +++ b/src/app/posts/[slug]/page.tsx @@ -1,87 +1,90 @@ import { Metadata } from 'next'; -import PostDetailClient from '@/components/post/PostDetailClient'; import { notFound } from 'next/navigation'; -import { Post } from '@/types'; +import { fetchPublicPost } from '@/api/publicPosts'; +import PostDetailClient from '@/components/post/PostDetailClient'; +import { DEFAULT_DESCRIPTION, getCanonicalUrl, parseApiDate, SITE_NAME, SITE_URL } from '@/lib/site'; type Props = { params: Promise<{ slug: string }>; }; -// 🛠️ 서버 사이드 데이터 패칭 함수 (Metadata와 Page 양쪽에서 재사용) -async function getPostFromServer(slug: string): Promise { - const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'; - - try { - const res = await fetch(`${BASE_URL}/api/posts/${slug}`, { - // 캐시 설정: 60초마다 갱신 (블로그 특성상 적절) - // 즉시 반영이 필요하다면 'no-store'로 설정하거나 revalidate: 0 사용 - next: { revalidate: 60 }, - }); - - if (!res.ok) return null; - - const json = await res.json(); - return json.data; - } catch (error) { - console.error('Server fetch error:', error); - return null; - } -} +const getPostDescription = (content?: string) => { + const plainText = content + ?.replace(/```[\s\S]*?```/g, ' ') + .replace(/!\[(.*?)\]\(.*?\)/g, '$1') + .replace(/\[(.*?)\]\(.*?\)/g, '$1') + .replace(/[#*`_~>]/g, '') + .replace(/\s+/g, ' ') + .trim(); + + if (!plainText) return DEFAULT_DESCRIPTION; + + return plainText.length > 150 ? `${plainText.slice(0, 150)}...` : plainText; +}; + +const getFirstMarkdownImage = (content?: string) => { + const imageMatch = content?.match(/!\[.*?\]\((.*?)\)/); + const imageUrl = imageMatch?.[1]?.split(/\s+/)[0] || '/og-image.png'; + + try { + return new URL(imageUrl, SITE_URL).toString(); + } catch { + return new URL('/og-image.png', SITE_URL).toString(); + } +}; -// 🌟 메타데이터 생성 (SEO) export async function generateMetadata({ params }: Props): Promise { const { slug } = await params; - const post = await getPostFromServer(slug); + const post = await fetchPublicPost(slug); + const canonicalUrl = getCanonicalUrl(`/posts/${encodeURIComponent(post?.slug || slug)}`); if (!post) { return { title: '게시글을 찾을 수 없습니다', + alternates: { + canonical: canonicalUrl, + }, }; } - // 본문 요약 및 이미지 추출 로직 - const description = post.content - ?.replace(/[#*`_~]/g, '') - .replace(/\n/g, ' ') - .substring(0, 150) + '...'; - - const imageMatch = post.content?.match(/!\[.*?\]\((.*?)\)/); - const imageUrl = imageMatch ? imageMatch[1] : '/og-image.png'; + const description = getPostDescription(post.content); + const imageUrl = getFirstMarkdownImage(post.content); + const publishedTime = parseApiDate(post.createdAt).toISOString(); + const modifiedTime = parseApiDate(post.updatedAt || post.createdAt).toISOString(); return { title: post.title, - description: description, + description, + alternates: { + canonical: canonicalUrl, + }, openGraph: { title: post.title, - description: description, - url: `https://blog.wypark.me/posts/${slug}`, - siteName: 'WYPark Blog', + description, + url: canonicalUrl, + siteName: SITE_NAME, images: [{ url: imageUrl, width: 1200, height: 630 }], type: 'article', - publishedTime: post.createdAt, // created_at 대신 createdAt 사용 주의 (타입 정의 따름) + publishedTime, + modifiedTime, authors: ['WYPark'], }, twitter: { card: 'summary_large_image', title: post.title, - description: description, + description, images: [imageUrl], }, }; } -// 🌟 실제 페이지 렌더링 (SSR 적용) export default async function PostDetailPage({ params }: Props) { const { slug } = await params; - - // 1. 서버에서 데이터를 미리 가져옵니다. - const post = await getPostFromServer(slug); + const post = await fetchPublicPost(slug); - // 2. 데이터가 없으면 404 페이지로 보냅니다. (봇에게도 404 신호를 줌) if (!post) { notFound(); } - // 3. 가져온 데이터를 클라이언트 컴포넌트에 'initialPost'로 넘겨줍니다. return ; -} \ No newline at end of file +} diff --git a/src/app/robots.ts b/src/app/robots.ts index 53dc2e0..52e8033 100644 --- a/src/app/robots.ts +++ b/src/app/robots.ts @@ -1,16 +1,14 @@ import { MetadataRoute } from 'next'; +import { SITE_URL } from '@/lib/site'; export default function robots(): MetadataRoute.Robots { - const baseUrl = 'https://blog.wypark.me'; - return { rules: { userAgent: '*', allow: '/', - // 검색 로봇이 굳이 긁어갈 필요 없는 페이지들은 차단 (글쓰기, 로그인 등) disallow: ['/write', '/login', '/signup', '/admin'], }, - // 여기서 동적으로 생성된 sitemap.xml을 가리킵니다. - sitemap: `${baseUrl}/sitemap.xml`, + sitemap: `${SITE_URL}/sitemap.xml`, }; -} \ No newline at end of file +} + diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index ea95d2c..b27fc0c 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,60 +1,40 @@ import { MetadataRoute } from 'next'; +import { + fetchPublicPosts, +} from '@/api/publicPosts'; +import { parseApiDate, SITE_URL } from '@/lib/site'; -interface SitemapPost { - slug: string; - updatedAt?: string; - createdAt?: string; -} +export const dynamic = 'force-dynamic'; +export const revalidate = 300; export default async function sitemap(): Promise { - const baseUrl = 'https://blog.wypark.me'; - // API 주소 환경변수 사용 (없으면 로컬) - const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'; - - // 1. 고정된 정적 페이지들 + const generatedAt = new Date(); const routes: MetadataRoute.Sitemap = [ { - url: baseUrl, - lastModified: new Date(), + url: SITE_URL, + lastModified: generatedAt, changeFrequency: 'daily', priority: 1, }, { - url: `${baseUrl}/archive`, - lastModified: new Date(), + url: `${SITE_URL}/archive`, + lastModified: generatedAt, changeFrequency: 'daily', priority: 0.8, }, ]; - try { - // 2. API에서 게시글 목록 가져오기 - const response = await fetch(`${apiUrl}/api/posts?size=1000&sort=createdAt,desc`, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - next: { revalidate: 3600 } - }); + const postsData = await fetchPublicPosts({ size: 1000, sort: 'createdAt,desc' }); + const posts = postsData?.content || []; - if (!response.ok) { - throw new Error('Failed to fetch posts'); - } - - const json = await response.json() as { data?: { content?: SitemapPost[] } }; - const posts = json.data?.content || []; - - // 3. 게시글 데이터를 사이트맵 형식으로 변환 - const postRoutes = posts.map((post) => ({ - // 🛠️ 핵심 수정: slug를 encodeURIComponent로 감싸서 특수문자(&, 한글 등)를 안전하게 처리합니다. - url: `${baseUrl}/posts/${encodeURIComponent(post.slug)}`, - lastModified: new Date(post.updatedAt || post.createdAt || Date.now()), - changeFrequency: 'weekly' as const, + const postRoutes: MetadataRoute.Sitemap = posts + .filter((post) => post.slug) + .map((post) => ({ + url: `${SITE_URL}/posts/${encodeURIComponent(post.slug)}`, + lastModified: parseApiDate(post.updatedAt || post.createdAt, generatedAt), + changeFrequency: 'weekly', priority: 0.8, })); - return [...routes, ...postRoutes]; - - } catch (error) { - console.error('Sitemap generation error:', error); - return routes; - } + return [...routes, ...postRoutes]; } diff --git a/src/lib/site.ts b/src/lib/site.ts new file mode 100644 index 0000000..d66badc --- /dev/null +++ b/src/lib/site.ts @@ -0,0 +1,33 @@ +export const SITE_URL = 'https://blog.wypark.me'; +export const SITE_NAME = 'WYPark Blog'; +export const DEFAULT_DESCRIPTION = '개발 블로그'; + +const API_DATE_TIMEZONE = '+09:00'; +const TIMEZONE_SUFFIX_PATTERN = /(z|[+-]\d{2}:?\d{2})$/i; + +const trimSubMilliseconds = (value: string) => { + return value.replace(/(\.\d{3})\d+/, '$1'); +}; + +const withApiTimezone = (value: string) => { + if (TIMEZONE_SUFFIX_PATTERN.test(value)) return value; + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return `${value}T00:00:00${API_DATE_TIMEZONE}`; + + return `${value}${API_DATE_TIMEZONE}`; +}; + +export const getCanonicalUrl = (path = '/') => { + return new URL(path, SITE_URL).toString(); +}; + +export const parseApiDate = (value?: string | null, fallback = new Date()) => { + if (!value) return fallback; + + const date = new Date(withApiTimezone(trimSubMilliseconds(value.trim()))); + + if (Number.isNaN(date.getTime())) return fallback; + + const now = new Date(); + return date > now ? now : date; +}; +