Enhances AI booking optimization with smart slot recommendations

Implements AI-driven time slot selection algorithm for booking system

Adds intelligent slot scoring mechanism that considers:
- Minimizing calendar gaps
- Optimizing employee time utilization
- Providing recommended time slots for customers

Introduces new AI features across booking interfaces to improve scheduling efficiency
This commit is contained in:
Janus C. H. Knudsen 2026-01-02 21:19:10 +01:00
parent 3b86a6c8b3
commit 2a066c6d14
18 changed files with 4496 additions and 25 deletions

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