Tyler Geddes
GTM Enablement + Customer Education operator with builder instincts.
Bringing my ideas to life with Claude Code. These aren't prompt-and-pray demos: I design the architecture, understand every moving part, and build tools I actually use.
Up and running
-
screenrec

A free screen recorder that runs entirely in your browser. Record your screen, your webcam, or your screen with a webcam bubble, trim the take, export an MP4. No account and no upload - recordings are saved on your own machine and never sent anywhere.
HTML/JS MediaRecorder WebCodecs IndexedDB -
MtnMkr

Builds an accurate 3D model of any US mountain from USGS lidar - 1 m resolution where Google Earth manages 10-30 m - and drapes your trip over it: GPX tracks, geotagged photos, notes. All 58 Colorado 14ers open in about a second. Works offline and exports to single-file HTML, ePub, and AR-ready USDZ.
React TypeScript three.js ViteA real GIS app with no server at all - the browser talks to USGS directly.

flowchart TD App["App.tsx - UI state, search, layers, trip data"] App --> loadArea["direct/source.ts loadArea - 3-source resolver"] loadArea -->|1. published tile| Prebake["direct/prebake.ts findBaked + loadPrebaked"] loadArea -->|2. if capabilities| Backend["api.ts createArea + fetchHeights"] loadArea -->|3. fallback| Direct["direct/usgs.ts buildArea"] Prebake --> R2[("Cloudflare R2 - index.json, heights.u16.gz, topo.png")] Direct --> demUrl["demUrl - 3DEP ImageServer export, F32 tiff"] demUrl --> USGS[("USGS 3DEP / The National Map")] Direct --> Tiff["direct/tiff.ts decodeFloat32Tiff"] Prebake --> Heights["Float32Array heights"] Backend --> Heights Tiff --> Heights Heights --> HF["geo.ts Heightfield"] HF --> Geom["buildTerrainGeometry - 2048 grid mesh"] HF --> Ray["Heightfield.raycast - heightfield raymarch"] Geom --> Viewer["viewer.ts Viewer - Three.js scene"] Ray --> Viewer Parsers["parsers.ts parseGpx / parseKml / parseKmz / photoFromFile"] --> Project["geo.ts projectTrackSegment"] Project --> Viewer Pins["pins.ts summit + photo + note pin textures"] --> Viewer Gaz["direct/gazetteer.ts search - bundled GNIS index"] --> App Viewer --> Export["export.ts composeStandalone"] Export --> Html["exportStandaloneHtml"] Export --> Epub["epub.ts exportStandaloneEpub"] Export --> Usdz["usdz.ts exportUsdz - AR"] Export --> Codec["standalone/codec.ts encodeHeights - uint16 quantize"] SW["sw-template.js - shell cacheFirst, index.json networkFirst"] --> Offline["Offline use"] Store["store.ts saveSession / loadSession"] --> OfflineGlossary of functions
- loadArea - The three-source resolver every map load goes through: try a pre-baked R2 tile, then the backend if it advertised capabilities, then build live from USGS. All three return the same
LoadedAreashape so nothing downstream knows which path ran (frontend/src/direct/source.ts). - areaId - The stable cache key,
sha1("{lat},{lon},{radius},{size}")[:12], deliberately matching the Python backend's key so browser cache, service worker, and saved sessions all agree. A pre-baked tile is only reachable if the UI's default grid size hashes to the same id - the reason a mismatched default silently made every bake unreachable (frontend/src/direct/usgs.ts). - findBaked - Matches a summit against each published area's member peaks rather than its centre, within ~11 m, so one grouped tile can answer for every peak sitting on it (frontend/src/direct/prebake.ts).
- loadPrebaked - Fetches
meta.json+heights.u16.gzand de-quantizes uint16 back to float metres via the stored{min, scale}; sniffs the gzip magic bytes first, because some hosts pre-inflate a.gzand others hand over raw bytes (frontend/src/direct/prebake.ts). - buildArea / demUrl - The no-server path: builds a square EPSG:3857 bbox, asks the 3DEP ImageServer for an
F32TIFF export with bilinear interpolation, and cleans nodata the same way the backend'sdem.pydoes (frontend/src/direct/usgs.ts). - decodeFloat32Tiff - A minimal in-browser GeoTIFF reader: walks the IFD, validates that the raster really is float32, and returns the heightmap as a
Float32Array. Avoids pulling a full GIS library into the bundle (frontend/src/direct/tiff.ts). - Heightfield.raycast - Picking is a raymarch over the height field, not a Three.js raycast: a slab test against the horizontal extents, a skip-ahead to where the ray first drops below the terrain ceiling, then bisection between the last two samples. Raycasting ~2M triangles per pointer move is far too slow (frontend/src/geo.ts).
- buildTerrainGeometry - Turns a
Heightfieldplus a vertical-exaggeration factor into theTHREE.BufferGeometryfor the 2048-grid terrain mesh (frontend/src/geo.ts). - projectTrackSegment - Projects a GPX/KML segment onto the terrain surface so a track sits on the mountain rather than through it (frontend/src/geo.ts).
- parseGpx / parseKml / parseKmz / photoFromFile - Trip-data intake: tracks and routes out of GPX and KML, KMZ unzipped in-browser, and geotagged photos read for their EXIF position (frontend/src/parsers.ts).
- encodeHeights / decodeHeights - Quantizes a float32 heightmap to uint16 against a stored
{min, scale}- roughly a 2x saving, and the reason a pre-baked peak is ~12 MB instead of double that. Used by both the R2 bake and the standalone export (frontend/src/standalone/codec.ts). - composeStandalone / exportStandaloneHtml - Packs terrain, textures, and trip data into a self-contained payload and writes a single-file HTML viewer that runs with no network at all (frontend/src/export.ts).
- exportStandaloneEpub - The same payload as an ePub. Strict XHTML matters here: a bare
hiddenattribute (valid HTML, invalid XHTML) was enough to freeze the book in Apple Books (frontend/src/epub.ts). - gazetteer.search - Peak search runs against a bundled GNIS index, so finding a mountain needs no geocoding service and works offline (frontend/src/direct/gazetteer.ts).
- sw-template.js fetch handler - The app shell is
cacheFirst, but the pre-bakeindex.jsonisnetworkFirst- it is the one mutable file, and caching it cache-first pinned every returning visitor to whichever bake they first loaded (frontend/src/sw-template.js).
- loadArea - The three-source resolver every map load goes through: try a pre-baked R2 tile, then the backend if it advertised capabilities, then build live from USGS. All three return the same
-
TrailKit

