Calendar Refinement

This commit is contained in:
2026-03-11 13:31:49 +01:00
parent 94215fdd65
commit 6162ff595f
8 changed files with 371 additions and 49 deletions

View File

@@ -289,21 +289,27 @@ Render a basic monthly calendar that links each day to canonical chronological p
```
{{calendar>}}
{{calendar>2024-10}}
{{calendar>2026-03&size=small}}
{{calendar>2026-03&size=large&show_times=0}}
```
Notes:
- `{{calendar>}}` renders the current month.
- `{{calendar>YYYY-MM}}` renders a specific month.
- `size=large|small` controls the widget layout and defaults to `large`.
- `show_times=1|0` controls inline event times in `large` mode and defaults to `1`.
- Day links target `chronological:YYYY:MM:DD`.
- Header month/year links target `chronological:YYYY:MM` and `chronological:YYYY`.
- Prev/next month buttons update the widget in-place without a full page reload.
- Month switches fetch server-rendered widget HTML via AJAX and replace only the widget node.
- Calendar output is marked as non-cacheable to keep missing/existing link styling and
current-day highlighting up to date.
- Each day cell can show colored corner triangles for slots that have events on that day.
Indicator placement is configured per slot via the `Display` setting.
Indicator colors are taken from the slot's configured color.
- Small mode keeps the compact day-number-plus-indicator layout.
- Large mode renders inline day events in the month cells and suppresses the corner indicators.
- Only slots whose `Display` setting is not `None` participate in widget visibility.
- Indicator placement in small mode is configured per slot via the `Display` setting.
- Slot colors are reused for both indicators and inline event accents.
### 0.4) Virtual chronological day pages

View File

