Feature: Tagebuch-Import (NoteStation .nsx + CSV) + Transponder in Gesundheitsdaten

- Import-Endpoint für Synology NoteStation (.nsx): HTML→Text, GPS, Bilder, Unix-Timestamp→Datum
- Import-Endpoint für CSV (Komma/Semikolon, BOM-safe, DE-Datumsformat)
- Import-Modal im Tagebuch mit Format-Auswahl-Karten und Ergebnis-Anzeige
- Transpondernummer in Gesundheitsdaten: Anzeige + Inline-Edit via Modal
- SW-Cache by-v142
This commit is contained in:
rene 2026-04-17 15:17:56 +02:00
parent 6fcf841594
commit 94e0ed3daa
7 changed files with 505 additions and 5 deletions

View file

@ -224,6 +224,40 @@
flex-shrink: 0;
}
/* Health: Transponder-Zeile */
.health-transponder {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
background: var(--c-surface);
border-bottom: 1px solid var(--c-border);
font-size: var(--text-sm);
color: var(--c-text-secondary);
flex-shrink: 0;
}
.health-transponder-label { color: var(--c-text-muted); }
.health-transponder-edit { margin-left: auto; }
/* Import: Format-Auswahl-Karten */
.import-format-card {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border: 2px solid var(--c-border);
border-radius: var(--radius-md);
cursor: pointer;
transition: border-color var(--transition-fast), background var(--transition-fast);
}
.import-format-card:hover { border-color: var(--c-primary-light); }
.import-format-card--active { border-color: var(--c-primary); background: var(--c-primary-subtle); }
.import-format-icon {
font-size: 1.5rem;
color: var(--c-primary);
flex-shrink: 0;
}
/* ------------------------------------------------------------
5. BADGES & STATUS-PILLS
------------------------------------------------------------ */

View file

