Si deseas disponer de un entorno completo para hacer aplicaciones, necesitas tener resuelto cómo implementar «un calendario» para agenda de citas, reuniones, etc. He estado esperando que SVAR UI publique su alternativa, pero hasta este momento, no lo ha hecho y por ello he trabajado con más detalle la solución de FullCalendar que del entorno del que venimos (PHPRunner y WEB), es el más habitual y tenemos experiencia en su utilización.
Este tema fue tratado en la guía S-004 aunque en dicha guía tuve muchas dificultades en su uso, quizá por el enfoque que me ofreció la IA en ese momento y en esta versión es un ejemplo mucho más completo, con todas las funciones aplicables, y desde mi punto de vista, muy bien resueltas.
Objetivo
Disponer de un ejemplo de Calendario y de «Timeline» de Recursos, con la biblioteca de Jacascript FullCalendar. El ejemplo incluye:
- Vista de calendario de diario, semana, mes y año (multimeses) y de Recursos de diario, semana y mes.
- Ajuste del producto para el Español (fácil adaptación a cualquier otro idioma).
- Disponibilidad de Tooltip en la actividades.
- Acción de clic sobre una actividad y control sobre los datos de la actividad.
- Funcionamiento de la reactividad en el producto.
DEMO: https://fhumanes.com/fullcalendar-svelte/
Solución Técnica
Toda las librerías se han instalado con NPM, en su última versión.
Aunque sea básicamente, se ha utilizado todas las librerías para así facilitar el código necesario para referenciar a cada una de ellas.
No se ha probado la funcionalidad de modificación de fechas, rangos, etc., desplazando la actividad por los días del calendario, porque siempre he creído que esas operaciones dependen de lo que representa la acción y que el alta, mantenimiento y visualización de la actividad es mejor desarrollarla a medida del tipo de la acción.
Lo único extraño que he hecho es obtener los ficheros de CSS del producto, ya que en esta versión sólo se «entrega» los ficheros JS y en ellos están definidos los CSS por defecto. Al facilitar los ficheros «fullcalendar-XXX.css», entiendo que lo que facilito es la adaptación del producto, puesto que ahí consta la lista de los estilos que utiliza por defecto. También, otro aspecto tratado es que no se utiliza JQuery.
Algunos de los ficheros del ejemplo son:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- <link rel="stylesheet" href="./fullcalendar.css"> -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>fullcalendar-svelte</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
/* FuulCalendar 6.1.20 */
.fc {
--fc-border-color: #ddd;
--fc-page-bg-color: #fff;
--fc-neutral-bg-color: #f5f5f5;
--fc-neutral-text-color: #808080;
--fc-button-text-color: #fff;
--fc-button-bg-color: #2c3e50;
--fc-button-border-color: #2c3e50;
--fc-button-hover-bg-color: #1a252f;
--fc-button-hover-border-color: #1a252f;
--fc-button-active-bg-color: #1a252f;
--fc-button-active-border-color: #1a252f;
--fc-event-bg-color: #3788d8;
--fc-event-border-color: #3788d8;
--fc-event-text-color: #fff;
--fc-event-selected-overlay-color: rgba(0, 0, 0, 0.25);
--fc-more-link-bg-color: #d0d0d0;
--fc-more-link-text-color: inherit;
--fc-now-indicator-color: red;
}
.fc,
.fc *,
.fc *::before,
.fc *::after {
box-sizing: border-box;
}
.fc {
position: relative;
z-index: 1;
}
.fc table {
border-collapse: collapse;
border-spacing: 0;
font-size: 1em;
}
.fc th {
text-align: center;
padding: 0.5em;
}
.fc td {
vertical-align: top;
}
.fc .fc-scrollgrid {
border: 1px solid var(--fc-border-color);
}
.fc .fc-scrollgrid-section > * {
border-bottom: 1px solid var(--fc-border-color);
}
.fc .fc-scrollgrid-section-body > * {
border-right: 1px solid var(--fc-border-color);
}
.fc .fc-daygrid-day {
min-height: 100px;
}
.fc .fc-daygrid-day-top {
display: flex;
justify-content: space-between;
padding: 4px;
}
.fc .fc-daygrid-day-number {
padding: 2px 4px;
}
.fc .fc-daygrid-event {
margin: 2px 4px;
padding: 2px 4px;
border-radius: 3px;
cursor: pointer;
background-color: var(--fc-event-bg-color);
color: var(--fc-event-text-color);
border: 1px solid var(--fc-event-border-color);
}
.fc .fc-daygrid-event:hover {
filter: brightness(0.9);
}
.fc .fc-event-main {
position: relative;
z-index: 2;
pointer-events: auto;
}
.fc .fc-daygrid-event-harness {
overflow: visible;
}
.fc .fc-highlight {
background: rgba(188, 232, 241, 0.3);
}
.fc .fc-button {
padding: 0.4em 0.65em;
border-radius: 4px;
border: 1px solid var(--fc-button-border-color);
background-color: var(--fc-button-bg-color);
color: var(--fc-button-text-color);
cursor: pointer;
}
.fc .fc-button:hover {
background-color: var(--fc-button-hover-bg-color);
}
.fc .fc-button:active {
background-color: var(--fc-button-active-bg-color);
}
.fc-tooltip {
position: absolute;
background: #333;
color: white;
padding: 6px 10px;
border-radius: 4px;
font-size: 12px;
pointer-events: none;
z-index: 999999; /* subir por encima de FullCalendar */
white-space: nowrap;
}
/* FullCalendar 6.1.20 */
.fc-daygrid {
width: 100%;
}
.fc-daygrid-day-frame {
min-height: 100px;
position: relative;
}
.fc-daygrid-day-events {
margin-top: 4px;
}
.fc-daygrid-event {
display: block;
font-size: 0.85em;
line-height: 1.2;
}
.fc-daygrid-event-dot {
display: inline-block;
width: 8px;
height: 8px;
background-color: var(--fc-event-bg-color);
border-radius: 50%;
margin-right: 4px;
}
.fc-daygrid-more-link {
display: block;
margin: 2px 4px;
font-size: 0.8em;
color: var(--fc-more-link-text-color);
background: var(--fc-more-link-bg-color);
padding: 2px 4px;
border-radius: 3px;
cursor: pointer;
}
/* FullCalendar 6.1.20 */
.fc-timegrid {
width: 100%;
}
.fc-timegrid-slot {
height: 40px;
border-bottom: 1px solid var(--fc-border-color);
}
.fc-timegrid-axis {
width: 60px;
text-align: right;
padding-right: 6px;
color: var(--fc-neutral-text-color);
}
.fc-timegrid-event {
position: absolute;
z-index: 3;
padding: 4px 6px;
border-radius: 3px;
background-color: var(--fc-event-bg-color);
color: var(--fc-event-text-color);
border: 1px solid var(--fc-event-border-color);
cursor: pointer;
}
.fc-timegrid-event:hover {
filter: brightness(0.9);
}
.fc-timegrid-now-indicator-line {
border-top: 2px solid var(--fc-now-indicator-color);
}
.fc-timegrid-now-indicator-arrow {
width: 0;
height: 0;
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
border-right: 6px solid var(--fc-now-indicator-color);
position: absolute;
left: -6px;
}
<script>
import { onMount } from "svelte";
import { Calendar } from "@fullcalendar/core";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction";
import multiMonthPlugin from "@fullcalendar/multimonth";
import resourcePlugin from "@fullcalendar/resource";
import resourceDayGridPlugin from "@fullcalendar/resource-daygrid";
import resourceTimeGridPlugin from "@fullcalendar/resource-timegrid";
import resourceTimelinePlugin from "@fullcalendar/resource-timeline";
import "./lib/fullcalendar-core.css";
import "./lib/fullcalendar-daygrid.css";
import "./lib/fullcalendar-timegrid.css";
let calendar;
let calendarEl;
let currentView = $state("month");
let visibleRange = $state("");
let resources = [
{ id: "sala1", title: "Sala 1" },
{ id: "sala2", title: "Sala 2" },
{ id: "sala3", title: "Sala 3" }
];
let events = [
{ id: "1", title: "Reunión", start: "2026-02-05T10:00:00", resourceId: "sala1" },
{ id: "2", title: "Cliente", start: "2026-02-10T12:00:00", resourceId: "sala2" }
];
function updateVisibleRange(start, end) {
const opcionesDia = { weekday: "long", day: "numeric", month: "long", year: "numeric" };
const opcionesMes = { month: "long", year: "numeric" };
const opcionesCorto = { day: "numeric", month: "short", year: "numeric" };
if (currentView === "day") {
visibleRange = start.toLocaleDateString("es-ES", opcionesDia);
}
if (currentView === "week") {
visibleRange =
`${start.toLocaleDateString("es-ES", opcionesCorto)} – ` +
`${end.toLocaleDateString("es-ES", opcionesCorto)}`;
}
if (currentView === "month") {
visibleRange = start.toLocaleDateString("es-ES", opcionesMes);
}
if (currentView === "year") {
visibleRange = start.getFullYear().toString();
}
}
function createCalendar() {
if (calendar) calendar.destroy();
calendar = new Calendar(calendarEl, {
plugins: [
dayGridPlugin,
timeGridPlugin,
interactionPlugin,
multiMonthPlugin,
resourcePlugin,
resourceDayGridPlugin,
resourceTimeGridPlugin,
resourceTimelinePlugin
],
locale: "es",
firstDay: 1,
initialView:
currentView === "day"
? "timeGridDay"
: currentView === "week"
? "timeGridWeek"
: currentView === "month"
? "dayGridMonth"
: "multiMonthYear",
resources,
events,
datesSet() {
const start = calendar.view.currentStart;
const end = calendar.view.currentEnd;
updateVisibleRange(start, end);
},
eventClick(info) {
alert("Has hecho clic en: " + info.event.title);
},
eventMouseEnter(info) {
const el = info.el;
const tooltip = document.createElement("div");
tooltip.className = "fc-tooltip";
tooltip.innerHTML = `
<b>${info.event.title}</b><br>
Inicio: ${info.event.start.toLocaleString("es-ES")}<br>
${info.event.end ? "Fin: " + info.event.end.toLocaleString("es-ES") : ""}
`;
document.body.appendChild(tooltip);
function move(e) {
tooltip.style.left = e.pageX + 10 + "px";
tooltip.style.top = e.pageY + 10 + "px";
}
el.addEventListener("mousemove", move);
el.addEventListener("mouseleave", () => {
tooltip.remove();
el.removeEventListener("mousemove", move);
});
}
});
calendar.render();
}
function changeView(view) {
currentView = view;
const fcView =
view === "day"
? "timeGridDay"
: view === "week"
? "timeGridWeek"
: view === "month"
? "dayGridMonth"
: "multiMonthYear";
calendar.changeView(fcView);
}
function next() {
calendar.next();
}
function prev() {
calendar.prev();
}
onMount(createCalendar);
</script>
<div class="controls">
<button onclick={() => changeView("day")}>Día</button>
<button onclick={() => changeView("week")}>Semana</button>
<button onclick={() => changeView("month")}>Mes</button>
<button onclick={() => changeView("year")}>Año</button>
<button onclick={prev}>Anterior</button>
<button onclick={next}>Siguiente</button>
<button onclick={() => calendar.changeView("resourceTimeGridDay")}>Recursos Día</button>
<button onclick={() => calendar.changeView("resourceTimeGridWeek")}>Recursos Semana</button>
<button onclick={() => calendar.changeView("resourceTimelineWeek")}>Timeline Recursos</button>
</div>
<!-- <div class="range">{visibleRange}</div> -->
<div bind:this={calendarEl} style="width:100%; height:700px;"></div>
<style>
.controls {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
/*
.range {
font-weight: bold;
margin-bottom: 1rem;
}
*/
</style>
Espero que os sea útil y que veaís lo sencillo y práctico que han dejado a este veterano producto integrado en todos esto nuevos entornos de desarrollos de Front-End en Javascript.
Como siempre, os dejo los fuentes para que los descarguéis y probéis en vuestros equipos.
