diff --git a/backend/controllers/calendarController.js b/backend/controllers/calendarController.js index f9c7abd..53c9a73 100644 --- a/backend/controllers/calendarController.js +++ b/backend/controllers/calendarController.js @@ -140,5 +140,64 @@ export default { console.error('Calendar getFriendsBirthdays:', error); res.status(500).json({ error: error.message || 'Internal server error' }); } + }, + + /** + * GET /api/calendar/widget/birthdays + * Get upcoming birthdays for widget display + */ + async getWidgetBirthdays(req, res) { + const hashedUserId = getHashedUserId(req); + if (!hashedUserId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + try { + const limit = parseInt(req.query.limit) || 10; + const birthdays = await calendarService.getUpcomingBirthdays(hashedUserId, limit); + res.json(birthdays); + } catch (error) { + console.error('Calendar getWidgetBirthdays:', error); + res.status(500).json({ error: error.message || 'Internal server error' }); + } + }, + + /** + * GET /api/calendar/widget/upcoming + * Get upcoming events for widget display + */ + async getWidgetUpcoming(req, res) { + const hashedUserId = getHashedUserId(req); + if (!hashedUserId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + try { + const limit = parseInt(req.query.limit) || 10; + const events = await calendarService.getUpcomingEvents(hashedUserId, limit); + res.json(events); + } catch (error) { + console.error('Calendar getWidgetUpcoming:', error); + res.status(500).json({ error: error.message || 'Internal server error' }); + } + }, + + /** + * GET /api/calendar/widget/mini + * Get mini calendar data for widget display + */ + async getWidgetMiniCalendar(req, res) { + const hashedUserId = getHashedUserId(req); + if (!hashedUserId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + try { + const data = await calendarService.getMiniCalendarData(hashedUserId); + res.json(data); + } catch (error) { + console.error('Calendar getWidgetMiniCalendar:', error); + res.status(500).json({ error: error.message || 'Internal server error' }); + } } }; diff --git a/backend/routers/calendarRouter.js b/backend/routers/calendarRouter.js index 7df3853..7009650 100644 --- a/backend/routers/calendarRouter.js +++ b/backend/routers/calendarRouter.js @@ -12,4 +12,9 @@ router.put('/events/:id', authenticate, calendarController.updateEvent); router.delete('/events/:id', authenticate, calendarController.deleteEvent); router.get('/birthdays', authenticate, calendarController.getFriendsBirthdays); +// Widget endpoints +router.get('/widget/birthdays', authenticate, calendarController.getWidgetBirthdays); +router.get('/widget/upcoming', authenticate, calendarController.getWidgetUpcoming); +router.get('/widget/mini', authenticate, calendarController.getWidgetMiniCalendar); + export default router; diff --git a/backend/services/calendarService.js b/backend/services/calendarService.js index e7954df..93797d7 100644 --- a/backend/services/calendarService.js +++ b/backend/services/calendarService.js @@ -274,6 +274,216 @@ class CalendarService { return age; } + /** + * Get upcoming birthdays for widget (sorted by next occurrence) + */ + async getUpcomingBirthdays(hashedUserId, limit = 10) { + const user = await User.findOne({ where: { hashedId: hashedUserId } }); + if (!user) { + throw new Error('User not found'); + } + + const userAge = await this.getUserAge(user.id); + const today = new Date(); + const currentYear = today.getFullYear(); + + // Get all accepted friendships + const friendships = await Friendship.findAll({ + where: { + accepted: true, + withdrawn: false, + denied: false, + [Op.or]: [ + { user1Id: user.id }, + { user2Id: user.id } + ] + } + }); + + const birthdays = []; + + for (const friendship of friendships) { + const friendId = friendship.user1Id === user.id ? friendship.user2Id : friendship.user1Id; + + const birthdateParam = await UserParam.findOne({ + where: { userId: friendId }, + include: [ + { + model: UserParamType, + as: 'paramType', + where: { description: 'birthdate' } + }, + { + model: UserParamVisibility, + as: 'param_visibilities', + include: [{ + model: UserParamVisibilityType, + as: 'visibility_type' + }] + } + ] + }); + + if (!birthdateParam || !birthdateParam.value) continue; + + const visibility = birthdateParam.param_visibilities?.[0]?.visibility_type?.description || 'Invisible'; + if (!this.isBirthdayVisibleToFriend(visibility, userAge)) continue; + + const friend = await User.findOne({ + where: { id: friendId }, + attributes: ['username', 'hashedId'] + }); + + if (!friend) continue; + + const birthdate = new Date(birthdateParam.value); + if (isNaN(birthdate.getTime())) continue; + + // Calculate next birthday + let nextBirthday = new Date(currentYear, birthdate.getMonth(), birthdate.getDate()); + if (nextBirthday < today) { + nextBirthday = new Date(currentYear + 1, birthdate.getMonth(), birthdate.getDate()); + } + + // Calculate days until birthday + const diffTime = nextBirthday.getTime() - today.getTime(); + const daysUntil = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + // Calculate age they will turn + const turningAge = nextBirthday.getFullYear() - birthdate.getFullYear(); + + birthdays.push({ + username: friend.username, + hashedId: friend.hashedId, + date: `${String(birthdate.getMonth() + 1).padStart(2, '0')}-${String(birthdate.getDate()).padStart(2, '0')}`, + nextDate: nextBirthday.toISOString().split('T')[0], + daysUntil, + turningAge + }); + } + + // Sort by days until birthday + birthdays.sort((a, b) => a.daysUntil - b.daysUntil); + + return birthdays.slice(0, limit); + } + + /** + * Get upcoming events for widget + */ + async getUpcomingEvents(hashedUserId, limit = 10) { + const user = await User.findOne({ where: { hashedId: hashedUserId } }); + if (!user) { + throw new Error('User not found'); + } + + const today = new Date(); + const todayStr = today.toISOString().split('T')[0]; + + const events = await CalendarEvent.findAll({ + where: { + userId: user.id, + [Op.or]: [ + { startDate: { [Op.gte]: todayStr } }, + { endDate: { [Op.gte]: todayStr } } + ] + }, + order: [['startDate', 'ASC'], ['startTime', 'ASC']], + limit + }); + + return events.map(e => ({ + id: e.id, + titel: e.title, + datum: e.startDate, + beschreibung: e.description || null, + categoryId: e.categoryId, + allDay: e.allDay, + startTime: e.startTime ? e.startTime.substring(0, 5) : null, + endDate: e.endDate + })); + } + + /** + * Get mini calendar data for widget + */ + async getMiniCalendarData(hashedUserId) { + const user = await User.findOne({ where: { hashedId: hashedUserId } }); + if (!user) { + throw new Error('User not found'); + } + + const today = new Date(); + const year = today.getFullYear(); + const month = today.getMonth(); + + // Get first and last day of month + const firstDay = new Date(year, month, 1); + const lastDay = new Date(year, month + 1, 0); + + const startStr = firstDay.toISOString().split('T')[0]; + const endStr = lastDay.toISOString().split('T')[0]; + + // Get user events for this month + const events = await CalendarEvent.findAll({ + where: { + userId: user.id, + [Op.or]: [ + { startDate: { [Op.between]: [startStr, endStr] } }, + { endDate: { [Op.between]: [startStr, endStr] } }, + { + [Op.and]: [ + { startDate: { [Op.lte]: startStr } }, + { endDate: { [Op.gte]: endStr } } + ] + } + ] + } + }); + + // Get birthdays for this month + const birthdays = await this.getFriendsBirthdays(hashedUserId, year); + const monthBirthdays = birthdays.filter(b => { + const bMonth = parseInt(b.startDate.split('-')[1]); + return bMonth === month + 1; + }); + + // Build days with events + const daysWithEvents = {}; + + for (const event of events) { + const start = new Date(event.startDate); + const end = event.endDate ? new Date(event.endDate) : start; + + for (let d = new Date(start); d <= end && d <= lastDay; d.setDate(d.getDate() + 1)) { + if (d >= firstDay) { + const dayNum = d.getDate(); + if (!daysWithEvents[dayNum]) { + daysWithEvents[dayNum] = { events: 0, birthdays: 0 }; + } + daysWithEvents[dayNum].events++; + } + } + } + + for (const birthday of monthBirthdays) { + const dayNum = parseInt(birthday.startDate.split('-')[2]); + if (!daysWithEvents[dayNum]) { + daysWithEvents[dayNum] = { events: 0, birthdays: 0 }; + } + daysWithEvents[dayNum].birthdays++; + } + + return { + year, + month: month + 1, + today: today.getDate(), + firstDayOfWeek: firstDay.getDay() === 0 ? 7 : firstDay.getDay(), // Monday = 1 + daysInMonth: lastDay.getDate(), + daysWithEvents + }; + } + /** * Format event for API response */ diff --git a/backend/utils/initializeWidgetTypes.js b/backend/utils/initializeWidgetTypes.js index 157e675..5a4720b 100644 --- a/backend/utils/initializeWidgetTypes.js +++ b/backend/utils/initializeWidgetTypes.js @@ -3,7 +3,10 @@ import WidgetType from '../models/type/widget_type.js'; const DEFAULT_WIDGET_TYPES = [ { label: 'Termine', endpoint: '/api/termine', description: 'Bevorstehende Termine', orderId: 1 }, { label: 'Falukant', endpoint: '/api/falukant/dashboard-widget', description: 'Charakter, Geld, Nachrichten, Kinder', orderId: 2 }, - { label: 'News', endpoint: '/api/news?language=de&category=top', description: 'Nachrichten (newsdata.io), Counter für Pagination', orderId: 3 } + { label: 'News', endpoint: '/api/news?language=de&category=top', description: 'Nachrichten (newsdata.io), Counter für Pagination', orderId: 3 }, + { label: 'Geburtstage', endpoint: '/api/calendar/widget/birthdays', description: 'Nächste Geburtstage von Freunden', orderId: 4 }, + { label: 'Nächste Termine', endpoint: '/api/calendar/widget/upcoming', description: 'Anstehende Kalendertermine', orderId: 5 }, + { label: 'Kalender', endpoint: '/api/calendar/widget/mini', description: 'Mini-Kalenderansicht', orderId: 6 } ]; /** diff --git a/frontend/src/components/DashboardWidget.vue b/frontend/src/components/DashboardWidget.vue index 0488f97..1a82604 100644 --- a/frontend/src/components/DashboardWidget.vue +++ b/frontend/src/components/DashboardWidget.vue @@ -25,18 +25,24 @@ import apiClient from '@/utils/axios.js'; import FalukantWidget from './widgets/FalukantWidget.vue'; import NewsWidget from './widgets/NewsWidget.vue'; import ListWidget from './widgets/ListWidget.vue'; +import BirthdayWidget from './widgets/BirthdayWidget.vue'; +import UpcomingEventsWidget from './widgets/UpcomingEventsWidget.vue'; +import MiniCalendarWidget from './widgets/MiniCalendarWidget.vue'; function getWidgetComponent(endpoint) { if (!endpoint || typeof endpoint !== 'string') return ListWidget; const ep = endpoint.toLowerCase(); if (ep.includes('falukant')) return FalukantWidget; if (ep.includes('news')) return NewsWidget; + if (ep.includes('calendar/widget/birthdays')) return BirthdayWidget; + if (ep.includes('calendar/widget/upcoming')) return UpcomingEventsWidget; + if (ep.includes('calendar/widget/mini')) return MiniCalendarWidget; return ListWidget; } export default { name: 'DashboardWidget', - components: { FalukantWidget, NewsWidget, ListWidget }, + components: { FalukantWidget, NewsWidget, ListWidget, BirthdayWidget, UpcomingEventsWidget, MiniCalendarWidget }, props: { widgetId: { type: String, required: true }, title: { type: String, required: true }, diff --git a/frontend/src/components/widgets/BirthdayWidget.vue b/frontend/src/components/widgets/BirthdayWidget.vue new file mode 100644 index 0000000..0247ea0 --- /dev/null +++ b/frontend/src/components/widgets/BirthdayWidget.vue @@ -0,0 +1,122 @@ + + + + 🎂 + + {{ birthday.username }} + + Heute! + Morgen + {{ formatDate(birthday.nextDate) }} + (wird {{ birthday.turningAge }}) + + + + {{ birthday.daysUntil }} Tage + + + + + Keine Geburtstage von Freunden sichtbar + + + + + + diff --git a/frontend/src/components/widgets/MiniCalendarWidget.vue b/frontend/src/components/widgets/MiniCalendarWidget.vue new file mode 100644 index 0000000..4128d22 --- /dev/null +++ b/frontend/src/components/widgets/MiniCalendarWidget.vue @@ -0,0 +1,213 @@ + + + + {{ monthName }} {{ calendarData.year }} + + + {{ day }} + + + + + {{ day }} + + + + + + + + + Zum Kalender → + + + + Kalender wird geladen... + + + + + + diff --git a/frontend/src/components/widgets/UpcomingEventsWidget.vue b/frontend/src/components/widgets/UpcomingEventsWidget.vue new file mode 100644 index 0000000..7408d7b --- /dev/null +++ b/frontend/src/components/widgets/UpcomingEventsWidget.vue @@ -0,0 +1,154 @@ + + + + + + {{ event.titel }} + + {{ formatDate(event.datum) }} + + {{ event.startTime }} Uhr + + Ganztägig + + {{ event.beschreibung }} + + + + + Keine anstehenden Termine + + + + + +