Visual inventory and gear organizer for outdoor activities. Build specific "loadouts" - combinations of gear and clothing - for different activities using a video-game style interface. Provides clean packing lists for these loadouts to help you prep for your day out.
HTML/JSFirst Claude project I built outside of work.

flowchart TD Build[build.mjs esbuild bundle] --> Dist[dist/TrailKit.html single file] Index[engine/index.js barrel] --> App[trailkit/app.js domain code] App --> Store[PlannerStore dispatch] App --> Drag[DragEngine init] App --> Rules[RulesEngine validate] App --> Persist[Persistence localStorage] Drag -->|onDragDrop| Rules Rules -->|valid| Place[placeTo and removeFrom] Place --> Store Store -->|subscriber Object.assign S| RenderAll[renderAll] RenderAll --> RenderStash[renderStash and renderMain and renderStats] RenderAll --> Persist Persist -->|restoreState| Store
Glossary of functions
- PlannerStore.dispatch - runs the middleware chain, applies the matching reducer (shallow-merging its partial state), then notifies subscribers; the single mutation path for all TrailKit actions. (src/engine/store.js)
- DragEngine.bindZone - wires
dragover/dragleave/dropon a DOM element and routes a completed drop to the app'sonDropcallback with the zone and index, centralizing drag plumbing. (src/engine/drag.js) - RulesEngine.validate - runs registered placement rules in order and returns the first failure, so a drop is checked before it commits. (src/engine/rules.js)
- onDragDrop - the app's drop handler passed to
DragEngine.init; validates viaRulesEngine, thenremoveFromthe source zone andplaceTothe target, restoring on failure and callingrenderAll. (src/trailkit/app.js) - ruleTypeMatch / ruleBackpackRequired / ruleCapacity - the three registered TrailKit rules (cheapest first) enforcing zone type maps, that a backpack exists before items go to main, and that capacity is not exceeded. (src/trailkit/app.js)
- renderAll - called after every dispatch; rebuilds all slot regions via
renderStash,renderBackpackSlot,renderWater,renderMain,renderWorn,renderStats, then persists. (src/trailkit/app.js) - setSampleGear - swaps the module-level
INVENTORYpointer betweenSAMPLE_INVENTORYandUSER_INVENTORYand flipsuseSampleGear, so all gear reads honor sample-vs-user mode. (src/trailkit/app.js) - persistState / restoreState - wrap
Persistence.save/loadwith a serializer that packsuseSampleGear, the user inventory, and all loadouts, re-pointingINVENTORYand re-syncing the liveSobject on restore. (src/trailkit/app.js) - exportXML / exportPackingLists / exportCSV - emit the
.trailkitXML file, a standalone print-ready packing-list HTML, and a CSV, all gated bymaybeExportwhich warns before exporting sample data. (src/trailkit/app.js) - StatsEngine.totalWeightKg - resolves an id list against an inventory array and sums item weights, the pure helper behind the loadout weight readout. (src/engine/stats.js)
-
GPXkit

