RM
Rebuild · 7 min read

My Gomi Guide: Solving the Four-Bin Problem

Four bins, five collection days, and a poster I kept re-reading from the top. So I built a tool to stop holding that in my head.

#japan#minato#guide

I handle all the trash in my household.

We have a poster on our fridge. It explains which bin, which day, how to prepare each item. It’s been there since we moved in, held up by a plain magnet, taking up space I’d rather give to a travel souvenir.

Every few days I’d glance at it before heading to the trash room. Sometimes I’d read the whole thing from the top just to confirm what I already thought I knew.

So I built a tool. The rules are in my phone now. The poster is coming down.

That space on the fridge is going to Japan Rail Pass magnets.

Gomi is not simple

The Japanese word for trash is gomi (ごみ). Japan’s recycling system is thorough and has a learning curve. Minato ward has four regular collection categories, each on a different day, each with its own prep rules.

That fridge poster is bilingual: English and Japanese, with illustrated examples for each category. I photographed it and started cataloguing.

The trash sorting guide poster in our apartment bin room, showing four collection categories with illustrated examples in English and Japanese
The English version of the poster on our fridge.

Here’s how the four categories break down:

Recyclable Plastics

資源プラスチック

FRI
  • Plastic bags & food trays (プラ mark)
  • Shampoo & detergent bottles
  • Plastic wrap & cup lids

Every Friday. プラ-marked plastics, rinsed or wiped. Light residue is fine.

Resources

資源

TUE
  • PET bottles (ペットボトル) — cap separate
  • Glass bottles, aluminium & steel cans
  • Newspaper, cardboard, clean paper

Every Tuesday. Rinse bottles and cans. Tie paper in bundles.

Combustible Garbage

可燃ごみ

WED / SAT
  • Food scraps, tissues, soiled paper
  • Old clothes, rubber, leather
  • Dirty styrofoam, pens without プラ mark

Every Wednesday and Saturday. The most frequent pickup.

Non-combustible Garbage

不燃ごみ

2nd & 4th THU
  • Ceramics, broken glass, metal pots
  • Light bulbs, umbrellas, small appliances
  • Spray cans & gas canisters (empty, separate bag)

Only twice a month. Miss it and you wait two weeks.

The poster has the basics. It even shows プラ for recyclable plastics. Here’s what I figured out by actually sorting:

Plastic is two categories

PET bottles go Tuesday as resources. プラ trays go Friday as recyclable plastics.

The cap comes off

Bottle is resources (Tue). Cap is recyclable plastics (Fri). Same item, split across pickups.

Dirty vs clean styrofoam

Clean with プラ mark = recyclable plastics. Soiled or greasy = burnable. Same material, different bin.

Non-combustibles twice a month

2nd and 4th Thursday. Miss it and you wait two weeks.

Over 30 cm is separate

Large waste (粗大ごみ) needs an advance booking, fee, and scheduled pickup. Not the bin.

What I built

The dataset covers 50 items across all four categories. Beyond the standard items, it covers the edge cases that matter most.

Tricky edge cases you will encounter

  • Rechargeable batteries are a fire hazard

    Since September 2025 Minato takes them on the non-combustible day. Tape the terminals, bag them separately, and label the bag キケン. Drop-off boxes also work. Never mix them into resources or plastics.

  • Smartphones go in a ward office collection box

    Delete your data first. These need a separate drop-off, not regular ward collection.

  • Home appliances under the Appliance Recycling Law

    Items like TVs, ACs, and refrigerators require a special disposal route, not regular pickup.

Search results show a one-line TL;DR, the collection day, and a prep section broken into bullets. The weekly schedule auto-highlights today’s collection.

Before generating a line of code

I framed what “good” meant for this specific tool.

GoalWhy
Correctness firstA wrong guess means your garbage stays in the shared trash room. No answer is safer than a confident guess.
Precision over recallSearch “conditioner” and the results include air conditioner too. With 50 items, false positives create more noise than a missed match.
Data changes without deploysMisclassifications happen after launch. Fixing a keyword shouldn’t require a site rebuild.

The constraints I was working inside:

ConstraintWhat it ruled out
No server runtimeThe site is output: 'static' Astro. All logic runs in the browser.
No ReactThe whole site is Astro + vanilla TypeScript. Significant framework overhead for one filterable list is not worth it.
Tailwind JIT scanningDiscovered mid-build. Tailwind scans source files at build time. JSON fetched at runtime is invisible to it. Style strings had to stay in component source.

Here’s what the system looks like from the outside:

System context

Developer

Code change

git push → Vercel rebuild → CDN

Data change

node upload.js → Blob overwrite
deploy
upload

Vercel CDN

HTML / JS / CSS

Built at deploy time

Vercel Blob

gomi-dataset.json

Updated without redeploy

static assets
fetch()

Browser — resident

Loads tool shell from CDN
Fetches dataset from Blob — cache: 'no-cache'
Runs entirely client-side from here

