From 3379a63baffcaa9fb7264e8b7bb969c68e934968 Mon Sep 17 00:00:00 2001 From: seb Date: Sun, 11 Oct 2026 02:36:46 +0200 Subject: [PATCH] u --- .agents/skills/coding-style/SKILL.md | 276 ++++++++++++++++++++++++++ .agents/skills/project-style | 1 + AGENTS.md | 2 +- src/automats/brandAutomat.js | 153 ++++++-------- src/components/brand/ArtikelMarke.jsx | 7 +- src/routes/artikel/marken.jsx | 22 +- 6 files changed, 356 insertions(+), 105 deletions(-) create mode 100644 .agents/skills/coding-style/SKILL.md create mode 120000 .agents/skills/project-style diff --git a/.agents/skills/coding-style/SKILL.md b/.agents/skills/coding-style/SKILL.md new file mode 100644 index 0000000..31aef55 --- /dev/null +++ b/.agents/skills/coding-style/SKILL.md @@ -0,0 +1,276 @@ +--- +name: coding-style +description: >- + Coding and project style conventions for the Dashfront repository. + Use this skill whenever authoring, modifying, or reviewing code, creating or updating + automats, writing React PureComponents, integrating dashgui components, or structuring + backend API and SSE communication. Names src/automats/brandAutomat.js as the canonical reference automat. +--- + +# Dashfront Coding & Project Style Guide + +This skill defines the canonical coding standards, architectural patterns, and project conventions for the `dashfront` codebase. + +> **Canonical Reference Automat**: [src/automats/brandAutomat.js](file:///home/seb/src/dashfront/src/automats/brandAutomat.js) +> **Canonical Reference View**: [src/routes/artikel/marken.jsx](file:///home/seb/src/dashfront/src/routes/artikel/marken.jsx) + +--- + +## 1. Core Architectural Principles + +1. **State Lives Outside React**: Domain and configuration state lives in module-scoped `react-automat` stores (`src/automats/*`), independent of component lifecycles. +2. **Zero Blank Flash**: Components read initial state **synchronously** in the `constructor` from the automat before subscribing in `componentDidMount`. Never show empty flicker when data is already cached. +3. **PureComponents Over Hooks for Stateful Views**: Views and widgets subscribe directly via `automat.subscribe(this, selector)` in React `PureComponent` classes. Do **not** use React hooks (`useState`, `useEffect`) inside `PureComponent` classes. +4. **No React Context for Data**: Never create React Context providers for domain models. Components import the automat or `src/api` namespace directly. +5. **No Mock Backends**: Never mock API routes or fake endpoints. The real Express backend runs locally on port `4208` (`dashback`), proxied via Vite. +6. **Silent SSE Updates**: Real-time server-sent events trigger **silent reloads** (`actions.reload()`), preserving visible UI state without resetting to `'loading'`. + +--- + +## 2. Canonical Reference Automat: `brandAutomat.js` + +All configuration and list automats in `src/automats/` must follow the structure and conventions exemplified by [src/automats/brandAutomat.js](file:///home/seb/src/dashfront/src/automats/brandAutomat.js): + +``` +src/automats/brandAutomat.js +├── 1. File JSDoc with Upstream Triggers documentation +├── 2. Imports (react-automat, api, authAutomat, sseAutomat) +├── 3. JSDoc State Typedef & initialState +├── 4. Pure Helper Functions (sorting, filtering) +├── 5. Concurrency control (inflightLoad guard, pagination) +├── 6. State Updater Helper (updateStateWithBrands) +├── 7. Actions Object (filter, load, reload, CRUD, reset) +├── 8. Automat Singleton Instantiation (new Automat(initialState, actions, { name })) +├── 9. Upstream Subscriptions (authAutomat lifecycle, sseAutomat events) +└── 10. Default Export +``` + +### Key Patterns Illustrated by `brandAutomat.js` + +#### A. Upstream Triggers Documentation +Always document the trigger contract at the top of the file: +```js +/** + * @file brandAutomat.js + * @description Manages catalog brands and manufacturers for the active company. + * + * Upstream Triggers: + * - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on + * `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`. + * - `sseAutomat`: Subscribes to the single `'brand'` event carrying + * `{ sender, brandId }` — any create/rename/delete/picture change triggers + * `actions.reload()`, which silently re-fetches the list. Events sent by this tab + * (matching `sender`) are ignored. + * - UI / Settings: Mutating actions (`create`, `rename`, `remove`, `addImage`, `deleteImage`). + */ +``` + +#### B. Predictable State Shape & Status Progression +Define state via JSDoc `@typedef` with standardized status progression: +```js +/** + * @typedef {Object} BrandState + * @property {Array} brands All items (full list, decoupled from page UI). + * @property {Array} filteredBrands Filtered items matching active filter. + * @property {string} filter Active search/filter string. + * @property {number} total Unpaged count. + * @property {'idle'|'loading'|'succeeded'|'failed'} status + * @property {string|null} error + */ + +const initialState = { + brands: [], + filteredBrands: [], + filter: '', + total: 0, + status: 'idle', + error: null, +}; +``` + +#### C. Inflight Load Deduplication & Silent Reload +- Guard fetches with `let inflightLoad = null` to coalesce concurrent load/reload calls into a single HTTP promise. +- `load()` sets `status: 'loading'` for initial loads. +- `reload()` silently updates state without setting `status: 'loading'`, keeping existing data on screen to prevent flicker. + +```js +let inflightLoad = null; + +const actions = { + /** Initial load with visible loading state */ + async load() { + if (inflightLoad) return inflightLoad; + brandAutomat.setState({ status: 'loading', error: null }); + return (inflightLoad = fetchAllBrands() + .then(({ brands }) => updateStateWithBrands(brands, { status: 'succeeded' })) + .catch((err) => { + brandAutomat.setState({ status: 'failed', error: err?.message ?? String(err) }); + throw err; + }) + .finally(() => { inflightLoad = null; })); + }, + + /** Silent re-fetch on SSE signal or background sync */ + async reload() { + if (inflightLoad) return inflightLoad; + return (inflightLoad = fetchAllBrands() + .then(({ brands }) => updateStateWithBrands(brands, { status: 'succeeded', error: null })) + .catch((err) => { + brandAutomat.setState({ error: err?.message ?? String(err) }); + throw err; + }) + .finally(() => { inflightLoad = null; })); + }, + + reset() { + brandAutomat.setState(initialState); + }, +}; +``` + +#### D. Auth Lifecycle Coordination +Automatically tie the automat lifecycle to `authAutomat`: +```js +authAutomat.subscribe((auth) => { + const companyId = auth.user?.selectedCompanyId; + if (auth.status === 'authed' && companyId) void brandAutomat.actions.load(); + else if (auth.status === 'anon') brandAutomat.actions.reset(); +}); +``` + +#### E. SSE Echo Suppression +When handling backend SSE events, always verify `sender` against `getSseClientId()` to suppress redundant reloads for mutations dispatched by the current browser tab: +```js +sseAutomat.actions.on('brand', (event) => { + try { + const payload = JSON.parse(event?.data ?? '{}'); + if (payload?.sender && payload.sender === getSseClientId()) return; + } catch { } + void brandAutomat.actions.reload().catch(() => { }); +}); +``` + +--- + +## 3. Reference UI Component Pattern + +Components consuming automats must follow the pattern established in [src/routes/artikel/marken.jsx](file:///home/seb/src/dashfront/src/routes/artikel/marken.jsx): + +```jsx +import { PureComponent } from 'react'; +import brandAutomat from '../../automats/brandAutomat'; +import { notifyError } from '../../utils/notify'; +import { enqueueSnackbar } from 'notistack'; +import { PanelTile, PanelTileGrid } from 'dashgui/panels'; +import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; + +export class ArtikelMarkenSearchField extends PureComponent { + constructor(props) { + super(props); + // 1. Synchronous initial read — zero blank flash! + this.state = { + input: brandAutomat.state.filter ?? '', + submitting: false, + }; + } + + componentDidMount() { + // 2. Fine-grained selector subscription + this.unsub = brandAutomat.subscribe(this, (state) => ({ + input: state.filter, + })); + } + + componentWillUnmount() { + // 3. Always clean up subscription + this.unsub?.(); + } + + onInputChange(input) { + this.setState({ input }); + brandAutomat.actions.filter(input); + } + + async handleSubmit() { + const name = this.state.input.trim(); + if (!name || this.state.submitting) return; + + this.setState({ submitting: true }); + try { + const row = await brandAutomat.actions.create({ name }); + enqueueSnackbar(`Marke „${row.name || name}“ angelegt.`, { variant: 'success' }); + this.setState({ input: '', submitting: false }); + brandAutomat.actions.filter(''); + } catch (err) { + this.setState({ submitting: false }); + notifyError(err); + } + } + + render() { + const { input, submitting } = this.state; + return ( + this.onInputChange(next)} + /> + ); + } +} +``` + +### Component Rules +1. **Always unsubscribe**: Save the return value of `automat.subscribe(this, ...)` in `this.unsub` and invoke `this.unsub?.()` in `componentWillUnmount()`. +2. **Synchronous constructor reads**: Read directly from `automat.state` or helper selector (e.g. `getArticle(sku)`) in the constructor. +3. **Selector efficiency**: The second argument to `subscribe(this, selector)` should return an object with only the slice of state needed by this component. +4. **No hooks in PureComponents**: Never call `useState`, `useEffect`, or `useMemo` inside `PureComponent`. + +--- + +## 4. UI Library Integration (`dashgui` & MUI) + +1. **Leverage `dashgui` Components**: + - `PanelTile` & `PanelTileGrid`: Container cards and responsive layouts. + - `SingleLineTextEdit`: Editable single-line text inputs (supports `search`, `clear`, `size="small"`). + - `RecordList`: High-performance list view for tabular and recordset data. + - `ElevatedPill`: Badges, chips, and status pills. + - `FillSpace`: Layout spacer. +2. **Theme Tokens & Color Constants**: + - Import shared palette constants from [src/colors.js](file:///home/seb/src/dashfront/src/colors.js) (`MAIN_BACKGROUND`, `PURPLE_PANEL`, etc.). + - Use standard MUI `Box`, `Button`, `Typography`, `CircularProgress` with `sx` props for layout spacing (`gap: 1`, `pb: 2`). +3. **Notifications**: + - Success: `enqueueSnackbar('Erfolgreich gespeichert', { variant: 'success' })` from `notistack`. + - Error: `notifyError(err)` from `src/utils/notify`. + - Page/Section Error Display: ``. + +--- + +## 5. Backend Communication & Pricing Rules + +1. **API Namespaces**: + - Always import from `src/api` (`import { brand, article, delivery } from '../api'`). + - The `http()` client automatically sets `credentials: 'include'`. + - **Never pass `googleAuthId`** in request payloads; the backend extracts identity directly from the HTTP-only cookie. +2. **Pricing Convention**: + - Prices sent to and received from the API/database are **net prices** (`net`). + - Gross prices displayed to end users must be calculated using tax rates from `companyAutomat` (`selectCompanyTaxrate()`, `selectCompanyReducedTaxrate()`). +3. **Binary / Media Assets**: + - Compress images client-side into AVIF format (`@jsquash/avif`) before uploading. + - Use `ThumbHash` for instant blur placeholders before loading full images from `/api/thumb/{hash}` or `/api/image/{hash}`. + +--- + +## 6. Implementation Checklist for New Automats + +When adding a new domain or configuration automat: + +- [ ] Place file in [src/automats/](file:///home/seb/src/dashfront/src/automats/). +- [ ] Add JSDoc file header documenting **Upstream Triggers** (auth, SSE, UI). +- [ ] Model the state with JSDoc `@typedef`, including `status: 'idle'|'loading'|'succeeded'|'failed'` and `error`. +- [ ] Implement `inflightLoad` promise guard for request deduplication. +- [ ] Implement both `load()` (visible loading) and `reload()` (silent background refresh). +- [ ] Subscribe to `authAutomat` to auto-load on `'authed'` with `selectedCompanyId` and `reset()` on `'anon'`. +- [ ] Subscribe to `sseAutomat` with `getSseClientId()` echo check to trigger `actions.reload()`. +- [ ] Instantiate `new Automat(initialState, actions, { name: '...' })`. +- [ ] Export singleton as default and re-export in [src/automats/index.js](file:///home/seb/src/dashfront/src/automats/index.js). diff --git a/.agents/skills/project-style b/.agents/skills/project-style new file mode 120000 index 0000000..9397310 --- /dev/null +++ b/.agents/skills/project-style @@ -0,0 +1 @@ +coding-style \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 4df9798..81ec46d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,7 +149,7 @@ src/ In `dashfront`, two state management solutions coexist harmoniously: ### A. `react-automat` (Recommended for Domain Models & Frequent Updates) -- **Skill reference**: Read [.agents/skills/automat/SKILL.md](file:///home/seb/src/dashfront/.agents/skills/automat/SKILL.md). +- **Skill references**: Read [.agents/skills/automat/SKILL.md](file:///home/seb/src/dashfront/.agents/skills/automat/SKILL.md) and [.agents/skills/coding-style/SKILL.md](file:///home/seb/src/dashfront/.agents/skills/coding-style/SKILL.md) (names [src/automats/brandAutomat.js](file:///home/seb/src/dashfront/src/automats/brandAutomat.js) as the reference automat). - Lives outside the React lifecycle in module scope (`src/automats/*.js`). - Designed for React `PureComponent` to bypass unnecessary React reconciliations. - **Pattern**: diff --git a/src/automats/brandAutomat.js b/src/automats/brandAutomat.js index 090b5a5..f540ba1 100644 --- a/src/automats/brandAutomat.js +++ b/src/automats/brandAutomat.js @@ -22,6 +22,8 @@ import sseAutomat, { getSseClientId } from './sseAutomat'; /** * @typedef {Object} BrandState * @property {Array} brands All brands (full list, decoupled from page UI). + * @property {Array} filteredBrands Filtered brands according to active filter query. + * @property {string} filter Active search/filter string. * @property {number} total Unpaged brand count. * @property {'idle'|'loading'|'succeeded'|'failed'} status * @property {string|null} error @@ -30,139 +32,119 @@ import sseAutomat, { getSseClientId } from './sseAutomat'; /** @type {BrandState} */ const initialState = { brands: [], + filteredBrands: [], + filter: '', total: 0, status: 'idle', error: null, }; -function sortBrands(brands) { - return [...brands].sort((a, b) => - String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'), - ); -} +const sortBrands = (brands) => + [...brands].sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de')); + +const applyFilter = (brands, filter) => { + const query = String(filter ?? '').trim().toLowerCase(); + return query ? brands.filter((b) => String(b.name ?? '').toLowerCase().includes(query)) : brands; +}; let brandAutomat; - -/** In-flight `load()`/`reload()` promise so concurrent callers share one request. */ let inflightLoad = null; - -/** Max page size the API accepts; full list is fetched by paging through. */ const FETCH_PAGE_SIZE = 100; async function fetchAllBrands() { - let offset = 0; - let total = Infinity; + let offset = 0, total = Infinity; const collected = []; - for (; ;) { + while (offset === 0 || collected.length < total) { const res = await brandApi.list({ offset, limit: FETCH_PAGE_SIZE }); if (offset === 0) total = Number(res.total ?? res.brands?.length ?? 0); const page = Array.isArray(res.brands) ? res.brands : []; collected.push(...page); offset += page.length; - if (page.length === 0 || collected.length >= total) break; + if (page.length === 0) break; } return { brands: sortBrands(collected), total: Number.isFinite(total) ? total : collected.length }; } +const updateStateWithBrands = (brands, extra = {}) => { + const filteredBrands = applyFilter(brands, brandAutomat.state.filter); + brandAutomat.setState({ brands, filteredBrands, total: brands.length, ...extra }); + return brands; +}; + const actions = { + /** Sets the active brand search filter and updates filteredBrands. */ + filter(query) { + const filter = String(query ?? ''); + brandAutomat.setState({ filter, filteredBrands: applyFilter(brandAutomat.state.brands, filter) }); + }, + /** Load all brands for the selected company (pages through the API). */ async load() { if (inflightLoad) return inflightLoad; brandAutomat.setState({ status: 'loading', error: null }); - inflightLoad = (async () => { - try { - const { brands, total } = await fetchAllBrands(); - brandAutomat.setState({ brands, total, status: 'succeeded' }); - return brands; - } catch (err) { + return (inflightLoad = fetchAllBrands() + .then(({ brands }) => updateStateWithBrands(brands, { status: 'succeeded' })) + .catch((err) => { brandAutomat.setState({ status: 'failed', error: err?.message ?? String(err) }); throw err; - } finally { - inflightLoad = null; - } - })(); - return inflightLoad; + }) + .finally(() => { inflightLoad = null; })); }, - /** - * Silently re-fetches all brands while keeping the current state - * visible (no loading flash). Subscribed views re-render from the updated - * state — this is the reload pattern for SSE-driven refreshes. - * - * @returns {Promise>} Fresh brands. - */ + /** Silently re-fetches all brands while keeping current state visible. */ async reload() { if (inflightLoad) return inflightLoad; - inflightLoad = (async () => { - try { - const { brands, total } = await fetchAllBrands(); - brandAutomat.setState({ brands, total, status: 'succeeded', error: null }); - return brands; - } catch (err) { + return (inflightLoad = fetchAllBrands() + .then(({ brands }) => updateStateWithBrands(brands, { status: 'succeeded', error: null })) + .catch((err) => { brandAutomat.setState({ error: err?.message ?? String(err) }); throw err; - } finally { - inflightLoad = null; - } - })(); - return inflightLoad; + }) + .finally(() => { inflightLoad = null; })); }, /** - * Create a brand (insert). 409 Brand already exists on duplicate name. - * @param {{ name: string, gsprpinfo?: string }} body + * Returns the brand state, loading brands first if uninitialized. + * + * @returns {Promise} Current brand state. */ + async getOrLoad() { + if (brandAutomat.state.status !== 'succeeded') { + await brandAutomat.actions.reload(); + } + return brandAutomat.state; + }, + + /** Create a brand (insert). */ async create(body) { const res = await brandApi.create(body); const row = res.brand; - const brands = sortBrands([ - ...brandAutomat.state.brands.filter((b) => b.id !== row.id), - row, - ]); - brandAutomat.setState({ brands, total: brands.length }); + updateStateWithBrands(sortBrands([...brandAutomat.state.brands.filter((b) => b.id !== row.id), row])); return row; }, - /** - * Rename a brand. Returns `{ id, name, gsprpinfo, touchedArticleCount }`. - * @param {number} id - * @param {{ name?: string, gsprpinfo?: string }} body - */ + /** Rename a brand. */ async rename(id, body) { const res = await brandApi.rename(id, body); const updated = res.brand ?? { id: res.id, name: res.name, gsprpinfo: res.gsprpinfo }; - const brands = sortBrands( - brandAutomat.state.brands.map((b) => (b.id === id ? { ...b, ...updated } : b)), - ); - brandAutomat.setState({ brands }); + updateStateWithBrands(sortBrands(brandAutomat.state.brands.map((b) => (b.id === id ? { ...b, ...updated } : b)))); return res; }, - /** - * Delete a brand by id. - * @param {number} id - */ + /** Delete a brand by id. */ async remove(id) { await brandApi.delete(id); - const brands = brandAutomat.state.brands.filter((b) => b.id !== id); - brandAutomat.setState({ brands, total: brands.length }); + updateStateWithBrands(brandAutomat.state.brands.filter((b) => b.id !== id)); }, - /** - * Add / update brand image. - * @param {number} id - * @param {Blob|File} file - */ + /** Add / update brand image. */ async addImage(id, file) { const res = await brandApi.addImage(id, file); await brandAutomat.actions.reload(); return res.brand; }, - /** - * Delete brand image. - * @param {number} id - */ + /** Delete brand image. */ async deleteImage(id) { const res = await brandApi.deleteImage(id); await brandAutomat.actions.reload(); @@ -177,37 +159,22 @@ const actions = { /** * Global brand state container. - * Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId. - * * @type {Automat} */ brandAutomat = new Automat(initialState, actions, { name: 'brand' }); -// Subscribe to auth changes authAutomat.subscribe((auth) => { const companyId = auth.user?.selectedCompanyId; - if (auth.status === 'authed' && companyId) { - void brandAutomat.actions.load(); - } else if (auth.status === 'anon') { - brandAutomat.actions.reset(); - } + if (auth.status === 'authed' && companyId) void brandAutomat.actions.load(); + else if (auth.status === 'anon') brandAutomat.actions.reset(); }); -// SSE: single `brand` event with `{ sender, brandId }` payload for every -// create/rename/delete/picture change. Silently reloads the list; subscribed -// views re-render from state. Own events are skipped. sseAutomat.actions.on('brand', (event) => { try { const payload = JSON.parse(event?.data ?? '{}'); - if (payload && typeof payload.sender === 'string' && payload.sender) { - try { - if (payload.sender === getSseClientId()) return; - } catch { } - } - } catch { - // Unparseable payload: fall through to reload. - } + if (payload?.sender && payload.sender === getSseClientId()) return; + } catch { } void brandAutomat.actions.reload().catch(() => { }); }); -export default brandAutomat; +export default brandAutomat; \ No newline at end of file diff --git a/src/components/brand/ArtikelMarke.jsx b/src/components/brand/ArtikelMarke.jsx index 4e44789..1606707 100644 --- a/src/components/brand/ArtikelMarke.jsx +++ b/src/components/brand/ArtikelMarke.jsx @@ -5,6 +5,7 @@ import ListItem from '@mui/material/ListItem'; import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import ImageSearchIcon from '@mui/icons-material/ImageSearch'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; +import Typography from '@mui/material/Typography'; import { enqueueSnackbar } from 'notistack'; import brandAutomat from '../../automats/brandAutomat'; import { notifyError } from '../../utils/notify'; @@ -128,7 +129,7 @@ class ArtikelMarke extends PureComponent { '&:hover': disabled ? undefined : { opacity: 0.8 }, }} > - + ) : ( )} - + ) : ({draft})} ({ + input: state.filter, + })); + } + + componentWillUnmount() { + this.unsub?.(); } onInputChange(input) { this.setState({ input }); - this.props.onChange?.(input); + brandAutomat.actions.filter(input); } async handleSubmit() { @@ -49,7 +53,7 @@ class ArtikelMarkenSearchField extends PureComponent { const row = await brandAutomat.actions.create({ name }); enqueueSnackbar(`Marke „${row.name || name}“ angelegt.`, { variant: 'success' }); this.setState({ input: '', submitting: false }); - this.props.onChange?.(''); + brandAutomat.actions.filter(''); } catch (err) { this.setState({ submitting: false }); notifyError(err); @@ -105,7 +109,7 @@ class ArtikelMarkenPage extends PureComponent { this.state = { status: brandAutomat.state.status, error: brandAutomat.state.error ? backendErrorFrom({ message: brandAutomat.state.error }) : null, - brands: brandAutomat.state.brands + brands: brandAutomat.state.filteredBrands ?? brandAutomat.state.brands, }; this.searchRef = createRef(); this.handleRetry = this.handleRetry.bind(this); @@ -115,7 +119,7 @@ class ArtikelMarkenPage extends PureComponent { setLastArticleSku(null); this.unsub = brandAutomat.subscribe(this, (state) => ({ - brands: state.brands, + brands: state.filteredBrands, status: state.status, error: state.error ? backendErrorFrom({ message: state.error }) : null, }));