Build the prototype: iPhone-distributor back-office — stock, forecasts, dashboard, one app with multiple tabs #1

Open
opened 2026-07-26 15:27:55 +00:00 by mAi · 1 comment
Collaborator

m, 2026-07-26 (PWA voice): "bau mal einen Prototyp für eine kleine App. Wir tun so, als wären wir ein Handy-Distributor fürs iPhone und wir möchten ein paar Funktionen einbauen. Kleine Datenbank im Hintergrund, du kannst bei uns Supabase nehmen und dann ein Webfrontend. Wir wollen Forecasts und aktuelle Bestände und insbesondere ein Dashboard. Alles in einer App, multiple Tabs."

This is a prototype. Demonstrable beats complete. It is a fictional business used to exercise the stack — there is no real distributor, no real data, and no user waiting on correctness. Build something m can click through.

Read this first — the stack is already decided

~/dev/web/docs/pwa-baseline.md (506 lines, repo m/mWeb) is the canonical baseline and records m's resolved choices. Do not re-litigate them and do not invent a stack:

  • Svelte 5 + SvelteKit 2 + Bun + adapter-node + Supabase
  • Datastore: own Postgres schema on the shared msupabase (not mBrian)
  • Migrations: in-repo db/NNN_*.sql, applied at server startup
  • Routes: /api/<resource>
  • Theming: light + dark with toggle ([data-theme="dark"]), full design-system tokens in the variables.css shape
  • Layout: Sidebar (desktop) + BottomNav (mobile) + CMD+K palette + slide-up sheet
  • localStorage keys: mdistri-<key>
  • No Web Push (that stays otto-only)

"Multiple tabs" = the sidebar/bottom-nav pattern from the baseline. That mechanism already exists in the design system; use it rather than building a tab bar.

Also worth reading before starting, for the shape of a comparable app in this repo family: ~/dev/web/fdbck and ~/dev/web/postcards.

Scope — three tabs

Bestände (current stock) — the inventory view. Models × storage × colour × condition, quantity on hand, warehouse location, purchase price, sale price. Filterable and sortable. This is the table the rest of the app hangs off.

Forecasts — projected demand and stock-out risk per SKU over a horizon (weeks). A prototype forecast is fine and should be visibly simple: a documented naive method (moving average over historical sales, plus a seasonality nudge) with the method named in the UI. Do not present a made-up number as if a model produced it — if it is a moving average, the UI says so.

Dashboard — m called this out specifically ("insbesondere ein Dashboard"), so it is the centrepiece, not an afterthought. Total stock value, units on hand, stock-out risks, top movers, incoming vs outgoing, a trend chart or two. It should be the first thing that opens and it should look good.

Seed data

Generate plausible fake data — real iPhone model names and storage tiers, sensible EUR price points, several months of synthetic sales history so the forecasts have something to chew on. Enough rows that the dashboard looks alive (hundreds, not five). Ship it as a seed migration so the prototype is reproducible from an empty schema.

Infrastructure — reuse the documented path

  • Schema: mdistri on the shared msupabase. Connect as postgres via the Supavisor pooler (100.99.98.203:6789), not as supabase_admin — a schema created by the wrong role causes a permission-denied crash-loop at container start. That trap is recorded and has cost a debugging session before.
  • Deploy: own Dokploy application on mlake, same chain as postcards (application.createsaveGiteaProvidersaveBuildType dockerfile → saveEnvironmentdomain.createapplication.deploy). The /mai-dokploy skill has the endpoint reference.
  • Preview first: mdistri.prev.msbls.de on the existing wildcard, so m can look at it before anything else is decided. No domain purchase, no money.

Ask, don't guess

If a product decision genuinely changes the build — how much of the distributor fiction to model (single warehouse or several? B2B customers or none?), what the forecast horizon should be — use AskUserQuestion, one round, several questions together. m prefers one form over a chain of small questions. But do not block on cosmetics; pick sensible defaults and say what you picked.

Acceptance

  • mdistri.prev.msbls.de loads, three tabs work, light and dark both look intentional.
  • The dashboard opens first and reads as a dashboard, not a table with a headline.
  • Stock and forecast views are driven by real rows in the mdistri schema, not hardcoded fixtures in the frontend.
  • The forecast method is stated in the UI.
  • A fresh clone plus migrations reproduces the whole thing from empty.
