3 Commits
Author SHA1 Message Date
kennyboy55 c08a035416 Bugfix: radio buttons, new badges, sort
Build dev docker image / build (push) Successful in 23s
Build dev docker image / build (push) Successful in 35s
Build dev docker image / release (push) Successful in 1s
2026-08-11 12:17:33 +02:00
kennyboy55 0f90972bcb Fix bug with new/restock labels
Build dev docker image / build (push) Successful in 40s
Build dev docker image / build (push) Successful in 36s
Build dev docker image / release (push) Successful in 2s
2026-08-11 11:56:17 +02:00
kennyboy55 36881293ed AI Changes: scan page fix, filters improvements
Build dev docker image / build (push) Successful in 23s
Build dev docker image / build (push) Successful in 36s
Build dev docker image / release (push) Successful in 1s
2026-08-07 11:53:20 +02:00
8 changed files with 136 additions and 72 deletions
+5
View File
@@ -1,5 +1,10 @@
Beer-inventory project for home use. Beer-inventory project for home use.
The project has three aspects.
- The website, which people can visit to see the current inventory of drinks. They can see in which section a drink is stored.
- The Admin website, which is part of the main website but only to logged in admin users. Which allows updating drinks, inventory, and adding or editing data.
- The scanner, which is a raspberry pi with touchscreen display and a USB barcode scanner. This is next to the fridge, and people use it to scan a drink to remove it from the inventory.
Used technology: Used technology:
- remix.run (now known as react router) - remix.run (now known as react router)
- prisma - prisma
+3 -7
View File
@@ -17,7 +17,6 @@ function toDate(value: Date | string | null | undefined): Date | null {
} }
function DrinkCard(arg:Arguments) { function DrinkCard(arg:Arguments) {
var trimmedDescription = arg.drink.description.length > 120 ? arg.drink.description.substring(0, 120) + "..." : arg.drink.description;
var link = "/inventory/"; var link = "/inventory/";
switch (arg.drink.type) { switch (arg.drink.type) {
@@ -61,10 +60,7 @@ function DrinkCard(arg:Arguments) {
} }
const drinkAddedAt = toDate((arg.drink as { addedAt?: Date | string | null }).addedAt ?? null); const drinkAddedAt = toDate((arg.drink as { addedAt?: Date | string | null }).addedAt ?? null);
const latestTimestamp = drinkAddedAt && (!latestActivity || drinkAddedAt.getTime() > latestActivity.getTime()) const isNewlyAdded = drinkAddedAt ? (Date.now() - drinkAddedAt.getTime()) <= 30 * 24 * 60 * 60 * 1000 : false;
? drinkAddedAt
: latestActivity;
const isNewlyAdded = latestTimestamp ? (Date.now() - latestTimestamp.getTime()) <= 30 * 24 * 60 * 60 * 1000 : false;
const isRecentlyRestocked = latestActivity ? (Date.now() - latestActivity.getTime()) <= 14 * 24 * 60 * 60 * 1000 : false; const isRecentlyRestocked = latestActivity ? (Date.now() - latestActivity.getTime()) <= 14 * 24 * 60 * 60 * 1000 : false;
return ( return (
@@ -72,11 +68,11 @@ function DrinkCard(arg:Arguments) {
<div style={{ position: 'relative', width: '100%', aspectRatio: '1 / 1', overflow: 'hidden' }}> <div style={{ position: 'relative', width: '100%', aspectRatio: '1 / 1', overflow: 'hidden' }}>
{ (isNewlyAdded || isRecentlyRestocked) ? ( { (isNewlyAdded || isRecentlyRestocked) ? (
<Badge <Badge
bg={isRecentlyRestocked ? "success" : "primary"} bg={isNewlyAdded ? "primary" : "success"}
className='position-absolute top-0 start-0 m-2 text-wrap' className='position-absolute top-0 start-0 m-2 text-wrap'
style={{ zIndex: 2 }} style={{ zIndex: 2 }}
> >
{isNewlyAdded && isRecentlyRestocked ? "New · Restocked" : isNewlyAdded ? "New" : "Recently added"} {isNewlyAdded ? "New" : (isRecentlyRestocked ? "Restocked" : "")}
</Badge> </Badge>
) : null } ) : null }
<Card.Img <Card.Img
+102 -40
View File
@@ -2,7 +2,7 @@
import { useSubmit } from '@remix-run/react'; import { useSubmit } from '@remix-run/react';
import { BeerStyle, Manufacturer, WineStyle } from '@zenstackhq/runtime/models'; import { BeerStyle, Manufacturer, WineStyle } from '@zenstackhq/runtime/models';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { Form, Col, Row } from 'react-bootstrap'; import { Button, Form, Col, Row } from 'react-bootstrap';
interface Arguments { interface Arguments {
beerStyles: BeerStyle[]; beerStyles: BeerStyle[];
@@ -20,11 +20,28 @@ function DrinkFilter(arg : Arguments) {
const showSodaFilter = arg.searchParams.has("drink", "Soda"); const showSodaFilter = arg.searchParams.has("drink", "Soda");
const showCocktailFilter = arg.searchParams.has("drink", "Cocktail"); const showCocktailFilter = arg.searchParams.has("drink", "Cocktail");
const form = useRef(null); const form = useRef<HTMLFormElement | null>(null);
const drinkTypes = ["Beer", "Wine", "Soda", "Cocktail"];
const [beerStyleExpanded, setBeerStyleExpanded] = useState(false);
const [wineStyleExpanded, setWineStyleExpanded] = useState(false);
const submitAfterRender = function(){ const toggleDrinkType = (drinkType: string) => {
setTimeout(() => submit(form.current), 10); const params = new URLSearchParams(arg.searchParams.toString());
} const values = params.getAll("drink");
if (values.includes(drinkType)) {
params.delete("drink");
values.filter((value) => value !== drinkType).forEach((value) => params.append("drink", value));
} else {
params.append("drink", drinkType);
}
if (params.get("abv") === "all") {
params.delete("abv");
}
submit(params, { method: "get", replace: true });
};
const handleFormChange = (event: React.ChangeEvent<HTMLFormElement>) => { const handleFormChange = (event: React.ChangeEvent<HTMLFormElement>) => {
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);
@@ -37,6 +54,8 @@ function DrinkFilter(arg : Arguments) {
} }
const abvSelection = arg.searchParams.get("abv") || "all"; const abvSelection = arg.searchParams.get("abv") || "all";
const selectedBeerStyleCount = arg.searchParams.getAll("beerstyle").length;
const selectedWineStyleCount = arg.searchParams.getAll("winestyle").length;
const abvOptions = [ const abvOptions = [
{ value: "all", label: "All" }, { value: "all", label: "All" },
{ value: "alcohol-free", label: "Alcohol free" }, { value: "alcohol-free", label: "Alcohol free" },
@@ -57,7 +76,7 @@ function DrinkFilter(arg : Arguments) {
/> />
)); ));
const sortKey = arg.searchParams.get("sort") || "name"; const sortKey = arg.searchParams.get("sort") || "new";
return ( return (
<Form noValidate method="GET" ref={form} className='pe-2' onChange={handleFormChange}> <Form noValidate method="GET" ref={form} className='pe-2' onChange={handleFormChange}>
@@ -66,8 +85,8 @@ function DrinkFilter(arg : Arguments) {
<Form.Group controlId="sort" className='bg-body-secondary rounded p-3'> <Form.Group controlId="sort" className='bg-body-secondary rounded p-3'>
<Form.Label className='fw-bold'>Sort</Form.Label> <Form.Label className='fw-bold'>Sort</Form.Label>
{ [ { [
["name", "Name"],
["new", "New"], ["new", "New"],
["name", "Name"],
["popular", "Popularity"], ["popular", "Popularity"],
["abv-asc", "Alcohol (Low -> High)"], ["abv-asc", "Alcohol (Low -> High)"],
["abv-desc", "Alcohol (High -> Low)"], ["abv-desc", "Alcohol (High -> Low)"],
@@ -87,17 +106,24 @@ function DrinkFilter(arg : Arguments) {
</Form.Group> </Form.Group>
<Form.Group controlId="drink-type" className='bg-body-secondary rounded p-3 mt-3'> <Form.Group controlId="drink-type" className='bg-body-secondary rounded p-3 mt-3'>
<Form.Label className='fw-bold'>Drink Type</Form.Label> <Form.Label className='fw-bold'>Drink Type</Form.Label>
{ ["Beer", "Wine", "Soda", "Cocktail"].map(key => ( <div className='row row-cols-2 g-2'>
<Form.Check {drinkTypes.map((key) => (
id={"drink-" + key} <div className='col' key={key}>
type="checkbox" <Button
label={key} type="button"
name="drink" variant={arg.searchParams.has("drink", key) ? "primary" : "outline-primary"}
value={key} className='w-100 text-start'
defaultChecked={arg.searchParams.has("drink", key) ? true : false} aria-pressed={arg.searchParams.has("drink", key)}
/> onClick={() => toggleDrinkType(key)}
>
{key}
</Button>
</div>
))}
</div>
{arg.searchParams.getAll("drink").map((drinkValue) => (
<input key={drinkValue} type="hidden" name="drink" value={drinkValue} />
))} ))}
</Form.Group> </Form.Group>
<Form.Group controlId="drink-abv" className='bg-body-secondary rounded p-3 mt-3'> <Form.Group controlId="drink-abv" className='bg-body-secondary rounded p-3 mt-3'>
<Form.Label className='fw-bold'>Alcohol Percentage</Form.Label> <Form.Label className='fw-bold'>Alcohol Percentage</Form.Label>
@@ -119,34 +145,67 @@ function DrinkFilter(arg : Arguments) {
{ showBeerFilter ? ( { showBeerFilter ? (
<> <>
<Form.Group controlId="beer-style" className='bg-body-secondary rounded p-3 mt-3'> <Form.Group controlId="beer-style" className='bg-body-secondary rounded p-3 mt-3'>
<Form.Label className='fw-bold mt-3'>Beer style</Form.Label> <div className='d-flex justify-content-between align-items-center'>
{ arg.beerStyles.map(style => ( <Form.Label className='fw-bold mb-0'>Beer style {selectedBeerStyleCount > 0 ? ( <span className="badge text-bg-dark">{selectedBeerStyleCount} selected</span>) : ''}</Form.Label>
<Form.Check <Button
id={"beerstyle-" + style.name} type='button'
type="checkbox" variant='outline-secondary'
label={style.name} size='sm'
name="beerstyle" onClick={() => setBeerStyleExpanded((current) => !current)}
value={style.id} >
defaultChecked={arg.searchParams.has("beerstyle", style.id.toString()) ? true : false} {beerStyleExpanded ? 'Collapse' : 'Expand'}
/> </Button>
))} </div>
{beerStyleExpanded ? (
<div className='mt-3'>
{ arg.beerStyles.map(style => (
<Form.Check
key={style.id}
id={"beerstyle-" + style.name}
type="checkbox"
label={style.name}
name="beerstyle"
value={style.id}
defaultChecked={arg.searchParams.has("beerstyle", style.id.toString()) ? true : false}
/>
))}
</div>
) : (
<div className='text-muted mt-3'>Tap expand to show beer styles</div>
)}
</Form.Group> </Form.Group>
</> </>
) : ""} ) : ""}
{ showWineFilter ? ( { showWineFilter ? (
<Form.Group controlId="wine-style" className='bg-body-secondary rounded p-3 mt-3'> <Form.Group controlId="wine-style" className='bg-body-secondary rounded p-3 mt-3'>
<Form.Label className='fw-bold'>Wine style</Form.Label> <div className='d-flex justify-content-between align-items-center'>
{ arg.wineStyles.map(style => ( <Form.Label className='fw-bold mb-0'>Wine style {selectedWineStyleCount > 0 ? ( <span className="badge text-bg-dark">{selectedWineStyleCount} selected</span>) : ''}</Form.Label>
<Form.Check <Button
id={"winestyle-" + style.name} type='button'
type="checkbox" variant='outline-secondary'
label={style.name} size='sm'
name="winestyle" onClick={() => setWineStyleExpanded((current) => !current)}
value={style.id} >
defaultChecked={arg.searchParams.has("winestyle", style.id.toString()) ? true : false} {wineStyleExpanded ? 'Collapse' : 'Expand'}
/> </Button>
))} </div>
</Form.Group> {wineStyleExpanded ? (
<div className='mt-3'>
{ arg.wineStyles.map(style => (
<Form.Check
id={"winestyle-" + style.name}
type="checkbox"
label={style.name}
name="winestyle"
value={style.id}
defaultChecked={arg.searchParams.has("winestyle", style.id.toString()) ? true : false}
/>
))}
</div>
) : (
<div className='text-muted mt-3'>Tap expand to show wine styles</div>
)}
</Form.Group>
) : ""} ) : ""}
{ showSodaFilter ? ( { showSodaFilter ? (
<Form.Group controlId="soda-carbonated" className='bg-body-secondary rounded p-3 mt-3'> <Form.Group controlId="soda-carbonated" className='bg-body-secondary rounded p-3 mt-3'>
@@ -154,6 +213,7 @@ function DrinkFilter(arg : Arguments) {
<Form.Check <Form.Check
type="radio" type="radio"
label="Both" label="Both"
id="carbonated-both"
name="carbonated" name="carbonated"
value={0} value={0}
defaultChecked={(arg.searchParams.get("carbonated") == "0" || !arg.searchParams.has("carbonated")) ? true : false} defaultChecked={(arg.searchParams.get("carbonated") == "0" || !arg.searchParams.has("carbonated")) ? true : false}
@@ -161,6 +221,7 @@ function DrinkFilter(arg : Arguments) {
<Form.Check <Form.Check
type="radio" type="radio"
label="Sparkling" label="Sparkling"
id="carbonated-sparkling"
name="carbonated" name="carbonated"
value={1} value={1}
defaultChecked={arg.searchParams.get("carbonated") == "1" ? true : false} defaultChecked={arg.searchParams.get("carbonated") == "1" ? true : false}
@@ -168,6 +229,7 @@ function DrinkFilter(arg : Arguments) {
<Form.Check <Form.Check
type="radio" type="radio"
label="Non-sparkling" label="Non-sparkling"
id="carbonated-flat"
name="carbonated" name="carbonated"
value={2} value={2}
defaultChecked={arg.searchParams.get("carbonated") == "2" ? true : false} defaultChecked={arg.searchParams.get("carbonated") == "2" ? true : false}
+5 -5
View File
@@ -97,7 +97,11 @@ function sortNewest(n1:DrinkComposite, n2:DrinkComposite) : number{
export function sortDrinksFromSearch(searchParams: URLSearchParams, searchResult: DrinkComposite[]) : DrinkComposite[] { export function sortDrinksFromSearch(searchParams: URLSearchParams, searchResult: DrinkComposite[]) : DrinkComposite[] {
var callback = sortName; var callback = sortNewest;
if(searchParams.has("sort", "new")){
callback = sortNewest;
}
if(searchParams.has("sort", "name")){ if(searchParams.has("sort", "name")){
callback = sortName; callback = sortName;
@@ -123,10 +127,6 @@ export function sortDrinksFromSearch(searchParams: URLSearchParams, searchResult
callback = sortPopularity; callback = sortPopularity;
} }
if(searchParams.has("sort", "new")){
callback = sortNewest;
}
var sortedArray: DrinkComposite[] = searchResult.sort(callback); var sortedArray: DrinkComposite[] = searchResult.sort(callback);
return sortedArray; return sortedArray;
+3 -3
View File
@@ -83,7 +83,7 @@ export function ErrorBoundary() {
const hourly = window.setInterval(() => { const hourly = window.setInterval(() => {
tryRefresh(); tryRefresh();
}, 60 * 60 * 1000); }, 15 * 60 * 1000); // Every 15 minutes
return () => { return () => {
window.removeEventListener("online", handleOnline); window.removeEventListener("online", handleOnline);
@@ -120,7 +120,7 @@ export function ErrorBoundary() {
> >
Try refresh now Try refresh now
</button> </button>
<div className="mt-2">Status: {isOnline ? "Online" : "Offline"}. Will retry automatically every hour.</div> <div className="mt-2">Status: {isOnline ? "Online" : "Offline"}. Will retry automatically every 15 minutes.</div>
</div> </div>
</div> </div>
</Col> </Col>
@@ -159,7 +159,7 @@ export function ErrorBoundary() {
> >
Try refresh now Try refresh now
</button> </button>
<div className="mt-2">Status: {isOnline ? "Online" : "Offline"}. Will retry automatically every hour.</div> <div className="mt-2">Status: {isOnline ? "Online" : "Offline"}. Will retry automatically every 15 minutes.</div>
</div> </div>
</div> </div>
</Col> </Col>
+1 -5
View File
@@ -78,11 +78,7 @@ export default function BeersRoute() {
return; return;
} }
if (key === "sort" && value === "name") { if (key === "sort" && value === "new") {
return;
}
if ((key === "min-abv" && value === "0") || (key === "max-abv" && value === "50") || (key === "min-ibu" && value === "0") || (key === "max-ibu" && value === "150")) {
return; return;
} }
+1 -1
View File
@@ -41,7 +41,7 @@ export default function InventoryLayout() {
setCheckouts(newCheckouts); setCheckouts(newCheckouts);
} }
}, 5000); }, 30000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}); });
+16 -11
View File
@@ -1,8 +1,8 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node"; import { json, redirect } from "@remix-run/node";
import { useActionData, useLoaderData } from "@remix-run/react"; import { Form as RemixForm, useActionData, useLoaderData } from "@remix-run/react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Col, Container, Form, Row, Table } from "react-bootstrap"; import { Col, Container, Form as BootstrapForm, Row, Table } from "react-bootstrap";
import timeAgo from "~/utils/datetime"; import timeAgo from "~/utils/datetime";
import { enhance } from "~/utils/db.server"; import { enhance } from "~/utils/db.server";
@@ -16,7 +16,7 @@ export async function loader({
return redirect("/"); return redirect("/");
} }
const history = await dbe.history.findMany({select: {id: true, checkoutAt: true, inventoryAfter: true, container: {select: {drink: true}}}, take: 10, orderBy: {checkoutAt: "desc"}}); const history = await dbe.history.findMany({select: {id: true, checkoutAt: true, inventoryAfter: true, container: {select: {drink: true}}}, take: 5, orderBy: {checkoutAt: "desc"}});
return json({history}); return json({history});
} }
@@ -116,6 +116,10 @@ export default function ScanRoute() {
return; return;
} }
if (inputRef.current) {
inputRef.current.value = "";
}
setLastScanned(aData.lastScanned); setLastScanned(aData.lastScanned);
setShowLastScanned(true); setShowLastScanned(true);
@@ -137,7 +141,7 @@ export default function ScanRoute() {
<tr> <tr>
<th>Drink</th> <th>Drink</th>
<th>When</th> <th>When</th>
<th>Amount left</th> <th># Left</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -158,14 +162,15 @@ export default function ScanRoute() {
<Col> <Col>
{/* Hidden fullscreen toggle button - triggered by clicking the h3 headers */} {/* Hidden fullscreen toggle button - triggered by clicking the h3 headers */}
<button <button
type="button"
ref={fullscreenBtnRef} ref={fullscreenBtnRef}
onClick={toggleFullscreen} onClick={toggleFullscreen}
style={{ display: 'none' }} style={{ display: 'none' }}
aria-hidden="true" aria-hidden="true"
/> />
<Form method="post" action="/scan?index" noValidate> <RemixForm method="post" action="/scan?index" noValidate>
<Form.Group> <BootstrapForm.Group>
<Form.Control <BootstrapForm.Control
ref={inputRef} ref={inputRef}
type="text" type="text"
name="barcode" name="barcode"
@@ -175,11 +180,11 @@ export default function ScanRoute() {
inputMode="text" inputMode="text"
style={{ height: '3rem', fontSize: '1.8rem', textAlign: 'center' }} style={{ height: '3rem', fontSize: '1.8rem', textAlign: 'center' }}
/> />
<Form.Control.Feedback type="invalid"> <BootstrapForm.Control.Feedback type="invalid">
{aData?.error} {aData?.error}
</Form.Control.Feedback> </BootstrapForm.Control.Feedback>
</Form.Group> </BootstrapForm.Group>
</Form> </RemixForm>
</Col> </Col>
</Row> </Row>
<Row> <Row>