Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "inventory-site",
"runtimeExecutable": "python3",
"runtimeArgs": ["-m", "http.server", "8412", "--directory", "_site"],
"port": 8412
}
]
}
66 changes: 66 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: deploy site

on:
push:
branches: [main]
paths:
- "data/**"
- "site/**"
- "scripts/build_site.py"
- "scripts/make_qr_labels.py"
- ".github/workflows/pages.yml"
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

# Never let two deploys race; a later push should win.
concurrency:
group: pages
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- run: pip install -r requirements.txt

# Data must be valid before it gets published to a public site.
- name: Validate data
run: python3 scripts/validate.py

- name: Configure Pages
id: pages
uses: actions/configure-pages@v5

- name: Build search site
run: python3 scripts/build_site.py

# QR payloads must point at wherever Pages actually serves this repo, so
# take the base URL from configure-pages rather than hardcoding it.
- name: Build QR label sheet
run: |
python3 scripts/make_qr_labels.py \
--base-url "${{ steps.pages.outputs.base_url }}"

- uses: actions/upload-pages-artifact@v3
with:
path: _site

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- id: deploy
uses: actions/deploy-pages@v4
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
.DS_Store
.claude/settings.local.json
_site/
16 changes: 10 additions & 6 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,12 +156,16 @@ Answer §7. Nothing below can be built without the repo-visibility and photo-pri
- `scripts/validate.py` + a CI workflow: schema check, closed-set enforcement, `location_id` referential integrity, ISO date check, duplicate `item_id` check. This is what keeps the data clean once several people are editing.
- **Done when:** `items.csv` has ~500 rows, validation is green in CI, and the xlsx is never opened again. **✅ Done 2026-08-03: 499 items, 173 locations, 16 review-queue rows.**

### Phase 2 — Make things findable (~6–8 h)
- `scripts/build_site.py` → `inventory.json` + one page per location; GitHub Action rebuilds on every push to `data/`.
- Single-page search UI: instant fuzzy search across name/category/notes/location, filter by room and status, mobile-first layout. Vendored Fuse.js, no framework, no bundler.
- Location deep links (`/#209-CAB-01`) so a QR code lands on "here is what is in this drawer."
- `scripts/make_qr_labels.py` → printable PDF sheet of QR stickers, one per location. **This is the feature that makes the system feel real** — scan the drawer, see the contents.
- **Done when:** a grad student on a phone can answer "do we have EZ DNA Methylation-Gold?" in under 10 seconds.
### Phase 2 — Make things findable (~6–8 h) — ✅ **Done 2026-08-03**
- `scripts/build_site.py` → a single self-contained `_site/index.html` with the data **inlined**, not fetched as a JSON sidecar. Inlining means the built page works over `file://`, so previewing it needs no local server and there are no CORS surprises.
- Single-page search UI: instant search across name/category/notes/owner/vendor **and location label**, so "fridge door" and "cabinet 3" are queries too. Filters for room, category, and status; match highlighting; mobile-first; dark mode.
- **No search library.** 499 items is small enough that a precomputed lowercase haystack per item plus a scored linear scan beats any index, and there's nothing vendored to keep alive. Scoring: name-prefix > name-substring > any-field, and every query token must match somewhere.
- Location deep links (`/#209-CAB-01`) with **descendant rollup** — scanning the sticker on the -20 freezer shows all 49 items across its shelves and drawers, not the zero items filed directly against the freezer itself.
- `scripts/make_qr_labels.py` → printable sheet of QR stickers, one per location, filterable by room and kind. **HTML, not PDF**, so there's no PDF toolchain to maintain: open and print from the browser. Sticker geometry is CSS variables at the top of the generated file, so it can be nudged to fit label stock without touching the script.
- `.github/workflows/pages.yml` validates, builds, and deploys on every push to `main`. QR payloads take their base URL from `configure-pages` output rather than a hardcoded string, so the codes always point at wherever Pages actually serves the repo.
- **Done when:** a grad student on a phone can answer "do we have EZ DNA Methylation-Gold?" in under 10 seconds. ✅

