What seasonal color analysis is

Seasonal color analysis is a personal styling framework that matches your wardrobe, makeup, and accessories to your natural coloring. The original 1980s system sorted people into four broad types named after the seasons. The modern 12-season system goes significantly deeper, breaking each family into three sub-seasons based on three independent dimensions: warmth (warm vs. cool undertones), depth (light vs. deep), and clarity (muted vs. clear).

The 12 types are True Spring, Light Spring, Warm Spring, Light Summer, True Summer, Cool Summer, Soft Autumn, True Autumn, Dark Autumn, Dark Winter, True Winter, and Bright Winter. Each one has a distinct color palette, set of clothing neutrals, makeup recommendations, jewelry metals, and hair guidance. Getting this right means buying fewer wrong things and looking genuinely good in what you own.

The theory is well-established. What does not exist, as a free tool that works well on a phone, is a site that gives you the full reference for your specific type without a subscription or a cluttered interface. That is what I set out to build.

What users actually experience

The home page shows all 12 seasonal types as a visual grid. You pick yours, or you work through the self-identification guide on the About page, which walks you through the three observable characteristics: vein color in natural light for warmth, a black-and-white selfie for contrast level, and how bright colors interact with your face for clarity.

Each season page loads a visually rich guide built from that season's editorial data. It shows the full color palette as swatches, describes the underlying color theory for that type, covers clothing neutrals and accent colors, gives makeup guidance by category (foundation undertone, lip shades, blush families, eye shadow ranges), addresses hair color, and shows which jewelry metals and stone tones work best.

Below the editorial content, the page renders curated product recommendations pulled live from the inventory engine: clothing, accessories, and beauty products that have been crawled from affiliate networks and matched to that season's palette and category keywords. Each product card links directly to the retailer via an affiliate URL.

Three pipelines, one Worker

The interesting part of Your Season is not the frontend. It is the backend architecture, which handles three entirely different kinds of data flowing through a single Cloudflare Worker.

Pipeline 1: Editorial

Season descriptions, palette data, and style guidance live in Google Sheets and are published manually to Cloudflare KV via a custom Apps Script menu button. The Worker serves this data to the frontend as pre-formatted JSON at approximately 8ms globally.

Pipeline 2: Inventory

Affiliate product data is crawled nightly from Amazon PA-API 5.0, CJ, and ShareASale by a Node.js container on my Unraid NAS, stored in SQLite, and served through a REST API exposed via Cloudflare Tunnel. The Worker proxies and caches these responses at the edge with a 1-hour TTL.

Pipeline 3: SEO / social

Social scrapers (iMessage link preview, Twitter card bots, LinkedIn) receive fully tagged HTML with per-season og:title, og:description, og:image, and twitter:card injected by the Worker using HTMLRewriter, cached for 1 hour per season URL.

Each pipeline has different latency requirements, different data owners, different update frequencies, and different failure modes. Running them as separate concerns means a problem in any one of them (say, the NAS going offline overnight) does not take down the others.

Pipeline 1: editorial content via Google Sheets and KV

The editorial content for each season is maintained in a Google Sheet. Season names, taglines, accent colors, palette hex codes, makeup notes, and wardrobe guidance are all edited there by a non-developer. A custom Apps Script menu button labeled "Publish to Cloudflare" fires a single UrlFetchApp.fetch() call to the Worker, which reads the Sheets REST API, serializes all 12 season objects into JSON, and writes them to KV in one pass.

The critical design decision here is that Apps Script is only the publish trigger. It is never in the live request path. Apps Script has 1 to 4 second cold starts and a 30-second execution cap. Using it to serve content to users would be a disaster. Once the data is in KV, the Worker serves it directly at Cloudflare edge speeds.

Do not use an onEdit trigger

An onEdit trigger fires on every keystroke and cell exit. Editing 50 hex codes in a session would trigger 50 individual KV writes, burning through Cloudflare KV's free tier limit of 1,000 writes per day in a single working session. The manual Publish button is intentional: it gives the editor control over when content goes live and costs exactly one KV write per publish.

Pipeline 2: the inventory engine on Unraid

The product recommendations are where things get interesting. Affiliate APIs do not let you cache responses indefinitely, and querying them in real time per page load would be too slow, too expensive, and rate-limited immediately. The solution is a background crawl architecture running on my Unraid NAS.

A Docker container on Unraid runs a Node.js 20 + Express API backed by a SQLite database via better-sqlite3. A second container runs the nightly affiliate crawler, which queries Amazon PA-API 5.0, CJ, and ShareASale for products matching each combination of season and category keyword (True Autumn tops, True Autumn outerwear, True Autumn lipstick, and so on) and writes the results to the SQLite product database.

The NAS is not directly accessible from the internet. A third Docker container, cloudflared tunnel, creates an outbound-only Cloudflare Tunnel connection that exposes the inventory API port through Cloudflare's network without any port forwarding, firewall rules, or public IP exposure. The Worker proxies requests to the NAS through this tunnel, adding a shared secret header that the Express middleware validates. Cloudflare caches the Worker's NAS responses at the edge with a 1-hour TTL, so even if the NAS is slow on any given request, only the first request per hour per season/category combination hits the NAS directly.

A fourth container runs Litestream, which continuously streams SQLite WAL frames to Cloudflare R2 as a rolling backup. If the NAS were to fail, the product database can be restored from R2 to any new host in minutes.

The inventory fetch is non-blocking on the frontend. The season page renders all of its editorial content immediately from KV, then makes a secondary fetch to the inventory endpoint. If it returns products, the wardrobe cards get populated with real affiliate links. If the NAS is down or the cache is cold, the cards show without product data rather than blocking the page.

Pipeline 3: per-season OG tags via HTMLRewriter

Social link previews are a significant traffic source for a site like this. When someone shares a True Autumn season page on Instagram, the preview should show the True Autumn palette swatch, the season name, and a relevant description. The naive implementation would be 12 separate HTML files, each with its own hard-coded meta tags. The problem is that all season content is loaded dynamically by JavaScript into a single season.html template, so without server-side intervention, every season URL would share the same generic meta tags.

The Cloudflare Worker solves this using HTMLRewriter. When the Worker detects that the incoming request is from a social scraper (based on the User-Agent), it reads the season slug from the query string, fetches the season metadata from KV, and uses HTMLRewriter to rewrite the <title>, og:title, og:description, og:image, og:url, and canonical tags in the raw HTML before it reaches the scraper. The JavaScript never executes. The scraper receives fully tagged HTML.

The complete response is cached for one hour at the edge, keyed by season URL. This means each season's social preview is only built once per cache window, regardless of how many times it is shared.

The SQLite FUSE gotcha on Unraid

This one cost me time, so it is worth documenting clearly.

Unraid's main array path (/mnt/user/) is served through mergerFS, a FUSE union filesystem. SQLite WAL mode requires mmap-based file locking that FUSE does not support. Mounting a Docker volume to /mnt/user/appdata/ causes intermittent SQLITE_BUSY errors and data corruption under concurrent write load, even with busy_timeout set high.

The fix is to mount Docker volumes to the cache drive's native filesystem instead:

# Wrong -- FUSE/mergerFS path, WAL incompatible
volumes:
  - /mnt/user/appdata/seasonal-colour/data:/data

# Correct -- raw XFS/BTRFS, WAL fully supported
volumes:
  - /mnt/cache/appdata/seasonal-colour/data:/data

/mnt/cache/ bypasses mergerFS and addresses the cache drive's native XFS or BTRFS filesystem directly, both of which provide the POSIX fcntl locking semantics that SQLite requires. The Litestream sidecar needs the same treatment since it reads WAL frames directly from the shared volume. A volume on a FUSE mount will cause Litestream to silently produce a corrupt R2 backup.

If no SSD cache drive is installed, /mnt/disk1/appdata/ works as a direct array disk path. Any raw array drive works. Only the FUSE-backed union path is problematic.

PWA implementation

Your Season installs as a full progressive web app on both iOS and Android. The PWA implementation covers three separate installation paths because the browser APIs differ significantly across platforms.

Android and Chromium-based browsers fire a beforeinstallprompt event, which the app captures and defers. After 30 seconds (early prompts have poor acceptance rates), a slide-up banner appears with an Install button that triggers the native browser install dialog.

iOS Safari and Chrome on iOS do not support beforeinstallprompt at all. For those users, a modal appears with step-by-step instructions: tap the Share button, then tap "Add to Home Screen." The modal includes proper focus trapping for accessibility, an Escape key handler, and focus restoration on dismiss.

Already-installed users (detected via window.navigator.standalone and the display-mode: standalone media query) skip all install UI entirely. A 7-day dismissal cooldown stored in localStorage prevents the install prompt from reappearing immediately after a user declines.

The Service Worker also handles the update lifecycle: when a new version is waiting to activate, a "Refresh" toast appears in the DOM. Clicking it posts SKIP_WAITING to the new Service Worker, then reloads the page once the new worker has claimed the client. This avoids the common issue where a user has a stale version cached indefinitely.

iOS splash screens are included for every major iPhone and iPad screen size to eliminate the white-screen flash on cold launch from the home screen.

Key decisions

Why Google Sheets as a CMS?

The editorial content needs to be editable without a developer in the loop. Google Sheets provides that with zero infrastructure cost, a familiar interface, and a robust API. The important constraint is that Sheets never touches the live request path. It is purely a write interface; the Worker reads from KV exclusively when serving users.

Why a self-hosted NAS rather than a managed database?

The inventory engine runs nightly crawl jobs against multiple affiliate APIs and stores results in SQLite. At current traffic volumes, the NAS runs this entire pipeline at zero ongoing compute cost. The Cloudflare Worker layer caches responses aggressively enough that the NAS query load is very low. The exit path to a cloud host (Fly.io, Hetzner, or Turso) is fully documented and executable in under two hours if the NAS situation ever changes.

Why vanilla JS with no build step?

The same reason as every other project in this stack: build steps break, drift, and add maintenance overhead that does not contribute to product value. The entire Your Season frontend is HTML, CSS, and ES modules. It loads fast on mobile, renders on low-end hardware, and is easy to debug. The one external dependency is Google Fonts, loaded via preconnect for performance.

Why Cloudflare R2 for the Litestream backup target?

R2 is S3-compatible, which means Litestream works with it without modification using the S3 provider and a custom endpoint URL. Zero egress fees mean streaming WAL frames to R2 continuously costs essentially nothing. And keeping backups in the same provider ecosystem as the rest of the stack (Pages, Workers, KV, Tunnel) simplifies credentials management significantly.

Your Season is live at yourseason.shop. Pick your season and see what the full guide looks like.