feat: Add privacy and terms pages, new blog posts, and refactor pages - #36
feat: Add privacy and terms pages, new blog posts, and refactor pages#36remcostoeten wants to merge 38 commits into
Conversation
- Remove company logo/initial avatars from company names - Remove position icons (code, education, business) from job titles - Adjust padding and hover areas since avatars no longer take space - Clean up work experience display for minimal text-only presentation
…stem with OG/Twitter image generation, and refactor site structure.
…e viewer, and update dependencies.
…ience details with expanded descriptions and skills.
…plement redirects
… Spotify integration.
… refactor UI components.
…d Spotify activity.
env + paths + deps + drizzle config + db connection + helpers + schema
- Created ActivityHoverCard component with React Portal and fixed positioning - Added GitHub project hover card (repo details, topics, languages, stars/forks) - Added GitHub activity hover card (full commit/PR/issue details) - Added Spotify hover card (album art, track info, now playing indicator) - Created /api/github/repo endpoint for fetching repository details - Integrated hover cards into ActivityFeed for project, activity, and song elements - Fixed overflow issues with viewport-aware positioning - Added lazy-loading for repo details on hover
…hook, enhance activity feed loading states, and refine UI rounding.
- Add useVimCommand hook for detecting vim-style commands - Add VimAuthProvider with username restriction (remcostoeten only) - Add auth indicator glow for authenticated users - Remove visible sign-in header, use hidden vim commands instead - Escape key resets command buffer - Console logging for debugging command sequences
- Fixed isVisible animation trigger to depend on both loading states - Calendar now properly shows contribution data after all data loads
Addresses alignment issues in activity feed and removes gap between activity and experience sections.
…ents (#33) - Add draft blog post system with admin-only visibility - Implement Vim-style authentication (;signin, ;signout) - Add OAuth modal with clean dark theme - Fix contribution calendar to end at current date (no empty space) - Add fallback UI for calendar days without detailed events - Improve activity feed with unified GitHub/Spotify display - Add work experience component - Update API routes for GitHub contributions/events - Add is-admin utility for server-side auth checks - Schema updates for GitHub activity tracking
feat: activity sync system for GitHub & Spotify - Add database schema for github_activities, spotify_listens, sync_metadata - Create activity-sync service with diff-based deduplication - Auto-link Spotify tracks to GitHub activities within 5-minute windows - Add /api/sync endpoint (admin & Vercel cron protected) - Add vercel.json with hourly cron schedule - Include raw SQL migration for manual database setup
…itial database migrations.
…igilo manager while updating dependencies and content.
…igilo, and switch to pnpm
…ponent styles for consistency.
…d new brand icons, and update dependencies.
…g post read time, while removing Vigilo.
… and geo-location data
…transitions and providers
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughThis PR introduces a comprehensive analytics and admin infrastructure, including contact form tracking with rate limiting, blog view tracking with fingerprint-based deduplication, an admin dashboard with metrics, PostHog telemetry integration, and database schema extensions for storing contact interactions and blog views. Additionally, it refactors the UI component library, removes unused features, and adds dynamic rendering flags to blog routes. Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant Client as Browser
participant Server
participant PostHog as PostHog<br/>Service
participant DB as Database
participant GeoAPI as IP Info<br/>API
User->>Client: Opens blog post
Client->>Server: GET /blog/[slug]
activate Server
Server->>DB: Query post + views
Server->>Server: checkAdminStatus()
Server-->>Client: HTML (with post data)
deactivate Server
Client->>Server: trackBlogView(slug)
activate Server
Server->>Server: Compute fingerprint(IP + UA)
Server->>DB: Check existing view
alt View exists
Server->>DB: Increment totalViews
Server-->>Client: {unique: false}
else New view
Server->>GeoAPI: fetchGeoInfo(IP)
Server->>DB: Insert blogViews record
Server->>DB: Increment totalViews + uniqueViews
Server-->>Client: {unique: true}
end
deactivate Server
Client->>PostHog: Capture event<br/>(blog_view_tracked)
PostHog-->>Client: ✓
User->>Client: Submits contact form
Client->>Server: submitContactForm(FormData)
activate Server
Server->>Server: Validate + honeypot check
Server->>Server: checkRateLimit(IP)
alt Rate limited
Server-->>Client: Error (too many submissions)
else Allowed
Server->>GeoAPI: fetchGeoInfo(IP)
Server->>DB: Insert contactSubmissions
Server-->>Client: Success
end
deactivate Server
Client->>PostHog: Capture event<br/>(form_submitted)
PostHog-->>Client: ✓
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Areas requiring extra attention:
Possibly related PRs
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
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.
Actionable comments posted: 8
Note
Due to the large number of review comments, Critical severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
src/hooks/use-spotify-playback.ts (3)
25-34: Documentation doesn't match implementation.The JSDoc comment states "Polls Spotify API every 3 seconds" (line 29), but the actual
POLL_INTERVALis 10 seconds on normal connections (line 21). Update the comment to reflect the actual polling intervals.🔎 Proposed fix
/** * Real-time Spotify playback monitor hook * * Features: - * - Polls Spotify API every 3 seconds + * - Polls Spotify API every 10-30 seconds (depending on connection) * - Updates progress locally every 200ms for smooth UI * - Drift compensation: resyncs with API on each poll * - T0 reset on track change * - Handles pause/resume/seek correctly */
99-110: Soft sync and hard reset branches are identical.Both the
if (shouldHardReset)block and theelseblock perform the exact same operation (T0Ref.current = newT0). The comment mentions "gradually adjust T0" for soft sync, but no gradual adjustment is implemented. Either implement a proper soft sync (e.g., interpolation) or remove the unnecessary conditional.🔎 Option 1: Remove redundant conditional
// Detect drift (if local progress differs significantly from API) const localProgress = calculateProgress(is_playing, duration_ms); const drift = Math.abs(localProgress - progress_ms); const shouldHardReset = drift > DRIFT_THRESHOLD || trackChanged; - if (shouldHardReset) { - T0Ref.current = newT0; + T0Ref.current = newT0; + if (shouldHardReset) { lastProgressRef.current = progress_ms; - } else { - // Soft sync: gradually adjust T0 - T0Ref.current = newT0; }🔎 Option 2: Implement actual soft sync
if (shouldHardReset) { T0Ref.current = newT0; lastProgressRef.current = progress_ms; } else { - // Soft sync: gradually adjust T0 - T0Ref.current = newT0; + // Soft sync: gradually adjust T0 using interpolation + const SOFT_SYNC_FACTOR = 0.3; + T0Ref.current = T0Ref.current + (newT0 - T0Ref.current) * SOFT_SYNC_FACTOR; }
76-92: Add error handling for the API call.
getCurrentPlayback()has no try/catch wrapper. If the API request fails (network error, auth expiry, etc.), the error will propagate unhandled, potentially breaking the polling loop or causing runtime exceptions.🔎 Proposed fix
const fetchPlaybackState = async () => { - const playback = await getCurrentPlayback(); + let playback; + try { + playback = await getCurrentPlayback(); + } catch (error) { + console.error('Failed to fetch Spotify playback:', error); + return; // Keep current state on error + } if (!playback || !playback.track) {src/app/blog/[...slug]/page.tsx (1)
17-28:generateStaticParamsis ineffective withforce-dynamic.Setting
export const dynamic = 'force-dynamic'disables static generation, makinggenerateStaticParamsunused at build time. The function will never be called since all routes are rendered dynamically. Either removegenerateStaticParamsor reconsider the rendering strategy if you want to benefit from static generation for non-admin scenarios.
♻️ Duplicate comments (1)
src/actions/contact.ts (1)
141-168: Schema mismatch: Geo columns missing in migration.Same issue as other functions - geo fields are referenced but not defined in the migration file for
contactInteractions.
🟠 Major comments (23)
src/components/landing/activity/contribution-graph.tsx-144-167 (1)
144-167: Theme detection is not reactive and may cause performance issues.The function queries the DOM (
document.documentElement.classList.contains('dark')) on every invocation. SincegetColorForLevelis called multiple times per render (once per day cell, in the legend, and in tooltips), this results in numerous DOM queries. More critically, theme changes won't trigger a re-render, so colors won't update when the user switches themes.🔎 Recommended fixes
Option 1: State-based theme detection with MutationObserver
+ const [isDarkTheme, setIsDarkTheme] = useState(() => + typeof document !== 'undefined' && document.documentElement.classList.contains('dark') + ); + + useEffect(() => { + if (typeof document === 'undefined') return; + + const observer = new MutationObserver(() => { + setIsDarkTheme(document.documentElement.classList.contains('dark')); + }); + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'] + }); + + return () => observer.disconnect(); + }, []); const getColorForLevel = (level: number) => { - // Dark theme colors (GitHub default) const darkColors = [ 'bg-[#161b22]', 'bg-[#0e4429]', 'bg-[#006d32]', 'bg-[#26a641]', 'bg-[#39d353]' ]; - - // Light theme colors (subtle) + const lightColors = [ 'bg-gray-200', 'bg-green-100/60', 'bg-green-200/70', 'bg-green-300/80', 'bg-green-400' ]; - - // Check if we're in dark theme by checking document class - const isDarkTheme = typeof document !== 'undefined' && document.documentElement.classList.contains('dark'); - + return isDarkTheme ? darkColors[level] : lightColors[level]; };Option 2: Use Tailwind's dark mode classes (simpler)
const getColorForLevel = (level: number) => { - // Dark theme colors (GitHub default) - const darkColors = [ - 'bg-[#161b22]', - 'bg-[#0e4429]', - 'bg-[#006d32]', - 'bg-[#26a641]', - 'bg-[#39d353]' - ]; - - // Light theme colors (subtle) - const lightColors = [ - 'bg-gray-200', - 'bg-green-100/60', - 'bg-green-200/70', - 'bg-green-300/80', - 'bg-green-400' - ]; - - // Check if we're in dark theme by checking document class - const isDarkTheme = typeof document !== 'undefined' && document.documentElement.classList.contains('dark'); - - return isDarkTheme ? darkColors[level] : lightColors[level]; + const colors = [ + 'bg-gray-200 dark:bg-[#161b22]', + 'bg-green-100/60 dark:bg-[#0e4429]', + 'bg-green-200/70 dark:bg-[#006d32]', + 'bg-green-300/80 dark:bg-[#26a641]', + 'bg-green-400 dark:bg-[#39d353]' + ]; + + return colors[level]; };Option 2 is preferred as it leverages Tailwind's built-in dark mode handling without JavaScript.
src/components/ui/work-experience.tsx-250-252 (1)
250-252:defaultOpenwon't respond toshouldExpandAllchanges after mount.The
defaultOpenprop only sets the initial state when the component mounts. WhenshouldExpandAllchanges (e.g., user clicks "View All Experience"), already-mountedCollapsiblecomponents incurrentJobandeducationsections won't expand because their internal state was already initialized.Consider using controlled state with the
openprop, or add akeythat changes withshouldExpandAllto force remount:🔎 Option 1: Use controlled state
export function ExperiencePositionItem({ position, shouldExpandAll = false, }: { position: ExperiencePositionItemType shouldExpandAll?: boolean }) { const [isOpen, setIsOpen] = React.useState(position.isExpanded ?? false); React.useEffect(() => { if (shouldExpandAll) { setIsOpen(true); } }, [shouldExpandAll]); // ... return ( <Collapsible open={isOpen} onOpenChange={setIsOpen} asChild> {/* ... */} </Collapsible> ); }🔎 Option 2: Force remount via key (simpler but less smooth)
In
ExperienceItem, add a key based onshouldExpandAll:{experience.positions.map((position) => ( - <ExperiencePositionItem key={position.id} position={position} shouldExpandAll={shouldExpandAll} /> + <ExperiencePositionItem key={`${position.id}-${shouldExpandAll}`} position={position} shouldExpandAll={shouldExpandAll} /> ))}Committable suggestion skipped: line range outside the PR's diff.
src/app/blog/topics/[topic]/page.tsx-7-8 (1)
7-8: Reconsider the dynamic rendering strategy for blog pages.Using
export const dynamic = 'force-dynamic'prevents static generation entirely, forcing server-side rendering on every request. This contradicts the presence ofgenerateStaticParams()and eliminates significant performance benefits:
- Increased TTFB due to per-request rendering
- Loss of CDN edge caching
- Higher server load during traffic spikes
ISR with
export const revalidate = 3600(or similar intervals) is the recommended pattern for blog content, as it maintains fast static performance while allowing periodic updates without requiring full dynamic rendering. Analytics and tracking can be handled entirely on the client side using route change listeners, which work with static pages.If dynamic rendering is truly necessary for your use case, please document the specific requirement. Otherwise, migrate to ISR with
generateStaticParams()to preserve static generation benefits.src/actions/auth.ts-11-15 (1)
11-15:cookies()requiresawaitin Next.js 15+.In Next.js 15, the
cookies()function fromnext/headersis now asynchronous. This will cause a runtime error or unexpected behavior.🔎 Proposed fix
const session = await auth.api.getSession({ headers: { - cookie: cookies().toString() + cookie: (await cookies()).toString() } })src/actions/auth.ts-21-24 (1)
21-24: Guard against undefined email comparison.If
ADMIN_EMAILis not configured (it's optional perenv.ts) andsession.user.emailis also undefined, the comparisonundefined === undefinedwould incorrectly returntrue, granting admin access.🔎 Proposed fix
- const isEmailMatch = session.user.email?.toLowerCase() === ADMIN_EMAIL?.toLowerCase() + const isEmailMatch = ADMIN_EMAIL && session.user.email + ? session.user.email.toLowerCase() === ADMIN_EMAIL.toLowerCase() + : false const isRoleAdmin = session.user.role === 'admin'src/app/api/example/route.ts-21-22 (1)
21-22: Error handling loses error context.The catch block returns a generic error message without logging the actual error, making debugging difficult.
🔎 Proposed improvement
} catch (error) { + console.error('[PostHog] Failed to capture event:', error) return NextResponse.json({ error: 'Failed to capture event' }, { status: 500 }) }src/components/blog/posts-server.tsx-15-21 (1)
15-21: Add error handling for database query.The database query lacks error handling. If the database is unavailable or the query fails, this will crash the page render.
🔎 Proposed improvement
- const viewData = await db.select({ - slug: blogPosts.slug, - views: blogPosts.totalViews, - uniqueViews: blogPosts.uniqueViews - }).from(blogPosts) + let viewData = [] + try { + viewData = await db.select({ + slug: blogPosts.slug, + views: blogPosts.totalViews, + uniqueViews: blogPosts.uniqueViews + }).from(blogPosts) + } catch (error) { + console.error('[BlogPosts] Failed to fetch view data:', error) + // Continue with empty viewData - posts will show 0 views + }src/app/page.tsx-13-13 (1)
13-13: Consider using Partial Prerendering (PPR) to isolate dynamic rendering.The page uses
force-dynamicbecauseBlogPostscomponent callsisAdmin(), which depends onheaders()for request-time data (session info and view counts). While this dynamic requirement is legitimate, forcing the entire page dynamic disables caching for static content likeIntro,TechStackCloud, and the layout.Instead of
force-dynamic, wrap theBlogPostscomponent in a Suspense boundary with a fallback UI. With Partial Prerendering, you can keep the page statically prerendered and explicitly defer only the sections that need request-time data like headers(), while the rest stays lightning-fast. This lets the static sections prerender at build time whileBlogPostsstreams in dynamically, significantly improving performance.src/hooks/use-admin.ts-10-24 (1)
10-24: Add cleanup to prevent state updates on unmounted component.The
useEffectdoesn't handle component unmounting during the async operation. If the component unmounts beforecheckAdminStatus()resolves, state updates will be attempted on an unmounted component.🔎 Proposed fix to add cleanup
useEffect(() => { + let mounted = true const verifyAdmin = async () => { try { const admin = await checkAdminStatus() - setIsAdmin(admin) + if (mounted) setIsAdmin(admin) } catch (error) { console.error('Failed to verify admin status:', error) - setIsAdmin(false) + if (mounted) setIsAdmin(false) } finally { - setIsLoading(false) + if (mounted) setIsLoading(false) } } verifyAdmin() + return () => { mounted = false } }, [])src/components/admin/contact/contact-overview.tsx-8-12 (1)
8-12: Replaceany[]with properly typed interfaces.The
ContactStatstype usesany[]for all three arrays, which eliminates type safety and makes runtime errors likely. Based on the usage in the component, these should have explicit types.🔎 Proposed fix with proper types
+type ContactSubmission = { + id: string | number + name: string + email: string + message: string + createdAt: string | Date +} + +type ContactInteraction = { + id: string | number + // add other fields as needed +} + +type ContactAbandonment = { + id: string | number + // add other fields as needed +} + type ContactStats = { - submissions: any[] - interactions: any[] - abandonments: any[] + submissions: ContactSubmission[] + interactions: ContactInteraction[] + abandonments: ContactAbandonment[] }src/components/admin/blogs/blog-list.tsx-59-59 (1)
59-59: ValidatepublishedAtdates during metadata parsing to prevent errors in sitemap/RSS generation.While
toLocaleDateString()won't throw with invalid dates,toISOString()will throw a RangeError if called on an invalid date. The metadata parser (src/utils/utils.ts) acceptspublishedAtas a raw string with no validation. This causes failures when generating sitemaps (sitemap-posts.xml/route.ts:11) and RSS feeds (rss/route.ts:21-22) that calltoISOString(). Add date format validation during parsing, or document the required format (ISO 8601 recommended).src/actions/analytics.ts-31-45 (1)
31-45: Race condition between uniqueness check and insert.Concurrent requests with the same fingerprint could both pass the
existingViewcheck and insert duplicate records. Consider using an upsert pattern or a unique constraint on(slug, fingerprint)withON CONFLICT.🔎 Suggested approach using upsert
- const existingView = await db.query.blogViews.findFirst({ - where: and( - eq(blogViews.slug, slug), - eq(blogViews.fingerprint, fingerprint) - ), - }) - - if (existingView) { - await db.update(blogPosts) - .set({ totalViews: sql`${blogPosts.totalViews} + 1` }) - .where(eq(blogPosts.slug, slug)) - return { unique: false } - } + // Use INSERT ... ON CONFLICT to handle race conditions atomically + const result = await db.insert(blogViews) + .values({ + slug, + fingerprint, + ipAddress: ip, + // ... geo fields + }) + .onConflictDoNothing({ target: [blogViews.slug, blogViews.fingerprint] }) + .returning({ id: blogViews.id }) + + const isUnique = result.length > 0This requires adding a unique constraint on
(slug, fingerprint)in the schema.Committable suggestion skipped: line range outside the PR's diff.
src/app/global.css-765-832 (1)
765-832: Duplicate:rootand.darktheme variable definitions with incompatible @theme blocks.The file contains two sets of theme variable definitions:
- Lines 45-79: Using HSL color space (e.g.,
--background: 0 0% 100%)- Lines 765-799: Using OKLCH color space (e.g.,
--background: oklch(1 0 0))However, the real issue is that custom properties defined with two dashes are subject to the cascade and inherit their value from their parent. The first :root definition at line 45 is inside
@layer base, while the second at line 765 is not, meaning the second will override the first.Additionally, the first @theme block (line 7) wraps variables with
hsl(var(...)), expecting HSL format values. The second :root at line 765 provides OKLCH values instead. While the second @theme inline (line 724) mitigates this by using directvar()references without thehsl()wrapper, this duplication creates maintenance burden and leaves the first @theme block incompatible with the OKLCH values if accidentally reactivated.The duplicate definitions should be consolidated into a single, intentional implementation that clearly targets OKLCH values throughout, or the first @theme's
hsl()wrappers should be updated to handle the new color space.src/components/landing/tech-stack-cloud.tsx-177-181 (1)
177-181: Pass current theme to Icon component instead of hardcodingtheme="dark".The Icon component in brand-icons.tsx accepts a
themeprop ('light' or 'dark') to determine its fill color (#f5f5f5 for dark, #111111 for light). Currently it's hardcoded totheme="dark", causing icons to always render with light-colored fills. In light mode (when the app's dark class is removed from the html element), these light-colored icons become invisible against the light background.Update the TechCard component to read the current theme from the app's theme system and pass it dynamically to the Icon component, or adjust the icon colors to work with both themes.
src/components/providers/posthog-provider.tsx-9-17 (1)
9-17: Add runtime guard for missing PostHog credentials.The non-null assertion on
env.NEXT_PUBLIC_POSTHOG_KEY!(line 10) will throw a runtime error if the environment variable is not set, since it's defined as optional in the env schema. Additionally, initializing PostHog at module scope can cause issues with hot module replacement during development.🔎 Recommended fix
if (typeof window !== 'undefined') { - posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY!, { - api_host: env.NEXT_PUBLIC_POSTHOG_HOST, - person_profiles: 'identified_only', - capture_pageview: false, // Disable automatic pageview capture - capture_pageleave: true, - capture_exceptions: true, // Enable error tracking - }) + const posthogKey = env.NEXT_PUBLIC_POSTHOG_KEY + const posthogHost = env.NEXT_PUBLIC_POSTHOG_HOST + + if (posthogKey && posthogHost) { + posthog.init(posthogKey, { + api_host: posthogHost, + person_profiles: 'identified_only', + capture_pageview: false, + capture_pageleave: true, + capture_exceptions: true, + }) + } }src/components/seo/structured-data.tsx-1-1 (1)
1-1: Remove unused import.The
Metadatatype from Next.js is imported but never used in this file.🔎 Proposed fix
-import { Metadata } from 'next' import { baseUrl } from '@/app/sitemap'src/components/contact/contact-popover.tsx-40-73 (1)
40-73: Add honeypot field to FormData.The honeypot field
_gotchais rendered in the form (lines 184-190) for spam protection, but it's not included in the FormData submission. The server actionsubmitContactFormexpects this field for honeypot validation.🔎 Proposed fix
const formData = new FormData(); formData.append('name', name); formData.append('email', email); formData.append('subject', subject); formData.append('message', message); + formData.append('_gotcha', '');src/components/contact/contact-popover.tsx-10-10 (1)
10-10: Remove unused import.The
posthogimport is not used anywhere in this file.🔎 Proposed fix
-import posthog from 'posthog-js';src/actions/contact.ts-102-109 (1)
102-109: Inconsistent IP parsing logic.Line 105 doesn't parse
x-forwarded-forby splitting on commas like thegetClientIp()function from@/lib/protectiondoes. This could result in getting a comma-separated list of IPs instead of just the first one. Use the importedgetClientIp()function for consistency.🔎 Proposed fix
async function getClientInfo() { const headersList = await headers() + const ip = await getClientIp() return { - ip: (headersList.get('x-forwarded-for') || headersList.get('x-real-ip') || 'unknown'), + ip, userAgent: headersList.get('user-agent') || 'unknown', referrer: headersList.get('referer') || null } }Committable suggestion skipped: line range outside the PR's diff.
src/actions/contact.ts-22-28 (1)
22-28: Remove the unsafe type assertion.The
as anytype assertion on line 28 bypasses TypeScript's type safety. Define a proper type for the raw form data instead.🔎 Proposed fix
+type RawFormData = { + name: FormDataEntryValue | null + email: FormDataEntryValue | null + subject: FormDataEntryValue | null + message: FormDataEntryValue | null + _gotcha: FormDataEntryValue | null +} + export async function submitContactForm(formData: FormData) { const rawData = { name: formData.get('name'), email: formData.get('email'), subject: formData.get('subject'), message: formData.get('message'), _gotcha: formData.get('_gotcha'), - } as any + } as RawFormDatasrc/actions/contact.ts-244-258 (1)
244-258: Inefficient in-memory aggregation - use SQL instead.Fetching all abandonment times and filtering in memory (lines 244-258) is inefficient and won't scale. Use SQL aggregation to compute these metrics in the database.
🔎 Proposed fix using SQL aggregation
- // Calculate average time to abandon - const abandonmentTimes = await db - .select({ timeToAbandon: contactAbandonments.timeToAbandon }) - .from(contactAbandonments) - - const avgTimeToAbandon = abandonmentTimes.length > 0 - ? Math.round(abandonmentTimes.reduce((sum, record) => sum + (record.timeToAbandon || 0), 0) / abandonmentTimes.length) - : 0 - - // Get abandonment by time ranges - const abandonmentRanges = { - under5s: abandonmentTimes.filter(t => (t.timeToAbandon || 0) < 5000).length, - under15s: abandonmentTimes.filter(t => (t.timeToAbandon || 0) >= 5000 && (t.timeToAbandon || 0) < 15000).length, - under30s: abandonmentTimes.filter(t => (t.timeToAbandon || 0) >= 15000 && (t.timeToAbandon || 0) < 30000).length, - over30s: abandonmentTimes.filter(t => (t.timeToAbandon || 0) >= 30000).length, - } + // Calculate average time to abandon and time ranges using SQL + const abandonmentStats = await db + .select({ + avgTime: sql<number>`avg(${contactAbandonments.timeToAbandon})`, + under5s: sql<number>`count(*) filter (where ${contactAbandonments.timeToAbandon} < 5000)`, + under15s: sql<number>`count(*) filter (where ${contactAbandonments.timeToAbandon} >= 5000 and ${contactAbandonments.timeToAbandon} < 15000)`, + under30s: sql<number>`count(*) filter (where ${contactAbandonments.timeToAbandon} >= 15000 and ${contactAbandonments.timeToAbandon} < 30000)`, + over30s: sql<number>`count(*) filter (where ${contactAbandonments.timeToAbandon} >= 30000)`, + }) + .from(contactAbandonments) + + const avgTimeToAbandon = Math.round(abandonmentStats[0]?.avgTime || 0) + const abandonmentRanges = { + under5s: abandonmentStats[0]?.under5s || 0, + under15s: abandonmentStats[0]?.under15s || 0, + under30s: abandonmentStats[0]?.under30s || 0, + over30s: abandonmentStats[0]?.over30s || 0, + }Note: You'll need to import
sqlfrom drizzle-orm.Committable suggestion skipped: line range outside the PR's diff.
src/actions/contact.ts-171-219 (1)
171-219: Missing transaction wrapper for dual insertion.Lines 184-197 and 200-212 perform two separate insertions without a transaction. If the first succeeds but the second fails, you'll have inconsistent data. Wrap both insertions in a transaction to ensure atomicity.
🔎 Proposed fix
export async function trackFormAbandonment( interactionId: string, timeToAbandon: number, lastFieldTouched?: string, formData?: Record<string, any> ) { try { const visitorId = await getVisitorId() const clientInfo = await getClientInfo() const geoInfo = await fetchGeoInfo(clientInfo.ip) - // Record the abandonment details - await db.insert(contactAbandonments).values({ - visitorId, - interactionId, - timeToAbandon, - lastFieldTouched: lastFieldTouched || null, - formData: formData ? JSON.stringify(formData) : null, - ipAddress: clientInfo.ip, - geoCountry: geoInfo?.country, - geoCity: geoInfo?.city, - geoRegion: geoInfo?.region, - geoLoc: geoInfo?.loc, - geoOrg: geoInfo?.org, - geoTimezone: geoInfo?.timezone, - }) - - // Also record as a general interaction for analytics - await db.insert(contactInteractions).values({ - visitorId, - interactionType: 'form_abandon', - ipAddress: clientInfo.ip, - userAgent: clientInfo.userAgent, - referrer: clientInfo.referrer, - geoCountry: geoInfo?.country, - geoCity: geoInfo?.city, - geoRegion: geoInfo?.region, - geoLoc: geoInfo?.loc, - geoOrg: geoInfo?.org, - geoTimezone: geoInfo?.timezone, - }) + await db.transaction(async (tx) => { + // Record the abandonment details + await tx.insert(contactAbandonments).values({ + visitorId, + interactionId, + timeToAbandon, + lastFieldTouched: lastFieldTouched || null, + formData: formData ? JSON.stringify(formData) : null, + ipAddress: clientInfo.ip, + geoCountry: geoInfo?.country, + geoCity: geoInfo?.city, + geoRegion: geoInfo?.region, + geoLoc: geoInfo?.loc, + geoOrg: geoInfo?.org, + geoTimezone: geoInfo?.timezone, + }) + + // Also record as a general interaction for analytics + await tx.insert(contactInteractions).values({ + visitorId, + interactionType: 'form_abandon', + ipAddress: clientInfo.ip, + userAgent: clientInfo.userAgent, + referrer: clientInfo.referrer, + geoCountry: geoInfo?.country, + geoCity: geoInfo?.city, + geoRegion: geoInfo?.region, + geoLoc: geoInfo?.loc, + geoOrg: geoInfo?.org, + geoTimezone: geoInfo?.timezone, + }) + }) return { success: true } } catch (error) {src/actions/contact.ts-58-73 (1)
58-73: Add explicit consent disclosure to contact form and update privacy policy.The contact form stores personal information (name, email) alongside IP address and geolocation data (country, city, region, coordinates, ISP, timezone). Your privacy policy states only "email and message" are collected, omitting the geolocation fields—creating a disclosure gap.
GDPR/CCPA compliance requires:
- Explicit consent checkbox on the form (not just reliance on privacy policy link)
- Accurate privacy policy detailing all collected data (currently incomplete)
- Documented retention period for contact submissions
- User-accessible deletion mechanism to honor "right to deletion" requests
Update the privacy policy contact section to list all collected fields, add a consent checkbox to the form UI, and implement a way for users to request deletion of their contact data.
🟡 Minor comments (17)
src/hooks/use-spotify-playback.ts-52-58 (1)
52-58: Fix inconsistent indentation.Multiple function definitions (
calculateProgress,updateLocalProgress,fetchPlaybackState,useEffect) have inconsistent indentation—some use 6 spaces instead of the standard 4. Run your formatter (e.g., Prettier) to normalize the code style.src/components/ui/work-experience.tsx-130-132 (1)
130-132: Off-by-one error in "more" count.The button displays
workHistory.lengthbut one job (previewJob) is already visible in the preview state. The count should reflect only the additional jobs that will appear when expanded.🔎 Proposed fix
<span className="transition-opacity duration-300"> - {showAll ? 'Show Less' : `View All Experience (${workHistory.length} more)`} + {showAll ? 'Show Less' : `View All Experience (${remainingJobs.length} more)`} </span>src/app/blog/categories/[category]/page.tsx-7-8 (1)
7-8: Remove unusedgenerateStaticParamsor reconsiderforce-dynamicnecessity.The
export const dynamic = 'force-dynamic'overridesgenerateStaticParams(), making the static params generation ineffective. The[...slug]page documents this is needed for "auth requirements," but other blog routes lack this justification and inherit the same setting.Options:
- Remove
generateStaticParams()from files usingforce-dynamic(it has no effect)- If auth is only needed for certain routes, consider using
force-dynamicselectively and removing it from static-eligible pages- Document the auth requirement consistently across all affected routes, or refactor to separate auth-gated and static-friendly routes
This is currently creating maintenance confusion and unnecessary code overhead.
src/app/blog/posts/engineering/draft-system-implementation.md-12-12 (1)
12-12: Fix typo in environment variable reference.
proccess.envshould beprocess.env.🔎 Proposed fix
-I had just implemented authentication, GitHub OAuth only for me to access a private admin route for analytics and metrics. I signed up, and added some logic in the _middleware_ proxy that simply checks `if (email === proccess.env.MY_EMAIL).. access granted`. Now for the harder task. +I had just implemented authentication, GitHub OAuth only for me to access a private admin route for analytics and metrics. I signed up, and added some logic in the _middleware_ proxy that simply checks `if (email === process.env.MY_EMAIL).. access granted`. Now for the harder task.src/app/blog/posts/engineering/spotify-oauth-guide.md-53-53 (1)
53-53: Fix spelling."usecase" should be two words: "use case".
🔎 Proposed fix
-Last question is: Which API/SDKs are you planning to use? Fill in your usecase (most likely web) and click "Save". After having pressed save you'll see your client ID, and secret if you press "View client secret". Copy these, and add to your `.env`or `.env.local` file like so: +Last question is: Which API/SDKs are you planning to use? Fill in your use case (most likely web) and click "Save". After having pressed save you'll see your client ID, and secret if you press "View client secret". Copy these, and add to your `.env`or `.env.local` file like so:src/app/sitemap-posts.xml/route.ts-11-11 (1)
11-11: Add validation for date parsing.The
publishedAtmetadata is passed directly tonew Date()without validation. If a post has an invalid or missing date, this could produce an invalid ISO string in the sitemap XML.🔎 Proposed fix to add date validation
${posts.map((post) => { const url = `${baseUrl}/blog/${post.slug}` - const lastMod = new Date(post.metadata.publishedAt).toISOString() + const publishedDate = post.metadata.publishedAt ? new Date(post.metadata.publishedAt) : new Date() + const lastMod = isNaN(publishedDate.getTime()) ? new Date().toISOString() : publishedDate.toISOString() const priority = 0.8src/app/privacy/privacy-content.tsx-7-11 (1)
7-11: Use a static date for "Last updated" instead of dynamic computation.The
lastUpdateddate is computed dynamically on every render, which means it will show today's date even though the policy content hasn't changed. This could mislead users into thinking the policy was recently updated when it wasn't.🔎 Proposed fix with static date
- const lastUpdated = new Date().toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric' - }) + // Update this date whenever the privacy policy content changes + const lastUpdated = 'December 21, 2025'src/components/admin/metrics/user-metrics.tsx-64-68 (1)
64-68: Add guard against division by zero.If
totalViewsis 0, the progress bar width calculation will result inNaNor division by zero. This could happen when there's no traffic data yet.🔎 Proposed fix to handle zero total views
<div className="w-24 h-2 bg-secondary rounded-full overflow-hidden"> <div className="h-full bg-primary" - style={{ width: `${(item.count / data.totalViews) * 100}%` }} + style={{ width: `${data.totalViews > 0 ? (item.count / data.totalViews) * 100 : 0}%` }} /> </div>src/components/admin/contact/contact-overview.tsx-56-56 (1)
56-56: Add error handling for invalid dates.The date formatting could throw if
createdAtis not a valid date value. Consider adding validation or a fallback.🔎 Proposed fix with error handling
<span className="text-xs text-muted-foreground whitespace-nowrap"> - {new Date(sub.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} + {sub.createdAt ? new Date(sub.createdAt).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) : 'Unknown'} </span>src/app/sitemap.xml/route.ts-25-31 (1)
25-31: Remove invalid HTTP headers.The headers
xml-versionandEncodingare not valid HTTP headers. These belong in the XML declaration (line 8) which is already correct. HTTP headers should only include standard headers likeContent-Type.🔎 Proposed fix
return new Response(sitemap, { headers: { 'Content-Type': 'application/xml', - 'xml-version': '1.0', - 'Encoding': 'UTF-8', }, })src/app/layout.tsx-40-40 (1)
40-40: Use correct metadata.other structure for dns-prefetch tags.The array of strings format won't generate proper
<link>tags. Use the object structure instead:other: [{ rel: 'dns-prefetch', url: '//api.github.com' }, { rel: 'dns-prefetch', url: '//api.spotify.com' }]src/actions/analytics.ts-11-14 (1)
11-14: Comment mentions "DailySalt" but implementation doesn't include it.The docstring mentions using a daily salt in the fingerprint, but the implementation on line 22 only uses
${ip}|${userAgent}. Either update the comment or implement the salt for time-bound uniqueness detection.🔎 Suggested fix for comment accuracy
/** * Tracks a blog post view with robust unique visitor detection. - * Uses a fingerprint of (IP + UserAgent + DailySalt) to detect uniqueness across Incognito. + * Uses a fingerprint of (IP + UserAgent) to detect uniqueness across Incognito. */src/actions/analytics.ts-69-74 (1)
69-74: Inconsistent return type structure.Successful paths return
{ unique: boolean }while the error path returns{ success: false }. Consumers need to check for different properties. Consider a consistent structure.🔎 Suggested consistent return type
- return { unique: true } + return { success: true, unique: true } } catch (error) { console.error('Failed to track blog view:', error) - return { success: false } + return { success: false, unique: false } }Also update line 44:
- return { unique: false } + return { success: true, unique: false }Committable suggestion skipped: line range outside the PR's diff.
src/components/blog/post-admin-controls.tsx-52-60 (1)
52-60: Delete button has no implementation after confirmation.The confirm dialog is shown, but the callback body is empty. This leaves users with a non-functional button that appears to work.
Would you like me to implement the delete action using a server action, or should I open an issue to track this incomplete functionality?
🔎 Placeholder suggestion
onClick={() => { if (confirm('Are you sure you want to delete this post?')) { + // TODO: Implement delete action + console.warn('Delete not yet implemented for:', post.slug) } }}src/components/blog/posts-client.tsx-129-139 (1)
129-139: Add validation for readTime parsing.Line 134 uses
parseInt(post.metadata.readTime)which assumesreadTimeis a numeric string. If the format changes (e.g., "5 min" or "5-10 min"),parseIntwill silently fail and returnNaN, which could cause rendering issues.🔎 Recommended defensive parsing
{post.metadata.readTime && ( <> <span className="text-neutral-300 dark:text-neutral-700">•</span> <span> + {(() => { + const minutes = parseInt(post.metadata.readTime!, 10); + return !isNaN(minutes) ? ( - <AnimatedNumber - value={parseInt(post.metadata.readTime)} - duration={dateDuration} - /> min read + <> + <AnimatedNumber value={minutes} duration={dateDuration} /> + {' min read'} + </> + ) : post.metadata.readTime; + })()} </span> </> )}src/components/ui/card.tsx-27-40 (1)
27-40: Type mismatch between ref type and rendered element.
CardTitledeclaresHTMLParagraphElementas the ref type but renders an<h3>element (which isHTMLHeadingElement). This can cause TypeScript issues when consumers try to access heading-specific properties on the ref.🔎 Proposed fix
const CardTitle = React.forwardRef< - HTMLParagraphElement, + HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement> >(({ className, ...props }, ref) => (src/app/blog/posts/productivity/mac-to-linux.mdx-1-1 (1)
1-1: Remove the blank line before the YAML frontmatter (line 1).The file has a blank line preceding the frontmatter delimiters. While the codebase's custom MDX parser handles this correctly, it's not standard practice for YAML frontmatter and adds unnecessary whitespace.
| // Unique Visitors (distinct fingerprint) | ||
| db.select({ count: count(blogViews.fingerprint) }).from(blogViews), |
There was a problem hiding this comment.
Bug: count() does not return distinct values.
count(blogViews.fingerprint) counts all non-null fingerprint values, not distinct ones. This will return the same value as totalViews. Use countDistinct for accurate unique visitor counts.
🔎 Suggested fix
+import { desc, count, countDistinct, sql, eq } from 'drizzle-orm'
+
// Unique Visitors (distinct fingerprint)
- db.select({ count: count(blogViews.fingerprint) }).from(blogViews),
+ db.select({ count: countDistinct(blogViews.fingerprint) }).from(blogViews),Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In src/actions/admin.ts around lines 21 to 22, the query currently uses
count(blogViews.fingerprint) which counts all non-null fingerprints (not
unique); replace that call with countDistinct(blogViews.fingerprint) so the DB
returns the number of unique visitors, and keep the same alias/name used for
this field in the select to preserve downstream code expectations.
| export async function trackContactButtonClick() { | ||
| try { | ||
| const visitorId = await getVisitorId() | ||
| const clientInfo = await getClientInfo() | ||
|
|
||
| const geoInfo = await fetchGeoInfo(clientInfo.ip) | ||
|
|
||
| await db.insert(contactInteractions).values({ | ||
| visitorId, | ||
| interactionType: 'button_click', | ||
| ipAddress: clientInfo.ip, | ||
| userAgent: clientInfo.userAgent, | ||
| referrer: clientInfo.referrer, | ||
| geoCountry: geoInfo?.country, | ||
| geoCity: geoInfo?.city, | ||
| geoRegion: geoInfo?.region, | ||
| geoLoc: geoInfo?.loc, | ||
| geoOrg: geoInfo?.org, | ||
| geoTimezone: geoInfo?.timezone, | ||
| }) | ||
|
|
||
| return { success: true } | ||
| } catch (error) { | ||
| console.error('Failed to track contact button click:', error) | ||
| return { success: false } | ||
| } | ||
| } |
There was a problem hiding this comment.
Schema mismatch: Geo columns missing in migration (same as contactSubmissions).
The code references geo fields (geoCountry, geoCity, etc.) for contactInteractions, but these columns are not defined in the migration file. This will cause insertion failures at runtime.
🤖 Prompt for AI Agents
In src/actions/contact.ts around lines 112 to 138, the insert references geo
columns (geoCountry, geoCity, geoRegion, geoLoc, geoOrg, geoTimezone) that are
missing from the DB migration for the contactInteractions table; update the
migration that creates/updates contactInteractions to add these columns with
appropriate types (nullable text/string or varchar, matching the
contactSubmissions schema), then regenerate/run the migration and adjust any
TypeScript DB schema types if needed; also ensure existing entries are handled
(nullable columns or a small backfill) so inserts no longer fail.
| title: 'Building a draft system for markdown files in Next.js' | ||
| publishedAt: '16-12-2025' | ||
| summary: 'Running a blog throug filesystem is great, althrough accidental commits of unfinished posts are bound to happen sooner or later. Here’s how I implemented a draft system using MDX frontmatter' | ||
| categories: [Engineering, Next.js, Authentication'] |
There was a problem hiding this comment.
Fix syntax error in frontmatter.
There's an unmatched quote in the categories array. The closing bracket should have a closing quote before it.
🔎 Proposed fix
-categories: [Engineering, Next.js, Authentication']
+categories: ['Engineering', 'Next.js', 'Authentication']📝 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.
| categories: [Engineering, Next.js, Authentication'] | |
| categories: ['Engineering', 'Next.js', 'Authentication'] |
🤖 Prompt for AI Agents
In src/app/blog/posts/engineering/draft-system-implementation.md around line 5,
the frontmatter has an unmatched single quote in the categories array
(categories: [Engineering, Next.js, Authentication']), remove the stray quote
and correct the array formatting; replace the line with a properly quoted or
unquoted YAML list (for example categories: ["Engineering", "Next.js",
"Authentication"] or categories: [Engineering, Next.js, Authentication]) so the
closing bracket and quotes are balanced.
| createRedirectUrl('/', { error: 'unknown_error' }) | ||
| ); | ||
| } | ||
| }``` |
There was a problem hiding this comment.
Fix code block syntax.
The code block starting at line 86 is missing its closing triple backticks. Line 172 has the closing backticks appended to the closing brace, which will cause rendering issues.
🔎 Proposed fix
);
}
-}```
+}
+```📝 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.
| }``` | |
| ); | |
| } | |
| } |
🤖 Prompt for AI Agents
In src/app/blog/posts/engineering/spotify-oauth-guide.md around line 172, the
closing code-fence is appended to the closing brace which breaks the code block;
update the file so the closing brace is on its own line and immediately after it
add a new line that contains the closing code-fence (three backtick characters)
to properly terminate the code block and fix rendering.
| import { PostHog } from 'posthog-node' | ||
|
|
||
| export default function PostHogClient() { | ||
| const posthogClient = new PostHog(process.env.POSTHOG_KEY!, { |
There was a problem hiding this comment.
Validate environment variable before non-null assertion.
The non-null assertion on process.env.POSTHOG_KEY will cause a runtime error if the variable is undefined. Add explicit validation and error handling.
🔎 Proposed fix
export default function PostHogClient() {
+ if (!process.env.POSTHOG_KEY) {
+ throw new Error('POSTHOG_KEY environment variable is required')
+ }
+
const posthogClient = new PostHog(process.env.POSTHOG_KEY!, {
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
flushAt: 1, // Flush immediately
flushInterval: 0, // No batching delay
})
return posthogClient
}🤖 Prompt for AI Agents
In src/app/posthog.ts around line 4, the code uses a non-null assertion on
process.env.POSTHOG_KEY which can throw at runtime if the env var is missing;
change it to explicitly read and validate the variable first (e.g. const key =
process.env.POSTHOG_KEY), check if (!key) then throw a clear Error or log and
exit with a helpful message about the missing POSTHOG_KEY, and only then
construct new PostHog(key, {...}) so you avoid the non-null assertion and fail
fast with a clear diagnostic.
| const pages = [ | ||
| { url: '', priority: 1.0, changefreq: 'daily' }, | ||
| { url: '/blog', priority: 0.9, changefreq: 'daily' }, | ||
| { url: '/blog/categories', priority: 0.8, changefreq: 'weekly' }, | ||
| { url: '/blog/topics', priority: 0.8, changefreq: 'weekly' }, | ||
| { url: '/about', priority: 0.7, changefreq: 'monthly' }, | ||
| { url: '/projects', priority: 0.7, changefreq: 'monthly' }, | ||
| { url: '/contact', priority: 0.6, changefreq: 'monthly' }, | ||
| ] |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n src/app/sitemap-pages.xml/route.tsRepository: remcostoeten/remcostoeten.nl
Length of output: 1425
🏁 Script executed:
fd -e tsx -e ts 'page\.(tsx|ts)$' src/appRepository: remcostoeten/remcostoeten.nl
Length of output: 479
🏁 Script executed:
git ls-files src/app | head -50Repository: remcostoeten/remcostoeten.nl
Length of output: 1777
Hardcoded sitemap is out of sync with actual routes.
The pages array includes routes that don't exist (/about, /projects, /contact) and excludes routes that do (/privacy, /terms, /admin). Generate this list dynamically from the file system or maintain it in a configuration file to prevent drift as routes change.
🤖 Prompt for AI Agents
In src/app/sitemap-pages.xml/route.ts around lines 4 to 12, the pages array is
hardcoded and contains stale routes (/about, /projects, /contact) while missing
current routes (/privacy, /terms, /admin); replace the static list with a
dynamic source of truth by either (a) reading your routes directory at
build/runtime (recursively list route files, map to URL paths, filter out API or
non-public routes, and derive priority/changefreq via config or filename
metadata) or (b) loading a maintained routes configuration file (JSON/YAML) and
using that to build the sitemap; ensure the implementation normalizes paths
(leading slash, index handling), validates existence, sorts entries, and falls
back to a default static list only if filesystem/config access fails.
| const lastUpdated = new Date().toLocaleDateString('en-US', { | ||
| year: 'numeric', | ||
| month: 'long', | ||
| day: 'numeric' | ||
| }) |
There was a problem hiding this comment.
Fix misleading dynamic date generation.
The "Last updated" date is computed using new Date(), which means it will always display today's date regardless of when the terms were actually last modified. This is misleading to users who need to know when terms actually changed.
🔎 Proposed fix: Use a static date constant
+const LAST_UPDATED = '2025-12-21'; // Update this date when terms change
+
export default function TermsContent() {
- const lastUpdated = new Date().toLocaleDateString('en-US', {
+ const lastUpdated = new Date(LAST_UPDATED).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})Or even simpler, directly use the formatted string:
export default function TermsContent() {
- const lastUpdated = new Date().toLocaleDateString('en-US', {
- year: 'numeric',
- month: 'long',
- day: 'numeric'
- })
+ const lastUpdated = 'December 21, 2025' // Update this date when terms change📝 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.
| const lastUpdated = new Date().toLocaleDateString('en-US', { | |
| year: 'numeric', | |
| month: 'long', | |
| day: 'numeric' | |
| }) | |
| const LAST_UPDATED = '2025-12-21'; // Update this date when terms change | |
| export default function TermsContent() { | |
| const lastUpdated = new Date(LAST_UPDATED).toLocaleDateString('en-US', { | |
| year: 'numeric', | |
| month: 'long', | |
| day: 'numeric' | |
| }) |
| const lastUpdated = new Date().toLocaleDateString('en-US', { | |
| year: 'numeric', | |
| month: 'long', | |
| day: 'numeric' | |
| }) | |
| export default function TermsContent() { | |
| const lastUpdated = 'December 21, 2025' // Update this date when terms change |
🤖 Prompt for AI Agents
In src/app/terms/terms-content.tsx around lines 7 to 11, replace the runtime new
Date() usage that always shows today's date with a static last-updated value
(either a hard-coded ISO date constant or a prop/value from your content source)
and format that value for display; update the code to accept or define a
constant like LAST_UPDATED = '2025-04-01' (or obtain the actual last-modified
date from the CMS/git metadata) and then format that fixed date to 'Month day,
year' for rendering so the "Last updated" label reflects the real change date
rather than the current date.
| CREATE TABLE "contact_abandonments" ( | ||
| "id" text PRIMARY KEY NOT NULL, | ||
| "visitor_id" text NOT NULL, | ||
| "interaction_id" text, | ||
| "time_to_abandon" integer, | ||
| "last_field_touched" text, | ||
| "form_data" text, | ||
| "ip_address" text, | ||
| "created_at" timestamp DEFAULT now() NOT NULL | ||
| ); | ||
| --> statement-breakpoint | ||
| CREATE TABLE "contact_interactions" ( | ||
| "id" text PRIMARY KEY NOT NULL, | ||
| "visitor_id" text NOT NULL, | ||
| "interaction_type" text NOT NULL, | ||
| "ip_address" text, | ||
| "user_agent" text, | ||
| "referrer" text, | ||
| "session_id" text, | ||
| "created_at" timestamp DEFAULT now() NOT NULL | ||
| ); | ||
| --> statement-breakpoint | ||
| CREATE TABLE "contact_submissions" ( | ||
| "id" text PRIMARY KEY NOT NULL, | ||
| "name" text NOT NULL, | ||
| "email" text NOT NULL, | ||
| "subject" text, | ||
| "message" text NOT NULL, | ||
| "ip_address" text, | ||
| "created_at" timestamp DEFAULT now() NOT NULL | ||
| ); |
There was a problem hiding this comment.
Critical: Missing geo columns in all contact tables.
The migration is missing all geo-related columns (geo_country, geo_city, geo_region, geo_loc, geo_org, geo_timezone) that are referenced in src/actions/contact.ts and defined in the schema snippets. This will cause insertion failures at runtime for:
contact_abandonments(lines 12-21)contact_interactions(lines 23-32)contact_submissions(lines 34-42)
🔎 Required column additions
For contact_abandonments, add after line 19:
"geo_country" text,
"geo_city" text,
"geo_region" text,
"geo_loc" text,
"geo_org" text,
"geo_timezone" text,For contact_interactions, add after line 30:
"geo_country" text,
"geo_city" text,
"geo_region" text,
"geo_loc" text,
"geo_org" text,
"geo_timezone" text,For contact_submissions, add after line 40:
"geo_country" text,
"geo_city" text,
"geo_region" text,
"geo_loc" text,
"geo_org" text,
"geo_timezone" text,🤖 Prompt for AI Agents
In src/server/db/migrations/0002_fair_invaders.sql around lines 12 to 42, the
three contact tables (contact_abandonments, contact_interactions,
contact_submissions) are missing the geo_* columns referenced elsewhere; add the
following columns to each table: geo_country, geo_city, geo_region, geo_loc,
geo_org, geo_timezone (all text) in the specified positions (after the existing
geo-related placement noted in the review) so inserts that populate geo fields
succeed and schema matches src/actions/contact.ts.

![Screenshot of /blog/[...slug]](https://images.weserv.nl?url=https://sjc.microlink.io/Op-A_Zez05tGy1u-0fSnMirHbBmcvUDszhwsDrW0zgovJ19gFz8emoBlhKzc9hhW8MugWa5OiGRhRcZq49xrRw.png&w=600)
![Screenshot of /blog/categories/[category]](https://images.weserv.nl?url=https://sjc.microlink.io/anI5xEEXjBfhgGOgXTr4fGQEEW-CA3lo7B4maPQz-ozpuzina-PLNvAG5uwi5sDQCSweRC0L8sYAtb6y020JhA.png&w=600)


![Screenshot of /blog/topics/[topic]](https://images.weserv.nl?url=https://sjc.microlink.io/m456phTU48H4ObBcXoy75gEB6p5Mc4YrAOYU6wRmOQz7p_13kmxlpV4An-18UM64vZNx9ozFvAlSyf6yIgmI4w.png&w=600)
Summary by CodeRabbit
Release Notes
New Features
Bug Fixes & Improvements
Documentation
✏️ Tip: You can customize this high-level summary in your review settings.