m, 2026-07-26 (PWA voice): *"bau mal einen Prototyp für eine kleine App. Wir tun so, als wären wir ein Handy-Distributor fürs iPhone und wir möchten ein paar Funktionen einbauen. Kleine Datenbank im Hintergrund, du kannst bei uns Supabase nehmen und dann ein Webfrontend. Wir wollen Forecasts und aktuelle Bestände und insbesondere ein Dashboard. Alles in einer App, multiple Tabs."* **This is a prototype.** Demonstrable beats complete. It is a fictional business used to exercise the stack — there is no real distributor, no real data, and no user waiting on correctness. Build something m can click through. ## Read this first — the stack is already decided **`~/dev/web/docs/pwa-baseline.md`** (506 lines, repo `m/mWeb`) is the canonical baseline and records m's resolved choices. Do not re-litigate them and do not invent a stack: - **Svelte 5 + SvelteKit 2 + Bun + adapter-node + Supabase** - Datastore: **own Postgres schema** on the shared msupabase (not mBrian) - Migrations: in-repo `db/NNN_*.sql`, applied at server startup - Routes: `/api/<resource>` - Theming: light + dark with toggle (`[data-theme="dark"]`), full design-system tokens in the `variables.css` shape - Layout: **Sidebar (desktop) + BottomNav (mobile)** + CMD+K palette + slide-up sheet - localStorage keys: `mdistri-<key>` - No Web Push (that stays otto-only) **"Multiple tabs" = the sidebar/bottom-nav pattern from the baseline.** That mechanism already exists in the design system; use it rather than building a tab bar. Also worth reading before starting, for the shape of a comparable app in this repo family: `~/dev/web/fdbck` and `~/dev/web/postcards`. ## Scope — three tabs **Bestände (current stock)** — the inventory view. Models × storage × colour × condition, quantity on hand, warehouse location, purchase price, sale price. Filterable and sortable. This is the table the rest of the app hangs off. **Forecasts** — projected demand and stock-out risk per SKU over a horizon (weeks). A prototype forecast is fine and should be **visibly** simple: a documented naive method (moving average over historical sales, plus a seasonality nudge) with the method named in the UI. **Do not present a made-up number as if a model produced it** — if it is a moving average, the UI says so. **Dashboard** — m called this out specifically (*"insbesondere ein Dashboard"*), so it is the centrepiece, not an afterthought. Total stock value, units on hand, stock-out risks, top movers, incoming vs outgoing, a trend chart or two. It should be the first thing that opens and it should look good. ## Seed data Generate plausible fake data — real iPhone model names and storage tiers, sensible EUR price points, several months of synthetic sales history so the forecasts have something to chew on. Enough rows that the dashboard looks alive (hundreds, not five). Ship it as a seed migration so the prototype is reproducible from an empty schema. ## Infrastructure — reuse the documented path - **Schema:** `mdistri` on the shared msupabase. Connect as **`postgres`** via the Supavisor pooler (`100.99.98.203:6789`), *not* as `supabase_admin` — a schema created by the wrong role causes a permission-denied crash-loop at container start. That trap is recorded and has cost a debugging session before. - **Deploy:** own Dokploy application on mlake, same chain as `postcards` (`application.create` → `saveGiteaProvider` → `saveBuildType` dockerfile → `saveEnvironment` → `domain.create` → `application.deploy`). The `/mai-dokploy` skill has the endpoint reference. - **Preview first:** `mdistri.prev.msbls.de` on the existing wildcard, so m can look at it before anything else is decided. **No domain purchase, no money.** ## Ask, don't guess If a product decision genuinely changes the build — how much of the distributor fiction to model (single warehouse or several? B2B customers or none?), what the forecast horizon should be — use `AskUserQuestion`, one round, several questions together. m prefers one form over a chain of small questions. But do not block on cosmetics; pick sensible defaults and say what you picked. ## Acceptance - `mdistri.prev.msbls.de` loads, three tabs work, light and dark both look intentional. - The dashboard opens first and reads as a dashboard, not a table with a headline. - Stock and forecast views are driven by real rows in the `mdistri` schema, not hardcoded fixtures in the frontend. - The forecast method is stated in the UI. - A fresh clone plus migrations reproduces the whole thing from empty.
Author
Collaborator

