diff --git a/.storage/lovelace_resources b/.storage/lovelace_resources index b0b30e76..d8789d42 100644 --- a/.storage/lovelace_resources +++ b/.storage/lovelace_resources @@ -231,7 +231,7 @@ }, { "id": "449ff43140654e4bbe2c7b2d361dfacc", - "url": "/hacsfiles/clock-weather-card/clock-weather-card.js?hacstag=522634019295", + "url": "/hacsfiles/clock-weather-card/clock-weather-card.js?hacstag=522634019296", "type": "module" }, { @@ -261,7 +261,7 @@ }, { "id": "6c3f7d87683d4473b5dc8fe8177535f6", - "url": "/hacsfiles/simple-thermostat/simple-thermostat.js?hacstag=1230152807450", + "url": "/hacsfiles/simple-thermostat/simple-thermostat.js?hacstag=1230152807451", "type": "module" }, { diff --git a/custom_components/maintenance_supporter/__init__.py b/custom_components/maintenance_supporter/__init__.py index bd9e0504..7fd3ce63 100644 --- a/custom_components/maintenance_supporter/__init__.py +++ b/custom_components/maintenance_supporter/__init__.py @@ -71,6 +71,7 @@ from .const import ( MAX_NAME_LENGTH, MAX_TEXT_LENGTH, MAX_TYPE_LENGTH, + MIN_COST, PLATFORMS, SERVICE_ADD_OBJECT, SERVICE_ADD_TASK, @@ -146,7 +147,7 @@ SERVICE_COMPLETE_SCHEMA = vol.Schema( { vol.Required(ATTR_ENTITY_ID): vol.All(cv.entity_ids, vol.Length(min=1)), vol.Optional("notes"): vol.All(cv.string, vol.Length(max=2000)), - vol.Optional("cost"): vol.All(vol.Coerce(float), vol.Range(min=0, max=MAX_COST)), + vol.Optional("cost"): vol.All(vol.Coerce(float), vol.Range(min=MIN_COST, max=MAX_COST)), vol.Optional("duration"): vol.All(vol.Coerce(int), vol.Range(min=0, max=MAX_DURATION_MINUTES)), # Meter readings (v2.20, #83): recorded value for `reading` tasks. vol.Optional("reading_value"): vol.All(vol.Coerce(float), vol.Range(min=-1e12, max=1e12)), diff --git a/custom_components/maintenance_supporter/const.py b/custom_components/maintenance_supporter/const.py index cf6f9275..264e8a3c 100644 --- a/custom_components/maintenance_supporter/const.py +++ b/custom_components/maintenance_supporter/const.py @@ -856,6 +856,9 @@ MAX_PANEL_TITLE_LENGTH = 50 # sidebar panel title override # config-flow-path sanitiser (they must agree, else a value the sanitiser # accepts can bypass the WS cap or vice-versa). MAX_COST = 1_000_000 # per completion / history entry +# #200: a credit — the old unit sold, a refund — is a negative cost; every +# total nets it (budget, task, object, area). +MIN_COST = -MAX_COST MAX_DURATION_MINUTES = 525_600 # one year in minutes MAX_TYPE_LENGTH = 50 # task_type, schedule_type MAX_CHECKLIST_ITEMS = 100 diff --git a/custom_components/maintenance_supporter/coordinator.py b/custom_components/maintenance_supporter/coordinator.py index 272899b4..bb1b4ad7 100644 --- a/custom_components/maintenance_supporter/coordinator.py +++ b/custom_components/maintenance_supporter/coordinator.py @@ -112,6 +112,7 @@ def _inert_task_result(task: MaintenanceTask, status: str, **extra: Any) -> dict task_result["_trigger_active"] = False task_result["_times_performed"] = task.times_performed task_result["_total_cost"] = task.total_cost + task_result["_average_cost"] = task.average_cost task_result["_average_duration"] = task.average_duration task_result["_last_entry"] = task.last_entry task_result.update(extra) @@ -526,6 +527,7 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): task_result["_trigger_current_delta"] = current - baseline task_result["_times_performed"] = task.times_performed task_result["_total_cost"] = task.total_cost + task_result["_average_cost"] = task.average_cost task_result["_average_duration"] = task.average_duration task_result["_last_entry"] = task.last_entry diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/complete-dialog.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/complete-dialog.test.ts index 6957d30f..c3f84ecd 100644 --- a/custom_components/maintenance_supporter/frontend-src/__tests__/complete-dialog.test.ts +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/complete-dialog.test.ts @@ -47,6 +47,21 @@ function setInput(el: MaintenanceCompleteDialog, index: number, value: string) { input.dispatchEvent(new Event("input")); } +/** The cost has its own element (#200: Cost | Credit and the amount). */ +function costInput(el: MaintenanceCompleteDialog) { + return el.shadowRoot!.querySelector("ms-cost-input")! as HTMLElement & { value: string; updateComplete: Promise }; +} + +function costAmount(el: MaintenanceCompleteDialog): HTMLInputElement { + return costInput(el).shadowRoot!.querySelector("input.amount")!; +} + +function setCost(el: MaintenanceCompleteDialog, value: string) { + const input = costAmount(el); + input.value = value; + input.dispatchEvent(new Event("input")); +} + function clickComplete(el: MaintenanceCompleteDialog) { const buttons = [...el.shadowRoot!.querySelectorAll(".dialog-actions ha-button")]; (buttons[buttons.length - 1] as HTMLElement).click(); @@ -60,8 +75,8 @@ describe("complete-dialog", () => { }); setInput(el, 0, "oil changed"); - setInput(el, 1, "12.5"); - setInput(el, 2, "30"); + setCost(el, "12.5"); + setInput(el, 1, "30"); // Tick the second checklist step (click the checkbox; the event bubbles // to the row's toggle handler exactly once). const boxes = [...el.shadowRoot!.querySelectorAll(".checklist-item input")]; @@ -105,6 +120,34 @@ describe("complete-dialog", () => { expect("completed_at" in msg).to.be.false; }); + it("a credit goes out as a negative cost (#200)", async () => { + // The old unit sold for 150: Credit, then the amount — a phone keypad + // has no minus key, so the switch carries the sign. + const { el, sent } = await mount(); + const cost = costInput(el); + await cost.updateComplete; + cost.shadowRoot!.querySelector(".kind.credit")!.click(); + await cost.updateComplete; + expect(cost.shadowRoot!.querySelector(".hint")!.textContent).to.include("Money back"); + setCost(el, "150"); + clickComplete(el); + await new Promise((r) => setTimeout(r, 10)); + const msg = sent.find((m) => m.type === "maintenance_supporter/task/complete")!; + expect(msg.cost).to.equal(-150); + }); + + it("notes may run over several lines (#202): a text area, sent trimmed, no Markdown promise", async () => { + const { el, sent } = await mount(); + expect(el.shadowRoot!.querySelector("textarea.field-input"), "the notes field is a text area").to.exist; + // The history and the printouts show notes as plain text. + expect(el.shadowRoot!.textContent).not.to.include("Markdown"); + setInput(el, 0, "Filter changed\nSeal checked\n"); + clickComplete(el); + await new Promise((r) => setTimeout(r, 10)); + const msg = sent.find((m) => m.type === "maintenance_supporter/task/complete")!; + expect(msg.notes, "the line break inside stays, the one at the end goes").to.equal("Filter changed\nSeal checked"); + }); + /** The optional backdate starts as a "Set date & time" button (#163): the * HA datetime selector has no empty state, so the field only appears once * the user asks for it, seeded with the current minute. */ @@ -396,10 +439,9 @@ describe("complete-dialog parts cost (#104)", () => { expect(chip(el)!.textContent).to.include("18"); chip(el)!.click(); await el.updateComplete; - // Buy task: restock qty, notes, cost. - const cost = [...el.shadowRoot!.querySelectorAll(".field-input")][2]; + await costInput(el).updateComplete; // Machine value for the — never profile-formatted. - expect(cost.value).to.equal("18"); + expect(costAmount(el).value).to.equal("18"); expect(chip(el), "chip hides once cost is set").to.equal(null); }); @@ -423,7 +465,7 @@ describe("complete-dialog parts cost (#104)", () => { it("buy task: no chip once the user typed a cost themselves", async () => { const el = await mountWithParts({ restockDefault: 1, restockUnitCost: 5 }); expect(chip(el)).to.exist; - setInput(el, 2, "3.10"); + setCost(el, "3.10"); await el.updateComplete; expect(chip(el)).to.equal(null); }); diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-completion-actions.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-completion-actions.test.ts index 402b8f1f..34d34cb6 100644 --- a/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-completion-actions.test.ts +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-completion-actions.test.ts @@ -128,8 +128,10 @@ describe("task-dialog completion-actions sections", () => { "ms-textfield", ); expect(qcInputs[0]?.value, "qc notes").to.equal("Quick note"); - expect(qcInputs[1]?.value, "qc cost").to.equal("4.5"); - expect(qcInputs[2]?.value, "qc duration").to.equal("10"); + // The cost has its own element (#200: Cost | Credit and the amount). + const qcCost = sections[1].querySelector("ms-cost-input"); + expect(qcCost?.value, "qc cost").to.equal("4.5"); + expect(qcInputs[1]?.value, "qc duration").to.equal("10"); const qcSelect = sections[1].querySelector("select.qc-feedback"); expect(qcSelect?.value, "qc feedback").to.equal("needed"); diff --git a/custom_components/maintenance_supporter/frontend-src/components/battery-fleet-section.ts b/custom_components/maintenance_supporter/frontend-src/components/battery-fleet-section.ts index cd31b773..145e90c6 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/battery-fleet-section.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/battery-fleet-section.ts @@ -444,7 +444,9 @@ export class MaintenanceBatteryFleetSection extends LitElement { : b.no_sensor ? html`${t("battery_fleet_no_sensor", L)}` : nothing; - const type = html`${b.quantity}× ${b.battery_type}`; + // The full type as a tooltip: a long one ends in an ellipsis (D#162). + const typeText = `${b.quantity}× ${b.battery_type}`; + const type = html`${typeText}`; const writer = this._canWrite; const showMark = writer && (o.mark === "always" || b.no_sensor || b.can_mark_replaced); return html` @@ -659,7 +661,10 @@ export class MaintenanceBatteryFleetSection extends LitElement { } static styles = css` + /* The query container of the row layout (see "In a narrow list" below): + * the list's own width, not the screen's. */ .bf-card { + container: bf-fleet / inline-size; background: var(--card-background-color, #fff); border: 1px solid var(--divider-color); border-radius: 10px; @@ -817,9 +822,16 @@ export class MaintenanceBatteryFleetSection extends LitElement { font-size: 12px; font-style: italic; } + /* Every row shares the list's columns, so ONE long type ("1× BATTERY + * PACK") widened the type column of all of them (D#162): it stops at + * 12em and ends in an ellipsis, the full text is the tooltip. */ .bf-type { color: var(--secondary-text-color); font-size: 13px; + max-width: 12em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .bf-recharge { color: var(--secondary-text-color); @@ -835,18 +847,37 @@ export class MaintenanceBatteryFleetSection extends LitElement { flex: 0 0 auto; cursor: help; } - /* On phones the row cannot fit name + chips + curve + bar + date in ONE - * line: the decorations yield (the percentage still carries the number) - * and the row wraps to two lines - the name spans the full width, the - * status chip moves under it (left, into the name column) and the rest - * keeps its pinned subgrid column, so type / percentage / date / eye - * stay aligned across rows. Without this the fixed max-content columns - * overflowed 400 px and the chips overlapped the wrapped names. */ - @media (max-width: 640px) { + /* In a narrow list the row cannot fit name + chips + curve + bar + date + * in ONE line: the decorations yield (the percentage still carries the + * number) and the row wraps to two lines - the name spans the full + * width, the status chip moves under it (left, into the name column) + * and the rest keeps its pinned subgrid column, so type / percentage / + * date / eye stay aligned across rows. Without this the fixed + * max-content columns overflowed 400 px and the chips overlapped the + * wrapped names. + * + * The LIST's width decides, not the screen's (D#162): the Lovelace card + * sits in a dashboard column of 400-500 px on a wide screen, kept the + * one-line layout there and cut off the badge, the date and the hide + * button. 680 px of list is where the one-line rows stop fitting; + * desktop task pages (700 px and more) keep them. */ + @container bf-fleet (max-width: 680px) { .bf-spark, .bf-bar { display: none; } + /* No fixed gaps: a column a phone leaves empty (status chip moved, + * curve and bar hidden) cost its 8 px gap all the same, and together + * with one long type the rows ran 25 px past a 402 px phone. Each + * occupied column brings its own 6 px instead, and the type column + * may shrink - its text ends in an ellipsis. */ + .bf-rows { + column-gap: 0; + grid-template-columns: minmax(0, 1fr) max-content minmax(0, max-content) repeat(7, max-content); + } + .bf-row > :not(.bf-dev) { + margin-inline-start: 6px; + } .bf-row { row-gap: 2px; } @@ -864,12 +895,10 @@ export class MaintenanceBatteryFleetSection extends LitElement { grid-row: 1; justify-self: end; } + /* The column shrinks, the ellipsis comes from the base rule (a cap in + * vw measured the screen, not the list). */ .bf-type { grid-row: 2; - max-width: 44vw; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } .bf-recharge, .bf-level, diff --git a/custom_components/maintenance_supporter/frontend-src/components/complete-dialog.ts b/custom_components/maintenance_supporter/frontend-src/components/complete-dialog.ts index 25afc5fc..7f4fddf5 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/complete-dialog.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/complete-dialog.ts @@ -14,6 +14,7 @@ import { REQUIRED_COMPLETION_LABELS } from "./required-completion-labels"; import { PhotoUploadController } from "../helpers/photo-upload-controller"; import { parseDurationMinutes } from "../helpers/duration"; import "./ms-date-field"; +import "./ms-cost-input"; import "./ms-photo-picker"; import { photoPickerStyles } from "./ms-photo-picker"; @@ -173,10 +174,14 @@ export class MaintenanceCompleteDialog extends LitElement { entry_id: this.entryId, task_id: this.taskId, }; - if (this._notes) data.notes = this._notes; + // Trimmed like the required check reads it: a text area easily keeps a + // trailing line break, which the history would show as an empty line. + const notes = this._notes.trim(); + if (notes) data.notes = notes; if (this._cost) { + // #200: a credit is negative; the server holds the bounds. const cost = parseFloat(this._cost); - if (!isNaN(cost) && cost >= 0) data.cost = cost; + if (!isNaN(cost)) data.cost = cost; } // Whole minutes — the server coerces to int (helpers/duration). const dur = parseDurationMinutes(this._duration); @@ -309,9 +314,15 @@ export class MaintenanceCompleteDialog extends LitElement { return this.requiredFields.filter((f) => !filled[f]); } + /** Does the task demand this detail? Its label then drops "(optional)" — + * "Notes (optional) *" contradicted itself. */ + private _demands(field: string): boolean { + return this.requiredFields.includes(field); + } + /** Marker appended to a required field's label. */ private _req(field: string) { - return this.requiredFields.includes(field) ? html`` : nothing; + return this._demands(field) ? html`` : nothing; } /** A buy task's price: restock qty (packages) × the part's price per @@ -485,20 +496,22 @@ export class MaintenanceCompleteDialog extends LitElement { only sees the title + Cancel/Complete buttons — the original bug report. Native inputs always render. --> -