Moving away from Azure Devops #1

Merged
Janus007 merged 113 commits from refac into master 2026-02-03 00:04:27 +01:00
18 changed files with 4496 additions and 25 deletions
Showing only changes of commit 2a066c6d14 - Show all commits

View file

@ -18,7 +18,10 @@
"Bash(powershell -Command \"Get-ChildItem -Path src -Filter ''index.ts'' -Recurse | Select-Object -ExpandProperty FullName\")",
"Bash(powershell -Command:*)",
"WebFetch(domain:www.npmjs.com)",
"WebFetch(domain:unpkg.com)"
"WebFetch(domain:unpkg.com)",
"Bash(node -e:*)",
"Bash(ls:*)",
"Bash(find:*)"
],
"deny": [],
"ask": []

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

244
package-lock.json generated
View file

@ -33,10 +33,12 @@
"postcss-cli": "^11.0.1",
"postcss-nesting": "^13.0.2",
"purgecss": "^7.0.2",
"read-excel-file": "^6.0.1",
"rollup": "^4.52.5",
"tslib": "^2.8.1",
"typescript": "^5.0.0",
"vitest": "^3.2.4"
"vitest": "^3.2.4",
"xlsx": "^0.18.5"
}
},
"node_modules/@asamuzakjp/css-color": {
@ -1348,6 +1350,16 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@ -1360,6 +1372,16 @@
"node": ">=0.4.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@ -1510,6 +1532,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bluebird": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
"dev": true,
"license": "MIT"
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@ -1618,6 +1647,20 @@
],
"license": "CC-BY-4.0"
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
@ -1781,6 +1824,16 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -1834,6 +1887,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"dev": true,
"license": "MIT"
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -2310,6 +2383,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/duplexer2": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
"integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"readable-stream": "^2.0.2"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@ -2581,6 +2664,16 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@ -2858,6 +2951,13 @@
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true,
"license": "ISC"
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@ -3036,6 +3136,13 @@
"node": ">=0.10.0"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"dev": true,
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -3325,6 +3432,13 @@
"dev": true,
"license": "ISC"
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"dev": true,
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@ -4289,6 +4403,13 @@
"node": ">= 0.8"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"dev": true,
"license": "MIT"
},
"node_modules/pseudo-classes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/pseudo-classes/-/pseudo-classes-1.0.0.tgz",
@ -4339,6 +4460,34 @@
"pify": "^2.3.0"
}
},
"node_modules/read-excel-file": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/read-excel-file/-/read-excel-file-6.0.1.tgz",
"integrity": "sha512-rH6huBFxsjZsUARCYh55O08cn1gqZH8bnLf0kI6y5K7+9yqBVzy8veO4gPV4VGKv4M9rdcRtXTDGZZNwPi1gDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@xmldom/xmldom": "^0.8.11",
"fflate": "^0.8.2",
"unzipper": "^0.12.3"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true,
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@ -4479,6 +4628,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@ -4597,6 +4753,19 @@
"specificity": "bin/specificity"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@ -4611,6 +4780,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@ -5048,6 +5227,27 @@
"node": ">=18.12.0"
}
},
"node_modules/unzipper": {
"version": "0.12.3",
"resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz",
"integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==",
"dev": true,
"license": "MIT",
"dependencies": {
"bluebird": "~3.7.2",
"duplexer2": "~0.1.4",
"fs-extra": "^11.2.0",
"graceful-fs": "^4.2.2",
"node-int64": "^0.4.0"
}
},
"node_modules/unzipper/node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/update-browserslist-db": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
@ -5796,6 +5996,26 @@
"node": ">=8"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
@ -5916,6 +6136,28 @@
}
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",

View file

