Add calendar toolbox with weekly and monthly scheduling
All checks were successful
Deploy Sokko G / deploy (push) Successful in 14s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 14s
This commit is contained in:
parent
4f73e0068b
commit
992665f4ea
39 changed files with 4331 additions and 36 deletions
|
|
@ -78,6 +78,7 @@ export function AppOverlays({
|
|||
key={notification.id}
|
||||
id={notification.id}
|
||||
message={notification.message}
|
||||
durationMs={notification.durationMs}
|
||||
onClose={dismissNotification}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
1215
website/src/features/toolboxes/modules/CalendarModule.jsx
Normal file
1215
website/src/features/toolboxes/modules/CalendarModule.jsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
266
website/src/features/toolboxes/modules/calendarUtils.js
Normal file
266
website/src/features/toolboxes/modules/calendarUtils.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}` : "#",
|
||||
|
|
|
|||
123
website/src/features/toolboxes/storage/modules/calendar.js
Normal file
123
website/src/features/toolboxes/storage/modules/calendar.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -15,13 +15,23 @@ const NOTEPAD_RGB_COLOR_MAP = {
|
|||
};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
return decodeBasicEntitiesDeep(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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(/ /gi, " ")
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export const ID_PREFIXES = {
|
|||
counter: "c",
|
||||
combo: "o",
|
||||
calc: "r",
|
||||
calendar: "h",
|
||||
marker: "k",
|
||||
timer: "z",
|
||||
task: "a",
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
];
|
||||
|
||||
|
|
|
|||
99
website/src/hooks/useToolboxAlerts.js
Normal file
99
website/src/hooks/useToolboxAlerts.js
Normal 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]);
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -243,7 +243,6 @@
|
|||
}
|
||||
|
||||
.library-doc-nav {
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
|
|
|
|||
1113
website/src/styles/toolboxes/_calendar.scss
Normal file
1113
website/src/styles/toolboxes/_calendar.scss
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -11,6 +11,7 @@
|
|||
@use "counters";
|
||||
@use "calculator";
|
||||
@use "timer";
|
||||
@use "calendar";
|
||||
@use "calculator-results";
|
||||
@use "table";
|
||||
@use "shared-controls";
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue