mirror of
https://github.com/trevor1969/teatracker.git
synced 2026-08-09 10:41:59 +00:00
- Add corsproxy.io as fallback for CORS-restricted WebDAV requests - Add fetchWithProxy wrapper method to NextcloudStorage class - Update all NextcloudStorage methods to use proxy fallback - Proxy is automatically used when direct connection fails - Add useProxy flag to track proxy usage This fixes the 'NetworkError when attempting to fetch resource' issue caused by CORS restrictions when accessing Nextcloud from file:// or different domains. Generated by Vibe Code Co-authored-by: trevor1969 <trevor1969@users.noreply.github.com>
1622 lines
59 KiB
JavaScript
1622 lines
59 KiB
JavaScript
// Nextcloud Storage Integration
|
|
// ============================================
|
|
|
|
class NextcloudStorage {
|
|
=======
|
|
// ============================================
|
|
// Nextcloud Storage Integration
|
|
// ============================================
|
|
|
|
// CORS Proxy for Nextcloud WebDAV (fallback when direct connection fails due to CORS)
|
|
const NEXTCLOUD_PROXY = 'https://corsproxy.io/?';
|
|
|
|
class NextcloudStorage {TeeTracker - Main Application with Nextcloud Support
|
|
|
|
// ============================================
|
|
// Configuration
|
|
// ============================================
|
|
|
|
const STORAGE_KEY_TEAS = 'teatracker_teas';
|
|
const STORAGE_KEY_ENTRIES = 'teatracker_entries';
|
|
const STORAGE_KEY_NC_CONFIG = 'teatracker_nextcloud_config';
|
|
|
|
// ============================================
|
|
// Nextcloud Storage Integration
|
|
// ============================================
|
|
|
|
class NextcloudStorage {
|
|
constructor(baseUrl, username, password, path = '/TeeTracker/') {
|
|
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
this.username = username;
|
|
this.password = password;
|
|
this.path = path.replace(/^\//, '/').replace(/\/$/, '/');
|
|
this.authHeader = 'Basic ' + btoa(`${username}:${password}`);
|
|
this.connected = false;
|
|
this.lastError = null;
|
|
this.useProxy = false;
|
|
}
|
|
|
|
getFileUrl(filename) {
|
|
return `${this.baseUrl}/remote.php/dav/files/${encodeURIComponent(this.username)}${this.path}${filename}`;
|
|
}
|
|
|
|
getDirectoryUrl() {
|
|
return `${this.baseUrl}/remote.php/dav/files/${encodeURIComponent(this.username)}${this.path}`;
|
|
}
|
|
|
|
// Get proxied URL for CORS fallback
|
|
getProxiedUrl(url) {
|
|
return `${NEXTCLOUD_PROXY}${encodeURIComponent(url)}`;
|
|
}
|
|
|
|
// Wrapper for fetch that automatically uses proxy on CORS failure
|
|
async fetchWithProxy(url, options = {}) {
|
|
try {
|
|
// First try direct connection
|
|
const response = await fetch(url, options);
|
|
|
|
// If we get a CORS error or network error, it will throw
|
|
// If we get here, the request succeeded (even if status is not 2xx)
|
|
if (response.ok || response.status === 404 || response.status === 405) {
|
|
this.useProxy = false;
|
|
return response;
|
|
}
|
|
|
|
// Some errors might still occur, so try proxy
|
|
console.log('Direct connection returned non-ok status, trying proxy...');
|
|
} catch (error) {
|
|
// Direct connection failed (CORS or network error), try proxy
|
|
console.log('Direct connection failed, trying proxy...');
|
|
}
|
|
|
|
// Try with proxy
|
|
try {
|
|
const proxyUrl = this.getProxiedUrl(url);
|
|
this.useProxy = true;
|
|
const response = await fetch(proxyUrl, options);
|
|
|
|
if (response.ok || response.status === 404 || response.status === 405) {
|
|
console.log('Proxy connection successful');
|
|
return response;
|
|
}
|
|
|
|
// If proxy also fails, throw error
|
|
throw new Error(`Proxy returned status: ${response.status}`);
|
|
} catch (proxyError) {
|
|
this.useProxy = false;
|
|
console.error('Proxy connection also failed:', proxyError);
|
|
throw new Error(`Both direct and proxy connections failed: ${error.message || ''} ${proxyError.message || ''}`);
|
|
}
|
|
}
|
|
|
|
async testConnection() {
|
|
try {
|
|
const response = await this.fetchWithProxy(this.getDirectoryUrl(), {
|
|
method: 'PROPFIND',
|
|
headers: {
|
|
'Authorization': this.authHeader,
|
|
'Content-Type': 'text/xml; charset=utf-8'
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
this.connected = true;
|
|
this.lastError = null;
|
|
return true;
|
|
} else if (response.status === 404) {
|
|
await this.ensureDirectory();
|
|
this.connected = true;
|
|
this.lastError = null;
|
|
return true;
|
|
} else {
|
|
this.connected = false;
|
|
this.lastError = `HTTP Error: ${response.status}`;
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
this.connected = false;
|
|
this.lastError = error.message;
|
|
console.error('Nextcloud connection error:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async ensureDirectory() {
|
|
try {
|
|
const response = await this.fetchWithProxy(this.getDirectoryUrl(), {
|
|
method: 'MKCOL',
|
|
headers: { 'Authorization': this.authHeader }
|
|
});
|
|
await this.ensureSubDirectory('backup');
|
|
return response.ok || response.status === 405;
|
|
} catch (error) {
|
|
console.error('Error creating directory:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async ensureSubDirectory(subPath) {
|
|
try {
|
|
const subDirUrl = `${this.getDirectoryUrl()}${subPath}/`;
|
|
const response = await this.fetchWithProxy(subDirUrl, {
|
|
method: 'MKCOL',
|
|
headers: { 'Authorization': this.authHeader }
|
|
});
|
|
return response.ok || response.status === 405;
|
|
} catch (error) {
|
|
console.error('Error creating subdirectory:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async loadFile(filename) {
|
|
try {
|
|
const url = this.getFileUrl(filename);
|
|
const response = await this.fetchWithProxy(url, {
|
|
headers: { 'Authorization': this.authHeader }
|
|
});
|
|
|
|
if (response.ok) {
|
|
const text = await response.text();
|
|
return text ? JSON.parse(text) : null;
|
|
} else if (response.status === 404) {
|
|
return null;
|
|
}
|
|
return null;
|
|
} catch (error) {
|
|
console.error(`Error loading ${filename}:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async saveFile(filename, data) {
|
|
try {
|
|
const url = this.getFileUrl(filename);
|
|
const response = await this.fetchWithProxy(url, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': this.authHeader,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(data, null, 2)
|
|
});
|
|
return response.ok;
|
|
} catch (error) {
|
|
console.error(`Error saving ${filename}:`, error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async fileExists(filename) {
|
|
try {
|
|
const url = this.getFileUrl(filename);
|
|
const response = await this.fetchWithProxy(url, {
|
|
method: 'HEAD',
|
|
headers: { 'Authorization': this.authHeader }
|
|
});
|
|
return response.ok;
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async createBackup(backupName, teas, entries) {
|
|
try {
|
|
await this.ensureSubDirectory('backup');
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const backupData = { timestamp, teas, entries };
|
|
const filename = `backup/${backupName}_${timestamp}.json`;
|
|
return await this.saveFile(filename, backupData);
|
|
} catch (error) {
|
|
console.error('Error creating backup:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async restoreBackup(backupFilename) {
|
|
try {
|
|
const data = await this.loadFile(backupFilename);
|
|
if (data && data.teas && data.entries) {
|
|
return { teas: data.teas, entries: data.entries };
|
|
}
|
|
return null;
|
|
} catch (error) {
|
|
console.error('Error restoring backup:', error);
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Main TeeTracker Application
|
|
// ============================================
|
|
|
|
class TeeTracker {
|
|
constructor() {
|
|
// Storage configuration
|
|
this.useNextcloud = false;
|
|
this.nextcloudStorage = null;
|
|
this.syncInProgress = false;
|
|
|
|
// Data
|
|
this.teas = [];
|
|
this.entries = [];
|
|
this.currentTeaId = null;
|
|
this.charts = {};
|
|
|
|
// Initialize
|
|
this.initStorage();
|
|
this.loadData().then(() => {
|
|
this.init();
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// Storage Initialization
|
|
// ============================================
|
|
|
|
initStorage() {
|
|
// Load Nextcloud configuration from localStorage
|
|
const ncConfig = localStorage.getItem(STORAGE_KEY_NC_CONFIG);
|
|
const ncPassword = sessionStorage.getItem(STORAGE_KEY_NC_CONFIG + '_password');
|
|
|
|
if (ncConfig) {
|
|
try {
|
|
const config = JSON.parse(ncConfig);
|
|
// Load password from sessionStorage (more secure than localStorage)
|
|
const password = ncPassword || '';
|
|
|
|
if (password) {
|
|
this.nextcloudStorage = new NextcloudStorage(
|
|
config.baseUrl,
|
|
config.username,
|
|
password,
|
|
config.path || '/TeeTracker/'
|
|
);
|
|
this.useNextcloud = true;
|
|
this.updateSyncStatus();
|
|
} else {
|
|
// Password not available, user needs to re-enter it
|
|
this.useNextcloud = false;
|
|
this.showStatusMessage('Bitte gib dein Nextcloud-Passwort erneut ein.', 'info');
|
|
}
|
|
} catch (error) {
|
|
console.error('Invalid Nextcloud config:', error);
|
|
this.useNextcloud = false;
|
|
}
|
|
} else {
|
|
// Don't pre-fill credentials - user must enter them manually
|
|
this.useNextcloud = false;
|
|
}
|
|
}
|
|
|
|
async loadData() {
|
|
if (this.useNextcloud && this.nextcloudStorage) {
|
|
// Try to load from Nextcloud first
|
|
await this.nextcloudStorage.ensureDirectory();
|
|
|
|
const teas = await this.nextcloudStorage.loadFile('teas.json');
|
|
const entries = await this.nextcloudStorage.loadFile('entries.json');
|
|
|
|
if (teas) {
|
|
this.teas = teas.map(tea => ({
|
|
...tea,
|
|
organic: tea.organic !== undefined ? tea.organic : false
|
|
}));
|
|
}
|
|
|
|
if (entries) {
|
|
this.entries = entries;
|
|
}
|
|
|
|
// If Nextcloud loading failed, fall back to localStorage
|
|
if (!teas || !entries) {
|
|
this.loadFromLocalStorage();
|
|
}
|
|
} else {
|
|
// Load from localStorage
|
|
this.loadFromLocalStorage();
|
|
}
|
|
}
|
|
|
|
loadFromLocalStorage() {
|
|
const teasData = localStorage.getItem(STORAGE_KEY_TEAS);
|
|
const entriesData = localStorage.getItem(STORAGE_KEY_ENTRIES);
|
|
|
|
if (teasData) {
|
|
try {
|
|
this.teas = JSON.parse(teasData).map(tea => ({
|
|
...tea,
|
|
organic: tea.organic !== undefined ? tea.organic : false
|
|
}));
|
|
} catch (error) {
|
|
this.teas = [];
|
|
}
|
|
}
|
|
|
|
if (entriesData) {
|
|
try {
|
|
this.entries = JSON.parse(entriesData);
|
|
} catch (error) {
|
|
this.entries = [];
|
|
}
|
|
}
|
|
}
|
|
|
|
async saveData() {
|
|
if (this.syncInProgress) return;
|
|
this.syncInProgress = true;
|
|
|
|
try {
|
|
if (this.useNextcloud && this.nextcloudStorage) {
|
|
await this.nextcloudStorage.ensureDirectory();
|
|
|
|
// Save to Nextcloud
|
|
const teasSaved = await this.nextcloudStorage.saveFile('teas.json', this.teas);
|
|
const entriesSaved = await this.nextcloudStorage.saveFile('entries.json', this.entries);
|
|
|
|
// Also save locally as backup
|
|
this.saveToLocalStorage();
|
|
|
|
if (teasSaved && entriesSaved) {
|
|
this.showToast('Daten erfolgreich mit Nextcloud synchronisiert!', 'success');
|
|
} else {
|
|
this.showToast('Lokale Speicherung erfolgreich, Nextcloud-Sync fehlgeschlagen', 'warning');
|
|
}
|
|
} else {
|
|
// Save only locally
|
|
this.saveToLocalStorage();
|
|
}
|
|
} catch (error) {
|
|
console.error('Error saving data:', error);
|
|
this.showToast('Fehler beim Speichern der Daten', 'error');
|
|
} finally {
|
|
this.syncInProgress = false;
|
|
}
|
|
}
|
|
|
|
saveToLocalStorage() {
|
|
localStorage.setItem(STORAGE_KEY_TEAS, JSON.stringify(this.teas));
|
|
localStorage.setItem(STORAGE_KEY_ENTRIES, JSON.stringify(this.entries));
|
|
}
|
|
|
|
updateSyncStatus() {
|
|
const statusBadge = document.getElementById('sync-status');
|
|
if (!statusBadge) return;
|
|
|
|
if (this.useNextcloud) {
|
|
if (this.nextcloudStorage && this.nextcloudStorage.connected) {
|
|
statusBadge.textContent = 'Verbunden mit Nextcloud';
|
|
statusBadge.className = 'status-badge connected';
|
|
} else {
|
|
statusBadge.textContent = 'Verbindung fehlgeschlagen';
|
|
statusBadge.className = 'status-badge disconnected';
|
|
}
|
|
} else {
|
|
statusBadge.textContent = 'Lokaler Modus';
|
|
statusBadge.className = 'status-badge local';
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Generate unique ID
|
|
// ============================================
|
|
|
|
generateId() {
|
|
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
|
}
|
|
|
|
// ============================================
|
|
// Get data by ID
|
|
// ============================================
|
|
|
|
getTeaById(id) {
|
|
return this.teas.find(tea => tea.id === id);
|
|
}
|
|
|
|
getTeaIndexById(id) {
|
|
return this.teas.findIndex(tea => tea.id === id);
|
|
}
|
|
|
|
// ============================================
|
|
// Tea Management
|
|
// ============================================
|
|
|
|
addTea(tea) {
|
|
const newTea = {
|
|
id: this.generateId(),
|
|
name: tea.name,
|
|
type: tea.type,
|
|
brand: tea.brand || '',
|
|
description: tea.description || '',
|
|
color: tea.color || '#8B4513',
|
|
organic: tea.organic || false,
|
|
createdAt: new Date().toISOString()
|
|
};
|
|
this.teas.push(newTea);
|
|
this.saveData();
|
|
return newTea;
|
|
}
|
|
|
|
updateTea(id, updates) {
|
|
const index = this.getTeaIndexById(id);
|
|
if (index !== -1) {
|
|
this.teas[index] = { ...this.teas[index], ...updates };
|
|
this.saveData();
|
|
return this.teas[index];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
deleteTea(id) {
|
|
const index = this.getTeaIndexById(id);
|
|
if (index !== -1) {
|
|
this.entries = this.entries.filter(entry => entry.teaId !== id);
|
|
this.teas.splice(index, 1);
|
|
this.saveData();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ============================================
|
|
// Entry Management
|
|
// ============================================
|
|
|
|
addEntry(entry) {
|
|
const newEntry = {
|
|
id: this.generateId(),
|
|
teaId: entry.teaId,
|
|
date: entry.date,
|
|
time: entry.time || '',
|
|
amount: parseInt(entry.amount) || 1,
|
|
notes: entry.notes || '',
|
|
createdAt: new Date().toISOString()
|
|
};
|
|
this.entries.push(newEntry);
|
|
this.saveData();
|
|
return newEntry;
|
|
}
|
|
|
|
deleteEntry(id) {
|
|
const index = this.entries.findIndex(entry => entry.id === id);
|
|
if (index !== -1) {
|
|
this.entries.splice(index, 1);
|
|
this.saveData();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ============================================
|
|
// Statistics
|
|
// ============================================
|
|
|
|
getTotalCups() {
|
|
return this.entries.reduce((sum, entry) => sum + entry.amount, 0);
|
|
}
|
|
|
|
getTodayCups() {
|
|
const today = new Date().toISOString().split('T')[0];
|
|
return this.entries
|
|
.filter(entry => entry.date === today)
|
|
.reduce((sum, entry) => sum + entry.amount, 0);
|
|
}
|
|
|
|
getWeekCups() {
|
|
const now = new Date();
|
|
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
|
|
return this.entries
|
|
.filter(entry => new Date(entry.date) >= weekAgo)
|
|
.reduce((sum, entry) => sum + entry.amount, 0);
|
|
}
|
|
|
|
getEntriesByPeriod(period) {
|
|
const now = new Date();
|
|
let startDate;
|
|
|
|
switch (period) {
|
|
case 'today':
|
|
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
break;
|
|
case 'week':
|
|
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
break;
|
|
case 'month':
|
|
startDate = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
|
|
break;
|
|
case 'year':
|
|
startDate = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
|
break;
|
|
case 'all':
|
|
startDate = new Date(0);
|
|
break;
|
|
default:
|
|
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
}
|
|
|
|
return this.entries.filter(entry => new Date(entry.date) >= startDate);
|
|
}
|
|
|
|
getCupsByPeriod(period) {
|
|
const entries = this.getEntriesByPeriod(period);
|
|
return entries.reduce((sum, entry) => sum + entry.amount, 0);
|
|
}
|
|
|
|
getCupsByTeaType(period = 'all') {
|
|
const entries = this.getEntriesByPeriod(period);
|
|
const typeCounts = {};
|
|
|
|
entries.forEach(entry => {
|
|
const tea = this.getTeaById(entry.teaId);
|
|
if (tea) {
|
|
typeCounts[tea.type] = (typeCounts[tea.type] || 0) + entry.amount;
|
|
}
|
|
});
|
|
|
|
return typeCounts;
|
|
}
|
|
|
|
getCupsByTea(period = 'all') {
|
|
const entries = this.getEntriesByPeriod(period);
|
|
const teaCounts = {};
|
|
|
|
entries.forEach(entry => {
|
|
const tea = this.getTeaById(entry.teaId);
|
|
if (tea) {
|
|
teaCounts[tea.name] = (teaCounts[tea.name] || 0) + entry.amount;
|
|
}
|
|
});
|
|
|
|
return teaCounts;
|
|
}
|
|
|
|
getDailyConsumption(period = 'week') {
|
|
const entries = this.getEntriesByPeriod(period);
|
|
const dailyCounts = {};
|
|
|
|
entries.forEach(entry => {
|
|
const date = entry.date;
|
|
dailyCounts[date] = (dailyCounts[date] || 0) + entry.amount;
|
|
});
|
|
|
|
return dailyCounts;
|
|
}
|
|
|
|
getMostConsumedTea(period = 'all') {
|
|
const teaCounts = this.getCupsByTea(period);
|
|
let maxCount = 0;
|
|
let mostConsumed = null;
|
|
|
|
for (const [teaName, count] of Object.entries(teaCounts)) {
|
|
if (count > maxCount) {
|
|
maxCount = count;
|
|
mostConsumed = teaName;
|
|
}
|
|
}
|
|
|
|
return { name: mostConsumed, count: maxCount };
|
|
}
|
|
|
|
countUniqueDays(entries) {
|
|
const days = new Set();
|
|
entries.forEach(entry => days.add(entry.date));
|
|
return days.size;
|
|
}
|
|
|
|
// ============================================
|
|
// UI Initialization
|
|
// ============================================
|
|
|
|
init() {
|
|
this.setupEventListeners();
|
|
this.renderAll();
|
|
this.initCharts();
|
|
this.updateSettingsStats();
|
|
}
|
|
|
|
setupEventListeners() {
|
|
// Tab navigation
|
|
document.querySelectorAll('.tab-button').forEach(button => {
|
|
button.addEventListener('click', (e) => this.switchTab(e.target.dataset.tab));
|
|
});
|
|
|
|
// Add tea button
|
|
if (document.getElementById('add-tea-btn')) {
|
|
document.getElementById('add-tea-btn').addEventListener('click', () => this.openTeaModal());
|
|
}
|
|
|
|
// Tea modal
|
|
if (document.getElementById('close-modal')) {
|
|
document.getElementById('close-modal').addEventListener('click', () => this.closeTeaModal());
|
|
}
|
|
if (document.getElementById('tea-form')) {
|
|
document.getElementById('tea-form').addEventListener('submit', (e) => this.handleTeaFormSubmit(e));
|
|
}
|
|
if (document.getElementById('delete-tea-btn')) {
|
|
document.getElementById('delete-tea-btn').addEventListener('click', () => this.handleDeleteTea());
|
|
}
|
|
|
|
// Confirm modal
|
|
if (document.getElementById('close-confirm')) {
|
|
document.getElementById('close-confirm').addEventListener('click', () => this.closeConfirmModal());
|
|
}
|
|
if (document.getElementById('confirm-no')) {
|
|
document.getElementById('confirm-no').addEventListener('click', () => this.closeConfirmModal());
|
|
}
|
|
if (document.getElementById('confirm-yes')) {
|
|
document.getElementById('confirm-yes').addEventListener('click', () => this.confirmDelete());
|
|
}
|
|
|
|
// Track form
|
|
if (document.getElementById('track-form')) {
|
|
document.getElementById('track-form').addEventListener('submit', (e) => this.handleTrackFormSubmit(e));
|
|
}
|
|
if (document.getElementById('cancel-track')) {
|
|
document.getElementById('cancel-track').addEventListener('click', () => this.resetTrackForm());
|
|
}
|
|
|
|
// Tea search and filter
|
|
if (document.getElementById('tea-search')) {
|
|
document.getElementById('tea-search').addEventListener('input', (e) => this.filterTeas());
|
|
}
|
|
if (document.getElementById('tea-type-filter')) {
|
|
document.getElementById('tea-type-filter').addEventListener('change', (e) => this.filterTeas());
|
|
}
|
|
|
|
// Stats period filter
|
|
if (document.getElementById('stats-period')) {
|
|
document.getElementById('stats-period').addEventListener('change', (e) => this.updateStats());
|
|
}
|
|
|
|
// Close modals on outside click
|
|
if (document.getElementById('tea-modal')) {
|
|
document.getElementById('tea-modal').addEventListener('click', (e) => {
|
|
if (e.target.id === 'tea-modal') this.closeTeaModal();
|
|
});
|
|
}
|
|
|
|
if (document.getElementById('confirm-modal')) {
|
|
document.getElementById('confirm-modal').addEventListener('click', (e) => {
|
|
if (e.target.id === 'confirm-modal') this.closeConfirmModal();
|
|
});
|
|
}
|
|
|
|
// Nextcloud settings
|
|
this.setupNextcloudListeners();
|
|
}
|
|
|
|
setupNextcloudListeners() {
|
|
// Toggle Nextcloud config visibility
|
|
const ncEnabled = document.getElementById('nc-enabled');
|
|
const ncConfig = document.getElementById('nc-config');
|
|
|
|
if (ncEnabled && ncConfig) {
|
|
ncEnabled.addEventListener('change', () => {
|
|
ncConfig.style.display = ncEnabled.checked ? 'block' : 'none';
|
|
});
|
|
|
|
// Load saved config
|
|
const config = localStorage.getItem(STORAGE_KEY_NC_CONFIG);
|
|
if (config) {
|
|
try {
|
|
const ncConfigData = JSON.parse(config);
|
|
ncEnabled.checked = true;
|
|
ncConfig.style.display = 'block';
|
|
document.getElementById('nc-url').value = ncConfigData.baseUrl || '';
|
|
document.getElementById('nc-username').value = ncConfigData.username || '';
|
|
document.getElementById('nc-password').value = ncConfigData.password || '';
|
|
document.getElementById('nc-path').value = ncConfigData.path || '/TeeTracker/';
|
|
} catch (error) {
|
|
console.error('Error loading NC config:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Test connection button
|
|
const testBtn = document.getElementById('test-nc-connection');
|
|
if (testBtn) {
|
|
testBtn.addEventListener('click', async () => {
|
|
await this.testNextcloudConnection();
|
|
});
|
|
}
|
|
|
|
// Save config button
|
|
const saveBtn = document.getElementById('save-nc-config');
|
|
if (saveBtn) {
|
|
saveBtn.addEventListener('click', async () => {
|
|
await this.saveNextcloudConfig();
|
|
});
|
|
}
|
|
|
|
// Export data button
|
|
const exportBtn = document.getElementById('export-data');
|
|
if (exportBtn) {
|
|
exportBtn.addEventListener('click', () => this.exportData());
|
|
}
|
|
|
|
// Import data button
|
|
const importBtn = document.getElementById('import-data');
|
|
if (importBtn) {
|
|
importBtn.addEventListener('click', () => this.openImportModal());
|
|
}
|
|
|
|
// Close import modal button
|
|
const closeImportBtn = document.getElementById('close-import-modal');
|
|
if (closeImportBtn) {
|
|
closeImportBtn.addEventListener('click', () => this.closeImportModal());
|
|
}
|
|
|
|
// Cancel import button
|
|
const cancelImportBtn = document.getElementById('cancel-import-btn');
|
|
if (cancelImportBtn) {
|
|
cancelImportBtn.addEventListener('click', () => this.closeImportModal());
|
|
}
|
|
|
|
// Import data button in modal
|
|
const importDataBtn = document.getElementById('import-data-btn');
|
|
if (importDataBtn) {
|
|
importDataBtn.addEventListener('click', () => this.handleImportData());
|
|
}
|
|
|
|
// Close import modal on outside click
|
|
const importModal = document.getElementById('import-modal');
|
|
if (importModal) {
|
|
importModal.addEventListener('click', (e) => {
|
|
if (e.target.id === 'import-modal') this.closeImportModal();
|
|
});
|
|
}
|
|
|
|
// Clear data button
|
|
const clearBtn = document.getElementById('clear-data');
|
|
if (clearBtn) {
|
|
clearBtn.addEventListener('click', () => this.showClearDataConfirm());
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Nextcloud Functions
|
|
// ============================================
|
|
|
|
async testNextcloudConnection() {
|
|
const statusElement = document.getElementById('nc-status');
|
|
if (!statusElement) return;
|
|
|
|
const url = document.getElementById('nc-url').value;
|
|
const username = document.getElementById('nc-username').value;
|
|
const password = document.getElementById('nc-password').value;
|
|
|
|
if (!url || !username || !password) {
|
|
this.showStatusMessage('Bitte fülle alle Pflichtfelder aus!', 'error');
|
|
return;
|
|
}
|
|
|
|
this.showStatusMessage('Verbindung wird getestet...', 'info');
|
|
|
|
try {
|
|
const ncStorage = new NextcloudStorage(url, username, password);
|
|
const connected = await ncStorage.testConnection();
|
|
|
|
if (connected) {
|
|
this.showStatusMessage('✅ Verbindung erfolgreich! Nextcloud ist erreichbar.', 'success');
|
|
} else {
|
|
this.showStatusMessage(`❌ Verbindung fehlgeschlagen: ${ncStorage.lastError}`, 'error');
|
|
}
|
|
} catch (error) {
|
|
this.showStatusMessage(`❌ Fehler: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
async saveNextcloudConfig() {
|
|
const statusElement = document.getElementById('nc-status');
|
|
if (!statusElement) return;
|
|
|
|
const enabled = document.getElementById('nc-enabled').checked;
|
|
const url = document.getElementById('nc-url').value;
|
|
const username = document.getElementById('nc-username').value;
|
|
const password = document.getElementById('nc-password').value;
|
|
const path = document.getElementById('nc-path').value;
|
|
|
|
if (enabled && (!url || !username || !password)) {
|
|
this.showStatusMessage('Bitte fülle alle Pflichtfelder aus!', 'error');
|
|
return;
|
|
}
|
|
|
|
this.showStatusMessage('Konfiguration wird gespeichert...', 'info');
|
|
|
|
try {
|
|
if (enabled) {
|
|
// Test connection first
|
|
const ncStorage = new NextcloudStorage(url, username, password, path);
|
|
const connected = await ncStorage.testConnection();
|
|
|
|
if (!connected) {
|
|
this.showStatusMessage(`❌ Verbindung fehlgeschlagen: ${ncStorage.lastError}`, 'error');
|
|
return;
|
|
}
|
|
|
|
// Save config WITHOUT password for security
|
|
// Password will be requested each time or stored in sessionStorage
|
|
const config = { baseUrl: url, username, path };
|
|
localStorage.setItem(STORAGE_KEY_NC_CONFIG, JSON.stringify(config));
|
|
|
|
// Store password in sessionStorage (cleared when browser closes)
|
|
sessionStorage.setItem(STORAGE_KEY_NC_CONFIG + '_password', password);
|
|
|
|
// Update app state
|
|
this.nextcloudStorage = ncStorage;
|
|
this.useNextcloud = true;
|
|
|
|
// Save current data to Nextcloud
|
|
await this.saveData();
|
|
|
|
this.showStatusMessage('✅ Konfiguration gespeichert und Daten synchronisiert!', 'success');
|
|
} else {
|
|
// Disable Nextcloud
|
|
localStorage.removeItem(STORAGE_KEY_NC_CONFIG);
|
|
sessionStorage.removeItem(STORAGE_KEY_NC_CONFIG + '_password');
|
|
this.useNextcloud = false;
|
|
this.nextcloudStorage = null;
|
|
this.showStatusMessage('✅ Nextcloud-Synchronisation deaktiviert. Daten werden lokal gespeichert.', 'success');
|
|
}
|
|
|
|
this.updateSyncStatus();
|
|
this.updateSettingsStats();
|
|
} catch (error) {
|
|
this.showStatusMessage(`❌ Fehler: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
showStatusMessage(message, type = 'info') {
|
|
const statusElement = document.getElementById('nc-status');
|
|
if (statusElement) {
|
|
statusElement.textContent = message;
|
|
statusElement.className = `status-message ${type}`;
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Data Export/Import
|
|
// ============================================
|
|
|
|
exportData() {
|
|
const data = {
|
|
teas: this.teas,
|
|
entries: this.entries,
|
|
exportedAt: new Date().toISOString()
|
|
};
|
|
|
|
const dataStr = JSON.stringify(data, null, 2);
|
|
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
|
const url = URL.createObjectURL(dataBlob);
|
|
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = `teatracker_export_${new Date().toISOString().split('T')[0]}.json`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
|
|
this.showToast('Daten wurden exportiert!', 'success');
|
|
}
|
|
|
|
openImportModal() {
|
|
const modal = document.getElementById('import-modal');
|
|
if (modal) {
|
|
document.getElementById('import-data-textarea').value = '';
|
|
modal.classList.add('active');
|
|
}
|
|
}
|
|
|
|
async handleImportData() {
|
|
const textarea = document.getElementById('import-data-textarea');
|
|
if (!textarea) return;
|
|
|
|
try {
|
|
const data = JSON.parse(textarea.value);
|
|
|
|
if (data.teas && data.entries) {
|
|
// Merge or replace data
|
|
if (confirm('Sollen die importierten Daten die bestehenden ersetzen?')) {
|
|
this.teas = data.teas.map(tea => ({
|
|
...tea,
|
|
organic: tea.organic !== undefined ? tea.organic : false
|
|
}));
|
|
this.entries = data.entries;
|
|
} else {
|
|
// Merge data
|
|
this.teas = [...this.teas, ...data.teas.map(tea => ({
|
|
...tea,
|
|
organic: tea.organic !== undefined ? tea.organic : false
|
|
}))];
|
|
this.entries = [...this.entries, ...data.entries];
|
|
}
|
|
|
|
await this.saveData();
|
|
this.closeImportModal();
|
|
this.showToast('Daten wurden erfolgreich importiert!', 'success');
|
|
this.renderAll();
|
|
this.updateSettingsStats();
|
|
} else {
|
|
this.showToast('Ungültiges Datenformat!', 'error');
|
|
}
|
|
} catch (error) {
|
|
this.showToast('Fehler beim Importieren der Daten!', 'error');
|
|
}
|
|
}
|
|
|
|
closeImportModal() {
|
|
const modal = document.getElementById('import-modal');
|
|
if (modal) {
|
|
modal.classList.remove('active');
|
|
}
|
|
}
|
|
|
|
showClearDataConfirm() {
|
|
const message = 'Möchtest du wirklich ALLE Daten löschen? Dieser Vorgang kann nicht rückgängig gemacht werden!';
|
|
document.getElementById('confirm-message').textContent = message;
|
|
document.getElementById('confirm-modal').classList.add('active');
|
|
|
|
// Override confirm function temporarily
|
|
this.tempConfirmAction = 'clearData';
|
|
}
|
|
|
|
async confirmClearData() {
|
|
this.teas = [];
|
|
this.entries = [];
|
|
|
|
// Clear both localStorage and Nextcloud
|
|
localStorage.removeItem(STORAGE_KEY_TEAS);
|
|
localStorage.removeItem(STORAGE_KEY_ENTRIES);
|
|
|
|
if (this.useNextcloud && this.nextcloudStorage) {
|
|
await this.nextcloudStorage.saveFile('teas.json', []);
|
|
await this.nextcloudStorage.saveFile('entries.json', []);
|
|
}
|
|
|
|
this.closeConfirmModal();
|
|
this.showToast('Alle Daten wurden gelöscht!', 'success');
|
|
this.renderAll();
|
|
this.updateSettingsStats();
|
|
}
|
|
|
|
// ============================================
|
|
// Tab Navigation
|
|
// ============================================
|
|
|
|
switchTab(tabId) {
|
|
document.querySelectorAll('.tab-button').forEach(button => {
|
|
button.classList.toggle('active', button.dataset.tab === tabId);
|
|
});
|
|
|
|
document.querySelectorAll('.tab-pane').forEach(pane => {
|
|
pane.classList.toggle('active', pane.id === tabId);
|
|
});
|
|
|
|
if (tabId === 'teas') {
|
|
this.renderTeasList();
|
|
} else if (tabId === 'track') {
|
|
this.renderTrackForm();
|
|
} else if (tabId === 'stats') {
|
|
this.updateStats();
|
|
} else if (tabId === 'dashboard') {
|
|
this.updateDashboard();
|
|
} else if (tabId === 'settings') {
|
|
this.updateSettingsStats();
|
|
}
|
|
}
|
|
|
|
renderAll() {
|
|
this.updateDashboard();
|
|
this.renderTeasList();
|
|
this.renderTrackForm();
|
|
this.updateStats();
|
|
this.updateSettingsStats();
|
|
}
|
|
|
|
updateSettingsStats() {
|
|
const totalCups = document.getElementById('settings-total-cups');
|
|
const totalTeas = document.getElementById('settings-total-teas');
|
|
const firstEntry = document.getElementById('settings-first-entry');
|
|
const lastEntry = document.getElementById('settings-last-entry');
|
|
|
|
if (totalCups) totalCups.textContent = this.getTotalCups();
|
|
if (totalTeas) totalTeas.textContent = this.teas.length;
|
|
|
|
if (firstEntry) {
|
|
if (this.entries.length > 0) {
|
|
const first = this.entries.reduce((a, b) => new Date(a.createdAt) < new Date(b.createdAt) ? a : b);
|
|
firstEntry.textContent = new Date(first.createdAt).toLocaleDateString('de-DE');
|
|
} else {
|
|
firstEntry.textContent = '-';
|
|
}
|
|
}
|
|
|
|
if (lastEntry) {
|
|
if (this.entries.length > 0) {
|
|
const last = this.entries.reduce((a, b) => new Date(a.createdAt) > new Date(b.createdAt) ? a : b);
|
|
lastEntry.textContent = new Date(last.createdAt).toLocaleDateString('de-DE');
|
|
} else {
|
|
lastEntry.textContent = '-';
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Dashboard
|
|
// ============================================
|
|
|
|
updateDashboard() {
|
|
const totalCups = document.getElementById('total-cups');
|
|
const todayCups = document.getElementById('today-cups');
|
|
const weekCups = document.getElementById('week-cups');
|
|
const totalTeas = document.getElementById('total-teas');
|
|
|
|
if (totalCups) totalCups.textContent = `${this.getTotalCups()} Tassen`;
|
|
if (todayCups) todayCups.textContent = `${this.getTodayCups()} Tassen`;
|
|
if (weekCups) weekCups.textContent = `${this.getWeekCups()} Tassen`;
|
|
if (totalTeas) totalTeas.textContent = `${this.teas.length} Sorten`;
|
|
|
|
this.renderRecentEntries();
|
|
}
|
|
|
|
renderRecentEntries() {
|
|
const container = document.getElementById('recent-entries');
|
|
if (!container) return;
|
|
|
|
const recentEntries = [...this.entries]
|
|
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
|
.slice(0, 5);
|
|
|
|
if (recentEntries.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">📝</div>
|
|
<div class="empty-state-text">Keine Einträge vorhanden</div>
|
|
<div class="empty-state-subtext">Fange an, deinen Tee-Konsum zu tracken!</div>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = recentEntries.map(entry => {
|
|
const tea = this.getTeaById(entry.teaId);
|
|
const date = new Date(entry.date);
|
|
const formattedDate = date.toLocaleDateString('de-DE');
|
|
const formattedTime = entry.time ? entry.time.substring(0, 5) : '';
|
|
|
|
return `
|
|
<div class="activity-item">
|
|
<div class="activity-info">
|
|
<div class="activity-tea">${tea ? tea.name : 'Unbekannter Tee'} ${tea && tea.organic ? '<span class="organic-badge" title="Bio-Tee">🌱</span>' : ''}</div>
|
|
<div class="activity-details">${formattedDate} ${formattedTime ? `um ${formattedTime}` : ''}</div>
|
|
</div>
|
|
<div class="activity-amount">${entry.amount} Tasse${entry.amount > 1 ? 'n' : ''}</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
// ============================================
|
|
// Tea Management UI
|
|
// ============================================
|
|
|
|
renderTeasList() {
|
|
const container = document.getElementById('teas-list');
|
|
if (!container) return;
|
|
|
|
if (this.teas.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">🍵</div>
|
|
<div class="empty-state-text">Keine Tee-Sorten vorhanden</div>
|
|
<div class="empty-state-subtext">Klicke auf "Tee hinzufügen", um deine erste Sorte anzulegen</div>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = this.teas.map(tea => `
|
|
<div class="tea-card" data-id="${tea.id}">
|
|
<div class="tea-card-header">
|
|
<div>
|
|
<h3 class="tea-name">${tea.name} ${tea.organic ? '<span class="organic-badge" title="Bio-Tee">🌱</span>' : ''}</h3>
|
|
<span class="tea-type">${tea.type}</span>
|
|
</div>
|
|
</div>
|
|
${tea.brand ? `<div class="tea-brand">Marke: ${tea.brand}</div>` : ''}
|
|
${tea.description ? `<div class="tea-description">${tea.description}</div>` : ''}
|
|
<div class="tea-actions">
|
|
<button class="tea-action-btn tea-edit-btn" onclick="app.editTea('${tea.id}')">Bearbeiten</button>
|
|
<button class="tea-action-btn tea-delete-btn" onclick="app.showDeleteTeaConfirm('${tea.id}')">Löschen</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
filterTeas() {
|
|
const searchTerm = document.getElementById('tea-search').value.toLowerCase();
|
|
const typeFilter = document.getElementById('tea-type-filter').value;
|
|
|
|
const filteredTeas = this.teas.filter(tea => {
|
|
const matchesSearch = tea.name.toLowerCase().includes(searchTerm) ||
|
|
tea.brand.toLowerCase().includes(searchTerm) ||
|
|
tea.description.toLowerCase().includes(searchTerm);
|
|
const matchesType = typeFilter === 'all' || tea.type === typeFilter;
|
|
return matchesSearch && matchesType;
|
|
});
|
|
|
|
const container = document.getElementById('teas-list');
|
|
if (!container) return;
|
|
|
|
if (filteredTeas.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">🔍</div>
|
|
<div class="empty-state-text">Keine Tee-Sorten gefunden</div>
|
|
<div class="empty-state-subtext">Versuche andere Suchbegriffe oder Filter</div>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = filteredTeas.map(tea => `
|
|
<div class="tea-card" data-id="${tea.id}">
|
|
<div class="tea-card-header">
|
|
<div>
|
|
<h3 class="tea-name">${tea.name} ${tea.organic ? '<span class="organic-badge" title="Bio-Tee">🌱</span>' : ''}</h3>
|
|
<span class="tea-type">${tea.type}</span>
|
|
</div>
|
|
</div>
|
|
${tea.brand ? `<div class="tea-brand">Marke: ${tea.brand}</div>` : ''}
|
|
${tea.description ? `<div class="tea-description">${tea.description}</div>` : ''}
|
|
<div class="tea-actions">
|
|
<button class="tea-action-btn tea-edit-btn" onclick="app.editTea('${tea.id}')">Bearbeiten</button>
|
|
<button class="tea-action-btn tea-delete-btn" onclick="app.showDeleteTeaConfirm('${tea.id}')">Löschen</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
openTeaModal(teaId = null) {
|
|
const modal = document.getElementById('tea-modal');
|
|
const form = document.getElementById('tea-form');
|
|
const deleteBtn = document.getElementById('delete-tea-btn');
|
|
|
|
if (!modal || !form) return;
|
|
|
|
if (teaId) {
|
|
const tea = this.getTeaById(teaId);
|
|
if (tea) {
|
|
document.getElementById('modal-title').textContent = 'Tee bearbeiten';
|
|
document.getElementById('tea-id').value = tea.id;
|
|
document.getElementById('tea-name').value = tea.name;
|
|
document.getElementById('tea-type').value = tea.type;
|
|
document.getElementById('tea-brand').value = tea.brand || '';
|
|
document.getElementById('tea-description').value = tea.description || '';
|
|
document.getElementById('tea-color').value = tea.color || '#8B4513';
|
|
document.getElementById('tea-organic').checked = tea.organic || false;
|
|
deleteBtn.style.display = 'inline-flex';
|
|
this.currentTeaId = tea.id;
|
|
}
|
|
} else {
|
|
document.getElementById('modal-title').textContent = 'Tee hinzufügen';
|
|
form.reset();
|
|
document.getElementById('tea-id').value = '';
|
|
deleteBtn.style.display = 'none';
|
|
this.currentTeaId = null;
|
|
}
|
|
|
|
modal.classList.add('active');
|
|
}
|
|
|
|
closeTeaModal() {
|
|
const modal = document.getElementById('tea-modal');
|
|
if (modal) {
|
|
modal.classList.remove('active');
|
|
const form = document.getElementById('tea-form');
|
|
if (form) form.reset();
|
|
this.currentTeaId = null;
|
|
}
|
|
}
|
|
|
|
handleTeaFormSubmit(e) {
|
|
e.preventDefault();
|
|
|
|
const formData = {
|
|
name: document.getElementById('tea-name').value.trim(),
|
|
type: document.getElementById('tea-type').value,
|
|
brand: document.getElementById('tea-brand').value.trim(),
|
|
description: document.getElementById('tea-description').value.trim(),
|
|
color: document.getElementById('tea-color').value,
|
|
organic: document.getElementById('tea-organic').checked
|
|
};
|
|
|
|
const teaId = document.getElementById('tea-id').value;
|
|
|
|
if (teaId) {
|
|
this.updateTea(teaId, formData);
|
|
this.showToast('Tee erfolgreich aktualisiert!', 'success');
|
|
} else {
|
|
this.addTea(formData);
|
|
this.showToast('Tee erfolgreich hinzugefügt!', 'success');
|
|
}
|
|
|
|
this.closeTeaModal();
|
|
this.renderTeasList();
|
|
this.renderTrackForm();
|
|
this.updateDashboard();
|
|
this.updateStats();
|
|
this.updateSettingsStats();
|
|
}
|
|
|
|
editTea(teaId) {
|
|
this.openTeaModal(teaId);
|
|
}
|
|
|
|
showDeleteTeaConfirm(teaId) {
|
|
this.currentTeaId = teaId;
|
|
const tea = this.getTeaById(teaId);
|
|
if (tea) {
|
|
document.getElementById('confirm-message').textContent =
|
|
`Möchtest du den Tee "${tea.name}" wirklich löschen? Alle zugehörigen Einträge werden ebenfalls gelöscht.`;
|
|
document.getElementById('confirm-modal').classList.add('active');
|
|
this.tempConfirmAction = 'deleteTea';
|
|
}
|
|
}
|
|
|
|
handleDeleteTea() {
|
|
if (this.currentTeaId) {
|
|
const tea = this.getTeaById(this.currentTeaId);
|
|
if (tea) {
|
|
document.getElementById('confirm-message').textContent =
|
|
`Möchtest du den Tee "${tea.name}" wirklich löschen? Alle zugehörigen Einträge werden ebenfalls gelöscht.`;
|
|
document.getElementById('confirm-modal').classList.add('active');
|
|
this.tempConfirmAction = 'deleteTea';
|
|
}
|
|
}
|
|
}
|
|
|
|
confirmDelete() {
|
|
if (this.tempConfirmAction === 'deleteTea' && this.currentTeaId) {
|
|
const tea = this.getTeaById(this.currentTeaId);
|
|
if (tea) {
|
|
this.deleteTea(this.currentTeaId);
|
|
this.showToast(`Tee "${tea.name}" wurde gelöscht.`, 'success');
|
|
this.closeConfirmModal();
|
|
this.closeTeaModal();
|
|
this.renderTeasList();
|
|
this.renderTrackForm();
|
|
this.updateDashboard();
|
|
this.updateStats();
|
|
this.updateSettingsStats();
|
|
}
|
|
} else if (this.tempConfirmAction === 'clearData') {
|
|
this.confirmClearData();
|
|
}
|
|
|
|
this.tempConfirmAction = null;
|
|
}
|
|
|
|
closeConfirmModal() {
|
|
const modal = document.getElementById('confirm-modal');
|
|
if (modal) {
|
|
modal.classList.remove('active');
|
|
}
|
|
this.tempConfirmAction = null;
|
|
this.currentTeaId = null;
|
|
}
|
|
|
|
// ============================================
|
|
// Track Form
|
|
// ============================================
|
|
|
|
renderTrackForm() {
|
|
const teaSelect = document.getElementById('track-tea');
|
|
const today = new Date().toISOString().split('T')[0];
|
|
|
|
if (!teaSelect) return;
|
|
|
|
document.getElementById('track-date').value = today;
|
|
|
|
if (this.teas.length === 0) {
|
|
teaSelect.innerHTML = '<option value="">-- Keine Tee-Sorten verfügbar --</option>';
|
|
} else {
|
|
teaSelect.innerHTML = '<option value="">-- Wähle eine Tee-Sorte --</option>' +
|
|
this.teas.map(tea =>
|
|
`<option value="${tea.id}">${tea.name} (${tea.type})${tea.organic ? ' 🌱' : ''}</option>`
|
|
).join('');
|
|
}
|
|
|
|
this.renderTrackRecentEntries();
|
|
}
|
|
|
|
handleTrackFormSubmit(e) {
|
|
e.preventDefault();
|
|
|
|
const teaId = document.getElementById('track-tea').value;
|
|
const date = document.getElementById('track-date').value;
|
|
const time = document.getElementById('track-time').value;
|
|
const amount = document.getElementById('track-amount').value;
|
|
const notes = document.getElementById('track-notes').value.trim();
|
|
|
|
if (!teaId || !date || !amount) {
|
|
this.showToast('Bitte fülle alle Pflichtfelder aus!', 'error');
|
|
return;
|
|
}
|
|
|
|
const entry = {
|
|
teaId,
|
|
date,
|
|
time,
|
|
amount,
|
|
notes
|
|
};
|
|
|
|
this.addEntry(entry);
|
|
this.showToast('Tee-Konsum erfolgreich getrackt!', 'success');
|
|
|
|
this.resetTrackForm();
|
|
|
|
this.updateDashboard();
|
|
this.updateStats();
|
|
this.renderTrackRecentEntries();
|
|
this.updateSettingsStats();
|
|
}
|
|
|
|
resetTrackForm() {
|
|
const form = document.getElementById('track-form');
|
|
if (form) {
|
|
form.reset();
|
|
const today = new Date().toISOString().split('T')[0];
|
|
document.getElementById('track-date').value = today;
|
|
document.getElementById('track-amount').value = 1;
|
|
}
|
|
}
|
|
|
|
renderTrackRecentEntries() {
|
|
const container = document.getElementById('track-recent-entries');
|
|
if (!container) return;
|
|
|
|
const recentEntries = [...this.entries]
|
|
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
|
.slice(0, 10);
|
|
|
|
if (recentEntries.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">📝</div>
|
|
<div class="empty-state-text">Keine Einträge vorhanden</div>
|
|
<div class="empty-state-subtext">Fange an, deinen Tee-Konsum zu tracken!</div>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = recentEntries.map(entry => {
|
|
const tea = this.getTeaById(entry.teaId);
|
|
const date = new Date(entry.date);
|
|
const formattedDate = date.toLocaleDateString('de-DE');
|
|
const formattedTime = entry.time ? entry.time.substring(0, 5) : '';
|
|
|
|
return `
|
|
<div class="activity-item">
|
|
<div class="activity-info">
|
|
<div class="activity-tea">${tea ? tea.name : 'Unbekannter Tee'} ${tea && tea.organic ? '<span class="organic-badge" title="Bio-Tee">🌱</span>' : ''}</div>
|
|
<div class="activity-details">${formattedDate} ${formattedTime ? `um ${formattedTime}` : ''}</div>
|
|
</div>
|
|
<div class="activity-amount">${entry.amount} Tasse${entry.amount > 1 ? 'n' : ''}</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
// ============================================
|
|
// Statistics
|
|
// ============================================
|
|
|
|
initCharts() {
|
|
this.createTeaTypeChart();
|
|
this.createDailyChart();
|
|
}
|
|
|
|
createTeaTypeChart() {
|
|
const ctx = document.getElementById('tea-type-chart');
|
|
if (!ctx) return;
|
|
|
|
const period = document.getElementById('stats-period').value || 'week';
|
|
const typeCounts = this.getCupsByTeaType(period);
|
|
|
|
const labels = Object.keys(typeCounts);
|
|
const data = Object.values(typeCounts);
|
|
|
|
const backgroundColors = labels.map((type, index) => {
|
|
const colors = [
|
|
'#8B4513', '#228B22', '#90EE90', '#FFD700',
|
|
'#FF6347', '#FF69B4', '#4169E1', '#8A2BE2'
|
|
];
|
|
return colors[index % colors.length];
|
|
});
|
|
|
|
if (this.charts.teaType) {
|
|
this.charts.teaType.destroy();
|
|
}
|
|
|
|
this.charts.teaType = new Chart(ctx, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
data: data,
|
|
backgroundColor: backgroundColors,
|
|
borderWidth: 2,
|
|
borderColor: '#fff'
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
position: 'right',
|
|
labels: {
|
|
padding: 15,
|
|
font: { size: 12 }
|
|
}
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
label: function(context) {
|
|
const label = context.label || '';
|
|
const value = context.raw || 0;
|
|
return `${label}: ${value} Tasse${value > 1 ? 'n' : ''}`;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
layout: { padding: 20 }
|
|
}
|
|
});
|
|
}
|
|
|
|
createDailyChart() {
|
|
const ctx = document.getElementById('daily-chart');
|
|
if (!ctx) return;
|
|
|
|
const period = document.getElementById('stats-period').value || 'week';
|
|
const dailyCounts = this.getDailyConsumption(period);
|
|
|
|
const sortedDates = Object.keys(dailyCounts).sort();
|
|
const labels = sortedDates.map(date => {
|
|
const d = new Date(date);
|
|
return period === 'year'
|
|
? d.toLocaleDateString('de-DE', { month: 'short', day: 'numeric' })
|
|
: d.toLocaleDateString('de-DE', { weekday: 'short', day: 'numeric' });
|
|
});
|
|
const data = sortedDates.map(date => dailyCounts[date]);
|
|
|
|
if (this.charts.daily) {
|
|
this.charts.daily.destroy();
|
|
}
|
|
|
|
this.charts.daily = new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: 'Tassen pro Tag',
|
|
data: data,
|
|
backgroundColor: 'rgba(139, 69, 19, 0.7)',
|
|
borderColor: 'rgba(139, 69, 19, 1)',
|
|
borderWidth: 2
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: {
|
|
callbacks: {
|
|
label: function(context) {
|
|
return `${context.raw} Tasse${context.raw > 1 ? 'n' : ''}`;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
ticks: {
|
|
stepSize: 1,
|
|
callback: function(value) {
|
|
return value + (value === 1 ? ' Tasse' : ' Tassen');
|
|
}
|
|
}
|
|
}
|
|
},
|
|
layout: { padding: 20 }
|
|
}
|
|
});
|
|
}
|
|
|
|
updateStats() {
|
|
const period = document.getElementById('stats-period').value || 'week';
|
|
|
|
this.createTeaTypeChart();
|
|
this.createDailyChart();
|
|
this.renderDetailedStats(period);
|
|
}
|
|
|
|
renderDetailedStats(period) {
|
|
const container = document.getElementById('stats-details');
|
|
if (!container) return;
|
|
|
|
const entries = this.getEntriesByPeriod(period);
|
|
const totalCups = entries.reduce((sum, entry) => sum + entry.amount, 0);
|
|
const uniqueDays = this.countUniqueDays(entries);
|
|
const avgDaily = uniqueDays > 0 ? Math.round(totalCups / uniqueDays) : 0;
|
|
const mostConsumed = this.getMostConsumedTea(period);
|
|
const teaCount = new Set(entries.map(entry => entry.teaId)).size;
|
|
|
|
container.innerHTML = `
|
|
<div class="detail-item">
|
|
<div class="detail-item-label">Gesamt getrunken</div>
|
|
<div class="detail-item-value">${totalCups} Tassen</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="detail-item-label">Durchschnitt pro Tag</div>
|
|
<div class="detail-item-value">${avgDaily} Tassen</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="detail-item-label">Aktive Tage</div>
|
|
<div class="detail-item-value">${uniqueDays} Tage</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="detail-item-label">Verschiedene Tees</div>
|
|
<div class="detail-item-value">${teaCount} Sorten</div>
|
|
</div>
|
|
${mostConsumed.name ? `
|
|
<div class="detail-item">
|
|
<div class="detail-item-label">Meist getrunken</div>
|
|
<div class="detail-item-value">${mostConsumed.name}</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="detail-item-label">Favorit (Anzahl)</div>
|
|
<div class="detail-item-value">${mostConsumed.count} Tassen</div>
|
|
</div>
|
|
` : ''}
|
|
`;
|
|
}
|
|
|
|
// ============================================
|
|
// Utility Functions
|
|
// ============================================
|
|
|
|
showToast(message, type = 'info') {
|
|
const existingToast = document.querySelector('.toast');
|
|
if (existingToast) {
|
|
existingToast.remove();
|
|
}
|
|
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast ${type}`;
|
|
toast.textContent = message;
|
|
document.body.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.style.animation = 'toastSlideIn 0.3s ease reverse';
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
// Initialize the application
|
|
let app;
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
app = new TeeTracker();
|
|
window.app = app;
|
|
});
|