The browser gets code from Vercel’s CDN and data from Blob. A data fix is a file upload. A code change goes through git. Neither requires the other.

How it evolved

It started as a simple list.

StepWhat changedProblem it solved
1Hardcoded array inside the componentGet something working; all data in one place
2Dataset moved to Vercel BlobFix misclassifications without a full redeploy
3CATEGORY_STYLES constant in component sourceCategory colors disappeared when style strings moved to JSON
4Three-state loading machineAsync fetch needs explicit handling for loading, error, and success
5Icons, TL;DRs, dedicated tool shellFaster scanning; tool feels like a tool, not a page on a blog

Steps 2 through 4 each left a mark.

The Tailwind gotcha that bit me

Tailwind’s JIT compiler scans source files at build time and generates only the CSS classes it finds. JSON fetched at runtime is invisible to it. Move category color strings into that JSON and they get dropped from the compiled CSS entirely. Everything renders unstyled.

gomi-dataset.json
{
  "plastics": {
    "badge": "bg-sky-100
              text-sky-700
              dark:bg-sky-900/40"
  }
}

Tailwind can't scan this at build time. The classes get stripped.

GomiGuide.astro
const CATEGORY_STYLES = {
  plastics: {
    badge: "bg-sky-100
            text-sky-700
            dark:bg-sky-900/40"
  }
};

Tailwind scans this file. All classes make it into the CSS bundle.

Data lives in the JSON. Styling lives in the component. Both end up in the right place.

The fix: keep CSS class strings in the component source as a static mapping. Data (item names, keywords, prep instructions, icon paths) lives in the JSON. Styling (badge colors, card backgrounds, borders) lives in a constant the scanner can reach.

One entry per category. Every class string is written out fully so the JIT scanner finds them:

const CATEGORY_STYLES = {
  plastics:       { badge: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300',           card: 'bg-sky-50/50 dark:bg-sky-950/20',          border: 'border-sky-200 dark:border-sky-800/40',          dayLabel: 'text-sky-600 dark:text-sky-400',       dotColor: 'bg-sky-500 dark:bg-sky-400' },
  resources:      { badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300', card: 'bg-emerald-50/50 dark:bg-emerald-950/20',    border: 'border-emerald-200 dark:border-emerald-800/40',    dayLabel: 'text-emerald-600 dark:text-emerald-400', dotColor: 'bg-emerald-500 dark:bg-emerald-400' },
  combustible:    { badge: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',    card: 'bg-orange-50/50 dark:bg-orange-950/20',       border: 'border-orange-200 dark:border-orange-800/40',       dayLabel: 'text-orange-600 dark:text-orange-400',   dotColor: 'bg-orange-500 dark:bg-orange-400' },
  noncombustible: { badge: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300',    card: 'bg-violet-50/50 dark:bg-violet-950/20',       border: 'border-violet-200 dark:border-violet-800/40',       dayLabel: 'text-violet-600 dark:text-violet-400',   dotColor: 'bg-violet-500 dark:bg-violet-400' },
};

At runtime, initUI() merges the fetched JSON with CATEGORY_STYLES via Object.assign(). Data from Blob, styles from the component — neither source is complete on its own.

Vercel Blob

gomi/gomi-dataset.json

public CDN

Items, categories, keywords, icons — no Tailwind classes

fetch(BLOB_URL, cache: 'no-cache' )

loadData()

Sets loading → content state. Triggers on DOMContentLoaded.

on success

initUI(data) — merge step

JSON fields + CATEGORY_STYLES Object.assign()

Data from Blob + styles from component source = complete category object

renders to DOM

Rendered UI

weekly timeline search results

State machine for loading, error, and content

Instead of toggling visibility in ad-hoc places, I built a three-state machine.

Component state machine

loading

Skeleton cards

#gomi-loading

fetch succeeds

content

Search + timeline

#gomi-content

fetch fails

error

Error + Retry btn

#gomi-error

retry → loading

Each state owns a DOM element: #gomi-loading for the skeleton, #gomi-error for the error card with a Retry button, #gomi-content for the tool itself. The three state functions toggle visibility on those elements.

The Retry button re-invokes loadData() in place. No page reload needed.

Search uses a custom boundary regex, not plain substring matching. Early builds matched “conditioner” to “air conditioner” and “can” to “candle.” The pattern uses (^|[^a-z])..([^a-z]|$) rather than \b, because \b treats the space in a multi-word keyword as a boundary and produces false negatives on phrases. With 50 items the match is fast, but the input is still debounced at 200 ms to avoid firing on every keystroke.

The poster came down

One note: this covers Azabu-Nagasakacho specifically. Pickup days vary by address even within Minato ward. The Minato ward official page is the authoritative source.

The point was to stop re-reading it from the top and take it down.

That spot is going to a magnet from Kyoto. One from Sapporo. One from wherever we go next.

The rules are in my phone now. The space on the fridge is mine.

Open My Gomi Guide →