Trail-data cleanup for the boring half of the job. Drop in a GPX file and it shows you what's actually in there, draws the route, and lets you trim the junk points off either end - the parking-lot GPS drift at the start, the fumbling for keys at the finish - then exports KML or clean GPX.
HTML/JS SVG
flowchart TD Drop["Drop zone / browse - handleFile"] --> parseGpx["parseGpx - DOMParser, namespace-agnostic findAll"] parseGpx --> origModel["origModel: waypoints, tracks, routes"] origModel --> getLines["getLines - tracks + routes as one ordered list"] getLines --> setupTrimUI["setupTrimUI - per-line START/END sliders"] setupTrimUI --> trims["trims[] - {start, end} index pair per line"] origModel --> trimmedModel["trimmedModel - slices points, never mutates orig"] trims --> trimmedModel trimmedModel --> refresh["refresh - redraw + restat"] refresh --> drawPreview["drawPreview - kept solid / cut faint-dashed"] refresh --> stats["totalDistance (haversine) - totalDuration - fmtDist / fmtDur"] drawPreview --> applyView["applyView - viewBox pan/zoom, counter-scales dot radii"] zoomBy["zoomBy - anchored zoom, point under cursor stays put"] --> applyView trimmedModel --> buildKml["buildKml - Document, Styles, Placemarks"] trimmedModel --> buildGpx["buildGpx - trk/rte with ele + time preserved"] buildKml --> saveBlob["saveBlob - Blob + object URL download"] buildGpx --> saveBlob resetTrims["resetTrims / reset"] --> trimsGlossary of functions
- parseGpx - Parses with
DOMParser, rejects non-XML and anything without a<gpx>root, then pulls top-level waypoints, every<trkseg>as its own line (suffixed(seg N)when a track has several), and every<rte>into one flat model. - findAll / childText - Namespace-agnostic DOM helpers matching on
localName, so GPX files from any device parse regardless of which namespace prefixes the writer used - the usual reason a strict parser rejects a valid track. - ptFrom - Reads one
lat/lonpair plus optional<ele>and<time>into a point, dropping anything non-numeric so a single malformed row can't poison the line. - getLines - Flattens tracks and routes into one ordered list, which is what the trim UI indexes into; keeping that ordering in one function is what lets
trims[i]stay a plain parallel array. - trimmedModel - Derives the export model by slicing each line's points to its
{start, end}pair. Purely derived -origModelis never mutated, so RESET is just clearing the indices. - buildKml - Emits KML by hand: a
<Document>, a shared line and waypoint<Style>, then one<Placemark>per feature. Building the string directly avoids shipping a KML library inside a single-file tool. - buildGpx - Writes the trimmed model back out as GPX, preserving elevation and timestamps so a trimmed file stays usable by whatever consumed the original.
- drawPreview - Renders the SVG route preview, drawing kept points solid and the trimmed-off portion faint-dashed, so you see what you are about to cut before you cut it.
- applyView / zoomBy - Pan and zoom by rewriting the SVG
viewBox.applyViewcounter-scales every dot's radius and stroke width by the zoom factor so markers keep constant screen size;zoomBysolves for the new centre so the point under the cursor stays put. - haversine / totalDistance - Great-circle distance between consecutive points, summed across every track and route for the WGS84 distance readout.
- handleFile - The single intake path for both drop and browse: read, parse, build the trim UI, draw, and show stats - or route the failure through
showErrorwith a message that says what was actually wrong with the file. - saveBlob - Wraps text in a
Blob, mints an object URL, clicks a synthetic anchor, and revokes - the download primitive both exports share.
- parseGpx - Parses with
-
PLDL