@@ -155,6 +155,8 @@ class action_plugin_luxtools extends ActionPlugin
if ($baseNs === '') {
$baseNs = 'chronological';
}
$size = ChronologicalCalendarWidget::normalizeSize((string)$INPUT->str('size'));
$showTimes = ChronologicalCalendarWidget::normalizeShowTimes($INPUT->str('show_times'));
if (!ChronologicalCalendarWidget::isValidMonth($year, $month)) {
http_status(400);
@@ -164,12 +166,21 @@ class action_plugin_luxtools extends ActionPlugin
$this->sendNoStoreHeaders();
// Load slot indicators and colors for the calendar widget
$slots = CalendarSlot::loadEnabled($this);
$indicators = CalendarService::monthIndicators($slots, $year, $month);
$widgetSlots = CalendarSlot::filterWidgetVisible($slots);
$indicators = [];
$dayEvents = [];
if ($size === 'large') {
$widgetData = CalendarService::monthWidgetData($widgetSlots, $year, $month);
$indicators = $widgetData['indicators'];
$dayEvents = $widgetData['events'];
} else {
$indicators = CalendarService::monthIndicators($widgetSlots, $year, $month);
}
$slotColors = [];
$slotDisplays = [];
foreach ($slots as $slot) {
foreach ($widgetSlots as $slot) {
$color = $slot->getColor();
if ($color !== '') {
$slotColors[$slot->getKey()] = $color;
@@ -177,7 +188,11 @@ class action_plugin_luxtools extends ActionPlugin
$slotDisplays[$slot->getKey()] = $slot->getDisplay();
}
$html = ChronologicalCalendarWidget::render($year, $month, $baseNs, $indicators, $slotColors, $slotDisplays);
$html = ChronologicalCalendarWidget::render($year, $month, $baseNs, $indicators, $slotColors, $slotDisplays, [
'size' => $size,
'showTimes' => $showTimes,
'dayEvents' => $dayEvents,
]);
if ($html === '') {
http_status(500);
echo 'Calendar rendering failed';

View File

@@ -54,7 +54,12 @@
return 'luxtools.calendar.month.' + baseNs;
}
function shouldPersistCalendarMonth(calendar) {
return (calendar.getAttribute('data-luxtools-size') || 'large') === 'small';
}
function readSavedCalendarMonth(calendar) {
if (!shouldPersistCalendarMonth(calendar)) return null;
if (!window.localStorage) return null;
try {
@@ -73,6 +78,7 @@
}
function saveCalendarMonth(calendar) {
if (!shouldPersistCalendarMonth(calendar)) return;
if (!window.localStorage) return;
var year = parseInt(calendar.getAttribute('data-current-year') || '', 10);
@@ -90,6 +96,7 @@
}
function clearSavedCalendarMonth(calendar) {
if (!shouldPersistCalendarMonth(calendar)) return;
if (!window.localStorage) return;
try {
@@ -104,11 +111,15 @@
if (!ajaxUrl) return Promise.reject(new Error('Missing calendar ajax url'));
var baseNs = calendar.getAttribute('data-base-ns') || 'chronological';
var size = calendar.getAttribute('data-luxtools-size') || 'large';
var showTimes = calendar.getAttribute('data-luxtools-show-times') || '1';
var params = new URLSearchParams({
call: 'luxtools_calendar_month',
year: String(year),
month: String(month),
base: baseNs
base: baseNs,
size: size,
show_times: showTimes
});
var url = ajaxUrl + (ajaxUrl.indexOf('?') >= 0 ? '&' : '?') + params.toString();
@@ -212,6 +223,7 @@
}
function restoreCalendarMonth(calendar) {
if (!shouldPersistCalendarMonth(calendar)) return;
var saved = readSavedCalendarMonth(calendar);
if (!saved) return;

View File

@@ -199,13 +199,11 @@ class CalendarService
for ($day = 1; $day <= $daysInMonth; $day++) {
$dateIso = sprintf('%04d-%02d-%02d', $year, $month, $day);
$events = self::collectFromCalendar($expanded, $slot->getKey(), $dateIso);
$cacheKey = $slot->getKey() . '|' . $dateIso;
self::$dayCache[$cacheKey] = $events;
if ($events !== []) {
$indicators[$dateIso][] = $slot->getKey();
// Pre-populate the day cache
$cacheKey = $slot->getKey() . '|' . $dateIso;
if (!isset(self::$dayCache[$cacheKey])) {
self::$dayCache[$cacheKey] = $events;
}
}
}
} catch (Throwable $e) {
@@ -216,6 +214,37 @@ class CalendarService
return $indicators;
}
/**
* Prepare month data for the calendar widget in one pass.
*
* Uses monthIndicators() to warm the per-slot day cache, then reuses the
* normalized events already cached for each day.
*
* @param CalendarSlot[] $slots
* @param int $year
* @param int $month
* @return array{indicators: array<string,string[]>, events: array<string,CalendarEvent[]>}
*/
public static function monthWidgetData(array $slots, int $year, int $month): array
{
$indicators = self::monthIndicators($slots, $year, $month);
$eventsByDate = [];
$daysInMonth = (int)date('t', mktime(0, 0, 0, $month, 1, $year));
for ($day = 1; $day <= $daysInMonth; $day++) {
$dateIso = sprintf('%04d-%02d-%02d', $year, $month, $day);
$events = self::eventsForDate($slots, $dateIso);
if ($events !== []) {
$eventsByDate[$dateIso] = $events;
}
}
return [
'indicators' => $indicators,
'events' => $eventsByDate,
];
}
/**
* Read and parse an ICS file, caching the parsed VCalendar per file path.
*

View File

@@ -122,6 +122,16 @@ class CalendarSlot
return $this->display !== 'none';
}
/**
* Whether this slot should participate in calendar widget visibility.
*
* @return bool
*/
public function isVisibleInWidget(): bool
{
return $this->shouldDisplayIndicator();
}
/**
* A slot is enabled if it has a local file path or a CalDAV URL.
*
@@ -190,6 +200,19 @@ class CalendarSlot
});
}
/**
* Keep only slots that should appear in the calendar widget.
*
* @param CalendarSlot[] $slots
* @return CalendarSlot[]
*/
public static function filterWidgetVisible(array $slots): array
{
return array_filter($slots, static function (CalendarSlot $slot): bool {
return $slot->isVisibleInWidget();
});
}
protected static function normalizeIndicatorDisplay(string $display): string
{
$display = strtolower(trim($display));

View File

@@ -7,6 +7,9 @@ namespace dokuwiki\plugin\luxtools;
*/
class ChronologicalCalendarWidget
{
/** @var int Maximum number of inline events shown per day cell in large mode */
protected const MAX_INLINE_EVENTS = 3;
/**
* Render full calendar widget HTML for one month.
*
@@ -16,6 +19,7 @@ class ChronologicalCalendarWidget
* @param array<string,string[]> $indicators date => [slotKey, ...] from CalendarService::monthIndicators()
* @param array<string,string> $slotColors slotKey => CSS color
* @param array<string,string> $slotDisplays slotKey => configured indicator position
* @param array{size?:string,showTimes?:bool,dayEvents?:array<string,CalendarEvent[]>} $options
* @return string
*/
public static function render(
@@ -24,10 +28,15 @@ class ChronologicalCalendarWidget
string $baseNs = 'chronological',
array $indicators = [],
array $slotColors = [],
array $slotDisplays = []
array $slotDisplays = [],
array $options = []
): string {
if (!self::isValidMonth($year, $month)) return '';
$size = self::normalizeSize((string)($options['size'] ?? 'large'));
$showTimes = (bool)($options['showTimes'] ?? true);
$dayEvents = is_array($options['dayEvents'] ?? null) ? $options['dayEvents'] : [];
$firstDayTs = mktime(0, 0, 0, $month, 1, $year);
$daysInMonth = (int)date('t', $firstDayTs);
$firstWeekday = (int)date('N', $firstDayTs); // 1..7 (Mon..Sun)
@@ -59,10 +68,12 @@ class ChronologicalCalendarWidget
$yearUrlTemplate = $dayUrlTemplate;
$ajaxUrl = defined('DOKU_BASE') ? (string)DOKU_BASE . 'lib/exe/ajax.php' : 'lib/exe/ajax.php';
$html = '<div class="luxtools-plugin luxtools-calendar" data-luxtools-calendar="1"'
$html = '<div class="luxtools-plugin luxtools-calendar luxtools-calendar-size-' . hsc($size) . '" data-luxtools-calendar="1"'
. ' data-base-ns="' . hsc($baseNs) . '"'
. ' data-current-year="' . hsc((string)$year) . '"'
. ' data-current-month="' . hsc(sprintf('%02d', $month)) . '"'
. ' data-luxtools-size="' . hsc($size) . '"'
. ' data-luxtools-show-times="' . ($showTimes ? '1' : '0') . '"'
. ' data-day-url-template="' . hsc($dayUrlTemplate) . '"'
. ' data-month-url-template="' . hsc($monthUrlTemplate) . '"'
. ' data-year-url-template="' . hsc($yearUrlTemplate) . '"'
@@ -114,12 +125,16 @@ class ChronologicalCalendarWidget
} else {
$date = sprintf('%04d-%02d-%02d', $year, $month, $dayNumber);
$dayId = ChronoID::dateToDayId($date, $baseNs);
$events = $dayEvents[$date] ?? [];
$classes = 'luxtools-calendar-day';
if ($events !== []) {
$classes .= ' luxtools-calendar-day-has-events';
}
$html .= '<td class="' . hsc($classes) . '" data-luxtools-date="' . hsc($date) . '">';
// Render slot indicators if any
if ($size === 'small') {
$dayIndicators = $indicators[$date] ?? [];
if ($dayIndicators !== []) {
$indicatorHtml = '';
@@ -137,10 +152,12 @@ class ChronologicalCalendarWidget
}
}
if ($dayId !== null && function_exists('html_wikilink')) {
$html .= (string)html_wikilink($dayId, (string)$dayNumber);
$html .= self::renderDayLink($dayId, (string)$dayNumber);
} else {
$html .= hsc((string)$dayNumber);
$html .= '<div class="luxtools-calendar-day-frame">';
$html .= '<div class="luxtools-calendar-day-number">' . self::renderDayLink($dayId, (string)$dayNumber) . '</div>';
$html .= self::renderInlineEvents($events, $slotColors, $showTimes);
$html .= '</div>';
}
$html .= '</td>';
}
@@ -155,6 +172,26 @@ class ChronologicalCalendarWidget
return $html;
}
/**
* @param string $size
* @return string
*/
public static function normalizeSize(string $size): string
{
$size = strtolower(trim($size));
return $size === 'small' ? 'small' : 'large';
}
/**
* @param string|null $value
* @return bool
*/
public static function normalizeShowTimes(?string $value): bool
{
if ($value === null) return true;
return trim($value) !== '0';
}
/**
* @param int $year
* @param int $month
@@ -166,4 +203,80 @@ class ChronologicalCalendarWidget
if ($month < 1 || $month > 12) return false;
return true;
}
/**
* @param string|null $dayId
* @param string $label
* @return string
*/
protected static function renderDayLink(?string $dayId, string $label): string
{
if ($dayId !== null && function_exists('html_wikilink')) {
return (string)html_wikilink($dayId, $label);
}
return hsc($label);
}
/**
* @param CalendarEvent[] $events
* @param array<string,string> $slotColors
* @param bool $showTimes
* @return string
*/
protected static function renderInlineEvents(array $events, array $slotColors, bool $showTimes): string
{
if ($events === []) {
return '<div class="luxtools-calendar-day-events"></div>';
}
$html = '<div class="luxtools-calendar-day-events"><ul class="luxtools-calendar-event-list">';
$visibleCount = min(count($events), self::MAX_INLINE_EVENTS);
for ($index = 0; $index < $visibleCount; $index++) {
$event = $events[$index];
$color = $slotColors[$event->slotKey] ?? '';
$style = $color !== '' ? ' style="--luxtools-slot-color:' . hsc($color) . '"' : '';
$dataAttrs = self::renderEventDataAttributes($event);
$html .= '<li class="luxtools-calendar-event"' . $style . $dataAttrs . '>';
if ($showTimes && !$event->allDay && $event->time !== '') {
$html .= '<span class="luxtools-calendar-event-time">' . hsc($event->time) . '</span>';
}
$html .= '<span class="luxtools-calendar-event-title">' . hsc($event->summary) . '</span>';
$html .= '</li>';
}
$remaining = count($events) - $visibleCount;
if ($remaining > 0) {
$html .= '<li class="luxtools-calendar-event luxtools-calendar-event-more">+' . hsc((string)$remaining) . ' more</li>';
}
$html .= '</ul></div>';
return $html;
}
/**
* @param CalendarEvent $event
* @return string
*/
protected static function renderEventDataAttributes(CalendarEvent $event): string
{
$attrs = ' data-luxtools-event="1"';
$attrs .= ' data-event-summary="' . hsc($event->summary) . '"';
$attrs .= ' data-event-start="' . hsc($event->startIso) . '"';
if ($event->endIso !== '') {
$attrs .= ' data-event-end="' . hsc($event->endIso) . '"';
}
if ($event->location !== '') {
$attrs .= ' data-event-location="' . hsc($event->location) . '"';
}
if ($event->description !== '') {
$attrs .= ' data-event-description="' . hsc($event->description) . '"';
}
$attrs .= ' data-event-allday="' . ($event->allDay ? '1' : '0') . '"';
$attrs .= ' data-event-slot="' . hsc($event->slotKey) . '"';
return $attrs;
}
}

131
style.css
View File

@@ -599,7 +599,8 @@ div.luxtools-calendar td.luxtools-calendar-day-today {
background-color: @ini_highlight;
}
div.luxtools-calendar td.luxtools-calendar-day a {
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > a,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a {
display: flex;
align-items: center;
justify-content: center;
@@ -612,27 +613,33 @@ div.luxtools-calendar td.luxtools-calendar-day a {
padding: 0.1em 0;
}
div.luxtools-calendar td.luxtools-calendar-day a.wikilink2:link,
div.luxtools-calendar td.luxtools-calendar-day a.wikilink2:visited {
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > a.wikilink2:link,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > a.wikilink2:visited,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a.wikilink2:link,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a.wikilink2:visited {
color: @ini_missing;
border-bottom: 0;
}
div.luxtools-calendar td.luxtools-calendar-day a:hover,
div.luxtools-calendar td.luxtools-calendar-day a:focus,
div.luxtools-calendar td.luxtools-calendar-day a:active,
div.luxtools-calendar td.luxtools-calendar-day a:visited {
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > a:hover,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > a:focus,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > a:active,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > a:visited,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a:hover,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a:focus,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a:active,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a:visited {
text-decoration: none;
border-bottom: 0;
box-shadow: none;
}
div.luxtools-calendar td.luxtools-calendar-day span.curid > a,
div.luxtools-calendar td.luxtools-calendar-day span.curid > a:visited,
div.luxtools-calendar td.luxtools-calendar-day span.curid > a:hover,
div.luxtools-calendar td.luxtools-calendar-day span.curid > a:focus,
div.luxtools-calendar td.luxtools-calendar-day span.curid > a:active {
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a:visited,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a:hover,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a:focus,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a:active {
font-weight: bold;
text-decoration: underline;
border-bottom: 0;
@@ -657,6 +664,106 @@ div.luxtools-calendar td.luxtools-calendar-day {
position: relative;
}
div.luxtools-calendar.luxtools-calendar-size-large table.luxtools-calendar-table td {
text-align: left;
vertical-align: top;
}
div.luxtools-calendar.luxtools-calendar-size-large td.luxtools-calendar-day {
height: 8.25em;
}
div.luxtools-calendar.luxtools-calendar-size-large td.luxtools-calendar-day-empty {
height: 8.25em;
}
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-frame {
min-height: 8.25em;
padding: 0.35em 0.4em 0.4em 0.4em;
box-sizing: border-box;
}
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-number {
text-align: right;
margin-bottom: 0.25em;
line-height: 1.1;
}
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-number > a,
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-number > span.curid > a,
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-number span.curid > a {
display: inline;
min-height: 0;
padding: 0;
background: transparent;
text-decoration: none;
border-bottom: 0;
box-shadow: none;
font-weight: bold;
}
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-number > a.wikilink2:link,
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-number > a.wikilink2:visited,
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-number span.curid > a.wikilink2:link,
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-number span.curid > a.wikilink2:visited {
color: @ini_missing;
}
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-events {
overflow: hidden;
}
div.luxtools-calendar.luxtools-calendar-size-large ul.luxtools-calendar-event-list {
list-style: none;
margin: 0;
padding: 0;
}
div.luxtools-calendar.luxtools-calendar-size-large li.luxtools-calendar-event {
display: flex;
align-items: baseline;
gap: 0.35em;
margin: 0 0 0.2em 0;
padding: 0.1em 0.2em 0.1em 0.35em;
border-left: 3px solid var(--luxtools-slot-color, @ini_border);
background-color: @ini_background_alt;
overflow: hidden;
cursor: pointer;
}
div.luxtools-calendar.luxtools-calendar-size-large li.luxtools-calendar-event:hover {
background-color: @ini_highlight;
}
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-event-time {
flex: 0 0 auto;
font-weight: bold;
white-space: nowrap;
}
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-event-title {
flex: 1 1 auto;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
div.luxtools-calendar.luxtools-calendar-size-large li.luxtools-calendar-event-more {
border-left-color: @ini_border;
justify-content: flex-end;
font-style: italic;
}
@media (max-width: 800px) {
div.luxtools-calendar.luxtools-calendar-size-large td.luxtools-calendar-day,
div.luxtools-calendar.luxtools-calendar-size-large td.luxtools-calendar-day-empty,
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-frame {
height: 7em;
min-height: 7em;
}
}
.luxtools-calendar-indicators {
position: absolute;
top: 0;

View File

@@ -64,6 +64,8 @@ class syntax_plugin_luxtools_calendar extends SyntaxPlugin
'year' => $resolved['year'],
'month' => $resolved['month'],
'base' => $baseNs,
'size' => ChronologicalCalendarWidget::normalizeSize((string)($params['size'] ?? 'large')),
'show_times' => ChronologicalCalendarWidget::normalizeShowTimes($params['show_times'] ?? null),
];
}
@@ -86,13 +88,24 @@ class syntax_plugin_luxtools_calendar extends SyntaxPlugin
$year = (int)$data['year'];
$month = (int)$data['month'];
$baseNs = (string)$data['base'];
$size = ChronologicalCalendarWidget::normalizeSize((string)($data['size'] ?? 'large'));
$showTimes = (bool)($data['show_times'] ?? true);
// Load slot indicators and colors for the calendar widget
$slots = CalendarSlot::loadEnabled($this);
$indicators = CalendarService::monthIndicators($slots, $year, $month);
$widgetSlots = CalendarSlot::filterWidgetVisible($slots);
$indicators = [];
$dayEvents = [];
if ($size === 'large') {
$widgetData = CalendarService::monthWidgetData($widgetSlots, $year, $month);
$indicators = $widgetData['indicators'];
$dayEvents = $widgetData['events'];
} else {
$indicators = CalendarService::monthIndicators($widgetSlots, $year, $month);
}
$slotColors = [];
$slotDisplays = [];
foreach ($slots as $slot) {
foreach ($widgetSlots as $slot) {
$color = $slot->getColor();
if ($color !== '') {
$slotColors[$slot->getKey()] = $color;
@@ -100,7 +113,11 @@ class syntax_plugin_luxtools_calendar extends SyntaxPlugin
$slotDisplays[$slot->getKey()] = $slot->getDisplay();
}
$renderer->doc .= ChronologicalCalendarWidget::render($year, $month, $baseNs, $indicators, $slotColors, $slotDisplays);
$renderer->doc .= ChronologicalCalendarWidget::render($year, $month, $baseNs, $indicators, $slotColors, $slotDisplays, [
'size' => $size,
'showTimes' => $showTimes,
'dayEvents' => $dayEvents,
]);
return true;
}