New apps Added
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* 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) ?? [] }));
|
||||
}
|
||||
Reference in New Issue
Block a user