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) ?? [] }));
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Download a text payload as a file — Companion-app safe.
|
||||
*
|
||||
* The naive pattern (a detached `<a download>`, `a.click()`, then an immediate
|
||||
* `URL.revokeObjectURL`) silently fails inside the Home Assistant Companion
|
||||
* app's WebView (especially iOS WKWebView): the anchor must be in the DOM,
|
||||
* carry `target="_blank"` so the app's download handler engages, and the
|
||||
* object URL must NOT be revoked before the (asynchronous) download has
|
||||
* started. This mirrors home-assistant-frontend's own `fileDownload` helper.
|
||||
*/
|
||||
export function downloadTextFile(
|
||||
content: string,
|
||||
filename: string,
|
||||
mime: string,
|
||||
): void {
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener";
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.dispatchEvent(new MouseEvent("click"));
|
||||
document.body.removeChild(a);
|
||||
// Revoke late: slower WebViews (the Companion app) kick off the download
|
||||
// asynchronously, and revoking the blob URL immediately cancels it.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a (same-origin) URL as a file — Companion-app safe.
|
||||
*
|
||||
* For binary blobs served by our authenticated view: fetch a signed path via
|
||||
* `auth/sign_path` first, then hand the signed URL here. Same DOM-anchored,
|
||||
* `target="_blank"` trick as {@link downloadTextFile} so the Companion WebView
|
||||
* engages its download handler; no blob URL to revoke.
|
||||
*/
|
||||
export function downloadUrl(url: string, filename: string): void {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener";
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.dispatchEvent(new MouseEvent("click"));
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Human-readable byte size (B / KB / MB) for document storage figures. */
|
||||
export function formatBytes(bytes: number | undefined): string {
|
||||
const b = bytes ?? 0;
|
||||
if (b < 1024) return `${b} B`;
|
||||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
|
||||
return `${(b / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Unit-aware interval math (issue #59) for the frontend's progress bars and
|
||||
* calendar projection stepping. Uses an AVERAGE day-span per unit (below), an
|
||||
* intentional approximation — the backend `helpers/dates.add_interval` is
|
||||
* calendar-exact (Jan+1mo = 31 days), so display-side spans can differ by a day
|
||||
* or two. Cosmetic only. Pure functions — unit-tested in interval.test.ts.
|
||||
*/
|
||||
|
||||
/** Approximate (average) days per interval unit — mean Gregorian month / Julian
|
||||
* year. NOT calendar-exact; see the module note above. */
|
||||
export const UNIT_DAYS: Record<string, number> = {
|
||||
days: 1,
|
||||
weeks: 7,
|
||||
months: 30.4368,
|
||||
years: 365.25,
|
||||
};
|
||||
|
||||
/** Approximate length of `intervalDays` of `unit` in days; 0 when no interval. */
|
||||
export function intervalSpanDays(
|
||||
intervalDays: number | null | undefined,
|
||||
unit?: string | null,
|
||||
): number {
|
||||
if (!intervalDays || intervalDays <= 0) return 0;
|
||||
return intervalDays * (UNIT_DAYS[unit || "days"] ?? 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress through the current cycle, unit-aware.
|
||||
* Returns a clamped 0–100 % plus `overflow` (raw % > 100, i.e. past due).
|
||||
* Falls back to {0, false} when there is no usable interval/countdown.
|
||||
*/
|
||||
export function daysProgress(
|
||||
intervalDays: number | null | undefined,
|
||||
daysUntilDue: number | null | undefined,
|
||||
unit?: string | null,
|
||||
): { pct: number; overflow: boolean } {
|
||||
const span = intervalSpanDays(intervalDays, unit);
|
||||
if (span <= 0 || daysUntilDue == null) return { pct: 0, overflow: false };
|
||||
const raw = ((span - daysUntilDue) / span) * 100;
|
||||
return { pct: Math.max(0, Math.min(100, raw)), overflow: raw > 100 };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/** (#67) Objects-table column catalog.
|
||||
*
|
||||
* Shared by the panel's table view and the Settings column-config UI. Keep
|
||||
* KNOWN/DEFAULT in lockstep with const.py (KNOWN_OBJECT_TABLE_COLUMNS /
|
||||
* DEFAULT_OBJECTS_TABLE_COLUMNS) — the backend sanitises to the same set.
|
||||
* Parity is enforced by tests/test_frontend_const_parity.py (drift fails CI).
|
||||
*/
|
||||
|
||||
export interface ObjectColumnDef {
|
||||
/** Stable column key (matches the persisted setting + object field). */
|
||||
key: string;
|
||||
/** i18n key for the header cell + the Settings checkbox label. */
|
||||
labelKey: string;
|
||||
/** Columns the user may not remove (always rendered). */
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/** Canonical order + label mapping. Most labels reuse existing i18n keys. */
|
||||
export const OBJECT_COLUMNS: ObjectColumnDef[] = [
|
||||
{ key: "name", labelKey: "name", required: true },
|
||||
{ key: "manufacturer", labelKey: "manufacturer" },
|
||||
{ key: "model", labelKey: "model" },
|
||||
{ key: "serial_number", labelKey: "serial_number_label" },
|
||||
{ key: "installation_date", labelKey: "installed" },
|
||||
{ key: "warranty_expiry", labelKey: "warranty" },
|
||||
{ key: "area_id", labelKey: "area" },
|
||||
{ key: "documentation_url", labelKey: "documentation_url_label" },
|
||||
{ key: "notes", labelKey: "object_notes_label" },
|
||||
{ key: "task_count", labelKey: "tasks" },
|
||||
{ key: "actions", labelKey: "actions" },
|
||||
];
|
||||
|
||||
export const KNOWN_OBJECT_COLUMNS: string[] = OBJECT_COLUMNS.map((c) => c.key);
|
||||
|
||||
export const DEFAULT_OBJECTS_TABLE_COLUMNS: string[] = [
|
||||
"name",
|
||||
"manufacturer",
|
||||
"model",
|
||||
"serial_number",
|
||||
"installation_date",
|
||||
"warranty_expiry",
|
||||
"area_id",
|
||||
"task_count",
|
||||
"actions",
|
||||
];
|
||||
|
||||
/**
|
||||
* Sanitise a stored/configured column list to known keys (order preserved,
|
||||
* deduped). Falls back to the defaults when empty/invalid, and always keeps
|
||||
* the required `name` column so the table stays usable. Mirrors the backend.
|
||||
*/
|
||||
export function sanitizeColumns(cols: unknown): string[] {
|
||||
if (!Array.isArray(cols)) return [...DEFAULT_OBJECTS_TABLE_COLUMNS];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const c of cols) {
|
||||
if (typeof c === "string" && KNOWN_OBJECT_COLUMNS.includes(c) && !seen.has(c)) {
|
||||
seen.add(c);
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
if (!out.length) return [...DEFAULT_OBJECTS_TABLE_COLUMNS];
|
||||
if (!out.includes("name")) out.unshift("name");
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/** Self-contained printable maintenance report for one object.
|
||||
*
|
||||
* Produces a full HTML document (inline print CSS) that the panel opens in a new
|
||||
* tab via a Blob URL — the user then prints or "Save as PDF" from the browser.
|
||||
* No PDF dependency, works offline. All user content is HTML-escaped.
|
||||
*/
|
||||
|
||||
import type { MaintenanceObject, MaintenanceTask } from "../types";
|
||||
|
||||
export interface ReportLabels {
|
||||
title: string;
|
||||
generated: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
serial: string;
|
||||
installed: string;
|
||||
warranty: string;
|
||||
area: string;
|
||||
notes: string;
|
||||
tasksHeading: string;
|
||||
colTask: string;
|
||||
colType: string;
|
||||
colStatus: string;
|
||||
colSchedule: string;
|
||||
colLastDone: string;
|
||||
colNextDue: string;
|
||||
colCost: string;
|
||||
colTimes: string;
|
||||
totalCost: string;
|
||||
/** Human schedule label for a task — pass formatRecurrence so calendar
|
||||
* kinds (weekdays/nth_weekday/…) render properly, not just intervals. */
|
||||
scheduleLabel: (t: MaintenanceTask) => string;
|
||||
statusLabel: (s: string) => string;
|
||||
typeLabel: (t: string) => string;
|
||||
none: string;
|
||||
}
|
||||
|
||||
function esc(v: unknown): string {
|
||||
return String(v ?? "").replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c] as string));
|
||||
}
|
||||
|
||||
export function buildObjectReportHtml(
|
||||
obj: MaintenanceObject,
|
||||
tasks: MaintenanceTask[],
|
||||
labels: ReportLabels,
|
||||
fmtDate: (iso: string | null | undefined) => string,
|
||||
currencySymbol: string,
|
||||
nowIso: string,
|
||||
): string {
|
||||
const meta = ([
|
||||
[labels.manufacturer, obj.manufacturer],
|
||||
[labels.model, obj.model],
|
||||
[labels.serial, obj.serial_number],
|
||||
[labels.installed, obj.installation_date ? fmtDate(obj.installation_date) : null],
|
||||
[labels.warranty, obj.warranty_expiry ? fmtDate(obj.warranty_expiry) : null],
|
||||
] as Array<[string, string | null | undefined]>).filter(([, v]) => !!v);
|
||||
|
||||
const rows = tasks.map((t) => {
|
||||
const schedule = labels.scheduleLabel(t);
|
||||
return `<tr>
|
||||
<td>${esc(t.name)}</td>
|
||||
<td>${esc(labels.typeLabel(t.type))}</td>
|
||||
<td>${esc(labels.statusLabel(t.status))}</td>
|
||||
<td>${esc(schedule)}</td>
|
||||
<td>${esc(t.last_performed ? fmtDate(t.last_performed) : labels.none)}</td>
|
||||
<td>${esc(t.next_due ? fmtDate(t.next_due) : labels.none)}</td>
|
||||
<td class="num">${t.times_performed ?? 0}</td>
|
||||
<td class="num">${(t.total_cost ?? 0).toFixed(2)} ${esc(currencySymbol)}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
const totalCost = tasks.reduce((n, t) => n + (t.total_cost ?? 0), 0);
|
||||
|
||||
return `<!DOCTYPE html><html><head><meta charset="utf-8">
|
||||
<title>${esc(labels.title)} — ${esc(obj.name)}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font: 13px/1.5 -apple-system, Segoe UI, Roboto, sans-serif; color: #1a1a1a; margin: 32px; }
|
||||
h1 { font-size: 22px; margin: 0 0 2px; }
|
||||
.sub { color: #666; margin: 0 0 20px; }
|
||||
.meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 6px 24px; margin-bottom: 20px; }
|
||||
.meta div { border-bottom: 1px solid #eee; padding: 4px 0; }
|
||||
.meta .k { color: #888; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
h2 { font-size: 15px; margin: 24px 0 8px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid #eee; vertical-align: top; }
|
||||
th { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: #888; border-bottom: 2px solid #ccc; }
|
||||
td.num, th.num { text-align: right; }
|
||||
tfoot td { font-weight: 600; border-top: 2px solid #ccc; border-bottom: none; }
|
||||
.notes { margin-top: 16px; white-space: pre-wrap; color: #333; }
|
||||
@media print { body { margin: 0; } @page { margin: 16mm; } }
|
||||
</style></head><body>
|
||||
<h1>${esc(obj.name)}</h1>
|
||||
<p class="sub">${esc(labels.title)} · ${esc(labels.generated)}: ${esc(fmtDate(nowIso))}</p>
|
||||
${meta.length ? `<div class="meta">${meta.map(([k, v]) =>
|
||||
`<div><div class="k">${esc(k)}</div>${esc(v)}</div>`).join("")}</div>` : ""}
|
||||
<h2>${esc(labels.tasksHeading)} (${tasks.length})</h2>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>${esc(labels.colTask)}</th><th>${esc(labels.colType)}</th><th>${esc(labels.colStatus)}</th>
|
||||
<th>${esc(labels.colSchedule)}</th><th>${esc(labels.colLastDone)}</th><th>${esc(labels.colNextDue)}</th>
|
||||
<th class="num">${esc(labels.colTimes)}</th><th class="num">${esc(labels.colCost)}</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows || `<tr><td colspan="8">${esc(labels.none)}</td></tr>`}</tbody>
|
||||
<tfoot><tr><td colspan="7">${esc(labels.totalCost)}</td><td class="num">${totalCost.toFixed(2)} ${esc(currencySymbol)}</td></tr></tfoot>
|
||||
</table>
|
||||
${obj.notes ? `<div class="notes"><strong>${esc(labels.notes)}:</strong>\n${esc(obj.notes)}</div>` : ""}
|
||||
</body></html>`;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/** Windowing math for the virtualized dashboard task table (500+ tasks).
|
||||
*
|
||||
* Pure functions only — the panel measures the scroll container / row height
|
||||
* and renders `rows.slice(start, end)` between two grid-spanning spacers whose
|
||||
* heights keep the scrollbar honest. The window start/end snap to a `step`
|
||||
* grid so a 1-row scroll doesn't rebind the whole slice on every frame.
|
||||
*
|
||||
* Why windowing at all: the table's cross-row column alignment comes from CSS
|
||||
* subgrid, which is incompatible with `content-visibility`/`size` containment
|
||||
* (the cheap skip-offscreen-paint trick used elsewhere). So for large installs
|
||||
* the initial O(n) render is paid on every keystroke/filter change unless the
|
||||
* DOM itself is windowed.
|
||||
*/
|
||||
|
||||
/** Rows below this count render normally — windowing has bookkeeping cost and
|
||||
* only pays off when the O(n) render actually hurts. */
|
||||
export const VIRTUAL_MIN_ROWS = 120;
|
||||
|
||||
export interface VirtualWindowInput {
|
||||
/** Scroll offset of the scrolling container. */
|
||||
scrollTop: number;
|
||||
/** Visible (client) height of the scrolling container. */
|
||||
viewportHeight: number;
|
||||
/** Top of the table relative to the container's content (scrollTop space). */
|
||||
listTop: number;
|
||||
/** Measured uniform row height in px (clamped to >= 1). */
|
||||
rowHeight: number;
|
||||
/** Total row count. */
|
||||
total: number;
|
||||
/** Extra rows rendered above/below the viewport. Default 12. */
|
||||
overscan?: number;
|
||||
/** Start/end snap to this grid to avoid re-render churn. Default 6. */
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export interface VirtualWindow {
|
||||
start: number;
|
||||
end: number;
|
||||
padTop: number;
|
||||
padBottom: number;
|
||||
}
|
||||
|
||||
export function computeWindow(i: VirtualWindowInput): VirtualWindow {
|
||||
if (i.total <= 0) return { start: 0, end: 0, padTop: 0, padBottom: 0 };
|
||||
const overscan = i.overscan ?? 12;
|
||||
const step = Math.max(1, i.step ?? 6);
|
||||
const rh = Math.max(1, i.rowHeight);
|
||||
|
||||
const firstVisible = Math.floor((i.scrollTop - i.listTop) / rh);
|
||||
const visibleCount = Math.ceil(i.viewportHeight / rh) + 1;
|
||||
|
||||
let start = Math.max(0, firstVisible - overscan);
|
||||
start = Math.floor(start / step) * step;
|
||||
let end = Math.min(i.total, Math.max(firstVisible, 0) + visibleCount + overscan);
|
||||
end = Math.min(i.total, Math.ceil(end / step) * step);
|
||||
|
||||
// Degenerate inputs (table far above/below viewport): keep a sane window.
|
||||
if (start >= end) {
|
||||
start = Math.min(start, Math.max(0, i.total - 1));
|
||||
end = Math.min(i.total, start + Math.max(visibleCount, 1));
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
padTop: start * rh,
|
||||
padBottom: (i.total - end) * rh,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/** (#67) Warranty status computation for object asset tracking.
|
||||
*
|
||||
* Pure, side-effect-free, and `today`-injectable so it can be unit-tested
|
||||
* deterministically without faking the system clock.
|
||||
*/
|
||||
|
||||
export type WarrantyStatusKind = "valid" | "expiring" | "expired" | "none";
|
||||
|
||||
export interface WarrantyStatus {
|
||||
kind: WarrantyStatusKind;
|
||||
/** Whole days until expiry (negative once expired); null when no date. */
|
||||
days: number | null;
|
||||
/** The ISO date string echoed back, or null when absent/invalid. */
|
||||
date: string | null;
|
||||
}
|
||||
|
||||
/** Days before expiry at which the warranty is flagged as "expiring" (amber). */
|
||||
export const WARRANTY_WARN_DAYS = 60;
|
||||
|
||||
/**
|
||||
* Classify an object's warranty expiry date relative to `today`.
|
||||
*
|
||||
* @param iso ISO `YYYY-MM-DD` warranty expiry date (null/undefined/""/invalid → "none").
|
||||
* @param today Reference date; defaults to now. Injectable for deterministic tests.
|
||||
*/
|
||||
export function warrantyStatus(
|
||||
iso: string | null | undefined,
|
||||
today: Date = new Date(),
|
||||
): WarrantyStatus {
|
||||
if (!iso) return { kind: "none", days: null, date: null };
|
||||
const exp = new Date(`${iso}T00:00:00`);
|
||||
if (isNaN(exp.getTime())) return { kind: "none", days: null, date: null };
|
||||
// Normalize both ends to local midnight so the difference is whole days
|
||||
// regardless of the time-of-day component of `today`.
|
||||
const t0 = Date.UTC(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
const t1 = Date.UTC(exp.getFullYear(), exp.getMonth(), exp.getDate());
|
||||
const days = Math.round((t1 - t0) / 86400000);
|
||||
if (days < 0) return { kind: "expired", days, date: iso };
|
||||
if (days <= WARRANTY_WARN_DAYS) return { kind: "expiring", days, date: iso };
|
||||
return { kind: "valid", days, date: iso };
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/** Task work sheet (v2.21) — a printable one-pager for a single task.
|
||||
*
|
||||
* Everything needed to actually DO the task, on one sheet of paper: object
|
||||
* + task details, the checklist as real tick boxes, the notes, and a QR
|
||||
* pair (open the task / complete it) so the paper links back to the panel.
|
||||
* When the task has a linked PDF manual with a page hint, the manual
|
||||
* excerpt pages are rendered INLINE (downscaled, two per row) via the
|
||||
* vendored pdf.js legacy build — the whole work sheet prints as one
|
||||
* document. The PDF link stays as the fallback when rendering fails.
|
||||
*
|
||||
* Mirrors helpers/report.ts: pure function building a self-contained HTML
|
||||
* document; the panel opens it in a new tab where the user prints or saves
|
||||
* as PDF. All user strings are escaped; labels arrive translated.
|
||||
*/
|
||||
|
||||
import type { MaintenanceTask } from "../types";
|
||||
|
||||
export interface WorksheetLabels {
|
||||
title: string; // "Work sheet"
|
||||
object: string;
|
||||
type: string;
|
||||
interval: string;
|
||||
nextDue: string;
|
||||
lastDone: string;
|
||||
priority: string;
|
||||
checklist: string;
|
||||
notes: string;
|
||||
scanView: string; // "Scan to open the task"
|
||||
scanComplete: string; // "Scan to complete"
|
||||
manualExcerpt: string; // "Manual excerpt"
|
||||
pages: string; // "pages"
|
||||
printedOn: string; // "Printed"
|
||||
never: string;
|
||||
typeLabel: (t: string) => string;
|
||||
statusLabel: (s: string) => string;
|
||||
}
|
||||
|
||||
export interface WorksheetExcerpt {
|
||||
title: string;
|
||||
startPage: number;
|
||||
endPage: number;
|
||||
url: string; // signed excerpt-endpoint URL (absolute)
|
||||
/** Absolute base URL of the vendored pdf.js assets; when set, the sheet
|
||||
* renders the excerpt pages inline (downscaled 2-up) via pdf.js. */
|
||||
vendorBase?: string;
|
||||
}
|
||||
|
||||
const esc = (v: unknown): string =>
|
||||
String(v ?? "").replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] as string);
|
||||
|
||||
export function buildTaskWorksheetHtml(
|
||||
task: MaintenanceTask,
|
||||
objectName: string,
|
||||
L: WorksheetLabels,
|
||||
formatDate: (iso: string) => string,
|
||||
formatRecurrence: (task: MaintenanceTask) => string,
|
||||
qrViewDataUri: string | null,
|
||||
qrCompleteDataUri: string | null,
|
||||
excerpt: WorksheetExcerpt | null,
|
||||
nowIso: string,
|
||||
): string {
|
||||
const meta: Array<[string, string]> = [
|
||||
[L.object, esc(objectName)],
|
||||
[L.type, esc(L.typeLabel(task.type))],
|
||||
[L.interval, esc(formatRecurrence(task))],
|
||||
[L.nextDue, task.next_due ? esc(formatDate(task.next_due)) : "—"],
|
||||
[L.lastDone, task.last_performed ? esc(formatDate(task.last_performed)) : esc(L.never)],
|
||||
];
|
||||
if (task.priority && task.priority !== "normal") {
|
||||
meta.push([L.priority, esc(task.priority)]);
|
||||
}
|
||||
|
||||
const checklist = (task.checklist || [])
|
||||
.map((item) => `<li><span class="box"></span>${esc(item)}</li>`)
|
||||
.join("");
|
||||
|
||||
const qr = (uri: string | null, caption: string) =>
|
||||
uri
|
||||
? `<figure class="qr"><img src="${uri}" alt="" /><figcaption>${esc(caption)}</figcaption></figure>`
|
||||
: "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>${esc(task.name)} — ${esc(L.title)}</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 14mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { font: 13px/1.45 -apple-system, "Segoe UI", Roboto, sans-serif; color: #111; margin: 0; }
|
||||
header { display: flex; justify-content: space-between; align-items: flex-start;
|
||||
border-bottom: 3px solid #111; padding-bottom: 8px; margin-bottom: 12px; }
|
||||
h1 { font-size: 22px; margin: 0 0 2px; }
|
||||
.obj { font-size: 14px; color: #444; }
|
||||
.qr-row { display: flex; gap: 18px; }
|
||||
.qr { margin: 0; text-align: center; }
|
||||
.qr img { width: 88px; height: 88px; display: block; }
|
||||
.qr figcaption { font-size: 9px; color: #555; max-width: 96px; }
|
||||
table.meta { border-collapse: collapse; margin-bottom: 12px; }
|
||||
table.meta td { padding: 2px 14px 2px 0; vertical-align: top; }
|
||||
table.meta td:first-child { color: #555; white-space: nowrap; }
|
||||
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.06em;
|
||||
border-bottom: 1px solid #bbb; padding-bottom: 2px; margin: 14px 0 6px; }
|
||||
ul.check { list-style: none; padding: 0; margin: 0; }
|
||||
ul.check li { display: flex; align-items: flex-start; gap: 8px; padding: 4px 0; font-size: 14px; }
|
||||
.box { width: 14px; height: 14px; border: 1.6px solid #111; border-radius: 2px;
|
||||
flex: 0 0 auto; margin-top: 2px; }
|
||||
.notes { white-space: pre-wrap; }
|
||||
.excerpt a { color: #0b57d0; word-break: break-all; }
|
||||
.excerpt-pages { display: flex; flex-wrap: wrap; gap: 3mm; margin-top: 4mm; }
|
||||
.excerpt-pages canvas { width: calc(50% - 2mm); height: auto;
|
||||
border: 0.4px solid #ccc; break-inside: avoid; }
|
||||
footer { position: fixed; bottom: 0; left: 0; right: 0; font-size: 9px; color: #888;
|
||||
border-top: 1px solid #ddd; padding-top: 3px; }
|
||||
@media screen { body { max-width: 800px; margin: 24px auto; padding: 0 16px; } }
|
||||
</style></head>
|
||||
<body>
|
||||
<header>
|
||||
<div>
|
||||
<h1>${esc(task.name)}</h1>
|
||||
<div class="obj">${esc(objectName)}</div>
|
||||
</div>
|
||||
<div class="qr-row">
|
||||
${qr(qrViewDataUri, L.scanView)}
|
||||
${qr(qrCompleteDataUri, L.scanComplete)}
|
||||
</div>
|
||||
</header>
|
||||
<table class="meta">
|
||||
${meta.map(([k, v]) => `<tr><td>${esc(k)}</td><td>${v}</td></tr>`).join("")}
|
||||
</table>
|
||||
${checklist ? `<h2>${esc(L.checklist)}</h2><ul class="check">${checklist}</ul>` : ""}
|
||||
${task.notes ? `<h2>${esc(L.notes)}</h2><div class="notes">${esc(task.notes)}</div>` : ""}
|
||||
${excerpt ? `<h2>${esc(L.manualExcerpt)}</h2>
|
||||
<div class="excerpt">${esc(excerpt.title)} — ${esc(L.pages)} ${excerpt.startPage}–${excerpt.endPage}:
|
||||
<a href="${esc(excerpt.url)}" target="_blank" rel="noopener">PDF</a>
|
||||
</div>
|
||||
<div id="excerpt-pages" class="excerpt-pages"></div>
|
||||
${excerpt.vendorBase ? `<script type="module">
|
||||
// Render the excerpt pages inline (downscaled, two per row) so the
|
||||
// whole work sheet prints as ONE document. The link above stays as
|
||||
// the fallback if pdf.js or the fetch fails.
|
||||
try {
|
||||
const pdfjs = await import(${JSON.stringify(excerpt.vendorBase + "/pdf.min.mjs")});
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = ${JSON.stringify(excerpt.vendorBase + "/pdf.worker.min.mjs")};
|
||||
const doc = await pdfjs.getDocument({ url: ${JSON.stringify(excerpt.url)} }).promise;
|
||||
const host = document.getElementById("excerpt-pages");
|
||||
for (let n = 1; n <= doc.numPages; n++) {
|
||||
const page = await doc.getPage(n);
|
||||
const viewport = page.getViewport({ scale: 1.4 }); // crisp at ~50% print width
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = viewport.width; canvas.height = viewport.height;
|
||||
await page.render({ canvasContext: canvas.getContext("2d"), viewport }).promise;
|
||||
host.appendChild(canvas);
|
||||
}
|
||||
} catch (e) { console.warn("excerpt inline render failed", e); }
|
||||
</script>` : ""}` : ""}
|
||||
<footer>${esc(objectName)} · ${esc(task.name)} · ${esc(L.printedOn)} ${esc(nowIso.slice(0, 10))}</footer>
|
||||
</body></html>`;
|
||||
}
|
||||
Reference in New Issue
Block a user