@ -33,10 +33,12 @@
"postcss-cli": "^11.0.1",
"postcss-nesting": "^13.0.2",
"purgecss": "^7.0.2",
"read-excel-file": "^6.0.1",
"rollup": "^4.52.5",
"tslib": "^2.8.1",
"typescript": "^5.0.0",
"vitest": "^3.2.4"
"vitest": "^3.2.4",
"xlsx": "^0.18.5"
},
"dependencies": {
"@novadi/core": "^0.6.0",

View file

@ -0,0 +1,255 @@
# AI Booking Optimering
## Produktoversigt
AI Booking Optimering er et intelligent tillægsmodul der hjælper saloner med at maksimere deres kalenderudnyttelse og reducere tabt omsætning fra tomme tidsslots.
---
## Features
### Feature 1: Smart Tidsforslag (Real-time)
Når kunder booker online, analyserer AI'en eksisterende bookinger og foreslår de mest optimale tidspunkter.
**Hvordan det virker:**
1. Kunden vælger ydelse (f.eks. Dameklip, 60 min)
2. AI'en analyserer dagens/ugens bookinger for den valgte medarbejder
3. Hvert ledigt tidsslot får en score baseret på:
- Minimering af huller
- Optimal udnyttelse af åbningstiden
- Kontinuitet i bookinger
4. Top 2-3 bedste slots markeres med "Anbefalet" badge
**Scoring-algoritme:**
| Kriterium | Score |
|-----------|-------|
| Starter ved åbningstid | +3 |
| Slutter præcis på næste booking | +3 |
| Starter lige efter en booking | +2 |
| Slutter ved lukketid | +1 |
| Skaber hul < 30 min | -2 |
**UX-principper:**
- Blød anbefaling - kunden kan stadig vælge alle ledige tider
- Grøn badge med AI-ikon på anbefalede tider
- Info-tekst forklarer fordelen
---
### Feature 2: Kalender-optimering Dashboard
Salonejere får et dashboard der identificerer huller og foreslår handlinger.
**Dashboard-komponenter:**
1. **Statistik-kort**
- Huller i dag
- Tabt omsætning (estimeret)
- Huller denne uge
- Potentiel besparelse
2. **Mini-kalender**
- Visuel oversigt over ugen
- Farvekodede dage (grøn = optimalt, gul = huller, rød = kritisk)
3. **Hul-liste**
- Detaljeret visning af hvert identificeret hul
- Medarbejder og tidspunkt
- Estimeret tabt omsætning
- AI-forslag til at fylde hullet
4. **AI-forslag typer:**
- **Flyt booking**: Foreslå at flytte en eksisterende kundes tid
- **Venteliste**: Kontakt kunde fra ventelisten
- **Rabattilbud**: Send SMS med rabat for at fylde hullet
5. **SMS-historik**
- Track sendte tilbud
- Accept/afvisning statistik
- Pending tilbud
---
## Teknisk Implementation
### POC 1: poc-booking-v2.html
**Tilføjede komponenter:**
```javascript
// Mock data for eksisterende bookinger
const existingBookings = {
'EMP001': {
'2026-01-06': [
{ start: '10:00', end: '11:00', service: 'Dameklip' },
{ start: '13:30', end: '14:30', service: 'Herreklip' }
]
}
};
// Scoring-algoritme
function calculateOptimalSlots(serviceDuration, date, employeeId) {
// 1. Hent bookinger for dato/medarbejder
// 2. Generer alle mulige slots (30 min intervaller)
// 3. Tjek overlap med eksisterende bookinger
// 4. Beregn score for hvert ledigt slot
// 5. Marker top 3 med positiv score som "recommended"
return slots;
}
```
**CSS-styling:**
- `.time-slot.recommended` - Grøn border og baggrund
- `.ai-badge` - Absolut positioneret badge med sparkle-ikon
- `.ai-info` - Info-boks over tidsgrid
### POC 2: poc-ai-booking-optimizer.html
**Struktur:**
- Topbar med AI-badge
- Stats-grid med 4 KPI-kort
- Main-grid med kalender og hul-liste
- Sidebar med optimeringsscore og SMS-historik
**Mock data:**
- `gaps[]` - Identificerede huller med forslag
- `weekDays[]` - Ugevisning med gap-status
- `smsHistory[]` - Historik over sendte tilbud
---
## Forretningsværdi
### ROI-beregning
```
Typisk salon:
- 4 medarbejdere
- 40 timer/uge pr. medarbejder
- 15% tomme slots = 24 timer/uge tabt
- Gennemsnitlig timepris: 500 kr.
- Tabt omsætning: 12.000 kr./uge = 624.000 kr./år
AI-optimering fylder 50% af huller:
- Ekstra omsætning: 312.000 kr./år
Pris for AI-modul: 499 kr./md = 5.988 kr./år
ROI: 52x investering
```
### Nøgletal at tracke
| Metrik | Beskrivelse |
|--------|-------------|
| Kalenderudnyttelse | % af tilgængelige timer der er booket |
| Gennemsnitligt hul | Minutter tabt pr. dag i gaps |
| Accept-rate | % af kunder der accepterer flyttetilbud |
| Tabt omsætning | Estimeret kr. i tomme slots |
| Optimeringsscore | Samlet effektivitet (mål: 90%+) |
---
## Fremtidig AI-udvidelse
### Niveau 1: Regelbaseret (Nuværende POC)
- Statiske scoring-regler
- Ingen læring
- Fungerer for alle saloner ens
### Niveau 2: Machine Learning
- Lærer fra salonens historik
- Personlige kundeprofilenr
- Forudsigelse af no-shows
- Dynamisk prisjustering
### ML-features (fremtidig):
1. **Historisk mønstergenkendelse**
- Populære vs. døde tider
- Sæsonvariation
- Service-specifikke mønstre
2. **Kundesegmentering**
- Fleksible vs. fastlåste kunder
- Pris-sensitive kunder
- No-show risiko-profiler
3. **Intelligent rabat-beregning**
- Dynamisk rabat baseret på:
- Hullets "værdi"
- Kundens prissensitivitet
- Sandsynlighed for naturlig booking
4. **Proaktiv optimering**
- Forudsig huller før de opstår
- Automatisk udsend tilbud
- Venteliste-matching
---
## Integration med eksisterende system
### Data-flow
```
Booking system
┌─────────────────┐
│ AI Optimizer │
│ - Analyse │
│ - Scoring │
│ - Anbefalinger │
└─────────────────┘
┌─────────────────┐ ┌─────────────────┐
│ Booking Widget │ │ Dashboard │
│ (kundevisning) │ │ (ejervisning) │
└─────────────────┘ └─────────────────┘
```
### API-endpoints (fremtidig)
```
GET /api/ai/optimal-slots?date=X&employee=Y&duration=Z
POST /api/ai/send-offer
GET /api/ai/gaps?week=X
GET /api/ai/stats
```
---
## Konfiguration
### Indstillinger pr. salon
| Indstilling | Beskrivelse | Default |
|-------------|-------------|---------|
| `minGapMinutes` | Mindste hul der tæller som tabt | 30 min |
| `recommendedSlots` | Antal anbefalede slots | 3 |
| `defaultDiscount` | Standard rabat ved flytning | 5% |
| `autoSendOffers` | Automatisk udsend tilbud | Fra |
| `smsEnabled` | Aktiver SMS-udsendelse | Til |
---
## Filer
| Fil | Beskrivelse |
|-----|-------------|
| `poc-booking-v2.html` | Kundens booking-widget med AI-anbefalinger |
| `poc-ai-booking-optimizer.html` | Dashboard til salonejere |
| `docs/ai-booking-optimering.md` | Denne dokumentation |
---
## Changelog
### Version 1.0 (Januar 2026)
- Initial POC implementation
- Regelbaseret scoring-algoritme
- Dashboard med hul-identifikation
- SMS-historik tracking

File diff suppressed because it is too large Load diff

View file

@ -504,6 +504,58 @@
text-decoration: line-through;
}
/* AI Recommended Slots */
.time-slot.recommended {
position: relative;
border: 2px solid var(--color-green);
background: color-mix(in srgb, var(--color-green) 8%, white);
}
.time-slot.recommended:hover:not(.disabled):not(.selected) {
background: color-mix(in srgb, var(--color-green) 15%, white);
}
.time-slot.recommended.selected {
background: var(--color-green);
border-color: var(--color-green);
}
.ai-badge {
position: absolute;
top: -8px;
right: -8px;
background: var(--color-green);
color: white;
font-size: 10px;
padding: 2px 5px;
border-radius: 4px;
display: flex;
align-items: center;
gap: 2px;
font-weight: 500;
font-family: var(--font-family);
}
.ai-badge i {
font-size: 10px;
}
.ai-info {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: color-mix(in srgb, var(--color-green) 10%, white);
border-radius: 8px;
margin-bottom: 12px;
font-size: 12px;
color: var(--color-green);
}
.ai-info i {
font-size: 16px;
}
/* ==========================================
WAITLIST
========================================== */
@ -1917,6 +1969,133 @@
{ id: "EMP004", name: "Viktor", role: "Junior Stylist", color: "#009688", priceModifier: -100 }
];
// ==========================================
// EKSISTERENDE BOOKINGER (Mock data til AI-optimering)
// ==========================================
const existingBookings = {
'EMP001': {
'2026-01-06': [
{ start: '10:00', end: '11:00', service: 'Dameklip' },
{ start: '13:30', end: '14:30', service: 'Herreklip' }
],
'2026-01-07': [
{ start: '09:00', end: '10:30', service: 'Bundfarve' },
{ start: '11:00', end: '12:00', service: 'Dameklip' },
{ start: '14:00', end: '15:00', service: 'Dameklip' }
]
},
'EMP002': {
'2026-01-06': [
{ start: '09:00', end: '10:00', service: 'Herreklip' },
{ start: '11:00', end: '12:00', service: 'Dameklip' },
{ start: '15:00', end: '16:30', service: 'Striber' }
],
'2026-01-07': [
{ start: '10:00', end: '11:00', service: 'Dameklip' },
{ start: '13:00', end: '14:00', service: 'Herreklip' }
]
},
'EMP003': {
'2026-01-06': [
{ start: '08:30', end: '09:30', service: 'Herreklip' },
{ start: '12:00', end: '13:00', service: 'Dameklip' }
]
},
'EMP004': {
'2026-01-06': [
{ start: '09:00', end: '10:00', service: 'Herreklip' },
{ start: '14:00', end: '15:30', service: 'Bundfarve' }
]
}
};
const SALON_OPEN = '08:00';
const SALON_CLOSE = '17:00';
// ==========================================
// AI SLOT OPTIMERING
// ==========================================
function timeToMinutes(time) {
const [h, m] = time.split(':').map(Number);
return h * 60 + m;
}
function minutesToTime(mins) {
const h = Math.floor(mins / 60);
const m = mins % 60;
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
}
function getBookingsForSlot(date, employeeId) {
if (!employeeId) {
let bestEmployee = 'EMP001';
let minBookings = Infinity;
for (const empId of Object.keys(existingBookings)) {
const bookings = existingBookings[empId]?.[date] || [];
if (bookings.length < minBookings) {
minBookings = bookings.length;
bestEmployee = empId;
}
}
return existingBookings[bestEmployee]?.[date] || [];
}
return existingBookings[employeeId]?.[date] || [];
}
function calculateOptimalSlots(serviceDuration, date, employeeId) {
const bookings = getBookingsForSlot(date, employeeId);
const openMins = timeToMinutes(SALON_OPEN);
const closeMins = timeToMinutes(SALON_CLOSE);
const slots = [];
for (let mins = openMins; mins <= closeMins - serviceDuration; mins += 30) {
const slotStart = mins;
const slotEnd = mins + serviceDuration;
const time = minutesToTime(mins);
let isTaken = false;
for (const booking of bookings) {
const bookStart = timeToMinutes(booking.start);
const bookEnd = timeToMinutes(booking.end);
if (slotStart < bookEnd && slotEnd > bookStart) {
isTaken = true;
break;
}
}
let score = 0;
if (!isTaken) {
if (slotStart === openMins) score += 3;
for (const booking of bookings) {
if (slotEnd === timeToMinutes(booking.start)) { score += 3; break; }
}
for (const booking of bookings) {
if (slotStart === timeToMinutes(booking.end)) { score += 2; break; }
}
for (const booking of bookings) {
const gap = timeToMinutes(booking.start) - slotEnd;
if (gap > 0 && gap < 30) { score -= 2; break; }
}
for (const booking of bookings) {
const gap = slotStart - timeToMinutes(booking.end);
if (gap > 0 && gap < 30) { score -= 2; break; }
}
if (slotEnd >= closeMins - 60) score += 1;
}
slots.push({ time, taken: isTaken, score, recommended: false });
}
const availableSlots = slots.filter(s => !s.taken);
availableSlots.sort((a, b) => b.score - a.score);
const topSlots = availableSlots.slice(0, 3);
for (const slot of topSlots) {
if (slot.score > 0) slot.recommended = true;
}
return slots;
}
// ==========================================
// STEP ANIMATION
// ==========================================
@ -2196,8 +2375,18 @@
daysHtml += `<div class="${cls}" data-date="${dateStr}">${d}</div>`;
}
const times = ['09:00', '09:30', '10:00', '10:30', '11:00', '11:30', '12:00', '13:00', '13:30', '14:00', '14:30', '15:00', '15:30', '16:00', '16:30'];
const taken = ['10:30', '14:00'];
// Beregn total varighed for valgte ydelser
const serviceDuration = state.services.reduce((sum, s) => sum + s.duration, 0) || 60;
// Brug AI-algoritme til at beregne optimale slots
const selectedDate = state.date || new Date().toISOString().split('T')[0];
const slots = calculateOptimalSlots(serviceDuration, selectedDate, state.employee);
// Filtrer til åbningstider (08:00-17:00)
const displaySlots = slots.filter(s => {
const mins = timeToMinutes(s.time);
return mins >= timeToMinutes('08:00') && mins <= timeToMinutes('16:30');
});
container.innerHTML = `
<div class="datetime-grid">
@ -2222,12 +2411,20 @@
</div>
<div class="time-section">
<div class="time-section-title">Ledige tider</div>
<div class="ai-info">
<i class="ph ph-sparkle"></i>
<span>Anbefalede tider passer bedst i vores kalender</span>
</div>
<div class="time-grid">
${times.map(t => {
${displaySlots.map(slot => {
let cls = 'time-slot';
if (taken.includes(t)) cls += ' disabled';
if (state.time === t) cls += ' selected';
return `<div class="${cls}" data-time="${t}">${t}</div>`;
if (slot.taken) cls += ' disabled';
if (slot.recommended && !slot.taken) cls += ' recommended';
if (state.time === slot.time) cls += ' selected';
return `<div class="${cls}" data-time="${slot.time}">
${slot.time}
${slot.recommended && !slot.taken ? '<span class="ai-badge"><i class="ph ph-sparkle"></i></span>' : ''}
</div>`;
}).join('')}
</div>

View file

@ -568,6 +568,59 @@
text-decoration: line-through;
}
swp-time-slot.recommended {
position: relative;
border: 2px solid var(--color-green);
background: color-mix(in srgb, var(--color-green) 10%, white);
}
swp-time-slot.recommended:hover {
background: color-mix(in srgb, var(--color-green) 18%, white);
}
swp-time-slot.recommended.selected {
background: var(--color-teal);
border-color: var(--color-teal);
}
swp-ai-badge {
position: absolute;
top: -10px;
right: -10px;
display: inline-flex;
align-items: center;
gap: 3px;
background: var(--color-green);
color: white;
font-size: 9px;
font-weight: 600;
padding: 3px 6px;
border-radius: 4px;
white-space: nowrap;
}
swp-ai-badge i {
font-size: 10px;
}
swp-ai-info {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: color-mix(in srgb, var(--color-green) 10%, white);
border: 1px solid color-mix(in srgb, var(--color-green) 25%, transparent);
border-radius: 8px;
margin-bottom: 16px;
font-size: 12px;
color: var(--color-text);
}
swp-ai-info i {
font-size: 18px;
color: var(--color-green);
}
/* ==========================================
CONTACT FORM
========================================== */
@ -1065,6 +1118,180 @@
{ id: "EMP004", name: "Viktor", role: "Frisør", color: "#009688" }
];
// ==========================================
// EKSISTERENDE BOOKINGER (Mock data til AI-optimering)
// ==========================================
const existingBookings = {
// Bookinger pr. medarbejder pr. dato
'EMP001': {
'2026-01-06': [
{ start: '10:00', end: '11:00', service: 'Dameklip' },
{ start: '13:30', end: '14:30', service: 'Herreklip' }
],
'2026-01-07': [
{ start: '09:00', end: '10:30', service: 'Bundfarve' },
{ start: '11:00', end: '12:00', service: 'Dameklip' },
{ start: '14:00', end: '15:00', service: 'Dameklip' }
]
},
'EMP002': {
'2026-01-06': [
{ start: '09:00', end: '10:00', service: 'Herreklip' },
{ start: '11:00', end: '12:00', service: 'Dameklip' },
{ start: '15:00', end: '16:30', service: 'Striber' }
],
'2026-01-07': [
{ start: '10:00', end: '11:00', service: 'Dameklip' },
{ start: '13:00', end: '14:00', service: 'Herreklip' }
]
},
'EMP003': {
'2026-01-06': [
{ start: '08:30', end: '09:30', service: 'Herreklip' },
{ start: '12:00', end: '13:00', service: 'Dameklip' }
]
},
'EMP004': {
'2026-01-06': [
{ start: '09:00', end: '10:00', service: 'Herreklip' },
{ start: '14:00', end: '15:30', service: 'Bundfarve' }
]
}
};
const SALON_OPEN = '08:00';
const SALON_CLOSE = '17:00';
// ==========================================
// AI SLOT OPTIMERING
// ==========================================
function timeToMinutes(time) {
const [h, m] = time.split(':').map(Number);
return h * 60 + m;
}
function minutesToTime(mins) {
const h = Math.floor(mins / 60);
const m = mins % 60;
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
}
function getBookingsForSlot(date, employeeId) {
// Hvis ingen præference, kombiner alle medarbejderes bookinger
if (!employeeId) {
// Find medarbejder med færrest bookinger (simulerer "første ledige")
let bestEmployee = 'EMP001';
let minBookings = Infinity;
for (const empId of Object.keys(existingBookings)) {
const bookings = existingBookings[empId]?.[date] || [];
if (bookings.length < minBookings) {
minBookings = bookings.length;
bestEmployee = empId;
}
}
return existingBookings[bestEmployee]?.[date] || [];
}
return existingBookings[employeeId]?.[date] || [];
}
function calculateOptimalSlots(serviceDuration, date, employeeId) {
const bookings = getBookingsForSlot(date, employeeId);
const openMins = timeToMinutes(SALON_OPEN);
const closeMins = timeToMinutes(SALON_CLOSE);
// Generer alle mulige slots (30 min intervaller)
const slots = [];
for (let mins = openMins; mins <= closeMins - serviceDuration; mins += 30) {
const slotStart = mins;
const slotEnd = mins + serviceDuration;
const time = minutesToTime(mins);
// Tjek om slot overlapper med eksisterende booking
let isTaken = false;
for (const booking of bookings) {
const bookStart = timeToMinutes(booking.start);
const bookEnd = timeToMinutes(booking.end);
if (slotStart < bookEnd && slotEnd > bookStart) {
isTaken = true;
break;
}
}
// Beregn score for dette slot
let score = 0;
if (!isTaken) {
// +3: Starter ved åbningstid
if (slotStart === openMins) {
score += 3;
}
// +3: Slutter præcis på næste booking
for (const booking of bookings) {
const bookStart = timeToMinutes(booking.start);
if (slotEnd === bookStart) {
score += 3;
break;
}
}
// +2: Starter lige efter en booking
for (const booking of bookings) {
const bookEnd = timeToMinutes(booking.end);
if (slotStart === bookEnd) {
score += 2;
break;
}
}
// -2: Skaber lille hul (< 30 min) til næste booking
for (const booking of bookings) {
const bookStart = timeToMinutes(booking.start);
const gap = bookStart - slotEnd;
if (gap > 0 && gap < 30) {
score -= 2;
break;
}
}
// -2: Skaber lille hul fra forrige booking
for (const booking of bookings) {
const bookEnd = timeToMinutes(booking.end);
const gap = slotStart - bookEnd;
if (gap > 0 && gap < 30) {
score -= 2;
break;
}
}
// +1: Sidst på dagen (fylder op bagfra)
if (slotEnd >= closeMins - 60) {
score += 1;
}
}
slots.push({
time,
taken: isTaken,
score,
recommended: false
});
}
// Marker top 3 ledige slots som recommended
const availableSlots = slots.filter(s => !s.taken);
availableSlots.sort((a, b) => b.score - a.score);
const topSlots = availableSlots.slice(0, 3);
for (const slot of topSlots) {
if (slot.score > 0) {
slot.recommended = true;
}
}
return slots;
}
// ==========================================
// INIT
// ==========================================
@ -1294,6 +1521,10 @@
</swp-calendar>
<swp-time-section>
<swp-section-title>Ledige tider</swp-section-title>
<swp-ai-info>
<i class="ph ph-sparkle"></i>
<span>Anbefalede tider passer bedst i vores kalender og minimerer ventetid</span>
</swp-ai-info>
<swp-time-grid id="timeGrid"></swp-time-grid>
</swp-time-section>
</swp-datetime-layout>
@ -1356,11 +1587,22 @@
const grid = document.getElementById('timeGrid');
if (!grid) return;
const times = ['09:00', '09:30', '10:00', '10:30', '11:00', '11:30', '12:00', '13:00', '13:30', '14:00', '14:30', '15:00', '15:30', '16:00', '16:30'];
const taken = ['10:30', '14:00'];
// Beregn samlet varighed af valgte services (eller default 60 min)
const serviceDuration = state.services.reduce((sum, s) => sum + s.duration, 0) || 60;
grid.innerHTML = times.map(t => `
<swp-time-slot data-time="${t}" class="${taken.includes(t) ? 'disabled' : ''} ${state.time === t ? 'selected' : ''}">${t}</swp-time-slot>
// Brug AI-algoritmen til at beregne optimale slots
const selectedDate = state.date || new Date().toISOString().split('T')[0];
const slots = calculateOptimalSlots(serviceDuration, selectedDate, state.employee);
// Render slots med AI-anbefalinger
grid.innerHTML = slots.map(slot => `
<swp-time-slot
data-time="${slot.time}"
class="${slot.taken ? 'disabled' : ''} ${slot.recommended ? 'recommended' : ''} ${state.time === slot.time ? 'selected' : ''}"
>
${slot.time}
${slot.recommended ? '<swp-ai-badge><i class="ph ph-sparkle"></i>Anbefalet</swp-ai-badge>' : ''}
</swp-time-slot>
`).join('');
grid.querySelectorAll('swp-time-slot:not(.disabled)').forEach(slot => {

View file

@ -2609,21 +2609,17 @@
<swp-card>
<swp-section-label>Provision</swp-section-label>
<swp-edit-section>
<swp-edit-row>
<swp-edit-label>Minimum pr. time</swp-edit-label>
<swp-edit-value contenteditable="true">220 kr</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>På services</swp-edit-label>
<swp-edit-value contenteditable="true">12%</swp-edit-value>
<swp-edit-value contenteditable="true">15%</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>På produktsalg</swp-edit-label>
<swp-edit-value contenteditable="true">8%</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>Bonus ved mål</swp-edit-label>
<swp-edit-value contenteditable="true">2.500 kr/md</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>Månedligt mål</swp-edit-label>
<swp-edit-value contenteditable="true">45.000 kr</swp-edit-value>
<swp-edit-value contenteditable="true">15%</swp-edit-value>
</swp-edit-row>
</swp-edit-section>
</swp-card>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff