Add calendar toolbox with weekly and monthly scheduling
All checks were successful
Deploy Sokko G / deploy (push) Successful in 14s

This commit is contained in:
Shinuwa 2026-08-20 15:48:24 +02:00
parent 4f73e0068b
commit 992665f4ea
39 changed files with 4331 additions and 36 deletions

View file

@ -78,6 +78,7 @@ export function AppOverlays({
key={notification.id}
id={notification.id}
message={notification.message}
durationMs={notification.durationMs}
onClose={dismissNotification}
/>
))}

View file

@ -24,11 +24,11 @@ export function ConfirmModal({ title, message, confirmLabel = "Confirmer", cance
);
}
export function NotificationToast({ id, message, onClose }) {
export function NotificationToast({ id, message, durationMs = 120000, onClose }) {
useEffect(() => {
const timeoutId = window.setTimeout(() => onClose(id), 120000);
const timeoutId = window.setTimeout(() => onClose(id), durationMs);
return () => window.clearTimeout(timeoutId);
}, [id, onClose]);
}, [durationMs, id, onClose]);
return (
<div className="notification-toast" role="status">

View file

@ -15,6 +15,7 @@ import {
clampQty,
hostnameFromUrl,
normalizeCalculatorData,
normalizeCalendarData,
normalizeChecklistData,
normalizeCombosData,
normalizeCountersData,
@ -191,6 +192,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
normalizeCountersData,
normalizeEquipmentPlannerData,
normalizeCalculatorData,
normalizeCalendarData,
normalizeImageAnnotationData,
normalizeNotepadData,
normalizeTableData,
@ -220,7 +222,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
setShowTutorial(shouldShowTutorial(toolbox, pendingTutorialToolboxId));
}, [pendingTutorialToolboxId, toolbox, toolboxId, toolboxTutorial]);
function addModule(type) {
function addModule(type, initialData = null) {
if (!type) return;
const module = { id: uid("mod"), type };
const modules = [...toolbox.modules, module];
@ -229,6 +231,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
modules,
moduleOrder: addModuleToOrder(toolbox.moduleOrder, modules, module.id)
});
if (initialData) updateModuleData(toolbox.id, module.id, initialData, type);
}
function createImageAnnotationModule(dataUrl) {

File diff suppressed because it is too large Load diff

View file

@ -442,9 +442,6 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
<div className="combos-module">
{(editing || editingComboId) && (
<div className="module-add-panel combos-editor">
{editing && !editingComboId && (
<TextExchangeActions type="combos" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
)}
<div className="combos-device-row">
<label>
<span>{textContent.deviceLabel || "Périphérique"}</span>
@ -592,6 +589,9 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
</div>
</div>
</form>
{editing && !editingComboId && (
<TextExchangeActions type="combos" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
)}
</div>
)}

View file

@ -0,0 +1,266 @@
// Rôle : regroupe les calculs hebdomadaires/mensuels, occurrences et alertes du calendrier.
export const CALENDAR_VIEW_MODES = ["week", "month"];
export const CALENDAR_MONTHLY_REPEAT_BY = ["monthDay", "weekday"];
export const CALENDAR_REPEAT_MODES = ["none", "daily", "weekly", "monthly"];
export const CALENDAR_MONTH_REPEAT_MODES = ["none", "weekly", "monthly"];
export const CALENDAR_ALERT_MODES = ["off", "visible", "site"];
export const CALENDAR_ALERT_OFFSETS = [10, 5, 0];
export const CALENDAR_COLORS = ["violet", "gold", "cyan", "pink", "green", "gray"];
export const WEEK_SLOT_MINUTES = 30;
export const WEEK_TIME_INPUT_STEP_MINUTES = 5;
export const WEEK_SLOT_COUNT = 48;
export const DAY_MS = 24 * 60 * 60 * 1000;
export const MINUTE_MS = 60 * 1000;
export const WEEK_DAY_COUNT = 7;
export function pad2(value) {
return String(value).padStart(2, "0");
}
export function formatLocalDateKey(dateLike) {
const date = new Date(dateLike);
if (Number.isNaN(date.getTime())) return "";
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
}
export function parseLocalDateKey(value) {
const match = String(value || "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return null;
const year = Number(match[1]);
const month = Number(match[2]) - 1;
const day = Number(match[3]);
const date = new Date(year, month, day);
if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) return null;
return date;
}
export function minutesToTimeValue(minutes) {
const normalized = Math.max(0, Math.min(23 * 60 + 59, Number(minutes) || 0));
return `${pad2(Math.floor(normalized / 60))}:${pad2(normalized % 60)}`;
}
export function timeValueToMinutes(value) {
const match = String(value || "").match(/^(\d{1,2}):(\d{2})$/);
if (!match) return -1;
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (!Number.isInteger(hours) || !Number.isInteger(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return -1;
return hours * 60 + minutes;
}
export function startOfLocalDay(dateLike) {
const date = new Date(dateLike);
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
export function addDays(dateLike, days) {
const date = new Date(dateLike);
date.setDate(date.getDate() + days);
return date;
}
export function addMonths(dateLike, months) {
const date = new Date(dateLike);
date.setMonth(date.getMonth() + months);
return date;
}
export function getLocalDayIndex(dateLike) {
return (new Date(dateLike).getDay() + 6) % WEEK_DAY_COUNT;
}
export function getWeekStart(dateLike, weekStartsOn = 1) {
const day = startOfLocalDay(dateLike);
const current = day.getDay();
const offset = (current - weekStartsOn + 7) % 7;
return addDays(day, -offset);
}
export function getWeekDays(weekStart) {
return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
}
export function getMonthStart(dateLike) {
const date = new Date(dateLike);
return new Date(date.getFullYear(), date.getMonth(), 1);
}
export function getMonthEnd(dateLike) {
const date = getMonthStart(dateLike);
date.setMonth(date.getMonth() + 1);
return date;
}
export function getDaysInMonth(dateLike) {
return new Date(new Date(dateLike).getFullYear(), new Date(dateLike).getMonth() + 1, 0).getDate();
}
export function getMonthGridDays(dateLike) {
const monthStart = getMonthStart(dateLike);
const monthEnd = getMonthEnd(dateLike);
const gridStart = getWeekStart(monthStart, 1);
const gridEnd = addDays(getWeekStart(addDays(monthEnd, -1), 1), 7);
const days = [];
for (let cursor = new Date(gridStart); cursor < gridEnd; cursor = addDays(cursor, 1)) {
const date = new Date(cursor);
days.push({
date,
dayNumber: date.getDate(),
dayIndex: getLocalDayIndex(date),
inMonth: date.getMonth() === monthStart.getMonth() && date.getFullYear() === monthStart.getFullYear()
});
}
return days;
}
export function getDateForWeekDay(weekStart, dayIndex, minutes = 0) {
const date = addDays(weekStart, Math.max(0, Math.min(WEEK_DAY_COUNT - 1, Number(dayIndex) || 0)));
date.setHours(0, Math.max(0, Number(minutes) || 0), 0, 0);
return date;
}
export function getDateForMonthDay(monthDate, monthDay, minutes = 0) {
const day = Number(monthDay);
const monthStart = getMonthStart(monthDate);
if (!Number.isInteger(day) || day < 1 || day > getDaysInMonth(monthStart)) return null;
const date = new Date(monthStart.getFullYear(), monthStart.getMonth(), day);
date.setHours(0, Math.max(0, Number(minutes) || 0), 0, 0);
return date;
}
function getWeekdayOrdinalInMonth(dateLike) {
const date = new Date(dateLike);
return Math.floor((date.getDate() - 1) / 7) + 1;
}
function getMonthlyWeekdayDate(monthDate, sourceDate, minutes = 0) {
const source = new Date(sourceDate);
const ordinal = getWeekdayOrdinalInMonth(source);
const dayIndex = getLocalDayIndex(source);
const monthStart = getMonthStart(monthDate);
const firstDayOffset = (dayIndex - getLocalDayIndex(monthStart) + WEEK_DAY_COUNT) % WEEK_DAY_COUNT;
const date = addDays(monthStart, firstDayOffset + (ordinal - 1) * WEEK_DAY_COUNT);
if (date.getMonth() !== monthStart.getMonth()) return null;
date.setHours(0, Math.max(0, Number(minutes) || 0), 0, 0);
return date;
}
function getMonthlyDateRange(event, monthDate) {
const sourceStart = parseLocalDateKey(event.startDate);
const sourceEnd = parseLocalDateKey(event.endDate || event.startDate);
if (!sourceStart || !sourceEnd || sourceEnd < sourceStart) return null;
const spanDays = Math.max(0, Math.round((startOfLocalDay(sourceEnd).getTime() - startOfLocalDay(sourceStart).getTime()) / DAY_MS));
const start = event.monthlyRepeatBy === "weekday"
? getMonthlyWeekdayDate(monthDate, sourceStart, event.startMinutes)
: getDateForMonthDay(monthDate, sourceStart.getDate(), event.startMinutes);
if (!start) return null;
const end = addDays(start, spanDays + 1);
return { start, end };
}
function getDatedEventRange(event) {
const start = parseLocalDateKey(event.startDate);
const endSource = parseLocalDateKey(event.endDate || event.startDate);
if (!start || !endSource || endSource < start) return null;
start.setHours(0, Math.max(0, Number(event.startMinutes) || 0), 0, 0);
const end = addDays(endSource, 1);
end.setHours(0, 0, 0, 0);
return { start, end };
}
export function getEventDurationMs(event) {
const startMinutes = Number(event.startMinutes);
const endMinutes = Number(event.endMinutes);
return Number.isFinite(startMinutes) && Number.isFinite(endMinutes) && endMinutes > startMinutes ? (endMinutes - startMinutes) * MINUTE_MS : 0;
}
export function getEventEndMs(event, startMs) {
const durationMs = getEventDurationMs(event);
return durationMs > 0 ? startMs + durationMs : startMs;
}
export function getCalendarAlertOffsets(event) {
const rawOffsets = Array.isArray(event?.alertOffsetMinutes) ? event.alertOffsetMinutes : [event?.alertOffsetMinutes];
const offsets = [...new Set(rawOffsets.map(Number).filter((offset) => CALENDAR_ALERT_OFFSETS.includes(offset)))];
return offsets.length ? offsets.sort((a, b) => b - a) : [10];
}
function getWeeklyOccurrenceStart(event, weekStart, offset) {
if (event.repeat === "none") return getDateForWeekDay(weekStart, event.dayIndex, event.startMinutes);
return getDateForWeekDay(addDays(weekStart, offset * 7), event.dayIndex, event.startMinutes);
}
export function getCalendarOccurrences(events, rangeStart, rangeEnd) {
const baseWeekStart = getWeekStart(rangeStart);
const startMs = rangeStart.getTime();
const endMs = rangeEnd.getTime();
return events.flatMap((event) => {
const durationMs = getEventDurationMs(event);
const candidates = [];
if (event.startDate) {
const baseRange = getDatedEventRange(event);
if (baseRange) {
const spanDays = Math.max(0, Math.round((baseRange.end.getTime() - baseRange.start.getTime()) / DAY_MS) - 1);
if (event.repeat === "monthly") {
for (let cursor = getMonthStart(rangeStart); cursor < rangeEnd; cursor = addMonths(cursor, 1)) {
const range = getMonthlyDateRange(event, cursor);
if (range) candidates.push(range);
}
} else if (event.repeat === "weekly") {
const firstOffset = Math.floor((getWeekStart(rangeStart).getTime() - getWeekStart(baseRange.start).getTime()) / (7 * DAY_MS)) - 1;
const weekCount = Math.ceil((rangeEnd.getTime() - rangeStart.getTime()) / (7 * DAY_MS)) + 3;
for (let index = Math.max(-1, firstOffset); index < firstOffset + weekCount; index += 1) {
const start = addDays(baseRange.start, index * 7);
const end = addDays(start, spanDays + 1);
candidates.push({ start, end });
}
} else {
candidates.push(baseRange);
}
}
} else if (event.repeat === "none") {
candidates.push(getWeeklyOccurrenceStart(event, baseWeekStart, 0));
} else {
candidates.push(getWeeklyOccurrenceStart(event, baseWeekStart, -1), getWeeklyOccurrenceStart(event, baseWeekStart, 0), getWeeklyOccurrenceStart(event, baseWeekStart, 1));
}
return candidates
.map((candidate) => candidate instanceof Date ? { start: candidate, end: null } : candidate)
.filter((candidate) => candidate?.start && !Number.isNaN(candidate.start.getTime()))
.filter((candidate) => !event.excludedOccurrences?.includes?.(formatLocalDateKey(candidate.start)))
.map((candidate) => {
const occurrenceStartMs = candidate.start.getTime();
const occurrenceEndMs = candidate.end && !Number.isNaN(candidate.end.getTime()) ? candidate.end.getTime() : 0;
return {
id: `${event.id}:${occurrenceStartMs}`,
event,
startMs: occurrenceStartMs,
endMs: occurrenceEndMs > occurrenceStartMs ? occurrenceEndMs : durationMs > 0 ? occurrenceStartMs + durationMs : occurrenceStartMs
};
})
.filter((occurrence) => {
const visibleEndMs = occurrence.endMs || occurrence.startMs;
return visibleEndMs >= startMs && occurrence.startMs < endMs;
});
}).sort((a, b) => a.startMs - b.startMs);
}
export function getNextCalendarAlertTime(event, nowMs) {
if (!event || event.alertMode === "off") return 0;
const rangeStart = addDays(new Date(nowMs), -1);
const rangeEnd = addDays(new Date(nowMs), 370);
const alert = getCalendarOccurrences([event], rangeStart, rangeEnd)
.flatMap((occurrence) => getCalendarAlertOffsets(event).map((offset) => occurrence.startMs - offset * MINUTE_MS))
.filter((alertMs) => alertMs >= nowMs)
.sort((a, b) => a - b)[0];
return alert || 0;
}
export function getCurrentCalendarOccurrence(event, nowMs) {
if (!event?.endMinutes && !event?.startDate) return null;
const rangeStart = addDays(new Date(nowMs), -1);
const rangeEnd = addDays(new Date(nowMs), 1);
return getCalendarOccurrences([event], rangeStart, rangeEnd)
.find((occurrence) => occurrence.startMs <= nowMs && occurrence.endMs > nowMs) || null;
}

View file

@ -8,6 +8,7 @@ import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { lockBodyScroll } from "../../../utils/bodyScrollLock.js";
import { TOOLBOX_MODULE_DEFINITIONS, TOOLBOX_MODULES } from "../toolboxModuleRegistry.js";
import { CalculatorModule } from "./CalculatorModule.jsx";
import { CalendarModule } from "./CalendarModule.jsx";
import { ChecklistModule } from "./ChecklistModule.jsx";
import { CombosModule } from "./CombosModule.jsx";
import { CountersModule } from "./CountersModule.jsx";
@ -28,6 +29,7 @@ const MODULE_COMPONENTS = {
counters: { ...TOOLBOX_MODULE_DEFINITIONS.counters, Component: CountersModule },
combos: { ...TOOLBOX_MODULE_DEFINITIONS.combos, Component: CombosModule },
calculator: { ...TOOLBOX_MODULE_DEFINITIONS.calculator, Component: CalculatorModule },
calendar: { ...TOOLBOX_MODULE_DEFINITIONS.calendar, Component: CalendarModule },
table: { ...TOOLBOX_MODULE_DEFINITIONS.table, Component: TableModule },
timer: { ...TOOLBOX_MODULE_DEFINITIONS.timer, Component: TimerModule },
taskPlanner: { ...TOOLBOX_MODULE_DEFINITIONS.taskPlanner, Component: TaskPlannerModule },
@ -352,7 +354,7 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
</div>
</header>
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
<Component toolboxId={toolbox.id} moduleId={module.id} context={context} editing={editing} />
<Component toolboxId={toolbox.id} moduleId={module.id} context={context} editing={editing} setEditing={setEditing} />
</div>
</article>
);

View file

@ -1,4 +1,5 @@
// Rôle : parse et sérialise les formats texte/TSV d'échange des outils toolbox.
import { CALENDAR_ALERT_MODES, CALENDAR_ALERT_OFFSETS, CALENDAR_COLORS, CALENDAR_MONTHLY_REPEAT_BY, CALENDAR_MONTH_REPEAT_MODES, minutesToTimeValue, timeValueToMinutes } from "./calendarUtils.js";
import { cellAddress, columnIndexToName } from "./tableFormulaEngine.js";
import { getCountdownTargetMs, getDailyTargetMs, getIntervalAnchorMs } from "./timerUtils.js";
@ -27,6 +28,7 @@ const SUPPORTED_TEXT_EXCHANGE_TYPES = new Set([
"calculator",
"table",
"timer",
"calendar",
"taskPlanner",
"equipmentPlanner",
"images",
@ -36,6 +38,12 @@ const COMBO_DEVICES = new Set(["playstation", "xbox", "switch", "n64", "keyboard
const COMBO_INPUT_KINDS = new Set(["button", "direction", "key", "mouse"]);
const TIMER_TYPES = new Set(["duration", "daily_time", "time_pattern", "interval"]);
const ALERT_MODES = new Set(["off", "visible", "site"]);
const CALENDAR_ALERT_MODE_SET = new Set(CALENDAR_ALERT_MODES);
const CALENDAR_ALERT_OFFSET_SET = new Set(CALENDAR_ALERT_OFFSETS);
const CALENDAR_COLOR_SET = new Set(CALENDAR_COLORS);
const CALENDAR_MONTHLY_REPEAT_BY_SET = new Set(CALENDAR_MONTHLY_REPEAT_BY);
const CALENDAR_MONTH_REPEAT_MODE_SET = new Set(CALENDAR_MONTH_REPEAT_MODES);
const CALENDAR_WEEKDAYS = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"];
const IMAGE_DATA_URL_PATTERN = /^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=\s]+$/i;
export function supportsTextExchange(type) {
@ -51,6 +59,7 @@ export function hasTextExchangeContent(type, data) {
if (type === "calculator") return Boolean(data?.entries?.length || data?.scrollResults);
if (type === "table") return Boolean(Object.keys(data?.cells || {}).length || Object.keys(data?.rowLabels || {}).length || Object.keys(data?.columnLabels || {}).length);
if (type === "timer") return Boolean(data?.stopwatch?.laps?.length || data?.countdowns?.length || data?.stopwatch?.elapsedMs);
if (type === "calendar") return Boolean(data?.events?.length);
if (type === "taskPlanner") return Boolean(data?.tasks?.length);
if (type === "equipmentPlanner") return Boolean(data?.equipments?.length);
if (type === "images") return Boolean(data?.images?.length);
@ -67,6 +76,7 @@ export function exportModuleText(type, data) {
if (type === "calculator") return exportCalculatorText(data);
if (type === "table") return exportTableTsv(data);
if (type === "timer") return exportTimerText(data);
if (type === "calendar") return exportCalendarText(data);
if (type === "taskPlanner") return exportTaskPlannerText(data);
if (type === "equipmentPlanner") return exportEquipmentPlannerText(data);
if (type === "images") return exportImagesText(data);
@ -87,6 +97,7 @@ export function importModuleText(type, text, context) {
else if (type === "calculator") data = importCalculatorText(value, context);
else if (type === "table") data = importTableTsv(value);
else if (type === "timer") data = importTimerText(value, context);
else if (type === "calendar") data = importCalendarText(value, context);
else if (type === "taskPlanner") data = importTaskPlannerText(value, context);
else if (type === "equipmentPlanner") data = importEquipmentPlannerText(value, context);
else if (type === "images") data = importImagesText(value, context);
@ -644,6 +655,112 @@ function importTimerText(text, context) {
return context.normalizeTimerData(data);
}
function parseCalendarDayIndex(value) {
const normalized = String(value || "").trim().toLowerCase();
const weekdayIndex = CALENDAR_WEEKDAYS.indexOf(normalized);
if (weekdayIndex >= 0) return weekdayIndex;
const parsed = parseInteger(normalized, -1);
return parsed >= 0 && parsed <= 6 ? parsed : -1;
}
function parseCalendarAlertOffsets(value) {
return String(value || "")
.split(",")
.map((item) => parseInteger(item.trim(), Number.NaN))
.filter((offset, index, offsets) => CALENDAR_ALERT_OFFSET_SET.has(offset) && offsets.indexOf(offset) === index)
.sort((a, b) => b - a);
}
function formatCalendarEventMeta(event, viewMode) {
const alertMode = CALENDAR_ALERT_MODE_SET.has(event.alertMode) ? event.alertMode : "off";
const meta = {
title: event.title,
color: event.color && event.color !== "violet" ? event.color : "",
repeatable: event.repeatable === false ? "false" : "",
alertMode: alertMode !== "off" ? alertMode : "",
alerts: alertMode !== "off" ? (event.alertOffsetMinutes || [10]).join(",") : ""
};
if (viewMode === "month") {
const monthlyRepeatBy = CALENDAR_MONTHLY_REPEAT_BY_SET.has(event.monthlyRepeatBy) ? event.monthlyRepeatBy : "monthDay";
if (monthlyRepeatBy === "weekday") {
return {
...meta,
repeatBy: "weekday",
repeat: event.repeat || "none",
startDate: event.startDate,
endDate: event.endDate && event.endDate !== event.startDate ? event.endDate : ""
};
}
return {
...meta,
repeatBy: "monthDay",
repeat: event.repeat || "none",
startDate: event.startDate,
endDate: event.endDate && event.endDate !== event.startDate ? event.endDate : ""
};
}
return {
...meta,
day: CALENDAR_WEEKDAYS[event.dayIndex] || "",
start: minutesToTimeValue(event.startMinutes),
end: event.endMinutes ? minutesToTimeValue(event.endMinutes) : ""
};
}
function exportCalendarText(data) {
const viewMode = data?.viewMode === "month" ? "month" : "week";
const lines = [`@viewMode: ${viewMode}`];
if (data?.weekStartsOn === 0) lines.push("@weekStartsOn: 0");
(data?.events || []).forEach((event) => {
lines.push(`event | ${formatMeta(formatCalendarEventMeta(event, viewMode))}`);
});
return lines.join("\n");
}
function importCalendarText(text, context) {
const trimmed = String(text || "").trim();
if (trimmed.startsWith("{")) {
return context.normalizeCalendarData(JSON.parse(trimmed));
}
const data = { initialized: true, viewMode: "week", weekStartsOn: 1, events: [] };
splitLines(text).forEach((line, index) => {
const cleanLine = line.trim();
if (!cleanLine) return;
const meta = parseMetaLine(cleanLine);
if (meta) {
if (meta.key === "viewMode") data.viewMode = meta.value === "month" ? "month" : "week";
if (meta.key === "weekStartsOn") data.weekStartsOn = parseInteger(meta.value, 1) === 0 ? 0 : 1;
return;
}
const { title, meta: lineMeta } = parsePipeParts(cleanLine);
if (title !== "event") throw lineError(index, `ligne calendrier invalide: ${cleanLine}`);
const event = {
id: context.uid("calendar"),
title: lineMeta.title || "",
repeatable: lineMeta.repeatable !== "false",
alertMode: CALENDAR_ALERT_MODE_SET.has(lineMeta.alertMode) ? lineMeta.alertMode : "off",
alertOffsetMinutes: parseCalendarAlertOffsets(lineMeta.alerts),
color: CALENDAR_COLOR_SET.has(lineMeta.color) ? lineMeta.color : "violet"
};
if (data.viewMode === "month") {
const monthlyRepeatBy = CALENDAR_MONTHLY_REPEAT_BY_SET.has(lineMeta.repeatBy) ? lineMeta.repeatBy : "monthDay";
event.startMinutes = 0;
event.monthlyRepeatBy = monthlyRepeatBy;
event.repeat = CALENDAR_MONTH_REPEAT_MODE_SET.has(lineMeta.repeat) ? lineMeta.repeat : "none";
event.startDate = lineMeta.startDate || "";
event.endDate = lineMeta.endDate || event.startDate;
} else {
event.dayIndex = parseCalendarDayIndex(lineMeta.day);
event.startMinutes = timeValueToMinutes(lineMeta.start);
if (lineMeta.end) event.endMinutes = timeValueToMinutes(lineMeta.end);
}
data.events.push(event);
});
return context.normalizeCalendarData(data);
}
function exportImagesText(data) {
return (data.images || []).flatMap((image) => [
image.label ? `# ${image.label}` : "#",

View file

@ -0,0 +1,123 @@
// Rôle : normalise et compacte les événements du calendrier hebdomadaire et mensuel.
import { CALENDAR_ALERT_MODES, CALENDAR_ALERT_OFFSETS, CALENDAR_COLORS, CALENDAR_MONTHLY_REPEAT_BY, CALENDAR_MONTH_REPEAT_MODES, CALENDAR_VIEW_MODES, formatLocalDateKey, getLocalDayIndex, parseLocalDateKey, WEEK_DAY_COUNT } from "../../modules/calendarUtils.js";
import { uid } from "./shared.js";
const VIEW_MODES = new Set(CALENDAR_VIEW_MODES);
const ALERT_MODES = new Set(CALENDAR_ALERT_MODES);
const ALERT_OFFSETS = new Set(CALENDAR_ALERT_OFFSETS);
const COLORS = new Set(CALENDAR_COLORS);
const MONTHLY_REPEAT_BY = new Set(CALENDAR_MONTHLY_REPEAT_BY);
const MONTH_REPEAT_MODES = new Set(CALENDAR_MONTH_REPEAT_MODES);
function normalizeDayIndex(value) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= 0 && parsed < WEEK_DAY_COUNT ? parsed : -1;
}
function normalizeMinuteOfDay(value) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= 0 && parsed < 24 * 60 ? parsed : -1;
}
function normalizeDateKey(value) {
const date = parseLocalDateKey(value);
return date ? formatLocalDateKey(date) : "";
}
function normalizeExcludedOccurrences(value) {
if (!Array.isArray(value)) return [];
return [...new Set(value.map(normalizeDateKey).filter(Boolean))].sort();
}
function normalizeAlertOffsets(value) {
const rawOffsets = Array.isArray(value) ? value : [value];
const offsets = [...new Set(rawOffsets.map(Number).filter((offset) => ALERT_OFFSETS.has(offset)))];
return offsets.length ? offsets.sort((a, b) => b - a) : [10];
}
export function normalizeCalendarData(data) {
const viewMode = VIEW_MODES.has(data?.viewMode) ? data.viewMode : "week";
const events = (Array.isArray(data?.events) ? data.events : [])
.map((event) => {
const title = String(event?.title || "").trim();
const dayIndex = normalizeDayIndex(event?.dayIndex);
const startDate = normalizeDateKey(event?.startDate);
const endDate = normalizeDateKey(event?.endDate || event?.startDate);
const parsedStartMinutes = normalizeMinuteOfDay(event?.startMinutes);
const startMinutes = viewMode === "month" && parsedStartMinutes < 0 ? 0 : parsedStartMinutes;
const monthlyRepeatBy = MONTHLY_REPEAT_BY.has(event?.monthlyRepeatBy) ? event.monthlyRepeatBy : "monthDay";
const monthRepeat = MONTH_REPEAT_MODES.has(event?.repeat) ? event.repeat : event?.repeatable === false ? "none" : "monthly";
const hasValidDateTarget = viewMode === "month"
? startDate && endDate && endDate >= startDate
: dayIndex >= 0;
if (!title || !hasValidDateTarget || startMinutes < 0) return null;
const endMinutes = normalizeMinuteOfDay(event?.endMinutes);
const alertMode = ALERT_MODES.has(event?.alertMode) ? event.alertMode : "off";
const alertOffsetMinutes = normalizeAlertOffsets(event?.alertOffsetMinutes);
const color = COLORS.has(event?.color) ? event.color : "violet";
const repeatable = viewMode === "month" ? monthRepeat !== "none" : event?.repeatable !== false;
const normalized = {
id: event?.id || uid("calendar"),
title,
startMinutes,
repeat: viewMode === "month" ? monthlyRepeatBy === "monthDay" && monthRepeat === "weekly" ? "none" : monthRepeat : repeatable ? "weekly" : "none",
repeatable,
alertMode,
alertOffsetMinutes,
color
};
if (viewMode === "month") {
normalized.monthlyRepeatBy = monthlyRepeatBy;
normalized.startDate = startDate;
normalized.endDate = endDate;
normalized.dayIndex = dayIndex >= 0 ? dayIndex : getLocalDayIndex(parseLocalDateKey(startDate));
normalized.excludedOccurrences = normalizeExcludedOccurrences(event?.excludedOccurrences);
} else {
normalized.dayIndex = dayIndex;
}
if (endMinutes > startMinutes) normalized.endMinutes = endMinutes;
return normalized;
})
.filter(Boolean);
return {
initialized: data?.initialized === true || events.length > 0,
viewMode,
weekStartsOn: Number(data?.weekStartsOn) === 0 ? 0 : 1,
events
};
}
export function compactCalendarDataForStorage(value) {
const normalized = normalizeCalendarData(value);
const compact = {};
if (normalized.initialized) compact.initialized = true;
if (normalized.viewMode !== "week") compact.viewMode = normalized.viewMode;
if (normalized.weekStartsOn !== 1) compact.weekStartsOn = normalized.weekStartsOn;
if (normalized.events.length) {
compact.events = normalized.events.map((event) => {
const compactEvent = {
id: event.id,
title: event.title
};
if (normalized.viewMode !== "month" || event.startMinutes !== 0) compactEvent.startMinutes = event.startMinutes;
if (normalized.viewMode === "month") {
compactEvent.startDate = event.startDate;
if (event.endDate !== event.startDate) compactEvent.endDate = event.endDate;
if (event.repeat !== "monthly") compactEvent.repeat = event.repeat;
if (event.monthlyRepeatBy !== "monthDay") compactEvent.monthlyRepeatBy = event.monthlyRepeatBy;
if (event.excludedOccurrences.length) compactEvent.excludedOccurrences = event.excludedOccurrences;
} else {
compactEvent.dayIndex = event.dayIndex;
}
if (normalized.viewMode !== "month" && event.endMinutes) compactEvent.endMinutes = event.endMinutes;
if (normalized.viewMode !== "month" && event.repeatable === false) compactEvent.repeatable = false;
if (event.alertMode !== "off") compactEvent.alertMode = event.alertMode;
if (event.alertMode !== "off" && (event.alertOffsetMinutes.length !== 1 || event.alertOffsetMinutes[0] !== 10)) compactEvent.alertOffsetMinutes = event.alertOffsetMinutes;
if (event.color !== "violet") compactEvent.color = event.color;
return compactEvent;
});
}
return Object.keys(compact).length ? compact : null;
}

View file

@ -1,5 +1,6 @@
// Rôle : expose le registre strict des compacteurs par type d'outil toolbox.
import { compactCalculatorDataForStorage } from "./calculator.js";
import { compactCalendarDataForStorage } from "./calendar.js";
import { compactChecklistDataForStorage } from "./checklist.js";
import { compactCombosDataForStorage } from "./combos.js";
import { compactCountersDataForStorage } from "./counters.js";
@ -20,6 +21,7 @@ export const MODULE_DATA_COMPACTORS = {
counters: compactCountersDataForStorage,
combos: compactCombosDataForStorage,
calculator: compactCalculatorDataForStorage,
calendar: compactCalendarDataForStorage,
table: compactTableDataForStorage,
timer: compactTimerDataForStorage,
taskPlanner: compactTaskPlannerDataForStorage,

View file

@ -15,13 +15,23 @@ const NOTEPAD_RGB_COLOR_MAP = {
};
function escapeHtml(value) {
return String(value || "")
return decodeBasicEntitiesDeep(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function decodeBasicEntitiesDeep(value) {
let current = String(value || "");
for (let index = 0; index < 5; index += 1) {
const next = decodeBasicEntities(current);
if (next === current) return next;
current = next;
}
return current;
}
function decodeBasicEntities(value) {
return String(value || "")
.replace(/&nbsp;/gi, " ")

View file

@ -9,6 +9,7 @@ export const ID_PREFIXES = {
counter: "c",
combo: "o",
calc: "r",
calendar: "h",
marker: "k",
timer: "z",
task: "a",

View file

@ -4,6 +4,7 @@ import { MODULE_DATA_COMPACTORS } from "./modules/index.js";
import { ID_PREFIXES, uid } from "./modules/shared.js";
export { normalizeCalculatorData } from "./modules/calculator.js";
export { normalizeCalendarData } from "./modules/calendar.js";
export { clampQty, normalizeChecklistData } from "./modules/checklist.js";
export { normalizeCombosData } from "./modules/combos.js";
export { normalizeCountersData } from "./modules/counters.js";
@ -250,6 +251,12 @@ function remapModuleDataForExport(type, data, nextId) {
return remapped;
}
if (type === "calendar") {
const remapped = { ...compact };
if (compact.events) remapped.events = compact.events.map((event) => ({ ...event, id: nextId("calendar") }));
return remapped;
}
if (type === "taskPlanner") {
const taskIdMap = new Map();
compact.tasks?.forEach((task) => taskIdMap.set(task.id, nextId("task")));

View file

@ -78,6 +78,15 @@ export const TOOLBOX_MODULE_DEFINITIONS = {
editable: true,
libraryCategory: "timeRoutines"
},
calendar: {
label: "Calendrier",
defaultTitle: "Calendrier",
mode: "Agenda hebdomadaire",
icon: "calendar",
editable: true,
scrollable: true,
libraryCategory: "timeRoutines"
},
taskPlanner: {
label: "Planificateur de tâches",
defaultTitle: "Planificateur de tâches",
@ -115,7 +124,7 @@ export const LIBRARY_CATEGORY_DEFINITIONS = [
{ key: "notesTracking", types: ["notepad", "checklist", "counters"] },
{ key: "references", types: ["images", "imageAnnotation", "links"] },
{ key: "calculationData", types: ["calculator", "table"] },
{ key: "timeRoutines", types: ["timer", "taskPlanner"] },
{ key: "timeRoutines", types: ["timer", "calendar", "taskPlanner"] },
{ key: "buildsCommands", types: ["combos", "equipmentPlanner"] }
];

View file

@ -0,0 +1,99 @@
// Rôle : surveille globalement les alertes internes des outils timer et calendrier.
import { useEffect, useRef } from "react";
import { getCalendarAlertOffsets, getCurrentCalendarOccurrence, getNextCalendarAlertTime } from "../features/toolboxes/modules/calendarUtils.js";
import { getCountdownTargetMs } from "../features/toolboxes/modules/timerUtils.js";
import { moduleStorageKey } from "../features/toolboxes/storage/toolboxStorage.js";
function getVisibleToolboxIds(route, drawerGameId, links) {
const visibleIds = new Set();
const toolboxMatch = route.match(/^\/toolbox\/([^/]+)/);
if (toolboxMatch?.[1]) visibleIds.add(toolboxMatch[1]);
const drawerToolboxId = drawerGameId ? links[drawerGameId] : "";
if (drawerToolboxId) visibleIds.add(drawerToolboxId);
return visibleIds;
}
function isAlertAllowed(alertMode, toolboxId, visibleToolboxIds) {
if (alertMode === "off") return false;
if (alertMode === "visible") return visibleToolboxIds.has(toolboxId);
return alertMode === "site";
}
function formatAlertMessage(template, toolbox, label) {
return template
.replaceAll("{label}", label)
.replaceAll("{toolbox}", toolbox.name);
}
export function useToolboxAlerts({ toolboxes, moduleData, links, route, drawerGameId, normalizeTimerData, normalizeCalendarData, textContent, notify }) {
const previousTargetsRef = useRef(new Map());
const resumedCalendarEventsRef = useRef(new Set());
const lastTickRef = useRef(Date.now());
useEffect(() => {
const visibleToolboxIds = getVisibleToolboxIds(route, drawerGameId, links);
const timerAlertTemplate = textContent?.timer?.alertMessage || "Compte à rebours terminé : {label}";
const calendarAlertTemplate = textContent?.calendar?.alertMessage || "Événement à venir : {label}";
const calendarResumeTemplate = textContent?.calendar?.resumeAlertMessage || "Événement en cours : {label}";
function tick() {
const nowMs = Date.now();
const lastTickMs = lastTickRef.current;
const nextTargets = new Map();
toolboxes.forEach((toolbox) => {
toolbox.modules.forEach((module) => {
if (module.type === "timer") {
const data = normalizeTimerData(moduleData[moduleStorageKey(toolbox.id, module.id)]);
data.countdowns.forEach((countdown) => {
if (!isAlertAllowed(countdown.alertMode, toolbox.id, visibleToolboxIds)) return;
const key = `${toolbox.id}:${module.id}:${countdown.id}`;
const targetMs = getCountdownTargetMs(countdown, nowMs);
const previousTargetMs = previousTargetsRef.current.get(key) || targetMs;
nextTargets.set(key, targetMs);
if (!targetMs || previousTargetMs <= lastTickMs || previousTargetMs > nowMs) return;
notify(formatAlertMessage(timerAlertTemplate, toolbox, countdown.label));
});
}
if (module.type === "calendar") {
const data = normalizeCalendarData(moduleData[moduleStorageKey(toolbox.id, module.id)]);
data.events.forEach((event) => {
if (!isAlertAllowed(event.alertMode, toolbox.id, visibleToolboxIds)) return;
const key = `${toolbox.id}:${module.id}:${event.id}`;
getCalendarAlertOffsets(event).forEach((offset) => {
const alertKey = `${key}:alert:${offset}`;
const alertMs = getNextCalendarAlertTime({ ...event, alertOffsetMinutes: offset }, nowMs);
const previousAlertMs = previousTargetsRef.current.get(alertKey) || alertMs;
if (alertMs) nextTargets.set(alertKey, alertMs);
if (alertMs && previousAlertMs > lastTickMs && previousAlertMs <= nowMs) {
notify(formatAlertMessage(calendarAlertTemplate, toolbox, event.title));
}
});
const currentOccurrence = getCurrentCalendarOccurrence(event, nowMs);
if (!currentOccurrence) return;
const resumeKey = `${key}:${currentOccurrence.startMs}`;
if (resumedCalendarEventsRef.current.has(resumeKey)) return;
resumedCalendarEventsRef.current.add(resumeKey);
notify(formatAlertMessage(calendarResumeTemplate, toolbox, event.title));
});
}
});
});
previousTargetsRef.current = nextTargets;
lastTickRef.current = nowMs;
}
tick();
const intervalId = window.setInterval(tick, 500);
return () => window.clearInterval(intervalId);
}, [drawerGameId, links, moduleData, normalizeCalendarData, normalizeTimerData, notify, route, textContent, toolboxes]);
}

View file

@ -9,12 +9,20 @@ import { useIndexedToolboxes } from "./features/toolboxes/storage/useIndexedTool
import { useToolboxActions } from "./features/toolboxes/useToolboxActions.js";
import { useAppData } from "./hooks/useAppData.js";
import { useGameFilters } from "./hooks/useGameFilters.js";
import { useTimerAlerts } from "./hooks/useTimerAlerts.js";
import { useToolboxAlerts } from "./hooks/useToolboxAlerts.js";
import { RouteContent } from "./router/RouteContent.jsx";
import { useHashRoute } from "./router/hashRouter.js";
import { normalizeTimerData } from "./features/toolboxes/storage/toolboxStorage.js";
import { normalizeCalendarData, normalizeTimerData } from "./features/toolboxes/storage/toolboxStorage.js";
const ToolboxDrawer = lazy(() => import("./features/toolboxes/ToolboxPages.jsx").then((module) => ({ default: module.ToolboxDrawer })));
const DEFAULT_NOTIFICATION_DURATION_MS = 120000;
const SHORT_NOTIFICATION_DURATION_MS = 5000;
function getNotificationDuration(message, options = {}) {
if (Number.isFinite(options.durationMs)) return Math.max(1000, Number(options.durationMs));
const normalized = String(message || "").toLowerCase();
return /\bcopi[ée]|\bcoll[ée]|presse-papiers/.test(normalized) ? SHORT_NOTIFICATION_DURATION_MS : DEFAULT_NOTIFICATION_DURATION_MS;
}
function App() {
const route = useHashRoute();
@ -31,9 +39,9 @@ function App() {
const handleStorageError = useCallback((message) => setStorageError(message), []);
const store = useIndexedToolboxes(handleStorageError);
const notify = useCallback((message) => {
const notify = useCallback((message, options = {}) => {
setNotifications((current) => [
{ id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, message },
{ id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, message, durationMs: getNotificationDuration(message, options) },
...current
].slice(0, 5));
}, []);
@ -44,7 +52,7 @@ function App() {
useEffect(() => {
function handleNotification(event) {
if (event.detail?.message) notify(event.detail.message);
if (event.detail?.message) notify(event.detail.message, event.detail);
}
window.addEventListener("sokkog:notify", handleNotification);
@ -70,14 +78,15 @@ function App() {
setImage
});
useTimerAlerts({
useToolboxAlerts({
toolboxes: store.toolboxes,
moduleData: store.moduleData,
links: store.links,
route,
drawerGameId,
normalizeTimerData,
textContent: siteContent?.toolboxes?.modules?.timer,
normalizeCalendarData,
textContent: siteContent?.toolboxes?.modules,
notify
});

View file

@ -6,6 +6,7 @@ import {
clampQty,
hostnameFromUrl,
normalizeCalculatorData,
normalizeCalendarData,
normalizeChecklistData,
normalizeCombosData,
normalizeCountersData,
@ -31,6 +32,7 @@ const MODULE_COMPONENTS = {
counters: lazy(() => import("../features/toolboxes/modules/CountersModule.jsx").then((module) => ({ default: module.CountersModule }))),
combos: lazy(() => import("../features/toolboxes/modules/CombosModule.jsx").then((module) => ({ default: module.CombosModule }))),
calculator: lazy(() => import("../features/toolboxes/modules/CalculatorModule.jsx").then((module) => ({ default: module.CalculatorModule }))),
calendar: lazy(() => import("../features/toolboxes/modules/CalendarModule.jsx").then((module) => ({ default: module.CalendarModule }))),
table: lazy(() => import("../features/toolboxes/modules/TableModule.jsx").then((module) => ({ default: module.TableModule }))),
timer: lazy(() => import("../features/toolboxes/modules/TimerModule.jsx").then((module) => ({ default: module.TimerModule }))),
taskPlanner: lazy(() => import("../features/toolboxes/modules/TaskPlannerModule.jsx").then((module) => ({ default: module.TaskPlannerModule }))),
@ -94,12 +96,15 @@ function getToolDescription(siteContent, definition) {
}
function getLibraryCategories(modules, content) {
const modulesByType = new Map((modules || []).map((module) => [module.type, module]));
const modulesByType = new Map();
(modules || []).forEach((module) => {
modulesByType.set(module.type, [...(modulesByType.get(module.type) || []), module]);
});
return LIBRARY_CATEGORY_DEFINITIONS.map((category) => ({
...category,
title: content.categories?.[category.key] || category.key,
description: content.categoryDescriptions?.[category.key] || "",
modules: category.types.map((type) => modulesByType.get(type)).filter(Boolean)
modules: category.types.flatMap((type) => modulesByType.get(type) || [])
})).filter((category) => category.modules.length);
}
@ -149,6 +154,7 @@ export function LibraryPage({ siteContent, actions }) {
normalizeCountersData,
normalizeEquipmentPlannerData,
normalizeCalculatorData,
normalizeCalendarData,
normalizeImageAnnotationData,
normalizeNotepadData,
normalizeTableData,
@ -245,7 +251,11 @@ export function LibraryPage({ siteContent, actions }) {
sourceData={libraryPayload.modules?.[module.id] || {}}
description={getToolDescription(siteContent, getToolboxModuleDefinition(module.type))}
categoryTitle={activeCategory.title}
docs={content.toolDocs?.[module.type]}
docs={{
...(content.toolDocs?.[module.type] || {}),
...(module.controls ? { controls: module.controls } : {}),
...(module.importFormat ? { importFormat: module.importFormat } : {})
}}
featureHeading={content.featureHeading}
advancedHeading={content.advancedHeading}
advancedCopyLabel={content.copyExampleLabel}

View file

@ -613,12 +613,8 @@ span {
}
.library-doc-nav {
position: sticky;
top: 0;
display: grid;
max-height: calc(100vh - var(--space-10));
gap: var(--space-4);
overflow: auto;
padding: var(--space-4);
scroll-margin-top: var(--space-5);
}

View file

@ -112,6 +112,31 @@
-webkit-mask-image: url("/static/icons/clock.svg");
}
.ui-icon-calendar {
mask-image: url("/static/icons/calendar.svg");
-webkit-mask-image: url("/static/icons/calendar.svg");
}
.ui-icon-calendar-month {
mask-image: url("/static/icons/calendar-month.svg");
-webkit-mask-image: url("/static/icons/calendar-month.svg");
}
.ui-icon-calendar-week {
mask-image: url("/static/icons/calendar-week.svg");
-webkit-mask-image: url("/static/icons/calendar-week.svg");
}
.ui-icon-day-number {
mask-image: url("/static/icons/day-number.svg");
-webkit-mask-image: url("/static/icons/day-number.svg");
}
.ui-icon-day-name {
mask-image: url("/static/icons/day-name.svg");
-webkit-mask-image: url("/static/icons/day-name.svg");
}
.ui-icon-stopwatch {
mask-image: url("/static/icons/stopwatch.svg");
-webkit-mask-image: url("/static/icons/stopwatch.svg");
@ -362,6 +387,11 @@
-webkit-mask-image: url("/static/icons/trashcan.svg");
}
.ui-icon-trashcan-one {
mask-image: url("/static/icons/trashcan-one.svg");
-webkit-mask-image: url("/static/icons/trashcan-one.svg");
}
.game-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));

View file

@ -243,7 +243,6 @@
}
.library-doc-nav {
position: static;
max-height: none;
}

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,7 @@
@use "counters";
@use "calculator";
@use "timer";
@use "calendar";
@use "calculator-results";
@use "table";
@use "shared-controls";

View file

@ -240,6 +240,11 @@
-webkit-mask-image: url("/static/icons/clock.svg");
}
.module-icon-calendar {
mask-image: url("/static/icons/calendar.svg");
-webkit-mask-image: url("/static/icons/calendar.svg");
}
.module-icon-stopwatch {
mask-image: url("/static/icons/stopwatch.svg");
-webkit-mask-image: url("/static/icons/stopwatch.svg");

View file

@ -127,7 +127,10 @@
.task-planner-type-select,
.task-planner-reset-override select,
.task-planner-reset-override input,
.task-planner-relations select {
.task-planner-relations select,
.combos-device-row select,
.equipment-planner-socket-links select,
.equipment-planner-socket-controls select {
width: 100%;
min-height: 36px;
border: 1px solid var(--color-border);
@ -140,7 +143,10 @@
.task-planner-add-form select option,
.task-planner-type-select option,
.task-planner-reset-override select option,
.task-planner-relations select option {
.task-planner-relations select option,
.combos-device-row select option,
.equipment-planner-socket-links select option,
.equipment-planner-socket-controls select option {
background: #11182f;
color: var(--color-text-primary);
}

View file

@ -81,7 +81,10 @@
.task-planner-add-form select,
.task-planner-type-select,
.task-planner-reset-override select,
.task-planner-relations select {
.task-planner-relations select,
.combos-device-row select,
.equipment-planner-socket-links select,
.equipment-planner-socket-controls select {
appearance: none;
-webkit-appearance: none;
overflow: hidden;
@ -113,7 +116,13 @@
.task-planner-reset-override select:hover,
.task-planner-reset-override select:focus,
.task-planner-relations select:hover,
.task-planner-relations select:focus {
.task-planner-relations select:focus,
.combos-device-row select:hover,
.combos-device-row select:focus,
.equipment-planner-socket-links select:hover,
.equipment-planner-socket-links select:focus,
.equipment-planner-socket-controls select:hover,
.equipment-planner-socket-controls select:focus {
border-color: rgba(196, 181, 253, 0.22);
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23f6c453' viewBox='-6.5 0 32 32'%3E%3Cpath d='M18.813 11.406l-7.906 9.906c-.75.906-1.906.906-2.625 0L.376 11.406c-.75-.938-.375-1.656.781-1.656h16.875c1.188 0 1.531.719.781 1.656z'/%3E%3C/svg%3E"),