New apps Added

This commit is contained in:
2026-07-08 10:43:39 -04:00
parent 3b1f4bbd75
commit fefc2c8b5c
1114 changed files with 406637 additions and 154 deletions
@@ -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 0100 % 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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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>`;
}