Prototyp steht — Preview: https://mdistri.prev.msbls.de

Drei Tabs auf dem pwa-baseline-Stack (Svelte 5 + SvelteKit 2 + Bun + adapter-node), eigenes mdistri-Schema auf msupabase. Branch mai/knuth/issue-1-iphone, nicht nach main gemerged — das macht der Head.

Commits

  • 9cfca51 — feat: iPhone-distributor back-office prototype
  • aaddcd2 — fix: Service-Worker + Deploy-Notizen

Was drin ist

Dashboard (öffnet zuerst): Hero-Zahl Umsatz letzte 4 Wochen mit Delta, KPI-Kacheln (Lagerwert, offene Aufträge, Stock-out-Risiko, erwarteter Wareneingang), Absatzchart mit 26 Wochen Ist und 8 Wochen Prognose auf derselben Achse, Top-Movers, Stock-out-Risiken, Bestand nach Standort, Umsatz nach Modellreihe, Top-Kunden, Wareneingang.

Bestände: 88 SKUs über 3 Standorte, filter- und sortierbar (Modellreihe, Zustand, Standort, nur Risiko-SKUs), Meldebestand-Markierung, Reichweite je SKU.

Forecasts: Prognose je SKU über 8 Wochen, Detailchart + Wochentabelle mit dem Saisonindex je Woche, Saisonindex-Panel, Sparklines in der Liste.

Dazu Sidebar/BottomNav nach Baseline, Light+Dark mit Toggle, CMD+K-Palette (Seiten + SKUs), PWA-Shell.

Zu den Forecast-Zahlen

Das Verfahren steht wörtlich im UI, bevor die erste Zahl kommt, inklusive Grenzen — nichts wird als Modellergebnis ausgegeben:

  1. Saisonindex je Kalendermonat, aus der eigenen Historie: mittlere Wochenmenge des Monats über den Katalog / mittlere Wochenmenge insgesamt, begrenzt auf 0,5–2,0. Monate mit unter zwei beobachteten Wochen bekommen keinen eigenen Index und werden zwischen den Nachbarmonaten interpoliert (im UI als „interpoliert" markiert).
  2. Basiswert je SKU: Mittel der letzten 8 abgeschlossenen Wochen, jede Woche vorher durch den Index ihres Monats geteilt.
  3. Prognose = Basiswert × Index des Zielmonats.

Schritt 2 ist der Punkt, an dem ich zweimal nachgebessert habe. Ohne die Saisonbereinigung zählt das Verfahren die Saison doppelt (das Fenster liegt schon im schwachen Monat) und die Kurve fällt an der Grenze Ist→Prognose um die Hälfte ab. Und ein Monat mit nur einer beobachteten Woche — hier der September mit der iPhone-17-Launchwoche — erzeugte einen Index am oberen Anschlag und damit einen Sprung, der wie ein Fehler aussah. Beides ist in src/lib/forecast.ts gelöst und mit Tests festgenagelt.

Genannte Grenzen: kein Trend, keine Aktionen, keine Lieferengpässe; der Index vermischt Saisonalität mit Produkt-Launches; bei unter einem Jahr Historie haben mehrere Monate keinen belastbaren Index.

Seed-Daten

Deterministisch (md5-abgeleitet, kein random()), reproduzierbar aus leerem Schema: 88 SKUs mit echten Modellnamen und aus der UVP abgeleiteten Preisen (iPhone 15 bis 17 Pro Max, neu + refurbished), 3 Lager, 14 B2B-Kunden, 2.907 Aufträge / 7.413 Positionen über 42 Wochen, aktuelle Bestände, 24 offene Bestellungen. Die Historie trägt eine echte Saisonkurve (Dezember-Peak, Januar-Tal), damit die Forecasts etwas zu rechnen haben.

Produktentscheidungen

AskUserQuestion war nicht verfügbar (kein Mensch am Pane), die Fragen liegen als Report beim Head. Defaults, die ich gewählt habe — bitte gegenlesen:

Frage Gewählt
Ein Lager oder mehrere? Drei (Zentrallager Hamburg, München-Süd, Berlin-Ost)
B2B-Kunden? Ja, mit Aufträgen und Auftragspositionen
Forecast-Horizont 8 Wochen
UI-Sprache Deutsch
Login Keins — Prototyp mit Fake-Daten

Verifiziert

  • bun run check sauber (340 Dateien, 0 Fehler), 34 Unit-Tests grün
  • Alle drei Routen live 200, SSR + Hydration ohne Konsolenfehler, Light und Dark, 1440px und 390px, kein horizontaler Overflow
  • CMD+K-Palette findet SKUs live; Forecasts-Tabelle rendert unter aktivem Service Worker über vier Durchläufe

Zwei Deploy-Fallen, die Zeit gekostet haben (jetzt in CLAUDE.md/README)

  1. MagicDNS gibt es im Container nicht. DATABASE_URL mit mriver.horse-ayu.ts.net stirbt beim Start mit getaddrinfo ENOTFOUND — Container im Dokploy-Overlay-Netz lösen *.ts.net nicht auf. Es muss die Tailscale-IP 100.99.98.203:6789 sein, so wie projax es auch macht. (Die Rollen-Falle aus dem Issue habe ich vermieden: verbunden als postgres.)
  2. *.prev.msbls.de gehört dem onepager-Wildcard. prev-msbls-wildcard.yml (mAi#320) routet jedes <slug>.prev.msbls.de mit priority: 50 auf das Caddy-Backend. Eine über die Dokploy-UI angelegte Domain erzeugt einen exakten Host()-Router ohne Priority (~28) und verliert — die Anfrage landet bei Caddy und wird mit 444 beantwortet. Der Kommentar in der Wildcard-Datei behauptet, exakte Host-Router seien nicht betroffen; für einen Host unter dem Wildcard stimmt das nicht. Gelöst wie bei postcards: handgelegte Datei deploy/traefik/mdistri-prev.yml mit priority: 100, Domain bewusst nicht in der Dokploy-UI.

Nebenbei aufgefallen: Traefik rendert jede Dynamic-Datei als Go-Template, bevor das YAML geparst wird. Ein docker --format-Beispiel mit doppelten geschweiften Klammern in einem #-Kommentar reicht, um nicht nur die eigene Datei zu killen, sondern den Watcher-Callback für das gesamte Dynamic-Verzeichnis. Ist im Header der Datei dokumentiert.

Gefundener Bug, der nichts mit dem Deploy zu tun hatte

Der erste Service Worker fing jeden Same-Origin-GET ab und löste respondWith im Fehlerfall mit dem Rückgabewert von caches.match() auf — der bei einem Miss undefined ist. Ergebnis: sporadisch scheiternde _app/immutable/*-Chunks und Failed to fetch dynamically imported module, während Traefik im selben Zeitraum ausschließlich 200er loggte. Der Worker fasst jetzt nur noch Navigationen an (gehashte Build-Assets erledigt der HTTP-Cache) und gibt auf jedem Pfad eine echte Response zurück. Mit Playwright gegengeprüft: Service Worker blockiert → 0 Fehler bei 66 Responses; erlaubt → Fehler auf den Chunks. Nach dem Fix vier Durchläufe sauber.

Offen / nicht gemacht

  • Nicht nach main gemerged und Issue nicht geschlossen — beides bewusst.
  • Die Preview-App deployt vom Feature-Branch. Nach dem Merge muss giteaBranch der Dokploy-App (4WUbPQkqCoU5wIvR3VVvc) auf main umgestellt werden.
  • Die Seed-Historie ist an das Datum des Migrationslaufs verankert. Läuft die App in einem halben Jahr ohne --reset, zeigt sie eine halbjährig alte „letzte Woche". Für einen Prototyp bewusst so; wenn das stört, ist ein Reseed-Job die Lösung.
## Prototyp steht — Preview: https://mdistri.prev.msbls.de Drei Tabs auf dem pwa-baseline-Stack (Svelte 5 + SvelteKit 2 + Bun + adapter-node), eigenes `mdistri`-Schema auf msupabase. Branch `mai/knuth/issue-1-iphone`, nicht nach main gemerged — das macht der Head. **Commits** - [`9cfca51`](https://mgit.msbls.de/m/mDistri/commit/9cfca51) — feat: iPhone-distributor back-office prototype - [`aaddcd2`](https://mgit.msbls.de/m/mDistri/commit/aaddcd2) — fix: Service-Worker + Deploy-Notizen ### Was drin ist **Dashboard** (öffnet zuerst): Hero-Zahl Umsatz letzte 4 Wochen mit Delta, KPI-Kacheln (Lagerwert, offene Aufträge, Stock-out-Risiko, erwarteter Wareneingang), Absatzchart mit 26 Wochen Ist und 8 Wochen Prognose **auf derselben Achse**, Top-Movers, Stock-out-Risiken, Bestand nach Standort, Umsatz nach Modellreihe, Top-Kunden, Wareneingang. **Bestände**: 88 SKUs über 3 Standorte, filter- und sortierbar (Modellreihe, Zustand, Standort, nur Risiko-SKUs), Meldebestand-Markierung, Reichweite je SKU. **Forecasts**: Prognose je SKU über 8 Wochen, Detailchart + Wochentabelle mit dem Saisonindex je Woche, Saisonindex-Panel, Sparklines in der Liste. Dazu Sidebar/BottomNav nach Baseline, Light+Dark mit Toggle, CMD+K-Palette (Seiten + SKUs), PWA-Shell. ### Zu den Forecast-Zahlen Das Verfahren steht wörtlich im UI, bevor die erste Zahl kommt, inklusive Grenzen — nichts wird als Modellergebnis ausgegeben: 1. **Saisonindex** je Kalendermonat, aus der eigenen Historie: mittlere Wochenmenge des Monats über den Katalog / mittlere Wochenmenge insgesamt, begrenzt auf 0,5–2,0. Monate mit unter zwei beobachteten Wochen bekommen keinen eigenen Index und werden zwischen den Nachbarmonaten interpoliert (im UI als „interpoliert" markiert). 2. **Basiswert** je SKU: Mittel der letzten 8 abgeschlossenen Wochen, **jede Woche vorher durch den Index ihres Monats geteilt**. 3. **Prognose** = Basiswert × Index des Zielmonats. Schritt 2 ist der Punkt, an dem ich zweimal nachgebessert habe. Ohne die Saisonbereinigung zählt das Verfahren die Saison doppelt (das Fenster liegt schon im schwachen Monat) und die Kurve fällt an der Grenze Ist→Prognose um die Hälfte ab. Und ein Monat mit nur einer beobachteten Woche — hier der September mit der iPhone-17-Launchwoche — erzeugte einen Index am oberen Anschlag und damit einen Sprung, der wie ein Fehler aussah. Beides ist in `src/lib/forecast.ts` gelöst und mit Tests festgenagelt. Genannte Grenzen: kein Trend, keine Aktionen, keine Lieferengpässe; der Index vermischt Saisonalität mit Produkt-Launches; bei unter einem Jahr Historie haben mehrere Monate keinen belastbaren Index. ### Seed-Daten Deterministisch (md5-abgeleitet, kein `random()`), reproduzierbar aus leerem Schema: 88 SKUs mit echten Modellnamen und aus der UVP abgeleiteten Preisen (iPhone 15 bis 17 Pro Max, neu + refurbished), 3 Lager, 14 B2B-Kunden, **2.907 Aufträge / 7.413 Positionen über 42 Wochen**, aktuelle Bestände, 24 offene Bestellungen. Die Historie trägt eine echte Saisonkurve (Dezember-Peak, Januar-Tal), damit die Forecasts etwas zu rechnen haben. ### Produktentscheidungen `AskUserQuestion` war nicht verfügbar (kein Mensch am Pane), die Fragen liegen als Report beim Head. Defaults, die ich gewählt habe — bitte gegenlesen: | Frage | Gewählt | |---|---| | Ein Lager oder mehrere? | **Drei** (Zentrallager Hamburg, München-Süd, Berlin-Ost) | | B2B-Kunden? | **Ja**, mit Aufträgen und Auftragspositionen | | Forecast-Horizont | **8 Wochen** | | UI-Sprache | **Deutsch** | | Login | **Keins** — Prototyp mit Fake-Daten | ### Verifiziert - `bun run check` sauber (340 Dateien, 0 Fehler), 34 Unit-Tests grün - Alle drei Routen live 200, SSR + Hydration ohne Konsolenfehler, Light und Dark, 1440px und 390px, kein horizontaler Overflow - CMD+K-Palette findet SKUs live; Forecasts-Tabelle rendert unter aktivem Service Worker über vier Durchläufe ### Zwei Deploy-Fallen, die Zeit gekostet haben (jetzt in CLAUDE.md/README) 1. **MagicDNS gibt es im Container nicht.** `DATABASE_URL` mit `mriver.horse-ayu.ts.net` stirbt beim Start mit `getaddrinfo ENOTFOUND` — Container im Dokploy-Overlay-Netz lösen `*.ts.net` nicht auf. Es muss die Tailscale-IP `100.99.98.203:6789` sein, so wie projax es auch macht. (Die Rollen-Falle aus dem Issue habe ich vermieden: verbunden als `postgres`.) 2. **`*.prev.msbls.de` gehört dem onepager-Wildcard.** `prev-msbls-wildcard.yml` (mAi#320) routet **jedes** `<slug>.prev.msbls.de` mit `priority: 50` auf das Caddy-Backend. Eine über die Dokploy-UI angelegte Domain erzeugt einen exakten `Host()`-Router ohne Priority (~28) und verliert — die Anfrage landet bei Caddy und wird mit 444 beantwortet. Der Kommentar in der Wildcard-Datei behauptet, exakte Host-Router seien nicht betroffen; für einen Host **unter** dem Wildcard stimmt das nicht. Gelöst wie bei postcards: handgelegte Datei `deploy/traefik/mdistri-prev.yml` mit `priority: 100`, Domain bewusst **nicht** in der Dokploy-UI. Nebenbei aufgefallen: Traefik rendert jede Dynamic-Datei als **Go-Template, bevor** das YAML geparst wird. Ein `docker --format`-Beispiel mit doppelten geschweiften Klammern in einem `#`-Kommentar reicht, um nicht nur die eigene Datei zu killen, sondern den Watcher-Callback für das **gesamte** Dynamic-Verzeichnis. Ist im Header der Datei dokumentiert. ### Gefundener Bug, der nichts mit dem Deploy zu tun hatte Der erste Service Worker fing jeden Same-Origin-GET ab und löste `respondWith` im Fehlerfall mit dem Rückgabewert von `caches.match()` auf — der bei einem Miss `undefined` ist. Ergebnis: sporadisch scheiternde `_app/immutable/*`-Chunks und `Failed to fetch dynamically imported module`, während Traefik im selben Zeitraum ausschließlich 200er loggte. Der Worker fasst jetzt nur noch Navigationen an (gehashte Build-Assets erledigt der HTTP-Cache) und gibt auf jedem Pfad eine echte Response zurück. Mit Playwright gegengeprüft: Service Worker blockiert → 0 Fehler bei 66 Responses; erlaubt → Fehler auf den Chunks. Nach dem Fix vier Durchläufe sauber. ### Offen / nicht gemacht - **Nicht nach main gemerged** und Issue nicht geschlossen — beides bewusst. - Die Preview-App deployt vom Feature-Branch. Nach dem Merge muss `giteaBranch` der Dokploy-App (`4WUbPQkqCoU5wIvR3VVvc`) auf `main` umgestellt werden. - Die Seed-Historie ist an das Datum des Migrationslaufs verankert. Läuft die App in einem halben Jahr ohne `--reset`, zeigt sie eine halbjährig alte „letzte Woche". Für einen Prototyp bewusst so; wenn das stört, ist ein Reseed-Job die Lösung.
mAi added the
status:done
label 2026-07-26 16:26:32 +00:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: m/mDistri#1
No description provided.