<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
	<channel>
		<title>RazerGhost — Devlog</title>
		<link>https://razerghost.xyz/devlog</link>
		<description>Personal site — link hub, devlog, watchlist, and listening history.</description>
		<atom:link href="https://razerghost.xyz/devlog/rss.xml" rel="self" type="application/rss+xml" />
		<item>
			<title>Building /notes as a force-directed graph, then rebuilding it for mobile</title>
			<link>https://razerghost.xyz/devlog/2026-07-21-the-notes-graph</link>
			<guid>https://razerghost.xyz/devlog/2026-07-21-the-notes-graph</guid>
			<pubDate>Tue, 21 Jul 2026 00:00:00 GMT</pubDate>
			<description>A canvas full of notes connected by springs is a fine idea for a mouse and a keyboard. It has nothing to say to a phone — so the mobile version isn&apos;t a responsive tweak, it&apos;s a second app sharing one state tree.</description>
			<content:encoded><![CDATA[<img src="https://razerghost.xyz/devlog/2026-07-21-the-notes-graph/og.png" alt="" /><p><a href="/notes">/notes</a> (GitHub-gated, same login as <code>/admin</code>) isn&#39;t a list. Every
note is a node on a canvas, connected to other notes by links you draw
yourself, and the whole thing behaves like a tiny physics simulation —
notes repel each other, links pull connected notes together like springs,
everything settles toward the center of the view. It&#39;s overkill for &quot;write
things down,&quot; and that&#39;s sort of the point: the graph <em>is</em> the organizing
structure, not a visualization bolted onto one.</p>
<h2 id="the-physics">The physics</h2>
<p>There&#39;s no library here — <a href="src/routes/notes/+page.svelte"><code>tick()</code></a> is a
hand-rolled force simulation that runs on <code>requestAnimationFrame</code> whenever
the graph is disturbed:</p>
<ul>
<li>Every pair of notes repels the other with a force proportional to
<code>REPEL / distance²</code>, clamped by a minimum distance so two notes can never
fully overlap.</li>
<li>Every link pulls its two notes toward a resting distance (<code>LINK_DISTANCE</code>)
like a spring — too close and it pushes apart, too far and it pulls in.</li>
<li>A weak constant force pulls every note toward the origin, so the graph
doesn&#39;t drift off toward infinity as repulsion accumulates.</li>
<li>Velocity is damped each frame (<code>DAMPING = 0.82</code>) so the whole thing
settles instead of oscillating forever.</li>
</ul>
<p>The loop keeps scheduling itself only while something&#39;s still moving faster
than <code>SETTLE_EPS</code>, or while a note is actively being dragged — once it&#39;s
calm, <code>simRunning</code> goes false and the browser stops doing any work at all.
Node radius scales with connection count (<code>22 + connections × 5</code>, capped at
46), so a heavily-linked note visibly reads as a hub without any separate
&quot;importance&quot; field to maintain.</p>
<p>Positions aren&#39;t recomputed from scratch on every load, either — <code>x</code>/<code>y</code> are
real columns on the <code>notes</code> table (added via an <code>ALTER TABLE</code> migration in
<a href="src/lib/server/notes.ts"><code>notes.ts</code></a>), and dragging a note debounces a save
back to the server 400ms after it stops moving. A note you haven&#39;t manually
placed falls back to a golden-angle spiral (<code>scatterPoint</code>) so a fresh
database doesn&#39;t start with every note stacked at the origin.</p>
<h2 id="a-tool-dock-instead-of-remembering-modifier-keys">A tool dock instead of remembering modifier keys</h2>
<p>Four interactions live on the same canvas — move a note, link two notes,
trace the shortest path between two notes, box-select several notes at
once — and they all start with a pointer-down on a node. Early on that meant
memorizing which modifier key meant what. The fix was a small dock of
sticky tool modes (Pointer / Link / Path / Select) so the current mode is
always visible and doesn&#39;t need to be held down. The modifier-key shortcuts
still work underneath (<code>effectiveTool()</code> checks <code>shiftKey</code>/<code>ctrlKey</code>/<code>altKey</code>
before falling back to whatever&#39;s selected in the dock) — the dock just made
the feature discoverable instead of being the only way to reach it.</p>
<p><strong>Path mode</strong> is the one with actual logic behind it: click a start note,
click an end note, and <code>bfsPath()</code> runs a breadth-first search over the link
graph (undirected, since path-finding doesn&#39;t care about link direction) and
highlights every link on the shortest route between them. It&#39;s a genuinely
useful question once a graph has more than a dozen notes — &quot;how are these
two ideas connected&quot; — answered with a plain BFS over an adjacency map built
fresh from the current <code>links</code> array each time.</p>
<p><strong>Select mode</strong> turns multi-select into a bulk-linking tool: box-select or
shift-click a set of notes, then click one more note as the &quot;hub&quot; and every
selected note gets linked to it in one pass (<code>linkSelectedTo</code>) — useful for
&quot;all of these relate to this one,&quot; which used to mean drawing the same link
by hand N times.</p>
<h2 id="wiki-links-and-revisions">Wiki-links and revisions</h2>
<p>Typing <code>[[Note Title]]</code> in a note&#39;s body and saving auto-creates a real
graph link to the matching note (<code>autoLinkWikiRefs</code>, matched case-
insensitively by title) — so a habit borrowed from Obsidian/Roam-style tools
becomes an actual edge in the same graph the canvas draws, not just a
clickable in-text reference.</p>
<p>Edits also snapshot into <code>note_revisions</code> before being overwritten —
throttled server-side to once per two minutes per note (<code>shouldSnapshot</code> in
<code>notes.ts</code>) so autosave firing on every second-long pause while typing
doesn&#39;t flood the table with near-duplicate snapshots. A note&#39;s last 20
revisions are browsable and one-click restorable from the panel. Deletes are
soft (<code>deleted_at</code>, filtered out of every read path but not actually
removed) specifically so a same-session &quot;Undo&quot; can bring a note straight
back, links and all, with no separate trash/restore flow to build.</p>
<h2 id="then-none-of-this-works-on-a-phone">Then: none of this works on a phone</h2>
<p>The canvas assumes a mouse. Dragging a node, drawing a link by dragging from
one note to another, holding a modifier key, hovering to see a tooltip —
none of it has a touch equivalent that isn&#39;t worse than the desktop version.
Rather than trying to retrofit touch gestures onto a pointer-driven
simulation, <code>/notes</code> now branches early: <code>isMobile</code> is set from
<code>window.matchMedia(&#39;(max-width: 768px)&#39;)</code> (with a <code>change</code> listener so
rotating a tablet or resizing a window updates it live), and mobile gets an
entirely different view — a plain scrollable list of notes with a search
box, and tapping one opens a full-screen editor with a title field, a
comma-separated tags field, a write/preview toggle for the markdown body,
and image attach, instead of the floating side panel desktop uses.</p>
<p>The interesting constraint wasn&#39;t building the list-and-editor view itself —
that part&#39;s straightforward — it&#39;s that <strong>both branches have to share the
same state</strong>. <code>nodes</code>, <code>links</code>, <code>selectedId</code>, <code>panelBody</code>, autosave timers,
all of it. A note edited from the mobile list view needs to show up
correctly if you resize the window past 768px without a page reload, and
vice versa. So the split is a CSS class toggle on the same <code>&lt;main&gt;</code>
(<code>class=&quot;{isMobile ? &#39;flex&#39; : &#39;hidden&#39;} …&quot;</code>) rather than two separately
mounted trees, plus a couple of guard clauses elsewhere — the force
simulation&#39;s <code>$effect</code> bails out early on mobile, since there&#39;s no point
running physics nobody can see or interact with — never a duplicated copy of
the data layer. Even the small stuff carried over deliberately: the mobile
&quot;Back&quot; button flushes any pending autosave before closing the editor
(<code>mobileBack</code>), mirroring the exact flush <code>selectNote</code> already did on
desktop to avoid a debounced save landing after the panel had already moved
on to different content.</p>
<p>One graph, two very different ways to touch it, zero duplicated state.</p>
]]></content:encoded>
		</item>
		<item>
			<title>A media library, an admin area that feels like one, and backups that diff</title>
			<link>https://razerghost.xyz/devlog/2026-07-20-media-library-and-admin-backups</link>
			<guid>https://razerghost.xyz/devlog/2026-07-20-media-library-and-admin-backups</guid>
			<pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate>
			<description>Three admin-area gaps that all trace back to the same root cause — nothing here persisted or gave feedback the way it should — and the git-based backup job that ties them together.</description>
			<content:encoded><![CDATA[<img src="https://razerghost.xyz/devlog/2026-07-20-media-library-and-admin-backups/og.png" alt="" /><p>Three separate admin-area problems, fixed in quick succession, that turned
out to share one root cause: the private tooling around this site had grown
faster than its plumbing.</p>
<h2 id="images-had-nowhere-real-to-live">Images had nowhere real to live</h2>
<p>The devlog and project editors&#39; cover/gallery fields were plain text paths —
whatever you typed had to already exist under <code>static/</code>, which meant
manually dropping a file into the repo before you could reference it. That&#39;s
fine for a handful of images added at commit time, and useless from a
running prod session: <code>static/</code> is baked into the Docker image at build
time, so anything added there doesn&#39;t survive a redeploy and can&#39;t be
touched by the running server at all.</p>
<p>The fix mirrors a pattern the site already had for note attachments: a media
library backed by the persistent data volume. <a href="src/lib/server/media.ts"><code>media.ts</code></a>
is deliberately small — save/list/delete against a <code>data/media/</code> directory,
filenames are <code>{uuid}-{slugified-original-name}.{ext}</code> so collisions can&#39;t
happen and the original name survives for readability, uploads are capped at
8MB and restricted to PNG/JPEG/GIF/WebP by MIME type. An admin-gated API
route handles upload/list/delete, a public <code>/media/[filename]</code> route serves
the files, and <code>/admin/media</code> is a browsable explorer with drag-and-drop
upload and multi-select bulk delete. A <code>MediaPicker</code> component then plugs
into the devlog/project cover field, the gallery field (multi-select), and
the post body itself.</p>
<h2 id="the-admin-area-itself-needed-the-same-care-its-data-model-got">The admin area itself needed the same care its data model got</h2>
<p>Once there was real functionality worth protecting, the rough edges around
it stood out more: no link back to the dashboard from any sub-page, saves
that did a silent full-page reload with no confirmation, deletes gated by a
native <code>window.confirm()</code>, and nothing to filter with once the devlog/project
lists grew past a handful of entries. All fixed together — a shared
<code>+layout.svelte</code> with a back-to-dashboard link, <code>use:enhance</code>-based save
feedback instead of relying on the reload itself as confirmation, a reusable
<code>ConfirmDialog</code> component, and client-side filter/sort on the list and
watchlist-cache pages. Small, but it&#39;s the difference between an admin area
that feels maintained and one that just happens to work.</p>
<h2 id="backups-that-diff-instead-of-just-existing">Backups that diff instead of just existing</h2>
<p>The persistent Coolify volume protects <code>data/</code> across a redeploy, but not
against losing the server itself — a host failure, a bad migration, a fat
finger against the volume. <code>/api/backup</code> (same secret-gated, hit-on-a-schedule
pattern as the Spotify scrobble endpoint) closes that gap by dumping the
SQLite databases and pushing them to a private git repo.</p>
<p>The interesting decision is <em>how</em> it dumps them. Committing the raw <code>.db</code>
files would work, but SQLite&#39;s on-disk format means a single changed row can
shuffle bytes across the whole file — every backup would look like a full
rewrite in <code>git diff</code>, and years of backups would compress and diff badly.
Instead, <a href="src/lib/server/backup.ts"><code>dumpDatabaseToSql</code></a> reproduces what
<code>sqlite3 &lt;file&gt; .dump</code> does: schema statements plus one <code>INSERT</code> per row, in
plain text. Two details made that not-quite-trivial:</p>
<ul>
<li><code>pragma_table_list()</code> distinguishes real tables from FTS5&#39;s internal
shadow tables (<code>notes_fts_data</code>, <code>notes_fts_config</code>, etc.) — those have
reserved names SQLite refuses to <code>CREATE</code>/<code>INSERT</code> into directly, and
they&#39;re rebuilt automatically alongside their parent virtual table, so
they have to be skipped rather than dumped.</li>
<li>Virtual tables themselves (the FTS5 index) hold no data of their own to
dump — only their schema statement is written; the app backfills the
index from the real table on next startup anyway.</li>
</ul>
<p>The result: five SQLite databases (<code>notes</code>, <code>simkl-cache</code>, <code>spotify-history</code>,
<code>status</code>, <code>newtab-settings</code>) as five <code>.sql</code> files, plus the note-attachments
and media directories copied wholesale, all committed to a fresh temp clone
of the backup repo and pushed. <code>git status --porcelain</code> after staging tells
it whether anything actually changed — a no-op run skips the commit instead
of creating an empty one every time the schedule fires. <code>/admin/backups</code>
surfaces the last commit&#39;s timestamp (read via a shallow clone of the backup
branch, no extra state of its own needed) and a &quot;run backup now&quot; button for
out-of-band peace of mind.</p>
]]></content:encoded>
		</item>
		<item>
			<title>A weekly heatmap, a project lightbox, and swapping a callout for a sparkline</title>
			<link>https://razerghost.xyz/devlog/2026-07-20-heatmap-lightbox-sparkline</link>
			<guid>https://razerghost.xyz/devlog/2026-07-20-heatmap-lightbox-sparkline</guid>
			<pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate>
			<description>Three small, unrelated features shipped together, tied by the same idea — once data already lives in SQLite, another way to look at it is just another query.</description>
			<content:encoded><![CDATA[<img src="https://razerghost.xyz/devlog/2026-07-20-heatmap-lightbox-sparkline/og.png" alt="" /><p>Three features, one commit, no shared code between them — but the same
underlying reason they were each cheap to build.</p>
<h2 id="a-weekday-hour-listening-habits-heatmap">A weekday × hour listening-habits heatmap</h2>
<p><code>/listens</code> already had a GitHub-style yearly activity heatmap and an
hour-of-day listening clock. The natural next chart cross-references them:
weekday on one axis, hour-of-day on the other, play count as intensity —
answering &quot;am I actually a Sunday-morning or Friday-night listener&quot; in one
glance instead of inferring it from two separate charts.</p>
<p><a href="src/lib/server/spotify-history-db.ts"><code>getWeekdayHourBreakdown</code></a> is the
whole implementation server-side: <code>strftime(&#39;%w&#39;, played_at)</code> and
<code>strftime(&#39;%H&#39;, played_at)</code> grouped together, one <code>GROUP BY</code> over a table
that already exists. Like every other aggregate on that page, it&#39;s sparse —
weekday/hour combinations with zero plays are just absent from the result
rather than present with a zero, and the client fills in the gaps when it
renders the grid. Optionally scoped to a single year via the same
<code>yearRange()</code> helper the yearly heatmap already used.</p>
<p>Adding the new chart also meant rebalancing <code>/listens</code>&#39; card layout — the
grid had grown one card at a time across several earlier passes and had
started to show mismatched-height dead space between cards. Nothing
algorithmic, just a manual pass at grouping the cards that visually belong
together.</p>
<h2 id="a-lightbox-for-project-galleries">A lightbox for project galleries</h2>
<p>Project pages had gallery images that opened at native size with no
zoom — click one, get a big image inline, no way to page through the rest
of the gallery without scrolling back up. <a href="src/lib/components/Lightbox.svelte"><code>Lightbox.svelte</code></a>
is a small, self-contained overlay: click-to-expand, arrow-key and
on-screen prev/next navigation, an image counter, Escape or a click on the
backdrop to close. <code>open</code> and <code>index</code> are <code>$bindable</code> props, so the project
page itself just needs an array of image URLs and a click handler that sets
the index — no gallery-specific logic lives in the component.</p>
<h2 id="swapping-the-homepage-s-top-track-callout-for-a-sparkline">Swapping the homepage&#39;s top-track callout for a sparkline</h2>
<p>The homepage previously surfaced &quot;top track&quot; as a static callout — accurate,
but static in a way that didn&#39;t reflect anything about <em>recent</em> listening,
and a single track name doesn&#39;t say much at a glance. It&#39;s replaced with a
14-day listening-activity sparkline: a small inline chart showing daily play
counts for the last two weeks, which reads as &quot;here&#39;s what I&#39;ve actually
been doing&quot; rather than a single frozen fact.</p>
<p><a href="src/lib/server/spotify-history-db.ts"><code>getRecentDailyPlayCounts</code></a> backs it —
almost identical in shape to the yearly heatmap query, just a rolling
<code>days</code>-sized window anchored on today (UTC) instead of a calendar year. Same
sparse-result convention, same memoization pattern as everything else in
that file. The homepage query is cheap enough to run on every request
because, like the rest of <code>/listens</code>, the data&#39;s already sitting local in
SQLite — no external call, no separate cache layer, just another <code>GROUP BY</code>.</p>
]]></content:encoded>
		</item>
		<item>
			<title>Turning /newtab into an actual dashboard</title>
			<link>https://razerghost.xyz/devlog/2026-07-20-a-newtab-dashboard</link>
			<guid>https://razerghost.xyz/devlog/2026-07-20-a-newtab-dashboard</guid>
			<pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate>
			<description>A browser new-tab page needs to survive being reopened fifty times a day — that constraint shaped everything from the background photo cache to the layout system more than any feature idea did.</description>
			<content:encoded><![CDATA[<img src="https://razerghost.xyz/devlog/2026-07-20-a-newtab-dashboard/og.png" alt="" /><p><a href="/newtab">/newtab</a> started as a simple pitch: replace the browser&#39;s blank
new-tab page with something that&#39;s actually mine — a clock, a search box, the
same Now Playing / Discord / GitHub widgets already living on the homepage,
and a nice background photo. Login-gated behind the same GitHub OAuth as
<code>/notes</code> and <code>/admin</code>, since none of this needs to be public.</p>
<h2 id="the-constraint-that-mattered-most">The constraint that mattered most</h2>
<p>A new-tab page isn&#39;t a page you visit — it&#39;s a page that reopens itself
constantly, often dozens of times an hour, and it needs to feel instant every
single time. That ruled out anything that leans on a slow first paint or an
env-var-gated config that needs a redeploy to change. Two decisions fell out
of it directly:</p>
<ul>
<li>The Unsplash background query is a <strong>setting stored in <code>newtab-settings.db</code></strong>,
editable from an in-page settings panel, not an environment variable.
Wanting a different photo theme shouldn&#39;t mean touching Coolify&#39;s env UI
and redeploying.</li>
<li>The fetched photo itself is <strong>persisted to the same DB</strong> as a durable
fallback, not just held in an in-memory cache.</li>
</ul>
<h2 id="why-the-photo-needed-a-durable-cache">Why the photo needed a durable cache</h2>
<p>The first pass cached the fetched Unsplash photo in memory, keyed by search
query. That worked until it didn&#39;t: every server restart — every redeploy,
and in dev, every file save that bounces the Vite server — wiped the cache
and forced an immediate re-fetch on the next load. Unsplash&#39;s demo tier caps
out at 50 requests/hour, and a page that gets reopened as often as a new-tab
page burns through that fast when every restart resets the counter to zero.</p>
<p>Persisting the last-fetched photo (URL, blur hash, credit) to
<code>newtab-settings.db</code> fixed that: a restart still loses the in-memory cache,
but the DB fallback means the very next request serves the last-known photo
instead of triggering a fresh fetch. The actual re-fetch only happens once
the cache&#39;s own TTL expires, restart or not.</p>
<p>The same commit also switched from Unsplash&#39;s <code>urls.regular</code> (capped at
1080px) to <code>urls.raw</code> with explicit width/quality params — the difference is
obvious at anything wider than a laptop screen, and it was worth the
one-line change once the caching was solid enough that fetching a bigger
image wasn&#39;t also fetching it <em>more often</em>.</p>
<h2 id="everything-a-dashboard-accumulates">Everything a dashboard accumulates</h2>
<p>Once the shell existed, <code>/newtab</code> grew the way these things do — one useful
thing at a time, each cheap to add because the persistence layer was already
there:</p>
<ul>
<li><strong>Quick links</strong> and <strong>search history</strong>, both trivial once there&#39;s
somewhere to put them — quick links later grew into its own small
feature, below.</li>
<li>A <strong>favoritable, cyclable background photo history</strong> instead of a single
cached photo — click through past backgrounds, star the ones worth
keeping.</li>
<li>A <strong>pomodoro-style focus timer</strong> with daily stats, because a new-tab page
is exactly where you&#39;d glance to start one.</li>
<li><strong>Quick note capture</strong> straight into the <a href="/devlog/2026-07-21-the-notes-graph">notes graph</a> —
type a thought, it lands as a real note without a context switch.</li>
<li>A small <strong>watchlist widget</strong> pulling from the same Simkl-backed data as
<code>/watchlist</code>.</li>
<li><strong>Client-side persisted drag/dock layout</strong> for the widgets — where you put
things stays where you put them, stored in <code>localStorage</code> rather than the
server since it&#39;s purely a per-browser display preference.</li>
</ul>
<p>None of these needed new infrastructure. <code>newtab-settings.db</code> already held
one blob of durable state; everything after the background-photo cache was
just more columns and more UI on top of a page that was already fast.</p>
<h2 id="quick-links-outgrew-their-popover">Quick links outgrew their popover</h2>
<p>&quot;Quick links&quot; started as exactly what it sounds like — add a label and a
URL, remove one you don&#39;t want anymore. That was fine for five links; it
stopped being fine once reordering, editing, and picking something better
than a bare text label all turned out to matter too. Rather than keep
wedging features into the small popover it lived in, the management UI moved
out into a dedicated <code>QuickLinksModal</code> component with actual room:
click-count-based sort (so frequently used links can float to the top
without dragging anything), up/down reordering without a drag-and-drop
library, inline editing, per-link keyboard shortcuts, and a curated
<a href="src/lib/components/quick-link-icons.ts">Lucide icon picker</a> — a hand-picked
subset (dev tools, media, finance, productivity, social/web, misc) rather
than the full icon set, stored as the same kebab-case name Lucide ships it
under so the value is portable and self-describing rather than an opaque
index into some list.</p>
<p>The one thing that had to be handled carefully: links added before the icon
picker existed had emoji stored in what&#39;s now the <code>icon</code> column. Rather than
a migration script to convert them, the icon component just falls back to
rendering the raw stored value as text if it isn&#39;t a recognized Lucide icon
name — an old emoji renders exactly as it always did, no data touched.</p>
]]></content:encoded>
		</item>
		<item>
			<title>The whole Watchlist page, built in a day</title>
			<link>https://razerghost.xyz/devlog/2026-07-18-watching-page-and-simkl-api</link>
			<guid>https://razerghost.xyz/devlog/2026-07-18-watching-page-and-simkl-api</guid>
			<pubDate>Sat, 18 Jul 2026 00:00:00 GMT</pubDate>
			<description>Simkl&apos;s bulk sync call doesn&apos;t carry genres, overviews, or runtimes — getting those without hammering a per-title endpoint on every load meant a small self-warming cache, plus a full-library snapshot to survive an outage.</description>
			<content:encoded><![CDATA[<img src="https://razerghost.xyz/devlog/2026-07-18-watching-page-and-simkl-api/og.png" alt="" /><p>The <a href="/watchlist">Watchlist page</a> opened as three static grids off one Simkl
API call. By the end of the day it had search, sorting, genre filters, a
stats section, and a caching layer underneath all of it — same page, same
day, just a lot more of it by the time it shipped.</p>
<h2 id="the-shape-of-it">The shape of it</h2>
<p><code>getLibrary()</code> in <code>simkl.ts</code> hits Simkl&#39;s <code>sync/all-items</code> endpoint once per
media type (shows, anime, movies) with <code>status=all</code>, which returns every
bucket — watching, completed, plan-to-watch, on hold, dropped — in one call
per type instead of one call per bucket. Korean dramas and movies mostly live
under the &quot;shows&quot;/&quot;movies&quot; buckets rather than &quot;anime&quot; on Simkl, but querying
all three keeps the page correct even for a title filed somewhere unexpected.</p>
<p>One thing the bulk response gets wrong on its own: poster links. Simkl&#39;s
detail URLs need the numeric id (<code>/tv/&lt;id&gt;/&lt;slug&gt;</code>), not just the slug — a
slug-only link 404s into the generic discover page instead of the title.</p>
<h2 id="genres-and-overviews-aren-t-in-that-call">Genres and overviews aren&#39;t in that call</h2>
<p>Genres, overview text, and per-episode runtime are a separate, per-title
request — and the library has around 250 titles. Firing all of them on every
page load isn&#39;t an option, so <code>enrichLibrary()</code> only ever tops up a small,
bounded batch (<code>DETAIL_BATCH_SIZE = 15</code>) of missing or stale entries per
request, caching results in <code>simkl-cache.db</code>&#39;s <code>simkl_details</code> table keyed by
<code>(simkl_id, media_type)</code>. A library this size fully warms over a handful of
page loads; anything beyond the batch just renders without genres until its
turn comes up — the same degrade-gracefully approach as the rest of the site.
Cached entries go stale after 60 days (genres don&#39;t change often), and rows
cached before <code>runtime</code> existed are treated as stale too, so they backfill
instead of waiting out the full window.</p>
<h2 id="surviving-a-simkl-outage">Surviving a Simkl outage</h2>
<p>Simkl&#39;s API rules explicitly permit caching user data locally, so alongside
the per-title cache, <code>simkl-cache.ts</code> also keeps a single-row snapshot of the
<em>entire</em> enriched library in <code>simkl_library_snapshot</code>. <code>getLibraryWithFallback()</code>
tries the live sync call first and saves a fresh snapshot on success; if the
call throws, it serves the last saved snapshot instead of an empty page, with
a <code>stale</code> flag the UI turns into a small banner (&quot;showing a cached copy
from…&quot;). <code>simkl-refresh.ts</code> also runs the same refresh on a 24-hour timer
independent of page traffic, so the fallback doesn&#39;t go stale just because
nobody visited the page in a while.</p>
<h2 id="what-it-adds-up-to">What it adds up to</h2>
<p>The stats section reads like Simkl&#39;s own stats page: watch time in days and
hours (from real per-title runtimes, not an estimate), peak year, most active
weekday, average rating, backlog size with hours-to-clear, completion rate,
and a top-5 genre breakdown — all computed from the same enriched library the
grids already have in memory, no extra queries. It&#39;s also the one page on
the site that breaks out of the usual narrow, centered column — search,
sort, and stats all use the full width, since a six-column poster grid needs
the room.</p>
<p><strong>Surprise me</strong> isn&#39;t a one-line text pick that pops a new tab either — it
reveals a full spotlight card in place: poster, rating, genres, overview, an
&quot;Open on Simkl&quot; link, and a &quot;Try another&quot; button to reroll without leaving
the page.</p>
<h2 id="two-tabs-because-anime-showed-up">Two tabs, because anime showed up</h2>
<p>Anime titles started showing up in the library alongside the kdramas and
movies, and mixing <em>Isekai</em>/<em>Seinen</em> genre chips in with kdrama tags didn&#39;t
read well as one filter set. The page split into <strong>TV &amp; Movies</strong> and
<strong>Anime</strong> tabs, each with its own stats, its own filters, its own &quot;Surprise
me&quot; — scoped so the shuffle button never hands a TV-and-movies session an
anime pick by accident. <a href="/watchlist">Go see for yourself</a>.</p>
]]></content:encoded>
		</item>
		<item>
			<title>Building /listens from a Spotify data export instead of an API</title>
			<link>https://razerghost.xyz/devlog/2026-07-18-the-listens-page</link>
			<guid>https://razerghost.xyz/devlog/2026-07-18-the-listens-page</guid>
			<pubDate>Sat, 18 Jul 2026 00:00:00 GMT</pubDate>
			<description>Spotify&apos;s API only gives you the last ~50 plays. Getting real history — and years of stats out of it — meant starting from my own data export instead.</description>
			<content:encoded><![CDATA[<img src="https://razerghost.xyz/devlog/2026-07-18-the-listens-page/og.png" alt="" /><p>The <a href="/listens">Listens page</a> isn&#39;t backed by a live feed. Spotify&#39;s own API
only exposes the last ~50 recently-played tracks — fine for a &quot;now playing&quot;
widget, useless for &quot;what have I actually listened to since 2019.&quot; Real
history only exists in one place: Spotify&#39;s own &quot;extended streaming history&quot;
export, the one you request from their privacy settings and wait a few days
for.</p>
<h2 id="the-shape-of-it">The shape of it</h2>
<p>The export lands as a pile of JSON files, one play per entry — track, artist,
album, timestamp, milliseconds played, the works. <code>spotify-history.ts</code>
parses and normalizes them; <code>spotify-history-db.ts</code> writes the result into
<code>spotify-history.db</code>, a SQLite file on the persistent data volume.
<code>/spotify-import</code> (gated behind the same GitHub login as <code>/admin</code>) is where
the files actually get uploaded.</p>
<p>Inserts are idempotent on <code>(played_at, spotify_uri, ms_played)</code>, which
matters more than it sounds like it should — Spotify&#39;s exports overlap at
the edges, and re-uploading the same file (or a newer export that reaches
back further) needs to be a no-op for anything already in the database, not
a pile of duplicate plays.</p>
<h2 id="filling-the-gap-between-exports">Filling the gap between exports</h2>
<p>An export is a snapshot, not a subscription — the moment it&#39;s imported it
starts going stale. <code>/api/spotify/scrobble</code> closes that gap: a secret-gated
endpoint that polls Spotify&#39;s recently-played on a schedule (a Coolify
Scheduled Task hits it periodically) and inserts anything new. Its
<code>ms_played</code> is only an estimate, so importing a fresh export later calls
<code>deleteScrobbledRange</code> first — anything scrobbled that the new export&#39;s date
range now covers for real gets replaced rather than double-counted.</p>
<h2 id="what-the-data-turns-into">What the data turns into</h2>
<p>Once a few years of plays are sitting in SQLite, the interesting part is
what you can compute from them cheaply: total listening time broken into
days/hours/minutes, peak year, most active weekday, top artists and tracks
(with a drill-down into which tracks made up an artist&#39;s plays), a
GitHub-style activity heatmap per year, an hour-of-day listening clock, and
an &quot;on this day&quot; section pulling matching dates across every year in the
history. All of it&#39;s just <code>GROUP BY</code> and a bit of date math over one table —
no external calls, no caching layer needed, because it&#39;s already local.</p>
<h2 id="search-twice">Search, twice</h2>
<p>The search box went through two versions. The first was a dropdown list of
text results — functional, but it needed a click-outside handler to dismiss
and gave you nothing to look at but track names. The second pass replaced it
with an inline grid of cards, each lazily fetching its own album art from
Spotify&#39;s public oEmbed endpoint — no auth, no token, just a fetch keyed and
cached by track URI, degrading to a plain music icon if it fails. A proper
&quot;no matches&quot; state and a clear button replaced the dismiss-by-clicking-
elsewhere behavior along the way.</p>
<p>Same idea as <a href="/devlog/2026-07-18-watching-page-and-simkl-api">building Watchlist</a>:
a page that started as &quot;just show the data&quot; kept growing search, filters,
and visual polish on top, one pass at a time, because once the underlying
data&#39;s already sitting in a local database, adding another way to look at it
is cheap.</p>
]]></content:encoded>
		</item>
		<item>
			<title>Giving Projects the same treatment as the devlog</title>
			<link>https://razerghost.xyz/devlog/2026-07-16-projects-pipeline</link>
			<guid>https://razerghost.xyz/devlog/2026-07-16-projects-pipeline</guid>
			<pubDate>Thu, 16 Jul 2026 00:00:00 GMT</pubDate>
			<description>The projects page was a hardcoded array with one entry. Now it&apos;s markdown, like everything else here.</description>
			<content:encoded><![CDATA[<img src="https://razerghost.xyz/devlog/2026-07-16-projects-pipeline/og.png" alt="" /><p>The projects page started as a single hardcoded object in the page
component — fine for one project, awkward the moment there&#39;s a second one
worth writing more than two sentences about.</p>
<h2 id="the-shape">The shape</h2>
<p>Same pattern as the devlog: frontmatter&#39;d markdown in
<code>src/content/projects/</code>, read off disk at request time.</p>
<pre><code class="language-ts">export interface ProjectMeta {
	slug: string;
	name: string;
	description: string;
	href?: string;
	live?: string;
	cover?: string;
	images: string[];
	tags: string[];
	stack: string[];
	status: ProjectStatus;
	featured: boolean;
	readingTime: number;
	searchText: string;
	draft: boolean;
	date: string;
}
</code></pre>
<p><code>getAllProjects()</code> lists them for the grid, <code>getProject(slug)</code> renders one
in full at <code>/projects/[slug]</code> — a straight copy of how <code>devlog.ts</code> already
works, down to reusing <code>gray-matter</code> for frontmatter and <code>marked</code> for the
body.</p>
<h2 id="what-this-unlocks">What this unlocks</h2>
<p>Each project now gets its own page instead of just a card that links out to
GitHub — room for a real writeup, screenshots, a live-site link, source
link, and tags, without touching a single line of Svelte. Add a <code>.md</code> file,
redeploy, done. Exactly the same deal the devlog already had.</p>
<p>This post is itself the first entry that isn&#39;t a devlog post <em>about</em>
something built — it&#39;s the writeup for a feature that&#39;s also visible one
click away, on <a href="/projects">the projects page</a> itself.</p>
<h2 id="update-the-copy-became-a-share">Update: the copy became a share</h2>
<p>That &quot;straight copy&quot; didn&#39;t stay a copy for long. <code>devlog.ts</code> and
<code>projects.ts</code> ended up with byte-identical <code>readEntryFile()</code> and
<code>toDateString()</code> functions — same <code>gray-matter</code> call, same
slug-from-filename logic, same fix for <code>gray-matter</code> parsing unquoted
frontmatter dates into <code>Date</code> objects instead of strings. Two copies of the
same fix is one more place for it to drift, so both now import
<code>readEntryFile</code>/<code>toDateString</code> from a shared <code>src/lib/server/content.ts</code>
instead of defining their own.</p>
<p>Their <code>toMeta()</code>/<code>getAll()</code>/<code>get()</code> functions stayed separate, though —
devlog posts have grown <code>series</code>, a table of contents, search text, and
footnotes that projects don&#39;t need, so forcing both into one shape would&#39;ve
cost more than the duplication it removed. Share the part that&#39;s actually
identical, not the part that just looks similar today.</p>
]]></content:encoded>
		</item>
		<item>
			<title>Two things the Dockerfile had to get right</title>
			<link>https://razerghost.xyz/devlog/2026-07-16-docker-build-tricks</link>
			<guid>https://razerghost.xyz/devlog/2026-07-16-docker-build-tricks</guid>
			<pubDate>Thu, 16 Jul 2026 00:00:00 GMT</pubDate>
			<description>pnpm&apos;s autoInstallPeers quietly defeats a --prod reinstall, and node:22-slim ships without curl for the HEALTHCHECK to shell out to.</description>
			<content:encoded><![CDATA[<img src="https://razerghost.xyz/devlog/2026-07-16-docker-build-tricks/og.png" alt="" /><p>Getting this shipped to Coolify meant a Dockerfile, and two things in it
turned out to be less obvious than they looked.</p>
<h2 id="prod-doesn-t-mean-what-it-sounds-like"><code>--prod</code> doesn&#39;t mean what it sounds like</h2>
<p>The build stage installs everything — devDependencies included — compiles
the app, then the runtime stage only needs the production deps. The
obvious move is a fresh <code>pnpm install --prod</code> there. That doesn&#39;t actually
shrink anything here: the icon packages (<code>@lucide/svelte</code>,
<code>@icons-pack/svelte-simple-icons</code>) declare <code>svelte</code> as a peerDependency,
and pnpm&#39;s <code>autoInstallPeers</code> resolves that whole peer chain —
<code>svelte</code> → <code>@sveltejs/kit</code> → <code>vite</code> → <code>esbuild</code>/<code>rollup</code>/<code>lightningcss</code> —
regardless of <code>--prod</code>, since peerDependencies aren&#39;t classified as dev
dependencies. The entire dev toolchain comes back anyway, just through a
different door.</p>
<p>The fix is to prune the <em>already-resolved</em> tree from the build stage
instead of asking pnpm to resolve a fresh one:</p>
<div data-embed="CodeDiff" data-lines='["-RUN pnpm install --prod","+RUN pnpm prune --prod && rm -rf \\","+  node_modules/.pnpm/typescript@* node_modules/.pnpm/vite@* \\","+  node_modules/.pnpm/esbuild@* node_modules/.pnpm/rollup@* \\","+  node_modules/.pnpm/@sveltejs+kit@*"]'></div>

<p><code>pnpm prune --prod</code> alone still leaves the dev toolchain physically on
disk, for the same peer-chain reason — so the leftover <code>.pnpm</code> directories
get removed explicitly afterward. Confirmed nothing on that list is ever
<code>require()</code>&#39;d at runtime by grepping the compiled <code>build/</code> output:
adapter-node&#39;s output is self-contained.</p>
<h2 id="no-curl-for-the-healthcheck">No curl for the HEALTHCHECK</h2>
<p>Both stages run on <code>node:22-slim</code>, which doesn&#39;t ship <code>curl</code> or <code>wget</code> —
and installing either just for a <code>HEALTHCHECK</code> isn&#39;t worth the extra image
layer. Node itself can make the request instead:</p>
<div data-embed="Terminal" data-lines='["node -e \"require(&apos;http&apos;).get(&apos;http://localhost:3000/healthz&apos;, r => process.exit(r.statusCode === 200 ? 0 : 1))\""]'></div>

<p>Dropped straight into the Dockerfile as the <code>HEALTHCHECK CMD</code>, polling
<code>/healthz</code> — a route that already existed for exactly this — so Coolify
(and <code>docker ps</code>) can see whether the container is actually serving, not
just that the process is alive. <code>--interval</code>, <code>--timeout</code>, and
<code>--start-period</code> keep it from flapping while the server boots.</p>
]]></content:encoded>
		</item>
		<item>
			<title>Picking a design system for a one-person site</title>
			<link>https://razerghost.xyz/devlog/2026-07-10-second-post</link>
			<guid>https://razerghost.xyz/devlog/2026-07-10-second-post</guid>
			<pubDate>Fri, 10 Jul 2026 00:00:00 GMT</pubDate>
			<description>Why this site borrows RG Digital&apos;s dark/near-black look, swaps the accent to cyan, and how one token file covers both light and dark mode.</description>
			<content:encoded><![CDATA[<img src="https://razerghost.xyz/devlog/2026-07-10-second-post/og.png" alt="" /><p>Didn&#39;t want to invent a new visual language just for a link hub, so this site
reuses the same near-black surface system as the other projects — same
spacing scale, same radii, same motion timing. The one deliberate change is
the accent color.</p>
<h2 id="lime-is-spoken-for">Lime is spoken for</h2>
<p>RG Digital&#39;s <code>#ccff00</code> lime is specifically that brand&#39;s accent — it doesn&#39;t
show up anywhere else. This site uses cyan instead, split into two tiers
rather than one flat accent color:</p>
<ul>
<li><code>--color-primary</code> (<code>#22d3ee</code>) for anything on screen at rest: links,
borders, button chrome, focus rings</li>
<li><code>--color-action</code> (<code>#00e5ff</code>) reserved for triggered/live moments — a flash,
a fill animation, something reacting to an event that just fired</li>
</ul>
<p>The rule of thumb is just &quot;if it&#39;s on screen at rest, use primary; if it&#39;s
reacting to something that just happened, use action.&quot; <code>.flash-in</code> and
<code>.pill-fired</code> are already defined in <code>tokens.css</code> for exactly that, but
nothing on the site fires them yet — a rule with no exceptions is easy to
follow later, so it&#39;s sitting there waiting rather than getting retrofitted
once something actually needs it.</p>
<h2 id="one-token-file-two-themes">One token file, two themes</h2>
<p>Light mode isn&#39;t a second design system — it&#39;s the same token file with a
<code>[data-theme=&#39;light&#39;]</code> override that only touches the neutral surface and
text tokens (<code>--bg</code>, <code>--surface</code>, <code>--border</code>, <code>--white</code>, <code>--gray</code>, <code>--dim</code>).
The accent color, the spacing scale, the radii, and every motion timing stay
byte-identical across both themes. <code>ThemeToggle.svelte</code> just flips an
attribute on <code>&lt;html&gt;</code> and persists the choice to <code>localStorage</code>; there&#39;s no
separate light-mode variant of any component to keep in sync.</p>
<h2 id="borrowed-the-boring-parts-wholesale">Borrowed the boring parts wholesale</h2>
<p>Spacing is a 4px-based scale (<code>4, 8, 12, 16, 20, 24, 32, 40, 48, 56, 64</code>),
radii are <code>6/8/12px</code> plus a <code>full</code> for pills, and the easing curves are the
same three cubic-beziers RG Digital already uses. The one convention worth
naming: UI-level transitions (hovers, toggles) stay under 300ms, page-level
transitions land around 400ms — copied straight from the source motion
guideline rather than reinvented per component. <code>prefers-reduced-motion</code>
turns off the ambient CTA glow and the flash-in animation for anyone who&#39;s
asked for less motion.</p>
<p>Kept it that simple on purpose. A personal devlog doesn&#39;t need a component
library — just enough consistency that it doesn&#39;t look like a template.</p>
]]></content:encoded>
		</item>
		<item>
			<title>Hello, world</title>
			<link>https://razerghost.xyz/devlog/2026-06-01-hello-world</link>
			<guid>https://razerghost.xyz/devlog/2026-06-01-hello-world</guid>
			<pubDate>Mon, 01 Jun 2026 00:00:00 GMT</pubDate>
			<description>First post on the new devlog — how this site is built and why it exists.</description>
			<content:encoded><![CDATA[<img src="https://razerghost.xyz/devlog/2026-06-01-hello-world/og.png" alt="" /><p>This is the first entry in the new devlog. The old razerghost.xyz was a static
portfolio; that content moved to <a href="https://rg-digital.dev">rg-digital.dev</a>. This
site is a smaller, personal thing: a link hub plus a running log of whatever
random project I&#39;m poking at.</p>
<h2 id="how-it-works">How it works</h2>
<p>Posts are plain markdown files in <code>src/content/devlog/</code>, read straight off disk
on every request — no static build step, no CMS, no database. Push a new <code>.md</code>
file, redeploy, done.</p>
<pre><code class="language-ts">const entries = getAllDevlogEntries();
</code></pre>
<p>Some things worth noting:</p>
<ul>
<li>No comments, no analytics</li>
<li>Runs as a real Node server (SvelteKit + <code>adapter-node</code>), not a static export</li>
<li>Posts can embed real, interactive Svelte components — see below</li>
</ul>
<h2 id="a-live-component-embedded-right-here">A live component, embedded right here</h2>
<p>Drop <code>&lt;div data-embed=&quot;Counter&quot;&gt;&lt;/div&gt;</code> into a post&#39;s markdown and it gets
hydrated client-side into an actual Svelte component after the page loads:</p>
<div data-embed="Counter"></div>

<p>Embeds can also take props via <code>data-*</code> attributes on the placeholder:</p>
<div data-embed="Terminal"></div>

<blockquote>
<p>Mostly this exists so I have somewhere to write things down.</p>
</blockquote>
]]></content:encoded>
		</item>
	</channel>
</rss>