388 lines
14 KiB
TypeScript
388 lines
14 KiB
TypeScript
/**
|
||
* Pure helper for the panel's Calendar tab (v1.5.0+).
|
||
*
|
||
* Given the loaded objects, a window in days, and an optional user filter,
|
||
* produce a flat day-bucketed event list ready for rendering.
|
||
*
|
||
* Recurring projection (time-based tasks): the first occurrence is the task's
|
||
* next_due (or "today" if overdue/triggered). Subsequent occurrences are
|
||
* projected by adding interval_days repeatedly until the window end, capped
|
||
* at MAX_OCCURRENCES_PER_TASK to prevent absurdly small intervals (e.g. a
|
||
* 1-day-interval task in a 30-day window would otherwise produce 30 entries).
|
||
*
|
||
* Sensor-triggered tasks: only next_due is shown — we have no honest way to
|
||
* predict when a sensor will next fire.
|
||
*/
|
||
|
||
import type { MaintenanceObjectResponse } from "../types";
|
||
import { intervalSpanDays } from "./interval";
|
||
|
||
export const MAX_OCCURRENCES_PER_TASK = 5;
|
||
|
||
export interface CalendarEvent {
|
||
/** ISO date string (YYYY-MM-DD) — the day this event is bucketed under. */
|
||
date: string;
|
||
entry_id: string;
|
||
task_id: string;
|
||
task_name: string;
|
||
object_name: string;
|
||
status: string; // "ok" | "due_soon" | "overdue" | "triggered"
|
||
days_until_due: number | null;
|
||
/** True if this is a projected recurrence (not the actual next_due). */
|
||
projected: boolean;
|
||
schedule_type: string;
|
||
interval_days: number | null;
|
||
/** v2.6.3 (#59): interval unit so the "every N …" label + projection match. */
|
||
interval_unit?: string | null;
|
||
responsible_user_id: string | null;
|
||
avg_cost: number | null;
|
||
/** v1.5.1: source indicator. */
|
||
adaptive_enabled: boolean;
|
||
/** v1.5.1: sensor prediction confidence ("low" | "medium" | "high") or null
|
||
* for time-based / no prediction available. */
|
||
prediction_confidence: string | null;
|
||
/** v2.2.0 — for past-window events, the original ISO timestamp of the
|
||
* underlying history entry. The history-edit WS uses this as the stable
|
||
* identifier (see ws_update_history_entry / original_timestamp). null
|
||
* for forward-projected / next_due events. */
|
||
history_timestamp?: string | null;
|
||
/** v2.2.0 — history entry type for past events
|
||
* ("completed" | "reset" | "skipped" | "triggered"). null for future. */
|
||
history_type?: string | null;
|
||
/** v2.2.0 — original cost / notes / duration from the history entry,
|
||
* so the past-events list shows what really happened (not avg_cost). */
|
||
history_cost?: number | null;
|
||
history_notes?: string | null;
|
||
history_duration?: number | null;
|
||
}
|
||
|
||
export interface CalendarDayBucket {
|
||
date: string; // ISO date YYYY-MM-DD
|
||
events: CalendarEvent[];
|
||
}
|
||
|
||
/**
|
||
* Format a Date as ISO YYYY-MM-DD using LOCAL time components — matches what
|
||
* the user sees on their wall clock, not UTC. The naive `.toISOString().slice(0,10)`
|
||
* approach silently shifts dates near midnight in non-UTC timezones.
|
||
*/
|
||
export function isoDateLocal(d: Date): string {
|
||
const y = d.getFullYear();
|
||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||
const day = String(d.getDate()).padStart(2, "0");
|
||
return `${y}-${m}-${day}`;
|
||
}
|
||
|
||
/** Build the list of N consecutive ISO dates starting today (local). */
|
||
export function buildWindowDates(today: Date, windowDays: number): string[] {
|
||
const out: string[] = [];
|
||
for (let i = 0; i < windowDays; i++) {
|
||
const d = new Date(today);
|
||
d.setDate(d.getDate() + i);
|
||
d.setHours(0, 0, 0, 0);
|
||
out.push(isoDateLocal(d));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Add `days` to an ISO date (local), return new ISO date. */
|
||
function addDaysIso(iso: string, days: number): string {
|
||
const [y, m, d] = iso.split("-").map(Number);
|
||
const date = new Date(y, m - 1, d);
|
||
date.setDate(date.getDate() + days);
|
||
return isoDateLocal(date);
|
||
}
|
||
|
||
/** Average recorded cost across history rows that HAVE a cost (mean of
|
||
* non-null `cost` values). Note this differs from the panel KPI, which divides
|
||
* total_cost by times_performed — completions without a recorded cost are
|
||
* counted differently. This mean is used only for the calendar tooltip. */
|
||
function computeAvgCost(history: Array<{ cost?: number | null }> | undefined): number | null {
|
||
if (!history || history.length === 0) return null;
|
||
const costs = history.map((h) => h.cost).filter((c): c is number => typeof c === "number");
|
||
if (costs.length === 0) return null;
|
||
return costs.reduce((a, b) => a + b, 0) / costs.length;
|
||
}
|
||
|
||
interface ProjectionInput {
|
||
windowStart: string;
|
||
windowEnd: string; // inclusive
|
||
task: any; // panel's task shape from MaintenanceObjectResponse
|
||
entryId: string;
|
||
objectName: string;
|
||
}
|
||
|
||
/**
|
||
* Project up to MAX_OCCURRENCES_PER_TASK occurrences for a single task.
|
||
* Returns events keyed by their bucketed date.
|
||
*/
|
||
function projectTask(input: ProjectionInput): CalendarEvent[] {
|
||
const { windowStart, windowEnd, task, entryId, objectName } = input;
|
||
const out: CalendarEvent[] = [];
|
||
|
||
const baseEvent = (date: string, projected: boolean): CalendarEvent => ({
|
||
date,
|
||
entry_id: entryId,
|
||
task_id: task.id,
|
||
task_name: task.name,
|
||
object_name: objectName,
|
||
// Projected recurrences are hypothetical future occurrences that assume
|
||
// the current cycle resolves on schedule. If the parent task is currently
|
||
// overdue/triggered, carrying that status forward to the projection (e.g.
|
||
// "OVERDUE 211d" on the May 7 projection of a 7-day-interval task) is
|
||
// misleading — the projection IS the assumption that the user completes
|
||
// it today, so the projected slot should read as a fresh "ok" event.
|
||
status: projected && (task.status === "overdue" || task.status === "triggered")
|
||
? "ok"
|
||
: task.status,
|
||
days_until_due: projected ? null : (task.days_until_due ?? null),
|
||
projected,
|
||
schedule_type: task.schedule_type,
|
||
interval_days: task.interval_days ?? null,
|
||
interval_unit: task.interval_unit ?? null,
|
||
responsible_user_id: task.responsible_user_id ?? null,
|
||
avg_cost: computeAvgCost(task.history),
|
||
adaptive_enabled: !!task.adaptive_config?.enabled,
|
||
prediction_confidence: task.threshold_prediction_confidence ?? null,
|
||
});
|
||
|
||
// Step between projected occurrences, unit-aware (issue #59): a weeks/months/
|
||
// years interval must advance by its real day-span, not the raw count (else a
|
||
// "1 year" task projects at 1-day steps and spams the window).
|
||
const stepDays = Math.max(
|
||
1, Math.round(intervalSpanDays(task.interval_days, task.interval_unit)),
|
||
);
|
||
|
||
// Overdue / triggered → bucket on today (windowStart) regardless of next_due
|
||
if (task.status === "overdue" || task.status === "triggered") {
|
||
out.push(baseEvent(windowStart, false));
|
||
// Continue projecting from "today" forward for time-based tasks
|
||
if (task.schedule_type === "time_based" && task.interval_days && task.interval_days > 0) {
|
||
let cursor = addDaysIso(windowStart, stepDays);
|
||
let count = 1;
|
||
while (cursor <= windowEnd && count < MAX_OCCURRENCES_PER_TASK) {
|
||
out.push(baseEvent(cursor, true));
|
||
count++;
|
||
cursor = addDaysIso(cursor, stepDays);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Non-actionable status (ok / due_soon): need a next_due
|
||
const nextDue = task.next_due;
|
||
if (typeof nextDue !== "string" || !nextDue) return out;
|
||
const firstDate = nextDue.slice(0, 10); // strip time portion if any
|
||
|
||
// First occurrence must be in window
|
||
if (firstDate >= windowStart && firstDate <= windowEnd) {
|
||
out.push(baseEvent(firstDate, false));
|
||
} else if (firstDate > windowEnd) {
|
||
return out; // entire occurrence chain is past window
|
||
}
|
||
|
||
// Subsequent projected occurrences (time-based only, with interval_days)
|
||
if (task.schedule_type === "time_based" && task.interval_days && task.interval_days > 0) {
|
||
let cursor = addDaysIso(firstDate, stepDays);
|
||
let count = out.length;
|
||
while (cursor <= windowEnd && count < MAX_OCCURRENCES_PER_TASK) {
|
||
// Skip occurrences before window start (when next_due itself was past)
|
||
if (cursor >= windowStart) {
|
||
out.push(baseEvent(cursor, true));
|
||
count++;
|
||
}
|
||
cursor = addDaysIso(cursor, stepDays);
|
||
}
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
/** Status sort priority: lower = shown first. */
|
||
const STATUS_RANK: Record<string, number> = {
|
||
overdue: 0,
|
||
triggered: 1,
|
||
due_soon: 2,
|
||
ok: 3,
|
||
};
|
||
|
||
/**
|
||
* Main entry point: build the day-bucketed event list for the Calendar tab.
|
||
*
|
||
* @param objects The panel's loaded objects (with nested tasks).
|
||
* @param today "Today" as a Date (caller passes new Date() — easier for tests).
|
||
* @param windowDays Number of days to include (7 / 14 / 30).
|
||
* @param userFilter null/empty = all; otherwise filter tasks by responsible_user_id.
|
||
*/
|
||
export function buildCalendarBuckets(
|
||
objects: MaintenanceObjectResponse[],
|
||
today: Date,
|
||
windowDays: number,
|
||
userFilter: string | null = null,
|
||
): CalendarDayBucket[] {
|
||
const days = buildWindowDates(today, windowDays);
|
||
const windowStart = days[0];
|
||
const windowEnd = days[days.length - 1];
|
||
|
||
const allEvents: CalendarEvent[] = [];
|
||
for (const obj of objects) {
|
||
const objectName = obj.object?.name || "";
|
||
const entryId = obj.entry_id;
|
||
const tasks = obj.tasks || [];
|
||
for (const task of tasks) {
|
||
// User filter
|
||
if (userFilter && task.responsible_user_id !== userFilter) continue;
|
||
// Disabled tasks never produce calendar events
|
||
if (task.enabled === false) continue;
|
||
const projected = projectTask({
|
||
windowStart, windowEnd, task, entryId, objectName,
|
||
});
|
||
allEvents.push(...projected);
|
||
}
|
||
}
|
||
|
||
// Bucket by date
|
||
const byDate = new Map<string, CalendarEvent[]>();
|
||
for (const day of days) byDate.set(day, []);
|
||
for (const ev of allEvents) {
|
||
const bucket = byDate.get(ev.date);
|
||
if (bucket) bucket.push(ev);
|
||
}
|
||
|
||
// Sort within day: status priority first, then projected last, then by name
|
||
for (const [, evs] of byDate) {
|
||
evs.sort((a, b) => {
|
||
const rA = STATUS_RANK[a.status] ?? 99;
|
||
const rB = STATUS_RANK[b.status] ?? 99;
|
||
if (rA !== rB) return rA - rB;
|
||
if (a.projected !== b.projected) return a.projected ? 1 : -1;
|
||
const cmp = a.object_name.localeCompare(b.object_name);
|
||
if (cmp !== 0) return cmp;
|
||
return a.task_name.localeCompare(b.task_name);
|
||
});
|
||
}
|
||
|
||
return days.map((d) => ({ date: d, events: byDate.get(d) ?? [] }));
|
||
}
|
||
|
||
// ── v2.2.0 — past-window bucketer ──────────────────────────────────────────
|
||
//
|
||
// Same shape as buildCalendarBuckets but walks the task HISTORY (not next_due)
|
||
// and buckets entries by their actual completion/reset/skip/trigger date.
|
||
// Used by the calendar card's past-mode chip ("← 30 d") to surface
|
||
// recently-performed maintenance for review or correction.
|
||
//
|
||
// Window: from (today − pastDays + 1) to today, inclusive — so passing 30
|
||
// gives 30 day-buckets ending today.
|
||
|
||
const HISTORY_TYPE_TO_STATUS: Record<string, string> = {
|
||
completed: "ok",
|
||
reset: "ok",
|
||
skipped: "due_soon",
|
||
triggered: "triggered",
|
||
trigger_replaced: "triggered",
|
||
trigger_removed: "ok", // config change, not a due/triggered event
|
||
};
|
||
|
||
interface HistoryEntryShape {
|
||
timestamp?: string;
|
||
type?: string;
|
||
notes?: string;
|
||
cost?: number;
|
||
duration?: number;
|
||
}
|
||
|
||
/** Build a list of N consecutive ISO dates ending today (local). */
|
||
export function buildPastWindowDates(today: Date, pastDays: number): string[] {
|
||
const out: string[] = [];
|
||
for (let i = pastDays - 1; i >= 0; i--) {
|
||
const d = new Date(today);
|
||
d.setDate(d.getDate() - i);
|
||
d.setHours(0, 0, 0, 0);
|
||
out.push(isoDateLocal(d));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Build day-bucketed events for the *past* N days using task history.
|
||
*
|
||
* Each history entry whose timestamp falls inside the window becomes one
|
||
* CalendarEvent with `history_timestamp` set — the calendar card uses that
|
||
* timestamp as the stable key when dispatching an edit-history click event.
|
||
*/
|
||
export function buildPastBuckets(
|
||
objects: MaintenanceObjectResponse[],
|
||
today: Date,
|
||
pastDays: number,
|
||
userFilter: string | null = null,
|
||
): CalendarDayBucket[] {
|
||
const days = buildPastWindowDates(today, pastDays);
|
||
const windowStart = days[0];
|
||
const windowEnd = days[days.length - 1];
|
||
|
||
const byDate = new Map<string, CalendarEvent[]>();
|
||
for (const day of days) byDate.set(day, []);
|
||
|
||
for (const obj of objects) {
|
||
const objectName = obj.object?.name || "";
|
||
const entryId = obj.entry_id;
|
||
const tasks = obj.tasks || [];
|
||
for (const task of tasks) {
|
||
if (userFilter && task.responsible_user_id !== userFilter) continue;
|
||
const history = (task.history || []) as HistoryEntryShape[];
|
||
for (const h of history) {
|
||
if (typeof h?.timestamp !== "string") continue;
|
||
const dateKey = h.timestamp.slice(0, 10); // YYYY-MM-DD
|
||
if (dateKey < windowStart || dateKey > windowEnd) continue;
|
||
const bucket = byDate.get(dateKey);
|
||
if (!bucket) continue;
|
||
const evType = h.type ?? "completed";
|
||
bucket.push({
|
||
date: dateKey,
|
||
entry_id: entryId,
|
||
task_id: task.id,
|
||
task_name: task.name,
|
||
object_name: objectName,
|
||
status: HISTORY_TYPE_TO_STATUS[evType] ?? "ok",
|
||
days_until_due: null,
|
||
projected: false,
|
||
schedule_type: task.schedule_type,
|
||
interval_days: task.interval_days ?? null,
|
||
responsible_user_id: task.responsible_user_id ?? null,
|
||
avg_cost: typeof h.cost === "number" ? h.cost : null,
|
||
adaptive_enabled: !!task.adaptive_config?.enabled,
|
||
prediction_confidence: null,
|
||
history_timestamp: h.timestamp,
|
||
history_type: evType,
|
||
history_cost: typeof h.cost === "number" ? h.cost : null,
|
||
history_notes: typeof h.notes === "string" ? h.notes : null,
|
||
history_duration: typeof h.duration === "number" ? h.duration : null,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Sort within day: type priority (completed first, triggered last) then by
|
||
// object/task name. Past events are never "projected" so no opacity dimming.
|
||
const PAST_TYPE_RANK: Record<string, number> = {
|
||
completed: 0,
|
||
reset: 1,
|
||
skipped: 2,
|
||
triggered: 3,
|
||
trigger_replaced: 4,
|
||
};
|
||
for (const [, evs] of byDate) {
|
||
evs.sort((a, b) => {
|
||
const rA = PAST_TYPE_RANK[a.history_type ?? ""] ?? 99;
|
||
const rB = PAST_TYPE_RANK[b.history_type ?? ""] ?? 99;
|
||
if (rA !== rB) return rA - rB;
|
||
const cmp = a.object_name.localeCompare(b.object_name);
|
||
if (cmp !== 0) return cmp;
|
||
return a.task_name.localeCompare(b.task_name);
|
||
});
|
||
}
|
||
|
||
return days.map((d) => ({ date: d, events: byDate.get(d) ?? [] }));
|
||
}
|