**Requires one manual step:** Settings → Pages → Source = **GitHub Actions**. The workflow can't enable Pages for the repo itself.

### Phase 3 — Make writing easy (~4–6 h)
- GitHub Issue Forms: **Add item**, **Mark consumed / low**, **Found something not listed**, **Verified a location**. Dropdowns populated from `locations.csv` (regenerated by the build Action, so they never drift).
Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Development plan and architecture: **[PLAN.md](PLAN.md)** · Photo rules (public

## Status

**Phase 1 complete** — the spreadsheet has been migrated to canonical CSVs with CI validation. Search UI, add-item forms, and photo intake are still to come; see [PLAN.md §5](PLAN.md#5-phases).
**Phases 1–2 complete** — the spreadsheet is migrated to validated CSVs, and there's a searchable site with printable QR stickers for every location. Add-item forms (Phase 3) and photo intake (Phase 4) are still to come; see [PLAN.md §5](PLAN.md#5-phases).

| | |
|---|---|
Expand All @@ -33,6 +33,22 @@ Two fields carry more weight than they look like they do:
- **`last_verified`** — every migrated row says `2021-08-29`, because that's what the spreadsheet's own sheet names claim. This makes five years of staleness a queryable fact instead of a caveat.
- **`source`** — `legacy-xlsx`, `manual`, or `photo-llm`. Permanent provenance, so an audit can always ask which rows a model wrote.

## The site

Search UI and QR stickers deploy from `main` to GitHub Pages. Build and preview locally:

```bash
python3 scripts/build_site.py && python3 scripts/make_qr_labels.py
```

That writes `_site/index.html` (self-contained — just open it, no server needed) and `_site/labels.html`, a printable sheet of QR stickers. Each sticker encodes `<site>/#<location_id>`, so scanning the one on a drawer shows that drawer's contents, including anything nested inside it.

Stickers for just part of the lab:

```bash
python3 scripts/make_qr_labels.py --rooms 213 --kinds drawer cabinet
```

## Working with it

```bash
Expand Down
18 changes: 9 additions & 9 deletions data/items.csv
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
item_id,name,category,location_id,quantity,unit,vendor,catalog_no,lot,received,expires,status,last_verified,verified_by,owner,source,photo_id,notes
itm-00001,Plastic graduated cylinders,glassware,209-CAB-01,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00002,Plastic boxes to hold sample tubes,sample,209-CAB-02,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00002,Plastic boxes to hold sample tubes,consumable,209-CAB-02,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00003,"Qubit RNA and DNA buffer, qubit assay tubes, DNA/RNA dye",equipment,209-DRW-03,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00004,Syringes and needles,consumable,209-DRW-04,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00005,Shaker supplies,equipment,209-DRW-05,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
Expand Down Expand Up @@ -31,11 +31,11 @@ itm-00029,labratory scoop,tool,209-DRW-07,,,,,,,,unverified,2021-08-29,,,legacy-
itm-00030,long handle micro scoop/spatulas,tool,209-DRW-07,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00031,handheld motorized pestle homogenizer,equipment,209-DRW-07,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00032,dissection plates,consumable,209-DRW-08,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00033,Plastic boxes to hold sample tubes,sample,209-CAB-09,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00033,Plastic boxes to hold sample tubes,consumable,209-CAB-09,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00034,Plastic bottles,glassware,209-CAB-10,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00035,plastic containers,consumable,209-CAB-10,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00036,2000mL flask,glassware,209-CAB-10,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00037,paper and plastic boxes to hold sample tubes,sample,209-CAB-10,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00037,paper and plastic boxes to hold sample tubes,consumable,209-CAB-10,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00038,Electrophoresis supplies,other,209-CAB-11,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00039,Electrophoresis supplies,other,209-CAB-12,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00040,Large bottle brushes,glassware,209-CAB-13,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
Expand Down Expand Up @@ -85,10 +85,10 @@ itm-00083,Scientific calculator,office,209-DRW-34,,,,,,,,unverified,2021-08-29,,
itm-00084,empty google notebook,office,209-DRW-34,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00085,graph paper on clipboard,office,209-DRW-34,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00086,paper/plastic report covers,office,209-DRW-34,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00087,EZNA mollusc dna kit product manual,kit,209-DRW-34,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00088,tissue FIX container (50mL) manual,consumable,209-DRW-34,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00087,EZNA mollusc dna kit product manual,office,209-DRW-34,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00088,tissue FIX container (50mL) manual,office,209-DRW-34,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00089,Mostly empty composition notebook (only first 3 pages used),office,209-DRW-35,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00090,DNeasy blood and tissue handbook,sample,209-DRW-35,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00090,DNeasy blood and tissue handbook,office,209-DRW-35,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00091,800mL commode collection container,consumable,209-DRW-37,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00092,Glass bottles,glassware,209-CAB-38,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00093,Glass bottles,glassware,209-CAB-39,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
Expand Down Expand Up @@ -245,7 +245,7 @@ itm-00243,ProFound c-Myc Tag IP/Co-IP Application Set,other,209-FRIDGE-S01,,,,,,
itm-00244,DNA C. gigas samples in snap cap tubes (blue tube rack),sample,209-FRIDGE-S01,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00245,DNA C. sikamea samples in snap cap tubes (green and red tube rack),sample,209-FRIDGE-S01,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00246,P5/P7 TRUSEQ in snap cap tubes (orange tube rack),sample,209-FRIDGE-S01,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00247,"Bag labeled ""Shelly"" with sample tubes",sample,209-FRIDGE-S01,,,,,,2021-10-30,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00247,"Bag labeled ""Shelly"" with sample tubes",consumable,209-FRIDGE-S01,,,,,,2021-10-30,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00248,Flow Cell compatible with MinION and GridION,other,209-FRIDGE-S01,4,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00249,MethylMiner Methylated DNA enrichment kit (x5 boxes),kit,209-FRIDGE-S01,,,,,,2019-02-14,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00250,Rneasy MinElute Spin Columns (in plastic grocery bag),consumable,209-FRIDGE-S01,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
Expand Down Expand Up @@ -420,7 +420,7 @@ itm-00418,DNA Chips,consumable,213-SHELF-S02,,,,,,2015-10-28,,unverified,2021-08
itm-00419,High Sensitivity DNA Chips,consumable,213-SHELF-S02,,,,,,2020-11-14,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00420,RNA Pico chips,consumable,213-SHELF-S02,,,,,,2018-01-04,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00421,RNA Pico chips,consumable,213-SHELF-S02,,,,,,2019-11-01,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00422,Kit instructions,kit,213-SHELF-S02,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00422,Kit instructions,office,213-SHELF-S02,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00423,Mini compostion (partly filled out),other,213-SHELF-S02,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00424,Syringes and safe-lock tubes,consumable,213-SHELF-S02,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00425,RNeasy Plus Micro kit (50),kit,213-SHELF-S03,,,,,,2019-03-22,,unverified,2021-08-29,,,legacy-xlsx,,
Expand Down Expand Up @@ -495,6 +495,6 @@ itm-00493,"small jar labeled ""sample collected in May 2000""",sample,213-F20-S0
itm-00494,MethylMiner Methylated DNA enrichment kit *CRAB ONLY* (opened 10/28/2020),kit,213-F20-S04-D06,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00495,CRC 471 gray whale blubber/skin,other,213-F20-S04-D06,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00496,small bag with several small bivalve samples,sample,213-F20-S04-D06,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00497,2-Mercaptoethanol (opened,reagent,213-F20-S04-D07,,,,,,2018-08-06,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00497,2-Mercaptoethanol,reagent,213-F20-S04-D07,,,,,,2018-08-06,,unverified,2021-08-29,,,legacy-xlsx,,other dates noted: 2018-08-08; opened
itm-00498,"Misc. samples (crab, geoduck, etc.)",sample,213-F20-S05,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
itm-00499,SPUD IPC stock reagants,reagent,213-F20-S05,,,,,,,,unverified,2021-08-29,,,legacy-xlsx,,
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
openpyxl>=3.1
segno>=1.6
116 changes: 116 additions & 0 deletions scripts/build_site.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Build the static search site into _site/.

Reads data/*.csv and inlines it into site/index.html, producing a completely
self-contained _site/index.html. Inlining rather than fetching a JSON sidecar
means the built page works over file:// -- no local web server needed to
preview it, and no CORS surprises.

Usage:
python3 scripts/build_site.py [--data DIR] [--template FILE] [--out DIR]
"""

from __future__ import annotations

import argparse
import csv
import datetime as dt
import json
from pathlib import Path

# Includes the `null` fallback so the substitution replaces the whole
# expression. Matching only the comment would leave `const DATA = {...}null;`,
# which is a syntax error -- and the template stays valid JavaScript this way.
PLACEHOLDER = "/*__INVENTORY_DATA__*/null"

# Short keys: this JSON is inlined into the page, so it is worth keeping small.
ITEM_KEYS = {
"item_id": "id", "name": "n", "category": "c", "location_id": "l",
"quantity": "q", "unit": "u", "vendor": "v", "catalog_no": "cat",
"received": "r", "expires": "e", "status": "s", "last_verified": "lv",
"owner": "o", "source": "src", "notes": "nt",
}


def read(path: Path) -> list[dict]:
with path.open(newline="") as handle:
return list(csv.DictReader(handle))


def breadcrumb(location_id: str, by_id: dict[str, dict]) -> str:
"""Human-readable ancestry, e.g. '-20C freezer Rm 213 > shelf 2 > drawer 8'."""
parts, node, guard = [], by_id.get(location_id), 0
while node and guard < 12:
parts.append(node["label"] or node["location_id"])
node = by_id.get(node["parent_id"]) if node["parent_id"] else None
guard += 1
return " › ".join(reversed(parts))


def build(datadir: Path, template: Path, outdir: Path) -> dict:
rooms = read(datadir / "rooms.csv")
locations = read(datadir / "locations.csv")
items = read(datadir / "items.csv")

by_id = {loc["location_id"]: loc for loc in locations}
counts: dict[str, int] = {}
for item in items:
counts[item["location_id"]] = counts.get(item["location_id"], 0) + 1

payload = {
"generated": dt.date.today().isoformat(),
"rooms": [{"id": r["room"], "label": r["label"], "notes": r["notes"]}
for r in rooms],
"locations": [
{
"id": loc["location_id"],
"room": loc["room"],
"kind": loc["kind"],
"label": loc["label"] or loc["location_id"],
"parent": loc["parent_id"],
"path": breadcrumb(loc["location_id"], by_id),
"notes": loc["notes"],
"count": counts.get(loc["location_id"], 0),
}
for loc in locations
],
"items": [
{short: item[long] for long, short in ITEM_KEYS.items() if item[long]}
for item in items
],
}

html = template.read_text()
if PLACEHOLDER not in html:
raise SystemExit(f"{template}: missing {PLACEHOLDER} placeholder")
# separators= drops the whitespace json.dumps adds by default.
data = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
html = html.replace(PLACEHOLDER, data)

outdir.mkdir(parents=True, exist_ok=True)
target = outdir / "index.html"
target.write_text(html)

# .nojekyll stops GitHub Pages from running Jekyll over the output, which
# would otherwise strip files beginning with an underscore.
(outdir / ".nojekyll").write_text("")

print(f"wrote {target} ({target.stat().st_size / 1024:.0f} KB)")
print(f" {len(payload['items'])} items, {len(payload['locations'])} locations, "
f"{len(payload['rooms'])} rooms")
return payload


def main() -> None:
root = Path(__file__).resolve().parent.parent
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--data", default=root / "data", type=Path)
parser.add_argument("--template", default=root / "site" / "index.html",
type=Path)
parser.add_argument("--out", default=root / "_site", type=Path)
args = parser.parse_args()
build(args.data, args.template, args.out)


if __name__ == "__main__":
main()
Loading
Loading