updated quality for ore

This commit is contained in:
2026-11-12 13:57:23 -07:00
parent 4fbad900a2
commit c8c5e658e2
12 changed files with 376 additions and 25 deletions

View File

@@ -119,18 +119,29 @@ The inventory view can be filtered by category and searched by name/class
name, and sorted by `name` (default), `category`, `quantity` (high to low) or name, and sorted by `name` (default), `category`, `quantity` (high to low) or
`updated` (most recently changed first). `updated` (most recently changed first).
Inventory rows store `(user_id, class_name, quantity, notes, visibility)`. Inventory rows store `(user_id, class_name, quantity, notes, visibility,
Adding an item that is already present increments its quantity; quantity, notes quality)`. Adding an item that is already present increments its quantity;
and visibility can then be edited or the row removed. Every added item starts quantity, notes, visibility and quality can then be edited or the row removed.
`private`; the Visibility toggle switches it to `available` and back. Responses Every added item starts `private`; the Visibility toggle switches it to
are enriched live from the catalog. If a catalog item disappears after a `available` and back. Responses are enriched live from the catalog.
`quality` is an optional mining/refining value from **1 to 1000**. It is only
accepted for commodities that are actually mineable — ores, minerals and gems
(and their refined/raw/pure variants). Gases, organics, processed goods, crafted
alloys and salvage are excluded. The catalog exposes this as
`quality_applicable`; the UI shows a Quality input only for those commodities.
`null` means the owner has not recorded a quality. On `PATCH`, sending
`"quality": null` clears it, while omitting the field leaves it unchanged. Each
inventory row holds one quality (the schema keeps one row per commodity, so
different quality lots are not separated). If a catalog item disappears after a
dataset refresh, the row is reported with `available: false` instead of being dataset refresh, the row is reported with `available: false` instead of being
silently deleted (`available` is about the catalog, `visibility` is the owner's silently deleted (`available` is about the catalog, `visibility` is the owner's
classification). classification).
The **Available** tab (backed by `GET /api/v1/available`) is public, like the The **Available** tab (backed by `GET /api/v1/available`) is public, like the
catalog: it lists every row marked `available` across all accounts with the catalog: it lists every row marked `available` across all accounts with the
item's catalog details, quantity, owner username and last update. Private rows item's catalog details, quantity, quality (when applicable), owner username and
last update. Private rows
never appear there and inventory notes are never exposed. never appear there and inventory notes are never exposed.
Clicking an item name in either **My Inventory** or **Available** opens the same Clicking an item name in either **My Inventory** or **Available** opens the same
@@ -188,7 +199,8 @@ so no PostgreSQL server is required for the suite. Production uses PostgreSQL.
Commodities are deduplicated to one catalog entry per name (the 1 SCU container Commodities are deduplicated to one catalog entry per name (the 1 SCU container
variant is canonical); quantity lives on the inventory row, so 1 SCU, 8 SCU and variant is canonical); quantity lives on the inventory row, so 1 SCU, 8 SCU and
32 SCU crates of the same commodity share a single entry. Refined and unrefined 32 SCU crates of the same commodity share a single entry. Refined and unrefined
materials stay separate (`Iron` vs `Iron (Ore)`). materials stay separate (`Iron` vs `Iron (Ore)`). Quality applies to both the
refined and raw/ore entries and is user-recorded per inventory row.
Tables are created on startup with an idempotent `create_all` and a connection Tables are created on startup with an idempotent `create_all` and a connection
retry loop, so a fresh volume bootstraps itself and a slow PostgreSQL start does retry loop, so a fresh volume bootstraps itself and a slow PostgreSQL start does
@@ -212,8 +224,8 @@ GET /api/v1/auth/me -> {user}
GET /api/v1/me/inventory?category=&search=&sort=&page=&page_size= GET /api/v1/me/inventory?category=&search=&sort=&page=&page_size=
GET /api/v1/me/inventory/summary GET /api/v1/me/inventory/summary
POST /api/v1/me/inventory {class_name, quantity?, notes?} POST /api/v1/me/inventory {class_name, quantity?, notes?, quality?}
PATCH /api/v1/me/inventory/{class_name} {quantity?, notes?, visibility?} PATCH /api/v1/me/inventory/{class_name} {quantity?, notes?, visibility?, quality?}
DELETE /api/v1/me/inventory/{class_name} DELETE /api/v1/me/inventory/{class_name}
GET /api/v1/available?category=&search=&sort=&page=&page_size= GET /api/v1/available?category=&search=&sort=&page=&page_size=

View File

@@ -55,3 +55,4 @@ LOGIN_WINDOW_SECONDS = int(os.environ.get("LOGIN_WINDOW_SECONDS", "900"))
# --- Input limits --------------------------------------------------------- # --- Input limits ---------------------------------------------------------
MAX_NOTES_LENGTH = 1000 MAX_NOTES_LENGTH = 1000
MAX_QUANTITY = 1_000_000 MAX_QUANTITY = 1_000_000
MAX_QUALITY = 1000

View File

@@ -83,6 +83,29 @@ def _upgrade_schema(engine) -> None:
""" """
) )
) )
conn.execute(
text(
"ALTER TABLE inventory_entries ADD COLUMN IF NOT EXISTS "
"quality INTEGER"
)
)
conn.execute(
text(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_inventory_quality_range'
) THEN
ALTER TABLE inventory_entries
ADD CONSTRAINT ck_inventory_quality_range
CHECK (quality IS NULL OR (quality >= 1 AND quality <= 1000));
END IF;
END $$
"""
)
)
def init_db(wait_seconds: int = 0, interval: float = 1.0) -> None: def init_db(wait_seconds: int = 0, interval: float = 1.0) -> None:

View File

@@ -46,6 +46,8 @@ def enrich_entry(entry: InventoryEntry) -> dict:
"manufacturer": None, "manufacturer": None,
"quantity": entry.quantity, "quantity": entry.quantity,
"notes": entry.notes, "notes": entry.notes,
"quality": entry.quality,
"quality_applicable": False,
"visibility": entry.visibility, "visibility": entry.visibility,
"available": False, "available": False,
"created_at": entry.created_at, "created_at": entry.created_at,
@@ -61,6 +63,8 @@ def enrich_entry(entry: InventoryEntry) -> dict:
"manufacturer": item["manufacturer"], "manufacturer": item["manufacturer"],
"quantity": entry.quantity, "quantity": entry.quantity,
"notes": entry.notes, "notes": entry.notes,
"quality": entry.quality,
"quality_applicable": item.get("quality_applicable", False),
"visibility": entry.visibility, "visibility": entry.visibility,
"available": True, "available": True,
"created_at": entry.created_at, "created_at": entry.created_at,
@@ -68,6 +72,14 @@ def enrich_entry(entry: InventoryEntry) -> dict:
} }
def _validate_quality(item: dict, quality: int | None) -> None:
if quality is not None and not item.get("quality_applicable", False):
raise HTTPException(
status_code=422,
detail=f"Quality does not apply to '{item['name']}'",
)
def _find(db: DbSession, user: User, class_name: str) -> InventoryEntry | None: def _find(db: DbSession, user: User, class_name: str) -> InventoryEntry | None:
return db.execute( return db.execute(
select(InventoryEntry).where( select(InventoryEntry).where(
@@ -178,10 +190,12 @@ def add_to_inventory(
db: DbSession = Depends(get_db), db: DbSession = Depends(get_db),
): ):
class_name = payload.class_name.strip() class_name = payload.class_name.strip()
if class_name not in catalog.store.by_class_name: item = catalog.store.by_class_name.get(class_name)
if item is None:
raise HTTPException( raise HTTPException(
status_code=404, detail=f"Catalog item '{class_name}' not found" status_code=404, detail=f"Catalog item '{class_name}' not found"
) )
_validate_quality(item, payload.quality)
entry = _find(db, user, class_name) entry = _find(db, user, class_name)
created = entry is None created = entry is None
@@ -192,12 +206,15 @@ def add_to_inventory(
class_name=class_name, class_name=class_name,
quantity=payload.quantity, quantity=payload.quantity,
notes=payload.notes, notes=payload.notes,
quality=payload.quality,
) )
db.add(entry) db.add(entry)
else: else:
entry.quantity += payload.quantity entry.quantity += payload.quantity
if payload.notes: if payload.notes:
entry.notes = payload.notes entry.notes = payload.notes
if payload.quality is not None:
entry.quality = payload.quality
try: try:
db.commit() db.commit()
@@ -210,6 +227,8 @@ def add_to_inventory(
entry.quantity += payload.quantity entry.quantity += payload.quantity
if payload.notes: if payload.notes:
entry.notes = payload.notes entry.notes = payload.notes
if payload.quality is not None:
entry.quality = payload.quality
db.commit() db.commit()
created = False created = False
@@ -235,6 +254,17 @@ def update_inventory_entry(
entry.notes = payload.notes entry.notes = payload.notes
if payload.visibility is not None: if payload.visibility is not None:
entry.visibility = payload.visibility entry.visibility = payload.visibility
if "quality" in payload.model_fields_set:
# Explicit null clears the value; omitted keeps the existing one.
if payload.quality is not None:
item = catalog.store.by_class_name.get(entry.class_name)
if item is None:
raise HTTPException(
status_code=422,
detail=f"Quality does not apply to '{entry.class_name}'",
)
_validate_quality(item, payload.quality)
entry.quality = payload.quality
db.commit() db.commit()
db.refresh(entry) db.refresh(entry)

View File

@@ -17,6 +17,7 @@ Real schemas this loader consumes:
""" """
import json import json
import os import os
import re
from app import config from app import config
@@ -27,6 +28,68 @@ CATEGORY_SHIP_ITEMS = "ship-items"
CATEGORY_FPS_ITEMS = "fps-items" CATEGORY_FPS_ITEMS = "fps-items"
CATEGORY_COMMODITIES = "commodities" CATEGORY_COMMODITIES = "commodities"
# Commodities that can carry a mining/refining quality value (1-1000). These are
# the ores, minerals and gems that players actually mine (and their refined
# counterparts); crafted alloys, gases, organics, processed goods and junk
# by-products are deliberately excluded. Keep in sync with the Ore quality table
# at https://starcitizen.tools/Ore_quality.
QUALITY_MATERIALS = frozenset(
name.lower()
for name in (
"Agricium",
"Aluminum",
"Aphorite",
"Aslarite",
"Beradom",
"Beryl",
"Bexalite",
"Borase",
"Carinite",
"Copper",
"Corundum",
"Diamond",
"Dolivine",
"Feynmaline",
"Glacosite",
"Gold",
"Hadanite",
"Hephaestanite",
"Iron",
"Jaclium",
"Janalite",
"Laranite",
"Lindinium",
"Ouratite",
"Pressurized Ice",
"Quantainium",
"Quartz",
"Riccite",
"Sadaryx",
"Saldynium",
"Savrilium",
"Silicon",
"Stileron",
"Taranite",
"Tin",
"Titanium",
"Torite",
"Tungsten",
)
)
# Variant suffixes are presentation labels, not a different material: "Iron
# (Ore)" and "Iron" share a quality value, as do "(Raw)", "(Pure)" and "(R)".
_QUALITY_VARIANT = re.compile(r"\s*\((?:Ore|Raw|Pure|R|\d+\s*SCU)\)\s*$", re.IGNORECASE)
_RAW_PREFIX = re.compile(r"^Raw\s+", re.IGNORECASE)
def _quality_applicable(name, category):
if category != CATEGORY_COMMODITIES:
return False
base_name = _QUALITY_VARIANT.sub("", name or "").strip()
base_name = _RAW_PREFIX.sub("", base_name).strip().lower()
return base_name in QUALITY_MATERIALS
def _load_json(data_dir, filename): def _load_json(data_dir, filename):
path = os.path.join(data_dir, filename) path = os.path.join(data_dir, filename)
@@ -80,6 +143,7 @@ def _normalize_ship(ship):
"classification": "Ship", "classification": "Ship",
"description": ship.get("Description") or ship.get("DescriptionText") or "", "description": ship.get("Description") or ship.get("DescriptionText") or "",
"tags": tags, "tags": tags,
"quality_applicable": False,
}, { }, {
"career": ship.get("Career") or "", "career": ship.get("Career") or "",
"role": ship.get("Role") or "", "role": ship.get("Role") or "",
@@ -111,6 +175,10 @@ def _normalize_item(item, category):
"classification": item.get("classification") or "", "classification": item.get("classification") or "",
"description": std.get("Description") or std.get("DescriptionText") or "", "description": std.get("Description") or std.get("DescriptionText") or "",
"tags": tags, "tags": tags,
"quality_applicable": _quality_applicable(
_resolve_name(std.get("Name"), item.get("name"), class_name=class_name),
category,
),
} }
if category == CATEGORY_COMMODITIES: if category == CATEGORY_COMMODITIES:
summary["size"] = None summary["size"] = None

View File

@@ -78,6 +78,10 @@ class InventoryEntry(Base):
"visibility IN ('private', 'available')", "visibility IN ('private', 'available')",
name="ck_inventory_visibility", name="ck_inventory_visibility",
), ),
CheckConstraint(
"quality IS NULL OR (quality >= 1 AND quality <= 1000)",
name="ck_inventory_quality_range",
),
) )
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
@@ -87,6 +91,10 @@ class InventoryEntry(Base):
class_name: Mapped[str] = mapped_column(String(255), nullable=False) class_name: Mapped[str] = mapped_column(String(255), nullable=False)
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1) quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
notes: Mapped[str] = mapped_column(Text, nullable=False, default="") notes: Mapped[str] = mapped_column(Text, nullable=False, default="")
# Mining/refining quality (1-1000) for commodities where it applies. NULL
# means "not specified"; non-quality commodities must keep this NULL. The
# applicability itself is catalog data, not stored here.
quality: Mapped[int | None] = mapped_column(Integer, nullable=True)
# "private" (default): only the owner sees the row. "available": the owner # "private" (default): only the owner sees the row. "available": the owner
# is offering the item. Unrelated to the response flag `available`, which # is offering the item. Unrelated to the response flag `available`, which
# only reports whether the catalog still contains the referenced item. # only reports whether the catalog still contains the referenced item.

View File

@@ -33,12 +33,15 @@ class InventoryAddRequest(BaseModel):
class_name: str = Field(min_length=1, max_length=255) class_name: str = Field(min_length=1, max_length=255)
quantity: int = Field(default=1, ge=1, le=config.MAX_QUANTITY) quantity: int = Field(default=1, ge=1, le=config.MAX_QUANTITY)
notes: str = Field(default="", max_length=config.MAX_NOTES_LENGTH) notes: str = Field(default="", max_length=config.MAX_NOTES_LENGTH)
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
class InventoryPatchRequest(BaseModel): class InventoryPatchRequest(BaseModel):
quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY) quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY)
notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH) notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH)
visibility: Literal["private", "available"] | None = None visibility: Literal["private", "available"] | None = None
# Explicit null clears the quality; omitting the field leaves it unchanged.
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
class InventoryEntryOut(BaseModel): class InventoryEntryOut(BaseModel):
@@ -51,6 +54,10 @@ class InventoryEntryOut(BaseModel):
manufacturer: dict | None manufacturer: dict | None
quantity: int quantity: int
notes: str notes: str
# Mining/refining quality (1-1000); only meaningful when
# `quality_applicable` is true. None means the owner has not set it.
quality: int | None = None
quality_applicable: bool = False
# Owner-chosen classification. Unrelated to `available` below. # Owner-chosen classification. Unrelated to `available` below.
visibility: Literal["private", "available"] visibility: Literal["private", "available"]
# False when the catalog no longer contains the referenced item (e.g. after # False when the catalog no longer contains the referenced item (e.g. after
@@ -84,6 +91,8 @@ class AvailableItemOut(BaseModel):
grade: Any | None = None grade: Any | None = None
manufacturer: dict | None manufacturer: dict | None
quantity: int quantity: int
quality: int | None = None
quality_applicable: bool = False
# Public listing: only the owner's display name, never notes. # Public listing: only the owner's display name, never notes.
username: str username: str
updated_at: datetime updated_at: datetime

View File

@@ -7,6 +7,7 @@ PASSWORD = "correct horse battery staple"
CARROCK = "ANVL_Carrack" CARROCK = "ANVL_Carrack"
RIFLE = "BEHR_Rifle_Ballistic_01" RIFLE = "BEHR_Rifle_Ballistic_01"
IRON_ORE = "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron" IRON_ORE = "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron"
CONSTRUCTION = "Carryable_TBO_FL_1SCU_Commodity_ProcessedGoods_ConstructionMaterials"
def authed_client(username: str) -> TestClient: def authed_client(username: str) -> TestClient:
@@ -18,11 +19,17 @@ def authed_client(username: str) -> TestClient:
return client return client
def add(client: TestClient, class_name: str, quantity: int = 1, notes: str = ""): def add(
resp = client.post( client: TestClient,
"/api/v1/me/inventory", class_name: str,
json={"class_name": class_name, "quantity": quantity, "notes": notes}, quantity: int = 1,
) notes: str = "",
quality: int | None = None,
):
payload = {"class_name": class_name, "quantity": quantity, "notes": notes}
if quality is not None:
payload["quality"] = quality
resp = client.post("/api/v1/me/inventory", json=payload)
assert resp.status_code in (200, 201), resp.text assert resp.status_code in (200, 201), resp.text
return resp return resp
@@ -128,3 +135,22 @@ def test_commodity_offers_are_listed_with_owner():
assert item["category"] == "commodities" assert item["category"] == "commodities"
assert item["quantity"] == 12 assert item["quantity"] == 12
assert item["username"] == "alice" assert item["username"] == "alice"
def test_commodity_quality_is_listed_publicly():
alice = authed_client("alice")
offer(alice, IRON_ORE, quantity=12)
add(alice, IRON_ORE, quality=875)
item = public_items()["items"][0]
assert item["quality"] == 875
assert item["quality_applicable"] is True
def test_non_quality_commodity_is_listed_without_quality():
alice = authed_client("alice")
offer(alice, CONSTRUCTION)
item = public_items()["items"][0]
assert item["quality"] is None
assert item["quality_applicable"] is False

View File

@@ -7,6 +7,8 @@ PASSWORD = "correct horse battery staple"
CARROCK = "ANVL_Carrack" CARROCK = "ANVL_Carrack"
RIFLE = "BEHR_Rifle_Ballistic_01" RIFLE = "BEHR_Rifle_Ballistic_01"
IRON_ORE = "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron" IRON_ORE = "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron"
IRON = "Carryable_TBO_FL_1SCU_Commodity_Metal_Iron"
CONSTRUCTION = "Carryable_TBO_FL_1SCU_Commodity_ProcessedGoods_ConstructionMaterials"
def authed_client(username: str = "alice") -> TestClient: def authed_client(username: str = "alice") -> TestClient:
@@ -18,11 +20,17 @@ def authed_client(username: str = "alice") -> TestClient:
return client return client
def add(client: TestClient, class_name: str, quantity: int = 1, notes: str = ""): def add(
return client.post( client: TestClient,
"/api/v1/me/inventory", class_name: str,
json={"class_name": class_name, "quantity": quantity, "notes": notes}, quantity: int = 1,
) notes: str = "",
quality: int | None = None,
):
payload = {"class_name": class_name, "quantity": quantity, "notes": notes}
if quality is not None:
payload["quality"] = quality
return client.post("/api/v1/me/inventory", json=payload)
def test_new_account_starts_with_empty_inventory(): def test_new_account_starts_with_empty_inventory():
@@ -264,3 +272,85 @@ def test_add_commodity_uses_catalog_name_and_quantity():
assert entry["quantity"] == 12 assert entry["quantity"] == 12
assert entry["available"] is True assert entry["available"] is True
assert entry["visibility"] == "private" assert entry["visibility"] == "private"
def test_catalog_marks_only_mineable_commodities_as_quality_applicable():
client = TestClient(app)
items = client.get(
"/api/v1/items", params={"category": "commodities", "page_size": 100}
).json()["items"]
flags = {item["class_name"]: item["quality_applicable"] for item in items}
assert flags[IRON] is True
assert flags[IRON_ORE] is True
assert flags[CONSTRUCTION] is False
assert flags["Carryable_TBO_FL_1SCU_Commodity_Metal_RMC"] is False
ships = client.get("/api/v1/items", params={"category": "ships"}).json()["items"]
assert all(item["quality_applicable"] is False for item in ships)
def test_quality_can_be_set_and_cleared_on_a_quality_commodity():
client = authed_client()
assert add(client, IRON_ORE).status_code == 201
resp = client.patch(f"/api/v1/me/inventory/{IRON_ORE}", json={"quality": 750})
assert resp.status_code == 200
body = resp.json()
assert body["quality"] == 750
assert body["quality_applicable"] is True
cleared = client.patch(
f"/api/v1/me/inventory/{IRON_ORE}", json={"quality": None}
)
assert cleared.status_code == 200
assert cleared.json()["quality"] is None
def test_add_persists_quality_and_keeps_it_when_quantity_is_incremented():
client = authed_client()
first = add(client, IRON, quality=900)
assert first.status_code == 201
assert first.json()["entry"]["quality"] == 900
second = add(client, IRON, quantity=2)
assert second.status_code == 200
entry = second.json()["entry"]
assert entry["quality"] == 900
assert entry["quantity"] == 3
replaced = add(client, IRON, quality=400)
assert replaced.json()["entry"]["quality"] == 400
def test_quality_is_rejected_for_non_mineable_commodities():
client = authed_client()
assert add(client, CONSTRUCTION).status_code == 201
resp = client.patch(
f"/api/v1/me/inventory/{CONSTRUCTION}", json={"quality": 500}
)
assert resp.status_code == 422
assert "does not apply" in resp.json()["detail"]
add_resp = add(client, CONSTRUCTION, quality=500)
assert add_resp.status_code == 422
def test_quality_is_rejected_for_ships():
client = authed_client()
assert add(client, CARROCK).status_code == 201
resp = client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quality": 100})
assert resp.status_code == 422
def test_quality_range_is_enforced():
client = authed_client()
assert add(client, IRON_ORE, quality=1).status_code == 201
assert add(client, IRON, quality=1000).status_code == 201
assert add(client, IRON_ORE, quality=0).status_code == 422
assert add(client, IRON_ORE, quality=1001).status_code == 422
assert add(client, IRON, quantity=1).status_code == 200
assert client.patch(
f"/api/v1/me/inventory/{IRON}", json={"quality": 1001}
).status_code == 422

View File

@@ -361,6 +361,10 @@ async function openModal(class_name) {
<div class="add-row"> <div class="add-row">
<label for="add-qty">Qty</label> <label for="add-qty">Qty</label>
<input type="number" id="add-qty" min="1" max="1000000" value="1"> <input type="number" id="add-qty" min="1" max="1000000" value="1">
${item.quality_applicable ? `
<label for="add-quality">Quality</label>
<input type="number" id="add-quality" min="1" max="1000" placeholder="—">
` : ""}
<button type="button" id="add-to-inventory" class="btn-primary"> <button type="button" id="add-to-inventory" class="btn-primary">
${state.user ? "Add to my inventory" : "Log in to add"} ${state.user ? "Add to my inventory" : "Log in to add"}
</button> </button>
@@ -368,7 +372,16 @@ async function openModal(class_name) {
`; `;
document.getElementById("add-to-inventory").addEventListener("click", () => { document.getElementById("add-to-inventory").addEventListener("click", () => {
const qty = Math.max(1, Number(document.getElementById("add-qty").value) || 1); const qty = Math.max(1, Number(document.getElementById("add-qty").value) || 1);
addToInventory(item.class_name, qty); let quality = null;
const qualityInput = document.getElementById("add-quality");
if (qualityInput && qualityInput.value.trim() !== "") {
quality = Number(qualityInput.value);
if (!Number.isInteger(quality) || quality < 1 || quality > 1000) {
showToast("Quality must be between 1 and 1000", true);
return;
}
}
addToInventory(item.class_name, qty, quality);
}); });
} catch (err) { } catch (err) {
els["modal-body"].innerHTML = `<p class="modal-desc">Failed to load item: ${escapeHtml(err.message)}</p>`; els["modal-body"].innerHTML = `<p class="modal-desc">Failed to load item: ${escapeHtml(err.message)}</p>`;
@@ -503,6 +516,48 @@ function itemNameCell(entry) {
return cell; return cell;
} }
function qualityCell(entry, editable) {
const cell = document.createElement("td");
if (!entry.quality_applicable) {
// Keep a recorded quality visible even if the catalog item disappeared.
cell.innerHTML =
entry.quality == null
? '<span class="inv-quality-na">—</span>'
: escapeHtml(String(entry.quality));
return cell;
}
if (!editable) {
cell.textContent = entry.quality == null ? "—" : String(entry.quality);
return cell;
}
const input = document.createElement("input");
input.type = "number";
input.className = "inv-quality";
input.min = "1";
input.max = "1000";
input.placeholder = "—";
input.value = entry.quality == null ? "" : String(entry.quality);
input.setAttribute("aria-label", `Quality of ${entry.name}`);
input.addEventListener("change", () => {
const raw = input.value.trim();
if (raw === "") {
entry.quality = null;
patchEntry(entry.class_name, { quality: null });
return;
}
const value = Number(raw);
if (!Number.isInteger(value) || value < 1 || value > 1000) {
input.value = entry.quality == null ? "" : String(entry.quality);
showToast("Quality must be between 1 and 1000", true);
return;
}
entry.quality = value;
patchEntry(entry.class_name, { quality: value });
});
cell.appendChild(input);
return cell;
}
function renderInventory(listing) { function renderInventory(listing) {
const tbody = els["inv-tbody"]; const tbody = els["inv-tbody"];
tbody.innerHTML = ""; tbody.innerHTML = "";
@@ -567,7 +622,15 @@ function renderInventory(listing) {
visibilityCell.appendChild(visibility); visibilityCell.appendChild(visibility);
applyVisibility(visibility, entry.visibility); applyVisibility(visibility, entry.visibility);
row.append(nameCell, categoryCell, qtyCell, notesCell, visibilityCell, actionCell); row.append(
nameCell,
categoryCell,
qtyCell,
qualityCell(entry, true),
notesCell,
visibilityCell,
actionCell
);
tbody.appendChild(row); tbody.appendChild(row);
} }
} }
@@ -633,15 +696,17 @@ async function deleteEntry(class_name, name) {
} }
} }
async function addToInventory(class_name, quantity = 1) { async function addToInventory(class_name, quantity = 1, quality = null) {
if (!state.user) { if (!state.user) {
openAuth("login", "Log in to add items to your inventory."); openAuth("login", "Log in to add items to your inventory.");
return; return;
} }
const body = { class_name, quantity };
if (quality !== null) body.quality = quality;
try { try {
const data = await apiFetch("/api/v1/me/inventory", { const data = await apiFetch("/api/v1/me/inventory", {
method: "POST", method: "POST",
body: { class_name, quantity }, body,
}); });
showToast( showToast(
data.created data.created
@@ -718,7 +783,14 @@ function renderAvailable(listing) {
updatedCell.className = "avail-updated"; updatedCell.className = "avail-updated";
updatedCell.textContent = formatDate(entry.updated_at); updatedCell.textContent = formatDate(entry.updated_at);
row.append(nameCell, categoryCell, qtyCell, userCell, updatedCell); row.append(
nameCell,
categoryCell,
qtyCell,
qualityCell(entry, false),
userCell,
updatedCell
);
tbody.appendChild(row); tbody.appendChild(row);
} }
} }

View File

@@ -90,6 +90,7 @@
<th>Item</th> <th>Item</th>
<th>Category</th> <th>Category</th>
<th>Qty</th> <th>Qty</th>
<th>Quality</th>
<th>Notes</th> <th>Notes</th>
<th>Visibility</th> <th>Visibility</th>
<th></th> <th></th>
@@ -129,6 +130,7 @@
<th>Item</th> <th>Item</th>
<th>Category</th> <th>Category</th>
<th>Qty</th> <th>Qty</th>
<th>Quality</th>
<th>Offered by</th> <th>Offered by</th>
<th>Updated</th> <th>Updated</th>
</tr> </tr>

View File

@@ -429,6 +429,16 @@ main {
border-radius: 6px; border-radius: 6px;
padding: 0.35rem 0.5rem; padding: 0.35rem 0.5rem;
} }
.inv-quality {
width: 5rem;
background: var(--bg-panel);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 0.35rem 0.5rem;
}
.inv-quality-na { color: var(--text-dim); }
.inv-quality:focus { outline: none; border-color: var(--accent); }
.inv-notes { .inv-notes {
width: 100%; width: 100%;
min-width: 12rem; min-width: 12rem;