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:
AgendaPro Dev
2026-07-27 10:11:16 -06:00
parent 4227db0c8b
commit d796538fd9
57 changed files with 2477 additions and 322 deletions
+52 -11
View File
@@ -98,12 +98,15 @@ export function hasConflict(existing: BusyWindow[], startMs: number, endMs: numb
return existing.some((b) => overlaps(startMs, endMs, b.startMs, b.endMs));
}
/** score = 50·specialtyMatch + 30·efficiency + 20·(1loadBalance), todo en [0,1]. Rango 0..100. */
/** score = 40·specialtyMatch + 20·efficiency + 40·(1loadBalance), todo en [0,1]. Rango 0..100.
* Pesos 40/20/40: specialty sigue mandando en matches reales, pero loadBalance (40) ahora
* puede superar la ventaja estática de efficiency (20) → el especialista libre/menos cargado
* gana con más frecuencia (equidad, anti-burnout). */
export function scoreCandidate(s: ScoreInput): number {
const sm = clamp01(s.specialtyMatch);
const eff = clamp01(s.efficiency);
const inv = clamp01(1 - clamp01(s.loadBalance));
return 50 * sm + 30 * eff + 20 * inv;
return 40 * sm + 20 * eff + 40 * inv;
}
import type { DatabaseSync } from "node:sqlite";
@@ -148,18 +151,24 @@ export function getCandidates(db: DatabaseSync, businessId: number, serviceId: n
}));
}
/** Estados que ocupan la ventana de trabajo del empleado para load/availability.
* NOTA: `no_show` NO está — la silla quedó vacía, así que no cuenta contra la carga
* del empleado (además ayuda a los sobre-agendados a recuperarse). */
export const BUSY_STATUSES = ["scheduled", "completed"] as const;
export function getExistingBusy(db: DatabaseSync, employeeIds: number[], dateIso: string): Map<number, BusyWindow[]> {
const map = new Map<number, BusyWindow[]>();
if (employeeIds.length === 0) return map;
const startOfDay = `${dateIso}T00:00:00`;
const endOfDay = `${dateIso}T23:59:59`;
const statusPh = BUSY_STATUSES.map(() => "?").join(",");
const rows = db
.prepare(
`SELECT employee_id, start_at, end_at FROM appointments
WHERE employee_id IN (${employeeIds.map(() => "?").join(",")})
AND status IN ('scheduled','completed') AND start_at >= ? AND start_at <= ?`
AND status IN (${statusPh}) AND start_at >= ? AND start_at <= ?`
)
.all(...employeeIds, startOfDay, endOfDay) as any[];
.all(...employeeIds, ...BUSY_STATUSES, startOfDay, endOfDay) as any[];
for (const r of rows) {
const arr = map.get(r.employee_id) ?? [];
arr.push({ startMs: new Date(r.start_at).getTime(), endMs: new Date(r.end_at).getTime() });
@@ -174,7 +183,7 @@ export function isAvailable(db: DatabaseSync, employeeId: number, startMs: numbe
return !hasConflict(busy, startMs, endMs);
}
interface RankedCandidate { info: CandidateInfo; score: number; reasons: string[] }
interface RankedCandidate { info: CandidateInfo; score: number; loadBalance: number; reasons: string[] }
function rankOne(
c: CandidateInfo,
@@ -195,7 +204,38 @@ function rankOne(
if (sm >= 1) reasons.push("Especialidad coincidente");
if (c.efficiency_score >= 75) reasons.push("Alta eficiencia");
if (loadBalance <= 0.25) reasons.push("Buena disponibilidad");
return { info: c, score, reasons };
return { info: c, score, loadBalance, reasons };
}
/**
* Hash determinista (xmur3) → uint32. Sin deps. Misma entrada ⇒ misma salida en cualquier
* plataforma, para que el desempate por hash sea reproducible por input.
* Úselo para repartir empates sin sesgo por id/seniority. */
export function hashStr(str: string): number {
let h = 1779033703 ^ str.length;
for (let i = 0; i < str.length; i++) {
h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
h = Math.imul(h ^ (h >>> 16), 2246822507);
h = Math.imul(h ^ (h >>> 13), 3266489909);
return (h ^ (h >>> 16)) >>> 0;
}
/** Epsilon para considerar dos loadBalance iguales (ratio de ms → posible ruido float). */
const LOAD_EPSILON = 1e-9;
/**
* ¿`a` desplaza al actual `best`? Orden de desempate (anti-burnout, sin sesgo por id):
* 1) mayor score
* 2) (cerca de empate) menor loadBalance — gana el menos cargado
* 3) hash determinista de `${dateIso}|${startMs}|${empId}` — mismo slot ⇒ misma decisión,
* pero slots distintos reparten entre empleados (no siempre cae en la misma persona).
*/
function beats(a: RankedCandidate, best: RankedCandidate, dateIso: string, startMs: number): boolean {
if (a.score !== best.score) return a.score > best.score;
if (Math.abs(a.loadBalance - best.loadBalance) > LOAD_EPSILON) return a.loadBalance < best.loadBalance;
return hashStr(`${dateIso}|${startMs}|${a.info.id}`) < hashStr(`${dateIso}|${startMs}|${best.info.id}`);
}
/** Usado por el endpoint de slots: devuelve el mejor candidato libre para un slot concreto. */
@@ -208,7 +248,8 @@ export function pickBestSlotEmployee(
endMs: number
): { id: number; name: string } | null {
const date = new Date(startMs);
let best: { id: number; name: string; score: number } | null = null;
const dateIso = isoDateStr(date);
let best: RankedCandidate | null = null;
for (const c of candidates) {
const wh = getWorkingHoursForDate(c.empWh, bizWh, date);
if (!wh) continue;
@@ -216,11 +257,11 @@ export function pickBestSlotEmployee(
if (hasConflict(busyMap.get(c.id) ?? [], startMs, endMs)) continue;
const ranked = rankOne(c, busyMap.get(c.id) ?? [], wh, service);
if (!ranked) continue;
if (!best || ranked.score > best.score || (ranked.score === best.score && c.id < best.id)) {
best = { id: c.id, name: c.name, score: ranked.score };
if (!best || beats(ranked, best, dateIso, startMs)) {
best = ranked;
}
}
return best ? { id: best.id, name: best.name } : null;
return best ? { id: best.info.id, name: best.info.name } : null;
}
export interface AutoAssignResult {
@@ -253,7 +294,7 @@ export function autoAssign(db: DatabaseSync, ctx: AutoAssignCtx): AutoAssignResu
if (hasConflict(busyMap.get(c.id) ?? [], ctx.startMs, ctx.endMs)) continue;
const ranked = rankOne(c, busyMap.get(c.id) ?? [], wh, { name: ctx.serviceName, category: ctx.serviceCategory });
if (!ranked) continue;
if (!best || ranked.score > best.score || (ranked.score === best.score && c.id < best.info.id)) {
if (!best || beats(ranked, best, dateIso, ctx.startMs)) {
best = ranked;
}
}