iobroker.laundrylens 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2489 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>LaundryLens</title>
7
+ <script type="text/javascript" src="../../socket.io/socket.io.js"></script>
8
+ <style>
9
+ :root {
10
+ --blue: #1a73e8;
11
+ --green: #34a853;
12
+ --orange: #ff9800;
13
+ --red: #ea4335;
14
+ --surf: #f8f9fa;
15
+ --bord: #e0e0e0;
16
+ --text: #202124;
17
+ --muted: #5f6368;
18
+ --r: 8px;
19
+ }
20
+ * { box-sizing: border-box; margin: 0; padding: 0; }
21
+ body { font-family: Roboto, sans-serif; font-size: 14px; color: var(--text); background: #fff; }
22
+
23
+ .tabs { display: flex; border-bottom: 2px solid var(--bord); background: #fff; position: sticky; top: 0; z-index: 100; flex-wrap: wrap; }
24
+ .tab { padding: 12px 16px; cursor: pointer; color: var(--muted); font-weight: 500; border-bottom: 3px solid transparent; margin-bottom: -2px; transition: .2s; font-size: 13px; }
25
+ .tab:hover { color: var(--blue); }
26
+ .tab.active { color: var(--blue); border-bottom-color: var(--blue); }
27
+ .badge-tab { background: var(--red); color: #fff; border-radius: 10px; padding: 1px 6px; font-size: 10px; font-weight: 700; }
28
+
29
+ .pane { display: none; padding: 16px; }
30
+ .pane.active { display: block; }
31
+
32
+ .card { background: #fff; border: 1px solid var(--bord); border-radius: var(--r); margin-bottom: 14px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
33
+ .card-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; background: var(--surf); border-bottom: 1px solid var(--bord); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--muted); }
34
+ .card-body { padding: 14px; }
35
+
36
+ .btn { display: inline-flex; align-items: center; gap: 5px; padding: 7px 13px; border: none; border-radius: 6px; font-size: 13px; font-weight: 500; cursor: pointer; transition: .15s; }
37
+ .btn:hover { opacity: .85; }
38
+ .btn-primary { background: var(--blue); color: #fff; }
39
+ .btn-success { background: var(--green); color: #fff; }
40
+ .btn-danger { background: var(--red); color: #fff; }
41
+ .btn-warn { background: var(--orange); color: #fff; }
42
+ .btn-outline { background: transparent; color: var(--blue); border: 1px solid var(--blue); }
43
+ .btn-sm { padding: 4px 10px; font-size: 12px; }
44
+ .btn-xs { padding: 3px 7px; font-size: 11px; }
45
+
46
+ .form-row { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 10px; align-items: flex-end; }
47
+ .form-group { display: flex; flex-direction: column; gap: 3px; flex: 1; min-width: 120px; }
48
+ label { font-size: 12px; color: var(--muted); font-weight: 500; }
49
+ input, select, textarea { padding: 7px 9px; border: 1px solid var(--bord); border-radius: 6px; font-size: 13px; color: var(--text); width: 100%; }
50
+ input:focus, select:focus { outline: none; border-color: var(--blue); }
51
+
52
+ .tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
53
+ .tbl th { text-align: left; padding: 7px 10px; background: var(--surf); border-bottom: 1px solid var(--bord); font-size: 11px; text-transform: uppercase; color: var(--muted); font-weight: 600; }
54
+ .tbl td { padding: 9px 10px; border-bottom: 1px solid #f0f0f0; vertical-align: middle; }
55
+ .tbl tr:last-child td { border-bottom: none; }
56
+ .tbl tr:hover td { background: #fafbfc; }
57
+
58
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
59
+ .bg-green { background: #e6f4ea; color: #1e7e34; }
60
+ .bg-blue { background: #e8f0fe; color: #1967d2; }
61
+ .bg-orange { background: #fef3e2; color: #e37400; }
62
+ .bg-red { background: #fce8e6; color: #c5221f; }
63
+ .bg-gray { background: #f1f3f4; color: var(--muted); }
64
+
65
+ .status-row { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; }
66
+ .chip { display: flex; align-items: center; gap: 7px; padding: 7px 12px; border-radius: 20px; border: 1px solid var(--bord); font-size: 13px; background: var(--surf); }
67
+ .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--bord); flex-shrink: 0; }
68
+ .dot.running { background: var(--green); animation: pulse 1.5s infinite; }
69
+ .dot.starting, .dot.ending { background: var(--orange); animation: pulse 1s infinite; }
70
+ .dot.off { background: #ccc; }
71
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
72
+
73
+ .prog-bg { height: 6px; background: #e8eaed; border-radius: 3px; overflow: hidden; margin: 6px 0; }
74
+ .prog-fill { height: 100%; background: linear-gradient(90deg, var(--blue), var(--green)); transition: width .5s; border-radius: 3px; }
75
+
76
+ .feedback-card { border: 2px solid var(--orange); border-radius: var(--r); padding: 14px; margin-bottom: 14px; background: #fef3e2; }
77
+ .feedback-card h4 { margin-bottom: 8px; color: #e37400; }
78
+
79
+ .overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.45); z-index: 1000; align-items: center; justify-content: center; }
80
+ .overlay.open { display: flex; }
81
+ .modal { background: #fff; border-radius: 12px; padding: 22px; width: 92%; max-width: 460px; box-shadow: 0 8px 32px rgba(0,0,0,.18); }
82
+ .modal h3 { margin-bottom: 14px; font-size: 15px; }
83
+ .modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
84
+
85
+ .dev-sel { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; padding: 9px 12px; background: var(--surf); border: 1px solid var(--bord); border-radius: var(--r); }
86
+ .dev-sel label { white-space: nowrap; font-size: 12px; color: var(--muted); }
87
+ .dev-sel select { flex: 1; }
88
+
89
+ #toasts { position: fixed; bottom: 20px; right: 20px; z-index: 2000; display: flex; flex-direction: column; gap: 6px; }
90
+ .toast { padding: 9px 14px; border-radius: 7px; font-size: 13px; font-weight: 500; color: #fff; animation: slideIn .2s; }
91
+ .toast.ok { background: var(--green); }
92
+ .toast.err { background: var(--red); }
93
+ .toast.info { background: var(--blue); }
94
+ @keyframes slideIn { from{transform:translateX(60px);opacity:0} to{transform:none;opacity:1} }
95
+
96
+ .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
97
+ .ml-auto { margin-left: auto; }
98
+ .text-muted { color: var(--muted); }
99
+ .text-sm { font-size: 12px; }
100
+ .mt-8 { margin-top: 8px; }
101
+ .empty { text-align: center; padding: 36px 16px; color: var(--muted); }
102
+ .toggle-wrap { display:flex; align-items:center; gap:10px; margin-bottom:6px; cursor:pointer; }
103
+ .toggle { width:44px; height:24px; background:#ccc; border-radius:12px; position:relative; transition:.3s; flex-shrink:0; }
104
+ .toggle.on { background:var(--green,#4caf50); }
105
+ .toggle::after { content:''; position:absolute; width:20px; height:20px; background:#fff; border-radius:50%; top:2px; left:2px; transition:.3s; }
106
+ .toggle.on::after { left:22px; }
107
+ .toggle-label { font-weight:500; }
108
+ </style>
109
+ </head>
110
+ <body>
111
+
112
+ <div class="tabs">
113
+ <div class="tab active" data-tab="overview" data-i18n="📊 Overview">📊 Overview</div>
114
+ <div class="tab" data-tab="programs" data-i18n="🗂 Programs">🗂 Programs</div>
115
+ <div class="tab" data-tab="cycles" data-i18n="🕐 Cycles">🕐 Cycles</div>
116
+ <div class="tab" data-tab="feedback"><span data-i18n="✅ Review">✅ Review</span> <span class="badge-tab" id="feedback-badge" style="display:none">0</span></div>
117
+ <div class="tab" data-tab="notify" data-i18n="🔔 Notifications">🔔 Notifications</div>
118
+ <div class="tab" data-tab="export" data-i18n="💾 Export">💾 Export</div>
119
+ </div>
120
+
121
+ <!-- OVERVIEW -->
122
+ <div class="pane active" id="pane-overview">
123
+ <div class="dev-sel">
124
+ <label data-i18n="Device:">Device:</label>
125
+ <select id="sel-overview" onchange="refreshOverview()"></select>
126
+ <button class="btn btn-outline btn-sm" onclick="refreshOverview()">↻</button>
127
+ </div>
128
+ <div class="feedback-card" id="overview-feedback" style="display:none">
129
+ <h4 data-i18n="⚠️ Review needed">⚠️ Review needed</h4>
130
+ <p class="text-sm" data-i18n="Last cycle not confidently detected. Please confirm or correct.">Last cycle not confidently detected. Please confirm or correct.</p>
131
+ <button class="btn btn-warn btn-sm mt-8" onclick="switchTab('feedback')" data-i18n="Go to review →">Go to review →</button>
132
+ </div>
133
+ <div class="card">
134
+ <div class="card-head"><span data-i18n="Live status">Live status</span><span class="text-sm text-muted" id="ov-updated"></span></div>
135
+ <div class="card-body">
136
+ <div class="status-row">
137
+ <div class="chip"><div class="dot off" id="ov-dot"></div><span id="ov-state">–</span></div>
138
+ <div class="chip" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">🏷 <span id="ov-program">–</span></div>
139
+ <div class="chip">📊 <span id="ov-confidence">–</span></div>
140
+ </div>
141
+ <div id="ov-phase-row" style="display:none;margin-top:12px;margin-bottom:4px;text-align:center">
142
+ <div id="ov-phase-pill" style="display:inline-block;background:#e8f0fe;border:1px solid #c5d8fd;border-radius:12px;padding:3px 14px;font-size:12px;color:#1a73e8;text-align:center"><span id="ov-phase">–</span></div>
143
+ </div>
144
+ <div id="ov-progress" style="display:none">
145
+ <div class="row" style="margin-bottom:4px">
146
+ <span class="text-sm text-muted" data-i18n="Progress">Progress</span>
147
+ <span class="text-sm ml-auto" id="ov-pct">0%</span>
148
+ </div>
149
+ <div class="prog-bg"><div class="prog-fill" id="ov-bar" style="width:0%"></div></div>
150
+ <div class="row mt-8">
151
+ <span class="text-sm text-muted" data-i18n="Time remaining">Time remaining</span>
152
+ <span class="text-sm ml-auto" id="ov-remaining">–</span>
153
+ </div>
154
+ <div class="row mt-8" id="ov-elapsed-row" style="display:none">
155
+ <span class="text-sm text-muted" data-i18n="Elapsed time">Elapsed time</span>
156
+ <span class="text-sm ml-auto" id="ov-elapsed">–</span>
157
+ </div>
158
+ </div>
159
+ </div>
160
+ </div>
161
+
162
+ <!-- Live control -->
163
+ <div class="card" id="ctrl-card" style="display:none">
164
+ <div class="card-head" data-i18n="🎮 Live control">🎮 Live control</div>
165
+ <div class="card-body">
166
+ <div class="form-row" style="align-items:flex-end">
167
+ <div class="form-group">
168
+ <label data-i18n="Program override">Program override</label>
169
+ <select id="ctrl-override" onchange="setProgramOverride()">
170
+ <option value="auto" data-i18n="🤖 Automatic">🤖 Automatic</option>
171
+ </select>
172
+ </div>
173
+ <div class="form-group" style="flex:0">
174
+ <label>&nbsp;</label>
175
+ <button class="btn btn-danger btn-sm" onclick="forceFinish()" id="btn-force-finish" style="display:none;height:100%;min-height:36px" data-i18n="⏹ End cycle">
176
+ ⏹ End cycle
177
+ </button>
178
+ </div>
179
+ </div>
180
+ </div>
181
+ </div>
182
+ <div class="card">
183
+ <div class="card-head" data-i18n="Last cycle">Last cycle</div>
184
+ <div class="card-body" id="ov-last"><span class="text-muted text-sm" data-i18n="No cycle recorded yet.">No cycle recorded yet.</span></div>
185
+ </div>
186
+ <div class="card">
187
+ <div class="card-head" data-i18n="All devices">All devices</div>
188
+ <div id="ov-devices-wrap"><div class="empty"><span class="text-muted text-sm" data-i18n="Loading…">Loading…</span></div></div>
189
+ </div>
190
+
191
+ <div class="card" id="suggested-card" style="display:none">
192
+ <div class="card-head" style="color:#e37400;border-bottom-color:#ff9800">
193
+ <span data-i18n="💡 Suggested settings">💡 Suggested settings</span>
194
+ </div>
195
+ <div class="card-body" id="suggested-body"></div>
196
+ </div>
197
+ </div>
198
+
199
+ <!-- PROGRAMS -->
200
+ <div class="pane" id="pane-programs">
201
+ <div class="dev-sel">
202
+ <label data-i18n="Device:">Device:</label>
203
+ <select id="sel-programs" onchange="loadPrograms()"></select>
204
+ </div>
205
+ <div class="card">
206
+ <div class="card-head" data-i18n="Create new program">Create new program</div>
207
+ <div class="card-body">
208
+ <div class="form-row">
209
+ <div class="form-group">
210
+ <label data-i18n="Program name">Program name</label>
211
+ <input type="text" id="prog-name" placeholder="e.g. Cotton 60°" data-i18n-placeholder="e.g. Cotton 60°">
212
+ </div>
213
+ <div class="form-group" style="max-width:130px">
214
+ <label data-i18n="Duration (min)">Duration (min)</label>
215
+ <input type="number" id="prog-duration" value="90" min="1" max="480">
216
+ </div>
217
+ <div class="form-group" style="flex:0">
218
+ <label>&nbsp;</label>
219
+ <button class="btn btn-success" onclick="createProgram()" data-i18n="+ Create">+ Create</button>
220
+ </div>
221
+ </div>
222
+ <p class="text-sm text-muted" data-i18n="💡 The adapter learns the real duration automatically after 2–3 cycles.">💡 The adapter learns the real duration automatically after 2–3 cycles.</p>
223
+ </div>
224
+ </div>
225
+ <div class="card">
226
+ <div class="card-head"><span data-i18n="Saved programs">Saved programs</span><span class="badge bg-blue" id="prog-count">0</span></div>
227
+ <div id="prog-list-wrap"><div class="empty"><p data-i18n="No programs created yet.">No programs created yet.</p></div></div>
228
+ </div>
229
+ <div class="card" id="antiknitter-card" style="display:none">
230
+ <div class="card-head" data-i18n="🌀 Anti-crease detection">🌀 Anti-crease detection</div>
231
+ <div class="card-body">
232
+ <p class="text-sm text-muted" style="margin-bottom:10px" data-i18n="Choose a recorded cycle as the anti-crease reference. The adapter will then automatically ignore this pattern after drying.">Choose a recorded cycle as the anti-crease reference. The adapter will then automatically ignore this pattern after drying.</p>
233
+ <div class="form-row" style="align-items:flex-end">
234
+ <div class="form-group" style="flex:1">
235
+ <label data-i18n="Reference cycle">Reference cycle</label>
236
+ <select id="antiknitter-select">
237
+ <option value="" data-i18n="– No cycle selected –">– No cycle selected –</option>
238
+ </select>
239
+ </div>
240
+ <div class="form-group" style="flex:0">
241
+ <label>&nbsp;</label>
242
+ <button class="btn btn-warn" onclick="saveAntiKnitterFromDropdown()" data-i18n="🌀 Save">🌀 Save</button>
243
+ </div>
244
+ <div class="form-group" style="flex:0">
245
+ <label>&nbsp;</label>
246
+ <button class="btn btn-outline" style="border-color:#ea4335;color:#c5221f" onclick="clearAntiKnitter()" data-i18n="🗑 Delete">🗑 Delete</button>
247
+ </div>
248
+ </div>
249
+ <div id="antiknitter-status" style="margin-top:8px;font-size:13px;color:var(--muted)"></div>
250
+ </div>
251
+ </div>
252
+ </div>
253
+
254
+ <!-- CYCLES -->
255
+ <div class="pane" id="pane-cycles">
256
+ <div class="dev-sel">
257
+ <label data-i18n="Device:">Device:</label>
258
+ <select id="sel-cycles" onchange="loadCycles()"></select>
259
+ <button class="btn btn-outline btn-sm" onclick="loadCycles()">↻</button>
260
+ </div>
261
+ <div class="card">
262
+ <div class="card-head"><span data-i18n="Cycle history">Cycle history</span><span class="badge bg-gray" id="cycle-count">0</span></div>
263
+ <div id="cycle-list-wrap"><div class="empty"><p data-i18n="No cycles recorded yet.">No cycles recorded yet.</p></div></div>
264
+ </div>
265
+ </div>
266
+
267
+ <!-- REVIEW -->
268
+ <div class="pane" id="pane-feedback">
269
+ <div class="dev-sel">
270
+ <label data-i18n="Device:">Device:</label>
271
+ <select id="sel-feedback" onchange="loadFeedback()"></select>
272
+ <button class="btn btn-outline btn-sm" onclick="loadFeedback()">↻</button>
273
+ </div>
274
+ <div class="card">
275
+ <div class="card-body">
276
+ <p class="text-sm text-muted" data-i18n="Confirm or correct cycles marked with ❓. This automatically improves detection.">Confirm or correct cycles marked with ❓. This automatically improves detection.</p>
277
+ </div>
278
+ </div>
279
+ <div id="feedback-list-wrap"><div class="empty"><p data-i18n="✅ All cycles confirmed!">✅ All cycles confirmed!</p></div></div>
280
+ </div>
281
+
282
+ <!-- EXPORT -->
283
+ <div class="pane" id="pane-export">
284
+ <div class="dev-sel">
285
+ <label data-i18n="Device:">Device:</label>
286
+ <select id="sel-export"></select>
287
+ </div>
288
+ <div class="card">
289
+ <div class="card-head" data-i18n="Back up configuration">Back up configuration</div>
290
+ <div class="card-body">
291
+ <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:12px">
292
+ <button class="btn btn-outline" onclick="doExport()" style="justify-content:center" data-i18n="⬇ Export">⬇ Export</button>
293
+ <label class="btn btn-outline" style="cursor:pointer;justify-content:center;text-align:center"><span data-i18n="⬆ Import">⬆ Import</span><input type="file" accept=".json" style="display:none" onchange="doImport(event)"></label>
294
+ <button class="btn btn-outline" onclick="clearAllData()" style="justify-content:center;border-color:#ea4335;color:#c5221f" data-i18n="🗑 Delete">🗑 Delete</button>
295
+ </div>
296
+ <textarea id="export-preview" rows="8" readonly placeholder="JSON appears here after export…" data-i18n-placeholder="JSON appears here after export…"></textarea>
297
+ </div>
298
+ </div>
299
+ </div>
300
+
301
+ <div class="pane" id="pane-notify">
302
+ <div class="dev-sel">
303
+ <label data-i18n="Device:">Device:</label>
304
+ <select id="sel-notify"></select>
305
+ </div>
306
+ <div class="card">
307
+ <div class="card-head" data-i18n="🔔 Notifications">🔔 Notifications</div>
308
+ <div class="card-body">
309
+ <div class="form-row" style="margin-bottom:10px">
310
+ <div class="form-group">
311
+ <label data-i18n="Adapter">Adapter</label>
312
+ <select id="notif-adapter" onchange="loadNotifTargets()">
313
+ <option value="" data-i18n="– No adapter –">– No adapter –</option>
314
+ </select>
315
+ </div>
316
+ <div class="form-group">
317
+ <label data-i18n="Recipient">Recipient</label>
318
+ <select id="notif-target">
319
+ <option value="" data-i18n="All (broadcast)">All (broadcast)</option>
320
+ </select>
321
+ <input type="email" id="notif-target-email" placeholder="e.g. name@example.com (blank = adapter default)" data-i18n-placeholder="e.g. name@example.com (blank = adapter default)" style="display:none">
322
+ </div>
323
+ </div>
324
+ <p class="text-sm text-muted" style="margin-bottom:10px"><span data-i18n="Placeholders:">Placeholders:</span> <code>{device}</code> <code>{program}</code> <code>{duration}</code> <code>{energy}</code> <code>{startTime}</code> <code>{endTime}</code></p>
325
+
326
+ <div class="form-group" style="margin-bottom:8px">
327
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
328
+ <div class="toggle" id="notif-start-enabled-tog" onclick="toggleNotif('start')"></div>
329
+ <input type="hidden" id="notif-start-enabled" value="0">
330
+ <span class="toggle-label" data-i18n="🟢 Start message">🟢 Start message</span>
331
+ </div>
332
+ <textarea id="notif-start-msg" rows="3" style="width:100%;resize:vertical;font-size:12px" placeholder="🧺 {device} running&#10;⏳ Determining time…" data-i18n-placeholder="🧺 {device} running&#10;⏳ Determining time…"></textarea>
333
+ </div>
334
+
335
+ <div class="form-group" style="margin-bottom:8px">
336
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
337
+ <div class="toggle" id="notif-update-enabled-tog" onclick="toggleNotif('update')"></div>
338
+ <input type="hidden" id="notif-update-enabled" value="0">
339
+ <span class="toggle-label" data-i18n="🔄 Update message">🔄 Update message</span>
340
+ </div>
341
+ <textarea id="notif-update-msg" rows="5" style="width:100%;resize:vertical;font-size:12px" placeholder="🧺 {device} update&#10;✅ Done at {endTime}&#10;📊 Program: {program}" data-i18n-placeholder="🧺 {device} update&#10;✅ Done at {endTime}&#10;📊 Program: {program}"></textarea>
342
+ <div class="form-row" style="margin-top:8px;gap:8px">
343
+ <div class="form-group" style="flex:1">
344
+ <label style="font-size:12px" data-i18n="Min. interval (min)">Min. interval (min)</label>
345
+ <input type="number" id="notif-update-interval" min="5" max="60" value="20" style="width:100%">
346
+ </div>
347
+ <div class="form-group" style="flex:1">
348
+ <label style="font-size:12px" data-i18n="Near-done from (%)">Near-done from (%)</label>
349
+ <input type="number" id="notif-update-nearend-pct" min="50" max="99" value="85" style="width:100%">
350
+ </div>
351
+ <div class="form-group" style="flex:1">
352
+ <label style="font-size:12px" data-i18n="Near-done dev. %">Near-done dev. %</label>
353
+ <input type="number" id="notif-update-nearend-diff-pct" min="10" max="100" value="50" style="width:100%">
354
+ </div>
355
+ <div class="form-group" style="flex:1">
356
+ <label style="font-size:12px" data-i18n="Near-done dev. min">Near-done dev. min</label>
357
+ <input type="number" id="notif-update-nearend-diff-min" min="5" max="30" value="10" style="width:100%">
358
+ </div>
359
+ </div>
360
+ </div>
361
+
362
+ <div class="form-group" style="margin-bottom:12px">
363
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
364
+ <div class="toggle" id="notif-done-enabled-tog" onclick="toggleNotif('done')"></div>
365
+ <input type="hidden" id="notif-done-enabled" value="0">
366
+ <span class="toggle-label" data-i18n="✅ Done message">✅ Done message</span>
367
+ </div>
368
+ <textarea id="notif-done-msg" rows="5" style="width:100%;resize:vertical;font-size:12px" placeholder="🧺 {device} done!&#10;⏱️ Total runtime: {duration} min&#10;📊 Program: {program}&#10;⚡ Consumption: {energy} kWh" data-i18n-placeholder="🧺 {device} done!&#10;⏱️ Total runtime: {duration} min&#10;📊 Program: {program}&#10;⚡ Consumption: {energy} kWh"></textarea>
369
+ </div>
370
+
371
+ <div class="row" style="gap:8px">
372
+ <button class="btn btn-success btn-sm" onclick="saveNotifConfig()" data-i18n="💾 Save">💾 Save</button>
373
+ <button class="btn btn-outline btn-sm" onclick="testNotif()" data-i18n="🔔 Test">🔔 Test</button>
374
+ </div>
375
+ </div>
376
+ </div>
377
+ </div>
378
+
379
+
380
+ <!-- GRAPH -->
381
+ <!-- MODALS -->
382
+ <div class="overlay" id="modal-rename">
383
+ <div class="modal">
384
+ <h3 data-i18n="✏️ Rename program">✏️ Rename program</h3>
385
+ <div class="form-group">
386
+ <label data-i18n="New name">New name</label>
387
+ <input type="text" id="rename-input">
388
+ </div>
389
+ <div class="modal-actions">
390
+ <button class="btn btn-outline" onclick="closeModal('modal-rename')" data-i18n="Cancel">Cancel</button>
391
+ <button class="btn btn-primary" onclick="confirmRename()" data-i18n="Save">Save</button>
392
+ </div>
393
+ </div>
394
+ </div>
395
+
396
+ <div class="overlay" id="modal-correct">
397
+ <div class="modal">
398
+ <h3 data-i18n="🔄 Correct program">🔄 Correct program</h3>
399
+ <p class="text-sm text-muted" style="margin-bottom:12px" data-i18n="Which program was it actually?">Which program was it actually?</p>
400
+ <div class="form-group">
401
+ <label data-i18n="Program">Program</label>
402
+ <select id="correct-select"></select>
403
+ </div>
404
+ <div class="modal-actions">
405
+ <button class="btn btn-outline" onclick="closeModal('modal-correct')" data-i18n="Cancel">Cancel</button>
406
+ <button class="btn btn-warn" onclick="confirmCorrect()" data-i18n="Correct & learn">Correct & learn</button>
407
+ </div>
408
+ </div>
409
+ </div>
410
+
411
+ <div class="overlay" id="modal-assign">
412
+ <div class="modal">
413
+ <h3 data-i18n="🏷 Assign program">🏷 Assign program</h3>
414
+ <p class="text-sm text-muted" style="margin-bottom:12px" data-i18n="Choose the program for this cycle:">Choose the program for this cycle:</p>
415
+ <div class="form-group">
416
+ <label data-i18n="Program">Program</label>
417
+ <select id="assign-select"></select>
418
+ </div>
419
+ <div class="modal-actions">
420
+ <button class="btn btn-outline" onclick="closeModal('modal-assign')" data-i18n="Cancel">Cancel</button>
421
+ <button class="btn btn-success" onclick="confirmAssign()" data-i18n="✓ Assign & learn">✓ Assign & learn</button>
422
+ </div>
423
+ </div>
424
+ </div>
425
+
426
+ <div id="toasts"></div>
427
+
428
+ <script>
429
+ 'use strict';
430
+
431
+ // ── Socket-Verbindung wie iobroker.info ───────────
432
+ var socket = io.connect();
433
+
434
+ // Global phase colors, keyed by language-neutral phase identifiers
435
+ // (not display text) so the UI can show a translated label per phase.
436
+ var PHASE_LINE_COLORS = {
437
+ heating: '#ea4335',
438
+ washing: '#1a73e8',
439
+ rinsing: '#34a853',
440
+ spinning: '#fbbc04',
441
+ soaking: '#9a6dff',
442
+ dryer_drying: '#fa7b17',
443
+ cooling: '#1a73e8',
444
+ anticrease: '#34a853',
445
+ prewash: '#9a6dff',
446
+ mainwash: '#ea4335',
447
+ finalrinse: '#34a853',
448
+ dish_drying: '#fbbc04',
449
+ };
450
+ var PHASE_BG_COLORS = {
451
+ heating: 'rgba(234,67,53,0.12)',
452
+ washing: 'rgba(26,115,232,0.10)',
453
+ rinsing: 'rgba(52,168,83,0.10)',
454
+ spinning: 'rgba(251,188,4,0.12)',
455
+ soaking: 'rgba(154,109,255,0.10)',
456
+ dryer_drying: 'rgba(250,123,23,0.10)',
457
+ cooling: 'rgba(26,115,232,0.08)',
458
+ anticrease: 'rgba(52,168,83,0.08)',
459
+ prewash: 'rgba(154,109,255,0.10)',
460
+ mainwash: 'rgba(234,67,53,0.10)',
461
+ finalrinse: 'rgba(52,168,83,0.10)',
462
+ dish_drying: 'rgba(251,188,4,0.10)',
463
+ };
464
+
465
+ // Display label (emoji + translated text) per phase key and language.
466
+ // Falls back to English if the current admin language isn't listed here.
467
+ var PHASE_LABELS = {
468
+ heating: { emoji: '🔥', en: 'Heating', de: 'Aufheizen', ru: 'Нагрев', pt: 'Aquecendo', nl: 'Verwarmen', fr: 'Chauffe', it: 'Riscaldamento', es: 'Calentando', pl: 'Podgrzewanie', uk: 'Нагрівання', 'zh-cn': '加热' },
469
+ washing: { emoji: '🫧', en: 'Washing', de: 'Wäscht', ru: 'Стирка', pt: 'Lavando', nl: 'Wast', fr: 'Lavage', it: 'Lavaggio', es: 'Lavando', pl: 'Pranie', uk: 'Прання', 'zh-cn': '洗涤' },
470
+ rinsing: { emoji: '💧', en: 'Rinsing', de: 'Spült', ru: 'Полоскание', pt: 'Enxaguando', nl: 'Spoelt', fr: 'Rinçage', it: 'Risciacquo', es: 'Aclarando', pl: 'Płukanie', uk: 'Полоскання', 'zh-cn': '漂洗' },
471
+ spinning: { emoji: '🌀', en: 'Spinning', de: 'Schleudert', ru: 'Отжим', pt: 'Centrifugando', nl: 'Centrifugeert', fr: 'Essorage', it: 'Centrifuga', es: 'Centrifugando', pl: 'Wirowanie', uk: 'Віджим', 'zh-cn': '脱水' },
472
+ soaking: { emoji: '🪣', en: 'Soaking', de: 'Einweichen', ru: 'Замачивание', pt: 'De molho', nl: 'Weken', fr: 'Trempage', it: 'Ammollo', es: 'En remojo', pl: 'Namaczanie', uk: 'Замочування', 'zh-cn': '浸泡' },
473
+ dryer_drying: { emoji: '♨️', en: 'Drying', de: 'Trocknet', ru: 'Сушка', pt: 'Secando', nl: 'Droogt', fr: 'Séchage', it: 'Asciugatura', es: 'Secando', pl: 'Suszenie', uk: 'Сушіння', 'zh-cn': '烘干' },
474
+ cooling: { emoji: '❄️', en: 'Cooling down', de: 'Abkühlen', ru: 'Охлаждение', pt: 'Arrefecendo', nl: 'Afkoelen', fr: 'Refroidissement', it: 'Raffreddamento', es: 'Enfriando', pl: 'Chłodzenie', uk: 'Охолодження', 'zh-cn': '冷却' },
475
+ anticrease: { emoji: '👕', en: 'Anti-crease', de: 'Anti-Knitter', ru: 'Защита от сминания', pt: 'Antiamarrotamento', nl: 'Kreukbescherming', fr: 'Anti-froissage', it: 'Antipiega', es: 'Antiarrugas', pl: 'Zapob. gnieceniu', uk: 'Захист від зминання', 'zh-cn': '防皱' },
476
+ prewash: { emoji: '🚿', en: 'Pre-wash', de: 'Vorspülen', ru: 'Предварительная мойка', pt: 'Pré-lavagem', nl: 'Voorspoelen', fr: 'Prélavage', it: 'Prelavaggio', es: 'Prelavado', pl: 'Wstępne mycie', uk: 'Попереднє миття', 'zh-cn': '预洗' },
477
+ mainwash: { emoji: '🔥', en: 'Main wash', de: 'Hauptspülgang', ru: 'Основная мойка', pt: 'Lavagem principal', nl: 'Hoofdwas', fr: 'Lavage principal', it: 'Lavaggio principale', es: 'Lavado principal', pl: 'Mycie główne', uk: 'Основне миття', 'zh-cn': '主洗' },
478
+ finalrinse: { emoji: '✨', en: 'Final rinse', de: 'Klarspülgang', ru: 'Финальное полоскание', pt: 'Enxágue final', nl: 'Naspoelen', fr: 'Rinçage final', it: 'Risciacquo finale', es: 'Aclarado final', pl: 'Płukanie końcowe', uk: 'Фінальне полоскання', 'zh-cn': '终洗' },
479
+ dish_drying: { emoji: '💨', en: 'Drying', de: 'Trocknet', ru: 'Сушка', pt: 'Secando', nl: 'Droogt', fr: 'Séchage', it: 'Asciugatura', es: 'Secando', pl: 'Suszenie', uk: 'Сушіння', 'zh-cn': '烘干' },
480
+ };
481
+
482
+ var _currentLang = null; // cached, detected once per admin session
483
+ var _translations = {}; // key → translated string, for the current language
484
+
485
+ /** Detects the current ioBroker Admin UI language via system.config, defaulting to 'en'. */
486
+ function detectLang(callback) {
487
+ if (_currentLang) { callback(_currentLang); return; }
488
+ try {
489
+ socket.emit('getObject', 'system.config', function(err, obj) {
490
+ _currentLang = (obj && obj.common && obj.common.language) || 'en';
491
+ callback(_currentLang);
492
+ });
493
+ } catch {
494
+ _currentLang = 'en';
495
+ callback(_currentLang);
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Loads the UI translation dictionary for the current language (falls back
501
+ * to English on error, or if a key is simply missing at lookup time).
502
+ *
503
+ * @param {Function} callback – called once translations are loaded
504
+ */
505
+ function loadTranslations(callback) {
506
+ detectLang(function(lang) {
507
+ fetch('i18n/' + lang + '/translations.json')
508
+ .then(function(r) { if (!r.ok) throw new Error('not found'); return r.json(); })
509
+ .then(function(json) { _translations = json; callback(); })
510
+ .catch(function() {
511
+ if (lang === 'en') { callback(); return; }
512
+ fetch('i18n/en/translations.json')
513
+ .then(function(r) { return r.json(); })
514
+ .then(function(json) { _translations = json; callback(); })
515
+ .catch(function() { callback(); });
516
+ });
517
+ });
518
+ }
519
+
520
+ /**
521
+ * Translates a UI string by its English source text (used as the lookup
522
+ * key, per ioBroker i18n convention). Falls back to the key itself if no
523
+ * translation is found (visibly shows untranslated strings during testing).
524
+ *
525
+ * @param {string} key – English source string
526
+ * @returns {string} – translated string for the current language, or the key itself
527
+ */
528
+ function _(key) {
529
+ return _translations[key] || key;
530
+ }
531
+
532
+ /** Replaces the text of every element with a data-i18n attribute with its translation. */
533
+ function applyTranslations() {
534
+ document.querySelectorAll('[data-i18n]').forEach(function(el) {
535
+ var key = el.getAttribute('data-i18n');
536
+ el.textContent = _(key);
537
+ });
538
+ document.querySelectorAll('[data-i18n-placeholder]').forEach(function(el) {
539
+ var key = el.getAttribute('data-i18n-placeholder');
540
+ el.placeholder = _(key);
541
+ });
542
+ document.querySelectorAll('[data-i18n-title]').forEach(function(el) {
543
+ var key = el.getAttribute('data-i18n-title');
544
+ el.title = _(key);
545
+ });
546
+ }
547
+
548
+ /**
549
+ * Returns the translated display label (emoji + text) for a phase key.
550
+ * Falls back to the neutral key itself if unknown, and to English if the
551
+ * detected language has no translation.
552
+ *
553
+ * @param {string} phaseKey – neutral phase identifier (e.g. 'washing')
554
+ * @returns {string} – e.g. "🫧 Washing" or "🫧 Wäscht" depending on language
555
+ */
556
+ function getPhaseLabel(phaseKey) {
557
+ var entry = PHASE_LABELS[phaseKey];
558
+ if (!entry) return phaseKey; // unknown key (e.g. legacy saved data) - show as-is
559
+ var lang = _currentLang || 'en';
560
+ var text = entry[lang] || entry.en;
561
+ return entry.emoji + ' ' + text;
562
+ }
563
+
564
+ /**
565
+ * Returns the translated display label for a device state.
566
+ *
567
+ * @param {string} state – state key (off/starting/running/paused/ending)
568
+ * @returns {string} – translated label, or the raw key if unknown
569
+ */
570
+ function getStateLabel(state) {
571
+ var labels = { off:_('Off'), starting:_('Starting…'), running:_('Running ⚙️'), paused:_('Paused'), ending:_('Ending…') };
572
+ return labels[state] || state;
573
+ }
574
+
575
+ var instance = 0;
576
+ var instanceExplicit = false;
577
+
578
+ // Instance aus URL lesen
579
+ var urlParams = new URLSearchParams(window.location.search);
580
+ if (urlParams.get('instance') !== null) {
581
+ instance = parseInt(urlParams.get('instance')) || 0;
582
+ instanceExplicit = true;
583
+ }
584
+
585
+ // ── State ─────────────────────────────────────────
586
+ var devices = [];
587
+ var programs = {};
588
+ var cycles = {};
589
+ var _renameId = null;
590
+ var _correctCycleId = null;
591
+ var _refreshTimer = null;
592
+
593
+ // ── sendTo ────────────────────────────────────────
594
+ function send(cmd, data) {
595
+ return new Promise(function(resolve) {
596
+ var deviceId = data && data.deviceId;
597
+ var dev = devices.find(function(d) { return d.deviceId === deviceId; });
598
+ if (dev) {
599
+ var inst = (dev._instance !== undefined) ? dev._instance : instance;
600
+ socket.emit('sendTo', 'laundrylens.' + inst, cmd, data || {}, resolve);
601
+ } else {
602
+ // deviceId nicht in devices – alle bekannten Instanzen probieren
603
+ var tried = 0;
604
+ var MAX = 5;
605
+ var found = false;
606
+ for (var i = 0; i < MAX; i++) {
607
+ (function(instNum) {
608
+ socket.emit('sendTo', 'laundrylens.' + instNum, cmd, data || {}, function(res) {
609
+ tried++;
610
+ if (!found && res && res.ok) {
611
+ found = true;
612
+ resolve(res);
613
+ } else if (tried >= MAX && !found) {
614
+ resolve(res); // return the last result
615
+ }
616
+ });
617
+ })(i);
618
+ }
619
+ }
620
+ });
621
+ }
622
+
623
+ // ── Toast ─────────────────────────────────────────
624
+ function toast(msg, type) {
625
+ var el = document.createElement('div');
626
+ el.className = 'toast ' + (type || 'info');
627
+ el.textContent = msg;
628
+ document.getElementById('toasts').appendChild(el);
629
+ setTimeout(function() { el.remove(); }, 3000);
630
+ }
631
+
632
+ // ── Tabs ──────────────────────────────────────────
633
+ // Order = numbering for deep-linking (#1, #2, ...) - deliberately
634
+ // language-independent, so links stay stable regardless of UI language.
635
+ var TAB_ORDER = ['overview', 'programs', 'cycles', 'feedback', 'notify', 'export'];
636
+
637
+ document.querySelectorAll('.tab').forEach(function(t) {
638
+ t.addEventListener('click', function() { switchTab(t.dataset.tab); });
639
+ });
640
+
641
+ // Purely visual switch (classes + hash) - WITHOUT triggering data loading.
642
+ // Important for the immediate hash check at boot time: no sendTo() requests
643
+ // to all instances should go out yet while devices[] is still empty.
644
+ function activateTabUI(name) {
645
+ document.querySelectorAll('.tab, .pane').forEach(function(el) { el.classList.remove('active'); });
646
+ document.querySelector('.tab[data-tab="' + name + '"]').classList.add('active');
647
+ document.getElementById('pane-' + name).classList.add('active');
648
+ var idx = TAB_ORDER.indexOf(name);
649
+ if (idx >= 0 && window.location.hash !== '#' + (idx + 1)) {
650
+ history.replaceState(null, '', '#' + (idx + 1));
651
+ }
652
+ }
653
+
654
+ function switchTab(name) {
655
+ activateTabUI(name);
656
+ if (name === 'overview') refreshOverview();
657
+ if (name === 'programs') loadPrograms();
658
+ if (name === 'cycles') loadCycles();
659
+ if (name === 'feedback') loadFeedback();
660
+ if (name === 'graph') loadGraphCycles();
661
+ if (name === 'notify') {
662
+ // Load with a delay so devices are reliably populated
663
+ setTimeout(function() {
664
+ if (devices.length > 0) {
665
+ loadNotifAdapters();
666
+ } else {
667
+ // Wait once more
668
+ setTimeout(function() {
669
+ if (devices.length > 0) loadNotifAdapters();
670
+ }, 1000);
671
+ }
672
+ }, 500);
673
+ }
674
+ }
675
+
676
+ // Evaluate the hash deep-link (#1..#6) IMMEDIATELY - deliberately visual-only
677
+ // and INDEPENDENT of the device loading process (init()/finish()). No data
678
+ // loading (no loadFeedback() etc.) is deliberately triggered here, so no
679
+ // sendTo() requests with an empty deviceId go out to all instances before
680
+ // getDevices() has ever completed. The actual data loading for the
681
+ // tab activated this way only happens in finish(), once the device list
682
+ // is really available.
683
+ (function() {
684
+ var hashNum = parseInt((window.location.hash || '').replace('#', ''), 10);
685
+ if (hashNum >= 1 && hashNum <= TAB_ORDER.length) {
686
+ activateTabUI(TAB_ORDER[hashNum - 1]);
687
+ }
688
+ })();
689
+
690
+ // ── Device selectors ──────────────────────────────
691
+ var selIds = ['overview','programs','cycles','feedback','notify','export'];
692
+
693
+ function populateSelectors(devs) {
694
+ var opts = devs.map(function(d) {
695
+ return '<option value="' + d.deviceId + '">' + (d.name || d.deviceId) + '</option>';
696
+ }).join('');
697
+ selIds.forEach(function(id) {
698
+ var el = document.getElementById('sel-' + id);
699
+ if (el) {
700
+ el.innerHTML = opts;
701
+ el.onchange = function() {
702
+ var val = this.value;
703
+ selIds.forEach(function(sid) {
704
+ var s = document.getElementById('sel-' + sid);
705
+ if (s && s !== el) s.value = val;
706
+ });
707
+ // Aktiven Tab neu laden
708
+ var activeTab = document.querySelector('.tab.active');
709
+ if (activeTab) {
710
+ var tab = activeTab.dataset.tab;
711
+ if (tab === 'overview') refreshOverview();
712
+ if (tab === 'programs') loadPrograms();
713
+ if (tab === 'cycles') loadCycles();
714
+ if (tab === 'feedback') loadFeedback();
715
+ if (tab === 'notify') loadNotifAdapters();
716
+ if (tab === 'export') { /* export needs no reload on device change */ }
717
+ }
718
+ };
719
+ }
720
+ });
721
+ }
722
+
723
+ function selVal(suffix) {
724
+ var el = document.getElementById('sel-' + suffix);
725
+ return el ? el.value : '';
726
+ }
727
+
728
+ function setActiveDevice(deviceId) {
729
+ selIds.forEach(function(id) {
730
+ var el = document.getElementById('sel-' + id);
731
+ if (el) el.value = deviceId;
732
+ });
733
+ }
734
+
735
+ // ── Init ──────────────────────────────────────────
736
+ function init() {
737
+ loadTranslations(function() { applyTranslations(); });
738
+ var allDevices = [];
739
+ var checked = 0;
740
+ var MAX = 5;
741
+ for (var i = 0; i < MAX; i++) {
742
+ (function(inst) {
743
+ var done = false;
744
+ var timer = setTimeout(function() {
745
+ if (!done) { done = true; checked++; if (checked >= MAX) finish(); }
746
+ }, 1500);
747
+ socket.emit('sendTo', 'laundrylens.' + inst, 'getDevices', {}, function(res) {
748
+ if (!done) {
749
+ done = true;
750
+ clearTimeout(timer);
751
+ if (res && res.ok && res.devices && res.devices.length > 0) {
752
+ res.devices.forEach(function(d) { d._instance = inst; allDevices.push(d); });
753
+ }
754
+ checked++;
755
+ if (checked >= MAX) finish();
756
+ }
757
+ });
758
+ })(i);
759
+ }
760
+ function finish() {
761
+ if (allDevices.length > 0) {
762
+ devices = allDevices;
763
+ populateSelectors(devices);
764
+ // If the URL explicitly specifies ?instance=X (e.g. when Admin
765
+ // opens this tab from a specific instance's configuration),
766
+ // pre-select the device belonging to that instance instead of
767
+ // simply showing the first device found.
768
+ if (instanceExplicit) {
769
+ var matchingDevice = devices.find(function(d) { return d._instance === instance; });
770
+ if (matchingDevice) setActiveDevice(matchingDevice.deviceId);
771
+ }
772
+ // Reload the currently active tab (possibly already set via hash
773
+ // at boot time) with the now-available device data - the hash
774
+ // evaluation itself already happens immediately at script start,
775
+ // independent of this.
776
+ var activeTab2 = document.querySelector('.tab.active');
777
+ var activeName = activeTab2 ? activeTab2.dataset.tab : 'overview';
778
+ if (activeName === 'overview') refreshOverview();
779
+ if (activeName === 'programs') loadPrograms();
780
+ if (activeName === 'cycles') loadCycles();
781
+ if (activeName === 'feedback') loadFeedback();
782
+ if (activeName === 'notify') loadNotifAdapters();
783
+ startAutoRefresh();
784
+ } else {
785
+ document.getElementById('ov-devices-wrap').innerHTML =
786
+ '<div class="empty"><p>' + _('No devices configured.') + '</p></div>';
787
+ }
788
+ }
789
+ }
790
+
791
+ // ── OVERVIEW ──────────────────────────────────────
792
+ function refreshOverview() {
793
+ var deviceId = selVal('overview');
794
+ if (!deviceId) return;
795
+
796
+ Promise.all([
797
+ send('getStatus', { deviceId: deviceId }),
798
+ send('getCycleHistory', { deviceId: deviceId }),
799
+ ]).then(function(results) {
800
+ var stRes = results[0];
801
+ var cyRes = results[1];
802
+
803
+ var needsFb = cyRes && cyRes.ok && cyRes.cycles.some(function(c) { return !c.confirmed; });
804
+ document.getElementById('overview-feedback').style.display = needsFb ? '' : 'none';
805
+ updateFeedbackBadge(cyRes && cyRes.cycles || []);
806
+
807
+ if (stRes && stRes.ok) {
808
+ var s = stRes.status;
809
+ document.getElementById('ov-dot').className = 'dot ' + s.state;
810
+ document.getElementById('ov-state').textContent = getStateLabel(s.state);
811
+ var progEl = document.getElementById('ov-program');
812
+ var confEl = document.getElementById('ov-confidence');
813
+ if (s.state === 'off') {
814
+ // When off: clear the pills
815
+ progEl.textContent = '–';
816
+ confEl.textContent = '–';
817
+ } else if (s.program && s.program !== 'detecting...') {
818
+ progEl.textContent = s.program;
819
+ confEl.textContent = s.confidence ? Math.round(s.confidence*100)+'%' : '–';
820
+ } else if (s.bestCandidate) {
821
+ progEl.innerHTML = '≈ ' + s.bestCandidate.name;
822
+ confEl.textContent = '~' + s.bestCandidate.confidence + '%';
823
+ } else {
824
+ progEl.textContent = s.program || '–';
825
+ confEl.textContent = s.confidence ? Math.round(s.confidence*100)+'%' : '–';
826
+ }
827
+ var running = s.state === 'running' || s.state === 'paused';
828
+ var isDetecting = !s.program || s.program === 'detecting...' || s.program === '–';
829
+ document.getElementById('ov-progress').style.display = running ? '' : 'none';
830
+ // Progress bar - hide when detecting
831
+ var bar = document.getElementById('ov-bar');
832
+ var pctEl = document.getElementById('ov-pct');
833
+ if (isDetecting) {
834
+ if (bar) bar.style.width = '0%';
835
+ if (pctEl) pctEl.textContent = '–';
836
+ } else {
837
+ if (bar) bar.style.width = (s.cycleProgress || 0) + '%';
838
+ if (pctEl) pctEl.textContent = (s.cycleProgress || 0) + '%';
839
+ }
840
+ // Time remaining - hide when detecting
841
+ var remEl = document.getElementById('ov-remaining');
842
+ if (remEl) remEl.textContent = (!isDetecting && s.timeRemaining > 0)
843
+ ? Math.round(s.timeRemaining / 60) + ' min'
844
+ : '–';
845
+ // Elapsed time
846
+ var elRow = document.getElementById('ov-elapsed-row');
847
+ var elEl = document.getElementById('ov-elapsed');
848
+ if (running && s.elapsedTime > 0) {
849
+ if (elRow) elRow.style.display = '';
850
+ if (elEl) elEl.textContent = s.elapsedTime + ' min';
851
+ } else {
852
+ if (elRow) elRow.style.display = 'none';
853
+ }
854
+
855
+ // Show phase
856
+ var phaseRow = document.getElementById('ov-phase-row');
857
+ var phaseEl = document.getElementById('ov-phase');
858
+ if (running && s.phase) {
859
+ if (phaseRow) phaseRow.style.display = '';
860
+ if (phaseEl) phaseEl.textContent = getPhaseLabel(s.phase);
861
+ } else {
862
+ if (phaseRow) phaseRow.style.display = 'none';
863
+ }
864
+ }
865
+
866
+ if (cyRes && cyRes.ok && cyRes.cycles.length > 0) {
867
+ var c = cyRes.cycles[0];
868
+ var dur = Math.round((c.durationMs || 0) / 60000);
869
+ var kwh = ((c.energyWh || 0) / 1000).toFixed(3);
870
+ var conf = c.confidence ? Math.round(c.confidence * 100) + '%' : '–';
871
+ var confirmed = c.confirmed
872
+ ? '<span class="badge bg-green">✓ ' + _('Confirmed') + '</span>'
873
+ : '<span class="badge bg-orange">❓ ' + _('Open') + '</span>';
874
+ document.getElementById('ov-last').innerHTML =
875
+ '<table class="tbl">' +
876
+ '<tr><td class="text-muted" style="width:130px">' + _('Program') + '</td><td><strong>' + (c.matchedProfile||_('Unknown')) + '</strong> ' + confirmed + '</td></tr>' +
877
+ '<tr><td class="text-muted">' + _('Start') + '</td><td>' + new Date(c.startTime).toLocaleString(_currentLang || 'en',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}) + '</td></tr>' +
878
+ '<tr><td class="text-muted">' + _('Duration') + '</td><td>' + dur + ' min</td></tr>' +
879
+ '<tr><td class="text-muted">' + _('Consumption') + '</td><td>' + kwh + ' kWh</td></tr>' +
880
+ '<tr><td class="text-muted">' + _('Confidence') + '</td><td>' + conf + '</td></tr>' +
881
+ '</table>';
882
+ }
883
+
884
+ // All devices
885
+ var colors = { off:'#ccc', running:'#34a853', starting:'#ff9800', ending:'#ea4335' };
886
+ Promise.all(devices.map(function(d) {
887
+ return send('getStatus', { deviceId: d.deviceId }).then(function(r) { return { d: d, r: r }; });
888
+ })).then(function(allRes) {
889
+ document.getElementById('ov-devices-wrap').innerHTML =
890
+ '<table class="tbl"><thead><tr><th>' + _('Device') + '</th><th>' + _('Status') + '</th><th>' + _('Program') + '</th></tr></thead><tbody>' +
891
+ allRes.map(function(item) {
892
+ var s = item.r && item.r.ok ? item.r.status : { state:'–', program:'–' };
893
+ return '<tr><td><strong>' + item.d.name + '</strong></td>' +
894
+ '<td><span style="display:inline-flex;align-items:center;gap:6px">' +
895
+ '<span style="width:9px;height:9px;border-radius:50%;background:' + (colors[s.state]||'#ccc') + ';display:inline-block"></span>' +
896
+ getStateLabel(s.state) + '</span></td>' +
897
+ '<td>' + (s.program||'–') + '</td></tr>';
898
+ }).join('') +
899
+ '</tbody></table>';
900
+ });
901
+
902
+ document.getElementById('ov-updated').textContent = new Date().toLocaleTimeString(_currentLang || 'en');
903
+
904
+ // Update control card
905
+ send('getProfiles', {deviceId: deviceId}).then(function(pr) {
906
+ updateControlCard(stRes && stRes.ok ? stRes.status : null, pr && pr.ok ? pr.profiles : []);
907
+ });
908
+
909
+ // Load suggested settings
910
+ send('getSuggestedSettings', {deviceId: deviceId}).then(function(res) {
911
+ var card = document.getElementById('suggested-card');
912
+ if (!res || !res.ok || !res.settings) { card.style.display = 'none'; return; }
913
+ var s = res.settings;
914
+ var rows = '';
915
+ var fields = [
916
+ { key:'startEnergyThreshold', label:_('Start energy gate'), unit:'Wh', min:0, max:50, dec:1 },
917
+ { key:'durationTolerance', label:_('Duration tolerance'), unit:'', min:0.05, max:0.5, dec:2 },
918
+ { key:'offDelayMin', label:_('Off delay'),unit:'min', min:1, max:60, dec:0 },
919
+ { key:'powerThreshold', label:_('Power-on threshold'), unit:'W', min:1, max:500, dec:0 },
920
+ ];
921
+ fields.forEach(function(f) {
922
+ if (s[f.key] === undefined) return;
923
+ var val = Math.min(f.max, Math.max(f.min, s[f.key]));
924
+ var valStr = val.toFixed(f.dec);
925
+ rows += '<tr>' +
926
+ '<td class="text-muted">' + f.label + '</td>' +
927
+ '<td><strong>' + valStr + (f.unit ? ' ' + f.unit : '') + '</strong></td>' +
928
+ '<td style="text-align:right;white-space:nowrap">' +
929
+ '<button class="btn btn-success btn-xs" title="' + _('Apply') + '" onclick="applySingleSuggestion(\''+deviceId+'\',\''+f.key+'\','+val+')">✓</button> ' +
930
+ '<button class="btn btn-outline btn-xs" title="' + _('Ignore') + '" onclick="dismissSuggestion(\''+deviceId+'\',\''+f.key+'\','+val+')">✕</button>' +
931
+ '</td></tr>';
932
+ });
933
+ document.getElementById('suggested-body').innerHTML =
934
+ '<p class="text-sm text-muted" style="margin-bottom:8px">' + _('Based on') + ' <strong>' + s.basedOnCycles + ' ' + _('cycles') + '</strong> – ' + _('fields with >10% deviation only:') + '</p>' +
935
+ '<table class="tbl">' + rows + '</table>';
936
+ card.style.display = '';
937
+ });
938
+ });
939
+ }
940
+
941
+ function updateFeedbackBadge(cyc) {
942
+ var open = cyc.filter(function(c) { return !c.confirmed; }).length;
943
+ var badge = document.getElementById('feedback-badge');
944
+ badge.textContent = open;
945
+ badge.style.display = open > 0 ? '' : 'none';
946
+ }
947
+
948
+ // Update clock every second
949
+ setInterval(function() {
950
+ var el = document.getElementById('ov-time');
951
+ if (el) el.textContent = new Date().toLocaleTimeString(_currentLang || 'en');
952
+ }, 1000);
953
+
954
+ setInterval(function(){ var e=document.getElementById("ov-updated"); if(e) e.textContent=new Date().toLocaleTimeString(_currentLang || 'en'); },1000);
955
+ function startAutoRefresh() {
956
+ clearInterval(_refreshTimer);
957
+ _refreshTimer = setInterval(function() {
958
+ if (document.getElementById('pane-overview').classList.contains('active')) refreshOverview();
959
+ }, 15000);
960
+ }
961
+
962
+ // ── PROGRAMS ──────────────────────────────────────
963
+ function loadPrograms() {
964
+ var deviceId = selVal('programs');
965
+ var isDryerDev = isDryer(deviceId);
966
+
967
+ // Only show the anti-crease card for dryers
968
+ var akCard = document.getElementById('antiknitter-card');
969
+ if (akCard) akCard.style.display = isDryerDev ? '' : 'none';
970
+
971
+ Promise.all([
972
+ send('getProfiles', { deviceId: deviceId }),
973
+ isDryerDev ? send('getCycleHistory', { deviceId: deviceId }) : Promise.resolve(null),
974
+ isDryerDev ? send('getAntiKnitter', { deviceId: deviceId }) : Promise.resolve(null),
975
+ ]).then(function(results) {
976
+ var res = results[0];
977
+ var cyRes = results[1];
978
+ var akRes = results[2];
979
+ if (!res || !res.ok) return toast(_('Error loading'), 'err');
980
+ programs[deviceId] = res.profiles;
981
+ document.getElementById('prog-count').textContent = res.profiles.length;
982
+ renderPrograms(deviceId);
983
+
984
+ if (isDryerDev) {
985
+ // Populate the cycle dropdown
986
+ var sel = document.getElementById('antiknitter-select');
987
+ sel.innerHTML = '<option value="">' + _('– No cycle selected –') + '</option>';
988
+ var cyList = (cyRes && cyRes.ok) ? cyRes.cycles : [];
989
+ cyList.forEach(function(c) {
990
+ var dur = Math.round((c.durationMs||0)/60000);
991
+ var dt = new Date(c.startTime).toLocaleString(_currentLang || 'en',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'});
992
+ var opt = document.createElement('option');
993
+ opt.value = c.id;
994
+ opt.textContent = dt + ' – ' + dur + ' min (' + ((c.energyWh||0)/1000).toFixed(3) + ' kWh)';
995
+ sel.appendChild(opt);
996
+ });
997
+ // Show current status
998
+ var statusEl = document.getElementById('antiknitter-status');
999
+ if (akRes && akRes.ok && akRes.antiKnitter) {
1000
+ var ak = akRes.antiKnitter;
1001
+ var learnedDate = new Date(ak.learnedAt).toLocaleString(_currentLang || 'en',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'});
1002
+ statusEl.innerHTML = '✅ ' + _('Learned on') + ' ' + learnedDate + ' · max ' + Math.round(ak.maxWatts) + 'W · ' + Math.round(ak.durationMs/60000) + ' ' + _('min lock period');
1003
+ statusEl.style.color = '#1e7e34';
1004
+ } else {
1005
+ statusEl.textContent = _('No anti-crease pattern saved yet.');
1006
+ statusEl.style.color = '';
1007
+ }
1008
+ }
1009
+ });
1010
+ }
1011
+
1012
+ function saveAntiKnitterFromDropdown() {
1013
+ var deviceId = selVal('programs');
1014
+ var cycleId = document.getElementById('antiknitter-select').value;
1015
+ if (!cycleId) return toast(_('Please select a cycle'), 'err');
1016
+ send('setAntiKnitter', { deviceId: deviceId, cycleId: cycleId }).then(function(res) {
1017
+ if (res && res.ok) {
1018
+ toast('✅ ' + _('Anti-crease pattern saved!') + ' ' + Math.round(res.durationMin) + ' min · max ' + Math.round(res.maxWatts) + 'W', 'ok');
1019
+ loadPrograms(); // Refresh status
1020
+ } else {
1021
+ toast((res && res.error) || _('Error'), 'err');
1022
+ }
1023
+ });
1024
+ }
1025
+
1026
+ function clearAntiKnitter() {
1027
+ var deviceId = selVal('programs');
1028
+ if (!confirm(_('Really delete the saved anti-crease pattern?'))) return;
1029
+ send('clearAntiKnitter', { deviceId: deviceId }).then(function(res) {
1030
+ if (res && res.ok) {
1031
+ toast('🗑 ' + _('Anti-crease pattern deleted'), 'ok');
1032
+ document.getElementById('antiknitter-select').value = '';
1033
+ loadPrograms(); // Refresh status
1034
+ } else {
1035
+ toast((res && res.error) || 'Fehler', 'err');
1036
+ }
1037
+ });
1038
+ }
1039
+
1040
+ function renderPrograms(deviceId) {
1041
+ var list = programs[deviceId] || [];
1042
+ var wrap = document.getElementById('prog-list-wrap');
1043
+ if (list.length === 0) {
1044
+ wrap.innerHTML = '<div class="empty"><p>Noch keine Programme angelegt.</p></div>';
1045
+ return;
1046
+ }
1047
+ wrap.innerHTML = '<table class="tbl"><thead><tr><th>Name</th><th>Dauer (Ø)</th><th>Zyklen</th><th>Angelegt</th><th></th></tr></thead><tbody>' +
1048
+ list.map(function(p) {
1049
+ return '<tr>' +
1050
+ '<td><strong>' + p.name + '</strong>' + (p.isManual ? ' <span class="badge bg-gray">manuell</span>' : ' <span class="badge bg-green">gelernt</span>') + '</td>' +
1051
+ '<td>' + (p.durationMs ? Math.round(p.durationMs/60000)+' min' : '–') + '</td>' +
1052
+ '<td>' + (p.cycleCount||0) + '</td>' +
1053
+ '<td class="text-sm text-muted">' + (p.createdAt ? new Date(p.createdAt).toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'}) : '–') + '</td>' +
1054
+ '<td><div class="row" style="gap:4px">' +
1055
+ '<button class="btn btn-outline btn-xs" onclick="openRename(\'' + p.id + '\',\'' + p.name.replace(/'/g,"\\'") + '\')">✏️</button>' +
1056
+ '<button class="btn btn-danger btn-xs" onclick="deleteProgram(\'' + p.id + '\',\'' + p.name.replace(/'/g,"\\'") + '\')">🗑</button>' +
1057
+ '</div></td></tr>';
1058
+ }).join('') +
1059
+ '</tbody></table>';
1060
+ }
1061
+
1062
+ function createProgram() {
1063
+ var deviceId = selVal('programs');
1064
+ var name = document.getElementById('prog-name').value.trim();
1065
+ var durationMin = parseInt(document.getElementById('prog-duration').value) || 90;
1066
+ if (!name) return toast('Bitte einen Namen eingeben', 'err');
1067
+ send('createProfile', { deviceId: deviceId, name: name, durationMin: durationMin }).then(function(res) {
1068
+ if (res && res.ok) {
1069
+ toast('Programm „' + name + '" angelegt', 'ok');
1070
+ document.getElementById('prog-name').value = '';
1071
+ loadPrograms();
1072
+ } else {
1073
+ toast((res && res.error) || 'Fehler', 'err');
1074
+ }
1075
+ });
1076
+ }
1077
+
1078
+ function openRename(id, name) {
1079
+ _renameId = id;
1080
+ document.getElementById('rename-input').value = name;
1081
+ document.getElementById('modal-rename').classList.add('open');
1082
+ setTimeout(function() { document.getElementById('rename-input').focus(); }, 50);
1083
+ }
1084
+
1085
+ function confirmRename() {
1086
+ var deviceId = selVal('programs');
1087
+ var name = document.getElementById('rename-input').value.trim();
1088
+ if (!name || !_renameId) return;
1089
+ send('renameProfile', { deviceId: deviceId, profileId: _renameId, name: name }).then(function(res) {
1090
+ closeModal('modal-rename');
1091
+ if (res && res.ok) { toast(_('Renamed'), 'ok'); loadPrograms(); }
1092
+ else toast((res && res.error) || _('Error'), 'err');
1093
+ });
1094
+ }
1095
+
1096
+ function deleteProgram(id, name) {
1097
+ if (!confirm(_('Really delete program "') + name + '"?')) return;
1098
+ var deviceId = selVal('programs');
1099
+ send('deleteProfile', { deviceId: deviceId, profileId: id }).then(function(res) {
1100
+ if (res && res.ok) { toast(_('Deleted'), 'ok'); loadPrograms(); }
1101
+ else toast((res && res.error) || _('Error'), 'err');
1102
+ });
1103
+ }
1104
+
1105
+ // ── CYCLES ─────────────────────────────────────────
1106
+ function loadCycles() {
1107
+ var deviceId = selVal('cycles');
1108
+ Promise.all([
1109
+ send('getCycleHistory', { deviceId: deviceId }),
1110
+ send('getProfiles', { deviceId: deviceId }),
1111
+ ]).then(function(results) {
1112
+ var cyRes = results[0];
1113
+ var prRes = results[1];
1114
+ if (!cyRes || !cyRes.ok) return;
1115
+ cycles[deviceId] = cyRes.cycles;
1116
+ programs[deviceId] = prRes && prRes.ok ? prRes.profiles : [];
1117
+ document.getElementById('cycle-count').textContent = cyRes.cycles.length;
1118
+ renderCycles(deviceId);
1119
+ });
1120
+ }
1121
+
1122
+ function renderCycles(deviceId) {
1123
+ var list = cycles[deviceId] || [];
1124
+ var wrap = document.getElementById('cycle-list-wrap');
1125
+ if (list.length === 0) {
1126
+ wrap.innerHTML = '<div class="empty"><p>' + _('No cycles recorded yet.') + '</p></div>';
1127
+ return;
1128
+ }
1129
+ var rows = list.map(function(c, idx) {
1130
+ var dur = Math.round((c.durationMs||0)/60000);
1131
+ var kwh = ((c.energyWh||0)/1000).toFixed(3);
1132
+ var conf = c.confidence ? Math.round(c.confidence*100)+'%'
1133
+ : (c.bestCandidate && c.bestCandidate.confidence ? '~' + c.bestCandidate.confidence + '%' : '–');
1134
+ var isAK = c.isAntiKnitter || c.matchedProfile === '🌀 Anti-Knitter';
1135
+ var statusBadge = isAK
1136
+ ? '<span class="badge" style="background:#34a853;color:#fff">🌀</span>'
1137
+ : (c.confirmed
1138
+ ? (c.corrected ? '<span class="badge bg-blue">🔄</span>' : '<span class="badge bg-green">✓</span>')
1139
+ : '<span class="badge bg-orange">❓</span>');
1140
+ var rowStyle = isAK ? 'opacity:0.6;' : '';
1141
+ var rowId = 'cycle-detail-' + idx;
1142
+ return '<tr class="cycle-row" onclick="toggleCycleDetail(\''+deviceId+'\',\''+c.id+'\',\''+rowId+'\')" style="cursor:pointer;'+rowStyle+'">' +
1143
+ '<td class="text-sm">' + new Date(c.startTime).toLocaleString(_currentLang || 'en',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) + '</td>' +
1144
+ '<td><strong>' + (c.matchedProfile||_('Unknown')) + '</strong><br><span class="text-sm text-muted">' + conf + '</span></td>' +
1145
+ '<td>' + dur + ' min</td>' +
1146
+ '<td>' + kwh + '</td>' +
1147
+ '<td>' + statusBadge + '</td>' +
1148
+ '<td><span class="cycle-arrow" id="arrow-'+rowId+'">▶</span></td>' +
1149
+ '</tr>' +
1150
+ '<tr id="' + rowId + '" style="display:none"><td colspan="6" style="padding:0">' +
1151
+ '<div class="cycle-detail-wrap" style="padding:12px;background:#f8f9fa;border-bottom:1px solid #e0e0e0">' +
1152
+ '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">' +
1153
+ '<span style="font-weight:600">' + _('Power curve') + '</span>' +
1154
+ '<span>' +
1155
+ '<button class="btn btn-outline btn-xs" onclick="event.stopPropagation();trimInlineGraph(\''+rowId+'\')" style="margin-right:4px">✂️ ' + _('Trim') + '</button>' +
1156
+ '<button class="btn btn-outline btn-xs" onclick="event.stopPropagation();shareInlineGraph(\''+deviceId+'\',\''+c.id+'\',\''+rowId+'\')">⚡ ' + _('Split') + '</button>' +
1157
+ '</span></div>' +
1158
+ '<canvas id="canvas-'+rowId+'" style="width:100%;height:180px;cursor:crosshair"></canvas>' +
1159
+ '<div id="legend-'+rowId+'" style="display:flex;flex-wrap:wrap;gap:6px;margin-top:6px"></div>' +
1160
+ '<div id="trim-controls-'+rowId+'" style="display:none;margin-top:8px;display:none">' +
1161
+ '<div style="display:flex;gap:8px">' +
1162
+ '<button class="btn btn-primary btn-sm" onclick="event.stopPropagation();applyTrimInline(\''+deviceId+'\',\''+c.id+'\',\''+rowId+'\')">✅ ' + _('Apply') + '</button>' +
1163
+ '<button class="btn btn-outline btn-sm" onclick="event.stopPropagation();cancelTrimInline(\''+rowId+'\')">✖</button>' +
1164
+ '</div></div>' +
1165
+ '<div id="split-controls-'+rowId+'" style="display:none;margin-top:8px">' +
1166
+ '<div style="display:flex;gap:8px">' +
1167
+ '<button class="btn btn-warn btn-sm" onclick="event.stopPropagation();applySplitInline(\''+deviceId+'\',\''+c.id+'\',\''+rowId+'\')">⚡ ' + _('Split') + '</button>' +
1168
+ '<button class="btn btn-outline btn-sm" onclick="event.stopPropagation();cancelSplitInline(\''+rowId+'\')">✖</button>' +
1169
+ '</div></div>' +
1170
+ '<div style="display:flex;gap:8px;margin-top:10px;flex-wrap:wrap">' +
1171
+ '<button class="btn btn-primary btn-sm" onclick="event.stopPropagation();saveAsProgram(\''+deviceId+'\',\''+c.id+'\')">💾 ' + _('Save as program') + '</button>' +
1172
+ '<button class="btn btn-outline btn-sm" onclick="event.stopPropagation();assignProgram(\''+deviceId+'\',\''+c.id+'\')">🏷 ' + _('Assign program') + '</button>' +
1173
+
1174
+ '<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteCycle(\''+deviceId+'\',\''+c.id+'\')">🗑 ' + _('Delete') + '</button>' +
1175
+ '</div></div></td></tr>';
1176
+ }).join('');
1177
+ wrap.innerHTML = '<table class="tbl" style="width:100%"><thead><tr><th>' + _('Start') + '</th><th>' + _('Program') + '</th><th>min</th><th>kWh</th><th></th><th></th></tr></thead><tbody>' + rows + '</tbody></table>';
1178
+ }
1179
+
1180
+ function toggleCycleDetail(deviceId, cycleId, rowId) {
1181
+ var row = document.getElementById(rowId);
1182
+ var arrow = document.getElementById('arrow-' + rowId);
1183
+ if (!row) return;
1184
+ var isOpen = row.style.display !== 'none';
1185
+ // Close all
1186
+ document.querySelectorAll('[id^="cycle-detail-"]').forEach(function(r) {
1187
+ r.style.display = 'none';
1188
+ });
1189
+ document.querySelectorAll('[id^="arrow-cycle-detail-"]').forEach(function(a) {
1190
+ a.textContent = '▶';
1191
+ });
1192
+ if (!isOpen) {
1193
+ row.style.display = '';
1194
+ if (arrow) arrow.textContent = '▼';
1195
+ // Load graph - setTimeout so the DOM has rendered
1196
+ send('getTrace', { deviceId: deviceId, cycleId: cycleId }).then(function(res) {
1197
+ if (!res || !res.ok || !res.trace) return;
1198
+ var cycleList = cycles[deviceId] || [];
1199
+ var cyc = cycleList.find(function(c) { return c.id === cycleId; });
1200
+ setTimeout(function() {
1201
+ var canvas = document.getElementById('canvas-' + rowId);
1202
+ var legendEl = document.getElementById('legend-' + rowId);
1203
+ if (!canvas) return;
1204
+ // Set a fixed size
1205
+ var wrap = canvas.closest('.cycle-detail-wrap') || canvas.parentElement;
1206
+ var w = (wrap ? wrap.clientWidth : 0) - 24;
1207
+ if (w < 100) w = window.innerWidth - 60;
1208
+ canvas.width = w;
1209
+ canvas.height = 180;
1210
+ canvas.style.width = w + 'px';
1211
+ canvas.style.height = '180px';
1212
+ var tracePoints = res.trace.points || res.trace.powerTrace || (Array.isArray(res.trace) ? res.trace : []);
1213
+ // Store tracePoints for trimming
1214
+ if (!_inlineState[rowId]) _inlineState[rowId] = {};
1215
+ _inlineState[rowId].tracePoints = tracePoints;
1216
+ _inlineState[rowId].phaseHistory = (cyc && cyc.phaseHistory) || [];
1217
+ _inlineState[rowId].canvas = canvas;
1218
+ _inlineState[rowId].legendEl = legendEl;
1219
+ drawInlineGraph(canvas, legendEl, tracePoints, _inlineState[rowId].phaseHistory);
1220
+ }, 200);
1221
+ });
1222
+ }
1223
+ }
1224
+
1225
+ function drawInlineGraph(canvas, legendEl, trace, phaseHist) {
1226
+ // Canvas size from attribute or parent
1227
+ var dpr = window.devicePixelRatio || 1;
1228
+ var W = canvas.style.width ? parseInt(canvas.style.width) :
1229
+ (canvas.parentElement ? canvas.parentElement.clientWidth - 24 : 300);
1230
+ if (W < 50) W = 300;
1231
+ var H = 180;
1232
+ canvas.width = W * dpr;
1233
+ canvas.height = H * dpr;
1234
+ canvas.style.width = W + 'px';
1235
+ canvas.style.height = H + 'px';
1236
+ var ctx = canvas.getContext('2d');
1237
+ if (!ctx) return;
1238
+ ctx.scale(dpr, dpr);
1239
+ ctx.clearRect(0, 0, W, H);
1240
+ if (!trace || trace.length < 2) {
1241
+ ctx.fillStyle = '#666'; ctx.font = '13px sans-serif'; ctx.textAlign = 'center';
1242
+ ctx.fillText(_('No trace data'), W/2, H/2); return;
1243
+ }
1244
+ var PAD = {top:12, right:8, bottom:20, left:40};
1245
+ var gW = W - PAD.left - PAD.right, gH = H - PAD.top - PAD.bottom;
1246
+ var t0 = trace[0].ts, t1 = trace[trace.length-1].ts, tRange = t1 - t0 || 1;
1247
+ var maxW = Math.max.apply(null, trace.map(function(p){return p.watts;})) || 1;
1248
+ function tx(ts) { return PAD.left + (ts - t0) / tRange * gW; }
1249
+ function ty(w) { return PAD.top + gH - (w / maxW * gH); }
1250
+
1251
+ // Phase backgrounds
1252
+ ctx.save();
1253
+ ctx.beginPath(); ctx.rect(PAD.left, PAD.top, gW, gH); ctx.clip();
1254
+ if (phaseHist && phaseHist.length > 0) {
1255
+ // startTime from the trace, for relative-tMs conversion
1256
+ var traceStart = (trace && trace.length > 0) ? trace[0].ts : 0;
1257
+ phaseHist.forEach(function(ph, i) {
1258
+ // Support both absolute ts and relative tMs
1259
+ var phTs = ph.ts !== undefined ? ph.ts : (traceStart + (ph.tMs || 0));
1260
+ var nextPh = phaseHist[i+1];
1261
+ var nextTs = nextPh !== undefined
1262
+ ? (nextPh.ts !== undefined ? nextPh.ts : (traceStart + (nextPh.tMs || 0)))
1263
+ : null;
1264
+ var xS = Math.max(tx(phTs), PAD.left);
1265
+ var xE = nextTs !== null ? Math.min(tx(nextTs), PAD.left+gW) : PAD.left+gW;
1266
+ ctx.fillStyle = (PHASE_BG_COLORS && PHASE_BG_COLORS[ph.phase]) || 'rgba(200,200,200,0.2)';
1267
+ ctx.fillRect(xS, PAD.top, xE-xS, gH);
1268
+ ctx.strokeStyle = (PHASE_LINE_COLORS && PHASE_LINE_COLORS[ph.phase]) || '#999';
1269
+ ctx.lineWidth = 1; ctx.setLineDash([3,3]);
1270
+ ctx.beginPath(); ctx.moveTo(xS, PAD.top); ctx.lineTo(xS, PAD.top+gH); ctx.stroke();
1271
+ ctx.setLineDash([]);
1272
+ });
1273
+ }
1274
+ ctx.restore();
1275
+
1276
+ // Kurve
1277
+ ctx.strokeStyle = '#1a73e8'; ctx.lineWidth = 1.5; ctx.setLineDash([]);
1278
+ ctx.beginPath();
1279
+ trace.forEach(function(p, i) {
1280
+ if (i === 0) ctx.moveTo(tx(p.ts), ty(p.watts));
1281
+ else ctx.lineTo(tx(p.ts), ty(p.watts));
1282
+ });
1283
+ ctx.stroke();
1284
+
1285
+ // Achsen
1286
+ ctx.strokeStyle = '#e0e0e0'; ctx.lineWidth = 0.5;
1287
+ [0.25,0.5,0.75,1].forEach(function(s) {
1288
+ var y = PAD.top + gH*(1-s);
1289
+ ctx.beginPath(); ctx.moveTo(PAD.left,y); ctx.lineTo(PAD.left+gW,y); ctx.stroke();
1290
+ ctx.fillStyle='#888'; ctx.font='9px sans-serif'; ctx.textAlign='right';
1291
+ ctx.fillText(Math.round(maxW*s)+'W', PAD.left-3, y+3);
1292
+ });
1293
+ // Zeit-Achse
1294
+ ctx.fillStyle='#888'; ctx.font='9px sans-serif'; ctx.textAlign='center';
1295
+ var durMin = Math.round(tRange/60000);
1296
+ [0, Math.round(durMin/2), durMin].forEach(function(m) {
1297
+ var x = PAD.left + (m/durMin)*gW;
1298
+ ctx.fillText(m+'min', x, PAD.top+gH+12);
1299
+ });
1300
+
1301
+ // Legende
1302
+ if (legendEl && phaseHist && phaseHist.length > 0) {
1303
+ var seen = {}, unique = [];
1304
+ phaseHist.forEach(function(ph) { if (!seen[ph.phase]) { seen[ph.phase]=true; unique.push(ph.phase); }});
1305
+ legendEl.style.display = 'flex';
1306
+ legendEl.innerHTML = unique.map(function(p) {
1307
+ var col = (PHASE_LINE_COLORS && PHASE_LINE_COLORS[p]) || '#999';
1308
+ return '<span style="display:flex;align-items:center;gap:3px;font-size:10px;color:#444">' +
1309
+ '<span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:'+col+'"></span>'+getPhaseLabel(p)+'</span>';
1310
+ }).join('');
1311
+ } else if (legendEl) { legendEl.style.display='none'; }
1312
+ }
1313
+
1314
+ // ── DELETE CYCLE ────────────────────────────────────────────
1315
+ function deleteCycle(deviceId, cycleId) {
1316
+ if (!confirm(_('Really delete this cycle?'))) return;
1317
+ send('deleteCycle', {deviceId: deviceId, cycleId: cycleId}).then(function(res) {
1318
+ if (res && res.ok) { toast(_('Cycle deleted'), 'ok'); loadCycles(); }
1319
+ else toast((res && res.error) || _('Error'), 'err');
1320
+ });
1321
+ }
1322
+
1323
+ // ── REVIEW ─────────────────────────────────────────
1324
+ function loadFeedback() {
1325
+ var deviceId = selVal('feedback');
1326
+ Promise.all([
1327
+ send('getCycleHistory', { deviceId: deviceId }),
1328
+ send('getProfiles', { deviceId: deviceId }),
1329
+ ]).then(function(results) {
1330
+ var cyRes = results[0];
1331
+ var prRes = results[1];
1332
+ if (!cyRes || !cyRes.ok) return;
1333
+ cycles[deviceId] = cyRes.cycles;
1334
+ programs[deviceId] = prRes && prRes.ok ? prRes.profiles : [];
1335
+ updateFeedbackBadge(cyRes.cycles);
1336
+ renderFeedback(deviceId);
1337
+ });
1338
+ }
1339
+
1340
+ function renderFeedback(deviceId) {
1341
+ var open = (cycles[deviceId] || []).filter(function(c) { return !c.confirmed; });
1342
+ var wrap = document.getElementById('feedback-list-wrap');
1343
+ if (open.length === 0) {
1344
+ wrap.innerHTML = '<div class="empty"><p>✅ ' + _('All cycles confirmed!') + '</p></div>';
1345
+ return;
1346
+ }
1347
+ wrap.innerHTML = open.map(function(c) {
1348
+ var dur = Math.round((c.durationMs||0)/60000);
1349
+ var kwh = ((c.energyWh||0)/1000).toFixed(3);
1350
+ var conf = c.confidence ? Math.round(c.confidence*100) : 0;
1351
+ var confColor = conf >= 80 ? 'bg-green' : conf >= 60 ? 'bg-orange' : 'bg-red';
1352
+ return '<div class="card">' +
1353
+ '<div class="card-head">' + new Date(c.startTime).toLocaleString(_currentLang || 'en',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}) + '<span class="badge ' + confColor + '">' + conf + '% ' + _('confidence') + '</span></div>' +
1354
+ '<div class="card-body">' +
1355
+ '<table class="tbl" style="margin-bottom:12px">' +
1356
+ '<tr><td class="text-muted" style="width:110px">' + _('Detected as') + '</td><td><strong>' + (c.matchedProfile||_('Unknown')) + '</strong></td></tr>' +
1357
+ '<tr><td class="text-muted">' + _('Duration') + '</td><td>' + dur + ' min</td></tr>' +
1358
+ '<tr><td class="text-muted">' + _('Consumption') + '</td><td>' + kwh + ' kWh</td></tr>' +
1359
+ '</table>' +
1360
+ '<div class="row" style="gap:8px">' +
1361
+ '<button class="btn btn-success btn-sm" onclick="confirmCycle(\'' + deviceId + '\',\'' + c.id + '\')">✓ ' + _('Correct – confirm') + '</button>' +
1362
+ '<button class="btn btn-warn btn-sm" onclick="openCorrect(\'' + deviceId + '\',\'' + c.id + '\')">🔄 ' + _('Wrong program') + '</button>' +
1363
+ '</div></div></div>';
1364
+ }).join('');
1365
+ }
1366
+
1367
+ function confirmCycle(deviceId, cycleId) {
1368
+ send('confirmCycle', { deviceId: deviceId, cycleId: cycleId }).then(function(res) {
1369
+ if (res && res.ok) { toast(_('Confirmed – adapter is learning!'), 'ok'); loadFeedback(); refreshOverview(); }
1370
+ else toast((res && res.error) || _('Error'), 'err');
1371
+ });
1372
+ }
1373
+
1374
+ function openCorrect(deviceId, cycleId) {
1375
+ _correctCycleId = { deviceId: deviceId, cycleId: cycleId };
1376
+ var progs = programs[deviceId] || [];
1377
+ document.getElementById('correct-select').innerHTML =
1378
+ progs.map(function(p) { return '<option value="' + p.id + '">' + p.name + '</option>'; }).join('');
1379
+ document.getElementById('modal-correct').classList.add('open');
1380
+ }
1381
+
1382
+ function confirmCorrect() {
1383
+ if (!_correctCycleId) return;
1384
+ var profileId = document.getElementById('correct-select').value;
1385
+ send('correctCycle', { deviceId: _correctCycleId.deviceId, cycleId: _correctCycleId.cycleId, profileId: profileId }).then(function(res) {
1386
+ closeModal('modal-correct');
1387
+ if (res && res.ok) { toast(_('Corrected – adapter is learning!'), 'ok'); loadFeedback(); }
1388
+ else toast((res && res.error) || _('Error'), 'err');
1389
+ });
1390
+ }
1391
+
1392
+ // ── EXPORT ──────────────────────────────────────────
1393
+ function doExport() {
1394
+ var deviceId = selVal('export');
1395
+ send('exportConfig', { deviceId: deviceId }).then(function(res) {
1396
+ if (!res || !res.ok) return toast(_('Export failed'), 'err');
1397
+ var json = JSON.stringify(res.export, null, 2);
1398
+ document.getElementById('export-preview').value = json;
1399
+ var a = document.createElement('a');
1400
+ a.href = URL.createObjectURL(new Blob([json], {type:'application/json'}));
1401
+ a.download = 'laundrylens_' + deviceId + '_' + new Date().toISOString().slice(0,10) + '.json';
1402
+ a.click();
1403
+ toast(_('Export downloaded'), 'ok');
1404
+ });
1405
+ }
1406
+
1407
+ function doImport(evt) {
1408
+ var file = evt.target.files[0];
1409
+ if (!file) return;
1410
+ var reader = new FileReader();
1411
+ reader.onload = function(e) {
1412
+ var data;
1413
+ try { data = JSON.parse(e.target.result); } catch(ex) { return toast(_('Invalid JSON file'), 'err'); }
1414
+ var deviceId = selVal('export');
1415
+ send('importConfig', { deviceId: deviceId, data: data }).then(function(res) {
1416
+ if (res && res.ok) toast(res.imported + ' Programme, ' + (res.importedCycles||0) + ' Zyklen, ' + (res.importedTraces||0) + ' Graphen importiert', 'ok');
1417
+ else toast((res && res.error) || 'Fehler', 'err');
1418
+ });
1419
+ };
1420
+ reader.readAsText(file);
1421
+ evt.target.value = '';
1422
+ }
1423
+
1424
+ // ── Modals ────────────────────────────────────────
1425
+ function closeModal(id) { document.getElementById(id).classList.remove('open'); }
1426
+ document.querySelectorAll('.overlay').forEach(function(m) {
1427
+ m.addEventListener('click', function(e) { if (e.target === m) m.classList.remove('open'); });
1428
+ });
1429
+ document.addEventListener('keydown', function(e) {
1430
+ if (e.key === 'Escape') document.querySelectorAll('.overlay.open').forEach(function(m) { m.classList.remove('open'); });
1431
+ });
1432
+
1433
+ // ── TELEGRAM ──────────────────────────────────────────────────
1434
+ function saveTelegramConfig() {
1435
+ var inst = document.getElementById('tg-instance').value;
1436
+ var target = document.getElementById('tg-target').value;
1437
+ var msg = document.getElementById('tg-msg').value;
1438
+ var deviceId = selVal('export');
1439
+ var dev = devices.find(function(d) { return d.deviceId === deviceId; });
1440
+ var instNum = (dev && dev._instance !== undefined) ? dev._instance : 0;
1441
+
1442
+ var sendToCall = target
1443
+ ? "sendTo('" + inst + "', {text: text, chatId: '" + target + "'});"
1444
+ : "sendTo('" + inst + "', text);";
1445
+
1446
+ var script =
1447
+ "on({id: 'laundrylens." + instNum + "." + deviceId + ".state', val: 'off', change: 'any'}, () => {\n" +
1448
+ " const prog = getState('laundrylens." + instNum + "." + deviceId + ".lastCycleProgram').val;\n" +
1449
+ " const min = getState('laundrylens." + instNum + "." + deviceId + ".lastCycleDuration').val;\n" +
1450
+ " const kwh = (getState('laundrylens." + instNum + "." + deviceId + ".lastCycleEnergy').val / 1000).toFixed(3);\n" +
1451
+ " const name = '" + (dev ? dev.name : deviceId) + "';\n" +
1452
+ " const text = '" + msg + "'\n" +
1453
+ " .replace('{program}', prog)\n" +
1454
+ " .replace('{duration}', min)\n" +
1455
+ " .replace('{energy}', kwh)\n" +
1456
+ " .replace('{device}', name);\n" +
1457
+ " " + sendToCall + "\n" +
1458
+ "});";
1459
+
1460
+ document.getElementById('tg-script').value = script;
1461
+ document.getElementById('tg-script-wrap').style.display = '';
1462
+ }
1463
+
1464
+ function copyTgScript() {
1465
+ var el = document.getElementById('tg-script');
1466
+ el.select();
1467
+ navigator.clipboard.writeText(el.value).then(function() {
1468
+ toast('Skript kopiert!', 'ok');
1469
+ }).catch(function() {
1470
+ document.execCommand('copy');
1471
+ toast('Kopiert!', 'ok');
1472
+ });
1473
+ }
1474
+
1475
+
1476
+ // ── LIVE-STEUERUNG ────────────────────────────────────────────
1477
+ function updateControlCard(status, profiles) {
1478
+ var card = document.getElementById('ctrl-card');
1479
+ var btn = document.getElementById('btn-force-finish');
1480
+ if (!card) return;
1481
+
1482
+ var running = status && (status.state === 'running' || status.state === 'starting' || status.state === 'paused');
1483
+ card.style.display = running ? '' : 'none';
1484
+ if (btn) btn.style.display = running ? '' : 'none';
1485
+
1486
+ // Populate the override dropdown
1487
+ var sel = document.getElementById('ctrl-override');
1488
+ if (sel && profiles) {
1489
+ var current = sel.value;
1490
+ sel.innerHTML = '<option value="auto">🤖 ' + _('Automatic') + '</option>' +
1491
+ (profiles || []).map(function(p) {
1492
+ return '<option value="'+p.name+'">'+p.name+' ('+Math.round((p.durationMs||0)/60000)+' min)</option>';
1493
+ }).join('');
1494
+ if (current) sel.value = current;
1495
+ }
1496
+ }
1497
+
1498
+ function setProgramOverride() {
1499
+ var deviceId = selVal('overview');
1500
+ var program = document.getElementById('ctrl-override').value;
1501
+ send('setProgramOverride', {deviceId: deviceId, program: program}).then(function(res) {
1502
+ if (res && res.ok) {
1503
+ toast(program === 'auto' ? 'Automatik aktiv' : 'Programm "'+program+'" gesetzt', 'ok');
1504
+ } else {
1505
+ toast((res && res.error) || 'Fehler', 'err');
1506
+ }
1507
+ });
1508
+ }
1509
+
1510
+ function forceFinish() {
1511
+ var deviceId = selVal('overview');
1512
+ if (!confirm('Zyklus wirklich beenden?')) return;
1513
+ send('forceFinish', {deviceId: deviceId}).then(function(res) {
1514
+ if (res && res.ok) {
1515
+ toast('Zyklus wird beendet…', 'ok');
1516
+ // Auto-Refresh nach 1.5s
1517
+ setTimeout(refreshOverview, 1500);
1518
+ } else {
1519
+ toast((res && res.error) || 'Fehler', 'err');
1520
+ }
1521
+ });
1522
+ }
1523
+
1524
+ // ── BENACHRICHTIGUNGEN ────────────────────────────────────────
1525
+ var _notifAdapters = [];
1526
+
1527
+
1528
+ function toggleNotif(type) {
1529
+ var tog = document.getElementById('notif-' + type + '-enabled-tog');
1530
+ var inp = document.getElementById('notif-' + type + '-enabled');
1531
+ if (!tog || !inp) return;
1532
+ var on = !tog.classList.contains('on');
1533
+ tog.classList.toggle('on', on);
1534
+ inp.value = on ? '1' : '0';
1535
+ }
1536
+ function setNotifToggle(type, val) {
1537
+ var tog = document.getElementById('notif-' + type + '-enabled-tog');
1538
+ var inp = document.getElementById('notif-' + type + '-enabled');
1539
+ if (!tog || !inp) return;
1540
+ tog.classList.toggle('on', !!val);
1541
+ inp.value = val ? '1' : '0';
1542
+ }
1543
+ function getNotifTargetValue() {
1544
+ var adapter = document.getElementById('notif-adapter').value;
1545
+ if (adapter.startsWith('email')) {
1546
+ var e = document.getElementById('notif-target-email');
1547
+ return e ? e.value.trim() : '';
1548
+ }
1549
+ var s = document.getElementById('notif-target');
1550
+ return s ? s.value : '';
1551
+ }
1552
+
1553
+ function setNotifTargetValue(val) {
1554
+ var adapter = document.getElementById('notif-adapter').value;
1555
+ if (adapter.startsWith('email')) {
1556
+ var e = document.getElementById('notif-target-email');
1557
+ if (e) e.value = val || '';
1558
+ } else {
1559
+ var s = document.getElementById('notif-target');
1560
+ if (s) s.value = val || '';
1561
+ }
1562
+ }
1563
+
1564
+ function loadNotifTargets(targetVal) {
1565
+ var adapter = document.getElementById('notif-adapter').value;
1566
+ var tSel = document.getElementById('notif-target');
1567
+ var tEmail = document.getElementById('notif-target-email');
1568
+ if (!tSel) return;
1569
+ var isEmail = adapter.startsWith('email');
1570
+ tSel.style.display = isEmail ? 'none' : '';
1571
+ if (tEmail) tEmail.style.display = isEmail ? '' : 'none';
1572
+ if (isEmail) {
1573
+ if (tEmail && targetVal) tEmail.value = targetVal;
1574
+ return;
1575
+ }
1576
+ tSel.innerHTML = '<option value="">Alle (broadcast)</option>';
1577
+ if (!adapter || !adapter.startsWith('telegram')) return;
1578
+ var dev = devices.length > 0 ? devices[0] : null;
1579
+ var inst = (dev && dev._instance !== undefined) ? dev._instance : 0;
1580
+ socket.emit('sendTo', 'laundrylens.' + inst, 'getTelegramUsers',
1581
+ { deviceId: dev ? dev.deviceId : '', instance: adapter },
1582
+ function(res) {
1583
+ if (!res || !res.ok || !res.users || !res.users.length) return;
1584
+ tSel.innerHTML = '<option value="">' + _('All (broadcast)') + '</option>' +
1585
+ res.users.map(function(u) {
1586
+ return '<option value="' + u.id + '">' + u.name + '</option>';
1587
+ }).join('');
1588
+ // Set target AFTER populating the dropdown
1589
+ if (targetVal) tSel.value = targetVal;
1590
+ }
1591
+ );
1592
+ }
1593
+
1594
+
1595
+ // Historical German default texts (this adapter was German-only before
1596
+ // multi-language support was added). Many existing installs have these
1597
+ // exact strings saved as their "message" - not a real customization, just
1598
+ // the old hardcoded default that got saved along with everything else.
1599
+ // Treated the same as an empty/unset field, so the current language's
1600
+ // translated default is shown instead of stale German text regardless of
1601
+ // the admin's configured language.
1602
+ var LEGACY_DEFAULT_MSGS = {
1603
+ startMsg: '🧺 {device} läuft\n⏳ Zeit wird ermittelt…',
1604
+ updateMsg: '🧺 {device} Update\n✅ Fertig um {endTime} Uhr\n[↩️ Vorher: {prevTime}]\n📊 Programm: {program}\n📈 Fortschritt: {progress}%',
1605
+ doneMsg: '🧺 {device} fertig!\n⏱️ Gesamtlaufzeit: {duration} min\n📊 Programm: {program}\n⚡ Verbrauch: {energy} kWh',
1606
+ };
1607
+
1608
+ /**
1609
+ * @param {string} saved – message text loaded from saved config
1610
+ * @param {string} key – 'startMsg'/'updateMsg'/'doneMsg'
1611
+ * @returns {boolean} – true if this is just the old hardcoded German
1612
+ * default lingering in config, not an intentional customization
1613
+ */
1614
+ function isLegacyDefaultMsg(saved, key) {
1615
+ return saved === LEGACY_DEFAULT_MSGS[key];
1616
+ }
1617
+
1618
+ function loadNotifAdapters() {
1619
+ var deviceId = selVal('notify');
1620
+ var dev = devices.find(function(d) { return d.deviceId === deviceId; }) || (devices.length > 0 ? devices[0] : null);
1621
+ var inst = (dev && dev._instance !== undefined) ? dev._instance : 0;
1622
+ if (!deviceId) deviceId = dev ? dev.deviceId : '';
1623
+ socket.emit('sendTo', 'laundrylens.' + inst, 'getNotifAdapters', {deviceId: deviceId}, function(res) {
1624
+ var sel = document.getElementById('notif-adapter');
1625
+ if (!sel) return;
1626
+ if (!res || !res.ok || !res.adapters || !res.adapters.length) {
1627
+ sel.innerHTML = '<option value="">' + _('– No adapter found –') + '</option>';
1628
+ return;
1629
+ }
1630
+ sel.innerHTML = '<option value="">' + _('– Choose adapter –') + '</option>' +
1631
+ res.adapters.map(function(a) {
1632
+ return '<option value="'+a.id+'">'+a.name+' ('+a.id+')</option>';
1633
+ }).join('');
1634
+ // Load saved config
1635
+ socket.emit('sendTo', 'laundrylens.' + inst, 'getNotifyConfig', {deviceId: deviceId}, function(r) {
1636
+ if (!r || !r.ok || !r.config) return;
1637
+ var cfg = r.config;
1638
+ if (cfg.adapter && sel.querySelector('option[value="'+cfg.adapter+'"]')) sel.value = cfg.adapter;
1639
+ if (cfg.adapter && (cfg.adapter.startsWith('telegram') || cfg.adapter.startsWith('email'))) loadNotifTargets();
1640
+ var sm = document.getElementById('notif-start-msg');
1641
+ var um = document.getElementById('notif-update-msg');
1642
+ var dm = document.getElementById('notif-done-msg');
1643
+ var DEFS = {
1644
+ startMsg: _('🧺 {device} running\n⏳ Determining time…'),
1645
+ updateMsg: _('🧺 {device} update\n✅ Done at {endTime}\n[↩️ Before: {prevTime}]\n📊 Program: {program}\n📈 Progress: {progress}%'),
1646
+ doneMsg: _('🧺 {device} done!\n⏱️ Total runtime: {duration} min\n📊 Program: {program}\n⚡ Consumption: {energy} kWh'),
1647
+ };
1648
+ setNotifToggle('start', cfg.startEnabled);
1649
+ if (sm) sm.value = (cfg.startMsg && !isLegacyDefaultMsg(cfg.startMsg, 'startMsg')) ? cfg.startMsg : DEFS.startMsg;
1650
+ setNotifToggle('update', cfg.updateEnabled);
1651
+ if (um) um.value = (cfg.updateMsg && !isLegacyDefaultMsg(cfg.updateMsg, 'updateMsg')) ? cfg.updateMsg : DEFS.updateMsg;
1652
+ setNotifToggle('done', cfg.doneEnabled);
1653
+ if (dm) dm.value = (cfg.doneMsg && !isLegacyDefaultMsg(cfg.doneMsg, 'doneMsg')) ? cfg.doneMsg : DEFS.doneMsg;
1654
+ // Update-Intervall Einstellungen
1655
+ var ui2 = document.getElementById('notif-update-interval');
1656
+ var np2 = document.getElementById('notif-update-nearend-pct');
1657
+ var nd2 = document.getElementById('notif-update-nearend-diff-pct');
1658
+ var nm2 = document.getElementById('notif-update-nearend-diff-min');
1659
+ if (ui2) ui2.value = cfg.updateIntervalMin || 20;
1660
+ if (np2) np2.value = cfg.updateNearEndPct || 85;
1661
+ if (nd2) nd2.value = cfg.updateNearEndDiffPct || 50;
1662
+ if (nm2) nm2.value = cfg.updateNearEndDiffMin || 10;
1663
+ if (cfg.target) setTimeout(function() {
1664
+ setNotifTargetValue(cfg.target);
1665
+ }, 600);
1666
+ });
1667
+ });
1668
+ }
1669
+
1670
+ function saveNotifConfig() {
1671
+ var deviceId = selVal('notify');
1672
+ var cfg = {
1673
+ adapter: document.getElementById('notif-adapter').value,
1674
+ target: getNotifTargetValue(),
1675
+ startEnabled: document.getElementById('notif-start-enabled').value === '1',
1676
+ startMsg: document.getElementById('notif-start-msg').value,
1677
+ updateEnabled: document.getElementById('notif-update-enabled').value === '1',
1678
+ updateMsg: document.getElementById('notif-update-msg').value,
1679
+ updateIntervalMin: parseInt(document.getElementById('notif-update-interval').value) || 20,
1680
+ updateNearEndPct: parseInt(document.getElementById('notif-update-nearend-pct').value) || 85,
1681
+ updateNearEndDiffPct: parseInt(document.getElementById('notif-update-nearend-diff-pct').value) || 50,
1682
+ updateNearEndDiffMin: parseInt(document.getElementById('notif-update-nearend-diff-min').value) || 10,
1683
+ doneEnabled: document.getElementById('notif-done-enabled').value === '1',
1684
+ doneMsg: document.getElementById('notif-done-msg').value,
1685
+ };
1686
+ // Kein Adapter = alles deaktiviert, trotzdem speichern
1687
+ send('saveNotifyConfig', {deviceId: deviceId, config: cfg}).then(function(res) {
1688
+ if (res && res.ok) toast('Benachrichtigung gespeichert!', 'ok');
1689
+ else toast((res && res.error) || 'Fehler', 'err');
1690
+ });
1691
+ }
1692
+
1693
+ function testNotif() {
1694
+ var deviceId = selVal('notify');
1695
+ var adapter = document.getElementById('notif-adapter').value;
1696
+ var target = getNotifTargetValue();
1697
+ var msg = (document.getElementById('notif-done-msg').value ||
1698
+ document.getElementById('notif-start-msg').value || '🔔 Test')
1699
+ .replace(/{device}/g, 'Test device')
1700
+ .replace(/{program}/g, 'Cotton 60')
1701
+ .replace(/{duration}/g, '92')
1702
+ .replace(/{energy}/g, '0.890')
1703
+ .replace(/{startTime}/g,'08:00')
1704
+ .replace(/{endTime}/g, '09:32');
1705
+ if (!adapter) return toast(_('Please choose an adapter'), 'err');
1706
+ send('sendNotification', {deviceId: deviceId, adapter: adapter, target: target, message: msg}).then(function(res) {
1707
+ if (res && res.ok) toast(_('Test message sent!'), 'ok');
1708
+ else toast((res && res.error) || _('Error'), 'err');
1709
+ });
1710
+ }
1711
+
1712
+
1713
+ // ── SUGGESTED SETTINGS ────────────────────────────────────────
1714
+ var SUGGESTION_FIELD_DEFS = {
1715
+ startEnergyThreshold: { label: _('Start energy gate'), unit: 'Wh' },
1716
+ durationTolerance: { label: _('Duration tolerance'), unit: '' },
1717
+ offDelayMin: { label: _('Off delay'), unit: 'min' },
1718
+ powerThreshold: { label: _('Power-on threshold'), unit: 'W' },
1719
+ };
1720
+
1721
+ /**
1722
+ * Applies a single suggested-settings field (not the others) and restarts
1723
+ * the adapter instance so the new value takes effect.
1724
+ *
1725
+ * @param {string} deviceId – device the suggestion belongs to
1726
+ * @param {string} field – suggestion field key
1727
+ * @param {number} value – suggested value to apply
1728
+ */
1729
+ function applySingleSuggestion(deviceId, field, value) {
1730
+ var dev = devices.find(function(d) { return d.deviceId === deviceId; });
1731
+ var inst = (dev && dev._instance !== undefined) ? dev._instance : 0;
1732
+ var def = SUGGESTION_FIELD_DEFS[field] || { label: field, unit: '' };
1733
+
1734
+ if (!confirm(_('Apply suggested value?') + '\n\n' + def.label + ': ' + value + (def.unit ? ' ' + def.unit : ''))) return;
1735
+
1736
+ var native = {};
1737
+ native[field] = value;
1738
+ socket.emit('extendObject', 'system.adapter.laundrylens.' + inst, {
1739
+ native: native
1740
+ }, function(err) {
1741
+ if (err) {
1742
+ toast(_('Error saving') + ': ' + err, 'err');
1743
+ return;
1744
+ }
1745
+ toast(_('Setting saved! Restarting adapter…'), 'ok');
1746
+ removeSuggestionRow(field);
1747
+ setTimeout(function() {
1748
+ socket.emit('sendTo', 'system.adapter.laundrylens.' + inst, 'restart', {}, function() {});
1749
+ }, 1000);
1750
+ });
1751
+ }
1752
+
1753
+ /**
1754
+ * Dismisses a single suggested-settings field so it stops being shown
1755
+ * (until the underlying recommendation changes to a different value) -
1756
+ * without applying it and without affecting any other suggestion.
1757
+ *
1758
+ * @param {string} deviceId – device the suggestion belongs to
1759
+ * @param {string} field – suggestion field key
1760
+ * @param {number} value – the specific suggested value being dismissed
1761
+ */
1762
+ function dismissSuggestion(deviceId, field, value) {
1763
+ send('dismissSuggestion', { deviceId: deviceId, field: field, value: value }).then(function(res) {
1764
+ if (!res || !res.ok) return toast((res && res.error) || _('Error'), 'err');
1765
+ removeSuggestionRow(field);
1766
+ });
1767
+ }
1768
+
1769
+ /** Removes a single suggestion row from the card, hiding the whole card if none remain. */
1770
+ function removeSuggestionRow(field) {
1771
+ var body = document.getElementById('suggested-body');
1772
+ if (!body) return;
1773
+ var rows = body.querySelectorAll('tr');
1774
+ rows.forEach(function(row) {
1775
+ if (row.innerHTML.indexOf("'" + field + "'") !== -1) row.remove();
1776
+ });
1777
+ if (body.querySelectorAll('tr').length === 0) {
1778
+ document.getElementById('suggested-card').style.display = 'none';
1779
+ }
1780
+ }
1781
+
1782
+ // ── CLEAR ALL DATA ──────────────────────────────────────────
1783
+ function clearAllData() {
1784
+ var deviceId = selVal('export');
1785
+ if (!deviceId) return toast(_('No device selected'), 'err');
1786
+ var dev = devices.find(function(d) { return d.deviceId === deviceId; });
1787
+ var name = dev ? dev.name : deviceId;
1788
+ if (!confirm(_('Delete all data for "') + name + '"?\n\n' + _('Profiles, cycles, and traces will be permanently deleted!'))) return;
1789
+ var inst = (dev && dev._instance !== undefined) ? dev._instance : instance;
1790
+ socket.emit('sendTo', 'laundrylens.' + inst, 'clearAllData', {deviceId: deviceId}, function(res) {
1791
+ if (res && res.ok) { toast(_('All data deleted!'), 'ok'); }
1792
+ else { toast(_('Error') + ': ' + ((res && res.error) || _('Unknown')), 'err'); }
1793
+ });
1794
+ }
1795
+
1796
+
1797
+
1798
+ // ── GRAPH ─────────────────────────────────────────────────────
1799
+ var _graphTrace = null;
1800
+ var _graphCycleId = null;
1801
+ var _trimMode = false;
1802
+ var _splitMode = false;
1803
+ var _trimStart = null;
1804
+ var _trimEnd = null;
1805
+ var _splitTs = null;
1806
+
1807
+ function loadGraphCycles() {
1808
+ var deviceId = selVal('graph');
1809
+ send('getCycleHistory', {deviceId: deviceId}).then(function(res) {
1810
+ if (!res || !res.ok) return;
1811
+ var sel = document.getElementById('graph-cycle-select');
1812
+ var withTrace = res.cycles.filter(function(c) { return c.hasTrace; });
1813
+ sel.innerHTML = '<option value="">' + _('– Choose cycle –') + '</option>' +
1814
+ withTrace.map(function(c) {
1815
+ var d = Math.round((c.durationMs||0)/60000);
1816
+ var dt = new Date(c.startTime).toLocaleString(_currentLang || 'en',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'});
1817
+ var tag = c.importedFromHA ? ' 🏠' : '';
1818
+ return '<option value="'+c.id+'">'+dt+' – '+(c.matchedProfile||'?')+' ('+d+' min)'+tag+'</option>';
1819
+ }).join('');
1820
+ document.getElementById('graph-card').style.display = 'none';
1821
+ document.getElementById('graph-actions').style.display = 'none';
1822
+ if (withTrace.length === 0) toast(_('No cycles with curve data available'), 'info');
1823
+ });
1824
+ }
1825
+
1826
+ function loadGraph() {
1827
+ var deviceId = selVal('graph');
1828
+ var cycleId = document.getElementById('graph-cycle-select').value;
1829
+ if (!cycleId) return;
1830
+ _graphCycleId = cycleId;
1831
+ send('getTrace', {deviceId: deviceId, cycleId: cycleId}).then(function(res) {
1832
+ if (!res || !res.ok || !res.trace) return toast(_('No trace available'), 'err');
1833
+ _graphTrace = res.trace;
1834
+ _trimStart = _graphTrace.startTime;
1835
+ _trimEnd = _graphTrace.endTime;
1836
+ cancelTrim();
1837
+ cancelSplit();
1838
+ document.getElementById('graph-card').style.display = '';
1839
+ document.getElementById('graph-actions').style.display = '';
1840
+ var isSynth = document.getElementById('graph-cycle-select').selectedOptions[0].text.includes('🏠');
1841
+ document.getElementById('graph-title').textContent = isSynth ? 'Leistungskurve (synthetisch aus HA)' : 'Leistungskurve';
1842
+ drawGraph();
1843
+ });
1844
+ }
1845
+
1846
+ function drawGraph() {
1847
+ if (!_graphTrace || !_graphTrace.points.length) return;
1848
+ var canvas = document.getElementById('power-graph');
1849
+ canvas.width = canvas.offsetWidth * (window.devicePixelRatio||1);
1850
+ canvas.height = canvas.offsetHeight * (window.devicePixelRatio||1);
1851
+ var ctx = canvas.getContext('2d');
1852
+ ctx.scale(window.devicePixelRatio||1, window.devicePixelRatio||1);
1853
+ var W = canvas.offsetWidth, H = canvas.offsetHeight;
1854
+ var pts = _graphTrace.points;
1855
+ var t0 = _graphTrace.startTime, t1 = _graphTrace.endTime;
1856
+ var tRange = t1 - t0 || 1;
1857
+ var maxW = Math.max.apply(null, pts.map(function(p){return p.watts;})) * 1.1 || 100;
1858
+ var PAD = {top:16, right:12, bottom:24, left:44};
1859
+ var gW = W - PAD.left - PAD.right, gH = H - PAD.top - PAD.bottom;
1860
+
1861
+ function tx(ts) { return PAD.left + (ts - t0) / tRange * gW; }
1862
+ function ty(watts) { return PAD.top + gH - (watts / maxW * gH); }
1863
+
1864
+ ctx.fillStyle = '#f8f9fa'; ctx.fillRect(0, 0, W, H);
1865
+
1866
+ // Grid
1867
+ ctx.strokeStyle = '#e0e0e0'; ctx.lineWidth = 0.5;
1868
+ [0, 0.25, 0.5, 0.75, 1.0].forEach(function(s) {
1869
+ var y = PAD.top + gH * (1 - s);
1870
+ ctx.beginPath(); ctx.moveTo(PAD.left, y); ctx.lineTo(PAD.left + gW, y); ctx.stroke();
1871
+ ctx.fillStyle = '#999'; ctx.font = '10px sans-serif'; ctx.textAlign = 'right';
1872
+ ctx.fillText(Math.round(maxW * s) + 'W', PAD.left - 4, y + 3);
1873
+ });
1874
+
1875
+ // Zeit-Achse
1876
+ var durMin = Math.round(tRange / 60000);
1877
+ var tick = durMin <= 30 ? 5 : durMin <= 60 ? 10 : durMin <= 120 ? 20 : 30;
1878
+ ctx.fillStyle = '#999'; ctx.textAlign = 'center';
1879
+ for (var m = 0; m <= durMin; m += tick) {
1880
+ var xp = tx(t0 + m * 60000);
1881
+ ctx.beginPath(); ctx.moveTo(xp, PAD.top + gH); ctx.lineTo(xp, PAD.top + gH + 4); ctx.stroke();
1882
+ ctx.fillText(m + 'min', xp, H - 4);
1883
+ }
1884
+
1885
+ // Phase changes shown as colored background regions
1886
+ // (uses the globally-defined PHASE_BG_COLORS map)
1887
+ // PHASE_LINE_COLORS: defined globally
1888
+ // Clip to the graph area so phases don't run past the Y axis
1889
+ ctx.save();
1890
+ ctx.beginPath();
1891
+ ctx.rect(PAD.left, PAD.top, gW, gH);
1892
+ ctx.clip();
1893
+
1894
+ var phaseHist = (_graphTrace && _graphTrace.phaseHistory) || [];
1895
+ var traceStartMain = (_graphTrace && _graphTrace.points && _graphTrace.points.length > 0)
1896
+ ? _graphTrace.points[0].ts : 0;
1897
+ if (phaseHist.length > 0) {
1898
+ phaseHist.forEach(function(ph, i) {
1899
+ var phTs = ph.ts !== undefined ? ph.ts : (traceStartMain + (ph.tMs || 0));
1900
+ var nextPh = phaseHist[i+1];
1901
+ var nextTs = nextPh !== undefined
1902
+ ? (nextPh.ts !== undefined ? nextPh.ts : (traceStartMain + (nextPh.tMs || 0)))
1903
+ : null;
1904
+ var xStart = Math.max(tx(phTs), PAD.left);
1905
+ var xEnd = nextTs !== null ? Math.min(tx(nextTs), PAD.left + gW) : PAD.left + gW;
1906
+ // Farbiger Hintergrundbereich
1907
+ var bg = PHASE_BG_COLORS[ph.phase] || 'rgba(150,150,150,0.07)';
1908
+ ctx.fillStyle = bg;
1909
+ ctx.fillRect(xStart, PAD.top, xEnd - xStart, gH);
1910
+ // Vertikale Linie beim Phasenwechsel
1911
+ if (i > 0) {
1912
+ var lc = PHASE_LINE_COLORS[ph.phase] || '#999';
1913
+ ctx.strokeStyle = lc; ctx.lineWidth = 1; ctx.setLineDash([3,3]);
1914
+ ctx.beginPath(); ctx.moveTo(xStart, PAD.top); ctx.lineTo(xStart, PAD.top + gH); ctx.stroke();
1915
+ ctx.setLineDash([]);
1916
+ }
1917
+ // Kein Label mehr im Canvas – wird unten als Legende angezeigt
1918
+ });
1919
+ }
1920
+
1921
+ // Legende unter dem Graph
1922
+ var legendEl = document.getElementById('phase-legend');
1923
+ if (legendEl) {
1924
+ if (phaseHist.length > 0) {
1925
+ // Nur eindeutige Phasen
1926
+ var seen = {}, uniquePhases = [];
1927
+ phaseHist.forEach(function(ph) {
1928
+ if (!seen[ph.phase]) { seen[ph.phase] = true; uniquePhases.push(ph.phase); }
1929
+ });
1930
+ legendEl.style.display = 'flex';
1931
+ legendEl.innerHTML = uniquePhases.map(function(phase) {
1932
+ var color = PHASE_LINE_COLORS[phase] || '#999';
1933
+ return '<span style="display:flex;align-items:center;gap:4px;font-size:11px;color:#444">' +
1934
+ '<span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:' + color + '"></span>' +
1935
+ getPhaseLabel(phase) + '</span>';
1936
+ }).join('');
1937
+ } else {
1938
+ legendEl.style.display = 'none';
1939
+ legendEl.innerHTML = '';
1940
+ }
1941
+ }
1942
+
1943
+ ctx.restore(); // Clip aufheben
1944
+
1945
+ // Trim-Bereich
1946
+ if (_trimMode) {
1947
+ ctx.fillStyle = 'rgba(26,115,232,0.08)';
1948
+ ctx.fillRect(tx(_trimStart), PAD.top, tx(_trimEnd) - tx(_trimStart), gH);
1949
+ [_trimStart, _trimEnd].forEach(function(ts, i) {
1950
+ var x = tx(ts);
1951
+ ctx.strokeStyle = '#ea4335'; ctx.lineWidth = 2;
1952
+ ctx.beginPath(); ctx.moveTo(x, PAD.top); ctx.lineTo(x, PAD.top + gH); ctx.stroke();
1953
+ ctx.fillStyle = '#ea4335';
1954
+ ctx.beginPath(); ctx.arc(x, PAD.top + gH/2, 7, 0, Math.PI*2); ctx.fill();
1955
+ ctx.fillStyle = '#fff'; ctx.font = 'bold 10px sans-serif'; ctx.textAlign = 'center';
1956
+ ctx.fillText(i===0?'◀':'▶', x, PAD.top + gH/2 + 4);
1957
+ });
1958
+ }
1959
+
1960
+ // Split-Marker
1961
+ if (_splitMode && _splitTs) {
1962
+ var xs = tx(_splitTs);
1963
+ ctx.strokeStyle = '#ff9800'; ctx.lineWidth = 2; ctx.setLineDash([4,4]);
1964
+ ctx.beginPath(); ctx.moveTo(xs, PAD.top); ctx.lineTo(xs, PAD.top + gH); ctx.stroke();
1965
+ ctx.setLineDash([]);
1966
+ }
1967
+
1968
+ // Kurve
1969
+ ctx.strokeStyle = '#1a73e8'; ctx.lineWidth = 2; ctx.setLineDash([]);
1970
+ ctx.beginPath();
1971
+ pts.forEach(function(p, i) {
1972
+ i === 0 ? ctx.moveTo(tx(p.ts), ty(p.watts)) : ctx.lineTo(tx(p.ts), ty(p.watts));
1973
+ });
1974
+ ctx.stroke();
1975
+
1976
+ // Fill area
1977
+ ctx.fillStyle = 'rgba(26,115,232,0.10)';
1978
+ ctx.beginPath();
1979
+ ctx.moveTo(tx(pts[0].ts), PAD.top + gH);
1980
+ pts.forEach(function(p) { ctx.lineTo(tx(p.ts), ty(p.watts)); });
1981
+ ctx.lineTo(tx(pts[pts.length-1].ts), PAD.top + gH);
1982
+ ctx.closePath(); ctx.fill();
1983
+
1984
+ // Info
1985
+ document.getElementById('graph-info-left').textContent = '0 min';
1986
+ document.getElementById('graph-info-mid').textContent = 'Max: '+Math.round(maxW/1.1)+'W';
1987
+ document.getElementById('graph-info-right').textContent = durMin+' min';
1988
+ }
1989
+
1990
+ function fmt(ts) { return new Date(ts).toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit',second:'2-digit'}); }
1991
+
1992
+ // Canvas Touch/Click Events
1993
+ document.addEventListener('DOMContentLoaded', function() {
1994
+ var canvas = document.getElementById('power-graph');
1995
+ if (!canvas) return;
1996
+ var dragging = null;
1997
+
1998
+ function getTs(e) {
1999
+ var rect = canvas.getBoundingClientRect();
2000
+ var cx = e.touches ? e.touches[0].clientX : e.clientX;
2001
+ var x = cx - rect.left;
2002
+ var PAD_L = 44, PAD_R = 12;
2003
+ var frac = Math.max(0, Math.min(1, (x - PAD_L) / (canvas.offsetWidth - PAD_L - PAD_R)));
2004
+ return Math.round(_graphTrace.startTime + frac * (_graphTrace.endTime - _graphTrace.startTime));
2005
+ }
2006
+
2007
+ canvas.addEventListener('mousedown', function(e) {
2008
+ if (!_graphTrace) return;
2009
+ if (_splitMode) { _splitTs = getTs(e); document.getElementById('split-point').value = fmt(_splitTs); drawGraph(); return; }
2010
+ if (_trimMode) { var ts = getTs(e); dragging = Math.abs(ts-_trimStart) < Math.abs(ts-_trimEnd) ? 'start' : 'end'; }
2011
+ });
2012
+ canvas.addEventListener('mousemove', function(e) {
2013
+ if (!_trimMode || !dragging) return; e.preventDefault();
2014
+ var ts = getTs(e);
2015
+ if (dragging==='start') _trimStart = Math.min(ts, _trimEnd-60000);
2016
+ else _trimEnd = Math.max(ts, _trimStart+60000);
2017
+ var dm = Math.round((_trimEnd-_trimStart)/60000);
2018
+ document.getElementById('trim-start').value = fmt(_trimStart);
2019
+ document.getElementById('trim-end').value = fmt(_trimEnd);
2020
+ document.getElementById('trim-duration').value = dm+' min';
2021
+ drawGraph();
2022
+ });
2023
+ canvas.addEventListener('mouseup', function() { dragging = null; });
2024
+ canvas.addEventListener('touchstart', function(e) {
2025
+ if (!_graphTrace) return;
2026
+ if (_splitMode) { _splitTs = getTs(e); document.getElementById('split-point').value = fmt(_splitTs); drawGraph(); return; }
2027
+ if (_trimMode) { var ts = getTs(e); dragging = Math.abs(ts-_trimStart) < Math.abs(ts-_trimEnd) ? 'start' : 'end'; }
2028
+ }, {passive:false});
2029
+ canvas.addEventListener('touchmove', function(e) {
2030
+ if (!_trimMode || !dragging) return; e.preventDefault();
2031
+ var ts = getTs(e);
2032
+ if (dragging==='start') _trimStart = Math.min(ts, _trimEnd-60000);
2033
+ else _trimEnd = Math.max(ts, _trimStart+60000);
2034
+ drawGraph();
2035
+ }, {passive:false});
2036
+ canvas.addEventListener('touchend', function() { dragging = null; });
2037
+ });
2038
+
2039
+ window.addEventListener('resize', function() { if (_graphTrace) drawGraph(); });
2040
+
2041
+ function toggleTrimMode() {
2042
+ _trimMode = !_trimMode; _splitMode = false;
2043
+ document.getElementById('btn-trim').style.cssText = _trimMode ? 'background:var(--blue);color:#fff' : '';
2044
+ document.getElementById('btn-split').style.cssText = '';
2045
+ document.getElementById('trim-controls').style.display = _trimMode ? '' : 'none';
2046
+ document.getElementById('split-controls').style.display = 'none';
2047
+ if (_trimMode) {
2048
+ _trimStart = _graphTrace.startTime; _trimEnd = _graphTrace.endTime;
2049
+ document.getElementById('trim-start').value = fmt(_trimStart);
2050
+ document.getElementById('trim-end').value = fmt(_trimEnd);
2051
+ document.getElementById('trim-duration').value = Math.round((_trimEnd-_trimStart)/60000)+' min';
2052
+ }
2053
+ drawGraph();
2054
+ }
2055
+
2056
+ function cancelTrim() {
2057
+ _trimMode = false;
2058
+ document.getElementById('btn-trim').style.cssText = '';
2059
+ document.getElementById('trim-controls').style.display = 'none';
2060
+ if (_graphTrace) { _trimStart = _graphTrace.startTime; _trimEnd = _graphTrace.endTime; }
2061
+ if (_graphTrace) drawGraph();
2062
+ }
2063
+
2064
+ function applyTrim() {
2065
+ var deviceId = selVal('graph');
2066
+ send('trimTrace', {deviceId:deviceId, cycleId:_graphCycleId, newStartTs:_trimStart, newEndTs:_trimEnd}).then(function(res) {
2067
+ if (res&&res.ok) { toast('Trimmen angewendet!','ok'); _graphTrace=res.trace; cancelTrim(); loadGraphCycles(); }
2068
+ else toast((res&&res.error)||'Fehler','err');
2069
+ });
2070
+ }
2071
+
2072
+ function toggleSplitMode() {
2073
+ _splitMode = !_splitMode; _trimMode = false;
2074
+ document.getElementById('btn-split').style.cssText = _splitMode ? 'background:var(--orange);color:#fff' : '';
2075
+ document.getElementById('btn-trim').style.cssText = '';
2076
+ document.getElementById('split-controls').style.display = _splitMode ? '' : 'none';
2077
+ document.getElementById('trim-controls').style.display = 'none';
2078
+ _splitTs = null; document.getElementById('split-point').value = '';
2079
+ drawGraph();
2080
+ }
2081
+
2082
+ function cancelSplit() {
2083
+ _splitMode = false; _splitTs = null;
2084
+ document.getElementById('btn-split').style.cssText = '';
2085
+ document.getElementById('split-controls').style.display = 'none';
2086
+ if (_graphTrace) drawGraph();
2087
+ }
2088
+
2089
+ function applySplit() {
2090
+ if (!_splitTs) return toast(_('Choose a split point in the graph'),'err');
2091
+ var deviceId = selVal('graph');
2092
+ send('splitTrace', {deviceId:deviceId, cycleId:_graphCycleId, splitTs:_splitTs}).then(function(res) {
2093
+ if (res&&res.ok) { toast(_('Split!'),'ok'); cancelSplit(); loadGraphCycles(); document.getElementById('graph-card').style.display='none'; }
2094
+ else toast((res&&res.error)||_('Error'),'err');
2095
+ });
2096
+ }
2097
+
2098
+ function deleteGraphCycle() {
2099
+ if (!_graphCycleId) return;
2100
+ var deviceId = selVal('graph');
2101
+ if (!confirm(_('Really delete this cycle?'))) return;
2102
+ send('deleteCycle', {deviceId: deviceId, cycleId: _graphCycleId}).then(function(res) {
2103
+ if (res && res.ok) {
2104
+ toast(_('Cycle deleted'), 'ok');
2105
+ document.getElementById('graph-card').style.display = 'none';
2106
+ document.getElementById('graph-actions').style.display = 'none';
2107
+ _graphCycleId = null;
2108
+ _graphTrace = null;
2109
+ loadGraphCycles();
2110
+ } else {
2111
+ toast((res && res.error) || 'Fehler', 'err');
2112
+ }
2113
+ });
2114
+ }
2115
+
2116
+
2117
+ // Wrapper for inline graph actions
2118
+
2119
+ // Trim/Split State
2120
+ var _inlineState = {};
2121
+
2122
+ function trimInlineGraph(rowId) {
2123
+ var st = _inlineState[rowId];
2124
+ if (!st || !st.tracePoints) { toast('Graph zuerst laden', 'err'); return; }
2125
+ var t0 = st.tracePoints[0].ts, t1 = st.tracePoints[st.tracePoints.length-1].ts;
2126
+ st.trimMode = !st.trimMode;
2127
+ st.splitMode = false;
2128
+ if (st.trimMode) {
2129
+ st.trimStart = t0; st.trimEnd = t1;
2130
+ st.canvas.style.cursor = 'ew-resize';
2131
+ _setupInlineTouch(rowId);
2132
+ } else {
2133
+ st.canvas.style.cursor = '';
2134
+ }
2135
+ var trimDiv = document.getElementById('trim-controls-' + rowId);
2136
+ var splitDiv = document.getElementById('split-controls-' + rowId);
2137
+ if (trimDiv) trimDiv.style.display = st.trimMode ? '' : 'none';
2138
+ if (splitDiv) splitDiv.style.display = 'none';
2139
+ _inlineRedraw(rowId);
2140
+ }
2141
+
2142
+ function shareInlineGraphSplit(rowId) {
2143
+ var st = _inlineState[rowId];
2144
+ if (!st || !st.tracePoints) { toast('Graph zuerst laden', 'err'); return; }
2145
+ var t0 = st.tracePoints[0].ts, t1 = st.tracePoints[st.tracePoints.length-1].ts;
2146
+ st.splitMode = !st.splitMode;
2147
+ st.trimMode = false;
2148
+ st.trimStart = undefined; st.trimEnd = undefined;
2149
+ if (st.splitMode) {
2150
+ st.splitTs = Math.round(t0 + (t1-t0)/2);
2151
+ st.canvas.style.cursor = 'ew-resize';
2152
+ _setupInlineTouch(rowId);
2153
+ } else {
2154
+ st.canvas.style.cursor = '';
2155
+ }
2156
+ var trimDiv = document.getElementById('trim-controls-' + rowId);
2157
+ var splitDiv = document.getElementById('split-controls-' + rowId);
2158
+ if (trimDiv) trimDiv.style.display = 'none';
2159
+ if (splitDiv) splitDiv.style.display = st.splitMode ? '' : 'none';
2160
+ _inlineRedraw(rowId);
2161
+ }
2162
+
2163
+ function cancelSplitInline(rowId) {
2164
+ var st = _inlineState[rowId];
2165
+ if (st) { st.splitMode = false; st.splitTs = undefined; }
2166
+ var splitDiv = document.getElementById('split-controls-' + rowId);
2167
+ if (splitDiv) splitDiv.style.display = 'none';
2168
+ if (st && st.canvas) st.canvas.style.cursor = '';
2169
+ _inlineRedraw(rowId);
2170
+ }
2171
+
2172
+ function cancelTrimInline(rowId) {
2173
+ var st = _inlineState[rowId];
2174
+ if (st) { st.trimMode = false; st.trimStart = undefined; st.trimEnd = undefined; }
2175
+ var trimDiv = document.getElementById('trim-controls-' + rowId);
2176
+ if (trimDiv) trimDiv.style.display = 'none';
2177
+ if (st && st.canvas) st.canvas.style.cursor = '';
2178
+ _inlineRedraw(rowId);
2179
+ }
2180
+
2181
+ function applyTrimInline(deviceId, cycleId, rowId) {
2182
+ var st = _inlineState[rowId];
2183
+ if (!st || !st.tracePoints || st.trimStart === undefined) return;
2184
+ var startTs = Math.min(st.trimStart, st.trimEnd);
2185
+ var endTs = Math.max(st.trimStart, st.trimEnd);
2186
+ send('trimTrace', { deviceId: deviceId, cycleId: cycleId, newStartTs: startTs, newEndTs: endTs }).then(function(res) {
2187
+ if (res && res.ok) {
2188
+ toast('Trimmen angewendet!', 'ok');
2189
+ st.trimMode = false;
2190
+ document.getElementById('trim-controls-' + rowId).style.display = 'none';
2191
+ reloadInlineGraph(deviceId, cycleId, rowId);
2192
+ } else toast((res && res.error) || 'Fehler', 'err');
2193
+ });
2194
+ }
2195
+
2196
+ function applySplitInline(deviceId, cycleId, rowId) {
2197
+ var st = _inlineState[rowId];
2198
+ if (!st) { toast('Kein State', 'err'); return; }
2199
+ if (!st.tracePoints) { toast('Kein Trace', 'err'); return; }
2200
+ if (st.splitTs === undefined || st.splitTs === null) { toast('Splitpunkt ziehen!', 'err'); return; }
2201
+ send('splitTrace', { deviceId: deviceId, cycleId: cycleId, splitTs: Math.round(st.splitTs) }).then(function(res) {
2202
+ if (res && res.ok) {
2203
+ toast('Geteilt!', 'ok');
2204
+ st.splitMode = false;
2205
+ var sd = document.getElementById('split-controls-' + rowId);
2206
+ if (sd) sd.style.display = 'none';
2207
+ loadCycles();
2208
+ } else toast((res && res.error) || 'Fehler beim Teilen', 'err');
2209
+ });
2210
+ }
2211
+
2212
+ function reloadInlineGraph(deviceId, cycleId, rowId) {
2213
+ var row = document.getElementById(rowId);
2214
+ if (row) row.style.display = 'none';
2215
+ setTimeout(function() {
2216
+ if (row) row.style.display = '';
2217
+ send('getTrace', { deviceId: deviceId, cycleId: cycleId }).then(function(res) {
2218
+ if (!res || !res.ok || !res.trace) return;
2219
+ var canvas = document.getElementById('canvas-' + rowId);
2220
+ var legendEl = document.getElementById('legend-' + rowId);
2221
+ if (!canvas) return;
2222
+ var tracePoints = res.trace.points || res.trace.powerTrace || [];
2223
+ if (!_inlineState[rowId]) _inlineState[rowId] = {};
2224
+ _inlineState[rowId].tracePoints = tracePoints;
2225
+ var cycleList = cycles[deviceId] || [];
2226
+ var cyc = cycleList.find(function(c) { return c.id === cycleId; });
2227
+ drawInlineGraph(canvas, legendEl, tracePoints, (cyc && cyc.phaseHistory) || []);
2228
+ });
2229
+ }, 100);
2230
+ }
2231
+
2232
+ function shareInlineGraph(deviceId, cycleId, rowId) {
2233
+ function activateSplit() {
2234
+ var st = _inlineState[rowId];
2235
+ if (!st || !st.tracePoints) return;
2236
+ var t0 = st.tracePoints[0].ts, t1 = st.tracePoints[st.tracePoints.length-1].ts;
2237
+ st.splitMode = !st.splitMode;
2238
+ st.trimMode = false;
2239
+ st.trimStart = undefined; st.trimEnd = undefined;
2240
+ if (st.splitMode) {
2241
+ st.splitTs = Math.round(t0 + (t1-t0)/2);
2242
+ var canvas = document.getElementById('canvas-' + rowId) || st.canvas;
2243
+ if (canvas) { st.canvas = canvas; canvas.style.cursor = 'ew-resize'; _setupInlineTouch(rowId); }
2244
+ } else {
2245
+ if (st.canvas) st.canvas.style.cursor = '';
2246
+ }
2247
+ var splitDiv = document.getElementById('split-controls-' + rowId);
2248
+ var trimDiv = document.getElementById('trim-controls-' + rowId);
2249
+ if (trimDiv) trimDiv.style.display = 'none';
2250
+ if (splitDiv) splitDiv.style.display = st.splitMode ? '' : 'none';
2251
+ _inlineRedraw(rowId);
2252
+ }
2253
+
2254
+ var st = _inlineState[rowId];
2255
+ if (!st || !st.tracePoints) {
2256
+ send('getTrace', { deviceId: deviceId, cycleId: cycleId }).then(function(res) {
2257
+ if (!res || !res.ok || !res.trace) { toast(_('No trace available'), 'err'); return; }
2258
+ if (!_inlineState[rowId]) _inlineState[rowId] = {};
2259
+ var st2 = _inlineState[rowId];
2260
+ st2.tracePoints = res.trace.points || res.trace.powerTrace || [];
2261
+ var canvas = document.getElementById('canvas-' + rowId);
2262
+ var legendEl = document.getElementById('legend-' + rowId);
2263
+ if (canvas) {
2264
+ st2.canvas = canvas; st2.legendEl = legendEl;
2265
+ var cycleList = cycles[deviceId] || [];
2266
+ var cyc = cycleList.find(function(c) { return c.id === cycleId; });
2267
+ st2.phaseHistory = (cyc && cyc.phaseHistory) || [];
2268
+ var dpr = window.devicePixelRatio || 1;
2269
+ var wrap = canvas.closest('.cycle-detail-wrap') || canvas.parentElement;
2270
+ var w = (wrap ? wrap.clientWidth : 0) - 24;
2271
+ if (w < 100) w = window.innerWidth - 60;
2272
+ canvas.width = w * dpr; canvas.height = 180 * dpr;
2273
+ canvas.style.width = w + 'px'; canvas.style.height = '180px';
2274
+ drawInlineGraph(canvas, legendEl, st2.tracePoints, st2.phaseHistory);
2275
+ }
2276
+ activateSplit();
2277
+ });
2278
+ return;
2279
+ }
2280
+ activateSplit();
2281
+ }
2282
+
2283
+
2284
+ // ── Inline Graph Touch/Drag ──────────────────────────────────
2285
+
2286
+ function _inlineRedraw(rowId) {
2287
+ var st = _inlineState[rowId];
2288
+ if (!st || !st.tracePoints) return;
2289
+ var canvas = document.getElementById('canvas-' + rowId) || st.canvas;
2290
+ if (!canvas) return;
2291
+ st.canvas = canvas;
2292
+ var legendEl = document.getElementById('legend-' + rowId) || st.legendEl;
2293
+ drawInlineGraph(canvas, legendEl, st.tracePoints, st.phaseHistory || []);
2294
+ var ctx = canvas.getContext('2d');
2295
+ var W = parseInt(canvas.style.width) || 300;
2296
+ var H = 180;
2297
+ var PAD = {top:12, right:8, bottom:20, left:40};
2298
+ var gW = W - PAD.left - PAD.right;
2299
+ var t0 = st.tracePoints[0].ts, t1 = st.tracePoints[st.tracePoints.length-1].ts;
2300
+ function tx(ts) { return PAD.left + (ts - t0) / (t1 - t0) * gW; }
2301
+
2302
+ ctx.save();
2303
+ if (st.trimMode) {
2304
+ var xs = tx(st.trimStart), xe = tx(st.trimEnd);
2305
+ // Darkened areas outside the selection
2306
+ ctx.fillStyle = 'rgba(0,0,0,0.25)';
2307
+ ctx.fillRect(PAD.left, PAD.top, xs - PAD.left, H - PAD.top - PAD.bottom);
2308
+ ctx.fillRect(xe, PAD.top, PAD.left + gW - xe, H - PAD.top - PAD.bottom);
2309
+ // Rote Linien
2310
+ ctx.strokeStyle = '#ea4335'; ctx.lineWidth = 2; ctx.setLineDash([5,4]);
2311
+ [xs, xe].forEach(function(x) {
2312
+ ctx.beginPath(); ctx.moveTo(x, PAD.top); ctx.lineTo(x, H - PAD.bottom); ctx.stroke();
2313
+ });
2314
+ ctx.setLineDash([]);
2315
+ // Pfeil-Handles
2316
+ ctx.fillStyle = '#ea4335';
2317
+ [xs, xe].forEach(function(x) {
2318
+ ctx.beginPath();
2319
+ ctx.arc(x, H/2, 12, 0, Math.PI*2);
2320
+ ctx.fill();
2321
+ ctx.fillStyle = '#fff';
2322
+ ctx.font = 'bold 12px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
2323
+ ctx.fillText('↔', x, H/2);
2324
+ ctx.fillStyle = '#ea4335';
2325
+ });
2326
+ }
2327
+ if (st.splitMode && st.splitTs !== undefined) {
2328
+ var xs2 = tx(st.splitTs);
2329
+ ctx.strokeStyle = '#ff9800'; ctx.lineWidth = 3; ctx.setLineDash([6,3]);
2330
+ ctx.beginPath(); ctx.moveTo(xs2, PAD.top); ctx.lineTo(xs2, H - PAD.bottom); ctx.stroke();
2331
+ ctx.setLineDash([]);
2332
+ // Oranger Handle
2333
+ ctx.fillStyle = '#ff9800';
2334
+ ctx.beginPath(); ctx.arc(xs2, H/2, 14, 0, Math.PI*2); ctx.fill();
2335
+ ctx.fillStyle = '#fff';
2336
+ ctx.font = 'bold 12px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
2337
+ ctx.fillText('↔', xs2, H/2);
2338
+ }
2339
+ ctx.restore();
2340
+ }
2341
+
2342
+ function _setupInlineTouch(rowId, deviceId, cycleId) {
2343
+ var st = _inlineState[rowId];
2344
+ if (!st || !st.canvas) return;
2345
+ var canvas = st.canvas;
2346
+ var dragging = null;
2347
+
2348
+ function getTs(e) {
2349
+ var rect = canvas.getBoundingClientRect();
2350
+ var cx = e.touches ? e.touches[0].clientX : e.clientX;
2351
+ var x = cx - rect.left;
2352
+ var W = parseInt(canvas.style.width) || 300;
2353
+ var PAD_L = 40, PAD_R = 8;
2354
+ var ratio = Math.max(0, Math.min(1, (x - PAD_L) / (W - PAD_L - PAD_R)));
2355
+ var t0 = st.tracePoints[0].ts, t1 = st.tracePoints[st.tracePoints.length-1].ts;
2356
+ return t0 + ratio * (t1 - t0);
2357
+ }
2358
+
2359
+ function onStart(e) {
2360
+ e.stopPropagation();
2361
+ var ts = getTs(e);
2362
+ var t0 = st.tracePoints[0].ts, t1 = st.tracePoints[st.tracePoints.length-1].ts;
2363
+ var range = t1 - t0;
2364
+ if (st.splitMode) {
2365
+ st.splitTs = ts; dragging = 'split';
2366
+ _inlineRedraw(rowId); return;
2367
+ }
2368
+ if (st.trimMode) {
2369
+ var dS = Math.abs(ts - st.trimStart) / range;
2370
+ var dE = Math.abs(ts - st.trimEnd) / range;
2371
+ dragging = dS < dE ? 'trimStart' : 'trimEnd';
2372
+ }
2373
+ }
2374
+ function onMove(e) {
2375
+ if (!dragging) return;
2376
+ e.preventDefault(); e.stopPropagation();
2377
+ var ts = getTs(e);
2378
+ var t0 = st.tracePoints[0].ts, t1 = st.tracePoints[st.tracePoints.length-1].ts;
2379
+ var minGap = (t1-t0) * 0.05;
2380
+ if (dragging === 'trimStart') st.trimStart = Math.min(ts, st.trimEnd - minGap);
2381
+ else if (dragging === 'trimEnd') st.trimEnd = Math.max(ts, st.trimStart + minGap);
2382
+ else if (dragging === 'split') st.splitTs = ts;
2383
+ _inlineRedraw(rowId);
2384
+ }
2385
+ function onEnd() { dragging = null; }
2386
+
2387
+ // Event Listener direkt auf Canvas setzen
2388
+ canvas.addEventListener('mousedown', onStart, {passive:false});
2389
+ canvas.addEventListener('mousemove', onMove, {passive:false});
2390
+ canvas.addEventListener('mouseup', onEnd);
2391
+ canvas.addEventListener('touchstart', onStart, {passive:false});
2392
+ canvas.addEventListener('touchmove', onMove, {passive:false});
2393
+ canvas.addEventListener('touchend', onEnd);
2394
+ }
2395
+
2396
+ function isDryer(deviceId) {
2397
+ var dev = devices.find(function(d) { return d.deviceId === deviceId; });
2398
+ if (!dev) return false;
2399
+ var t = (dev.deviceType || '').toLowerCase();
2400
+ return t === 'dryer' || t === 'trockner';
2401
+ }
2402
+
2403
+ function saveAsAntiKnitter(deviceId, cycleId) {
2404
+ if (!confirm('Diesen Zyklus als Anti-Knitter-Muster speichern?\nEr wird verwendet um Anti-Knitter nach dem Trocknen zu erkennen.')) return;
2405
+ send('setAntiKnitter', { deviceId: deviceId, cycleId: cycleId }).then(function(res) {
2406
+ if (res && res.ok) toast('✅ Anti-Knitter gespeichert! Dauer: ' + Math.round(res.durationMin) + ' min, Max: ' + Math.round(res.maxWatts) + 'W', 'ok');
2407
+ else toast((res && res.error) || 'Fehler', 'err');
2408
+ });
2409
+ }
2410
+
2411
+ function saveAsProgram(deviceId, cycleId) {
2412
+ var name = prompt('Programmname:');
2413
+ if (!name) return;
2414
+ send('createProfileFromTrace', {deviceId: deviceId, cycleId: cycleId, name: name}).then(function(res) {
2415
+ if (!res || !res.ok) return toast((res && res.error) || 'Fehler', 'err');
2416
+ // Direkt dem Zyklus zuweisen
2417
+ send('correctCycle', {deviceId: deviceId, cycleId: cycleId, profileId: res.profileId}).then(function(res2) {
2418
+ if (res2 && res2.ok) { toast('Programm gespeichert & zugewiesen!', 'ok'); loadCycles(); }
2419
+ else toast((res2 && res2.error) || 'Fehler beim Zuweisen', 'err');
2420
+ });
2421
+ });
2422
+ }
2423
+
2424
+ function assignProgram(deviceId, cycleId) {
2425
+ send('getProfiles', {deviceId: deviceId}).then(function(res) {
2426
+ if (!res || !res.ok || !res.profiles.length) return toast('Keine Programme vorhanden', 'err');
2427
+ var sel = document.getElementById('assign-select');
2428
+ sel.innerHTML = res.profiles.map(function(p) {
2429
+ return '<option value="'+p.id+'">'+p.name+' ('+Math.round((p.durationMs||0)/60000)+' min)</option>';
2430
+ }).join('');
2431
+ // Remember cycleId and deviceId for confirmation
2432
+ document.getElementById('modal-assign').dataset.cycleId = cycleId;
2433
+ document.getElementById('modal-assign').dataset.deviceId = deviceId;
2434
+ document.getElementById('modal-assign').classList.add('open');
2435
+ });
2436
+ }
2437
+
2438
+ function createProfileFromGraph() {
2439
+ var name = prompt('Programmname:');
2440
+ if (!name) return;
2441
+ send('createProfileFromTrace', {deviceId:selVal('graph'), cycleId:_graphCycleId, name:name}).then(function(res) {
2442
+ if (res&&res.ok) toast('Programm gespeichert!','ok');
2443
+ else toast((res&&res.error)||'Fehler','err');
2444
+ });
2445
+ }
2446
+
2447
+ function assignGraphCycle() {
2448
+ var deviceId = selVal('graph');
2449
+ send('getProfiles', {deviceId: deviceId}).then(function(res) {
2450
+ if (!res || !res.ok || !res.profiles.length) return toast('Keine Programme vorhanden', 'err');
2451
+ var sel = document.getElementById('assign-select');
2452
+ sel.innerHTML = res.profiles.map(function(p) {
2453
+ return '<option value="'+p.id+'">'+p.name+' ('+Math.round((p.durationMs||0)/60000)+' min)</option>';
2454
+ }).join('');
2455
+ document.getElementById('modal-assign').classList.add('open');
2456
+ });
2457
+ }
2458
+
2459
+ function confirmAssign() {
2460
+ var modal = document.getElementById('modal-assign');
2461
+ var deviceId = modal.dataset.deviceId || selVal('graph');
2462
+ var cycleId = modal.dataset.cycleId || _graphCycleId;
2463
+ var profileId = document.getElementById('assign-select').value;
2464
+ if (!profileId) return;
2465
+ send('correctCycle', {deviceId: deviceId, cycleId: cycleId, profileId: profileId}).then(function(res) {
2466
+ closeModal('modal-assign');
2467
+ modal.dataset.deviceId = '';
2468
+ modal.dataset.cycleId = '';
2469
+ if (res && res.ok) {
2470
+ toast('Zugewiesen & gelernt!', 'ok');
2471
+ loadCycles();
2472
+ } else {
2473
+ toast((res && res.error) || 'Fehler', 'err');
2474
+ }
2475
+ });
2476
+ }
2477
+
2478
+ // ── Boot ──────────────────────────────────────────
2479
+ socket.on('connect', function() {
2480
+ init();
2481
+ });
2482
+
2483
+ // Falls bereits verbunden
2484
+ if (socket.connected) {
2485
+ init();
2486
+ }
2487
+ </script>
2488
+ </body>
2489
+ </html>