Download and research YouTube playlists. Recovers the titles of deleted or private videos by querying the Wayback Machine, so your archive isn't full of mystery entries.
HTML/JS Wayback Machine API
flowchart TD ui["index.html single-page UI"] -->|EventSource /api/events| sse["SSE broadcast hub"] ui -->|GET /api/playlist-info| info["getPlaylist (lib/playlist.js)"] ui -->|POST /api/recover| rec["recoverTitles + getTimelines"] ui -->|POST /api/download| dl["spawn yt-dlp"] ui -->|POST /api/download-recovered| arc["recoverMedia (lib/archive.js)"] server["server.js Express :3001"] --> info server --> rec server --> dl server --> arc server -->|startup| deps["ensureBinaries (lib/bindeps.js)"] info -->|optional, with API key| ytdata["getPlaylistItemDates (lib/ytdata.js)"] rec --> recover["recoverTitle: Wayback CDX + og:title"] rec --> timeline["getTimeline: bracket deletion window"] dl --> sse arc --> sse rec --> sse deps --> ytbin["yt-dlp / ffmpeg on PATH or ~/.pldl/bin"]
Glossary of functions
- getPlaylist - Enumerates every video in a playlist via
yt-dlp --flat-playlist -J, normalizing entries into a single array withavailableandavailabilityflags; the entry point for listing a playlist. (lib/playlist.js) - classify - Determines a video's state (public/private/deleted/unlisted/unavailable) by matching its placeholder title, since
--flat-playlistmode leaves yt-dlp'savailabilityfield null for every entry. (lib/playlist.js) - recoverTitle - Recovers a deleted video's title by finding its earliest HTTP-200 Wayback snapshot and scraping
og:title(falling back to<title>), the core deleted-title recovery primitive. (lib/recover.js) - recoverTitles - Runs
recoverTitleacross many video IDs with bounded concurrency (3) and a polite inter-request delay, emitting per-item progress callbacks. (lib/recover.js) - getTimeline - Brackets a video's deletion window by reading its full Wayback CDX snapshot history to find
lastSeenAlive(latest 200) andfirstSeenGone(first later 404/410). (lib/timeline.js) - deriveWindow - Pure helper that scans ordered CDX rows to compute the lastSeenAlive / firstSeenGone bracket and snapshot count, isolating the timeline logic from network fetching. (lib/timeline.js)
- recoverMedia - Best-effort recovery of an unavailable video from the Internet Archive: tries the
ytarchive:extractor for the stream, else saves thumbnail/info-json, always writing a title sidecar, then classifies the outcome. (lib/archive.js) - ensureBinaries - Resolves yt-dlp and ffmpeg at startup (PATH, then managed
~/.pldl/bin, then download), publishing their paths via env vars so the lib modules can shell out to them. (lib/bindeps.js) - getPlaylistItemDates - Optional YouTube Data API v3 enrichment that paginates
playlistItems.listinto aMap<videoId, {dateAdded, videoPublishedAt}>, giving an "alive on this date" signal where Wayback fails. (lib/ytdata.js) - broadcast - Fans a JSON event to every connected SSE client; all async download and recovery progress flows through this single hub rather than the originating HTTP response. (server.js)
- initDeps - Kicks off
ensureBinariesafter the server is listening and streams setup state over SSE, while a 503 guard holds yt-dlp-dependent endpoints until deps are ready. (server.js)
- getPlaylist - Enumerates every video in a playlist via
-
PlanFit

