[{"data":1,"prerenderedAt":1995},["ShallowReactive",2],{"navigation":3,"/projects/splitlog":14,"/projects/splitlog-surround":1984},[4],{"title":5,"path":6,"stem":7,"children":8,"page":13},"Blog","/blog","blog",[9],{"title":10,"path":11,"stem":12},"My Experience as a Junior Software Engineer (First Year)","/blog/my-experience-as-a-junior-software-engineer","blog/my-experience-as-a-junior-software-engineer",false,{"id":15,"title":16,"body":17,"buttonLabel":1966,"date":1967,"description":1968,"extension":1969,"image":1970,"meta":1971,"navigation":700,"path":1972,"seo":1973,"stem":1974,"tags":1975,"__hash__":1983},"projects/projects/splitlog.md","Splitlog — Personal Run Management Platform",{"type":18,"value":19,"toc":1937},"minimark",[20,25,29,48,51,54,57,63,66,70,73,108,113,116,118,122,125,128,131,136,139,142,144,148,155,161,167,173,179,181,185,190,195,198,201,206,209,229,234,237,254,257,262,267,270,296,301,304,312,315,320,323,348,351,353,357,361,364,369,394,397,403,513,516,520,523,526,531,837,840,845,848,851,979,983,1004,1007,1010,1014,1020,1250,1256,1267,1533,1535,1539,1543,1546,1549,1580,1585,1588,1592,1595,1598,1616,1619,1623,1626,1629,1640,1643,1647,1650,1653,1667,1672,1674,1678,1682,1688,1694,1698,1724,1728,1731,1763,1765,1769,1829,1831,1835,1838,1843,1846,1857,1860,1863,1871,1879,1884,1887,1890,1892,1896,1899,1902,1922,1927,1930,1933],[21,22,24],"h2",{"id":23},"the-backstory","THE BACKSTORY",[26,27,28],"p",{},"Every time I finished a run, I had a ritual:",[30,31,32,36,39,42,45],"ol",{},[33,34,35],"li",{},"Open Strava on my phone",[33,37,38],{},"Read off the distance, time, pace, heart rate",[33,40,41],{},"Open Notion",[33,43,44],{},"Find my training plan database",[33,46,47],{},"Manually type every stat into the right row",[26,49,50],{},"Every. Single. Run.",[26,52,53],{},"It was tedious. It was error-prone. And honestly, after a hard\nworkout — the last thing I wanted to do was data entry.",[26,55,56],{},"So I did what any developer would do.",[26,58,59],{},[60,61,62],"strong",{},"I built something to fix it.",[64,65],"hr",{},[21,67,69],{"id":68},"the-challenge","THE CHALLENGE",[26,71,72],{},"This wasn't just a personal productivity problem.\nIt was also my biggest technical challenge to date:",[74,75,76,83,89,95,101],"ul",{},[33,77,78,79,82],{},"🆕 ",[60,80,81],{},"First time with Next.js"," — I had 2 years of Vue/Nuxt\nprofessionally, but React and Next.js were new territory",[33,84,78,85,88],{},[60,86,87],{},"First time with Supabase"," — never built auth or a\ndatabase layer from scratch before",[33,90,78,91,94],{},[60,92,93],{},"First time with real API OAuth"," — Strava's OAuth 2.0\nflow, webhooks, token refresh",[33,96,78,97,100],{},[60,98,99],{},"First full-stack project"," — frontend, backend API routes,\ndatabase, deployment. All me.",[33,102,103,104,107],{},"🤖 ",[60,105,106],{},"First time thinking about AI integration"," — planning\nan AI coaching feature mid-build",[26,109,110],{},[60,111,112],{},"Almost every technology in this project was new to me.",[26,114,115],{},"I used AI tools (Claude) heavily to accelerate learning — but like\nmy previous projects, I made sure to understand every line.\nI'd ask \"why does this pattern work?\" before moving on.",[64,117],{},[21,119,121],{"id":120},"the-aha-moment","THE AHA MOMENT",[26,123,124],{},"I want to be honest about this.",[26,126,127],{},"There were a lot of \"first times\" in this project.\nFirst time setting up Supabase RLS policies.\nFirst time handling OAuth token refresh.\nFirst time building a webhook endpoint.",[26,129,130],{},"But the moment that made everything worth it?",[26,132,133],{},[60,134,135],{},"I finished a run. Opened my app. And it was already there.",[26,137,138],{},"Distance, pace, heart rate, elevation — all synced automatically\nthe moment Strava recorded my activity. No copy-pasting.\nNo manual entry. Just my data, in my own platform,\nthat I built from scratch.",[26,140,141],{},"That's when I understood what it meant to build something\nthat actually solves a real problem.",[64,143],{},[21,145,147],{"id":146},"the-product","THE PRODUCT",[26,149,150],{},[151,152],"img",{"alt":153,"src":154},"Splitlog Dashboard — real run data synced automatically","/projects/splitlog/dashboard.png",[26,156,157],{},[151,158],{"alt":159,"src":160},"Training Plan Manager with progress tracking","/projects/splitlog/training-plan.png",[26,162,163],{},[151,164],{"alt":165,"src":166},"Assign Run modal — one click to link a Strava run to a training day","/projects/splitlog/assign-run.png",[26,168,169],{},[151,170],{"alt":171,"src":172},"Analytics — training heatmap and pace trend","/projects/splitlog/analytics.png",[26,174,175],{},[151,176],{"alt":177,"src":178},"Personal Bests — auto-calculated from Strava data","/projects/splitlog/personal-bests.png",[64,180],{},[21,182,184],{"id":183},"what-i-built","WHAT I BUILT",[186,187,189],"h3",{"id":188},"core-features","Core Features",[26,191,192],{},[60,193,194],{},"1. Strava Auto-Sync via Webhooks",[26,196,197],{},"The heart of the whole app. When you finish a run,\nStrava sends a webhook event to my server within seconds.\nMy app catches it, fetches the full activity details,\nand saves everything to the database automatically.",[26,199,200],{},"No manual sync. No polling. Just instant, real data.",[26,202,203],{},[60,204,205],{},"2. Run Dashboard with TanStack Table",[26,207,208],{},"A powerful, filterable table of all your runs —\nsomething Strava's own UI makes surprisingly difficult.",[74,210,211,214,217,220,223,226],{},[33,212,213],{},"Sort by distance, pace, date, heart rate, elevation",[33,215,216],{},"Filter by distance range (under 5km, 5–10km, half marathon, etc.)",[33,218,219],{},"Filter by time period (last 30 days, 3 months, all time)",[33,221,222],{},"Global search across all runs",[33,224,225],{},"Pagination with configurable page size",[33,227,228],{},"Stats cards showing total runs, total distance,\nlongest run, total elevation",[26,230,231],{},[60,232,233],{},"3. Training Plan Manager",[26,235,236],{},"Create training plans (like \"Training for 1st Marathon\")\nwith scheduled workout days. Each day has:",[74,238,239,242,245,248,251],{},[33,240,241],{},"Workout type (Easy Run, Tempo, Long Run, Interval, Rest)",[33,243,244],{},"Target distance",[33,246,247],{},"Scheduled date",[33,249,250],{},"Notes",[33,252,253],{},"Assigned Strava run with auto-filled stats",[26,255,256],{},"The assign flow is the feature I'm most proud of:\nclick \"Assign run\" on any training day → modal shows all\nyour Strava runs with stats → click one →\ndistance, time, pace, HR all auto-fill instantly.",[26,258,259],{},[60,260,261],{},"That replaced my entire Notion workflow.",[26,263,264],{},[60,265,266],{},"4. Analytics Page",[26,268,269],{},"Four charts built with Recharts:",[74,271,272,278,284,290],{},[33,273,274,277],{},[60,275,276],{},"Training Heatmap"," — GitHub-style calendar showing\nwhich days you ran over the past year.\nDarker = longer run. Current streak tracked.",[33,279,280,283],{},[60,281,282],{},"Pace Trend"," — line chart showing if you're\ngetting faster over time (inverted Y-axis\nbecause lower pace = faster)",[33,285,286,289],{},[60,287,288],{},"Heart Rate Zones"," — donut chart showing\ntime spent in each of 5 HR zones",[33,291,292,295],{},[60,293,294],{},"Distance Distribution"," — bar chart grouping\nruns by distance range",[26,297,298],{},[60,299,300],{},"5. Personal Bests",[26,302,303],{},"Two dark cards showing your records:",[74,305,306,309],{},[33,307,308],{},"Race PRs (5K, 10K, Half Marathon, Marathon) —\nauto-calculated from your actual Strava data\nOR manually entered from official race results",[33,310,311],{},"Activity Records (Longest Run, Most Elevation,\nHighest Max HR, Best Week)",[26,313,314],{},"Manual official PRs override auto-calculated ones\nand show an \"Official\" badge.",[26,316,317],{},[60,318,319],{},"6. User Profile",[26,321,322],{},"Settings page with tabbed navigation:",[74,324,325,331,337,343],{},[33,326,327,330],{},[60,328,329],{},"Profile"," — personal info, running profile,\nofficial race PRs, avatar picker",[33,332,333,336],{},[60,334,335],{},"Integrations"," — Strava connect + sync",[33,338,339,342],{},[60,340,341],{},"Preferences"," — coming soon",[33,344,345,342],{},[60,346,347],{},"Data",[26,349,350],{},"Avatar picker has 18 preset avatars across 3 categories\n(Runners, Animals, Vibes). No file uploads —\nclean and simple.",[64,352],{},[21,354,356],{"id":355},"the-technical-deep-dive","THE TECHNICAL DEEP DIVE",[186,358,360],{"id":359},"_1-strava-oauth-20-webhook-pipeline","1. Strava OAuth 2.0 + Webhook Pipeline",[26,362,363],{},"This was the most technically complex part of the whole project.",[26,365,366],{},[60,367,368],{},"The OAuth flow:",[26,370,371,372,375,376,378,379,381,382,384,385,387,388,390,391,393],{},"You finish a run on Strava ",[373,374],"br",{},"\n→ Strava POST /api/webhooks/strava (within seconds) ",[373,377],{},"\n→ validate webhook signature ",[373,380],{},"\n→ fetch full activity from Strava API ",[373,383],{},"\n→ filter: only save runs (not rides, swims, etc.) ",[373,386],{},"\n→ transform Strava data → our DB schema ",[373,389],{},"\n→ upsert to Supabase activities table ",[373,392],{},"\n→ appears in dashboard automatically",[26,395,396],{},"The hardest bug I fixed: the webhook handler was querying\nSupabase using the regular client — but webhooks run\nwithout an authenticated user session.\nRLS was blocking the query silently.",[26,398,399,402],{},[60,400,401],{},"Fix:"," Created a separate service role client\nthat bypasses RLS for server-side operations:",[404,405,410],"pre",{"className":406,"code":407,"language":408,"meta":409,"style":409},"language-ts shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","// lib/supabase/service.ts\nexport function createServiceClient() {\n  return createClient(\n    process.env.NEXT_PUBLIC_SUPABASE_URL!,\n    process.env.SUPABASE_SERVICE_ROLE_KEY!, // bypasses RLS\n  );\n}\n","ts","",[411,412,413,422,444,457,478,498,507],"code",{"__ignoreMap":409},[414,415,418],"span",{"class":416,"line":417},"line",1,[414,419,421],{"class":420},"sHwdD","// lib/supabase/service.ts\n",[414,423,425,429,433,437,441],{"class":416,"line":424},2,[414,426,428],{"class":427},"s7zQu","export",[414,430,432],{"class":431},"spNyl"," function",[414,434,436],{"class":435},"s2Zo4"," createServiceClient",[414,438,440],{"class":439},"sMK4o","()",[414,442,443],{"class":439}," {\n",[414,445,447,450,453],{"class":416,"line":446},3,[414,448,449],{"class":427},"  return",[414,451,452],{"class":435}," createClient",[414,454,456],{"class":455},"swJcz","(\n",[414,458,460,464,467,470,472,475],{"class":416,"line":459},4,[414,461,463],{"class":462},"sTEyZ","    process",[414,465,466],{"class":439},".",[414,468,469],{"class":462},"env",[414,471,466],{"class":439},[414,473,474],{"class":462},"NEXT_PUBLIC_SUPABASE_URL",[414,476,477],{"class":439},"!,\n",[414,479,481,483,485,487,489,492,495],{"class":416,"line":480},5,[414,482,463],{"class":462},[414,484,466],{"class":439},[414,486,469],{"class":462},[414,488,466],{"class":439},[414,490,491],{"class":462},"SUPABASE_SERVICE_ROLE_KEY",[414,493,494],{"class":439},"!,",[414,496,497],{"class":420}," // bypasses RLS\n",[414,499,501,504],{"class":416,"line":500},6,[414,502,503],{"class":455},"  )",[414,505,506],{"class":439},";\n",[414,508,510],{"class":416,"line":509},7,[414,511,512],{"class":439},"}\n",[26,514,515],{},"That one change made everything work.",[186,517,519],{"id":518},"_2-tanstack-table-headless-table-with-custom-filters","2. TanStack Table — Headless Table with Custom Filters",[26,521,522],{},"I chose TanStack Table v8 because I needed full control\nover sorting, filtering, grouping, and pagination\nwithout fighting a pre-styled component library.",[26,524,525],{},"The learning curve was steep. But the power is real.",[26,527,528],{},[60,529,530],{},"Custom filter functions for non-standard comparisons:",[404,532,534],{"className":406,"code":533,"language":408,"meta":409,"style":409},"// Distance range filter — checks if value is within [min, max]\nconst rangeFilter: FilterFn\u003CRun> = (row, columnId, filterValue) => {\n  const val = row.getValue(columnId) as number;\n  const [min, max] = filterValue as [number, number];\n  return val >= min && val \u003C= max;\n};\n\n// Date filter — checks if run is after a cutoff date\nconst dateAfterFilter: FilterFn\u003CRun> = (row, columnId, filterValue) => {\n  const val = row.getValue(columnId) as string;\n  if (!val) return false;\n  return new Date(val) >= new Date(filterValue);\n};\n",[411,535,536,541,594,629,667,691,696,702,708,746,774,799,832],{"__ignoreMap":409},[414,537,538],{"class":416,"line":417},[414,539,540],{"class":420},"// Distance range filter — checks if value is within [min, max]\n",[414,542,543,546,549,552,556,559,562,565,568,571,575,578,581,583,586,589,592],{"class":416,"line":424},[414,544,545],{"class":431},"const",[414,547,548],{"class":462}," rangeFilter",[414,550,551],{"class":439},":",[414,553,555],{"class":554},"sBMFI"," FilterFn",[414,557,558],{"class":439},"\u003C",[414,560,561],{"class":554},"Run",[414,563,564],{"class":439},">",[414,566,567],{"class":439}," =",[414,569,570],{"class":439}," (",[414,572,574],{"class":573},"sHdIc","row",[414,576,577],{"class":439},",",[414,579,580],{"class":573}," columnId",[414,582,577],{"class":439},[414,584,585],{"class":573}," filterValue",[414,587,588],{"class":439},")",[414,590,591],{"class":431}," =>",[414,593,443],{"class":439},[414,595,596,599,602,604,607,609,612,615,618,621,624,627],{"class":416,"line":446},[414,597,598],{"class":431},"  const",[414,600,601],{"class":462}," val",[414,603,567],{"class":439},[414,605,606],{"class":462}," row",[414,608,466],{"class":439},[414,610,611],{"class":435},"getValue",[414,613,614],{"class":455},"(",[414,616,617],{"class":462},"columnId",[414,619,620],{"class":455},") ",[414,622,623],{"class":427},"as",[414,625,626],{"class":554}," number",[414,628,506],{"class":439},[414,630,631,633,636,639,641,644,647,649,651,654,656,659,661,663,665],{"class":416,"line":459},[414,632,598],{"class":431},[414,634,635],{"class":439}," [",[414,637,638],{"class":462},"min",[414,640,577],{"class":439},[414,642,643],{"class":462}," max",[414,645,646],{"class":439},"]",[414,648,567],{"class":439},[414,650,585],{"class":462},[414,652,653],{"class":427}," as",[414,655,635],{"class":455},[414,657,658],{"class":554},"number",[414,660,577],{"class":439},[414,662,626],{"class":554},[414,664,646],{"class":455},[414,666,506],{"class":439},[414,668,669,671,673,676,679,682,684,687,689],{"class":416,"line":480},[414,670,449],{"class":427},[414,672,601],{"class":462},[414,674,675],{"class":439}," >=",[414,677,678],{"class":462}," min",[414,680,681],{"class":439}," &&",[414,683,601],{"class":462},[414,685,686],{"class":439}," \u003C=",[414,688,643],{"class":462},[414,690,506],{"class":439},[414,692,693],{"class":416,"line":500},[414,694,695],{"class":439},"};\n",[414,697,698],{"class":416,"line":509},[414,699,701],{"emptyLinePlaceholder":700},true,"\n",[414,703,705],{"class":416,"line":704},8,[414,706,707],{"class":420},"// Date filter — checks if run is after a cutoff date\n",[414,709,711,713,716,718,720,722,724,726,728,730,732,734,736,738,740,742,744],{"class":416,"line":710},9,[414,712,545],{"class":431},[414,714,715],{"class":462}," dateAfterFilter",[414,717,551],{"class":439},[414,719,555],{"class":554},[414,721,558],{"class":439},[414,723,561],{"class":554},[414,725,564],{"class":439},[414,727,567],{"class":439},[414,729,570],{"class":439},[414,731,574],{"class":573},[414,733,577],{"class":439},[414,735,580],{"class":573},[414,737,577],{"class":439},[414,739,585],{"class":573},[414,741,588],{"class":439},[414,743,591],{"class":431},[414,745,443],{"class":439},[414,747,749,751,753,755,757,759,761,763,765,767,769,772],{"class":416,"line":748},10,[414,750,598],{"class":431},[414,752,601],{"class":462},[414,754,567],{"class":439},[414,756,606],{"class":462},[414,758,466],{"class":439},[414,760,611],{"class":435},[414,762,614],{"class":455},[414,764,617],{"class":462},[414,766,620],{"class":455},[414,768,623],{"class":427},[414,770,771],{"class":554}," string",[414,773,506],{"class":439},[414,775,777,780,782,785,788,790,793,797],{"class":416,"line":776},11,[414,778,779],{"class":427},"  if",[414,781,570],{"class":455},[414,783,784],{"class":439},"!",[414,786,787],{"class":462},"val",[414,789,620],{"class":455},[414,791,792],{"class":427},"return",[414,794,796],{"class":795},"sfNiH"," false",[414,798,506],{"class":439},[414,800,802,804,807,810,812,814,816,819,821,823,825,828,830],{"class":416,"line":801},12,[414,803,449],{"class":427},[414,805,806],{"class":439}," new",[414,808,809],{"class":435}," Date",[414,811,614],{"class":455},[414,813,787],{"class":462},[414,815,620],{"class":455},[414,817,818],{"class":439},">=",[414,820,806],{"class":439},[414,822,809],{"class":435},[414,824,614],{"class":455},[414,826,827],{"class":462},"filterValue",[414,829,588],{"class":455},[414,831,506],{"class":439},[414,833,835],{"class":416,"line":834},13,[414,836,695],{"class":439},[26,838,839],{},"These let me build the distance range and time period\ndropdowns that filter the table instantly.",[26,841,842],{},[60,843,844],{},"The stats cards react to filters too:",[26,846,847],{},"When you filter to \"Last 30 days\" — the stats cards\n(Total Runs, Total Distance, etc.) update to reflect\nonly those filtered runs, not all runs.",[26,849,850],{},"I achieved this by passing filtered rows back up to\nthe parent via a callback:",[404,852,854],{"className":406,"code":853,"language":408,"meta":409,"style":409},"useEffect(() => {\n  if (onFilteredRowsChange) {\n    const filteredRows = table\n      .getFilteredRowModel()\n      .rows.map((row) => row.original);\n    onFilteredRowsChange(filteredRows);\n  }\n}, [columnFilters, globalFilter, data]);\n",[411,855,856,869,883,896,907,940,954,959],{"__ignoreMap":409},[414,857,858,861,863,865,867],{"class":416,"line":417},[414,859,860],{"class":435},"useEffect",[414,862,614],{"class":462},[414,864,440],{"class":439},[414,866,591],{"class":431},[414,868,443],{"class":439},[414,870,871,873,875,878,880],{"class":416,"line":424},[414,872,779],{"class":427},[414,874,570],{"class":455},[414,876,877],{"class":462},"onFilteredRowsChange",[414,879,620],{"class":455},[414,881,882],{"class":439},"{\n",[414,884,885,888,891,893],{"class":416,"line":446},[414,886,887],{"class":431},"    const",[414,889,890],{"class":462}," filteredRows",[414,892,567],{"class":439},[414,894,895],{"class":462}," table\n",[414,897,898,901,904],{"class":416,"line":459},[414,899,900],{"class":439},"      .",[414,902,903],{"class":435},"getFilteredRowModel",[414,905,906],{"class":455},"()\n",[414,908,909,911,914,916,919,921,923,925,927,929,931,933,936,938],{"class":416,"line":480},[414,910,900],{"class":439},[414,912,913],{"class":462},"rows",[414,915,466],{"class":439},[414,917,918],{"class":435},"map",[414,920,614],{"class":455},[414,922,614],{"class":439},[414,924,574],{"class":573},[414,926,588],{"class":439},[414,928,591],{"class":431},[414,930,606],{"class":462},[414,932,466],{"class":439},[414,934,935],{"class":462},"original",[414,937,588],{"class":455},[414,939,506],{"class":439},[414,941,942,945,947,950,952],{"class":416,"line":500},[414,943,944],{"class":435},"    onFilteredRowsChange",[414,946,614],{"class":455},[414,948,949],{"class":462},"filteredRows",[414,951,588],{"class":455},[414,953,506],{"class":439},[414,955,956],{"class":416,"line":509},[414,957,958],{"class":439},"  }\n",[414,960,961,964,967,969,972,974,977],{"class":416,"line":704},[414,962,963],{"class":439},"},",[414,965,966],{"class":462}," [columnFilters",[414,968,577],{"class":439},[414,970,971],{"class":462}," globalFilter",[414,973,577],{"class":439},[414,975,976],{"class":462}," data])",[414,978,506],{"class":439},[186,980,982],{"id":981},"_3-database-design-with-row-level-security","3. Database Design with Row Level Security",[26,984,985,986,988,989,991,992,994,995,997,998,1000,1001,1003],{},"Five tables, all with Supabase RLS enabled:\n",[373,987],{},"\nusers → extends Supabase auth ",[373,990],{},"\ntraining_plans → user's training blocks ",[373,993],{},"\ntraining_plan_days → scheduled workout days",[373,996],{},"\nactivities → synced from Strava",[373,999],{},"\nplan_activity_map → links runs to plan days",[373,1002],{},"\nuser_profiles → personal info + PRs + avatar",[26,1005,1006],{},"RLS policies mean every query is automatically\nscoped to the logged-in user at the database level —\nnot just in application code.",[26,1008,1009],{},"This makes the app SaaS-ready even though\nit's currently personal use only.",[186,1011,1013],{"id":1012},"_4-personal-bests-two-layer-system","4. Personal Bests — Two Layer System",[26,1015,1016,1019],{},[60,1017,1018],{},"Layer 1 (automatic):"," Calculates PRs from\nexisting Strava data using distance range matching:",[404,1021,1023],{"className":406,"code":1022,"language":408,"meta":409,"style":409},"function findBestEffort(runs, minKm, maxKm) {\n  return runs\n    .filter(\n      (r) =>\n        r.distance_km >= minKm &&\n        r.distance_km \u003C= maxKm &&\n        r.avg_pace_sec_per_km > 0,\n    )\n    .sort((a, b) => a.avg_pace_sec_per_km - b.avg_pace_sec_per_km)[0];\n}\n\nconst best5k = findBestEffort(runs, 4.8, 5.5);\nconst best10k = findBestEffort(runs, 9.5, 11.0);\n",[411,1024,1025,1052,1059,1069,1082,1099,1113,1132,1137,1186,1190,1194,1223],{"__ignoreMap":409},[414,1026,1027,1030,1033,1035,1038,1040,1043,1045,1048,1050],{"class":416,"line":417},[414,1028,1029],{"class":431},"function",[414,1031,1032],{"class":435}," findBestEffort",[414,1034,614],{"class":439},[414,1036,1037],{"class":573},"runs",[414,1039,577],{"class":439},[414,1041,1042],{"class":573}," minKm",[414,1044,577],{"class":439},[414,1046,1047],{"class":573}," maxKm",[414,1049,588],{"class":439},[414,1051,443],{"class":439},[414,1053,1054,1056],{"class":416,"line":424},[414,1055,449],{"class":427},[414,1057,1058],{"class":462}," runs\n",[414,1060,1061,1064,1067],{"class":416,"line":446},[414,1062,1063],{"class":439},"    .",[414,1065,1066],{"class":435},"filter",[414,1068,456],{"class":455},[414,1070,1071,1074,1077,1079],{"class":416,"line":459},[414,1072,1073],{"class":439},"      (",[414,1075,1076],{"class":573},"r",[414,1078,588],{"class":439},[414,1080,1081],{"class":431}," =>\n",[414,1083,1084,1087,1089,1092,1094,1096],{"class":416,"line":480},[414,1085,1086],{"class":462},"        r",[414,1088,466],{"class":439},[414,1090,1091],{"class":462},"distance_km",[414,1093,675],{"class":439},[414,1095,1042],{"class":462},[414,1097,1098],{"class":439}," &&\n",[414,1100,1101,1103,1105,1107,1109,1111],{"class":416,"line":500},[414,1102,1086],{"class":462},[414,1104,466],{"class":439},[414,1106,1091],{"class":462},[414,1108,686],{"class":439},[414,1110,1047],{"class":462},[414,1112,1098],{"class":439},[414,1114,1115,1117,1119,1122,1125,1129],{"class":416,"line":509},[414,1116,1086],{"class":462},[414,1118,466],{"class":439},[414,1120,1121],{"class":462},"avg_pace_sec_per_km",[414,1123,1124],{"class":439}," >",[414,1126,1128],{"class":1127},"sbssI"," 0",[414,1130,1131],{"class":439},",\n",[414,1133,1134],{"class":416,"line":704},[414,1135,1136],{"class":455},"    )\n",[414,1138,1139,1141,1144,1146,1148,1151,1153,1156,1158,1160,1163,1165,1167,1170,1172,1174,1176,1179,1182,1184],{"class":416,"line":710},[414,1140,1063],{"class":439},[414,1142,1143],{"class":435},"sort",[414,1145,614],{"class":455},[414,1147,614],{"class":439},[414,1149,1150],{"class":573},"a",[414,1152,577],{"class":439},[414,1154,1155],{"class":573}," b",[414,1157,588],{"class":439},[414,1159,591],{"class":431},[414,1161,1162],{"class":462}," a",[414,1164,466],{"class":439},[414,1166,1121],{"class":462},[414,1168,1169],{"class":439}," -",[414,1171,1155],{"class":462},[414,1173,466],{"class":439},[414,1175,1121],{"class":462},[414,1177,1178],{"class":455},")[",[414,1180,1181],{"class":1127},"0",[414,1183,646],{"class":455},[414,1185,506],{"class":439},[414,1187,1188],{"class":416,"line":748},[414,1189,512],{"class":439},[414,1191,1192],{"class":416,"line":776},[414,1193,701],{"emptyLinePlaceholder":700},[414,1195,1196,1198,1201,1204,1206,1209,1211,1214,1216,1219,1221],{"class":416,"line":801},[414,1197,545],{"class":431},[414,1199,1200],{"class":462}," best5k ",[414,1202,1203],{"class":439},"=",[414,1205,1032],{"class":435},[414,1207,1208],{"class":462},"(runs",[414,1210,577],{"class":439},[414,1212,1213],{"class":1127}," 4.8",[414,1215,577],{"class":439},[414,1217,1218],{"class":1127}," 5.5",[414,1220,588],{"class":462},[414,1222,506],{"class":439},[414,1224,1225,1227,1230,1232,1234,1236,1238,1241,1243,1246,1248],{"class":416,"line":834},[414,1226,545],{"class":431},[414,1228,1229],{"class":462}," best10k ",[414,1231,1203],{"class":439},[414,1233,1032],{"class":435},[414,1235,1208],{"class":462},[414,1237,577],{"class":439},[414,1239,1240],{"class":1127}," 9.5",[414,1242,577],{"class":439},[414,1244,1245],{"class":1127}," 11.0",[414,1247,588],{"class":462},[414,1249,506],{"class":439},[26,1251,1252,1255],{},[60,1253,1254],{},"Layer 2 (manual override):"," Official race PRs\nentered in the profile page. These override\nauto-calculated ones and show an \"Official\" badge.",[26,1257,1258,1259,1262,1263,1266],{},"Stored as seconds in the DB, displayed as ",[411,1260,1261],{},"mm:ss","\nor ",[411,1264,1265],{},"h:mm:ss"," — converted with a utility function:",[404,1268,1270],{"className":406,"code":1269,"language":408,"meta":409,"style":409},"export function secondsToTimeString(seconds) {\n  const h = Math.floor(seconds / 3600);\n  const m = Math.floor((seconds % 3600) / 60);\n  const s = seconds % 60;\n  if (h > 0)\n    return `${h}:${m.toString().padStart(2, \"0\")}:\n                     ${s.toString().padStart(2, \"0\")}`;\n  return `${m}:${s.toString().padStart(2, \"0\")}`;\n}\n",[411,1271,1272,1290,1321,1358,1376,1392,1448,1485,1529],{"__ignoreMap":409},[414,1273,1274,1276,1278,1281,1283,1286,1288],{"class":416,"line":417},[414,1275,428],{"class":427},[414,1277,432],{"class":431},[414,1279,1280],{"class":435}," secondsToTimeString",[414,1282,614],{"class":439},[414,1284,1285],{"class":573},"seconds",[414,1287,588],{"class":439},[414,1289,443],{"class":439},[414,1291,1292,1294,1297,1299,1302,1304,1307,1309,1311,1314,1317,1319],{"class":416,"line":424},[414,1293,598],{"class":431},[414,1295,1296],{"class":462}," h",[414,1298,567],{"class":439},[414,1300,1301],{"class":462}," Math",[414,1303,466],{"class":439},[414,1305,1306],{"class":435},"floor",[414,1308,614],{"class":455},[414,1310,1285],{"class":462},[414,1312,1313],{"class":439}," /",[414,1315,1316],{"class":1127}," 3600",[414,1318,588],{"class":455},[414,1320,506],{"class":439},[414,1322,1323,1325,1328,1330,1332,1334,1336,1339,1341,1344,1346,1348,1351,1354,1356],{"class":416,"line":446},[414,1324,598],{"class":431},[414,1326,1327],{"class":462}," m",[414,1329,567],{"class":439},[414,1331,1301],{"class":462},[414,1333,466],{"class":439},[414,1335,1306],{"class":435},[414,1337,1338],{"class":455},"((",[414,1340,1285],{"class":462},[414,1342,1343],{"class":439}," %",[414,1345,1316],{"class":1127},[414,1347,620],{"class":455},[414,1349,1350],{"class":439},"/",[414,1352,1353],{"class":1127}," 60",[414,1355,588],{"class":455},[414,1357,506],{"class":439},[414,1359,1360,1362,1365,1367,1370,1372,1374],{"class":416,"line":459},[414,1361,598],{"class":431},[414,1363,1364],{"class":462}," s",[414,1366,567],{"class":439},[414,1368,1369],{"class":462}," seconds",[414,1371,1343],{"class":439},[414,1373,1353],{"class":1127},[414,1375,506],{"class":439},[414,1377,1378,1380,1382,1385,1387,1389],{"class":416,"line":480},[414,1379,779],{"class":427},[414,1381,570],{"class":455},[414,1383,1384],{"class":462},"h",[414,1386,1124],{"class":439},[414,1388,1128],{"class":1127},[414,1390,1391],{"class":455},")\n",[414,1393,1394,1397,1400,1402,1405,1408,1411,1414,1416,1419,1421,1423,1426,1428,1431,1433,1436,1438,1441,1443,1445],{"class":416,"line":500},[414,1395,1396],{"class":427},"    return",[414,1398,1399],{"class":439}," `${",[414,1401,1384],{"class":462},[414,1403,1404],{"class":439},"}",[414,1406,551],{"class":1407},"sfazB",[414,1409,1410],{"class":439},"${",[414,1412,1413],{"class":462},"m",[414,1415,466],{"class":439},[414,1417,1418],{"class":435},"toString",[414,1420,440],{"class":462},[414,1422,466],{"class":439},[414,1424,1425],{"class":435},"padStart",[414,1427,614],{"class":462},[414,1429,1430],{"class":1127},"2",[414,1432,577],{"class":439},[414,1434,1435],{"class":439}," \"",[414,1437,1181],{"class":1407},[414,1439,1440],{"class":439},"\"",[414,1442,588],{"class":462},[414,1444,1404],{"class":439},[414,1446,1447],{"class":1407},":\n",[414,1449,1450,1453,1456,1458,1460,1462,1464,1466,1468,1470,1472,1474,1476,1478,1480,1483],{"class":416,"line":509},[414,1451,1452],{"class":439},"                     ${",[414,1454,1455],{"class":462},"s",[414,1457,466],{"class":439},[414,1459,1418],{"class":435},[414,1461,440],{"class":462},[414,1463,466],{"class":439},[414,1465,1425],{"class":435},[414,1467,614],{"class":462},[414,1469,1430],{"class":1127},[414,1471,577],{"class":439},[414,1473,1435],{"class":439},[414,1475,1181],{"class":1407},[414,1477,1440],{"class":439},[414,1479,588],{"class":462},[414,1481,1482],{"class":439},"}`",[414,1484,506],{"class":439},[414,1486,1487,1489,1491,1493,1495,1497,1499,1501,1503,1505,1507,1509,1511,1513,1515,1517,1519,1521,1523,1525,1527],{"class":416,"line":704},[414,1488,449],{"class":427},[414,1490,1399],{"class":439},[414,1492,1413],{"class":462},[414,1494,1404],{"class":439},[414,1496,551],{"class":1407},[414,1498,1410],{"class":439},[414,1500,1455],{"class":462},[414,1502,466],{"class":439},[414,1504,1418],{"class":435},[414,1506,440],{"class":462},[414,1508,466],{"class":439},[414,1510,1425],{"class":435},[414,1512,614],{"class":462},[414,1514,1430],{"class":1127},[414,1516,577],{"class":439},[414,1518,1435],{"class":439},[414,1520,1181],{"class":1407},[414,1522,1440],{"class":439},[414,1524,588],{"class":462},[414,1526,1482],{"class":439},[414,1528,506],{"class":439},[414,1530,1531],{"class":416,"line":710},[414,1532,512],{"class":439},[64,1534],{},[21,1536,1538],{"id":1537},"what-i-learned","WHAT I LEARNED",[186,1540,1542],{"id":1541},"_1-new-tech-doesnt-have-to-be-scary","1. New Tech Doesn't Have to Be Scary",[26,1544,1545],{},"I came from Vue/Nuxt. React and Next.js felt foreign at first.",[26,1547,1548],{},"But the mental models transferred:",[74,1550,1551,1561,1571,1577],{},[33,1552,1553,1554,1557,1558],{},"Vue ",[411,1555,1556],{},"ref"," → React ",[411,1559,1560],{},"useState",[33,1562,1563,1564,1567,1568],{},"Nuxt ",[411,1565,1566],{},"useFetch"," → TanStack Query ",[411,1569,1570],{},"useQuery",[33,1572,1553,1573,1576],{},[411,1574,1575],{},"composables"," → React custom hooks",[33,1578,1579],{},"Nuxt server routes → Next.js API routes",[26,1581,1582],{},[60,1583,1584],{},"The concepts were the same. Just different syntax.",[26,1586,1587],{},"By week 2 of building Splitlog, Next.js felt natural.\nBy week 4, I preferred the App Router pattern\nover anything I'd used before.",[186,1589,1591],{"id":1590},"_2-debugging-real-integrations-is-different","2. Debugging Real Integrations is Different",[26,1593,1594],{},"You can't mock a Strava webhook in your head.\nAt some point you have to run it and see what breaks.",[26,1596,1597],{},"I learned to:",[74,1599,1600,1603,1610,1613],{},[33,1601,1602],{},"Log everything during development",[33,1604,1605,1606,1609],{},"Read error codes carefully\n(",[411,1607,1608],{},"PGRST116"," = row not found in Supabase)",[33,1611,1612],{},"Check the obvious things first\n(is RLS blocking this? is the token expired?)",[33,1614,1615],{},"Trust the data, not my assumptions",[26,1617,1618],{},"The webhook RLS bug took me an hour to debug.\nBut I'll never forget how Supabase RLS works now.",[186,1620,1622],{"id":1621},"_3-ai-assisted-development-is-a-skill","3. AI-Assisted Development is a Skill",[26,1624,1625],{},"I used Claude heavily throughout this project —\nespecially for patterns I'd never seen before\n(OAuth flows, webhook handlers, TanStack Table config).",[26,1627,1628],{},"But I learned that AI assistance is most powerful when:",[74,1630,1631,1634,1637],{},[33,1632,1633],{},"You understand the problem first",[33,1635,1636],{},"You question the generated code",[33,1638,1639],{},"You break down \"why\" it works before moving on",[26,1641,1642],{},"Copy-pasting without understanding is technical debt.\nUnderstanding first, then using AI to accelerate —\nthat's the skill.",[186,1644,1646],{"id":1645},"_4-full-stack-changes-how-you-think","4. Full Stack Changes How You Think",[26,1648,1649],{},"Before Splitlog, I was a frontend developer\nwho consumed APIs built by someone else.",[26,1651,1652],{},"Now I think in systems:",[74,1654,1655,1658,1661,1664],{},[33,1656,1657],{},"How does the data flow from Strava → DB → UI?",[33,1659,1660],{},"What happens when the token expires mid-request?",[33,1662,1663],{},"How does RLS affect this query?",[33,1665,1666],{},"What if two webhook events arrive simultaneously?",[26,1668,1669],{},[60,1670,1671],{},"Full stack thinking made me a better frontend developer too.",[64,1673],{},[21,1675,1677],{"id":1676},"the-results","THE RESULTS",[186,1679,1681],{"id":1680},"what-i-replaced","What I Replaced",[26,1683,1684,1685,1687],{},"Before Splitlog — my post-run workflow:\n",[373,1686],{},"\nFinish run → open Strava → copy stats →\nopen Notion → find training plan →\nmanually type every field → done\n~5 minutes every run, error-prone",[26,1689,1690,1691,1693],{},"After Splitlog:\n",[373,1692],{},"\nFinish run → open Splitlog →\ndata is already there ✓\n~0 minutes, automatic",[186,1695,1697],{"id":1696},"technical-achievements","Technical Achievements",[74,1699,1700,1703,1706,1709,1712,1715,1718,1721],{},[33,1701,1702],{},"✅ Real-time Strava webhook sync\n(data appears within seconds of finishing a run)",[33,1704,1705],{},"✅ OAuth 2.0 with automatic token refresh",[33,1707,1708],{},"✅ Powerful run dashboard replacing Strava's\nlimited activity list",[33,1710,1711],{},"✅ Training plan manager replacing Notion workflow",[33,1713,1714],{},"✅ Analytics with 4 chart types + personal bests",[33,1716,1717],{},"✅ User profile with 2-layer PR system",[33,1719,1720],{},"✅ Mobile responsive",[33,1722,1723],{},"✅ Deployed to production on Vercel",[186,1725,1727],{"id":1726},"for-me","For Me",[26,1729,1730],{},"This project leveled me up across the board:",[74,1732,1733,1739,1745,1751,1757],{},[33,1734,1735,1738],{},[60,1736,1737],{},"Learned Next.js App Router"," from zero\nto production in 6 weeks",[33,1740,1741,1744],{},[60,1742,1743],{},"Built my first full auth system","\n(Supabase + middleware + protected routes)",[33,1746,1747,1750],{},[60,1748,1749],{},"Integrated a real third-party API","\nwith OAuth + webhooks",[33,1752,1753,1756],{},[60,1754,1755],{},"Deployed a full-stack app"," to production",[33,1758,1759,1762],{},[60,1760,1761],{},"Started planning AI features"," —\nnext version will include an AI coach\nthat generates training plans automatically\nbased on your Strava data and race goals",[64,1764],{},[21,1766,1768],{"id":1767},"tech-stack","TECH STACK",[26,1770,1771,1774,1775,1777,1780,1781,1783,1786,1787,1789,1792,1793,1795,1798,1799,1801,1804,1805,1807,1810,1811,1813,1816,1817,1819,1822,1823,1825,1828],{},[60,1772,1773],{},"Frontend:"," Next.js 15 (App Router), TypeScript",[373,1776],{},[60,1778,1779],{},"Styling:"," Tailwind CSS, shadcn/ui",[373,1782],{},[60,1784,1785],{},"Table:"," TanStack Table v8",[373,1788],{},[60,1790,1791],{},"Charts:"," Recharts",[373,1794],{},[60,1796,1797],{},"Data Fetching:"," TanStack Query",[373,1800],{},[60,1802,1803],{},"Database:"," Supabase (PostgreSQL)",[373,1806],{},[60,1808,1809],{},"Auth:"," Supabase Auth + middleware",[373,1812],{},[60,1814,1815],{},"API:"," Next.js API Routes (serverless)",[373,1818],{},[60,1820,1821],{},"Integration:"," Strava API v3 (OAuth 2.0 + Webhooks)",[373,1824],{},[60,1826,1827],{},"Deployment:"," Vercel",[64,1830],{},[21,1832,1834],{"id":1833},"whats-next","WHAT'S NEXT",[26,1836,1837],{},"Splitlog v2 is already planned:",[26,1839,1840],{},[60,1841,1842],{},"AI Coach (In Progress)",[26,1844,1845],{},"An AI coach that generates your full training plan\nautomatically based on:",[74,1847,1848,1851,1854],{},[33,1849,1850],{},"Your target race and goal time",[33,1852,1853],{},"Your current fitness from actual Strava data",[33,1855,1856],{},"Your weekly availability and preferences",[26,1858,1859],{},"No more manually adding training days one by one.\nJust tell the AI \"I want to run a sub-2:00 half marathon\nin 12 weeks\" and it builds the whole plan.",[26,1861,1862],{},"This will use the Claude API with a custom system prompt\ngiving the AI your real run history as context —\nmaking the plans genuinely personalized,\nnot just generic templates.",[26,1864,1865,1868,1870],{},[60,1866,1867],{},"Garmin Support",[373,1869],{},"\nExtend the integration to support Garmin Connect\nalongside Strava.",[26,1872,1873,1876,1878],{},[60,1874,1875],{},"Export to PDF/CSV",[373,1877],{},"\nDownload your training data for offline use or sharing.",[26,1880,1881],{},[60,1882,1883],{},"Mobile App (Future)",[26,1885,1886],{},"A dedicated iOS and Android app built with React Native —\nso you can check your dashboard, review your training plan,\nand assign runs directly from your phone right after\nyou finish a workout.",[26,1888,1889],{},"No more opening a browser. Just open the app,\nsee your run already synced, tap to assign it\nto your training plan, and close it.\nThe full Splitlog experience in your pocket.",[64,1891],{},[21,1893,1895],{"id":1894},"the-bigger-picture","THE BIGGER PICTURE",[26,1897,1898],{},"I started this project because I was frustrated\nwith manual data entry after runs.",[26,1900,1901],{},"I ended up building a full-stack platform with:",[74,1903,1904,1907,1910,1913,1916,1919],{},[33,1905,1906],{},"Real OAuth integrations",[33,1908,1909],{},"Webhook-driven automation",[33,1911,1912],{},"A database with proper security policies",[33,1914,1915],{},"Charts and analytics",[33,1917,1918],{},"A training plan system",[33,1920,1921],{},"AI features on the roadmap",[26,1923,1924],{},[60,1925,1926],{},"This is the project that made me a full-stack developer.",[26,1928,1929],{},"Not because it's the most complex app ever built.\nBut because I built every layer of it myself —\nand I understood every decision I made along the way.",[26,1931,1932],{},"The next run is already tracked. 🏃",[1934,1935,1936],"style",{},"html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .sHdIc, html code.shiki .sHdIc{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#EEFFFF;--shiki-default-font-style:italic;--shiki-dark:#BABED8;--shiki-dark-font-style:italic}html pre.shiki code .sfNiH, html code.shiki .sfNiH{--shiki-light:#FF5370;--shiki-default:#FF9CAC;--shiki-dark:#FF9CAC}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}",{"title":409,"searchDepth":424,"depth":424,"links":1938},[1939,1940,1941,1942,1943,1946,1952,1958,1963,1964,1965],{"id":23,"depth":424,"text":24},{"id":68,"depth":424,"text":69},{"id":120,"depth":424,"text":121},{"id":146,"depth":424,"text":147},{"id":183,"depth":424,"text":184,"children":1944},[1945],{"id":188,"depth":446,"text":189},{"id":355,"depth":424,"text":356,"children":1947},[1948,1949,1950,1951],{"id":359,"depth":446,"text":360},{"id":518,"depth":446,"text":519},{"id":981,"depth":446,"text":982},{"id":1012,"depth":446,"text":1013},{"id":1537,"depth":424,"text":1538,"children":1953},[1954,1955,1956,1957],{"id":1541,"depth":446,"text":1542},{"id":1590,"depth":446,"text":1591},{"id":1621,"depth":446,"text":1622},{"id":1645,"depth":446,"text":1646},{"id":1676,"depth":424,"text":1677,"children":1959},[1960,1961,1962],{"id":1680,"depth":446,"text":1681},{"id":1696,"depth":446,"text":1697},{"id":1726,"depth":446,"text":1727},{"id":1767,"depth":424,"text":1768},{"id":1833,"depth":424,"text":1834},{"id":1894,"depth":424,"text":1895},"View Case Study","2026","I was tired of copy-pasting my Strava stats into Notion after every run. So I built my own platform to automate it. First full-stack project. First time with Next.js, Supabase, and real API integrations. The moment my run synced automatically? That's when it clicked.","md","/projects/splitlog.png",{},"/projects/splitlog",{"title":16,"description":1968},"projects/splitlog",[1976,1977,1978,1979,1980,1981,1982],"Next.js","TypeScript","Supabase","TanStack Table","Strava API","Tailwind CSS","Recharts","yuBhEBDSu4Tbvbzbf54ietf_CKGyHh6RkN42WbLYAwA",[1985,1990],{"title":1986,"path":1987,"stem":1988,"description":1989,"children":-1},"Oldweb Diary","/projects/old-web-diary","projects/old-web-diary","A digital museum of early internet culture. Built a full-stack nostalgia platform with three retro OS themes, 50+ meme origin stories, This Day in Internet History, a Lost Media Vault, and a community Memory Wall.",{"title":1991,"path":1992,"stem":1993,"description":1994,"children":-1},"Three Years Later: Rebuilding My College Capstone","/projects/three-years-later-rebuilding-my-college-capstone","projects/three-years-later-rebuilding-my-college-capstone","From PHP/vanilla JS to Nuxt 4/TypeScript—what I learned by recreating my own work",1782865071013]