393 files

This commit is contained in:
Home Assistant Version Control
2026-09-27 17:16:54 +00:00
parent b05b7a897e
commit 39d97ac2db
404 changed files with 54006 additions and 11034 deletions
@@ -16,6 +16,7 @@
import type { MaintenanceObjectResponse } from "../types";
import { intervalSpanDays } from "./interval";
import { statusRank } from "../status-constants";
export const MAX_OCCURRENCES_PER_TASK = 5;
@@ -73,6 +74,14 @@ export function isoDateLocal(d: Date): string {
return `${y}-${m}-${day}`;
}
/** The local wall-clock minute as "YYYY-MM-DDTHH:MM:00" (seconds zeroed) —
* the value shape of a datetime field; the date half is isoDateLocal. */
export function isoMinuteLocal(d: Date): string {
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return `${isoDateLocal(d)}T${hh}:${mm}:00`;
}
/** Build the list of N consecutive ISO dates starting today (local). */
export function buildWindowDates(today: Date, windowDays: number): string[] {
const out: string[] = [];
@@ -198,14 +207,6 @@ function projectTask(input: ProjectionInput): CalendarEvent[] {
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.
*
@@ -252,8 +253,8 @@ export function buildCalendarBuckets(
// 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;
const rA = statusRank(a.status);
const rB = statusRank(b.status);
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);
@@ -288,7 +289,7 @@ const HISTORY_TYPE_TO_STATUS: Record<string, string> = {
trigger_removed: "ok", // config change, not a due/triggered event
};
interface HistoryEntryShape {
export interface HistoryEntryShape {
timestamp?: string;
type?: string;
notes?: string;
@@ -296,6 +297,52 @@ interface HistoryEntryShape {
duration?: number;
}
/** `${entry_id}/${task_id}` — the key of a task's fetched full history. */
export function pastHistoryKey(entryId: string, taskId: string): string {
return `${entryId}/${taskId}`;
}
/** Tasks whose LIST history (the payload diet keeps the last 20 entries)
* may be missing entries inside the past window: more entries exist
* (`history_count`) and the oldest listed one is still inside the window.
* A daily task showed only its last 20 days in the 30/90-day past view
* (DRY audit 2026-09-26). `sig` changes when the task gains / loses
* entries OR a listed entry changes (an edit keeps the count — the card
* kept painting the pre-edit notes / cost / day, bug audit 2026-09-26 #2),
* so the card refetches only then. */
export function pastHistoryGaps(
objects: MaintenanceObjectResponse[],
today: Date,
pastDays: number,
): Array<{ entryId: string; taskId: string; key: string; sig: string }> {
const windowStart = buildPastWindowDates(today, pastDays)[0];
const out: Array<{ entryId: string; taskId: string; key: string; sig: string }> = [];
for (const obj of objects) {
for (const task of obj.tasks || []) {
const listed = (task.history || []) as HistoryEntryShape[];
const count = task.history_count ?? listed.length;
if (count <= listed.length) continue;
const stamps = listed.map((h) => (typeof h?.timestamp === "string" ? h.timestamp : "")).filter(Boolean);
const oldest = stamps.length ? stamps.reduce((a, b) => (a < b ? a : b)).slice(0, 10) : "";
if (oldest && oldest < windowStart) continue; // the window is fully listed
const key = pastHistoryKey(obj.entry_id, task.id);
out.push({ entryId: obj.entry_id, taskId: task.id, key, sig: `${key}:${count}:${contentHash(JSON.stringify(listed))}` });
}
}
return out;
}
/** FNV-1a (32 bit) of a string, base 36 — a cheap change detector for the
* signature above, not a security primitive. */
function contentHash(text: string): string {
let h = 0x811c9dc5;
for (let i = 0; i < text.length; i++) {
h ^= text.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return (h >>> 0).toString(36);
}
/** Build a list of N consecutive ISO dates ending today (local). */
export function buildPastWindowDates(today: Date, pastDays: number): string[] {
const out: string[] = [];
@@ -320,6 +367,9 @@ export function buildPastBuckets(
today: Date,
pastDays: number,
userFilter: string | null = null,
/** Full histories fetched for the tasks `pastHistoryGaps` named, keyed by
* `pastHistoryKey` — they replace the windowed list history. */
fullHistory: Readonly<Record<string, HistoryEntryShape[]>> = {},
): CalendarDayBucket[] {
const days = buildPastWindowDates(today, pastDays);
const windowStart = days[0];
@@ -334,7 +384,7 @@ export function buildPastBuckets(
const tasks = obj.tasks || [];
for (const task of tasks) {
if (userFilter && task.responsible_user_id !== userFilter) continue;
const history = (task.history || []) as HistoryEntryShape[];
const history = fullHistory[pastHistoryKey(entryId, task.id)] ?? ((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
@@ -17,8 +17,10 @@ export const LS_KEYS = {
batteryRosterOpen: "msp-bf-roster-open",
/** #164: how the object's document list is ordered (newest | oldest | title | category). */
docSort: "msp-doc-sort",
/** #161: the camera (deviceId) the in-app viewfinder should open with. */
cameraDevice: "msp-camera-device",
/** #161: the camera the in-app viewfinder should open with, by LABEL
* ("camera2 0, facing back"). Replaces the old msp-camera-device: the
* Android WebView salts device ids per page, so a stored id never matched. */
cameraLabel: "msp-camera-label",
overviewTab: "msp-overview-tab",
collapsedSections: "msp-collapsed-sections",
chartRange: "msp-chart-range",
@@ -14,6 +14,7 @@
*/
import type { MaintenanceTask } from "../types";
import { isoDateLocal } from "./calendar-bucket";
export interface WorksheetLabels {
title: string; // "Work sheet"
@@ -46,6 +47,16 @@ export interface WorksheetExcerpt {
vendorBase?: string;
}
/** The printing day in the USER's calendar and date format. The raw
* `nowIso.slice(0, 10)` was the UTC day (`toISOString()`), so a sheet
* printed after midnight east of UTC — or in the evening west of it —
* carried the wrong date, and in ISO order regardless of the profile's
* date format (bug audit 2026-09-26). */
function printedOn(nowIso: string, formatDate: (iso: string) => string): string {
const d = new Date(nowIso);
return Number.isNaN(d.getTime()) ? nowIso.slice(0, 10) : formatDate(isoDateLocal(d));
}
const esc = (v: unknown): string =>
String(v ?? "").replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] as string);
@@ -164,6 +175,6 @@ export function buildTaskWorksheetHtml(
}
} 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>
<footer>${esc(objectName)} · ${esc(task.name)} · ${esc(L.printedOn)} ${esc(printedOn(nowIso, formatDate))}</footer>
</body></html>`;
}