feat: rebrand AgendaPro -> AgendaMax + calendar fixes + current-week demo seed
- Rebrand all user-facing text to AgendaMax - Fix sidebar scrolling: card container no longer overflows html (flex h-full min-h-0) - Fix calendar toolbar hover: active buttons keep readable contrast - Sticky calendar toolbar (month/week/day always visible on scroll) - Seed guarantees 2-4 appointments per day for current week (Mon-Sat)
This commit is contained in:
@@ -4,7 +4,9 @@ import assert from "node:assert/strict";
|
||||
import {
|
||||
parseWorkingHours, isoDayOfWeek, getWorkingHoursForDate,
|
||||
normalizeText, tokens, specialtyMatch, overlaps, hasConflict, scoreCandidate,
|
||||
pickBestSlotEmployee, BUSY_STATUSES,
|
||||
} from "./scheduling.ts";
|
||||
import type { CandidateInfo, BusyWindow } from "./scheduling.ts";
|
||||
|
||||
test("parseWorkingHours: json válido → mapa 1..7", () => {
|
||||
const m = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" }, 6: null, 7: null }));
|
||||
@@ -69,13 +71,13 @@ test("hasConflict: lista vacía → false", () => {
|
||||
assert.equal(hasConflict([], 100, 200), false);
|
||||
});
|
||||
|
||||
test("scoreCandidate: pesos 50/30/20", () => {
|
||||
test("scoreCandidate: pesos 40/20/40", () => {
|
||||
// todo al máximo → 100
|
||||
assert.equal(scoreCandidate({ specialtyMatch: 1, efficiency: 1, loadBalance: 0 }), 100);
|
||||
// todo al mínimo → 0
|
||||
assert.equal(scoreCandidate({ specialtyMatch: 0, efficiency: 0, loadBalance: 1 }), 0);
|
||||
// sólo specialty (0.5) → 25
|
||||
assert.equal(scoreCandidate({ specialtyMatch: 0.5, efficiency: 0, loadBalance: 1 }), 25);
|
||||
// sólo specialty (0.5) → 20 (40·0.5)
|
||||
assert.equal(scoreCandidate({ specialtyMatch: 0.5, efficiency: 0, loadBalance: 1 }), 20);
|
||||
});
|
||||
|
||||
test("scoreCandidate: mayor efficiency → mayor score", () => {
|
||||
@@ -89,3 +91,55 @@ test("scoreCandidate: menor load (más disponible) → mayor score", () => {
|
||||
const free = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.5, loadBalance: 0.1 });
|
||||
assert.ok(free > busy);
|
||||
});
|
||||
|
||||
test("scoreCandidate: fairness — libre+ineficiente vence a ocupado+eficiente (misma especialidad)", () => {
|
||||
// Con los pesos viejos (50/30/20) el efficiency estático (30) aplastaba al load (20):
|
||||
// un senior ocupado siempre ganaba. Con 40/20/40, el load (40) supera al efficiency (20).
|
||||
// A: especialista ocupado (load 0.8) y muy eficiente (0.9)
|
||||
const a = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.9, loadBalance: 0.8 });
|
||||
// B: especialista libre (load 0.1) e ineficiente (0.2)
|
||||
const b = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.2, loadBalance: 0.1 });
|
||||
assert.equal(a, 40 * 0.5 + 20 * 0.9 + 40 * (1 - 0.8)); // 46
|
||||
assert.equal(b, 40 * 0.5 + 20 * 0.2 + 40 * (1 - 0.1)); // 60
|
||||
assert.ok(b > a, "el especialista libre debe vencer al ocupado+eficiente (anti-burnout)");
|
||||
});
|
||||
|
||||
test("pickBestSlotEmployee: empate en score → gana el de menor loadBalance (no por id)", () => {
|
||||
const bizWh = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "17:00" } }));
|
||||
const startMs = new Date(2026, 6, 27, 13, 0).getTime(); // lunes, dentro de horario
|
||||
const endMs = new Date(2026, 6, 27, 14, 0).getTime();
|
||||
// A (id menor, senior): especialidad coincide (sm=1), ocupado media jornada (load=0.5)
|
||||
// → 40·1 + 20·0.5 + 40·0.5 = 70
|
||||
// B (id mayor, junior): sin especialidad (sm=0.5), libre (load=0)
|
||||
// → 40·0.5 + 20·0.5 + 40·1 = 70 → empate exacto; menor load gana
|
||||
const A: CandidateInfo = { id: 1, name: "Senior", color: "#fff", role: "stylist", specialties: ["corte"], efficiency_score: 50, empWh: null };
|
||||
const B: CandidateInfo = { id: 2, name: "Junior", color: "#fff", role: "stylist", specialties: [], efficiency_score: 50, empWh: null };
|
||||
const busy = new Map<number, BusyWindow[]>([
|
||||
[1, [{ startMs: new Date(2026, 6, 27, 9, 0).getTime(), endMs: new Date(2026, 6, 27, 13, 0).getTime() }]],
|
||||
[2, []],
|
||||
]);
|
||||
const best = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs);
|
||||
assert.equal(best?.id, 2, "el junior libre gana el empate por menor loadBalance, NO por id menor");
|
||||
});
|
||||
|
||||
test("pickBestSlotEmployee: empate total (score+load) → decisión determinista y reproducible", () => {
|
||||
const bizWh = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "17:00" } }));
|
||||
const startMs = new Date(2026, 6, 27, 9, 0).getTime();
|
||||
const endMs = new Date(2026, 6, 27, 10, 0).getTime();
|
||||
// Dos candidatos idénticos salvo el id → mismo score y mismo loadBalance → hash decide.
|
||||
const A: CandidateInfo = { id: 10, name: "Diez", color: "#fff", role: "s", specialties: ["corte"], efficiency_score: 50, empWh: null };
|
||||
const B: CandidateInfo = { id: 20, name: "Veinte", color: "#fff", role: "s", specialties: ["corte"], efficiency_score: 50, empWh: null };
|
||||
const busy = new Map<number, BusyWindow[]>([[10, []], [20, []]]);
|
||||
const r1 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs);
|
||||
const r2 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs);
|
||||
assert.ok(r1 && (r1.id === 10 || r1.id === 20), "debe elegir uno de los dos candidatos");
|
||||
assert.equal(r1?.id, r2?.id, "misma entrada → misma decisión (hash determinista, reproducible)");
|
||||
});
|
||||
|
||||
test("BUSY_STATUSES: scheduled+completed sí cuentan; no_show NO (silla vacía)", () => {
|
||||
const s: string[] = [...BUSY_STATUSES];
|
||||
assert.ok(s.includes("scheduled"));
|
||||
assert.ok(s.includes("completed"));
|
||||
assert.equal(s.includes("no_show"), false);
|
||||
assert.equal(s.length, 2);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user