diff --git a/AGENTS.md b/AGENTS.md index 81ec46d..cd2bbf2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -259,6 +259,9 @@ npm run build # Preview production build npm run preview + +# Regenerate Automat Graph visualization (automat-graph.svg & automat-graph.dot) +npm run automat-graph ``` > [!NOTE] @@ -488,6 +491,13 @@ npm run cert # Generates local certs in ./certs/ npm run dev:https # Runs Vite HTTPS server on port 4308 ``` +### Automat Graph Visualization (`automat-graph.svg` & `automat-graph.dot`) +- Visual topology diagram generated via `npm run automat-graph` (`generate-automat-graph.js`). +- Modeled after `schema-graph.svg` in `dashdbschema` and `api-graph.svg` in `dashback`. +- Visualizes 32 Automats across 12 domain clusters (Session & Real-Time, Catalog, Search, Properties, Purchasing, Sales, Inventory, Finance, POS, AI Workspace, Virtual Grids, System UI). +- Color-coded badges for `STATE` (purple), `ACTION` (green), `SELECT` (blue), `API` (amber), `SSE` (rose), and `HOOK` (orange). +- Traces 60 relational and dataflow links with dedicated arrival ports (`in#`), exit compass directions (`e`/`w`), and arc-fixed self-loops. + --- ## 16. Agent Cheatsheet & Code Recipes diff --git a/README.md b/README.md index df58f88..c42d5d0 100644 --- a/README.md +++ b/README.md @@ -58,10 +58,13 @@ future test cases. The Vite dev proxy (`/api` → Dashback) satisfies the same-origin cookie requirement without needing HTTPS on the frontend dev server. -## Architecture +## Architecture & State Management +- `src/automats/*` — reactive state containers powered by `react-automat` (no React Context, hooks, or HOCs). +- `automat-graph.svg` / `automat-graph.dot` — interactive visual topology of all 32 Automats across 12 domain clusters and 60 dataflow relationships. +- `npm run automat-graph` — regenerates the Automat Graph SVG and DOT files using Graphviz Wasm. - `src/api/index.js` — all backend communication (`credentials: 'include'`). - `src/auth/googleSignIn.js` — Google Identity Services helper. - `src/auth/AuthProvider.jsx` — session state + login/logout. - `src/auth/useAuth.js` — auth context hook. -- `src/components/*` — small function components with local state. +- `src/components/*` — components reading synchronously from automats and subscribing via fine-grained selectors. diff --git a/automat-graph.dot b/automat-graph.dot new file mode 100644 index 0000000..26afac2 --- /dev/null +++ b/automat-graph.dot @@ -0,0 +1,162 @@ +digraph automats { + graph [rankdir=TB, splines=spline, overlap=false, bgcolor="#1e2430", + nodesep=0.95, ranksep=1.0, pad=0.5, fontname="Helvetica", + compound=true, mclimit=1.0]; + node [shape=plaintext, fontname="Helvetica"]; + edge [fontname="Helvetica", fontsize=9, penwidth=1.4, arrowsize=0.7]; + + subgraph cluster_session_realtime { + label="Session & Real-Time Hub"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + authAutomat [label=<
authAutomat
Session & Multi-Tenant Gateway
STATEuser: AuthUserCurrent user & company
STATEstatus'anon' | 'authed' | 'loading'
ACTIONbootstrapSession()Restore or fetch session
ACTIONswitchCompany(id)Multi-tenant switch
ACTIONlogout()Clear session cookie
SELECTselectSelectedCompanyId()Tenant selector
HOOKauth/sessionEstablishedSession ready lifecycle
HOOKauth/companyChangedCompany change signal
APIGET /api/auth/sessionCookie session check
>]; + sseAutomat [label=<
sseAutomat
Single Persistent SSE Connection
STATEstatus'connected' | 'error'
STATEclientId (cid)Tab-scoped UUID in session
ACTIONreconnect()Cycle on company switch
ACTIONon(event, handler)Register signal listener
ACTIONwatchArticle(sku)Attach article-<sku>
SELECTinvalidateArticle(sku)Silent cache refresh
SSEcategory / articlesCatalog mutation events
SSEorder / shipmentFulfillment events
APIGET /api/eventsPersistent stream
>]; + } + subgraph cluster_catalog_products { + label="Catalog & Products"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + categoryAutomat [label=<
categoryAutomat
Hierarchical Category Tree
STATEcategories: MapID -> Category Node Map
STATErootCategoryIdCompany root node ID
STATEarticleCountTotal parent items
ACTIONloadTree()Fetch tree & warm views
ACTIONcreateCategory()Insert child node
ACTIONmoveCategory()Parent reparenting
ACTIONdeleteCategory()Prune subtree
SELECTgetCategoryBreadcrumbs()Hierarchical path trace
APIGET /api/categoryRecursive tree load
>]; + categoryViewAutomat [label=<
categoryViewAutomat
KeyedAutomat (Slug Path)
STATEviews: Map<slug, Data>Articles & direct children
ACTIONloadAllCategoryViews()Prewarm all tree slugs
ACTIONreloadLoadedCategoryViews()Refresh cached views
ACTIONinvalidateAllCategoryViews()Clear on SSE signal
SELECTcollectLoadedCategoryViewData()Extract union data
SELECTgetOrCreateCategoryAutomat()Per-slug reactive store
APIGET /api/category/:id/articlesCategory article batch
>]; + artikelAutomat [label=<
artikelAutomat
In-Memory Article Record Cache
STATEarticles: Map<sku, Object>Deduplicated SKU cache
ACTIONloadArticle(sku)Fetch or cached promise
ACTIONreloadArticle(sku)Silent background sync
ACTIONsetArticleName(sku, name)Optimistic rename
ACTIONclearArticle(sku)Evict SKU from cache
SELECTgetArticle(sku)Sync constructor read
SELECTcreateArticleSelector(sku)Fine-grained selector
APIGET /api/article/:skuFull article document
>]; + articleDetailAutomat [label=<
articleDetailAutomat
KeyedAutomat (SKU)
STATEarticleHistory: Map<sku>Change history log
STATEarticleSupplier: Map<sku>Assigned supplier list
ACTIONarticleHistoryAutomat.load()Fetch SKU audit trail
ACTIONarticleSupplierAutomat.load()Fetch supplier catalog
APIGET /api/article/:sku/historyAudit change entries
APIGET /api/article/:sku/supplierVendor links & prices
>]; + brandAutomat [label=<
brandAutomat
Catalog Brands & Makers
STATEbrands: Brand[]Decoupled brand catalog
STATEfilteredBrands: Brand[]Filtered search slice
ACTIONload(silent)Deduplicated list fetch
ACTIONcreate() / rename()Brand mutations
ACTIONaddImage() / deleteImage()Brand logo AVIF image
APIGET /api/brandPaginated brand records
SSEbrandSSE signal with sender tab
>]; + unitAutomat [label=<
unitAutomat
Units of Measure (UoM)
STATEunits: Unit[]Allowed SI & sales units
STATEstatus: 'idle'|'ready'Load lifecycle state
ACTIONload()Fetch localized units
ACTIONupsert(body)Create or update unit
ACTIONdelete(id)Remove unused unit
APIGET /api/unitCompany unit directory
>]; + } + subgraph cluster_search_indexing { + label="Search & Indexing"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + articleIndexAutomat [label=<
articleIndexAutomat
Derived Client Search Index
STATEitems: ArticleIndexRecord[]De-duplicated article union
STATEstatus: 'ready'Index lifecycle flag
ACTIONbuildArticleIndex()Derive from category views
ACTIONsetArticleIndexName()Patch on rename
ACTIONreload()Re-derive from views
SELECTselectArticleIndexItems()Fast record projection
SELECTensureLoaded()Await warm views
>]; + articleSearchAutomat [label=<
articleSearchAutomat
In-Memory FlexSearch Engine
STATEtarget: stringActive search query
STATEfilter: stringActive category filter
STATEresult: ArticleSearchResultFacets, recordset, count
ACTIONsearch(target, filter)Synchronous indexed query
SELECTselectSearchResult()Reactive search outcome
>]; + } + subgraph cluster_properties_attributes { + label="Properties & Attributes"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + propertyAutomat [label=<
propertyAutomat
Custom Metadata Properties
STATEproperties: PropertyName[]Sorted company properties
ACTIONload()Fetch property definitions
ACTIONcreateProperty()Add new property definition
ACTIONupdateProperty()Edit title & sort
APIGET /api/propertynameCompany properties
SSEpropertynameReal-time property signal
>]; + attributeAutomat [label=<
attributeAutomat
Product Attributes & Discrete Values
STATEattributes: AttributeName[]Attributes with sorted values
ACTIONload()Fetch attribute definitions
ACTIONcreateAttribute()Create attribute group
ACTIONaddValue() / deleteValue()Discrete value options
APIGET /api/attributenameAttributes & values
SSEattributenameReal-time attribute signal
>]; + } + subgraph cluster_purchasing_logistics { + label="Suppliers & Deliveries"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + supplierAutomat [label=<
supplierAutomat
Supplier Directory & Catalogs
STATEsuppliers: Supplier[]Active vendor list
STATEstatus: 'ready'Load lifecycle state
ACTIONload()Deduplicated fetch
ACTIONreload()Silent background sync
APIGET /api/supplierVendor records
SSEsupplierReal-time supplier signal
>]; + deliveryAutomat [label=<
deliveryAutomat
createListAutomat (Deliveries)
STATEitems: Delivery[]Company delivery receipts
STATEstatus: 'ready'List lifecycle state
ACTIONload() / reload()Deduplicated fetch & sync
ACTIONreset()Clear on session switch
APIGET /api/deliveryInbound delivery log
>]; + deliveryEditorAutomat [label=<
deliveryEditorAutomat
Inbound Receipt & OCR Machine
STATElines: DeliveryLine[]Uncommitted draft lines
STATEdeliveryEntitySelected supplier ID
STATEattachments: File[]Invoice PDFs & OCR results
STATEdraftCount: numberUncommitted lines for badge
ACTIONaddLine() / updateLine()Interactive item editing
ACTIONcommitDelivery()Persist goods receipt
SELECTbuildSupplierArticlesMap()Auto-match SKU/EAN
APIPOST /api/deliveryCreate goods receipt
>]; + } + subgraph cluster_sales_crm { + label="Sales & CRM"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + quoteAutomat [label=<
quoteAutomat
createListAutomat (Quotes)
STATEitems: Quote[]Sales quotes / Angebote
ACTIONload() / reload()Deduplicated fetch
ACTIONreset()Reset on logout
APIGET /api/quoteCompany quote list
>]; + orderAutomat [label=<
orderAutomat
Chunk-Paginated Order Engine
STATEitems: Order[]Sales orders & lines
STATEtotal: numberUnfiltered order count
STATEonlyUnpaid / onlyUndeliveredFulfillment filters
ACTIONloadChunk(index)Chunk size 50 prefetch
ACTIONreload()Silent orders re-fetch
SELECTselectOrders() / selectTotal()Filter-applied slice
APIGET /api/orderPaged order stream
SSEorder / shipmentFulfillment mutations
>]; + entityAutomat [label=<
entityAutomat
createListAutomat (Entities)
STATEitems: Entity[]Customers & partners
ACTIONload() / reload()Customer directory load
ACTIONreset()Reset on session switch
APIGET /api/entityDirectory records
>]; + customergroupAutomat [label=<
customergroupAutomat
createListAutomat (Groups)
STATEitems: Customergroup[]Pricing discount tiers
ACTIONload() / reload()Discount group load
APIGET /api/customergroupPricing tiers
>]; + } + subgraph cluster_inventory_storage { + label="Inventory & Warehousing"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + warehouseAutomat [label=<
warehouseAutomat
Warehouse Locations & Stock
STATEwarehouses: Warehouse[]Storage areas & bins
STATEfilteredWarehousesFilter search slice
ACTIONload() / reload()Deduplicated sync
ACTIONsetFilter(query)Local filter query
APIGET /api/warehouseWarehouse listings
SSEwarehouseReal-time stock movement
>]; + } + subgraph cluster_finance_config { + label="Company & Finance"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + companyAutomat [label=<
companyAutomat
Active Company Configuration
STATEcurrent: CompanyTax rates & ISO language
ACTIONload() / reload()Fetch tenant settings
SELECTselectCompanyTaxrate()Standard tax rate
SELECTselectCompanyRootCategoryId()Root category pointer
SELECTselectCompanyName()Company legal name
APIGET /api/company/:idLegal & tax profile
>]; + accountAutomat [label=<
accountAutomat
createListAutomat (Accounts)
STATEitems: Account[]Bank & payment ledgers
ACTIONload() / reload()Payment accounts load
APIGET /api/accountChart of accounts
>]; + numberAutomat [label=<
numberAutomat
createListAutomat (Sequences)
STATEitems: NumberSeq[]Invoice/receipt counters
ACTIONload() / reload()Number series sync
APIGET /api/numberDocument counters
>]; + } + subgraph cluster_hardware_pos { + label="POS & Hardware"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + posterminalAutomat [label=<
posterminalAutomat
POS Terminals & Gating
STATEterminals: Posterminal[]Hardware terminals
ACTIONload()Fetch POS registers
ACTIONcreate() / delete()Terminal registrations
ACTIONtoggleCategory()Gated category filters
SELECTselectPosterminals()Active registers
APIGET /api/posterminalTerminal device configs
>]; + } + subgraph cluster_ai_workspace { + label="AI Assistant & Workspace"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + chatAutomat [label=<
chatAutomat
AI Assistant Drawer & SSE
STATEisOpen: booleanDocked chat drawer toggle
STATEconversations: []Conversation sessions
STATEmessages: ModelMessage[]Live message transcript
STATEsseStatus: 'connected'Token streaming stream
ACTIONsendMessage(text)Prompt model with tools
ACTIONanswerAskUser()Interactive tool modal
SELECTuserEnv / routeToolsClient navigation tool
APIPOST /api/modelconversationAI conversation session
SSEmodelconversation/eventsLive token stream
>]; + vfsAutomat [label=<
vfsAutomat
Virtual Filesystem Explorer
STATEfiles: VfsFile[]Company VFS workspace
STATEselectedFileNameCurrent active file
STATEfileContent / isDirtyEditor buffer & diff
ACTIONselectFile() / saveFile()Applet editor buffer
ACTIONuploadFiles() / deleteFile()File manipulations
APIGET /api/vfsVFS directory index
SSEvfsVFS mutation signal
>]; + } + subgraph cluster_audit_grids { + label="Audit & Virtual Grids"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + articleHistoryGridAutomat [label=<
articleHistoryGridAutomat
createGridAutomat (Article)
STATErows: [] / totalRowsBlock size 50 virtual rows
ACTIONfetchRows(viewport)Infinite scroll query
ACTIONreload()Reload on company switch
APIGET /api/article/historyInfinite audit window
>]; + globalHistoryGridAutomat [label=<
globalHistoryGridAutomat
createGridAutomat (Global)
STATErows: [] / totalRowsBlock size 50 virtual rows
ACTIONfetchRows(viewport)Infinite scroll query
ACTIONreload()Reload on company switch
APIGET /api/globalhistoryCompany global audit
>]; + } + subgraph cluster_system_ui { + label="UI, Settings & System"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + navigationAutomat [label=<
navigationAutomat
Cross-Section Navigation Memory
STATElastDeliveryPathActive receipt subpath
STATElastArticleSkuActive SKU selection
STATElastCategorySlug / PathActive category tree node
STATElastSearchTerm / PathPreserved search query
ACTIONsetLastDeliveryPath()Restore on sidebar click
ACTIONsetLastArticleSku()Restore SKU on section click
ACTIONclearNavigation()Reset on company switch
>]; + userSettingsAutomat [label=<
userSettingsAutomat
IndexedDB User Preferences
STATEviewerPosition: 'top'|'right'Editor docking location
STATEchatDrawerWidth: 460AI drawer pixel width
ACTIONsetViewerPosition()Persist docking
ACTIONsetChatDrawerWidth()Persist drawer width
HOOKpersist: trueSurvives page reloads
>]; + helpAutomat [label=<
helpAutomat
IndexedDB Contextual Help
STATEenabled: booleanInteractive help badges
STATEactiveHelpId: string|nullActive popper ID
ACTIONtoggle() / setEnabled()Toggle badge mode
ACTIONopenPopper() / closePopper()Single active popper
HOOKpersist: trueSurvives page reloads
>]; + pwaAutomat [label=<
pwaAutomat
Service Worker & Offline Lifecycle
STATEneedRefresh: booleanNew SW release pending
STATEofflineReady: booleanOffline assets cached
STATEdialogOpen / updatingPrompt modal state
ACTIONapplyUpdate()Skip waiting & reload
ACTIONopenDialog() / closeDialog()Modal controls
>]; + Legend [label=<
Automat Graph Legend
STATEReactive store state property
ACTIONState mutation action
SELECTReactive PureComponent selector
APIBackend REST/Fetch endpoint
SSEInbound Server-Sent Event trigger
HOOKAuth / persistence lifecycle hook
in#Inbound arrival port on Automat header
▪—▶Reactive dataflow / upstream trigger
>]; + } + + authAutomat:p_auth_user:e -> sseAutomat:in0:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="authAutomat → sseAutomat (bind session & cid)", label=" bind session & cid ", constraint=false, weight=1]; + authAutomat:p_auth_switchCompany:w -> companyAutomat:in0:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="authAutomat → companyAutomat (load company profile)", label=" load company profile ", constraint=false, weight=1]; + authAutomat:p_auth_status:w -> categoryAutomat:in5:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="authAutomat → categoryAutomat (sync tree on authed)", label=" sync tree on authed ", constraint=false, weight=1]; + authAutomat:p_auth_status:w -> brandAutomat:in0:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="authAutomat → brandAutomat (sync brands on authed)", label=" sync brands on authed ", constraint=false, weight=1]; + authAutomat:p_auth_status:w -> unitAutomat:in0:n [color="#c79bff", fontcolor="#c79bff", tooltip="authAutomat → unitAutomat (sync units on authed)", label=" sync units on authed ", constraint=false, weight=1]; + authAutomat:p_auth_status:e -> attributeAutomat:in0:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="authAutomat → attributeAutomat (sync attributes on authed)", label=" sync attributes on authed ", constraint=false, weight=1]; + authAutomat:p_auth_status:e -> propertyAutomat:in0:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="authAutomat → propertyAutomat (sync properties on authed)", label=" sync properties on authed ", constraint=false, weight=1]; + authAutomat:p_auth_status:w -> supplierAutomat:in1:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="authAutomat → supplierAutomat (sync suppliers on authed)", label=" sync suppliers on authed ", constraint=false, weight=1]; + authAutomat:p_auth_status:e -> warehouseAutomat:in0:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="authAutomat → warehouseAutomat (sync warehouses on authed)", label=" sync warehouses on authed ", constraint=false, weight=1]; + authAutomat:p_auth_status:w -> posterminalAutomat:in1:n [color="#58d18a", fontcolor="#58d18a", tooltip="authAutomat → posterminalAutomat (sync terminals on authed)", label=" sync terminals on authed ", constraint=false, weight=1]; + authAutomat:p_auth_status:w -> deliveryAutomat:in1:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="authAutomat → deliveryAutomat (sync deliveries on authed)", label=" sync deliveries on authed ", constraint=false, weight=1]; + authAutomat:p_auth_status:e -> quoteAutomat:in0:n [color="#a98cff", fontcolor="#a98cff", tooltip="authAutomat → quoteAutomat (sync quotes on authed)", label=" sync quotes on authed ", constraint=false, weight=1]; + authAutomat:p_auth_status:e -> orderAutomat:in0:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="authAutomat → orderAutomat (sync orders on authed)", label=" sync orders on authed ", constraint=false, weight=1]; + authAutomat:p_auth_status:e -> entityAutomat:in0:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="authAutomat → entityAutomat (sync entities on authed)", label=" sync entities on authed ", constraint=false, weight=1]; + authAutomat:p_auth_status:e -> customergroupAutomat:in1:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="authAutomat → customergroupAutomat (sync customer groups)", label=" sync customer groups ", constraint=false, weight=1]; + authAutomat:p_auth_status:w -> numberAutomat:in0:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="authAutomat → numberAutomat (sync number series)", label=" sync number series ", constraint=false, weight=1]; + authAutomat:p_auth_status:w -> accountAutomat:in0:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="authAutomat → accountAutomat (sync payment accounts)", label=" sync payment accounts ", constraint=false, weight=1]; + authAutomat:p_auth_status:e -> chatAutomat:in1:n [color="#ff5f9e", fontcolor="#ff5f9e", tooltip="authAutomat → chatAutomat (auth chat & reload)", label=" auth chat & reload ", weight=3]; + authAutomat:p_auth_status:e -> vfsAutomat:in0:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="authAutomat → vfsAutomat (load company vfs)", label=" load company vfs ", constraint=false, weight=1]; + authAutomat:p_auth_status:e -> articleHistoryGridAutomat:in0:n [color="#66b3ff", fontcolor="#66b3ff", tooltip="authAutomat → articleHistoryGridAutomat (reload history on authed)", label=" reload history on authed ", constraint=false, weight=1]; + authAutomat:p_auth_status:e -> globalHistoryGridAutomat:in0:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="authAutomat → globalHistoryGridAutomat (reload audit on authed)", label=" reload audit on authed ", constraint=false, weight=1]; + sseAutomat:p_sse_events:w -> categoryAutomat:in6:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="sseAutomat → categoryAutomat ('category' sse trigger)", label=" 'category' sse trigger ", constraint=false, weight=1]; + sseAutomat:p_sse_events:w -> categoryViewAutomat:in1:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="sseAutomat → categoryViewAutomat (invalidate views on sse)", label=" invalidate views on sse ", constraint=false, weight=1]; + sseAutomat:p_sse_events:w -> articleIndexAutomat:in2:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="sseAutomat → articleIndexAutomat ('articles' sse trigger)", label=" 'articles' sse trigger ", constraint=false, weight=1]; + sseAutomat:p_sse_events:w -> artikelAutomat:in4:n [color="#c79bff", fontcolor="#c79bff", tooltip="sseAutomat → artikelAutomat ('article-<sku>' sse reload)", label=" 'article-<sku>' sse reload ", weight=3]; + sseAutomat:p_sse_events:w -> articleDetailAutomat:in0:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="sseAutomat → articleDetailAutomat (invalidateArticle(sku))", label=" invalidateArticle(sku) ", constraint=false, weight=1]; + sseAutomat:p_sse_events:e -> warehouseAutomat:in1:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="sseAutomat → warehouseAutomat ('warehouse' sse trigger)", label=" 'warehouse' sse trigger ", constraint=false, weight=1]; + sseAutomat:p_sse_events:w -> supplierAutomat:in2:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="sseAutomat → supplierAutomat ('supplier' sse trigger)", label=" 'supplier' sse trigger ", constraint=false, weight=1]; + sseAutomat:p_sse_events:w -> brandAutomat:in1:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="sseAutomat → brandAutomat ('brand' sse trigger)", label=" 'brand' sse trigger ", constraint=false, weight=1]; + sseAutomat:p_sse_events:e -> attributeAutomat:in1:n [color="#58d18a", fontcolor="#58d18a", tooltip="sseAutomat → attributeAutomat ('attributename' sse)", label=" 'attributename' sse ", constraint=false, weight=1]; + sseAutomat:p_sse_events:e -> propertyAutomat:in1:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="sseAutomat → propertyAutomat ('propertyname' sse)", label=" 'propertyname' sse ", constraint=false, weight=1]; + sseAutomat:p_sse_events:e -> orderAutomat:in1:n [color="#a98cff", fontcolor="#a98cff", tooltip="sseAutomat → orderAutomat ('order'/'shipment' sse)", label=" 'order'/'shipment' sse ", constraint=false, weight=1]; + sseAutomat:p_sse_events:e -> vfsAutomat:in2:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="sseAutomat → vfsAutomat ('vfs' sse mutation)", label=" 'vfs' sse mutation ", constraint=false, weight=1]; + companyAutomat:p_comp_root:e -> categoryAutomat:in1:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="companyAutomat → categoryAutomat (rootCategoryId)", label=" rootCategoryId ", constraint=false, weight=1]; + categoryAutomat:p_cat_loadTree:e -> categoryViewAutomat:in0:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="categoryAutomat → categoryViewAutomat (attachTree & warm slugs)", label=" attachTree & warm slugs ", weight=3]; + categoryViewAutomat:p_catview_collect:e -> articleIndexAutomat:in1:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="categoryViewAutomat → articleIndexAutomat (derived union index)", label=" derived union index ", weight=3]; + articleIndexAutomat:p_idx_items:e -> articleSearchAutomat:in1:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="articleIndexAutomat → articleSearchAutomat (query index items)", label=" query index items ", weight=3]; + categoryAutomat:p_cat_categories:e -> articleSearchAutomat:in0:n [color="#ff5f9e", fontcolor="#ff5f9e", tooltip="categoryAutomat → articleSearchAutomat (categoryFacets)", label=" categoryFacets ", constraint=false, weight=1]; + artikelAutomat:p_art_setName:e -> articleIndexAutomat:in0:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="artikelAutomat → articleIndexAutomat (setArticleIndexName (optimistic))", label=" setArticleIndexName (optimistic) ", constraint=false, weight=1]; + artikelAutomat:p_art_set:e -> categoryAutomat:in3:n [color="#66b3ff", fontcolor="#66b3ff", tooltip="artikelAutomat → categoryAutomat (articleCount updates)", label=" articleCount updates ", weight=3]; + articleDetailAutomat:p_artdet_hist:e -> artikelAutomat:in2:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="articleDetailAutomat → artikelAutomat (watch SKU details)", label=" watch SKU details ", weight=3]; + deliveryEditorAutomat:p_deled_supplier:w -> supplierAutomat:in0:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="deliveryEditorAutomat → supplierAutomat (match vendor catalog)", label=" match vendor catalog ", constraint=false, weight=1]; + deliveryEditorAutomat:p_deled_lines:e -> artikelAutomat:in1:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="deliveryEditorAutomat → artikelAutomat (lookup SKU & pack units)", label=" lookup SKU & pack units ", constraint=false, weight=1]; + deliveryEditorAutomat:p_deled_commit:e -> deliveryAutomat:in0:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="deliveryEditorAutomat → deliveryAutomat (commit delivery receipt)", label=" commit delivery receipt ", weight=3]; + quoteAutomat:p_quote_items:w -> orderAutomat:in2:n [color="#c79bff", fontcolor="#c79bff", tooltip="quoteAutomat → orderAutomat (convert quote to order)", label=" convert quote to order ", weight=3]; + orderAutomat:p_order_items:e -> entityAutomat:in1:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="orderAutomat → entityAutomat (customer lookup)", label=" customer lookup ", weight=3]; + companyAutomat:p_comp_curr:e -> customergroupAutomat:in0:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="companyAutomat → customergroupAutomat (poscustomergroup)", label=" poscustomergroup ", constraint=false, weight=1]; + posterminalAutomat:p_pos_term:e -> categoryAutomat:in2:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="posterminalAutomat → categoryAutomat (filter enabled roots)", label=" filter enabled roots ", weight=3]; + companyAutomat:p_comp_curr:e -> posterminalAutomat:in0:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="companyAutomat → posterminalAutomat (company context)", label=" company context ", weight=3]; + chatAutomat:p_chat_msg:e -> vfsAutomat:in1:n [color="#58d18a", fontcolor="#58d18a", tooltip="chatAutomat → vfsAutomat (inspect / edit applets)", label=" inspect / edit applets ", constraint=false, weight=1]; + chatAutomat:p_chat_tools:w -> navigationAutomat:in1:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="chatAutomat → navigationAutomat (set_user_route tool)", label=" set_user_route tool ", constraint=false, weight=1]; + chatAutomat:p_chat_sse:e -> sseAutomat:in1:n [color="#a98cff", fontcolor="#a98cff", tooltip="chatAutomat → sseAutomat (getSseClientId())", label=" getSseClientId() ", weight=3]; + navigationAutomat:p_nav_sku:e -> artikelAutomat:in0:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="navigationAutomat → artikelAutomat (restore last SKU)", label=" restore last SKU ", constraint=false, weight=1]; + navigationAutomat:p_nav_catslug:e -> categoryAutomat:in0:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="navigationAutomat → categoryAutomat (restore last category)", label=" restore last category ", constraint=false, weight=1]; + navigationAutomat:p_nav_delpath:e -> deliveryEditorAutomat:in0:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="navigationAutomat → deliveryEditorAutomat (restore draft path)", label=" restore draft path ", weight=3]; + userSettingsAutomat:p_usr_drawer:e -> chatAutomat:in0:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="userSettingsAutomat → chatAutomat (chatDrawerWidth)", label=" chatDrawerWidth ", weight=3]; + helpAutomat:p_help_active:e -> navigationAutomat:in0:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="helpAutomat → navigationAutomat (route-contextual help)", label=" route-contextual help ", weight=3]; + categoryAutomat:p_cat_categories:ne -> categoryAutomat:in4:n [color="#ff5f9e", fontcolor="#ff5f9e", tooltip="categoryAutomat → categoryAutomat (nested tree hierarchy)", label=" nested tree hierarchy ", weight=3]; + artikelAutomat:p_art_set:ne -> artikelAutomat:in3:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="artikelAutomat → artikelAutomat (variants & parent SKU)", label=" variants & parent SKU ", weight=3]; + chatAutomat:p_chat_msg:nw -> chatAutomat:in2:n [color="#66b3ff", fontcolor="#66b3ff", tooltip="chatAutomat → chatAutomat (stream delta appends)", label=" stream delta appends ", weight=3]; +} \ No newline at end of file diff --git a/automat-graph.svg b/automat-graph.svg new file mode 100644 index 0000000..a4768a1 --- /dev/null +++ b/automat-graph.svg @@ -0,0 +1,2193 @@ + + + + + + +automats + + +cluster_session_realtime + +Session & Real-Time Hub + + +cluster_catalog_products + +Catalog & Products + + +cluster_search_indexing + +Search & Indexing + + +cluster_properties_attributes + +Properties & Attributes + + +cluster_purchasing_logistics + +Suppliers & Deliveries + + +cluster_sales_crm + +Sales & CRM + + +cluster_inventory_storage + +Inventory & Warehousing + + +cluster_finance_config + +Company & Finance + + +cluster_hardware_pos + +POS & Hardware + + +cluster_ai_workspace + +AI Assistant & Workspace + + +cluster_audit_grids + +Audit & Virtual Grids + + +cluster_system_ui + +UI, Settings & System + + + +authAutomat + + +authAutomat +Session & Multi-Tenant Gateway + + + +STATE +user: AuthUser +Current user & company + + + +STATE +status +'anon' | 'authed' | 'loading' + + + +ACTION +bootstrapSession() +Restore or fetch session + + + +ACTION +switchCompany(id) +Multi-tenant switch + + + +ACTION +logout() +Clear session cookie + + + +SELECT +selectSelectedCompanyId() +Tenant selector + + + +HOOK +auth/sessionEstablished +Session ready lifecycle + + + +HOOK +auth/companyChanged +Company change signal + + + +API +GET /api/auth/session +Cookie session check + + + +sseAutomat + + + + +sseAutomat +Single Persistent SSE Connection + + + +STATE +status +'connected' | 'error' + + + +STATE +clientId (cid) +Tab-scoped UUID in session + + + +ACTION +reconnect() +Cycle on company switch + + + +ACTION +on(event, handler) +Register signal listener + + + +ACTION +watchArticle(sku) +Attach article-<sku> + + + +SELECT +invalidateArticle(sku) +Silent cache refresh + + + +SSE +category / articles +Catalog mutation events + + + +SSE +order / shipment +Fulfillment events + + + +API +GET /api/events +Persistent stream + + + +authAutomat:e->sseAutomat:n + + + + + +  bind session & cid   + + + +categoryAutomat + + + + + + + + + +categoryAutomat +Hierarchical Category Tree + + + +STATE +categories: Map +ID -> Category Node Map + + + +STATE +rootCategoryId +Company root node ID + + + +STATE +articleCount +Total parent items + + + +ACTION +loadTree() +Fetch tree & warm views + + + +ACTION +createCategory() +Insert child node + + + +ACTION +moveCategory() +Parent reparenting + + + +ACTION +deleteCategory() +Prune subtree + + + +SELECT +getCategoryBreadcrumbs() +Hierarchical path trace + + + +API +GET /api/category +Recursive tree load + + + +authAutomat:w->categoryAutomat:n + + + + + +  sync tree on authed   + + + +brandAutomat + + + + +brandAutomat +Catalog Brands & Makers + + + +STATE +brands: Brand[] +Decoupled brand catalog + + + +STATE +filteredBrands: Brand[] +Filtered search slice + + + +ACTION +load(silent) +Deduplicated list fetch + + + +ACTION +create() / rename() +Brand mutations + + + +ACTION +addImage() / deleteImage() +Brand logo AVIF image + + + +API +GET /api/brand +Paginated brand records + + + +SSE +brand +SSE signal with sender tab + + + +authAutomat:w->brandAutomat:n + + + + + +  sync brands on authed   + + + +unitAutomat + + + +unitAutomat +Units of Measure (UoM) + + + +STATE +units: Unit[] +Allowed SI & sales units + + + +STATE +status: 'idle'|'ready' +Load lifecycle state + + + +ACTION +load() +Fetch localized units + + + +ACTION +upsert(body) +Create or update unit + + + +ACTION +delete(id) +Remove unused unit + + + +API +GET /api/unit +Company unit directory + + + +authAutomat:w->unitAutomat:n + + + + + +  sync units on authed   + + + +propertyAutomat + + + + +propertyAutomat +Custom Metadata Properties + + + +STATE +properties: PropertyName[] +Sorted company properties + + + +ACTION +load() +Fetch property definitions + + + +ACTION +createProperty() +Add new property definition + + + +ACTION +updateProperty() +Edit title & sort + + + +API +GET /api/propertyname +Company properties + + + +SSE +propertyname +Real-time property signal + + + +authAutomat:e->propertyAutomat:n + + + + + +  sync properties on authed   + + + +attributeAutomat + + + + +attributeAutomat +Product Attributes & Discrete Values + + + +STATE +attributes: AttributeName[] +Attributes with sorted values + + + +ACTION +load() +Fetch attribute definitions + + + +ACTION +createAttribute() +Create attribute group + + + +ACTION +addValue() / deleteValue() +Discrete value options + + + +API +GET /api/attributename +Attributes & values + + + +SSE +attributename +Real-time attribute signal + + + +authAutomat:e->attributeAutomat:n + + + + + +  sync attributes on authed   + + + +supplierAutomat + + + + + +supplierAutomat +Supplier Directory & Catalogs + + + +STATE +suppliers: Supplier[] +Active vendor list + + + +STATE +status: 'ready' +Load lifecycle state + + + +ACTION +load() +Deduplicated fetch + + + +ACTION +reload() +Silent background sync + + + +API +GET /api/supplier +Vendor records + + + +SSE +supplier +Real-time supplier signal + + + +authAutomat:w->supplierAutomat:n + + + + + +  sync suppliers on authed   + + + +deliveryAutomat + + + + +deliveryAutomat +createListAutomat (Deliveries) + + + +STATE +items: Delivery[] +Company delivery receipts + + + +STATE +status: 'ready' +List lifecycle state + + + +ACTION +load() / reload() +Deduplicated fetch & sync + + + +ACTION +reset() +Clear on session switch + + + +API +GET /api/delivery +Inbound delivery log + + + +authAutomat:w->deliveryAutomat:n + + + + + +  sync deliveries on authed   + + + +quoteAutomat + + + +quoteAutomat +createListAutomat (Quotes) + + + +STATE +items: Quote[] +Sales quotes / Angebote + + + +ACTION +load() / reload() +Deduplicated fetch + + + +ACTION +reset() +Reset on logout + + + +API +GET /api/quote +Company quote list + + + +authAutomat:e->quoteAutomat:n + + + + + +  sync quotes on authed   + + + +orderAutomat + + + + + +orderAutomat +Chunk-Paginated Order Engine + + + +STATE +items: Order[] +Sales orders & lines + + + +STATE +total: number +Unfiltered order count + + + +STATE +onlyUnpaid / onlyUndelivered +Fulfillment filters + + + +ACTION +loadChunk(index) +Chunk size 50 prefetch + + + +ACTION +reload() +Silent orders re-fetch + + + +SELECT +selectOrders() / selectTotal() +Filter-applied slice + + + +API +GET /api/order +Paged order stream + + + +SSE +order / shipment +Fulfillment mutations + + + +authAutomat:e->orderAutomat:n + + + + + +  sync orders on authed   + + + +entityAutomat + + + + +entityAutomat +createListAutomat (Entities) + + + +STATE +items: Entity[] +Customers & partners + + + +ACTION +load() / reload() +Customer directory load + + + +ACTION +reset() +Reset on session switch + + + +API +GET /api/entity +Directory records + + + +authAutomat:e->entityAutomat:n + + + + + +  sync entities on authed   + + + +customergroupAutomat + + + + +customergroupAutomat +createListAutomat (Groups) + + + +STATE +items: Customergroup[] +Pricing discount tiers + + + +ACTION +load() / reload() +Discount group load + + + +API +GET /api/customergroup +Pricing tiers + + + +authAutomat:e->customergroupAutomat:n + + + + + +  sync customer groups   + + + +warehouseAutomat + + + + +warehouseAutomat +Warehouse Locations & Stock + + + +STATE +warehouses: Warehouse[] +Storage areas & bins + + + +STATE +filteredWarehouses +Filter search slice + + + +ACTION +load() / reload() +Deduplicated sync + + + +ACTION +setFilter(query) +Local filter query + + + +API +GET /api/warehouse +Warehouse listings + + + +SSE +warehouse +Real-time stock movement + + + +authAutomat:e->warehouseAutomat:n + + + + + +  sync warehouses on authed   + + + +companyAutomat + + + +companyAutomat +Active Company Configuration + + + +STATE +current: Company +Tax rates & ISO language + + + +ACTION +load() / reload() +Fetch tenant settings + + + +SELECT +selectCompanyTaxrate() +Standard tax rate + + + +SELECT +selectCompanyRootCategoryId() +Root category pointer + + + +SELECT +selectCompanyName() +Company legal name + + + +API +GET /api/company/:id +Legal & tax profile + + + +authAutomat:w->companyAutomat:n + + + + + +  load company profile   + + + +accountAutomat + + + +accountAutomat +createListAutomat (Accounts) + + + +STATE +items: Account[] +Bank & payment ledgers + + + +ACTION +load() / reload() +Payment accounts load + + + +API +GET /api/account +Chart of accounts + + + +authAutomat:w->accountAutomat:n + + + + + +  sync payment accounts   + + + +numberAutomat + + + +numberAutomat +createListAutomat (Sequences) + + + +STATE +items: NumberSeq[] +Invoice/receipt counters + + + +ACTION +load() / reload() +Number series sync + + + +API +GET /api/number +Document counters + + + +authAutomat:w->numberAutomat:n + + + + + +  sync number series   + + + +posterminalAutomat + + + + +posterminalAutomat +POS Terminals & Gating + + + +STATE +terminals: Posterminal[] +Hardware terminals + + + +ACTION +load() +Fetch POS registers + + + +ACTION +create() / delete() +Terminal registrations + + + +ACTION +toggleCategory() +Gated category filters + + + +SELECT +selectPosterminals() +Active registers + + + +API +GET /api/posterminal +Terminal device configs + + + +authAutomat:w->posterminalAutomat:n + + + + + +  sync terminals on authed   + + + +chatAutomat + + + + + +chatAutomat +AI Assistant Drawer & SSE + + + +STATE +isOpen: boolean +Docked chat drawer toggle + + + +STATE +conversations: [] +Conversation sessions + + + +STATE +messages: ModelMessage[] +Live message transcript + + + +STATE +sseStatus: 'connected' +Token streaming stream + + + +ACTION +sendMessage(text) +Prompt model with tools + + + +ACTION +answerAskUser() +Interactive tool modal + + + +SELECT +userEnv / routeTools +Client navigation tool + + + +API +POST /api/modelconversation +AI conversation session + + + +SSE +modelconversation/events +Live token stream + + + +authAutomat:e->chatAutomat:n + + + + + +  auth chat & reload   + + + +vfsAutomat + + + + + +vfsAutomat +Virtual Filesystem Explorer + + + +STATE +files: VfsFile[] +Company VFS workspace + + + +STATE +selectedFileName +Current active file + + + +STATE +fileContent / isDirty +Editor buffer & diff + + + +ACTION +selectFile() / saveFile() +Applet editor buffer + + + +ACTION +uploadFiles() / deleteFile() +File manipulations + + + +API +GET /api/vfs +VFS directory index + + + +SSE +vfs +VFS mutation signal + + + +authAutomat:e->vfsAutomat:n + + + + + +  load company vfs   + + + +articleHistoryGridAutomat + + + +articleHistoryGridAutomat +createGridAutomat (Article) + + + +STATE +rows: [] / totalRows +Block size 50 virtual rows + + + +ACTION +fetchRows(viewport) +Infinite scroll query + + + +ACTION +reload() +Reload on company switch + + + +API +GET /api/article/history +Infinite audit window + + + +authAutomat:e->articleHistoryGridAutomat:n + + + + + +  reload history on authed   + + + +globalHistoryGridAutomat + + + +globalHistoryGridAutomat +createGridAutomat (Global) + + + +STATE +rows: [] / totalRows +Block size 50 virtual rows + + + +ACTION +fetchRows(viewport) +Infinite scroll query + + + +ACTION +reload() +Reload on company switch + + + +API +GET /api/globalhistory +Company global audit + + + +authAutomat:e->globalHistoryGridAutomat:n + + + + + +  reload audit on authed   + + + +sseAutomat:w->categoryAutomat:n + + + + + +  'category' sse trigger   + + + +categoryViewAutomat + + + + +categoryViewAutomat +KeyedAutomat (Slug Path) + + + +STATE +views: Map<slug, Data> +Articles & direct children + + + +ACTION +loadAllCategoryViews() +Prewarm all tree slugs + + + +ACTION +reloadLoadedCategoryViews() +Refresh cached views + + + +ACTION +invalidateAllCategoryViews() +Clear on SSE signal + + + +SELECT +collectLoadedCategoryViewData() +Extract union data + + + +SELECT +getOrCreateCategoryAutomat() +Per-slug reactive store + + + +API +GET /api/category/:id/articles +Category article batch + + + +sseAutomat:w->categoryViewAutomat:n + + + + + +  invalidate views on sse   + + + +artikelAutomat + + + + + + + +artikelAutomat +In-Memory Article Record Cache + + + +STATE +articles: Map<sku, Object> +Deduplicated SKU cache + + + +ACTION +loadArticle(sku) +Fetch or cached promise + + + +ACTION +reloadArticle(sku) +Silent background sync + + + +ACTION +setArticleName(sku, name) +Optimistic rename + + + +ACTION +clearArticle(sku) +Evict SKU from cache + + + +SELECT +getArticle(sku) +Sync constructor read + + + +SELECT +createArticleSelector(sku) +Fine-grained selector + + + +API +GET /api/article/:sku +Full article document + + + +sseAutomat:w->artikelAutomat:n + + + + + +  'article-<sku>' sse reload   + + + +articleDetailAutomat + + + +articleDetailAutomat +KeyedAutomat (SKU) + + + +STATE +articleHistory: Map<sku> +Change history log + + + +STATE +articleSupplier: Map<sku> +Assigned supplier list + + + +ACTION +articleHistoryAutomat.load() +Fetch SKU audit trail + + + +ACTION +articleSupplierAutomat.load() +Fetch supplier catalog + + + +API +GET /api/article/:sku/history +Audit change entries + + + +API +GET /api/article/:sku/supplier +Vendor links & prices + + + +sseAutomat:w->articleDetailAutomat:n + + + + + +  invalidateArticle(sku)   + + + +sseAutomat:w->brandAutomat:n + + + + + +  'brand' sse trigger   + + + +articleIndexAutomat + + + + + +articleIndexAutomat +Derived Client Search Index + + + +STATE +items: ArticleIndexRecord[] +De-duplicated article union + + + +STATE +status: 'ready' +Index lifecycle flag + + + +ACTION +buildArticleIndex() +Derive from category views + + + +ACTION +setArticleIndexName() +Patch on rename + + + +ACTION +reload() +Re-derive from views + + + +SELECT +selectArticleIndexItems() +Fast record projection + + + +SELECT +ensureLoaded() +Await warm views + + + +sseAutomat:w->articleIndexAutomat:n + + + + + +  'articles' sse trigger   + + + +sseAutomat:e->propertyAutomat:n + + + + + +  'propertyname' sse   + + + +sseAutomat:e->attributeAutomat:n + + + + + +  'attributename' sse   + + + +sseAutomat:w->supplierAutomat:n + + + + + +  'supplier' sse trigger   + + + +sseAutomat:e->orderAutomat:n + + + + + +  'order'/'shipment' sse   + + + +sseAutomat:e->warehouseAutomat:n + + + + + +  'warehouse' sse trigger   + + + +sseAutomat:e->vfsAutomat:n + + + + + +  'vfs' sse mutation   + + + +categoryAutomat:ne->categoryAutomat:n + + + + + +  nested tree hierarchy   + + + +categoryAutomat:e->categoryViewAutomat:n + + + + + +  attachTree & warm slugs   + + + +articleSearchAutomat + + + + +articleSearchAutomat +In-Memory FlexSearch Engine + + + +STATE +target: string +Active search query + + + +STATE +filter: string +Active category filter + + + +STATE +result: ArticleSearchResult +Facets, recordset, count + + + +ACTION +search(target, filter) +Synchronous indexed query + + + +SELECT +selectSearchResult() +Reactive search outcome + + + +categoryAutomat:e->articleSearchAutomat:n + + + + + +  categoryFacets   + + + +categoryViewAutomat:e->articleIndexAutomat:n + + + + + +  derived union index   + + + +artikelAutomat:e->categoryAutomat:n + + + + + +  articleCount updates   + + + +artikelAutomat:ne->artikelAutomat:n + + + + + +  variants & parent SKU   + + + +artikelAutomat:e->articleIndexAutomat:n + + + + + +  setArticleIndexName (optimistic)   + + + +articleDetailAutomat:e->artikelAutomat:n + + + + + +  watch SKU details   + + + +articleIndexAutomat:e->articleSearchAutomat:n + + + + + +  query index items   + + + +deliveryEditorAutomat + + + +deliveryEditorAutomat +Inbound Receipt & OCR Machine + + + +STATE +lines: DeliveryLine[] +Uncommitted draft lines + + + +STATE +deliveryEntity +Selected supplier ID + + + +STATE +attachments: File[] +Invoice PDFs & OCR results + + + +STATE +draftCount: number +Uncommitted lines for badge + + + +ACTION +addLine() / updateLine() +Interactive item editing + + + +ACTION +commitDelivery() +Persist goods receipt + + + +SELECT +buildSupplierArticlesMap() +Auto-match SKU/EAN + + + +API +POST /api/delivery +Create goods receipt + + + +deliveryEditorAutomat:e->artikelAutomat:n + + + + + +  lookup SKU & pack units   + + + +deliveryEditorAutomat:w->supplierAutomat:n + + + + + +  match vendor catalog   + + + +deliveryEditorAutomat:e->deliveryAutomat:n + + + + + +  commit delivery receipt   + + + +quoteAutomat:w->orderAutomat:n + + + + + +  convert quote to order   + + + +orderAutomat:e->entityAutomat:n + + + + + +  customer lookup   + + + +companyAutomat:e->categoryAutomat:n + + + + + +  rootCategoryId   + + + +companyAutomat:e->customergroupAutomat:n + + + + + +  poscustomergroup   + + + +companyAutomat:e->posterminalAutomat:n + + + + + +  company context   + + + +posterminalAutomat:e->categoryAutomat:n + + + + + +  filter enabled roots   + + + +chatAutomat:e->sseAutomat:n + + + + + +  getSseClientId()   + + + +chatAutomat:nw->chatAutomat:n + + + + + +  stream delta appends   + + + +chatAutomat:e->vfsAutomat:n + + + + + +  inspect / edit applets   + + + +navigationAutomat + + + + +navigationAutomat +Cross-Section Navigation Memory + + + +STATE +lastDeliveryPath +Active receipt subpath + + + +STATE +lastArticleSku +Active SKU selection + + + +STATE +lastCategorySlug / Path +Active category tree node + + + +STATE +lastSearchTerm / Path +Preserved search query + + + +ACTION +setLastDeliveryPath() +Restore on sidebar click + + + +ACTION +setLastArticleSku() +Restore SKU on section click + + + +ACTION +clearNavigation() +Reset on company switch + + + +chatAutomat:w->navigationAutomat:n + + + + + +  set_user_route tool   + + + +navigationAutomat:e->categoryAutomat:n + + + + + +  restore last category   + + + +navigationAutomat:e->artikelAutomat:n + + + + + +  restore last SKU   + + + +navigationAutomat:e->deliveryEditorAutomat:n + + + + + +  restore draft path   + + + +userSettingsAutomat + + +userSettingsAutomat +IndexedDB User Preferences + + + +STATE +viewerPosition: 'top'|'right' +Editor docking location + + + +STATE +chatDrawerWidth: 460 +AI drawer pixel width + + + +ACTION +setViewerPosition() +Persist docking + + + +ACTION +setChatDrawerWidth() +Persist drawer width + + + +HOOK +persist: true +Survives page reloads + + + +userSettingsAutomat:e->chatAutomat:n + + + + + +  chatDrawerWidth   + + + +helpAutomat + + +helpAutomat +IndexedDB Contextual Help + + + +STATE +enabled: boolean +Interactive help badges + + + +STATE +activeHelpId: string|null +Active popper ID + + + +ACTION +toggle() / setEnabled() +Toggle badge mode + + + +ACTION +openPopper() / closePopper() +Single active popper + + + +HOOK +persist: true +Survives page reloads + + + +helpAutomat:e->navigationAutomat:n + + + + + +  route-contextual help   + + + +pwaAutomat + + +pwaAutomat +Service Worker & Offline Lifecycle + + + +STATE +needRefresh: boolean +New SW release pending + + + +STATE +offlineReady: boolean +Offline assets cached + + + +STATE +dialogOpen / updating +Prompt modal state + + + +ACTION +applyUpdate() +Skip waiting & reload + + + +ACTION +openDialog() / closeDialog() +Modal controls + + + +Legend + + + +Automat Graph Legend + + +STATE + +Reactive store state property + + +ACTION + +State mutation action + + +SELECT + +Reactive PureComponent selector + + +API + +Backend REST/Fetch endpoint + + +SSE + +Inbound Server-Sent Event trigger + + +HOOK + +Auth / persistence lifecycle hook + +in# + +Inbound arrival port on Automat header + +▪—▶ + +Reactive dataflow / upstream trigger + + + diff --git a/generate-automat-graph.js b/generate-automat-graph.js new file mode 100644 index 0000000..798c1c1 --- /dev/null +++ b/generate-automat-graph.js @@ -0,0 +1,1034 @@ +import { writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Graphviz } from '@hpcc-js/wasm-graphviz'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const outputPath = join(__dirname, 'automat-graph.svg'); +const dotOutputPath = join(__dirname, 'automat-graph.dot'); + +const COLORS = { + background: '#1e2430', + tableBorder: '#3f4c5f', + headerBg: '#33415c', + headerText: '#e2e8f0', + rowBg: '#2a3242', + rowBgAlt: '#272e3d', + keyBg: '#2b3a52', + text: '#d8dee9', + muted: '#8b95a7', + clusterFill: '#202a3a', +}; + +const BADGE_COLORS = { + STATE: { fg: '#c084fc', bg: '#2b1a42' }, // Purple + ACTION: { fg: '#4ade80', bg: '#143826' }, // Green + SELECT: { fg: '#38bdf8', bg: '#182b40' }, // Sky Blue + API: { fg: '#fbbf24', bg: '#3b2c12' }, // Amber + SSE: { fg: '#f87171', bg: '#3a181e' }, // Rose/Red + HOOK: { fg: '#fb923c', bg: '#382414' }, // Orange/Gold +}; + +const EDGE_COLORS = [ + '#5aa7ff', // Blue + '#6bdf9a', // Mint Green + '#f5c15c', // Golden Amber + '#ff7fc2', // Pink + '#c79bff', // Lavender + '#5cd6d6', // Cyan + '#ff9f5a', // Coral + '#8f95ff', // Indigo + '#ff6b6b', // Soft Red + '#58d18a', // Emerald + '#4fc4e8', // Sky + '#a98cff', // Violet + '#ff8f66', // Tangerine + '#4ecb8f', // Seafoam + '#ff7aa8', // Rose + '#e0b25a', // Sand + '#56c8c8', // Aqua + '#ff5f9e', // Fuchsia + '#ffb25a', // Ochre + '#66b3ff', // Periwinkle +]; + +const CLUSTERS = [ + { + id: 'session_realtime', + label: 'Session & Real-Time Hub', + automats: ['authAutomat', 'sseAutomat'], + }, + { + id: 'catalog_products', + label: 'Catalog & Products', + automats: ['categoryAutomat', 'categoryViewAutomat', 'artikelAutomat', 'articleDetailAutomat', 'brandAutomat', 'unitAutomat'], + }, + { + id: 'search_indexing', + label: 'Search & Indexing', + automats: ['articleIndexAutomat', 'articleSearchAutomat'], + }, + { + id: 'properties_attributes', + label: 'Properties & Attributes', + automats: ['propertyAutomat', 'attributeAutomat'], + }, + { + id: 'purchasing_logistics', + label: 'Suppliers & Deliveries', + automats: ['supplierAutomat', 'deliveryAutomat', 'deliveryEditorAutomat'], + }, + { + id: 'sales_crm', + label: 'Sales & CRM', + automats: ['quoteAutomat', 'orderAutomat', 'entityAutomat', 'customergroupAutomat'], + }, + { + id: 'inventory_storage', + label: 'Inventory & Warehousing', + automats: ['warehouseAutomat'], + }, + { + id: 'finance_config', + label: 'Company & Finance', + automats: ['companyAutomat', 'accountAutomat', 'numberAutomat'], + }, + { + id: 'hardware_pos', + label: 'POS & Hardware', + automats: ['posterminalAutomat'], + }, + { + id: 'ai_workspace', + label: 'AI Assistant & Workspace', + automats: ['chatAutomat', 'vfsAutomat'], + }, + { + id: 'audit_grids', + label: 'Audit & Virtual Grids', + automats: ['articleHistoryGridAutomat', 'globalHistoryGridAutomat'], + }, + { + id: 'system_ui', + label: 'UI, Settings & System', + automats: ['navigationAutomat', 'userSettingsAutomat', 'helpAutomat', 'pwaAutomat', 'Legend'], + }, +]; + +function escapeHtml(text) { + return String(text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function truncate(str, max = 30) { + if (!str) return ''; + return str.length > max ? `${str.slice(0, max - 1)}…` : str; +} + +/** + * Complete metadata for every Automat in Dashfront. + */ +const AUTOMAT_SPECS = [ + // 1. Session & Real-Time Hub + { + id: 'authAutomat', + title: 'authAutomat', + subtitle: 'Session & Multi-Tenant Gateway', + entries: [ + { type: 'STATE', name: 'user: AuthUser', desc: 'Current user & company' }, + { type: 'STATE', name: 'status', desc: '\'anon\' | \'authed\' | \'loading\'' }, + { type: 'ACTION', name: 'bootstrapSession()', desc: 'Restore or fetch session' }, + { type: 'ACTION', name: 'switchCompany(id)', desc: 'Multi-tenant switch', port: 'p_auth_switchCompany' }, + { type: 'ACTION', name: 'logout()', desc: 'Clear session cookie' }, + { type: 'SELECT', name: 'selectSelectedCompanyId()', desc: 'Tenant selector' }, + { type: 'HOOK', name: 'auth/sessionEstablished', desc: 'Session ready lifecycle', port: 'p_auth_status' }, + { type: 'HOOK', name: 'auth/companyChanged', desc: 'Company change signal', port: 'p_auth_user' }, + { type: 'API', name: 'GET /api/auth/session', desc: 'Cookie session check' }, + ], + }, + { + id: 'sseAutomat', + title: 'sseAutomat', + subtitle: 'Single Persistent SSE Connection', + entries: [ + { type: 'STATE', name: 'status', desc: '\'connected\' | \'error\'' }, + { type: 'STATE', name: 'clientId (cid)', desc: 'Tab-scoped UUID in session' }, + { type: 'ACTION', name: 'reconnect()', desc: 'Cycle on company switch' }, + { type: 'ACTION', name: 'on(event, handler)', desc: 'Register signal listener' }, + { type: 'ACTION', name: 'watchArticle(sku)', desc: 'Attach article-' }, + { type: 'SELECT', name: 'invalidateArticle(sku)', desc: 'Silent cache refresh' }, + { type: 'SSE', name: 'category / articles', desc: 'Catalog mutation events', port: 'p_sse_events' }, + { type: 'SSE', name: 'order / shipment', desc: 'Fulfillment events' }, + { type: 'API', name: 'GET /api/events', desc: 'Persistent stream' }, + ], + }, + + // 2. Catalog & Products + { + id: 'categoryAutomat', + title: 'categoryAutomat', + subtitle: 'Hierarchical Category Tree', + entries: [ + { type: 'STATE', name: 'categories: Map', desc: 'ID -> Category Node Map', port: 'p_cat_categories' }, + { type: 'STATE', name: 'rootCategoryId', desc: 'Company root node ID' }, + { type: 'STATE', name: 'articleCount', desc: 'Total parent items' }, + { type: 'ACTION', name: 'loadTree()', desc: 'Fetch tree & warm views', port: 'p_cat_loadTree' }, + { type: 'ACTION', name: 'createCategory()', desc: 'Insert child node' }, + { type: 'ACTION', name: 'moveCategory()', desc: 'Parent reparenting' }, + { type: 'ACTION', name: 'deleteCategory()', desc: 'Prune subtree' }, + { type: 'SELECT', name: 'getCategoryBreadcrumbs()', desc: 'Hierarchical path trace' }, + { type: 'API', name: 'GET /api/category', desc: 'Recursive tree load' }, + ], + }, + { + id: 'categoryViewAutomat', + title: 'categoryViewAutomat', + subtitle: 'KeyedAutomat (Slug Path)', + entries: [ + { type: 'STATE', name: 'views: Map', desc: 'Articles & direct children' }, + { type: 'ACTION', name: 'loadAllCategoryViews()', desc: 'Prewarm all tree slugs' }, + { type: 'ACTION', name: 'reloadLoadedCategoryViews()', desc: 'Refresh cached views' }, + { type: 'ACTION', name: 'invalidateAllCategoryViews()', desc: 'Clear on SSE signal' }, + { type: 'SELECT', name: 'collectLoadedCategoryViewData()', desc: 'Extract union data', port: 'p_catview_collect' }, + { type: 'SELECT', name: 'getOrCreateCategoryAutomat()', desc: 'Per-slug reactive store' }, + { type: 'API', name: 'GET /api/category/:id/articles', desc: 'Category article batch' }, + ], + }, + { + id: 'artikelAutomat', + title: 'artikelAutomat', + subtitle: 'In-Memory Article Record Cache', + entries: [ + { type: 'STATE', name: 'articles: Map', desc: 'Deduplicated SKU cache', port: 'p_art_set' }, + { type: 'ACTION', name: 'loadArticle(sku)', desc: 'Fetch or cached promise' }, + { type: 'ACTION', name: 'reloadArticle(sku)', desc: 'Silent background sync' }, + { type: 'ACTION', name: 'setArticleName(sku, name)', desc: 'Optimistic rename', port: 'p_art_setName' }, + { type: 'ACTION', name: 'clearArticle(sku)', desc: 'Evict SKU from cache' }, + { type: 'SELECT', name: 'getArticle(sku)', desc: 'Sync constructor read' }, + { type: 'SELECT', name: 'createArticleSelector(sku)', desc: 'Fine-grained selector' }, + { type: 'API', name: 'GET /api/article/:sku', desc: 'Full article document' }, + ], + }, + { + id: 'articleDetailAutomat', + title: 'articleDetailAutomat', + subtitle: 'KeyedAutomat (SKU)', + entries: [ + { type: 'STATE', name: 'articleHistory: Map', desc: 'Change history log', port: 'p_artdet_hist' }, + { type: 'STATE', name: 'articleSupplier: Map', desc: 'Assigned supplier list' }, + { type: 'ACTION', name: 'articleHistoryAutomat.load()', desc: 'Fetch SKU audit trail' }, + { type: 'ACTION', name: 'articleSupplierAutomat.load()', desc: 'Fetch supplier catalog' }, + { type: 'API', name: 'GET /api/article/:sku/history', desc: 'Audit change entries' }, + { type: 'API', name: 'GET /api/article/:sku/supplier', desc: 'Vendor links & prices' }, + ], + }, + { + id: 'brandAutomat', + title: 'brandAutomat', + subtitle: 'Catalog Brands & Makers', + entries: [ + { type: 'STATE', name: 'brands: Brand[]', desc: 'Decoupled brand catalog' }, + { type: 'STATE', name: 'filteredBrands: Brand[]', desc: 'Filtered search slice' }, + { type: 'ACTION', name: 'load(silent)', desc: 'Deduplicated list fetch' }, + { type: 'ACTION', name: 'create() / rename()', desc: 'Brand mutations' }, + { type: 'ACTION', name: 'addImage() / deleteImage()', desc: 'Brand logo AVIF image' }, + { type: 'API', name: 'GET /api/brand', desc: 'Paginated brand records' }, + { type: 'SSE', name: 'brand', desc: 'SSE signal with sender tab' }, + ], + }, + { + id: 'unitAutomat', + title: 'unitAutomat', + subtitle: 'Units of Measure (UoM)', + entries: [ + { type: 'STATE', name: 'units: Unit[]', desc: 'Allowed SI & sales units' }, + { type: 'STATE', name: 'status: \'idle\'|\'ready\'', desc: 'Load lifecycle state' }, + { type: 'ACTION', name: 'load()', desc: 'Fetch localized units' }, + { type: 'ACTION', name: 'upsert(body)', desc: 'Create or update unit' }, + { type: 'ACTION', name: 'delete(id)', desc: 'Remove unused unit' }, + { type: 'API', name: 'GET /api/unit', desc: 'Company unit directory' }, + ], + }, + + // 3. Search & Discovery + { + id: 'articleIndexAutomat', + title: 'articleIndexAutomat', + subtitle: 'Derived Client Search Index', + entries: [ + { type: 'STATE', name: 'items: ArticleIndexRecord[]', desc: 'De-duplicated article union', port: 'p_idx_items' }, + { type: 'STATE', name: 'status: \'ready\'', desc: 'Index lifecycle flag' }, + { type: 'ACTION', name: 'buildArticleIndex()', desc: 'Derive from category views' }, + { type: 'ACTION', name: 'setArticleIndexName()', desc: 'Patch on rename' }, + { type: 'ACTION', name: 'reload()', desc: 'Re-derive from views' }, + { type: 'SELECT', name: 'selectArticleIndexItems()', desc: 'Fast record projection' }, + { type: 'SELECT', name: 'ensureLoaded()', desc: 'Await warm views' }, + ], + }, + { + id: 'articleSearchAutomat', + title: 'articleSearchAutomat', + subtitle: 'In-Memory FlexSearch Engine', + entries: [ + { type: 'STATE', name: 'target: string', desc: 'Active search query' }, + { type: 'STATE', name: 'filter: string', desc: 'Active category filter' }, + { type: 'STATE', name: 'result: ArticleSearchResult', desc: 'Facets, recordset, count' }, + { type: 'ACTION', name: 'search(target, filter)', desc: 'Synchronous indexed query' }, + { type: 'SELECT', name: 'selectSearchResult()', desc: 'Reactive search outcome' }, + ], + }, + + // 4. Properties & Attributes + { + id: 'propertyAutomat', + title: 'propertyAutomat', + subtitle: 'Custom Metadata Properties', + entries: [ + { type: 'STATE', name: 'properties: PropertyName[]', desc: 'Sorted company properties' }, + { type: 'ACTION', name: 'load()', desc: 'Fetch property definitions' }, + { type: 'ACTION', name: 'createProperty()', desc: 'Add new property definition' }, + { type: 'ACTION', name: 'updateProperty()', desc: 'Edit title & sort' }, + { type: 'API', name: 'GET /api/propertyname', desc: 'Company properties' }, + { type: 'SSE', name: 'propertyname', desc: 'Real-time property signal' }, + ], + }, + { + id: 'attributeAutomat', + title: 'attributeAutomat', + subtitle: 'Product Attributes & Discrete Values', + entries: [ + { type: 'STATE', name: 'attributes: AttributeName[]', desc: 'Attributes with sorted values' }, + { type: 'ACTION', name: 'load()', desc: 'Fetch attribute definitions' }, + { type: 'ACTION', name: 'createAttribute()', desc: 'Create attribute group' }, + { type: 'ACTION', name: 'addValue() / deleteValue()', desc: 'Discrete value options' }, + { type: 'API', name: 'GET /api/attributename', desc: 'Attributes & values' }, + { type: 'SSE', name: 'attributename', desc: 'Real-time attribute signal' }, + ], + }, + + // 5. Suppliers & Deliveries + { + id: 'supplierAutomat', + title: 'supplierAutomat', + subtitle: 'Supplier Directory & Catalogs', + entries: [ + { type: 'STATE', name: 'suppliers: Supplier[]', desc: 'Active vendor list' }, + { type: 'STATE', name: 'status: \'ready\'', desc: 'Load lifecycle state' }, + { type: 'ACTION', name: 'load()', desc: 'Deduplicated fetch' }, + { type: 'ACTION', name: 'reload()', desc: 'Silent background sync' }, + { type: 'API', name: 'GET /api/supplier', desc: 'Vendor records' }, + { type: 'SSE', name: 'supplier', desc: 'Real-time supplier signal' }, + ], + }, + { + id: 'deliveryAutomat', + title: 'deliveryAutomat', + subtitle: 'createListAutomat (Deliveries)', + entries: [ + { type: 'STATE', name: 'items: Delivery[]', desc: 'Company delivery receipts' }, + { type: 'STATE', name: 'status: \'ready\'', desc: 'List lifecycle state' }, + { type: 'ACTION', name: 'load() / reload()', desc: 'Deduplicated fetch & sync' }, + { type: 'ACTION', name: 'reset()', desc: 'Clear on session switch' }, + { type: 'API', name: 'GET /api/delivery', desc: 'Inbound delivery log' }, + ], + }, + { + id: 'deliveryEditorAutomat', + title: 'deliveryEditorAutomat', + subtitle: 'Inbound Receipt & OCR Machine', + entries: [ + { type: 'STATE', name: 'lines: DeliveryLine[]', desc: 'Uncommitted draft lines', port: 'p_deled_lines' }, + { type: 'STATE', name: 'deliveryEntity', desc: 'Selected supplier ID', port: 'p_deled_supplier' }, + { type: 'STATE', name: 'attachments: File[]', desc: 'Invoice PDFs & OCR results' }, + { type: 'STATE', name: 'draftCount: number', desc: 'Uncommitted lines for badge' }, + { type: 'ACTION', name: 'addLine() / updateLine()', desc: 'Interactive item editing' }, + { type: 'ACTION', name: 'commitDelivery()', desc: 'Persist goods receipt', port: 'p_deled_commit' }, + { type: 'SELECT', name: 'buildSupplierArticlesMap()', desc: 'Auto-match SKU/EAN' }, + { type: 'API', name: 'POST /api/delivery', desc: 'Create goods receipt' }, + ], + }, + + // 6. Sales & CRM + { + id: 'quoteAutomat', + title: 'quoteAutomat', + subtitle: 'createListAutomat (Quotes)', + entries: [ + { type: 'STATE', name: 'items: Quote[]', desc: 'Sales quotes / Angebote', port: 'p_quote_items' }, + { type: 'ACTION', name: 'load() / reload()', desc: 'Deduplicated fetch' }, + { type: 'ACTION', name: 'reset()', desc: 'Reset on logout' }, + { type: 'API', name: 'GET /api/quote', desc: 'Company quote list' }, + ], + }, + { + id: 'orderAutomat', + title: 'orderAutomat', + subtitle: 'Chunk-Paginated Order Engine', + entries: [ + { type: 'STATE', name: 'items: Order[]', desc: 'Sales orders & lines', port: 'p_order_items' }, + { type: 'STATE', name: 'total: number', desc: 'Unfiltered order count' }, + { type: 'STATE', name: 'onlyUnpaid / onlyUndelivered', desc: 'Fulfillment filters' }, + { type: 'ACTION', name: 'loadChunk(index)', desc: 'Chunk size 50 prefetch' }, + { type: 'ACTION', name: 'reload()', desc: 'Silent orders re-fetch' }, + { type: 'SELECT', name: 'selectOrders() / selectTotal()', desc: 'Filter-applied slice' }, + { type: 'API', name: 'GET /api/order', desc: 'Paged order stream' }, + { type: 'SSE', name: 'order / shipment', desc: 'Fulfillment mutations' }, + ], + }, + { + id: 'entityAutomat', + title: 'entityAutomat', + subtitle: 'createListAutomat (Entities)', + entries: [ + { type: 'STATE', name: 'items: Entity[]', desc: 'Customers & partners' }, + { type: 'ACTION', name: 'load() / reload()', desc: 'Customer directory load' }, + { type: 'ACTION', name: 'reset()', desc: 'Reset on session switch' }, + { type: 'API', name: 'GET /api/entity', desc: 'Directory records' }, + ], + }, + { + id: 'customergroupAutomat', + title: 'customergroupAutomat', + subtitle: 'createListAutomat (Groups)', + entries: [ + { type: 'STATE', name: 'items: Customergroup[]', desc: 'Pricing discount tiers' }, + { type: 'ACTION', name: 'load() / reload()', desc: 'Discount group load' }, + { type: 'API', name: 'GET /api/customergroup', desc: 'Pricing tiers' }, + ], + }, + + // 7. Inventory & Storage + { + id: 'warehouseAutomat', + title: 'warehouseAutomat', + subtitle: 'Warehouse Locations & Stock', + entries: [ + { type: 'STATE', name: 'warehouses: Warehouse[]', desc: 'Storage areas & bins' }, + { type: 'STATE', name: 'filteredWarehouses', desc: 'Filter search slice' }, + { type: 'ACTION', name: 'load() / reload()', desc: 'Deduplicated sync' }, + { type: 'ACTION', name: 'setFilter(query)', desc: 'Local filter query' }, + { type: 'API', name: 'GET /api/warehouse', desc: 'Warehouse listings' }, + { type: 'SSE', name: 'warehouse', desc: 'Real-time stock movement' }, + ], + }, + + // 8. Company & Finance + { + id: 'companyAutomat', + title: 'companyAutomat', + subtitle: 'Active Company Configuration', + entries: [ + { type: 'STATE', name: 'current: Company', desc: 'Tax rates & ISO language', port: 'p_comp_curr' }, + { type: 'ACTION', name: 'load() / reload()', desc: 'Fetch tenant settings' }, + { type: 'SELECT', name: 'selectCompanyTaxrate()', desc: 'Standard tax rate' }, + { type: 'SELECT', name: 'selectCompanyRootCategoryId()', desc: 'Root category pointer', port: 'p_comp_root' }, + { type: 'SELECT', name: 'selectCompanyName()', desc: 'Company legal name' }, + { type: 'API', name: 'GET /api/company/:id', desc: 'Legal & tax profile' }, + ], + }, + { + id: 'accountAutomat', + title: 'accountAutomat', + subtitle: 'createListAutomat (Accounts)', + entries: [ + { type: 'STATE', name: 'items: Account[]', desc: 'Bank & payment ledgers' }, + { type: 'ACTION', name: 'load() / reload()', desc: 'Payment accounts load' }, + { type: 'API', name: 'GET /api/account', desc: 'Chart of accounts' }, + ], + }, + { + id: 'numberAutomat', + title: 'numberAutomat', + subtitle: 'createListAutomat (Sequences)', + entries: [ + { type: 'STATE', name: 'items: NumberSeq[]', desc: 'Invoice/receipt counters' }, + { type: 'ACTION', name: 'load() / reload()', desc: 'Number series sync' }, + { type: 'API', name: 'GET /api/number', desc: 'Document counters' }, + ], + }, + + // 9. POS & Hardware + { + id: 'posterminalAutomat', + title: 'posterminalAutomat', + subtitle: 'POS Terminals & Gating', + entries: [ + { type: 'STATE', name: 'terminals: Posterminal[]', desc: 'Hardware terminals', port: 'p_pos_term' }, + { type: 'ACTION', name: 'load()', desc: 'Fetch POS registers' }, + { type: 'ACTION', name: 'create() / delete()', desc: 'Terminal registrations' }, + { type: 'ACTION', name: 'toggleCategory()', desc: 'Gated category filters' }, + { type: 'SELECT', name: 'selectPosterminals()', desc: 'Active registers' }, + { type: 'API', name: 'GET /api/posterminal', desc: 'Terminal device configs' }, + ], + }, + + // 10. AI Assistant & Workspace + { + id: 'chatAutomat', + title: 'chatAutomat', + subtitle: 'AI Assistant Drawer & SSE', + entries: [ + { type: 'STATE', name: 'isOpen: boolean', desc: 'Docked chat drawer toggle' }, + { type: 'STATE', name: 'conversations: []', desc: 'Conversation sessions' }, + { type: 'STATE', name: 'messages: ModelMessage[]', desc: 'Live message transcript', port: 'p_chat_msg' }, + { type: 'STATE', name: 'sseStatus: \'connected\'', desc: 'Token streaming stream', port: 'p_chat_sse' }, + { type: 'ACTION', name: 'sendMessage(text)', desc: 'Prompt model with tools' }, + { type: 'ACTION', name: 'answerAskUser()', desc: 'Interactive tool modal' }, + { type: 'SELECT', name: 'userEnv / routeTools', desc: 'Client navigation tool', port: 'p_chat_tools' }, + { type: 'API', name: 'POST /api/modelconversation', desc: 'AI conversation session' }, + { type: 'SSE', name: 'modelconversation/events', desc: 'Live token stream' }, + ], + }, + { + id: 'vfsAutomat', + title: 'vfsAutomat', + subtitle: 'Virtual Filesystem Explorer', + entries: [ + { type: 'STATE', name: 'files: VfsFile[]', desc: 'Company VFS workspace' }, + { type: 'STATE', name: 'selectedFileName', desc: 'Current active file' }, + { type: 'STATE', name: 'fileContent / isDirty', desc: 'Editor buffer & diff' }, + { type: 'ACTION', name: 'selectFile() / saveFile()', desc: 'Applet editor buffer' }, + { type: 'ACTION', name: 'uploadFiles() / deleteFile()', desc: 'File manipulations' }, + { type: 'API', name: 'GET /api/vfs', desc: 'VFS directory index' }, + { type: 'SSE', name: 'vfs', desc: 'VFS mutation signal' }, + ], + }, + + // 11. Audit & Virtual Grids + { + id: 'articleHistoryGridAutomat', + title: 'articleHistoryGridAutomat', + subtitle: 'createGridAutomat (Article)', + entries: [ + { type: 'STATE', name: 'rows: [] / totalRows', desc: 'Block size 50 virtual rows' }, + { type: 'ACTION', name: 'fetchRows(viewport)', desc: 'Infinite scroll query' }, + { type: 'ACTION', name: 'reload()', desc: 'Reload on company switch' }, + { type: 'API', name: 'GET /api/article/history', desc: 'Infinite audit window' }, + ], + }, + { + id: 'globalHistoryGridAutomat', + title: 'globalHistoryGridAutomat', + subtitle: 'createGridAutomat (Global)', + entries: [ + { type: 'STATE', name: 'rows: [] / totalRows', desc: 'Block size 50 virtual rows' }, + { type: 'ACTION', name: 'fetchRows(viewport)', desc: 'Infinite scroll query' }, + { type: 'ACTION', name: 'reload()', desc: 'Reload on company switch' }, + { type: 'API', name: 'GET /api/globalhistory', desc: 'Company global audit' }, + ], + }, + + // 12. UI, Settings & System + { + id: 'navigationAutomat', + title: 'navigationAutomat', + subtitle: 'Cross-Section Navigation Memory', + entries: [ + { type: 'STATE', name: 'lastDeliveryPath', desc: 'Active receipt subpath', port: 'p_nav_delpath' }, + { type: 'STATE', name: 'lastArticleSku', desc: 'Active SKU selection', port: 'p_nav_sku' }, + { type: 'STATE', name: 'lastCategorySlug / Path', desc: 'Active category tree node', port: 'p_nav_catslug' }, + { type: 'STATE', name: 'lastSearchTerm / Path', desc: 'Preserved search query' }, + { type: 'ACTION', name: 'setLastDeliveryPath()', desc: 'Restore on sidebar click' }, + { type: 'ACTION', name: 'setLastArticleSku()', desc: 'Restore SKU on section click' }, + { type: 'ACTION', name: 'clearNavigation()', desc: 'Reset on company switch' }, + ], + }, + { + id: 'userSettingsAutomat', + title: 'userSettingsAutomat', + subtitle: 'IndexedDB User Preferences', + entries: [ + { type: 'STATE', name: 'viewerPosition: \'top\'|\'right\'', desc: 'Editor docking location' }, + { type: 'STATE', name: 'chatDrawerWidth: 460', desc: 'AI drawer pixel width', port: 'p_usr_drawer' }, + { type: 'ACTION', name: 'setViewerPosition()', desc: 'Persist docking' }, + { type: 'ACTION', name: 'setChatDrawerWidth()', desc: 'Persist drawer width' }, + { type: 'HOOK', name: 'persist: true', desc: 'Survives page reloads' }, + ], + }, + { + id: 'helpAutomat', + title: 'helpAutomat', + subtitle: 'IndexedDB Contextual Help', + entries: [ + { type: 'STATE', name: 'enabled: boolean', desc: 'Interactive help badges' }, + { type: 'STATE', name: 'activeHelpId: string|null', desc: 'Active popper ID', port: 'p_help_active' }, + { type: 'ACTION', name: 'toggle() / setEnabled()', desc: 'Toggle badge mode' }, + { type: 'ACTION', name: 'openPopper() / closePopper()', desc: 'Single active popper' }, + { type: 'HOOK', name: 'persist: true', desc: 'Survives page reloads' }, + ], + }, + { + id: 'pwaAutomat', + title: 'pwaAutomat', + subtitle: 'Service Worker & Offline Lifecycle', + entries: [ + { type: 'STATE', name: 'needRefresh: boolean', desc: 'New SW release pending' }, + { type: 'STATE', name: 'offlineReady: boolean', desc: 'Offline assets cached' }, + { type: 'STATE', name: 'dialogOpen / updating', desc: 'Prompt modal state' }, + { type: 'ACTION', name: 'applyUpdate()', desc: 'Skip waiting & reload' }, + { type: 'ACTION', name: 'openDialog() / closeDialog()', desc: 'Modal controls' }, + ], + }, +]; + +/** + * High-fidelity relationships between Automats: + * - Lifecycle synchronization (Auth / Session) + * - Real-time SSE dispatch & invalidation + * - Hierarchical catalog derivation & indexing + * - Purchasing & inventory workflows + * - AI tools & virtual workspace + */ +const EDGE_DEFS = [ + // Session & Auth lifecycle + { from: 'authAutomat', to: 'sseAutomat', label: 'bind session & cid', port: 'p_auth_user' }, + { from: 'authAutomat', to: 'companyAutomat', label: 'load company profile', port: 'p_auth_switchCompany' }, + { from: 'authAutomat', to: 'categoryAutomat', label: 'sync tree on authed', port: 'p_auth_status' }, + { from: 'authAutomat', to: 'brandAutomat', label: 'sync brands on authed', port: 'p_auth_status' }, + { from: 'authAutomat', to: 'unitAutomat', label: 'sync units on authed', port: 'p_auth_status' }, + { from: 'authAutomat', to: 'attributeAutomat', label: 'sync attributes on authed', port: 'p_auth_status' }, + { from: 'authAutomat', to: 'propertyAutomat', label: 'sync properties on authed', port: 'p_auth_status' }, + { from: 'authAutomat', to: 'supplierAutomat', label: 'sync suppliers on authed', port: 'p_auth_status' }, + { from: 'authAutomat', to: 'warehouseAutomat', label: 'sync warehouses on authed', port: 'p_auth_status' }, + { from: 'authAutomat', to: 'posterminalAutomat', label: 'sync terminals on authed', port: 'p_auth_status' }, + { from: 'authAutomat', to: 'deliveryAutomat', label: 'sync deliveries on authed', port: 'p_auth_status' }, + { from: 'authAutomat', to: 'quoteAutomat', label: 'sync quotes on authed', port: 'p_auth_status' }, + { from: 'authAutomat', to: 'orderAutomat', label: 'sync orders on authed', port: 'p_auth_status' }, + { from: 'authAutomat', to: 'entityAutomat', label: 'sync entities on authed', port: 'p_auth_status' }, + { from: 'authAutomat', to: 'customergroupAutomat', label: 'sync customer groups', port: 'p_auth_status' }, + { from: 'authAutomat', to: 'numberAutomat', label: 'sync number series', port: 'p_auth_status' }, + { from: 'authAutomat', to: 'accountAutomat', label: 'sync payment accounts', port: 'p_auth_status' }, + { from: 'authAutomat', to: 'chatAutomat', label: 'auth chat & reload', port: 'p_auth_status' }, + { from: 'authAutomat', to: 'vfsAutomat', label: 'load company vfs', port: 'p_auth_status' }, + { from: 'authAutomat', to: 'articleHistoryGridAutomat', label: 'reload history on authed', port: 'p_auth_status' }, + { from: 'authAutomat', to: 'globalHistoryGridAutomat', label: 'reload audit on authed', port: 'p_auth_status' }, + + // Real-time SSE dispatch + { from: 'sseAutomat', to: 'categoryAutomat', label: '\'category\' sse trigger', port: 'p_sse_events' }, + { from: 'sseAutomat', to: 'categoryViewAutomat', label: 'invalidate views on sse', port: 'p_sse_events' }, + { from: 'sseAutomat', to: 'articleIndexAutomat', label: '\'articles\' sse trigger', port: 'p_sse_events' }, + { from: 'sseAutomat', to: 'artikelAutomat', label: '\'article-\' sse reload', port: 'p_sse_events' }, + { from: 'sseAutomat', to: 'articleDetailAutomat', label: 'invalidateArticle(sku)', port: 'p_sse_events' }, + { from: 'sseAutomat', to: 'warehouseAutomat', label: '\'warehouse\' sse trigger', port: 'p_sse_events' }, + { from: 'sseAutomat', to: 'supplierAutomat', label: '\'supplier\' sse trigger', port: 'p_sse_events' }, + { from: 'sseAutomat', to: 'brandAutomat', label: '\'brand\' sse trigger', port: 'p_sse_events' }, + { from: 'sseAutomat', to: 'attributeAutomat', label: '\'attributename\' sse', port: 'p_sse_events' }, + { from: 'sseAutomat', to: 'propertyAutomat', label: '\'propertyname\' sse', port: 'p_sse_events' }, + { from: 'sseAutomat', to: 'orderAutomat', label: '\'order\'/\'shipment\' sse', port: 'p_sse_events' }, + { from: 'sseAutomat', to: 'vfsAutomat', label: '\'vfs\' sse mutation', port: 'p_sse_events' }, + + // Catalog, Indexing & Search pipeline + { from: 'companyAutomat', to: 'categoryAutomat', label: 'rootCategoryId', port: 'p_comp_root' }, + { from: 'categoryAutomat', to: 'categoryViewAutomat', label: 'attachTree & warm slugs', port: 'p_cat_loadTree' }, + { from: 'categoryViewAutomat', to: 'articleIndexAutomat', label: 'derived union index', port: 'p_catview_collect' }, + { from: 'articleIndexAutomat', to: 'articleSearchAutomat', label: 'query index items', port: 'p_idx_items' }, + { from: 'categoryAutomat', to: 'articleSearchAutomat', label: 'categoryFacets', port: 'p_cat_categories' }, + { from: 'artikelAutomat', to: 'articleIndexAutomat', label: 'setArticleIndexName (optimistic)', port: 'p_art_setName' }, + { from: 'artikelAutomat', to: 'categoryAutomat', label: 'articleCount updates', port: 'p_art_set' }, + { from: 'articleDetailAutomat', to: 'artikelAutomat', label: 'watch SKU details', port: 'p_artdet_hist' }, + + // Purchasing & Deliveries + { from: 'deliveryEditorAutomat', to: 'supplierAutomat', label: 'match vendor catalog', port: 'p_deled_supplier' }, + { from: 'deliveryEditorAutomat', to: 'artikelAutomat', label: 'lookup SKU & pack units', port: 'p_deled_lines' }, + { from: 'deliveryEditorAutomat', to: 'deliveryAutomat', label: 'commit delivery receipt', port: 'p_deled_commit' }, + + // Sales & CRM + { from: 'quoteAutomat', to: 'orderAutomat', label: 'convert quote to order', port: 'p_quote_items' }, + { from: 'orderAutomat', to: 'entityAutomat', label: 'customer lookup', port: 'p_order_items' }, + { from: 'companyAutomat', to: 'customergroupAutomat', label: 'poscustomergroup', port: 'p_comp_curr' }, + + // POS & Hardware + { from: 'posterminalAutomat', to: 'categoryAutomat', label: 'filter enabled roots', port: 'p_pos_term' }, + { from: 'companyAutomat', to: 'posterminalAutomat', label: 'company context', port: 'p_comp_curr' }, + + // AI Assistant & Workspace + { from: 'chatAutomat', to: 'vfsAutomat', label: 'inspect / edit applets', port: 'p_chat_msg' }, + { from: 'chatAutomat', to: 'navigationAutomat', label: 'set_user_route tool', port: 'p_chat_tools' }, + { from: 'chatAutomat', to: 'sseAutomat', label: 'getSseClientId()', port: 'p_chat_sse' }, + + // UI Navigation & Preferences + { from: 'navigationAutomat', to: 'artikelAutomat', label: 'restore last SKU', port: 'p_nav_sku' }, + { from: 'navigationAutomat', to: 'categoryAutomat', label: 'restore last category', port: 'p_nav_catslug' }, + { from: 'navigationAutomat', to: 'deliveryEditorAutomat', label: 'restore draft path', port: 'p_nav_delpath' }, + { from: 'userSettingsAutomat', to: 'chatAutomat', label: 'chatDrawerWidth', port: 'p_usr_drawer' }, + { from: 'helpAutomat', to: 'navigationAutomat', label: 'route-contextual help', port: 'p_help_active' }, + + // Self-loops + { from: 'categoryAutomat', to: 'categoryAutomat', label: 'nested tree hierarchy', port: 'p_cat_categories' }, + { from: 'artikelAutomat', to: 'artikelAutomat', label: 'variants & parent SKU', port: 'p_art_set' }, + { from: 'chatAutomat', to: 'chatAutomat', label: 'stream delta appends', port: 'p_chat_msg' }, +]; + +function edgeKey(edge) { + return `${edge.from}|${edge.to}|${edge.label}`; +} + +function assignEdgeColors(edges) { + const colors = new Map(); + let next = 0; + for (const edge of edges) { + const key = edgeKey(edge); + if (!colors.has(key)) { + colors.set(key, EDGE_COLORS[next % EDGE_COLORS.length]); + next++; + } + } + return colors; +} + +/** + * Spanning ranking forest: only one primary outgoing edge per automat + * is used for rank constraints. Other edges get constraint=false to avoid + * vertical over-stretching. + */ +function selectRankingEdgeKeys(edges) { + const ranking = new Set(); + const bySource = new Map(); + + for (const edge of edges) { + if (edge.from === edge.to) continue; + if (!bySource.has(edge.from)) bySource.set(edge.from, []); + bySource.get(edge.from).push(edge); + } + + for (const group of bySource.values()) { + group.sort((a, b) => a.label.localeCompare(b.label)); + ranking.add(edgeKey(group[0])); + } + + return ranking; +} + +function buildLegendLabel() { + const badgeRow = (type, desc) => { + const c = BADGE_COLORS[type]; + return ( + `` + + `${type}` + + `${desc}` + ); + }; + const row = (swatch, text) => + `${swatch}` + + `${text}`; + + return ( + `<` + + `` + + badgeRow('STATE', 'Reactive store state property') + + badgeRow('ACTION', 'State mutation action') + + badgeRow('SELECT', 'Reactive PureComponent selector') + + badgeRow('API', 'Backend REST/Fetch endpoint') + + badgeRow('SSE', 'Inbound Server-Sent Event trigger') + + badgeRow('HOOK', 'Auth / persistence lifecycle hook') + + row( + `in#`, + 'Inbound arrival port on Automat header' + ) + + row( + `▪—▶`, + 'Reactive dataflow / upstream trigger' + ) + + `
` + + `Automat Graph Legend
>` + ); +} + +function renderAutomatNode(spec, inboundCount = 0) { + if (spec.id === 'Legend') { + return buildLegendLabel(); + } + + const titleFont = + `${escapeHtml(spec.title)}` + + `
${escapeHtml(spec.subtitle)}`; + + let headerInner; + if (inboundCount > 0) { + const portCells = []; + for (let i = 0; i < inboundCount; i++) { + portCells.push( + `` + ); + } + headerInner = + `` + + `${portCells.join('')}` + + `` + + `
${titleFont}
`; + } else { + headerInner = titleFont; + } + + const rows = [ + `${headerInner}`, + ]; + + for (const [i, entry] of spec.entries.entries()) { + const bColor = BADGE_COLORS[entry.type] ?? { fg: COLORS.text, bg: COLORS.rowBg }; + const rowBg = i % 2 === 0 ? COLORS.rowBg : COLORS.rowBgAlt; + const portAttr = entry.port ? ` PORT="${escapeHtml(entry.port)}"` : ''; + + rows.push( + `` + + `` + + `` + + `` + + `` + + `` + + `
` + + `${escapeHtml(entry.type)}` + + `${escapeHtml(entry.name)}` + + `${escapeHtml(truncate(entry.desc, 30))}
` + ); + } + + return ( + `<` + + rows.join('') + + `
>` + ); +} + +function assignEdgeAnchors(edges, positions) { + const headPort = new Map(); + const tailCompass = new Map(); + const inboundCounts = new Map(); + + const byTarget = new Map(); + const bySource = new Map(); + + for (const edge of edges) { + if (!byTarget.has(edge.to)) byTarget.set(edge.to, []); + byTarget.get(edge.to).push(edge); + + if (!bySource.has(edge.from)) bySource.set(edge.from, []); + bySource.get(edge.from).push(edge); + } + + for (const [target, group] of byTarget) { + group.sort((a, b) => { + const ax = positions?.get(a.from)?.x ?? 0; + const bx = positions?.get(b.from)?.x ?? 0; + if (ax !== bx) return ax - bx; + return a.label.localeCompare(b.label); + }); + inboundCounts.set(target, group.length); + group.forEach((edge, i) => { + headPort.set(edgeKey(edge), `in${i}`); + }); + } + + for (const group of bySource.values()) { + group.forEach((edge, i) => { + if (edge.from === edge.to) { + tailCompass.set(edgeKey(edge), i % 2 === 0 ? 'ne' : 'nw'); + return; + } + const from = positions?.get(edge.from); + const to = positions?.get(edge.to); + const fromX = from?.x ?? 0; + const toX = to?.x ?? fromX; + const goEast = toX >= fromX; + tailCompass.set(edgeKey(edge), goEast ? 'e' : 'w'); + }); + } + + return { headPort, tailCompass, inboundCounts }; +} + +function buildDot(specsMap, edges, positions, anchors) { + const edgeColors = assignEdgeColors(edges); + const rankingKeys = selectRankingEdgeKeys(edges); + const { headPort, tailCompass, inboundCounts } = + anchors ?? assignEdgeAnchors(edges, positions); + + const lines = [ + 'digraph automats {', + ' graph [rankdir=TB, splines=spline, overlap=false, bgcolor="#1e2430",', + ' nodesep=0.95, ranksep=1.0, pad=0.5, fontname="Helvetica",', + ' compound=true, mclimit=1.0];', + ' node [shape=plaintext, fontname="Helvetica"];', + ' edge [fontname="Helvetica", fontsize=9, penwidth=1.4, arrowsize=0.7];', + '', + ]; + + const clusteredAutomats = new Set(); + + for (const cluster of CLUSTERS) { + const presentInCluster = cluster.automats.filter((id) => specsMap.has(id)); + if (presentInCluster.length === 0) continue; + + lines.push(` subgraph cluster_${cluster.id} {`); + lines.push(` label="${escapeHtml(cluster.label)}"; labeljust="l"; fontsize=15;`); + lines.push(` style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f";`); + lines.push(` fontcolor="#e2e8f0"; margin=14;`); + + for (const autoId of presentInCluster) { + clusteredAutomats.add(autoId); + const spec = specsMap.get(autoId); + const inCount = inboundCounts.get(autoId) ?? 0; + const label = renderAutomatNode(spec, inCount); + lines.push(` ${autoId} [label=${label}];`); + } + lines.push(' }'); + } + + // Any unclustered automats + for (const [autoId, spec] of specsMap) { + if (clusteredAutomats.has(autoId)) continue; + const inCount = inboundCounts.get(autoId) ?? 0; + const label = renderAutomatNode(spec, inCount); + lines.push(` ${autoId} [label=${label}];`); + } + + lines.push(''); + + for (const edge of edges) { + const key = edgeKey(edge); + const color = edgeColors.get(key) ?? '#5aa7ff'; + const isRanking = rankingKeys.has(key); + const hp = headPort.get(key) ?? 'in0'; + const tc = tailCompass.get(key) ?? 'e'; + + const attrs = [ + `color="${color}"`, + `fontcolor="${color}"`, + `tooltip="${escapeHtml(`${edge.from} → ${edge.to} (${edge.label})`)}"`, + `label=" ${escapeHtml(edge.label)} "`, + ]; + + if (!isRanking && edge.from !== edge.to) { + attrs.push('constraint=false', 'weight=1'); + } else { + attrs.push('weight=3'); + } + + // Source port from row entry if available, else exit compass + const fromPort = edge.port ? `:${edge.port}:${tc}` : `:${tc}`; + const toPort = `:${hp}:n`; + + lines.push(` ${edge.from}${fromPort} -> ${edge.to}${toPort} [${attrs.join(', ')}];`); + } + + lines.push('}'); + return lines.join('\n'); +} + +function parsePlainPositions(plain) { + const positions = new Map(); + for (const line of plain.split('\n')) { + if (!line.startsWith('node ')) continue; + const match = line.match(/^node\s+(\S+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)/); + if (!match) continue; + positions.set(match[1], { + x: Number(match[2]), + y: Number(match[3]), + width: Number(match[4]), + height: Number(match[5]), + }); + } + return positions; +} + +function fixSelfLoopSvg(svg) { + return svg.replace( + /\s*(\w+)(?::\w+)?->\2(?::\w+)?<\/title>\s*<g id="(a_edge\d+)"><a ([^>]+)>\s*<path([^>]*) d="([^"]+)"\/>\s*<polygon([^>]*) points="([^"]+)"\/>\s*<\/a>\s*<\/g>\s*<\/g>/g, + (match, edgeId, _table, aId, aAttrs, pathAttrs, d, polyAttrs) => { + const pts = [...d.matchAll(/([-\d.]+)[,\s]+([-\d.]+)/g)].map((m) => [Number(m[1]), Number(m[2])]); + if (pts.length < 2) return match; + + const [x0, y0] = pts[0]; + const [x1, y1] = pts[pts.length - 1]; + const east = Math.max(x0, x1) + 36; + const north = Math.min(y0, y1) - 28; + + const tx = x1 - east; + const ty = y1 - north; + const len = Math.hypot(tx, ty) || 1; + const dx = tx / len; + const dy = ty / len; + const arrowLen = 9; + const arrowHalf = 3.5; + const baseX = x1 - dx * arrowLen; + const baseY = y1 - dy * arrowLen; + const px = -dy * arrowHalf; + const py = dx * arrowHalf; + + const fmt = (n) => (Math.round(n * 100) / 100).toString(); + const newDFmt = `M${fmt(x0)},${fmt(y0)}C${fmt(east)},${fmt(y0)} ${fmt(east)},${fmt(north)} ${fmt(baseX)},${fmt(baseY)}`; + const newPointsFmt = + `${fmt(x1)},${fmt(y1)} ` + + `${fmt(baseX + px)},${fmt(baseY + py)} ` + + `${fmt(baseX - px)},${fmt(baseY - py)} ` + + `${fmt(x1)},${fmt(y1)}`; + + return ( + `<g id="${edgeId}" class="edge">\n` + + `<title>${_table}->${_table}\n` + + `\n` + + `\n` + + `\n` + + `\n` + + `\n` + + `` + ); + } + ); +} + +console.log('Generating Dashfront Automat Graph…'); + +const specsMap = new Map(); +for (const spec of AUTOMAT_SPECS) { + specsMap.set(spec.id, spec); +} + +// Add Legend pseudo-automat +specsMap.set('Legend', { + id: 'Legend', + title: 'Legend', + subtitle: '', + entries: [], +}); + +const edges = EDGE_DEFS.filter((def) => specsMap.has(def.from) && specsMap.has(def.to)); + +console.log(`Structured ${specsMap.size - 1} Automats across ${CLUSTERS.length} domain clusters with ${edges.length} relationships`); + +const graphviz = await Graphviz.load(); + +// Pass 1: Initial layout to learn coordinates +console.log('Layout pass 1/2: computing node positions…'); +const initialDot = buildDot(specsMap, edges, null, null); +const plain = graphviz.layout(initialDot, 'plain', 'dot'); +const positions = parsePlainPositions(plain); + +// Pass 2: Anchor assignment using learned positions +console.log('Layout pass 2/2: assigning header arrival ports & exit directions…'); +const anchors = assignEdgeAnchors(edges, positions); +const finalDot = buildDot(specsMap, edges, positions, anchors); + +const rawSvg = graphviz.layout(finalDot, 'svg', 'dot'); +const fixedSvg = fixSelfLoopSvg(rawSvg); + +// Ensure background color is set on svg tag +const finalSvg = fixedSvg.replace( + /]*?)>/, + `` +); + +writeFileSync(dotOutputPath, finalDot, 'utf8'); +writeFileSync(outputPath, finalSvg, 'utf8'); + +const dim = finalSvg.match(/width="([\d.]+)pt" height="([\d.]+)pt"/); +console.log(`Written ${dotOutputPath}`); +console.log(`Written ${outputPath}`); +if (dim) { + console.log(`Layout ${dim[1]}pt × ${dim[2]}pt`); +} diff --git a/package.json b/package.json index d017bf1..8f4098a 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "preview:https": "VITE_HTTPS=1 vite preview", "rig:collect": "node test/collect.mjs", "rig:reference": "node test/capture.mjs --out=test/screenshots/reference", - "rig:compare": "node test/capture.mjs --out=test/screenshots/current && node test/compare.mjs --tolerance=0.001" + "rig:compare": "node test/capture.mjs --out=test/screenshots/current && node test/compare.mjs --tolerance=0.001", + "automat-graph": "node generate-automat-graph.js" }, "dependencies": { "@emotion/react": "^11.14.0", diff --git a/src/automats/index.js b/src/automats/index.js index 7d3b467..516afe8 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -77,6 +77,8 @@ export { isOrderDelivered, getNegativeSeekCutoff, formatNegativeSeek, + getChunkStats, + subscribeChunkStats, } from './orderAutomat'; export { default as entityAutomat } from './entityAutomat'; export { default as customergroupAutomat } from './customergroupAutomat'; diff --git a/src/automats/orderAutomat.js b/src/automats/orderAutomat.js index d944cbe..948f77d 100644 --- a/src/automats/orderAutomat.js +++ b/src/automats/orderAutomat.js @@ -74,13 +74,91 @@ let pendingChunkReject = null; const loadedChunks = new Set(); const inflightChunks = new Map(); +// Dev / Observability: Chunk event history and subscriber registry +const MAX_CHUNK_EVENTS = 120; +let nextEventId = 0; +const chunkEvents = []; +const chunkStatsListeners = new Set(); + +function recordChunkEvent(entry) { + const event = { + id: ++nextEventId, + time: Date.now(), + epoch: queryEpoch, + ...entry, + }; + chunkEvents.push(event); + if (chunkEvents.length > MAX_CHUNK_EVENTS) { + chunkEvents.shift(); + } + notifyChunkStats(); +} + +function notifyChunkStats() { + if (chunkStatsListeners.size === 0) return; + const stats = getChunkStats(); + for (const listener of chunkStatsListeners) { + try { + listener(stats); + } catch (e) { + console.error('[orderAutomat] chunkStatsListener error', e); + } + } +} + +export function getChunkStats() { + const total = automat?.state?.total ?? 0; + const chunkCount = total > 0 ? Math.ceil(total / CHUNK_SIZE) : (loadedChunks.size > 0 ? Math.max(...loadedChunks) + 1 : 0); + const items = automat?.state?.items; + let loadedItemsCount = 0; + if (Array.isArray(items)) { + for (let i = 0; i < items.length; i++) { + if (items[i] != null) loadedItemsCount++; + } + } + + return { + automatName: 'order', + chunkSize: CHUNK_SIZE, + total, + chunkCount, + loadedChunks: Array.from(loadedChunks).sort((a, b) => a - b), + inflightChunks: Array.from(inflightChunks.keys()).sort((a, b) => a - b), + pendingChunks: Array.from(pendingTargetChunks).sort((a, b) => a - b), + loadedItemsCount, + status: automat?.state?.status ?? 'idle', + queryEpoch, + filter: automat?.state?.filter ?? '', + onlyUnpaid: automat?.state?.onlyUnpaid ?? false, + onlyUndelivered: automat?.state?.onlyUndelivered ?? false, + negativeSeekMonths: automat?.state?.negativeSeekMonths ?? 0, + items, + events: [...chunkEvents], + }; +} + +export function subscribeChunkStats(listener) { + chunkStatsListeners.add(listener); + try { + listener(getChunkStats()); + } catch (e) { + console.error(e); + } + return () => { + chunkStatsListeners.delete(listener); + }; +} + // Clears pending debounced chunk requests function clearChunkDebounce() { if (chunkDebounceTimer) { clearTimeout(chunkDebounceTimer); chunkDebounceTimer = null; } - pendingTargetChunks.clear(); + if (pendingTargetChunks.size > 0) { + pendingTargetChunks.clear(); + notifyChunkStats(); + } pendingChunkPromise = null; pendingChunkResolve = null; pendingChunkReject = null; @@ -97,6 +175,12 @@ function _executeRequiredChunks(chunks, targetEpoch, prefetch = true) { return Promise.resolve(automat.state.items); } + recordChunkEvent({ + type: 'execute', + chunks: chunkList, + message: `Lade Chunks [${chunkList.join(', ')}] (Epoch ${targetEpoch})`, + }); + const promises = []; for (const c of chunkList) { promises.push(actions.loadChunk(c, targetEpoch)); @@ -112,6 +196,11 @@ function _executeRequiredChunks(chunks, targetEpoch, prefetch = true) { if (total > 0 && c * CHUNK_SIZE >= total) { break; } + recordChunkEvent({ + type: 'prefetch', + chunkIndex: c, + message: `Prefetch Chunk #${c}`, + }); void actions.loadChunk(c, targetEpoch); } } @@ -214,6 +303,12 @@ function applyFilterReload() { clearChunkDebounce(); loadedChunks.clear(); inflightChunks.clear(); + recordChunkEvent({ + type: 'filter_reload', + epoch: queryEpoch, + message: `Filter angewendet (Epoch ${queryEpoch})`, + }); + notifyChunkStats(); void actions.loadChunk(0, queryEpoch); } @@ -261,6 +356,11 @@ const actions = { clearChunkDebounce(); const chunks = new Set(); for (let c = start; c <= end; c++) chunks.add(c); + recordChunkEvent({ + type: 'require_immediate', + chunks: Array.from(chunks), + message: `Sofortige Anforderung: Chunks ${start}..${end}`, + }); return _executeRequiredChunks(chunks, queryEpoch, prefetch); } @@ -282,6 +382,12 @@ const actions = { for (let c = start; c <= end; c++) { pendingTargetChunks.add(c); } + recordChunkEvent({ + type: 'pending', + chunks: Array.from(pendingTargetChunks), + message: `Debounce vorgemerkt (${CHUNK_DEBOUNCE_MS}ms): Chunks [${Array.from(pendingTargetChunks).join(', ')}]`, + }); + notifyChunkStats(); if (chunkDebounceTimer) { clearTimeout(chunkDebounceTimer); @@ -300,6 +406,7 @@ const actions = { chunkDebounceTimer = null; const chunksToLoad = new Set(pendingTargetChunks); pendingTargetChunks.clear(); + notifyChunkStats(); const resolve = pendingChunkResolve; const reject = pendingChunkReject; @@ -307,6 +414,12 @@ const actions = { pendingChunkResolve = null; pendingChunkReject = null; + recordChunkEvent({ + type: 'debounce_fire', + chunks: Array.from(chunksToLoad), + message: `Debounce abgelaufen: Starte Chunks [${Array.from(chunksToLoad).join(', ')}]`, + }); + _executeRequiredChunks(chunksToLoad, captureEpoch, true) .then((res) => resolve?.(res)) .catch((err) => reject?.(err)); @@ -322,9 +435,19 @@ const actions = { return Promise.resolve(automat.state.items); } if (loadedChunks.has(index)) { + recordChunkEvent({ + type: 'hit', + chunkIndex: index, + message: `Cache-Hit: Chunk #${index} bereits im Speicher`, + }); return Promise.resolve(automat.state.items); } if (inflightChunks.has(index)) { + recordChunkEvent({ + type: 'inflight_hit', + chunkIndex: index, + message: `Inflight-Hit: Chunk #${index} lädt bereits`, + }); return inflightChunks.get(index); } @@ -334,6 +457,13 @@ const actions = { automat.setState({ status: 'loading', error: null }); } + const startTime = Date.now(); + recordChunkEvent({ + type: 'start', + chunkIndex: index, + message: `Netzwerk-Request: Lade Chunk #${index} (Offset ${offset}..${offset + limit - 1})`, + }); + const promise = fetchOrdersPage(offset, limit) .then(({ orders, total }) => { // Discard if active filter query changed while request was in flight @@ -359,6 +489,7 @@ const actions = { } } + const duration = Date.now() - startTime; loadedChunks.add(index); automat.setState({ @@ -368,11 +499,27 @@ const actions = { error: null, }); + recordChunkEvent({ + type: 'loaded', + chunkIndex: index, + count: orders.length, + duration, + message: `Chunk #${index} geladen (${orders.length} Aufträge in ${duration}ms)`, + }); + return items; }) .catch((err) => { if (targetEpoch === queryEpoch) { + const duration = Date.now() - startTime; console.error(`[orderAutomat] loadChunk(${index}) error:`, err); + recordChunkEvent({ + type: 'error', + chunkIndex: index, + duration, + error: err?.message || String(err), + message: `Fehler bei Chunk #${index}: ${err?.message || err}`, + }); if (index === 0 && automat.state.items === null) { automat.setState({ status: 'failed', error: err }); } @@ -381,9 +528,11 @@ const actions = { }) .finally(() => { inflightChunks.delete(index); + notifyChunkStats(); }); inflightChunks.set(index, promise); + notifyChunkStats(); return promise; }, @@ -478,6 +627,11 @@ const actions = { loadedChunks.clear(); inflightChunks.clear(); const count = Array.isArray(items) ? items.length : 0; + recordChunkEvent({ + type: 'replace', + message: `Items ersetzt (${count} Einträge)`, + }); + notifyChunkStats(); automat.setState({ items, total: count, @@ -487,6 +641,63 @@ const actions = { return items; }, + // MARK: evicts a specific chunk from cache for testing + evictChunk(chunkIndex) { + const index = Math.max(0, Number(chunkIndex) || 0); + if (!loadedChunks.has(index)) return automat.state.items; + loadedChunks.delete(index); + let items = automat.state.items; + if (Array.isArray(items)) { + items = [...items]; + const offset = index * CHUNK_SIZE; + for (let i = offset; i < offset + CHUNK_SIZE && i < items.length; i++) { + items[i] = null; + } + automat.setState({ items }); + } + recordChunkEvent({ + type: 'evict', + chunkIndex: index, + message: `Chunk #${index} aus Cache entfernt (evicted)`, + }); + notifyChunkStats(); + return automat.state.items; + }, + + // MARK: clears all loaded chunks from cache + clearCache() { + clearChunkDebounce(); + loadedChunks.clear(); + inflightChunks.clear(); + let items = automat.state.items; + if (Array.isArray(items)) { + items = new Array(automat.state.total || 0).fill(null); + automat.setState({ items }); + } + recordChunkEvent({ + type: 'clear', + message: 'Alle Chunks aus dem Cache entfernt', + }); + notifyChunkStats(); + return automat.state.items; + }, + + // MARK: prefetches all remaining chunks + prefetchAllChunks() { + const total = automat.state.total || 0; + const chunkCount = Math.ceil(total / CHUNK_SIZE); + recordChunkEvent({ + type: 'prefetch_all', + message: `Starte Prefetch aller ${chunkCount} Chunks`, + }); + for (let c = 0; c < chunkCount; c++) { + if (!loadedChunks.has(c) && !inflightChunks.has(c)) { + void actions.loadChunk(c, queryEpoch); + } + } + notifyChunkStats(); + }, + // MARK: patches an order locally in the automat items cache patchOrder(id, patch) { if (!Array.isArray(automat.state.items)) return automat.state.items; @@ -514,6 +725,11 @@ const actions = { clearChunkDebounce(); loadedChunks.clear(); inflightChunks.clear(); + recordChunkEvent({ + type: 'reset', + message: 'Order-Automat zurückgesetzt', + }); + notifyChunkStats(); automat.setState(initialState); }, }; @@ -533,6 +749,11 @@ export function requireChunk(chunkIndex, endOrOptions, maybeOptions) { orderAutomat.getItems = getItems; orderAutomat.requireChunk = requireChunk; orderAutomat.CHUNK_SIZE = CHUNK_SIZE; +orderAutomat.getChunkStats = getChunkStats; +orderAutomat.subscribeChunkStats = subscribeChunkStats; +orderAutomat.evictChunk = actions.evictChunk; +orderAutomat.clearCache = actions.clearCache; +orderAutomat.prefetchAllChunks = actions.prefetchAllChunks; // Reset on logout or company change let lastCompanyId = null; diff --git a/src/components/manifest/OrderList.jsx b/src/components/manifest/OrderList.jsx index 234de0c..272e35e 100644 --- a/src/components/manifest/OrderList.jsx +++ b/src/components/manifest/OrderList.jsx @@ -11,7 +11,9 @@ import Typography from '@mui/material/Typography'; import Checkbox from '@mui/material/Checkbox'; import Switch from '@mui/material/Switch'; import Skeleton from '@mui/material/Skeleton'; +import Tooltip from '@mui/material/Tooltip'; import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; +import ViewModuleIcon from '@mui/icons-material/ViewModule'; import { enqueueSnackbar } from 'notistack'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import FillSpace from 'dashgui/FillSpace'; @@ -423,6 +425,26 @@ class OrderSearchField extends PureComponent { Reset + + {import.meta.env.DEV && ( + + } + label="Chunk-Cache" + onClick={() => window.dispatchEvent(new CustomEvent('dash:toggle-chunk-monitor'))} + sx={{ + cursor: 'pointer', + height: 28, + fontWeight: 600, + fontSize: '0.75rem', + bgcolor: 'background.paper', + boxShadow: 1, + '&:hover': { bgcolor: 'action.hover' }, + }} + /> + + )} diff --git a/src/devtools/ChunkCacheMonitor.jsx b/src/devtools/ChunkCacheMonitor.jsx new file mode 100644 index 0000000..000f9c2 --- /dev/null +++ b/src/devtools/ChunkCacheMonitor.jsx @@ -0,0 +1,822 @@ +import { PureComponent, createRef } from 'react'; +import { keyframes } from '@emotion/react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import CircularProgress from '@mui/material/CircularProgress'; +import Divider from '@mui/material/Divider'; +import Fab from '@mui/material/Fab'; +import IconButton from '@mui/material/IconButton'; +import LinearProgress from '@mui/material/LinearProgress'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Tab from '@mui/material/Tab'; +import Tabs from '@mui/material/Tabs'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; +import Badge from '@mui/material/Badge'; +import ViewModuleIcon from '@mui/icons-material/ViewModule'; +import CloseIcon from '@mui/icons-material/Close'; +import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'; +import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined'; +import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'; +import CloudDownloadIcon from '@mui/icons-material/CloudDownload'; +import LayersClearIcon from '@mui/icons-material/LayersClear'; +import PlaylistAddCheckIcon from '@mui/icons-material/PlaylistAddCheck'; +import HistoryIcon from '@mui/icons-material/History'; +import GridViewIcon from '@mui/icons-material/GridView'; +import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; + +import orderAutomat, { + CHUNK_SIZE, + isOrderPaid, + isOrderDelivered, + subscribeChunkStats, +} from '../automats/orderAutomat'; +import { formatPrice } from '../utils/price'; + +const PANEL_WIDTH = 620; +const PANEL_HEIGHT = 480; + +const pulseAnimationA = keyframes` + 0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(25, 118, 210, 0); } + 35% { transform: scale(1.15); box-shadow: 0 0 0 8px rgba(25, 118, 210, 0.45); } + 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(25, 118, 210, 0); } +`; +const pulseAnimationB = keyframes` + 0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(25, 118, 210, 0); } + 35% { transform: scale(1.15); box-shadow: 0 0 0 8px rgba(25, 118, 210, 0.45); } + 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(25, 118, 210, 0); } +`; + +const inflightGlow = keyframes` + 0% { border-color: rgba(2, 136, 209, 0.4); box-shadow: 0 0 4px rgba(2, 136, 209, 0.2); } + 50% { border-color: rgba(2, 136, 209, 1); box-shadow: 0 0 10px rgba(2, 136, 209, 0.6); } + 100% { border-color: rgba(2, 136, 209, 0.4); box-shadow: 0 0 4px rgba(2, 136, 209, 0.2); } +`; + +function orderGrossTotal(lines) { + if (!Array.isArray(lines)) return 0; + return lines.reduce((acc, line) => { + const net = (Number(line.amount) || 0) * (Number(line.net) || 0) * (1 - (Number(line.rebate) || 0) / 100); + return acc + net * (1 + (Number(line.taxrate) || 0) / 100); + }, 0); +} + +class ChunkCacheMonitor extends PureComponent { + constructor(props) { + super(props); + this.state = { + open: false, + position: null, + tab: 'grid', // 'grid' | 'log' + selectedChunk: null, + pulseCounter: 0, + stats: orderAutomat.getChunkStats?.() || { + chunkSize: CHUNK_SIZE, + total: 0, + chunkCount: 0, + loadedChunks: [], + inflightChunks: [], + pendingChunks: [], + loadedItemsCount: 0, + status: 'idle', + queryEpoch: 0, + filter: '', + onlyUnpaid: false, + onlyUndelivered: false, + negativeSeekMonths: 0, + items: null, + events: [], + }, + }; + + this.panelRef = createRef(); + this.handleToggle = this.handleToggle.bind(this); + this.handleClose = this.handleClose.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + this.handlePointerDown = this.handlePointerDown.bind(this); + this.handlePointerMove = this.handlePointerMove.bind(this); + this.handlePointerUp = this.handlePointerUp.bind(this); + this.handleSelectChunk = this.handleSelectChunk.bind(this); + this.handleLoadChunk = this.handleLoadChunk.bind(this); + this.handleEvictChunk = this.handleEvictChunk.bind(this); + this.handleClearCache = this.handleClearCache.bind(this); + this.handlePrefetchAll = this.handlePrefetchAll.bind(this); + this.handleReload = this.handleReload.bind(this); + } + + componentDidMount() { + this.unsubscribeStats = subscribeChunkStats((stats) => { + this.setState((prev) => ({ + stats, + pulseCounter: prev.pulseCounter + 1, + })); + }); + + window.addEventListener('keydown', this.handleKeyDown); + this.customEventListener = () => this.handleToggle(); + window.addEventListener('dash:toggle-chunk-monitor', this.customEventListener); + } + + componentWillUnmount() { + this.unsubscribeStats?.(); + window.removeEventListener('keydown', this.handleKeyDown); + if (this.customEventListener) { + window.removeEventListener('dash:toggle-chunk-monitor', this.customEventListener); + } + } + + handleKeyDown(event) { + if (event.altKey && event.shiftKey && event.key?.toLowerCase() === 'c') { + event.preventDefault(); + this.handleToggle(); + } else if (event.key === 'Escape' && this.state.open) { + if (this.state.selectedChunk !== null) { + this.setState({ selectedChunk: null }); + } else { + this.handleClose(); + } + } + } + + handleToggle() { + this.setState((state) => ({ open: !state.open })); + } + + handleClose() { + this.setState({ open: false, selectedChunk: null }); + } + + handlePointerDown(event) { + if (event.target.closest('button') || event.target.closest('.MuiTab-root')) return; + const rect = this.panelRef.current?.getBoundingClientRect(); + if (!rect) return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + this.drag = { + pointerId: event.pointerId, + pointerX: event.clientX, + pointerY: event.clientY, + left: rect.left, + top: rect.top, + }; + } + + handlePointerMove(event) { + if (!this.drag || event.pointerId !== this.drag.pointerId) return; + const maxLeft = Math.max(0, window.innerWidth - PANEL_WIDTH); + const maxTop = Math.max(0, window.innerHeight - 120); + this.setState({ + position: { + left: Math.max(0, Math.min(maxLeft, this.drag.left + event.clientX - this.drag.pointerX)), + top: Math.max(0, Math.min(maxTop, this.drag.top + event.clientY - this.drag.pointerY)), + }, + }); + } + + handlePointerUp(event) { + if (this.drag?.pointerId === event.pointerId) this.drag = null; + } + + handleSelectChunk(chunkIndex) { + this.setState((prev) => ({ + selectedChunk: prev.selectedChunk === chunkIndex ? null : chunkIndex, + })); + } + + handleLoadChunk(chunkIndex, e) { + e?.stopPropagation(); + void orderAutomat.actions.requireChunk(chunkIndex, chunkIndex, { immediate: true }); + } + + handleEvictChunk(chunkIndex, e) { + e?.stopPropagation(); + orderAutomat.evictChunk?.(chunkIndex); + if (this.state.selectedChunk === chunkIndex) { + this.setState({ selectedChunk: null }); + } + } + + handleClearCache() { + orderAutomat.clearCache?.(); + this.setState({ selectedChunk: null }); + } + + handlePrefetchAll() { + orderAutomat.prefetchAllChunks?.(); + } + + handleReload() { + void orderAutomat.actions.reload(); + this.setState({ selectedChunk: null }); + } + + renderChunkGrid() { + const { stats, selectedChunk } = this.state; + const { total, chunkCount, loadedChunks, inflightChunks, pendingChunks, items } = stats; + const loadedSet = new Set(loadedChunks); + const inflightSet = new Set(inflightChunks); + const pendingSet = new Set(pendingChunks); + + // If total is 0 and no chunks loaded, show uninitialized state + if (chunkCount === 0 && (!items || items.length === 0)) { + return ( + + + Automat ist noch nicht initialisiert (keine Daten geladen). + + + + ); + } + + const blocks = []; + const count = Math.max(chunkCount, 1); + + for (let c = 0; c < count; c++) { + const isLoaded = loadedSet.has(c); + const isInflight = inflightSet.has(c); + const isPending = pendingSet.has(c); + const isSelected = selectedChunk === c; + + const offsetStart = c * CHUNK_SIZE; + const offsetEnd = Math.min((c + 1) * CHUNK_SIZE - 1, Math.max(0, total - 1)); + + // Calculate loaded items inside this chunk + let itemsInChunk = 0; + if (Array.isArray(items)) { + for (let i = offsetStart; i <= offsetEnd; i++) { + if (items[i] != null) itemsInChunk++; + } + } + const expectedInChunk = Math.max(0, offsetEnd - offsetStart + 1); + + let statusColor = 'text.secondary'; + let borderStyle = '1px solid'; + let borderColor = 'divider'; + let bgColor = 'background.paper'; + let statusLabel = 'Nicht geladen'; + + if (isLoaded) { + statusLabel = `Geladen (${itemsInChunk}/${expectedInChunk})`; + statusColor = 'success.main'; + borderColor = 'success.main'; + bgColor = 'rgba(46, 125, 50, 0.08)'; + } else if (isInflight) { + statusLabel = 'Lädt über Netzwerk...'; + statusColor = 'info.main'; + borderColor = 'info.main'; + bgColor = 'rgba(2, 136, 209, 0.08)'; + } else if (isPending) { + statusLabel = 'Vorgemerkt (Debounce)'; + statusColor = 'warning.main'; + borderStyle = '1px dashed'; + borderColor = 'warning.main'; + bgColor = 'rgba(237, 108, 2, 0.08)'; + } + + blocks.push( + (isLoaded ? this.handleSelectChunk(c) : this.handleLoadChunk(c))} + sx={{ + position: 'relative', + p: 1, + borderRadius: 1.5, + border: borderStyle, + borderColor: isSelected ? 'primary.main' : borderColor, + boxShadow: isSelected ? '0 0 0 2px rgba(25, 118, 210, 0.4)' : 1, + bgcolor: isSelected ? 'rgba(25, 118, 210, 0.12)' : bgColor, + cursor: 'pointer', + transition: 'all 0.15s ease-in-out', + animation: isInflight ? `${inflightGlow} 1.5s infinite` : 'none', + '&:hover': { + borderColor: 'primary.main', + transform: 'translateY(-1px)', + boxShadow: 2, + }, + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + minHeight: 74, + }} + > + {/* Top Row: Chunk Number & Status Indicator */} + + + #{c} + + + {isLoaded ? ( + + + + ) : isInflight ? ( + + + + ) : isPending ? ( + + + + ) : ( + + + + )} + + + {/* Middle: Item range index */} + + {offsetStart}–{offsetEnd} + + + {/* Bottom row: Item count / load action */} + + + {isLoaded ? `${itemsInChunk} Aufträge` : isInflight ? 'Laden…' : isPending ? 'Wartet…' : 'Klick: Laden'} + + + {isLoaded && ( + + this.handleEvictChunk(c, e)} + sx={{ p: 0.25, ml: 0.5 }} + > + + + + )} + + , + ); + } + + return ( + + {blocks} + + ); + } + + renderChunkDetail() { + const { stats, selectedChunk } = this.state; + if (selectedChunk === null) return null; + + const { items, total } = stats; + const offsetStart = selectedChunk * CHUNK_SIZE; + const offsetEnd = Math.min((selectedChunk + 1) * CHUNK_SIZE - 1, Math.max(0, total - 1)); + + const chunkOrders = []; + if (Array.isArray(items)) { + for (let i = offsetStart; i <= offsetEnd; i++) { + if (items[i] != null) { + chunkOrders.push({ item: items[i], index: i }); + } + } + } + + return ( + + + + this.setState({ selectedChunk: null })}> + + + + Chunk #{selectedChunk} Inspection + + + + + + + + + + + + {chunkOrders.length === 0 ? ( + + Keine Aufträge in diesem Chunk geladen. + + ) : ( + + {chunkOrders.map(({ item: order, index }) => { + const paid = isOrderPaid(order); + const delivered = isOrderDelivered(order); + const gross = orderGrossTotal(order.lines); + + return ( + + + #{index} + + + {order.number || `ID ${order.id}`} + + + {order.date ? new Date(order.date).toLocaleDateString() : '–'} + + + Kunde #{order.entityId || '–'} + + + + + {gross > 0 ? formatPrice(gross) : '–'} + + + ); + })} + + )} + + + ); + } + + renderEventLog() { + const { stats } = this.state; + const events = stats.events || []; + + if (events.length === 0) { + return ( + + + Noch keine Chunk-Ereignisse protokolliert. + + + ); + } + + return ( + + + {[...events].reverse().map((entry) => { + const timeStr = new Date(entry.time).toLocaleTimeString(); + let chipColor = 'default'; + if (entry.type === 'loaded' || entry.type === 'hit') chipColor = 'success'; + if (entry.type === 'start' || entry.type === 'prefetch') chipColor = 'info'; + if (entry.type === 'pending' || entry.type === 'debounce_fire') chipColor = 'warning'; + if (entry.type === 'error') chipColor = 'error'; + + return ( + + + {timeStr} + + + + {entry.message} + + + ); + })} + + + ); + } + + render() { + const { open, position, tab, stats, pulseCounter } = this.state; + const { total, chunkCount, loadedChunks, inflightChunks, pendingChunks, loadedItemsCount, status } = stats; + + const loadedCount = loadedChunks.length; + const expectedChunks = Math.max(chunkCount, 1); + const progressPercent = expectedChunks > 0 ? Math.min(100, Math.round((loadedCount / expectedChunks) * 100)) : 0; + const hasInflight = inflightChunks.length > 0; + + return ( + <> + {!open ? ( + + theme.zIndex.tooltip, + }} + > + + + + + + ) : ( + theme.zIndex.tooltip, + border: '1px solid', + borderColor: 'divider', + borderRadius: 2, + }} + > + {/* Header / Drag Bar */} + + + + + Chunk-Cache-Monitor + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Metrics & Progress Ribbon */} + + + + Cache-Füllstand: {progressPercent}% ({loadedCount} von {expectedChunks} Chunks) + + + {loadedItemsCount} von {total} Aufträgen im RAM · Chunk-Größe {CHUNK_SIZE} + + + + + + + {hasInflight && ( + } + label={`${inflightChunks.length} lädt`} + sx={{ height: 20, fontSize: '0.7rem' }} + /> + )} + {pendingChunks.length > 0 && ( + + )} + {stats.filter && ( + + )} + {stats.onlyUnpaid && ( + + )} + {stats.onlyUndelivered && ( + + )} + {stats.negativeSeekMonths !== 0 && ( + + )} + + + + {/* View Tabs */} + + this.setState({ tab: val })} + sx={{ minHeight: 36, '& .MuiTab-root': { minHeight: 36, py: 0.5, fontSize: '0.75rem' } }} + > + } iconPosition="start" label="Chunk-Map" value="grid" /> + } + iconPosition="start" + label={`Ereignis-Log (${stats.events?.length || 0})`} + value="log" + /> + + + + {/* Body */} + + {tab === 'grid' ? this.renderChunkGrid() : this.renderEventLog()} + {tab === 'grid' && this.renderChunkDetail()} + + + )} + + ); + } +} + +export default ChunkCacheMonitor; diff --git a/src/main.jsx b/src/main.jsx index 01ca9b2..55e2824 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -27,6 +27,9 @@ const AutomatInspector = import.meta.env.DEV const SseMonitor = import.meta.env.DEV ? lazy(() => import('./devtools/SseMonitor')) : null; +const ChunkCacheMonitor = import.meta.env.DEV + ? lazy(() => import('./devtools/ChunkCacheMonitor')) + : null; async function clearDevServiceWorker() { if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return; @@ -71,6 +74,11 @@ createRoot(document.getElementById('root')).render( ) : null} + {ChunkCacheMonitor ? ( + + + + ) : null}