Files
teatracker/app.js
2026-06-06 11:28:14 +00:00

1922 lines
66 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Local File Storage
// ============================================
class LocalFileStorage {
constructor() {
this.basePath = DATA_DIRECTORY;
this.connected = false;
this.lastError = null;
}
async testConnection() {
try {
// Try to create a test file
const testFile = this.basePath + 'test_connection.txt';
const response = await fetch(testFile, {
method: 'PUT',
body: 'test'
});
if (response.ok) {
// Clean up test file
await fetch(testFile, { method: 'DELETE' });
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.log('Local file storage not available:', error.message);
return false;
}
}
async loadFile(filename) {
try {
const url = this.basePath + filename;
const response = await fetch(url, {
method: 'GET'
});
if (response.ok) {
const text = await response.text();
return text.trim() === '' ? [] : (text ? JSON.parse(text) : null);
} else if (response.status === 404) {
return null;
}
return null;
} catch (error) {
console.log(`Error loading ${filename}:`, error.message);
return null;
}
}
async saveFile(filename, data) {
try {
const url = this.basePath + filename;
const response = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data, null, 2)
});
return response.ok;
} catch (error) {
console.log(`Error saving ${filename}:`, error.message);
return false;
}
}
async fileExists(filename) {
try {
const url = this.basePath + filename;
const response = await fetch(url, {
method: 'HEAD'
});
return response.ok;
} catch (error) {
return false;
}
}
}
=======
// ============================================
// Local File Storage
// ============================================
class LocalFileStorage {
constructor() {
this.basePath = DATA_DIRECTORY;
this.connected = false;
this.lastError = null;
this.tested = false;
}
async testConnection() {
if (this.tested) return this.connected;
this.tested = true;
try {
// First, check if we can read from the data directory
const testFile = this.basePath + 'teas.json';
const readResponse = await fetch(testFile, {
method: 'GET'
});
if (readResponse.ok) {
// If we can read, try to write
const testWriteFile = this.basePath + '.write_test_' + Date.now() + '.tmp';
const writeResponse = await fetch(testWriteFile, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ test: true })
});
if (writeResponse.ok) {
// Clean up test file
await fetch(testWriteFile, { method: 'DELETE' }).catch(() => {});
this.connected = true;
this.lastError = null;
return true;
}
}
this.connected = false;
this.lastError = 'Server unterstützt Dateispeicherung nicht (PUT/DELETE nicht verfügbar)';
return false;
} catch (error) {
this.connected = false;
this.lastError = error.message;
console.log('Local file storage not available:', error.message);
return false;
}
}
async loadFile(filename) {
try {
const url = this.basePath + filename;
const response = await fetch(url, {
method: 'GET',
cache: 'no-store'
});
if (response.ok) {
const text = await response.text();
if (!text || text.trim() === '') {
return [];
}
return JSON.parse(text);
} else if (response.status === 404) {
return null;
}
return null;
} catch (error) {
console.log(`Error loading ${filename}:`, error.message);
return null;
}
}
async saveFile(filename, data) {
try {
// First ensure the data directory exists by trying to create it
// Note: We can't actually create directories via fetch, but we can try to save the file
const url = this.basePath + filename;
// Try with PUT first (standard for creating/updating files)
let response = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data, null, 2)
});
if (response.ok) {
return true;
}
// If PUT fails, try POST (some servers use POST for file creation)
response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data, null, 2)
});
if (response.ok) {
return true;
}
console.log(`Failed to save ${filename}: PUT and POST both failed`);
return false;
} catch (error) {
console.log(`Error saving ${filename}:`, error.message);
return false;
}
}
async fileExists(filename) {
try {
const url = this.basePath + filename;
const response = await fetch(url, {
method: 'HEAD'
});
return response.ok;
} catch (error) {
return false;
}
}
async ensureDirectory() {
// Try to create a placeholder file to ensure directory exists
// This works on servers that support PUT for file creation
try {
const placeholder = this.basePath + '.gitkeep';
const response = await fetch(placeholder, {
method: 'PUT',
headers: {
'Content-Type': 'text/plain'
},
body: 'This file ensures the data directory exists'
});
return response.ok;
} catch (error) {
return false;
}
}
}TeeTracker - Main Application
// Version 4.0: Local file storage in data/ directory
// ============================================
// Configuration
// ============================================
const STORAGE_KEY_TEAS = 'teatracker_teas';
const STORAGE_KEY_ENTRIES = 'teatracker_entries';
const DATA_DIRECTORY = 'data/';
// ============================================
// Local File Storage
// ============================================
class LocalFileStorage {
constructor() {
this.basePath = DATA_DIRECTORY;
this.connected = false;
this.lastError = null;
}
async testConnection() {
try {
// Try to create a test file
const testFile = this.basePath + 'test_connection.txt';
const response = await fetch(testFile, {
method: 'PUT',
body: 'test'
});
if (response.ok) {
// Clean up test file
await fetch(testFile, { method: 'DELETE' });
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.log('Local file storage not available:', error.message);
return false;
}
}
async loadFile(filename) {
try {
const url = this.basePath + filename;
const response = await fetch(url, {
method: 'GET'
});
if (response.ok) {
const text = await response.text();
return text.trim() === '' ? [] : (text ? JSON.parse(text) : null);
} else if (response.status === 404) {
return null;
}
return null;
} catch (error) {
console.log(`Error loading ${filename}:`, error.message);
return null;
}
}
async saveFile(filename, data) {
try {
const url = this.basePath + filename;
const response = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data, null, 2)
});
return response.ok;
} catch (error) {
console.log(`Error saving ${filename}:`, error.message);
return false;
}
}
async fileExists(filename) {
try {
const url = this.basePath + filename;
const response = await fetch(url, {
method: 'HEAD'
});
return response.ok;
} catch (error) {
return false;
}
}
}
// ============================================
// Main TeeTracker Application
// ============================================
class TeeTracker {
constructor() {
// Storage
this.fileStorage = new LocalFileStorage();
this.useFileStorage = false;
this.syncInProgress = false;
// Data
this.teas = [];
this.entries = [];
this.currentTeaId = null;
this.charts = {};
this.currentBgImage = null;
// Tea Timer
this.timer = null;
this.timerSeconds = 180;
this.timerRunning = false;
this.timerPaused = false;
this.remainingSeconds = 180;
// Initialize
this.initStorage();
this.setupEventListeners();
this.setupTimerListeners();
this.loadData().then(() => {
this.renderAll();
this.initCharts();
this.updateSettingsStats();
});
}
// ============================================
// Storage Initialization
// ============================================
async initStorage() {
// Test if file storage is available
this.useFileStorage = await this.fileStorage.testConnection();
this.updateSyncStatus();
}
async loadData() {
if (this.syncInProgress) return;
this.syncInProgress = true;
try {
if (this.useFileStorage) {
// Try to ensure data directory exists
await this.fileStorage.ensureDirectory();
// Try to load from file storage first
const teas = await this.fileStorage.loadFile('teas.json');
const entries = await this.fileStorage.loadFile('entries.json');
// Always load from localStorage as backup
const localTeas = localStorage.getItem(STORAGE_KEY_TEAS);
const localEntries = localStorage.getItem(STORAGE_KEY_ENTRIES);
// Load from file storage if available, otherwise use local data
if (teas && Array.isArray(teas)) {
this.teas = teas.map(tea => ({
...tea,
organic: tea.organic !== undefined ? tea.organic : false,
rating: tea.rating !== undefined ? tea.rating : 3
}));
} else if (localTeas) {
try {
this.teas = JSON.parse(localTeas).map(tea => ({
...tea,
organic: tea.organic !== undefined ? tea.organic : false,
rating: tea.rating !== undefined ? tea.rating : 3
}));
} catch (error) {
console.error('Error loading local teas:', error);
this.teas = [];
}
} else {
this.teas = [];
}
if (entries && Array.isArray(entries)) {
this.entries = entries.map(entry => ({
...entry,
teaspoons: entry.teaspoons !== undefined ? entry.teaspoons : 1
}));
} else if (localEntries) {
try {
this.entries = JSON.parse(localEntries).map(entry => ({
...entry,
teaspoons: entry.teaspoons !== undefined ? entry.teaspoons : 1
}));
} catch (error) {
console.error('Error loading local entries:', error);
this.entries = [];
}
} else {
this.entries = [];
}
// If we loaded from file storage, save to localStorage as backup
if ((teas || entries) && (localTeas === null || localEntries === null)) {
this.saveToLocalStorage();
}
} else {
// Load from localStorage only
this.loadFromLocalStorage();
}
} catch (error) {
console.error('Error loading data:', error);
this.loadFromLocalStorage();
} finally {
this.syncInProgress = false;
}
}
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,
rating: tea.rating !== undefined ? tea.rating : 3
}));
} catch (error) {
this.teas = [];
}
}
if (entriesData) {
try {
this.entries = JSON.parse(entriesData).map(entry => ({
...entry,
teaspoons: entry.teaspoons !== undefined ? entry.teaspoons : 1
}));
} catch (error) {
this.entries = [];
}
}
}
async saveData() {
if (this.syncInProgress) return;
this.syncInProgress = true;
try {
if (this.useFileStorage) {
// Save to file storage
const teasSaved = await this.fileStorage.saveFile('teas.json', this.teas);
const entriesSaved = await this.fileStorage.saveFile('entries.json', this.entries);
// Also save locally as backup
this.saveToLocalStorage();
if (teasSaved && entriesSaved) {
this.showToast('Daten erfolgreich in data/-Verzeichnis gespeichert!', 'success');
} else {
this.showToast('Lokale Speicherung erfolgreich, Dateispeicherung 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.useFileStorage) {
statusBadge.textContent = 'Dateispeicherung aktiv';
statusBadge.className = 'status-badge connected';
} else {
statusBadge.textContent = 'Lokaler Modus (localStorage)';
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,
rating: tea.rating || 3,
image: tea.image || '',
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,
teaspoons: parseInt(entry.teaspoons) || 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':
default:
startDate = new Date(0);
break;
}
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 && tea.type) {
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.id] = {
name: tea.name,
type: tea.type,
count: (teaCounts[tea.id] ? teaCounts[tea.id].count : 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 teaId in teaCounts) {
if (teaCounts[teaId].count > maxCount) {
maxCount = teaCounts[teaId].count;
mostConsumed = teaCounts[teaId];
}
}
return mostConsumed;
}
countUniqueDays(entries) {
const days = new Set();
entries.forEach(entry => {
days.add(entry.date);
});
return days.size;
}
// ============================================
// Initialization
// ============================================
init() {
// Ensure data directory exists by attempting to save
this.saveData().catch(() => {});
}
setupEventListeners() {
// Tab navigation
document.querySelectorAll('.tab-button').forEach(button => {
button.addEventListener('click', () => {
this.switchTab(button.dataset.tab);
});
});
// Add tea button
const addTeaBtn = document.getElementById('add-tea-btn');
if (addTeaBtn) {
addTeaBtn.addEventListener('click', () => this.openTeaModal());
}
// Tea modal
const teaModal = document.getElementById('tea-modal');
if (teaModal) {
teaModal.addEventListener('click', (e) => {
if (e.target.id === 'tea-modal') this.closeTeaModal();
});
}
// Tea form
const teaForm = document.getElementById('tea-form');
if (teaForm) {
teaForm.addEventListener('submit', (e) => this.handleTeaFormSubmit(e));
}
// Cancel tea button
const cancelTeaBtn = document.getElementById('cancel-tea-btn');
if (cancelTeaBtn) {
cancelTeaBtn.addEventListener('click', () => this.closeTeaModal());
}
// Track form
const trackForm = document.getElementById('track-form');
if (trackForm) {
trackForm.addEventListener('submit', (e) => this.handleTrackFormSubmit(e));
}
// Stats period filter
const statsPeriod = document.getElementById('stats-period');
if (statsPeriod) {
statsPeriod.addEventListener('change', () => this.updateStats());
}
// Export/Import/Clear data buttons
const exportBtn = document.getElementById('export-data');
if (exportBtn) {
exportBtn.addEventListener('click', () => this.exportData());
}
const importBtn = document.getElementById('import-data');
if (importBtn) {
importBtn.addEventListener('click', () => this.openImportModal());
}
const clearBtn = document.getElementById('clear-data');
if (clearBtn) {
clearBtn.addEventListener('click', () => this.showClearDataConfirm());
}
// Import modal
const importModal = document.getElementById('import-modal');
if (importModal) {
importModal.addEventListener('click', (e) => {
if (e.target.id === 'import-modal') this.closeImportModal();
});
}
const closeImportBtn = document.getElementById('close-import-modal');
if (closeImportBtn) {
closeImportBtn.addEventListener('click', () => this.closeImportModal());
}
const cancelImportBtn = document.getElementById('cancel-import-btn');
if (cancelImportBtn) {
cancelImportBtn.addEventListener('click', () => this.closeImportModal());
}
const importDataBtn = document.getElementById('import-data-btn');
if (importDataBtn) {
importDataBtn.addEventListener('click', () => this.handleImportData());
}
// Confirm modal
const confirmModal = document.getElementById('confirm-modal');
if (confirmModal) {
confirmModal.addEventListener('click', (e) => {
if (e.target.id === 'confirm-modal') this.closeConfirmModal();
});
}
const cancelConfirmBtn = document.getElementById('confirm-no');
if (cancelConfirmBtn) {
cancelConfirmBtn.addEventListener('click', () => this.closeConfirmModal());
}
const confirmBtn = document.getElementById('confirm-yes');
if (confirmBtn) {
confirmBtn.addEventListener('click', () => this.confirmAction());
}
// Background settings
this.setupBackgroundListeners();
}
setupTimerListeners() {
// Timer controls
const startTimerBtn = document.getElementById('start-timer');
const pauseTimerBtn = document.getElementById('pause-timer');
const resetTimerBtn = document.getElementById('reset-timer');
const timerInput = document.getElementById('timer-input');
if (startTimerBtn) {
startTimerBtn.addEventListener('click', () => this.startTimer());
}
if (pauseTimerBtn) {
pauseTimerBtn.addEventListener('click', () => this.pauseTimer());
}
if (resetTimerBtn) {
resetTimerBtn.addEventListener('click', () => this.resetTimer());
}
if (timerInput) {
timerInput.addEventListener('change', (e) => this.updateTimerFromInput(e));
timerInput.addEventListener('input', (e) => this.validateTimerInput(e));
}
}
setupBackgroundListeners() {
// Background image upload
const bgUpload = document.getElementById('bg-image-upload');
if (bgUpload) {
bgUpload.addEventListener('change', (e) => this.handleBackgroundUpload(e));
}
// Background enable toggle
const bgEnabled = document.getElementById('bg-enabled');
if (bgEnabled) {
bgEnabled.addEventListener('change', () => this.toggleBackground());
}
// Save background button
const saveBgBtn = document.getElementById('save-bg');
if (saveBgBtn) {
saveBgBtn.addEventListener('click', () => this.saveBackgroundSettings());
}
// Remove background button
const removeBgBtn = document.getElementById('remove-bg');
if (removeBgBtn) {
removeBgBtn.addEventListener('click', () => this.removeBackground());
}
// Load saved background settings
this.loadBackgroundSettings();
}
// ============================================
// Background Functions
// ============================================
handleBackgroundUpload(e) {
const file = e.target.files[0];
if (!file) return;
const preview = document.getElementById('bg-preview-img');
const previewContainer = document.getElementById('bg-image-preview');
const removeBtn = document.getElementById('remove-bg');
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (event) => {
preview.src = event.target.result;
previewContainer.style.display = 'block';
removeBtn.style.display = 'inline-flex';
this.currentBgImage = event.target.result;
};
reader.readAsDataURL(file);
}
}
toggleBackground() {
const enabled = document.getElementById('bg-enabled').checked;
if (enabled && this.currentBgImage) {
document.documentElement.style.setProperty('--bg-image', `url("${this.currentBgImage}")`);
} else {
document.documentElement.style.setProperty('--bg-image', 'none');
}
}
saveBackgroundSettings() {
const enabled = document.getElementById('bg-enabled').checked;
const settings = {
enabled: enabled,
image: this.currentBgImage || ''
};
localStorage.setItem('teatracker_bg_settings', JSON.stringify(settings));
this.showToast('Hintergrundeinstellungen gespeichert!', 'success');
}
removeBackground() {
this.currentBgImage = null;
document.getElementById('bg-image-upload').value = '';
document.getElementById('bg-preview-img').src = '';
document.getElementById('bg-image-preview').style.display = 'none';
document.getElementById('remove-bg').style.display = 'none';
document.documentElement.style.setProperty('--bg-image', 'none');
document.getElementById('bg-enabled').checked = false;
}
loadBackgroundSettings() {
const settings = localStorage.getItem('teatracker_bg_settings');
if (settings) {
try {
const bgSettings = JSON.parse(settings);
this.currentBgImage = bgSettings.image || null;
if (bgSettings.enabled && this.currentBgImage) {
document.getElementById('bg-enabled').checked = true;
document.getElementById('bg-preview-img').src = this.currentBgImage;
document.getElementById('bg-image-preview').style.display = 'block';
document.getElementById('remove-bg').style.display = 'inline-flex';
this.toggleBackground();
}
} catch (error) {
console.error('Error loading background settings:', error);
}
}
}
// ============================================
// Timer Functions
// ============================================
startTimer() {
if (this.timerRunning) return;
if (this.timerPaused) {
this.timerPaused = false;
} else {
const input = document.getElementById('timer-input');
if (input) {
this.timerSeconds = parseInt(input.value) || 180;
this.remainingSeconds = this.timerSeconds;
} else {
this.remainingSeconds = this.timerSeconds;
}
}
this.timerRunning = true;
this.updateTimerDisplay();
this.timer = setInterval(() => {
this.remainingSeconds--;
this.updateTimerDisplay();
if (this.remainingSeconds <= 0) {
this.stopTimer();
this.showToast('⏰ Tee ist fertig!', 'success');
const audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2teleQAA');
audio.play().catch(() => {});
}
}, 1000);
}
pauseTimer() {
if (!this.timerRunning) return;
clearInterval(this.timer);
this.timerRunning = false;
this.timerPaused = true;
}
stopTimer() {
clearInterval(this.timer);
this.timerRunning = false;
this.timerPaused = false;
}
resetTimer() {
this.stopTimer();
const input = document.getElementById('timer-input');
if (input) {
this.timerSeconds = parseInt(input.value) || 180;
} else {
this.timerSeconds = 180;
}
this.remainingSeconds = this.timerSeconds;
this.updateTimerDisplay();
}
updateTimerDisplay() {
const display = document.getElementById('timer-display');
if (display) {
const minutes = Math.floor(this.remainingSeconds / 60);
const seconds = this.remainingSeconds % 60;
display.textContent = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
}
updateTimerFromInput(e) {
const value = parseInt(e.target.value);
if (value >= 10 && value <= 1800) {
this.timerSeconds = value;
this.remainingSeconds = value;
this.updateTimerDisplay();
}
}
validateTimerInput(e) {
const value = e.target.value;
if (value && (parseInt(value) < 10 || parseInt(value) > 1800)) {
e.target.value = this.timerSeconds;
}
}
// ============================================
// 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');
// Set action type
this.tempConfirmAction = 'clearData';
}
async confirmAction() {
if (this.tempConfirmAction === 'clearData') {
await this.confirmClearData();
} else if (this.tempConfirmAction === 'deleteTea') {
this.handleDeleteTea();
}
this.tempConfirmAction = null;
}
async confirmClearData() {
this.teas = [];
this.entries = [];
// Clear localStorage
localStorage.removeItem(STORAGE_KEY_TEAS);
localStorage.removeItem(STORAGE_KEY_ENTRIES);
// Clear file storage if available
if (this.useFileStorage) {
await this.fileStorage.saveFile('teas.json', []);
await this.fileStorage.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');
const recentActivities = document.getElementById('recent-entries');
if (totalCups) totalCups.textContent = this.getTotalCups();
if (todayCups) todayCups.textContent = this.getTodayCups();
if (weekCups) weekCups.textContent = this.getWeekCups();
if (totalTeas) totalTeas.textContent = this.teas.length;
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 Aktivitäten 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' : ''}${entry.teaspoons ? ` (${entry.teaspoons} TL)` : ''}</div>
</div>
`;
}).join('');
}
// ============================================
// Tea List
// ============================================
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">Füge deine erste Tee-Sorte hinzu!</div>
<button class="btn btn-primary" onclick="app.openTeaModal()"> Tee hinzufügen</button>
</div>
`;
return;
}
this.filterTeas();
}
filterTeas() {
const searchInput = document.getElementById('tea-search');
const typeFilter = document.getElementById('tea-type-filter');
if (!searchInput) return;
const searchTerm = searchInput.value.toLowerCase();
const selectedType = typeFilter ? typeFilter.value : 'all';
const filteredTeas = this.teas.filter(tea => {
const matchesSearch = !searchTerm ||
tea.name.toLowerCase().includes(searchTerm) ||
tea.brand.toLowerCase().includes(searchTerm) ||
tea.description.toLowerCase().includes(searchTerm);
const matchesType = selectedType === 'all' || tea.type === selectedType;
return matchesSearch && matchesType;
});
this.renderFilteredTeas(filteredTeas);
}
renderFilteredTeas(teas) {
const container = document.getElementById('teas-list');
if (!container) return;
if (teas.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 = teas.map(tea => `
<div class="tea-card" style="--tea-color: ${tea.color}">
<div class="tea-card-header">
<div class="tea-card-name">${tea.name} ${tea.organic ? '<span class="organic-badge" title="Bio-Tee">🌱</span>' : ''}</div>
<div class="tea-card-type">${tea.type}</div>
</div>
<div class="tea-card-body">
${tea.brand ? `<div class="tea-card-brand">Marke: ${tea.brand}</div>` : ''}
${tea.description ? `<div class="tea-card-description">${tea.description}</div>` : ''}
${tea.rating ? this.renderStarRatingStatic(tea.rating) : ''}
</div>
<div class="tea-card-footer">
<button class="btn-icon" onclick="app.editTea('${tea.id}')" title="Bearbeiten">
<span class="icon">✏️</span>
</button>
<button class="btn-icon btn-danger" onclick="app.showDeleteTeaConfirm('${tea.id}')" title="Löschen">
<span class="icon">🗑️</span>
</button>
</div>
</div>
`).join('');
}
// ============================================
// Tea Modal
// ============================================
openTeaModal(teaId = null) {
const modal = document.getElementById('tea-modal');
const form = document.getElementById('tea-form');
if (modal) {
if (teaId) {
const tea = this.getTeaById(teaId);
if (tea) {
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;
document.getElementById('tea-image-preview-img').src = tea.image || '';
document.getElementById('tea-image-preview').style.display = tea.image ? 'block' : 'none';
this.renderStarRating(tea.rating || 3, 'tea-rating');
this.currentTeaId = tea.id;
}
} else {
// New tea
form.reset();
document.getElementById('tea-color').value = '#8B4513';
document.getElementById('tea-organic').checked = false;
document.getElementById('tea-image-preview-img').src = '';
document.getElementById('tea-image-preview').style.display = 'none';
this.renderStarRating(3, 'tea-rating');
this.currentTeaId = null;
}
modal.classList.add('active');
}
}
closeTeaModal() {
const modal = document.getElementById('tea-modal');
if (modal) {
modal.classList.remove('active');
}
this.currentTeaId = null;
}
handleTeaFormSubmit(e) {
e.preventDefault();
const id = document.getElementById('tea-id').value;
const name = document.getElementById('tea-name').value.trim();
const type = document.getElementById('tea-type').value;
const brand = document.getElementById('tea-brand').value.trim();
const description = document.getElementById('tea-description').value.trim();
const color = document.getElementById('tea-color').value;
const organic = document.getElementById('tea-organic').checked;
const image = document.getElementById('tea-image-preview').src || '';
// Get rating from star rating
const rating = this.getStarRating('tea-rating');
if (!name || !type) {
this.showToast('Bitte fülle alle Pflichtfelder aus!', 'error');
return;
}
const teaData = { name, type, brand, description, color, organic, rating, image };
if (id) {
// Update existing tea
this.updateTea(id, teaData);
this.showToast('Tee-Sorte erfolgreich aktualisiert!', 'success');
} else {
// Add new tea
this.addTea(teaData);
this.showToast('Tee-Sorte erfolgreich hinzugefügt!', 'success');
}
this.closeTeaModal();
this.renderTeasList();
this.renderTrackForm();
this.updateSettingsStats();
}
renderStarRating(rating, containerId) {
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = '';
for (let i = 1; i <= 5; i++) {
const star = document.createElement('span');
star.className = 'star';
star.textContent = i <= rating ? '★' : '☆';
star.dataset.value = i;
star.addEventListener('click', () => this.handleStarRatingClick(star, containerId));
container.appendChild(star);
}
}
renderStarRatingStatic(rating) {
let html = '<div class="tea-rating">';
for (let i = 1; i <= 5; i++) {
html += `<span class="star">${i <= rating ? '★' : '☆'}</span>`;
}
html += '</div>';
return html;
}
getStarRating(containerId) {
const container = document.getElementById(containerId);
if (!container) return 3;
const stars = container.querySelectorAll('.star');
for (let i = 0; i < stars.length; i++) {
if (stars[i].textContent === '★') {
return i + 1;
}
}
return 0;
}
handleStarRatingClick(star, containerId) {
const value = parseInt(star.dataset.value);
const container = document.getElementById(containerId);
if (!container) return;
const stars = container.querySelectorAll('.star');
stars.forEach((s, index) => {
s.textContent = index < value ? '★' : '☆';
});
}
handleImageUpload(e) {
const file = e.target.files[0];
if (!file) return;
const preview = document.getElementById('tea-image-preview-img');
const previewContainer = document.getElementById('tea-image-preview');
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (event) => {
preview.src = event.target.result;
previewContainer.style.display = 'block';
};
reader.readAsDataURL(file);
}
}
editTea(teaId) {
this.openTeaModal(teaId);
}
showDeleteTeaConfirm(teaId) {
const tea = this.getTeaById(teaId);
const message = `Möchtest du wirklich die Tee-Sorte "${tea ? tea.name : 'Unbekannt'}" löschen? Alle damit verbundenen Einträge werden ebenfalls gelöscht!`;
document.getElementById('confirm-message').textContent = message;
document.getElementById('confirm-modal').classList.add('active');
this.tempConfirmAction = 'deleteTea';
this.currentTeaId = teaId;
}
handleDeleteTea() {
if (this.currentTeaId) {
const tea = this.getTeaById(this.currentTeaId);
if (tea) {
this.deleteTea(this.currentTeaId);
this.showToast(`Tee-Sorte "${tea.name}" wurde gelöscht!`, 'success');
this.renderTeasList();
this.renderTrackForm();
this.updateSettingsStats();
}
}
this.currentTeaId = null;
}
confirmDelete() {
this.handleDeleteTea();
}
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 teaspoons = document.getElementById('track-teaspoons').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,
teaspoons,
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' : ''}${entry.teaspoons ? ` (${entry.teaspoons} TL)` : ''}</div>
</div>
`;
}).join('');
}
// ============================================
// Charts
// ============================================
initCharts() {
// Check if Chart.js is loaded
if (typeof Chart === 'undefined') {
console.log('Chart.js not loaded, skipping charts initialization');
return;
}
this.createTeaTypeChart();
this.createDailyChart();
}
createTeaTypeChart() {
const ctx = document.getElementById('tea-type-chart');
if (!ctx) return;
const typeCounts = this.getCupsByTeaType('all');
const labels = Object.keys(typeCounts);
const data = Object.values(typeCounts);
// Define colors for each tea type
const typeColors = {
'Schwarztee': '#8B4513',
'Grüntee': '#228B22',
'Weißtee': '#F5F5DC',
'Oolong': '#DAA520',
'Pu-Erh': '#654321',
'Früchtetee': '#FF6347',
'Kräutertee': '#9ACD32',
'Sonstiges': '#808080'
};
const backgroundColors = labels.map(label => typeColors[label] || '#8B4513');
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: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
position: 'bottom',
labels: {
font: { size: 12 },
padding: 10
}
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.raw || 0;
return `${label}: ${value} Tasse${value !== 1 ? 'n' : ''}`;
}
}
}
}
}
});
}
createDailyChart() {
const ctx = document.getElementById('daily-chart');
if (!ctx) return;
const dailyCounts = this.getDailyConsumption('week');
const labels = Object.keys(dailyCounts).sort();
const data = labels.map(date => dailyCounts[date] || 0);
// Format dates for display
const formattedLabels = labels.map(date => {
const d = new Date(date);
return d.toLocaleDateString('de-DE', { weekday: 'short', day: 'numeric' });
});
if (this.charts.daily) {
this.charts.daily.destroy();
}
this.charts.daily = new Chart(ctx, {
type: 'bar',
data: {
labels: formattedLabels,
datasets: [{
label: 'Tassen pro Tag',
data: data,
backgroundColor: 'rgba(139, 69, 19, 0.7)',
borderColor: 'rgba(139, 69, 19, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1
}
}
},
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: function(context) {
const value = context.raw || 0;
return `${value} Tasse${value !== 1 ? 'n' : ''}`;
}
}
}
}
}
});
}
// ============================================
// Statistics
// ============================================
updateStats() {
const period = document.getElementById('stats-period')?.value || 'all';
this.renderDetailedStats(period);
// Update charts
this.createTeaTypeChart();
this.createDailyChart();
}
renderDetailedStats(period) {
const totalCups = this.getCupsByPeriod(period);
const avgPerDay = this.calculateAveragePerDay(period);
const activeDays = this.calculateActiveDays(period);
const uniqueTeas = this.calculateUniqueTeas(period);
const mostConsumed = this.getMostConsumedTea(period);
const statsContainer = document.getElementById('detailed-stats');
if (!statsContainer) return;
statsContainer.innerHTML = `
<div class="stat-card">
<div class="stat-value">${totalCups}</div>
<div class="stat-label">Gesamt getrunken</div>
</div>
<div class="stat-card">
<div class="stat-value">${avgPerDay.toFixed(1)}</div>
<div class="stat-label">Durchschnitt pro Tag</div>
</div>
<div class="stat-card">
<div class="stat-value">${activeDays}</div>
<div class="stat-label">Aktive Tage</div>
</div>
<div class="stat-card">
<div class="stat-value">${uniqueTeas}</div>
<div class="stat-label">Verschiedene Tees</div>
</div>
<div class="stat-card">
<div class="stat-value">${mostConsumed ? mostConsumed.name : '-'}</div>
<div class="stat-label">Meist getrunkener Tee</div>
</div>
`;
}
calculateAveragePerDay(period) {
const entries = this.getEntriesByPeriod(period);
if (entries.length === 0) return 0;
const totalCups = this.getCupsByPeriod(period);
const days = this.countUniqueDays(entries);
return days > 0 ? totalCups / days : 0;
}
calculateActiveDays(period) {
const entries = this.getEntriesByPeriod(period);
return this.countUniqueDays(entries);
}
calculateUniqueTeas(period) {
const entries = this.getEntriesByPeriod(period);
const teaIds = new Set();
entries.forEach(entry => teaIds.add(entry.teaId));
return teaIds.size;
}
// ============================================
// Toast Notifications
// ============================================
showToast(message, type = 'info') {
const toastContainer = document.getElementById('toast-container');
if (!toastContainer) return;
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
toastContainer.appendChild(toast);
setTimeout(() => {
toast.classList.add('show');
}, 100);
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
}
// Initialize application
document.addEventListener('DOMContentLoaded', () => {
window.app = new TeeTracker();
});