393 files
This commit is contained in:
@@ -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) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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>`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user