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
+46
View File
@@ -0,0 +1,46 @@
// server/lib/metrics.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { cancelRate, noShowRate, completionRate } from "./metrics.ts";
test("cancelRate: basic ratio rounded to 1 decimal", () => {
assert.equal(cancelRate(1, 4), 25);
assert.equal(cancelRate(3, 7), 42.9);
assert.equal(cancelRate(0, 10), 0);
});
test("cancelRate: zero total → 0 (no NaN)", () => {
assert.equal(cancelRate(0, 0), 0);
assert.equal(cancelRate(5, 0), 0);
assert.equal(cancelRate(5, -3), 0);
});
test("cancelRate: all cancelled → 100", () => {
assert.equal(cancelRate(8, 8), 100);
});
test("noShowRate: basic ratio rounded to 1 decimal", () => {
assert.equal(noShowRate(1, 10), 10);
assert.equal(noShowRate(1, 3), 33.3);
assert.equal(noShowRate(2, 9), 22.2);
});
test("noShowRate: zero total → 0", () => {
assert.equal(noShowRate(0, 0), 0);
assert.equal(noShowRate(3, 0), 0);
});
test("completionRate: basic ratio rounded to whole percent", () => {
assert.equal(completionRate(7, 10), 70);
assert.equal(completionRate(1, 3), 33);
assert.equal(completionRate(2, 3), 67);
});
test("completionRate: zero total → 0", () => {
assert.equal(completionRate(0, 0), 0);
assert.equal(completionRate(5, 0), 0);
});
test("completionRate: all completed → 100", () => {
assert.equal(completionRate(10, 10), 100);
});
+20
View File
@@ -0,0 +1,20 @@
// server/lib/metrics.ts
// Pure KPI metric definitions. All return 0 when there is no data (no divide-by-zero).
/** Cancellation rate: cancelled / total, rounded to 1 decimal (percent). */
export function cancelRate(cancelled: number, total: number): number {
if (total <= 0) return 0;
return Math.round((cancelled / total) * 1000) / 10;
}
/** No-show rate: noShow / total, rounded to 1 decimal (percent). */
export function noShowRate(noShow: number, total: number): number {
if (total <= 0) return 0;
return Math.round((noShow / total) * 1000) / 10;
}
/** Completion rate: completed / total, rounded to the nearest whole percent. */
export function completionRate(completed: number, total: number): number {
if (total <= 0) return 0;
return Math.round((completed / total) * 100);
}
+57 -3
View File
@@ -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);
});
+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;
}
}
+81
View File
@@ -0,0 +1,81 @@
// server/lib/time.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import {
bizDateISO,
bizTodayISO,
bizDayBoundsSqlite,
bizDayBoundsIso,
wallToUtcISO,
wallToUtcDate,
toSqliteUtc,
toIsoUtc,
} from "./time.ts";
const MX = "America/Mexico_City"; // UTC-6 (no DST since 2023)
const BA = "America/Buenos_Aires"; // UTC-3
test("bizTodayISO: UTC instant just past midnight (00:30Z) is still the previous day in MX", () => {
// 2026-07-27T00:30:00Z == 2026-07-26T18:30 local → still the 26th
assert.equal(bizTodayISO(MX, new Date("2026-07-27T00:30:00Z")), "2026-07-26");
});
test("bizTodayISO: late afternoon UTC (17:00Z) is same biz day in MX", () => {
assert.equal(bizTodayISO(MX, new Date("2026-07-26T17:00:00Z")), "2026-07-26");
});
test("bizDateISO: Buenos Aires UTC-3 midday stays same day", () => {
// 17:00Z == 14:00 local
assert.equal(bizDateISO(new Date("2026-07-26T17:00:00Z"), BA), "2026-07-26");
});
test("bizDateISO: Buenos Aires UTC-3 evening stays same day", () => {
// 23:00Z == 20:00 local
assert.equal(bizDateISO(new Date("2026-07-26T23:00:00Z"), BA), "2026-07-26");
});
test("bizDateISO: UTC instant rolls to next local day", () => {
// 2026-07-27T05:00:00Z == 2026-07-26T23:00 in MX → still 26th
assert.equal(bizDateISO(new Date("2026-07-27T05:00:00Z"), MX), "2026-07-26");
// 2026-07-27T06:30:00Z == 2026-07-27T00:30 in MX → now 27th
assert.equal(bizDateISO(new Date("2026-07-27T06:30:00Z"), MX), "2026-07-27");
});
test("wallToUtcISO: MX wall 18:00 → UTC next-day 00:00", () => {
assert.equal(wallToUtcISO(MX, 2026, 7, 26, 18, 0, 0), "2026-07-27T00:00:00.000Z");
});
test("wallToUtcISO: MX wall 00:00 → UTC 06:00 same date", () => {
assert.equal(wallToUtcISO(MX, 2026, 7, 26, 0, 0, 0), "2026-07-26T06:00:00.000Z");
});
test("wallToUtcDate: round-trip via bizDateISO stays on the wall day", () => {
const utc = wallToUtcDate(MX, 2026, 7, 26, 18, 0, 0);
assert.equal(bizDateISO(utc, MX), "2026-07-26");
});
test("bizDayBoundsSqlite: today bounds capture the evening rush (UTC-6)", () => {
// "now" is 2026-07-26T23:30:00Z == 2026-07-26T17:30 local MX → biz day 2026-07-26
const b = bizDayBoundsSqlite(MX, 0, new Date("2026-07-26T23:30:00Z"));
// biz-local midnight 2026-07-26 in MX == 06:00Z; end of day 23:59:59 == 2026-07-27T05:59:59Z
assert.equal(b.start, "2026-07-26 06:00:00");
assert.equal(b.end, "2026-07-27 05:59:59");
});
test("bizDayBoundsIso: ISO-Z bounds for start_at comparisons", () => {
const b = bizDayBoundsIso(MX, 0, new Date("2026-07-26T23:30:00Z"));
assert.equal(b.start, "2026-07-26T06:00:00Z");
assert.equal(b.end, "2026-07-27T05:59:59Z");
});
test("bizDayBoundsSqlite: negative offset reaches into the past", () => {
// 30 days before biz day 2026-07-26 → 2026-06-26, midnight MX == 06:00Z
const b = bizDayBoundsSqlite(MX, -30, new Date("2026-07-26T23:30:00Z"));
assert.equal(b.start, "2026-06-26 06:00:00");
});
test("toSqliteUtc / toIsoUtc format helpers", () => {
const d = new Date("2026-07-27T00:00:00.000Z");
assert.equal(toSqliteUtc(d), "2026-07-27 00:00:00");
assert.equal(toIsoUtc(d), "2026-07-27T00:00:00Z");
});
+117
View File
@@ -0,0 +1,117 @@
// server/lib/time.ts
// Pure timezone helpers for business-local ("biz") date math.
// Mexico is UTC-6, so SQLite date('now') (= UTC) misattributes the evening rush.
// All functions are deterministic given an explicit instant (default = new Date()).
/** Returns the calendar date (YYYY-MM-DD) of the given instant in the given IANA tz. */
export function bizDateISO(instant: Date, tz: string): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: tz || "UTC",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(instant); // en-CA yields "YYYY-MM-DD"
}
/** Business "today" (YYYY-MM-DD) for a tz, at the given instant (default: server now). */
export function bizTodayISO(tz: string, now: Date = new Date()): string {
return bizDateISO(now, tz);
}
/** tz offset (in minutes) of the given instant, east of UTC positive. */
function tzOffsetMinutes(instant: Date, tz: string): number {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz || "UTC",
timeZoneName: "longOffset",
}).formatToParts(instant);
const off = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT+00:00";
// e.g. "GMT-06:00", "GMT+05:30", "GMT+00:00", or bare "GMT" for UTC
const m = off.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/);
if (!m) return 0; // bare "GMT" → UTC
const sign = m[1] === "-" ? -1 : 1;
const h = parseInt(m[2], 10);
const min = m[3] ? parseInt(m[3], 10) : 0;
return sign * (h * 60 + min);
}
/**
* Convert a business-local wall-clock (year, month, day, hh, mm, ss) in `tz` to a UTC Date.
* We first treat the wall-clock as if it were UTC, read the tz offset at that instant,
* then subtract the offset: a west tz (negative offset) is "behind" UTC, so the same
* wall-clock happens later in UTC → UTC = wall offset.
*/
export function wallToUtcDate(
tz: string,
y: number,
mo: number,
d: number,
hh: number,
mm: number,
ss: number
): Date {
const guess = new Date(Date.UTC(y, mo - 1, d, hh, mm, ss));
const offsetMin = tzOffsetMinutes(guess, tz);
return new Date(guess.getTime() - offsetMin * 60000);
}
/** Business-day UTC bounds (start = local 00:00:00, end = local 23:59:59) for today +/- offset. */
export function bizDayBounds(
tz: string,
dayOffset = 0,
now: Date = new Date()
): { start: Date; end: Date } {
const today = bizDateISO(now, tz);
const [y, m, d] = today.split("-").map(Number);
const base = new Date(Date.UTC(y, m - 1, d));
base.setUTCDate(base.getUTCDate() + dayOffset);
const ty = base.getUTCFullYear();
const tm = base.getUTCMonth() + 1;
const td = base.getUTCDate();
return {
start: wallToUtcDate(tz, ty, tm, td, 0, 0, 0),
end: wallToUtcDate(tz, ty, tm, td, 23, 59, 59),
};
}
/** Format a UTC instant as SQLite canonical "YYYY-MM-DD HH:MM:SS" (matches datetime() output). */
export function toSqliteUtc(d: Date): string {
return d.toISOString().slice(0, 19).replace("T", " ");
}
/** Format a UTC instant as ISO "YYYY-MM-DDTHH:MM:SSZ" (matches the stored start_at format). */
export function toIsoUtc(d: Date): string {
return d.toISOString().slice(0, 19) + "Z";
}
/** Business-day bounds in SQLite canonical format — pair with `datetime(column) >= ?`. */
export function bizDayBoundsSqlite(
tz: string,
dayOffset = 0,
now: Date = new Date()
): { start: string; end: string } {
const b = bizDayBounds(tz, dayOffset, now);
return { start: toSqliteUtc(b.start), end: toSqliteUtc(b.end) };
}
/** Business-day bounds in ISO-Z format — pair with raw `start_at >= ?`. */
export function bizDayBoundsIso(
tz: string,
dayOffset = 0,
now: Date = new Date()
): { start: string; end: string } {
const b = bizDayBounds(tz, dayOffset, now);
return { start: toIsoUtc(b.start), end: toIsoUtc(b.end) };
}
/** Convenience: convert a business-local wall-clock to a UTC ISO string. */
export function wallToUtcISO(
tz: string,
y: number,
mo: number,
d: number,
hh: number,
mm: number,
ss: number
): string {
return wallToUtcDate(tz, y, mo, d, hh, mm, ss).toISOString();
}