Workout planner that incorporates outdoor activities like mountain biking and hiking into your training schedule. Makes recurring schedules simple to plan out, auto-fills your calendar with a balanced workout plan, and lets you define workout categories (ie. cardio, leg day, upper body) and get a random workout of that type assigned.
HTML/JS
flowchart TD Load[loadState] --> Migrate[migrate to v1 shape] Migrate --> UserData[userData snapshot] UserData --> S[S active snapshot] Sample[freshSampleData] --> S S --> RenderCalendar[renderCalendar and renderLibrary and renderBot] Drop[desktop drop / mobile tap] --> PlaceOnDay[placeOnDay funnel] PlaceOnDay --> DropViolation[getDropViolation] DropViolation --> RestCheck[checkTypeRestViolation] PlaceOnDay -->|library| AddToDay[addToDay] PlaceOnDay -->|type pill| DayTags[S.dayTags] AddToDay --> Save[save to localStorage] AddToDay --> Schedule[getDaySchedule memo] Save --> RenderCalendar Auto[autoSchedule] --> DayTags Violations[getViolations] --> RenderCalendar
Glossary of functions
- loadState / migrate - read the latest (or legacy fallback)
localStoragepayload and lift it to the current v1 grouped shape, populatinguserData,uiPrefs, anduseSampleMode. (PlanFit-0.85.html) - save - serializes only
userData(neversampleData) into the grouped library/plan/schedule/log payload underplanfit_data_v1, after invalidating the schedule memo. (PlanFit-0.85.html) - placeOnDay - the single placement funnel for desktop drop and mobile tap; validates via
getDropViolation, then tags a day, adds a library workout, or moves a scheduled item. (PlanFit-0.85.html) - getDaySchedule - merges manually scheduled items with matching recurring items for a date, sorts activities before workouts, and memoizes the result. (PlanFit-0.85.html)
- getViolations - walks a +/-60 day window against
S.rulesto produce min-rest and max-gap violation badges shown on the calendar. (PlanFit-0.85.html) - checkTypeRestViolation - for a candidate date and type, finds the nearest prior/next matching days and rejects a placement that would break the type's min-rest rule; reused in both drop validation and auto-fill. (PlanFit-0.85.html)
- autoSchedule - greedily tags un-tagged future days in the current month by rule-urgency scoring, with a weekday
fallbackrotation when no rule is urgent. (PlanFit-0.85.html) - setSampleMode - swaps
Sbetween the in-memorysampleDataanduserDatasnapshots so sample-mode edits stay ephemeral. (PlanFit-0.85.html) - buildUserXML / loadUserXML - write user data as versioned
<planfit>XML with library/plan/schedule/log groupings, and import it using version-agnostic selectors that accept both the legacy 0.5 and current 1.0 layouts. (PlanFit-0.85.html) - escapeHTML / safeURL / newId / fmtDate - core safety and ID/date helpers used throughout: HTML-escape user content, sanitize URLs, mint collision-safe ids, and round-trip
Dateto"YYYY-MM-DD". (PlanFit-0.85.html) - MobileLayer.isMobile / hasTouch - split viewport-width (layout) from touch-capability (input) detection, so touch laptops get tap-to-place alongside drag-drop. (PlanFit-0.85.html)
- loadState / migrate - read the latest (or legacy fallback)
-
KidPlan

Mobile-first summer activity planner for two boys and a busy family. Integrates tightly with Google Calendar, and manages naps, backup activities when plans abruptly change, and prompts parents to plan out unscheduled days in advance.
Google Apps Script Google Sheets HTML/JS
flowchart TD fe[web/index.html single-file frontend served by Apps Script] fe -->|POST action + token| entry[api.gs doGet / doPost] entry --> dispatch[api.gs dispatch_ + ROUTES] dispatch --> auth[auth.gs withAuth_ token check] auth --> handlers[Route handlers: upsert_plan_item, list_plan_items, upload_photo, run_photo_ocr] handlers --> sheetsmod[sheets.gs upsertRow_ / getRows_ / deleteRow_] sheetsmod --> sheet[(Google Sheets: PlanItems / Library / Tags / Photos / Settings)] handlers --> calmod[calendar.gs writePlanItemToCalendar_] calmod --> gcal[(Shared Google Calendar)] handlers --> drivemod[drive.gs uploadPhotoToDrive_] drivemod --> drive[(Google Drive photo archive)] handlers --> visionmod[vision.gs runVisionOcrOnDriveFile_] visionmod --> vision[(Cloud Vision OCR)] trig[triggers.gs recurring photo-prompt event] --> gcal
Glossary of functions
- doGet / doPost / dispatch_ - The HTTP entry point: every request carries an
actionplus params (query string on GET, JSON body on POST), routed throughdispatch_to a handler in theROUTEStable and returned as JSON (gas/api.gs). - withAuth_ - Wraps every non-ping handler so it runs only when the request's shared token matches the
API_TOKENscript property; declared as a hoistingfunctionsoapi.gscan reference it at load time beforeauth.gsevaluates (gas/auth.gs). - upsert_plan_item - Creates or updates a plan item, mirrors it to the calendar and stores the event id back, validates backup pairing, and drags a paired backup's date/time along when its primary shifts (gas/api.gs).
- duplicate_plan_items_to_range - Copies a source date's primaries and their paired backups across an inclusive date range, minting new ids and rewriting
backup_for_idso the pairing carries over with fresh calendar events (gas/api.gs). - upsertRow_ - The lock-protected write chokepoint: finds a row by key and updates in place or appends, stamps
updated_at, and forces date/time columns to text format to avoid timezone-serial drift (gas/sheets.gs). - getRows_ / coerceCellForRead_ - Reads a tab in one pass into header-keyed objects, coercing Date cells back to timezone-stable strings and normalizing boolean columns (gas/sheets.gs).
- writePlanItemToCalendar_ - Idempotently creates or updates the GCal event for a plan item via its stored
gcal_event_id, builds the kid-lane title, defaults a 60-min end time, and tints backups a muted color (gas/calendar.gs). - list_calendar_events - Returns events in a date range across the family and read-only calendars, tagging each as
kidplan(matches a known plan-item event id) orexternalso the frontend can filter (gas/api.gs). - upload_photo / run_photo_ocr - Two-step paper-calendar capture: decode and store a base64 image to Drive plus a Photos row, then run Cloud Vision DOCUMENT_TEXT_DETECTION on that Drive file and persist the OCR text (gas/api.gs, gas/drive.gs, gas/vision.gs).
- apiCall / apiCallRaw - The frontend's single fetch wrapper: POSTs
{action, token, ...params}to the live GAS/execURL, throws onok:false, and unwraps thedataenvelope for callers (web/index.html). - setupRecurringPhotoPromptEvent - One-time idempotent setup that creates a recurring "Sync paper calendar" calendar event linking back to the app's photo view, guarded by a stored event id (gas/triggers.gs).
- setupSeedSheet - One-time idempotent provisioning that creates all five tabs with header rows and text-formatted date/time columns, then seeds presets only when empty (gas/sheets.gs).
- doGet / doPost / dispatch_ - The HTTP entry point: every request carries an
// EXPERIMENTS & TOYS
Smaller experiments, write-ups, and toys.
Every single-file tool on this site, as icons on a fake Mac OS 7 desktop. Double-click one and it runs in a draggable window - two at a time if you want.
Open the desktop
ABOUT
Who's behind this
I'm Tyler. I've spent eight years in B2B SaaS building GTM Enablement and Customer Education programs at companies like Simpro, KarmaCheck, Buildout, Apto, and RE/MAX. My day job has always been about closing the gap between what a product can do and what the people selling, supporting, and using it actually understand.
Since picking up Claude Code, I've started closing the gap from the other side too - building the tools myself. This site is where those builds live.
To see my portfolio for GTM Enablement and Customer Education, visit tgeddes.com.