@ -408,6 +408,21 @@ const API = (() => {
resetToken: () => del('/webcal/token'),
};
const importData = {
notestation(dogId, file) {
const fd = new FormData();
fd.append('dog_id', dogId);
fd.append('file', file);
return upload('/import/notestation', fd);
},
csv(dogId, file) {
const fd = new FormData();
fd.append('dog_id', dogId);
fd.append('file', file);
return upload('/import/csv', fd);
},
};
// ----------------------------------------------------------
// ERROR-KLASSE
// ----------------------------------------------------------
@ -425,7 +440,7 @@ const API = (() => {
get, post, put, patch, del, upload,
auth, dogs, diary, health, tieraerzte, poison,
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
friends, chat, webcal,
friends, chat, webcal, importData,
subscribeToPush, getLocation,
APIError,
};

View file

@ -135,12 +135,20 @@ window.Page_diary = (() => {
// ----------------------------------------------------------
async function _renderDiary() {
_container.innerHTML = `
<div class="by-toolbar diary-toolbar">
<button class="btn btn-secondary btn-sm" id="diary-import-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#download-simple"></use></svg>
Importieren
</button>
</div>
<div id="diary-list"></div>
<div id="diary-load-more" style="display:none; text-align:center; padding:var(--space-4)">
<button class="btn btn-secondary" id="diary-btn-more">Weitere laden</button>
</div>
`;
_container.querySelector('#diary-import-btn')
?.addEventListener('click', _showImport);
_container.querySelector('#diary-btn-more')
?.addEventListener('click', () => _loadMore());
@ -547,6 +555,125 @@ window.Page_diary = (() => {
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// IMPORT
// ----------------------------------------------------------
function _showImport() {
UI.modal.open({
title: 'Tagebuch importieren',
body: `
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-bottom:var(--space-4)">
Importiere Einträge aus einer anderen App in das Tagebuch von
<strong>${_escape(_appState.activeDog?.name || 'deinem Hund')}</strong>.
</p>
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
<label class="import-format-card" id="fmt-nsx">
<input type="radio" name="import-fmt" value="nsx" checked style="display:none">
<div class="import-format-icon">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note"></use></svg>
</div>
<div>
<div style="font-weight:var(--weight-semibold)">Synology NoteStation</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">.nsx-Datei aus dem NoteStation-Export</div>
</div>
</label>
<label class="import-format-card" id="fmt-csv">
<input type="radio" name="import-fmt" value="csv" style="display:none">
<div class="import-format-icon">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-csv"></use></svg>
</div>
<div>
<div style="font-weight:var(--weight-semibold)">CSV / Excel</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Spalten: datum, titel, text, tags, gps_lat, gps_lon, is_milestone</div>
</div>
</label>
</div>
<div style="margin-top:var(--space-4)">
<label class="form-label">Datei auswählen</label>
<input type="file" class="form-control" id="import-file-input"
accept=".nsx,.csv" style="cursor:pointer">
</div>
<div id="import-result" style="display:none;margin-top:var(--space-4)"></div>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="import-start-btn">Importieren</button>`,
});
// Format-Karten klickbar machen
document.querySelectorAll('.import-format-card').forEach(card => {
card.addEventListener('click', () => {
document.querySelectorAll('.import-format-card').forEach(c => c.classList.remove('import-format-card--active'));
card.classList.add('import-format-card--active');
card.querySelector('input[type=radio]').checked = true;
// Accept-Attribut anpassen
const fmt = card.querySelector('input').value;
document.getElementById('import-file-input').accept = fmt === 'nsx' ? '.nsx' : '.csv';
});
});
// Erste Karte direkt aktiv setzen
document.getElementById('fmt-nsx')?.classList.add('import-format-card--active');
document.getElementById('import-start-btn').addEventListener('click', async () => {
const fileInput = document.getElementById('import-file-input');
const fmt = document.querySelector('input[name="import-fmt"]:checked')?.value;
const btn = document.getElementById('import-start-btn');
const resultEl = document.getElementById('import-result');
if (!fileInput.files.length) {
UI.toast('Bitte zuerst eine Datei auswählen.', 'warning');
return;
}
const file = fileInput.files[0];
const dogId = _appState.activeDog?.id;
UI.setLoading(btn, true);
resultEl.style.display = 'none';
try {
const res = fmt === 'nsx'
? await API.importData.notestation(dogId, file)
: await API.importData.csv(dogId, file);
const errHtml = res.errors?.length
? `<details style="margin-top:var(--space-2)"><summary style="font-size:var(--text-xs);cursor:pointer">${res.errors.length} Fehler anzeigen</summary>
<pre style="font-size:var(--text-xs);white-space:pre-wrap;margin-top:var(--space-1)">${_escape(res.errors.join('\n'))}</pre></details>`
: '';
resultEl.innerHTML = `
<div style="background:var(--c-success-subtle);border-radius:var(--radius-md);
padding:var(--space-3) var(--space-4);color:var(--c-success)">
<strong>${res.imported} Einträge importiert</strong>
${res.skipped ? `<span style="color:var(--c-text-muted);font-size:var(--text-sm)"> · ${res.skipped} übersprungen</span>` : ''}
${errHtml}
</div>`;
resultEl.style.display = 'block';
UI.setLoading(btn, false);
// Diary neu laden falls etwas importiert wurde
if (res.imported > 0) {
_offset = 0;
_entries = [];
await _load();
_renderList();
}
} catch (e) {
resultEl.innerHTML = `
<div style="background:var(--c-danger-subtle);border-radius:var(--radius-md);
padding:var(--space-3) var(--space-4);color:var(--c-danger)">
Fehler: ${_escape(e.message || String(e))}
</div>`;
resultEl.style.display = 'block';
UI.setLoading(btn, false);
}
});
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------

View file

@ -125,12 +125,26 @@ window.Page_health = (() => {
// HEALTH-ANSICHT — Tabs mit Einträgen
// ----------------------------------------------------------
async function _renderHealth() {
const dog = _appState.activeDog;
const transponderHtml = `
<div class="health-transponder" id="health-transponder">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#wave-sine"></use></svg>
<span class="health-transponder-label">Transponder:</span>
<span class="health-transponder-nr" id="health-transponder-nr">
${dog?.chip_nr ? `<strong>${_esc(dog.chip_nr)}</strong>` : '<em style="color:var(--c-text-muted)">nicht eingetragen</em>'}
</span>
<button class="btn btn-link btn-sm health-transponder-edit" id="health-transponder-edit"
style="padding:0;font-size:var(--text-xs);color:var(--c-text-muted)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil-simple"></use></svg>
</button>
</div>`;
_container.innerHTML = `
<div class="by-toolbar health-header">
<button class="btn btn-secondary btn-sm" id="health-ki-btn">
${UI.icon('star')} KI-Zusammenfassung
</button>
</div>
${transponderHtml}
<div id="health-reminders"></div>
<div class="by-tabs" id="by-tabs"></div>
<div id="by-tab-content"></div>
@ -139,6 +153,8 @@ window.Page_health = (() => {
_renderTabBar();
_container.querySelector('#health-ki-btn')
.addEventListener('click', _showKiSummary);
_container.querySelector('#health-transponder-edit')
.addEventListener('click', () => _editTransponder(dog));
await _loadAll();
_renderErinnerungen();
@ -1519,6 +1535,42 @@ window.Page_health = (() => {
});
}
// ----------------------------------------------------------
// TRANSPONDER-BEARBEITUNG
// ----------------------------------------------------------
async function _editTransponder(dog) {
const currentNr = dog?.chip_nr || '';
UI.modal.open({
title: 'Transpondernummer',
body: `
<div class="mb-3">
<label class="form-label">Chip-Nummer (15-stellig)</label>
<input id="transponder-input" class="form-control" type="text"
value="${_esc(currentNr)}" placeholder="z.B. 276009200123456" maxlength="20">
</div>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="transponder-save-btn">Speichern</button>`,
});
document.getElementById('transponder-save-btn').addEventListener('click', async () => {
const nr = document.getElementById('transponder-input').value.trim() || null;
const btn = document.getElementById('transponder-save-btn');
UI.setLoading(btn, true);
try {
await API.dogs.update(dog.id, { chip_nr: nr });
_appState.activeDog.chip_nr = nr;
UI.modal.close();
const nrEl = _container.querySelector('#health-transponder-nr');
if (nrEl) nrEl.innerHTML = nr
? `<strong>${_esc(nr)}</strong>`
: '<em style="color:var(--c-text-muted)">nicht eingetragen</em>';
} catch (e) {
UI.setLoading(btn, false);
UI.toast('Fehler beim Speichern', 'error');
}
});
}
// ----------------------------------------------------------
// KI-ZUSAMMENFASSUNG
// ----------------------------------------------------------

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v141';
const CACHE_VERSION = 'by-v142';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten