Files
CRM/public/app.js

1133 lines
40 KiB
JavaScript

document.addEventListener('DOMContentLoaded', () => {
// --- APPLICATION STATE ---
let state = {
friends: [],
meetings: [],
selectedFriend: null,
activeTab: 'dashboard'
};
// --- SELECT DOM ELEMENTS ---
const DOM = {
// Navigation & Tabs
navItems: document.querySelectorAll('.nav-item'),
tabPanes: document.querySelectorAll('.tab-pane'),
pageTitle: document.getElementById('page-title'),
// Dashboard Stats
statTotalFriends: document.getElementById('stat-total-friends'),
statMeetingsMonth: document.getElementById('stat-meetings-month'),
statAvgHonor: document.getElementById('stat-avg-honor'),
statUpcomingBirthdays: document.getElementById('stat-upcoming-birthdays'),
// Dashboard Panels
urgentContactsList: document.getElementById('urgent-contacts-list'),
upcomingBirthdaysList: document.getElementById('upcoming-birthdays-list'),
quickLogForm: document.getElementById('quick-log-form'),
quickFriendSelect: document.getElementById('quick-friend-select'),
quickDate: document.getElementById('quick-date'),
quickActivity: document.getElementById('quick-activity'),
quickMood: document.getElementById('quick-mood'),
quickDetails: document.getElementById('quick-details'),
// Friends Directory
friendsSearch: document.getElementById('friends-search'),
btnOpenAddModal: document.getElementById('btn-add-friend-modal'),
friendsGrid: document.getElementById('friends-grid'),
// Add Friend Modal
addFriendModal: document.getElementById('add-friend-modal'),
addFriendForm: document.getElementById('add-friend-form'),
btnCloseAddModal: document.getElementById('btn-close-add-modal'),
btnCancelAddModal: document.getElementById('btn-cancel-add-modal'),
// Friend Detail Modal
detailModal: document.getElementById('friend-detail-modal'),
btnCloseDetailModal: document.getElementById('btn-close-detail-modal'),
btnDeleteFriend: document.getElementById('btn-delete-friend'),
btnEditFriendTrigger: document.getElementById('btn-edit-friend-trigger'),
detailAvatar: document.getElementById('detail-avatar'),
detailName: document.getElementById('detail-name'),
detailRelationshipBadge: document.getElementById('detail-badge-relationship'),
detailHonorValue: document.getElementById('detail-honor-value'),
btnHonorPlus: document.getElementById('btn-honor-plus'),
btnHonorMinus: document.getElementById('btn-honor-minus'),
detailBirthday: document.getElementById('detail-birthday'),
detailContact: document.getElementById('detail-contact'),
detailAddress: document.getElementById('detail-address'),
detailFamily: document.getElementById('detail-family'),
detailLife: document.getElementById('detail-life'),
detailHobbies: document.getElementById('detail-hobbies'),
detailMilestones: document.getElementById('detail-milestones'),
detailFood: document.getElementById('detail-food'),
detailNotes: document.getElementById('detail-notes'),
detailTopicsList: document.getElementById('detail-topics-list'),
detailMeetingsTimeline: document.getElementById('detail-meetings-timeline'),
addTopicForm: document.getElementById('add-topic-form'),
newTopicInput: document.getElementById('new-topic-input'),
profileLogMeetingForm: document.getElementById('profile-log-meeting-form'),
profileMeetingDate: document.getElementById('profile-meeting-date'),
profileMeetingMood: document.getElementById('profile-meeting-mood'),
profileMeetingActivity: document.getElementById('profile-meeting-activity'),
profileMeetingDetails: document.getElementById('profile-meeting-details'),
// Edit Friend Modal
editFriendModal: document.getElementById('edit-friend-modal'),
editFriendForm: document.getElementById('edit-friend-form'),
btnCloseEditModal: document.getElementById('btn-close-edit-modal'),
btnCancelEditModal: document.getElementById('btn-cancel-edit-modal'),
editId: document.getElementById('edit-id'),
editName: document.getElementById('edit-name'),
editBirthday: document.getElementById('edit-birthday'),
editContact: document.getElementById('edit-contact'),
editRelationship: document.getElementById('edit-relationship'),
editFamily: document.getElementById('edit-family'),
editAddress: document.getElementById('edit-address'),
editHonor: document.getElementById('edit-honor'),
editJob: document.getElementById('edit-job'),
editLife: document.getElementById('edit-life'),
editHobbies: document.getElementById('edit-hobbies'),
editMilestones: document.getElementById('edit-milestones'),
editFood: document.getElementById('edit-food'),
editNotes: document.getElementById('edit-notes'),
// Obsidian Import Tab
obsidianImportForm: document.getElementById('obsidian-import-form'),
importFilename: document.getElementById('import-filename'),
importContent: document.getElementById('import-content'),
importResult: document.getElementById('import-result')
};
// --- INITIALIZATION ---
async function init() {
setupTabNavigation();
setupEventListeners();
setFormDefaultDates();
// Initial fetch of data
await refreshAllData();
}
// --- DATA FETCHING & SYNC ---
async function refreshAllData() {
showGlobalLoaders();
await Promise.all([
fetchFriends(),
fetchMeetings()
]);
renderDashboard();
renderFriendsDirectory();
// If a friend modal is open, refresh it as well
if (state.selectedFriend) {
await refreshFriendDetails(state.selectedFriend.id);
}
}
async function fetchFriends() {
try {
const res = await fetch('/api/friends');
if (!res.ok) throw new Error('Fehler beim Laden der Freunde');
state.friends = await res.json();
} catch (err) {
console.error(err);
alert('Konnte Freunde nicht laden: ' + err.message);
}
}
async function fetchMeetings() {
try {
const res = await fetch('/api/meetings');
if (!res.ok) throw new Error('Fehler beim Laden der Treffen');
state.meetings = await res.json();
} catch (err) {
console.error(err);
// We fall back to empty meetings list gracefully if route doesn't work yet
state.meetings = [];
}
}
async function refreshFriendDetails(friendId) {
try {
const res = await fetch(`/api/friends/${friendId}`);
if (!res.ok) throw new Error('Konnte Details nicht laden');
state.selectedFriend = await res.json();
populateFriendDetails(state.selectedFriend);
} catch (err) {
console.error(err);
alert('Fehler beim Aktualisieren der Details: ' + err.message);
}
}
// --- SPA ROUTING / TAB NAVIGATION ---
function setupTabNavigation() {
// URL Hash handling
window.addEventListener('hashchange', handleHashRoute);
// Sidebar clicks
DOM.navItems.forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const tab = item.getAttribute('data-tab');
window.location.hash = tab;
});
});
// Handle initial load route
handleHashRoute();
}
function handleHashRoute() {
let tab = window.location.hash.replace('#', '') || 'dashboard';
// Validate tab
const validTabs = ['dashboard', 'friends', 'import'];
if (!validTabs.includes(tab)) tab = 'dashboard';
state.activeTab = tab;
// Toggle nav items
DOM.navItems.forEach(item => {
if (item.getAttribute('data-tab') === tab) {
item.classList.add('active');
} else {
item.classList.remove('active');
}
});
// Toggle panes
DOM.tabPanes.forEach(pane => {
if (pane.id === `tab-${tab}`) {
pane.classList.add('active');
} else {
pane.classList.remove('active');
}
});
// Update Header Title
const titles = {
dashboard: 'Dashboard',
friends: 'Freunde-Verzeichnis',
import: 'Obsidian Markdown Import'
};
DOM.pageTitle.textContent = titles[tab] || 'Dashboard';
}
// --- EVENT LISTENERS ---
function setupEventListeners() {
// --- Modals Toggle ---
DOM.btnOpenAddModal.addEventListener('click', () => openModal(DOM.addFriendModal));
DOM.btnCloseAddModal.addEventListener('click', () => closeModal(DOM.addFriendModal));
DOM.btnCancelAddModal.addEventListener('click', () => closeModal(DOM.addFriendModal));
DOM.btnCloseDetailModal.addEventListener('click', () => {
closeModal(DOM.detailModal);
state.selectedFriend = null;
});
DOM.btnCloseEditModal.addEventListener('click', () => closeModal(DOM.editFriendModal));
DOM.btnCancelEditModal.addEventListener('click', () => closeModal(DOM.editFriendModal));
// Close modals on clicking outside container
window.addEventListener('click', (e) => {
if (e.target.classList.contains('modal-backdrop')) {
closeModal(e.target);
if (e.target.id === 'friend-detail-modal') {
state.selectedFriend = null;
}
}
});
// --- Search Filter ---
DOM.friendsSearch.addEventListener('input', (e) => {
renderFriendsDirectory(e.target.value);
});
// --- Add Friend Form Submit ---
DOM.addFriendForm.addEventListener('submit', async (e) => {
e.preventDefault();
const friendData = {
name: document.getElementById('add-name').value.trim(),
birthday: document.getElementById('add-birthday').value,
contact: document.getElementById('add-contact').value.trim(),
relationship_status: document.getElementById('add-relationship').value.trim(),
family: document.getElementById('add-family').value.trim(),
address: document.getElementById('add-address').value.trim(),
honor: parseInt(document.getElementById('add-honor').value, 10) || 0,
job: document.getElementById('add-job').value.trim(),
life_situation: document.getElementById('add-life').value.trim(),
hobbies: document.getElementById('add-hobbies').value.trim(),
milestones: document.getElementById('add-milestones').value.trim(),
food_preferences: document.getElementById('add-food').value.trim(),
random_notes: document.getElementById('add-notes').value.trim()
};
try {
const res = await fetch('/api/friends', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(friendData)
});
if (!res.ok) throw new Error('Fehler beim Anlegen');
DOM.addFriendForm.reset();
setFormDefaultDates();
closeModal(DOM.addFriendModal);
await refreshAllData();
} catch (err) {
alert('Konnte Freund nicht speichern: ' + err.message);
}
});
// --- Edit Friend Form Submit ---
DOM.editFriendForm.addEventListener('submit', async (e) => {
e.preventDefault();
const friendId = DOM.editId.value;
const friendData = {
name: DOM.editName.value.trim(),
birthday: DOM.editBirthday.value,
contact: DOM.editContact.value.trim(),
relationship_status: DOM.editRelationship.value.trim(),
family: DOM.editFamily.value.trim(),
address: DOM.editAddress.value.trim(),
honor: parseInt(DOM.editHonor.value, 10) || 0,
job: DOM.editJob.value.trim(),
life_situation: DOM.editLife.value.trim(),
hobbies: DOM.editHobbies.value.trim(),
milestones: DOM.editMilestones.value.trim(),
food_preferences: DOM.editFood.value.trim(),
random_notes: DOM.editNotes.value.trim()
};
try {
const res = await fetch(`/api/friends/${friendId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(friendData)
});
if (!res.ok) throw new Error('Fehler beim Aktualisieren');
closeModal(DOM.editFriendModal);
await refreshAllData();
if (state.selectedFriend) {
await refreshFriendDetails(friendId);
}
} catch (err) {
alert('Konnte Profildaten nicht aktualisieren: ' + err.message);
}
});
// --- Delete Friend Trigger ---
DOM.btnDeleteFriend.addEventListener('click', async () => {
if (!state.selectedFriend) return;
const confirmDelete = confirm(`Bist du sicher, dass du ${state.selectedFriend.name} aus dem CRM löschen willst? Alle Treffen und Notizen gehen verloren.`);
if (!confirmDelete) return;
try {
const res = await fetch(`/api/friends/${state.selectedFriend.id}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error('Löschen fehlgeschlagen');
closeModal(DOM.detailModal);
state.selectedFriend = null;
await refreshAllData();
} catch (err) {
alert('Fehler beim Löschen: ' + err.message);
}
});
// --- Edit Modal Prefill & Trigger ---
DOM.btnEditFriendTrigger.addEventListener('click', () => {
if (!state.selectedFriend) return;
const f = state.selectedFriend;
DOM.editId.value = f.id;
DOM.editName.value = f.name || '';
DOM.editBirthday.value = f.birthday || '';
DOM.editContact.value = f.contact || '';
DOM.editRelationship.value = f.relationship_status || '';
DOM.editFamily.value = f.family || '';
DOM.editAddress.value = f.address || '';
DOM.editHonor.value = f.honor || 0;
DOM.editJob.value = f.job || '';
DOM.editLife.value = f.life_situation || '';
DOM.editHobbies.value = f.hobbies || '';
DOM.editMilestones.value = f.milestones || '';
DOM.editFood.value = f.food_preferences || '';
DOM.editNotes.value = f.random_notes || '';
openModal(DOM.editFriendModal);
});
// --- Honor Score +/- Clickers ---
DOM.btnHonorPlus.addEventListener('click', () => adjustHonor(1));
DOM.btnHonorMinus.addEventListener('click', () => adjustHonor(-1));
// --- Log Meeting Form (Dashboard) Submit ---
DOM.quickLogForm.addEventListener('submit', async (e) => {
e.preventDefault();
const meetingData = {
friend_id: parseInt(DOM.quickFriendSelect.value, 10),
date: DOM.quickDate.value,
activity: DOM.quickActivity.value.trim(),
mood: DOM.quickMood.value.trim(),
details: DOM.quickDetails.value.trim()
};
try {
const res = await fetch('/api/meetings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(meetingData)
});
if (!res.ok) throw new Error('Eintrag fehlgeschlagen');
DOM.quickLogForm.reset();
setFormDefaultDates();
await refreshAllData();
} catch (err) {
alert('Konnte Treffen nicht eintragen: ' + err.message);
}
});
// --- Log Meeting Form (Profile Modal) Submit ---
DOM.profileLogMeetingForm.addEventListener('submit', async (e) => {
e.preventDefault();
if (!state.selectedFriend) return;
const meetingData = {
friend_id: state.selectedFriend.id,
date: DOM.profileMeetingDate.value,
activity: DOM.profileMeetingActivity.value.trim(),
mood: DOM.profileMeetingMood.value.trim(),
details: DOM.profileMeetingDetails.value.trim()
};
try {
const res = await fetch('/api/meetings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(meetingData)
});
if (!res.ok) throw new Error('Eintrag fehlgeschlagen');
DOM.profileLogMeetingForm.reset();
setFormDefaultDates();
await refreshFriendDetails(state.selectedFriend.id);
await refreshAllData();
} catch (err) {
alert('Konnte Treffen nicht eintragen: ' + err.message);
}
});
// --- Add Topic Form Submit ---
DOM.addTopicForm.addEventListener('submit', async (e) => {
e.preventDefault();
if (!state.selectedFriend) return;
const topicData = {
friend_id: state.selectedFriend.id,
topic: DOM.newTopicInput.value.trim()
};
try {
const res = await fetch('/api/topics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(topicData)
});
if (!res.ok) throw new Error('Hinzufügen fehlgeschlagen');
DOM.newTopicInput.value = '';
await refreshFriendDetails(state.selectedFriend.id);
await refreshAllData();
} catch (err) {
alert('Konnte Thema nicht hinzufügen: ' + err.message);
}
});
// --- Obsidian Import Submit ---
DOM.obsidianImportForm.addEventListener('submit', async (e) => {
e.preventDefault();
const importData = {
filename: DOM.importFilename.value.trim() || 'Imported_Obsidian_Profile.md',
content: DOM.importContent.value
};
try {
DOM.importResult.classList.remove('hidden');
DOM.importResult.innerHTML = '<div class="loading-spinner"></div><p style="text-align:center;">Analysiere Markdown-Struktur...</p>';
const res = await fetch('/api/import-obsidian', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(importData)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Import fehlgeschlagen');
DOM.importResult.innerHTML = `
<div class="import-success-alert">
<i data-lucide="check-circle" class="alert-icon success"></i>
<div class="alert-body">
<h4>${data.name} erfolgreich importiert!</h4>
<p>${data.message}</p>
<button class="btn btn-primary btn-sm" id="btn-view-imported" data-id="${data.id}">
<i data-lucide="external-link"></i> Profil von ${data.name} öffnen
</button>
</div>
</div>
`;
lucide.createIcons();
DOM.obsidianImportForm.reset();
// Bind quick view button
document.getElementById('btn-view-imported').addEventListener('click', (e) => {
const friendId = e.currentTarget.getAttribute('data-id');
openFriendDetail(friendId);
});
await refreshAllData();
} catch (err) {
DOM.importResult.innerHTML = `
<div class="import-error-alert">
<i data-lucide="alert-triangle" class="alert-icon error"></i>
<div class="alert-body">
<h4>Fehler beim Importieren</h4>
<p>${err.message}</p>
</div>
</div>
`;
lucide.createIcons();
}
});
}
// --- DYNAMIC RENDERING: DASHBOARD ---
function renderDashboard() {
const friends = state.friends;
const meetings = state.meetings;
// 1. STATS CALCULATIONS
// Stat: Total Friends
DOM.statTotalFriends.textContent = friends.length;
// Stat: Meetings this month
const today = new Date();
const currentYear = today.getFullYear();
const currentMonth = today.getMonth(); // 0-indexed
const meetingsThisMonth = meetings.filter(m => {
if (!m.date) return false;
const mDate = new Date(m.date);
return mDate.getFullYear() === currentYear && mDate.getMonth() === currentMonth;
}).length;
DOM.statMeetingsMonth.textContent = meetingsThisMonth;
// Stat: Average Honor
const totalHonor = friends.reduce((sum, f) => sum + (f.honor || 0), 0);
const avgHonor = friends.length > 0 ? Math.round(totalHonor / friends.length) : 0;
DOM.statAvgHonor.textContent = avgHonor;
// Stat: Upcoming Birthdays count
const upcomingBdays = friends.filter(f => {
if (!f.birthday) return false;
const days = getDaysUntilBirthday(f.birthday);
return days !== null && days >= 0 && days <= 30;
});
DOM.statUpcomingBirthdays.textContent = upcomingBdays.length;
// 2. RENDER URGENT CONTACTS ("Lange nicht gesehen")
DOM.urgentContactsList.innerHTML = '';
if (friends.length === 0) {
DOM.urgentContactsList.innerHTML = '<p class="empty-list-desc">Noch keine Freunde eingetragen.</p>';
} else {
// Sort friends: those never met first, then those met longest ago
const sortedUrgent = [...friends].sort((a, b) => {
if (!a.last_meeting_date && !b.last_meeting_date) return a.name.localeCompare(b.name);
if (!a.last_meeting_date) return -1; // a first
if (!b.last_meeting_date) return 1; // b first
return new Date(a.last_meeting_date) - new Date(b.last_meeting_date);
});
// Show top 5 urgent contacts
sortedUrgent.slice(0, 5).forEach(f => {
const item = document.createElement('div');
item.className = 'list-item-glass';
let subText = '';
let warningClass = '';
if (!f.last_meeting_date) {
subText = 'Noch nie getroffen';
warningClass = 'urgent';
} else {
const days = getDaysSince(f.last_meeting_date);
subText = `Zuletzt vor ${days} Tagen getroffen (${formatGermanDate(f.last_meeting_date)})`;
if (days > 60) warningClass = 'urgent';
else if (days > 30) warningClass = 'warning';
}
const initials = getInitials(f.name);
item.innerHTML = `
<div class="list-item-left">
<div class="list-avatar">${initials}</div>
<div class="list-meta">
<span class="list-item-name">${f.name}</span>
<span class="list-item-sub ${warningClass}"><i data-lucide="clock"></i> ${subText}</span>
</div>
</div>
<button class="btn btn-secondary btn-sm btn-view-profile" data-id="${f.id}">
<i data-lucide="eye"></i> Profil
</button>
`;
DOM.urgentContactsList.appendChild(item);
});
// Bind click triggers
DOM.urgentContactsList.querySelectorAll('.btn-view-profile').forEach(btn => {
btn.addEventListener('click', (e) => {
const id = e.currentTarget.getAttribute('data-id');
openFriendDetail(id);
});
});
}
// 3. RENDER UPCOMING BIRTHDAYS PANEL
DOM.upcomingBirthdaysList.innerHTML = '';
if (upcomingBdays.length === 0) {
DOM.upcomingBirthdaysList.innerHTML = '<p class="empty-list-desc">Keine Geburtstage in den nächsten 30 Tagen.</p>';
} else {
// Sort upcoming by closest day
upcomingBdays.sort((a, b) => getDaysUntilBirthday(a.birthday) - getDaysUntilBirthday(b.birthday));
upcomingBdays.forEach(f => {
const days = getDaysUntilBirthday(f.birthday);
const age = getAgeTurning(f.birthday);
const item = document.createElement('div');
item.className = 'list-item-glass';
let dayString = '';
if (days === 0) {
dayString = 'Heute! 🎉';
} else if (days === 1) {
dayString = 'Morgen!';
} else {
dayString = `in ${days} Tagen`;
}
const initials = getInitials(f.name);
const cleanBday = formatGermanDate(f.birthday, false); // format without year or with dots
item.innerHTML = `
<div class="list-item-left">
<div class="list-avatar birthday-avatar"><i data-lucide="cake"></i></div>
<div class="list-meta">
<span class="list-item-name">${f.name}</span>
<span class="list-item-sub birthday-text">${cleanBday} &bull; wird <strong>${age}</strong> (${dayString})</span>
</div>
</div>
<button class="btn btn-secondary btn-sm btn-view-profile" data-id="${f.id}">
<i data-lucide="gift"></i> Profil
</button>
`;
DOM.upcomingBirthdaysList.appendChild(item);
});
// Bind click triggers
DOM.upcomingBirthdaysList.querySelectorAll('.btn-view-profile').forEach(btn => {
btn.addEventListener('click', (e) => {
const id = e.currentTarget.getAttribute('data-id');
openFriendDetail(id);
});
});
}
// 4. POPULATE QUICK LOG FRIEND SELECT
DOM.quickFriendSelect.innerHTML = '<option value="">Wähle einen Freund...</option>';
friends.forEach(f => {
const opt = document.createElement('option');
opt.value = f.id;
opt.textContent = f.name;
DOM.quickFriendSelect.appendChild(opt);
});
// Initialize Lucide Icons
lucide.createIcons();
}
// --- DYNAMIC RENDERING: FRIENDS DIRECTORY ---
function renderFriendsDirectory(searchQuery = '') {
const grid = DOM.friendsGrid;
grid.innerHTML = '';
const query = searchQuery.trim().toLowerCase();
const filtered = state.friends.filter(f => {
if (!query) return true;
return (f.name || '').toLowerCase().includes(query) ||
(f.address || '').toLowerCase().includes(query) ||
(f.hobbies || '').toLowerCase().includes(query) ||
(f.job || '').toLowerCase().includes(query);
});
if (filtered.length === 0) {
grid.innerHTML = `
<div class="empty-state glass-panel">
<i data-lucide="search-code" class="empty-icon"></i>
<h3>Keine Freunde gefunden</h3>
<p>Passe deinen Suchbegriff an oder füge einen neuen Freund hinzu.</p>
</div>
`;
lucide.createIcons();
return;
}
filtered.forEach(f => {
const card = document.createElement('div');
card.className = 'friend-card glass-panel';
card.setAttribute('data-id', f.id);
const initials = getInitials(f.name);
// Residential town extraction (last line of address or simply cut address)
let town = 'Unbekannt';
if (f.address) {
const addressLines = f.address.split('\n');
town = addressLines[addressLines.length - 1].trim();
}
// Format last meeting subtext
let lastMeetSub = 'Noch nie getroffen';
if (f.last_meeting_date) {
lastMeetSub = `Zuletzt: ${formatGermanDate(f.last_meeting_date)}`;
}
card.innerHTML = `
<div class="friend-card-header">
<div class="friend-avatar">${initials}</div>
<div class="friend-card-title">
<h3>${f.name}</h3>
<span class="friend-card-location"><i data-lucide="map-pin"></i> ${town}</span>
</div>
</div>
<div class="friend-card-body">
<div class="friend-badge-row">
<span class="card-badge info"><i data-lucide="award"></i> Ehre: <strong class="honor-card-val">${f.honor || 0}</strong></span>
${f.birthday ? `<span class="card-badge"><i data-lucide="cake"></i> ${formatGermanDate(f.birthday, false)}</span>` : ''}
</div>
<p class="last-meet-tag"><i data-lucide="calendar"></i> ${lastMeetSub}</p>
</div>
<div class="friend-card-footer">
<div class="quick-honor-card-buttons">
<button class="btn-honor-card card-minus" data-action="minus"><i data-lucide="minus"></i></button>
<button class="btn-honor-card card-plus" data-action="plus"><i data-lucide="plus"></i></button>
</div>
<button class="btn btn-primary btn-sm btn-view-details"><i data-lucide="user"></i> Profil</button>
</div>
`;
// Event Listeners for Card Items
card.querySelector('.btn-view-details').addEventListener('click', () => openFriendDetail(f.id));
// Card Honor +/- button handlers
card.querySelectorAll('.btn-honor-card').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const action = btn.getAttribute('data-action');
const change = action === 'plus' ? 1 : -1;
const valDisplay = card.querySelector('.honor-card-val');
try {
const res = await fetch(`/api/friends/${f.id}/honor`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ change })
});
const data = await res.json();
if (res.ok) {
// Update state locally
const friendIndex = state.friends.findIndex(x => x.id === f.id);
if (friendIndex !== -1) {
state.friends[friendIndex].honor = data.honor;
}
// Direct UI bump animation
valDisplay.textContent = data.honor;
valDisplay.classList.add('bump-animate');
setTimeout(() => valDisplay.classList.remove('bump-animate'), 300);
// Refresh dashboard in background without full render flashes
renderDashboard();
}
} catch (err) {
console.error(err);
}
});
});
grid.appendChild(card);
});
lucide.createIcons();
}
// --- DYNAMIC RENDERING: FRIEND PROFILE DETAIL ---
async function openFriendDetail(friendId) {
showModal(DOM.detailModal);
await refreshFriendDetails(friendId);
}
function populateFriendDetails(friend) {
DOM.detailAvatar.textContent = getInitials(friend.name);
DOM.detailName.textContent = friend.name;
// Relationship badge
DOM.detailRelationshipBadge.textContent = friend.relationship_status || 'Kein Beziehungsstatus';
if (friend.relationship_status && friend.relationship_status.toUpperCase().includes('ALONE')) {
DOM.detailRelationshipBadge.className = 'relationship-badge forever-alone';
} else {
DOM.detailRelationshipBadge.className = 'relationship-badge';
}
DOM.detailHonorValue.textContent = friend.honor || 0;
// Formatting General details
let bdayText = '-';
if (friend.birthday) {
const days = getDaysUntilBirthday(friend.birthday);
const age = getAgeTurning(friend.birthday);
let daysRemainingStr = '';
if (days === 0) daysRemainingStr = ' (Heute! 🎉)';
else if (days === 1) daysRemainingStr = ' (Morgen!)';
else daysRemainingStr = ` (in ${days} Tagen, wird ${age})`;
bdayText = `${formatGermanDate(friend.birthday)} ${daysRemainingStr}`;
}
DOM.detailBirthday.textContent = bdayText;
DOM.detailContact.textContent = friend.contact || '-';
DOM.detailAddress.textContent = friend.address || '-';
DOM.detailFamily.textContent = friend.family || '-';
// Job, Hobbies, Milestones
DOM.detailLife.innerHTML = formatMarkdownParagraphs(friend.life_situation || '-');
let hobbiesHtml = '-';
if (friend.hobbies) {
hobbiesHtml = friend.hobbies.split(',').map(h => `<span class="hobby-tag">${h.trim()}</span>`).join(' ');
} else if (friend.job) {
hobbiesHtml = `<span class="hobby-tag">${friend.job}</span>`;
}
DOM.detailHobbies.innerHTML = hobbiesHtml;
DOM.detailMilestones.innerHTML = formatMarkdownParagraphs(friend.milestones || '-');
// Food & Random Notes
DOM.detailFood.textContent = friend.food_preferences || '-';
DOM.detailNotes.innerHTML = formatMarkdownParagraphs(friend.random_notes || '-');
// RENDER TOPICS CHECKLIST
renderTopics(friend.topics || []);
// RENDER MEETINGS TIMELINE
renderTimeline(friend.meetings || []);
lucide.createIcons();
}
function renderTopics(topics) {
const list = DOM.detailTopicsList;
list.innerHTML = '';
if (topics.length === 0) {
list.innerHTML = '<li class="empty-list-desc">Keine offenen Gesprächsthemen.</li>';
return;
}
topics.forEach(t => {
const li = document.createElement('li');
li.className = `topic-item ${t.completed ? 'completed' : ''}`;
li.innerHTML = `
<label class="topic-checkbox-label">
<input type="checkbox" class="topic-checkbox" data-id="${t.id}" ${t.completed ? 'checked' : ''}>
<span class="custom-checkbox"></span>
<span class="topic-text">${t.topic}</span>
</label>
<button class="btn-delete-topic" data-id="${t.id}"><i data-lucide="trash-2"></i></button>
`;
// Checkbox toggle logic
li.querySelector('.topic-checkbox').addEventListener('change', async (e) => {
const checked = e.target.checked;
try {
const res = await fetch(`/api/topics/${t.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: checked ? 1 : 0 })
});
if (res.ok) {
li.classList.toggle('completed', checked);
await refreshAllData();
}
} catch (err) {
console.error(err);
}
});
// Delete topic logic
li.querySelector('.btn-delete-topic').addEventListener('click', async () => {
try {
const res = await fetch(`/api/topics/${t.id}`, { method: 'DELETE' });
if (res.ok) {
li.remove();
await refreshFriendDetails(state.selectedFriend.id);
await refreshAllData();
}
} catch (err) {
console.error(err);
}
});
list.appendChild(li);
});
lucide.createIcons();
}
function renderTimeline(meetings) {
const timeline = DOM.detailMeetingsTimeline;
timeline.innerHTML = '';
if (meetings.length === 0) {
timeline.innerHTML = '<p class="empty-list-desc">Noch keine Treffen dokumentiert.</p>';
return;
}
meetings.forEach(m => {
const item = document.createElement('div');
item.className = 'timeline-item';
let moodBadge = '';
if (m.mood) {
let moodClass = 'mood-default';
const cleanedMood = m.mood.toLowerCase();
if (cleanedMood.includes('legendär') || cleanedMood.includes('super') || cleanedMood.includes('genial')) {
moodClass = 'mood-excellent';
} else if (cleanedMood.includes('entspannt') || cleanedMood.includes('gut') || cleanedMood.includes('zufrieden')) {
moodClass = 'mood-good';
}
moodBadge = `<span class="mood-badge ${moodClass}">${m.mood}</span>`;
}
item.innerHTML = `
<div class="timeline-dot"></div>
<div class="timeline-content glass-panel">
<div class="timeline-header-row">
<span class="timeline-date"><i data-lucide="calendar"></i> ${formatGermanDate(m.date)}</span>
<div class="timeline-actions">
${moodBadge}
<button class="btn-delete-meeting" data-id="${m.id}"><i data-lucide="trash-2"></i></button>
</div>
</div>
<h4 class="timeline-activity">${m.activity}</h4>
${m.details ? `<p class="timeline-details">${m.details.replace(/\n/g, '<br>')}</p>` : ''}
</div>
`;
// Delete meeting logic
item.querySelector('.btn-delete-meeting').addEventListener('click', async () => {
const confirmDel = confirm('Willst du diesen Treffen-Eintrag unwiderruflich löschen?');
if (!confirmDel) return;
try {
const res = await fetch(`/api/meetings/${m.id}`, { method: 'DELETE' });
if (res.ok) {
item.remove();
await refreshFriendDetails(state.selectedFriend.id);
await refreshAllData();
}
} catch (err) {
console.error(err);
}
});
timeline.appendChild(item);
});
lucide.createIcons();
}
// --- EHRE COUNTER ADJUSTMENT ---
async function adjustHonor(change) {
if (!state.selectedFriend) return;
const f = state.selectedFriend;
const valDisplay = DOM.detailHonorValue;
try {
const res = await fetch(`/api/friends/${f.id}/honor`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ change })
});
const data = await res.json();
if (res.ok) {
f.honor = data.honor;
valDisplay.textContent = data.honor;
// Animate counter
valDisplay.classList.add('bump-animate');
setTimeout(() => valDisplay.classList.remove('bump-animate'), 300);
// Update main state and refresh background views
const fIndex = state.friends.findIndex(x => x.id === f.id);
if (fIndex !== -1) {
state.friends[fIndex].honor = data.honor;
}
renderDashboard();
renderFriendsDirectory();
}
} catch (err) {
console.error(err);
}
}
// --- GENERAL HELPER FUNCTIONS ---
function openModal(modal) {
modal.classList.add('active');
document.body.style.overflow = 'hidden'; // prevent bg scroll
}
function closeModal(modal) {
modal.classList.remove('active');
document.body.style.overflow = '';
}
function showModal(modal) {
openModal(modal);
}
function setFormDefaultDates() {
const todayStr = new Date().toISOString().split('T')[0];
DOM.quickDate.value = todayStr;
DOM.profileMeetingDate.value = todayStr;
}
function showGlobalLoaders() {
DOM.urgentContactsList.innerHTML = '<div class="loading-spinner"></div>';
DOM.upcomingBirthdaysList.innerHTML = '<div class="loading-spinner"></div>';
DOM.friendsGrid.innerHTML = '<div class="loading-spinner"></div>';
}
function getInitials(name) {
if (!name) return '?';
const parts = name.trim().split(/\s+/);
if (parts.length === 1) return parts[0].substring(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
function getDaysSince(dateStr) {
const diff = new Date() - new Date(dateStr);
return Math.floor(diff / (1000 * 60 * 60 * 24));
}
function formatGermanDate(dateStr, includeYear = true) {
if (!dateStr) return '';
const parts = dateStr.split('-');
if (parts.length !== 3) return dateStr;
const day = parts[2];
const month = parts[1];
const year = parts[0];
const months = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
const monthIndex = parseInt(month, 10) - 1;
if (includeYear) {
return `${day}. ${months[monthIndex]} ${year}`;
} else {
return `${day}. ${months[monthIndex].substring(0, 3)}`;
}
}
function getDaysUntilBirthday(birthdayStr) {
if (!birthdayStr) return null;
const parts = birthdayStr.split('-');
if (parts.length !== 3) return null;
const birthMonth = parseInt(parts[1], 10) - 1;
const birthDay = parseInt(parts[2], 10);
const today = new Date();
// Midnight check for perfect calculation
today.setHours(0,0,0,0);
const currentYear = today.getFullYear();
let nextBday = new Date(currentYear, birthMonth, birthDay);
nextBday.setHours(0,0,0,0);
if (nextBday < today) {
nextBday.setFullYear(currentYear + 1);
}
const oneDay = 24 * 60 * 60 * 1000;
const diffDays = Math.round((nextBday.getTime() - today.getTime()) / oneDay);
return diffDays;
}
function getAgeTurning(birthdayStr) {
if (!birthdayStr) return null;
const parts = birthdayStr.split('-');
if (parts.length !== 3) return null;
const birthYear = parseInt(parts[0], 10);
const birthMonth = parseInt(parts[1], 10) - 1;
const birthDay = parseInt(parts[2], 10);
const today = new Date();
const currentYear = today.getFullYear();
let nextBday = new Date(currentYear, birthMonth, birthDay);
if (nextBday < today) {
return currentYear + 1 - birthYear;
}
return currentYear - birthYear;
}
function formatMarkdownParagraphs(text) {
if (!text) return '';
return text
.split('\n\n')
.map(p => {
let cleaned = p.trim();
if (!cleaned) return '';
// Basic list items formatting if it starts with hyphen
if (cleaned.startsWith('-')) {
const listItems = cleaned.split('\n').map(li => `<li>${li.replace(/^-/, '').trim()}</li>`).join('');
return `<ul>${listItems}</ul>`;
}
return `<p>${cleaned.replace(/\n/g, '<br>')}</p>`;
})
.join('');
}
// --- BOOTSTRAP APP ---
init();
});