Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ const nextConfig: NextConfig = {
typedRoutes: true,
// Enable standalone output for Docker deployment
output: 'standalone',
experimental: {
// Optimize tree-shaking for common packages
// Reason: These packages export many modules, but we only use a subset
optimizePackageImports: [
'lucide-react',
'recharts',
'framer-motion',
'@radix-ui/react-tabs',
'@radix-ui/react-dialog',
'@radix-ui/react-popover',
'@radix-ui/react-tooltip',
],
},
}

export default nextConfig
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"@turf/turf": "^7.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.38.4",
Expand All @@ -40,7 +39,6 @@
"maplibre-gl": "^5.0.1",
"nanoid": "^5.1.6",
"next": "16.1.0-canary.34",
"playwright": "^1.58.0",
"react": "^19.0.0-rc.1",
"react-dom": "^19.0.0-rc.1",
"react-map-gl": "^8.1.0",
Expand All @@ -62,6 +60,7 @@
"eslint": "^9.18.0",
"eslint-config-hyoban": "^4.0.10",
"lint-staged": "^16.0.2",
"playwright": "^1.58.0",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.10",
"simple-git-hooks": "^2.13.1",
Expand Down
55 changes: 46 additions & 9 deletions src/app/activity/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,38 @@
import { motion } from 'framer-motion'
import { useAtom } from 'jotai'
import { ArrowLeft, Pause, Play, Square } from 'lucide-react'
import dynamic from 'next/dynamic'
import { useParams, useRouter } from 'next/navigation'
import { useEffect, useMemo, useState } from 'react'

import { ActivityActionBar } from '@/components/activity/ActivityActionBar'
import { AIInsight } from '@/components/activity/AIInsight'
import { HeartRateChart } from '@/components/activity/HeartRateChart'
import { HeartRateZones } from '@/components/activity/HeartRateZones'
import { PaceChart } from '@/components/activity/PaceChart'
import { PaceDistribution } from '@/components/activity/PaceDistribution'
import { SplitsTable } from '@/components/activity/SplitsTable'
import { ArtGallery } from '@/components/art'
import { AnimatedRoute } from '@/components/map/AnimatedRoute'
import { FloatingInfoCard } from '@/components/map/FloatingInfoCard'
import { KilometerMarkers } from '@/components/map/KilometerMarkers'
import { PaceRouteLayer } from '@/components/map/PaceRouteLayer'
import { RunMap } from '@/components/map/RunMap'
import { AnimatedTabs, AnimatedTabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'

// Lazy load map components - MapLibre GL is ~60KB gzipped
const RunMap = dynamic(
() => import('@/components/map/RunMap').then((m) => ({ default: m.RunMap })),
{
ssr: false,
loading: () => (
<div className="h-[300px] animate-pulse rounded-2xl bg-gray-100 sm:h-[400px] dark:bg-gray-900" />
),
},
)

const AnimatedRoute = dynamic(() =>
import('@/components/map/AnimatedRoute').then((m) => ({ default: m.AnimatedRoute })),
)

const PaceRouteLayer = dynamic(() =>
import('@/components/map/PaceRouteLayer').then((m) => ({ default: m.PaceRouteLayer })),
)

const KilometerMarkers = dynamic(() =>
import('@/components/map/KilometerMarkers').then((m) => ({ default: m.KilometerMarkers })),
)
import { useActivityWithSplits } from '@/hooks/use-activities'
import { springs } from '@/lib/animation'
import { generateMockTrackPoints } from '@/lib/map/mock-data'
Expand All @@ -37,6 +52,28 @@ import { formatDate, formatTime } from '@/lib/utils'
import { animationProgressAtom, isPlayingAtom } from '@/stores/map'
import type { Split } from '@/types/activity'

// Lazy load non-default tab components to reduce initial bundle
// Reason: Recharts (~40KB gz) and other heavy components shouldn't load until user clicks the tab
const HeartRateChart = dynamic(() =>
import('@/components/activity/HeartRateChart').then((m) => ({ default: m.HeartRateChart })),
)

const HeartRateZones = dynamic(() =>
import('@/components/activity/HeartRateZones').then((m) => ({ default: m.HeartRateZones })),
)

const PaceDistribution = dynamic(() =>
import('@/components/activity/PaceDistribution').then((m) => ({ default: m.PaceDistribution })),
)

const ArtGallery = dynamic(() =>
import('@/components/art/ArtGallery').then((m) => ({ default: m.ArtGallery })),
)

const AIInsight = dynamic(() =>
import('@/components/activity/AIInsight').then((m) => ({ default: m.AIInsight })),
)

export default function ActivityDetailPage() {
const params = useParams()
const router = useRouter()
Expand Down
35 changes: 30 additions & 5 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,47 @@

import { AnimatePresence, motion } from 'framer-motion'
import { Activity, Calendar, Clock, Layers, MapPin } from 'lucide-react'
import dynamic from 'next/dynamic'
import { useMemo, useState } from 'react'

import { ActivityHeatmap } from '@/components/activity/ActivityHeatmap'
import { ActivityTable } from '@/components/activity/ActivityTable'
import { PersonalRecords } from '@/components/activity/PersonalRecords'
import { StatsCard } from '@/components/activity/StatsCard'
import { Header } from '@/components/layout/Header'
import { PaceRouteLayer } from '@/components/map/PaceRouteLayer'
import { RouteLayer } from '@/components/map/RouteLayer'
import { RunMap } from '@/components/map/RunMap'
import { useActivities, useActivityStats } from '@/hooks/use-activities'
import { parsePaceSegments } from '@/lib/map/pace-utils'
import { cn } from '@/lib/utils'
import type { Activity as ActivityType } from '@/types/activity'
import type { RouteData } from '@/types/map'

// Lazy load heavy components to reduce initial bundle size
// Reason: MapLibre GL (~60KB gz) + react-map-gl should not block first paint
const RunMap = dynamic(
() => import('@/components/map/RunMap').then((m) => ({ default: m.RunMap })),
{
ssr: false,
loading: () => (
<div className="h-[400px] animate-pulse rounded-3xl bg-gray-100 sm:h-[500px] dark:bg-gray-900" />
),
},
)

const RouteLayer = dynamic(() =>
import('@/components/map/RouteLayer').then((m) => ({ default: m.RouteLayer })),
)

const PaceRouteLayer = dynamic(() =>
import('@/components/map/PaceRouteLayer').then((m) => ({ default: m.PaceRouteLayer })),
)

// Reason: Below-the-fold components don't need eager loading
const ActivityHeatmap = dynamic(() =>
import('@/components/activity/ActivityHeatmap').then((m) => ({ default: m.ActivityHeatmap })),
)

const PersonalRecords = dynamic(() =>
import('@/components/activity/PersonalRecords').then((m) => ({ default: m.PersonalRecords })),
)

type StatsPeriod = 'week' | 'month'
type MapLayerMode = 'route' | 'pace'

Expand Down
19 changes: 18 additions & 1 deletion src/components/activity/ActivityTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ import {
Zap,
} from 'lucide-react'
import Link from 'next/link'
import { useCallback } from 'react'

import { RippleContainer } from '@/components/ui/ripple'
import { layoutTransition, springs } from '@/lib/animation'
import { calculatePace, formatDuration, formatPace } from '@/lib/pace/calculator'
import { trpc } from '@/lib/trpc/client'
import type { Activity } from '@/types/activity'

/**
Expand Down Expand Up @@ -181,6 +183,18 @@ export function ActivityTable({ activities, className = '' }: ActivityTableProps
// Calculate achievements for all activities
const achievements = calculateAchievements(activities)

// Get trpc utils for prefetching
const trpcUtils = trpc.useUtils()

// Prefetch activity data on hover for faster navigation
const handleMouseEnter = useCallback(
(activityId: string) => {
// Prefetch activity with splits data
trpcUtils.activities.getWithSplits.prefetch({ id: activityId })
},
[trpcUtils],
)

if (activities.length === 0) {
return (
<motion.div
Expand Down Expand Up @@ -218,7 +232,10 @@ export function ActivityTable({ activities, className = '' }: ActivityTableProps
variants={itemVariants}
className="group"
>
<Link href={`/activity/${activity.id}`}>
<Link
href={`/activity/${activity.id}`}
onMouseEnter={() => handleMouseEnter(activity.id)}
>
<RippleContainer className="rounded-xl" color="rgba(0, 0, 0, 0.08)">
<motion.div
className="rounded-xl border border-white/20 bg-white/50 px-5 py-4 backdrop-blur-xl backdrop-saturate-150 transition-colors duration-150 hover:bg-white/70 dark:border-white/10 dark:bg-black/20 dark:hover:bg-black/30"
Expand Down
52 changes: 6 additions & 46 deletions src/components/ui/skeleton.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
/**
* Skeleton Component
*
* Animated loading skeleton with shimmer effect
* Respects user's reduced motion preferences
* CSS-only loading skeleton with shimmer effect
* Automatically respects user's reduced motion preferences via CSS media query
*/

'use client'

import { motion } from 'framer-motion'

import { useReducedMotion } from '@/hooks/use-reduced-motion'
import { shimmerVariants } from '@/lib/animation/variants'
import { cn } from '@/lib/utils'

export interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
Expand All @@ -34,18 +28,14 @@ export function Skeleton({
variant = 'default',
width,
height,
onAnimationStart,
onAnimationEnd,
...props
}: SkeletonProps) {
const prefersReducedMotion = useReducedMotion()

const styles = {
width: width || undefined,
height: height || undefined,
}

const baseClasses = 'relative overflow-hidden bg-fill'
const baseClasses = 'relative overflow-hidden bg-fill animate-pulse'

const variantClasses = {
default: 'rounded-xl',
Expand All @@ -54,42 +44,12 @@ export function Skeleton({
rectangular: 'rounded-2xl',
}

// Use simple pulse for reduced motion
if (prefersReducedMotion) {
return (
<div
className={cn(baseClasses, variantClasses[variant], 'animate-pulse', className)}
style={styles}
{...props}
/>
)
}

return (
<motion.div
<div
className={cn(baseClasses, variantClasses[variant], className)}
style={styles}
variants={shimmerVariants}
initial="initial"
animate="animate"
{...(props as any)}
>
<motion.div
className="absolute inset-0 -translate-x-full"
style={{
background:
'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.1) 50%, transparent 100%)',
}}
animate={{
x: ['0%', '200%'],
}}
transition={{
duration: 1.5,
repeat: Infinity,
ease: 'linear',
}}
/>
</motion.div>
{...props}
/>
)
}

Expand Down
4 changes: 2 additions & 2 deletions src/lib/art/fingerprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ export function drawFingerprint(
const rotationsPerKm = 0.5 // Each km = half rotation (180Β°)
const totalRotations = rings.length * rotationsPerKm
const startRadius = rings[0].innerRadius
const endRadius = rings[rings.length - 1].outerRadius
const endRadius = rings.at(-1)!.outerRadius
const totalAngle = totalRotations * Math.PI * 2

// High resolution for smooth spiral
Expand Down Expand Up @@ -382,7 +382,7 @@ export function drawFingerprintAnimated(
const rotationsPerKm = 0.5
const totalRotations = rings.length * rotationsPerKm
const startRadius = rings[0].innerRadius
const endRadius = rings[rings.length - 1].outerRadius
const endRadius = rings.at(-1)!.outerRadius
const totalAngle = totalRotations * Math.PI * 2

const pointsPerRotation = 120
Expand Down
4 changes: 3 additions & 1 deletion src/lib/trpc/Provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ export function TRPCProvider({ children }: { children: React.ReactNode }) {
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 1000, // 5 seconds
// 60 seconds - running activity data doesn't change frequently
// Reason: Reduces unnecessary refetches, previous 5s was too aggressive
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
},
},
Expand Down