mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-25 10:34:39 +02:00
✨ feat(calendar): add complete calendar app with backend, web, and landing
- NestJS backend with Drizzle ORM (port 3014) - Calendar, Event, Reminder, Share, Sync modules - Full CRUD API endpoints - PostgreSQL database schema (5 tables) - SvelteKit web app with Svelte 5 runes (port 5179) - Week, Day, Month views - Agenda list view - Event management (create, edit, delete) - Calendar management - Auth integration with Mana Core Auth - i18n support (DE, EN, FR, ES, IT) - Astro landing page (port 4322) - Hero, Features, Pricing sections - Responsive dark theme design - @calendar/shared package - TypeScript types for Calendar, Event, Reminder, Share - RFC 5545 RRULE support for recurring events - Full documentation (CLAUDE.md, README.md)
This commit is contained in:
parent
623b1a21b1
commit
00176a25e0
114 changed files with 9433 additions and 0 deletions
242
apps/calendar/packages/shared/src/utils/date.ts
Normal file
242
apps/calendar/packages/shared/src/utils/date.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
/**
|
||||
* Date utility functions for calendar operations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get the start of day for a given date
|
||||
*/
|
||||
export function startOfDay(date: Date): Date {
|
||||
const result = new Date(date);
|
||||
result.setHours(0, 0, 0, 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the end of day for a given date
|
||||
*/
|
||||
export function endOfDay(date: Date): Date {
|
||||
const result = new Date(date);
|
||||
result.setHours(23, 59, 59, 999);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the start of week for a given date
|
||||
* @param date - The date
|
||||
* @param weekStartsOn - 0 = Sunday, 1 = Monday
|
||||
*/
|
||||
export function startOfWeek(date: Date, weekStartsOn: 0 | 1 = 1): Date {
|
||||
const result = new Date(date);
|
||||
const day = result.getDay();
|
||||
const diff = (day < weekStartsOn ? 7 : 0) + day - weekStartsOn;
|
||||
result.setDate(result.getDate() - diff);
|
||||
result.setHours(0, 0, 0, 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the end of week for a given date
|
||||
* @param date - The date
|
||||
* @param weekStartsOn - 0 = Sunday, 1 = Monday
|
||||
*/
|
||||
export function endOfWeek(date: Date, weekStartsOn: 0 | 1 = 1): Date {
|
||||
const result = startOfWeek(date, weekStartsOn);
|
||||
result.setDate(result.getDate() + 6);
|
||||
result.setHours(23, 59, 59, 999);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the start of month for a given date
|
||||
*/
|
||||
export function startOfMonth(date: Date): Date {
|
||||
const result = new Date(date);
|
||||
result.setDate(1);
|
||||
result.setHours(0, 0, 0, 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the end of month for a given date
|
||||
*/
|
||||
export function endOfMonth(date: Date): Date {
|
||||
const result = new Date(date);
|
||||
result.setMonth(result.getMonth() + 1);
|
||||
result.setDate(0);
|
||||
result.setHours(23, 59, 59, 999);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the start of year for a given date
|
||||
*/
|
||||
export function startOfYear(date: Date): Date {
|
||||
const result = new Date(date);
|
||||
result.setMonth(0, 1);
|
||||
result.setHours(0, 0, 0, 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the end of year for a given date
|
||||
*/
|
||||
export function endOfYear(date: Date): Date {
|
||||
const result = new Date(date);
|
||||
result.setMonth(11, 31);
|
||||
result.setHours(23, 59, 59, 999);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add days to a date
|
||||
*/
|
||||
export function addDays(date: Date, days: number): Date {
|
||||
const result = new Date(date);
|
||||
result.setDate(result.getDate() + days);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add weeks to a date
|
||||
*/
|
||||
export function addWeeks(date: Date, weeks: number): Date {
|
||||
return addDays(date, weeks * 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add months to a date
|
||||
*/
|
||||
export function addMonths(date: Date, months: number): Date {
|
||||
const result = new Date(date);
|
||||
result.setMonth(result.getMonth() + months);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add years to a date
|
||||
*/
|
||||
export function addYears(date: Date, years: number): Date {
|
||||
const result = new Date(date);
|
||||
result.setFullYear(result.getFullYear() + years);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two dates are on the same day
|
||||
*/
|
||||
export function isSameDay(date1: Date, date2: Date): boolean {
|
||||
return (
|
||||
date1.getFullYear() === date2.getFullYear() &&
|
||||
date1.getMonth() === date2.getMonth() &&
|
||||
date1.getDate() === date2.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a date is today
|
||||
*/
|
||||
export function isToday(date: Date): boolean {
|
||||
return isSameDay(date, new Date());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a date is in the past
|
||||
*/
|
||||
export function isPast(date: Date): boolean {
|
||||
return date.getTime() < Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a date is in the future
|
||||
*/
|
||||
export function isFuture(date: Date): boolean {
|
||||
return date.getTime() > Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all days in a month as an array
|
||||
*/
|
||||
export function getDaysInMonth(year: number, month: number): Date[] {
|
||||
const days: Date[] = [];
|
||||
const date = new Date(year, month, 1);
|
||||
while (date.getMonth() === month) {
|
||||
days.push(new Date(date));
|
||||
date.setDate(date.getDate() + 1);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of days in a month
|
||||
*/
|
||||
export function getMonthDayCount(year: number, month: number): number {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date range for display
|
||||
*/
|
||||
export function formatDateRange(start: Date, end: Date): string {
|
||||
const startStr = start.toLocaleDateString();
|
||||
const endStr = end.toLocaleDateString();
|
||||
|
||||
if (isSameDay(start, end)) {
|
||||
return startStr;
|
||||
}
|
||||
|
||||
return `${startStr} - ${endStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a time for display (HH:MM)
|
||||
*/
|
||||
export function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an ISO date string to Date
|
||||
*/
|
||||
export function parseISODate(dateString: string | Date): Date {
|
||||
if (dateString instanceof Date) {
|
||||
return dateString;
|
||||
}
|
||||
return new Date(dateString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get week number of the year (ISO 8601)
|
||||
*/
|
||||
export function getWeekNumber(date: Date): number {
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
const dayNum = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array of hours in a day (0-23)
|
||||
*/
|
||||
export function getHoursInDay(): number[] {
|
||||
return Array.from({ length: 24 }, (_, i) => i);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate event duration in minutes
|
||||
*/
|
||||
export function getEventDurationMinutes(start: Date, end: Date): number {
|
||||
return Math.round((end.getTime() - start.getTime()) / (1000 * 60));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two time ranges overlap
|
||||
*/
|
||||
export function doTimeRangesOverlap(
|
||||
start1: Date,
|
||||
end1: Date,
|
||||
start2: Date,
|
||||
end2: Date
|
||||
): boolean {
|
||||
return start1 < end2 && end1 > start2;
|
||||
}
|
||||
3
apps/calendar/packages/shared/src/utils/index.ts
Normal file
3
apps/calendar/packages/shared/src/utils/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Calendar utilities
|
||||
export * from './date';
|
||||
export * from './recurrence';
|
||||
267
apps/calendar/packages/shared/src/utils/recurrence.ts
Normal file
267
apps/calendar/packages/shared/src/utils/recurrence.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
import type { RecurrencePattern, RecurrenceFrequency, Weekday } from '../types/recurrence';
|
||||
import { addDays, addWeeks, addMonths, addYears } from './date';
|
||||
|
||||
/**
|
||||
* Parse an RFC 5545 RRULE string to a RecurrencePattern object
|
||||
*
|
||||
* Example: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;UNTIL=20241231T235959Z"
|
||||
*/
|
||||
export function parseRRule(rrule: string): RecurrencePattern | null {
|
||||
if (!rrule || !rrule.startsWith('FREQ=')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = rrule.split(';');
|
||||
const pattern: RecurrencePattern = {
|
||||
frequency: 'DAILY',
|
||||
};
|
||||
|
||||
for (const part of parts) {
|
||||
const [key, value] = part.split('=');
|
||||
|
||||
switch (key) {
|
||||
case 'FREQ':
|
||||
if (['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'].includes(value)) {
|
||||
pattern.frequency = value as RecurrenceFrequency;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'INTERVAL':
|
||||
pattern.interval = parseInt(value, 10);
|
||||
break;
|
||||
|
||||
case 'BYDAY':
|
||||
pattern.byDay = value.split(',') as Weekday[];
|
||||
break;
|
||||
|
||||
case 'BYMONTHDAY':
|
||||
pattern.byMonthDay = value.split(',').map((d) => parseInt(d, 10));
|
||||
break;
|
||||
|
||||
case 'BYMONTH':
|
||||
pattern.byMonth = value.split(',').map((m) => parseInt(m, 10));
|
||||
break;
|
||||
|
||||
case 'COUNT':
|
||||
pattern.count = parseInt(value, 10);
|
||||
break;
|
||||
|
||||
case 'UNTIL':
|
||||
// Parse UNTIL date (format: YYYYMMDD or YYYYMMDDTHHMMSSZ)
|
||||
const year = parseInt(value.substring(0, 4), 10);
|
||||
const month = parseInt(value.substring(4, 6), 10) - 1;
|
||||
const day = parseInt(value.substring(6, 8), 10);
|
||||
pattern.until = new Date(year, month, day, 23, 59, 59);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return pattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a RecurrencePattern object to an RFC 5545 RRULE string
|
||||
*/
|
||||
export function formatRRule(pattern: RecurrencePattern): string {
|
||||
const parts: string[] = [`FREQ=${pattern.frequency}`];
|
||||
|
||||
if (pattern.interval && pattern.interval > 1) {
|
||||
parts.push(`INTERVAL=${pattern.interval}`);
|
||||
}
|
||||
|
||||
if (pattern.byDay && pattern.byDay.length > 0) {
|
||||
parts.push(`BYDAY=${pattern.byDay.join(',')}`);
|
||||
}
|
||||
|
||||
if (pattern.byMonthDay && pattern.byMonthDay.length > 0) {
|
||||
parts.push(`BYMONTHDAY=${pattern.byMonthDay.join(',')}`);
|
||||
}
|
||||
|
||||
if (pattern.byMonth && pattern.byMonth.length > 0) {
|
||||
parts.push(`BYMONTH=${pattern.byMonth.join(',')}`);
|
||||
}
|
||||
|
||||
if (pattern.count) {
|
||||
parts.push(`COUNT=${pattern.count}`);
|
||||
}
|
||||
|
||||
if (pattern.until) {
|
||||
const date = new Date(pattern.until);
|
||||
const until = `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}${String(date.getDate()).padStart(2, '0')}T235959Z`;
|
||||
parts.push(`UNTIL=${until}`);
|
||||
}
|
||||
|
||||
return parts.join(';');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable description of a recurrence pattern
|
||||
*/
|
||||
export function describeRecurrence(pattern: RecurrencePattern | null): string {
|
||||
if (!pattern) {
|
||||
return 'Does not repeat';
|
||||
}
|
||||
|
||||
const interval = pattern.interval || 1;
|
||||
|
||||
switch (pattern.frequency) {
|
||||
case 'DAILY':
|
||||
if (interval === 1) return 'Daily';
|
||||
return `Every ${interval} days`;
|
||||
|
||||
case 'WEEKLY':
|
||||
if (pattern.byDay && pattern.byDay.length > 0) {
|
||||
if (pattern.byDay.length === 5 && !pattern.byDay.includes('SA') && !pattern.byDay.includes('SU')) {
|
||||
return interval === 1 ? 'Every weekday' : `Every ${interval} weeks on weekdays`;
|
||||
}
|
||||
const days = pattern.byDay.map(dayToLabel).join(', ');
|
||||
return interval === 1 ? `Weekly on ${days}` : `Every ${interval} weeks on ${days}`;
|
||||
}
|
||||
return interval === 1 ? 'Weekly' : `Every ${interval} weeks`;
|
||||
|
||||
case 'MONTHLY':
|
||||
if (pattern.byMonthDay && pattern.byMonthDay.length > 0) {
|
||||
const days = pattern.byMonthDay.join(', ');
|
||||
return interval === 1 ? `Monthly on day ${days}` : `Every ${interval} months on day ${days}`;
|
||||
}
|
||||
return interval === 1 ? 'Monthly' : `Every ${interval} months`;
|
||||
|
||||
case 'YEARLY':
|
||||
return interval === 1 ? 'Yearly' : `Every ${interval} years`;
|
||||
|
||||
default:
|
||||
return 'Custom repeat';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert weekday code to label
|
||||
*/
|
||||
function dayToLabel(day: Weekday): string {
|
||||
const labels: Record<Weekday, string> = {
|
||||
MO: 'Mon',
|
||||
TU: 'Tue',
|
||||
WE: 'Wed',
|
||||
TH: 'Thu',
|
||||
FR: 'Fri',
|
||||
SA: 'Sat',
|
||||
SU: 'Sun',
|
||||
};
|
||||
return labels[day];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert JavaScript day (0-6) to Weekday
|
||||
*/
|
||||
export function jsDateToWeekday(jsDay: number): Weekday {
|
||||
const weekdays: Weekday[] = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
|
||||
return weekdays[jsDay];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Weekday to JavaScript day (0-6)
|
||||
*/
|
||||
export function weekdayToJsDate(weekday: Weekday): number {
|
||||
const weekdays: Weekday[] = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
|
||||
return weekdays.indexOf(weekday);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate occurrences of a recurring event within a date range
|
||||
*
|
||||
* @param startDate - The start date of the first occurrence
|
||||
* @param pattern - The recurrence pattern
|
||||
* @param rangeStart - Start of the date range to generate occurrences for
|
||||
* @param rangeEnd - End of the date range
|
||||
* @param exceptions - Dates to exclude (as ISO strings)
|
||||
* @param maxOccurrences - Maximum number of occurrences to generate
|
||||
*/
|
||||
export function generateOccurrences(
|
||||
startDate: Date,
|
||||
pattern: RecurrencePattern,
|
||||
rangeStart: Date,
|
||||
rangeEnd: Date,
|
||||
exceptions: string[] = [],
|
||||
maxOccurrences: number = 365
|
||||
): Date[] {
|
||||
const occurrences: Date[] = [];
|
||||
const exceptionsSet = new Set(exceptions);
|
||||
const interval = pattern.interval || 1;
|
||||
|
||||
let currentDate = new Date(startDate);
|
||||
let count = 0;
|
||||
|
||||
// Get the maximum end date (either pattern.until or rangeEnd)
|
||||
const maxDate = pattern.until
|
||||
? new Date(Math.min(new Date(pattern.until).getTime(), rangeEnd.getTime()))
|
||||
: rangeEnd;
|
||||
|
||||
while (currentDate <= maxDate && count < maxOccurrences) {
|
||||
// Check if we've exceeded COUNT
|
||||
if (pattern.count && occurrences.length >= pattern.count) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if this date matches the pattern
|
||||
if (matchesPattern(currentDate, pattern)) {
|
||||
// Check if date is in range and not in exceptions
|
||||
if (currentDate >= rangeStart && !exceptionsSet.has(currentDate.toISOString().split('T')[0])) {
|
||||
occurrences.push(new Date(currentDate));
|
||||
}
|
||||
}
|
||||
|
||||
// Move to next potential occurrence
|
||||
currentDate = getNextOccurrence(currentDate, pattern.frequency, interval);
|
||||
count++;
|
||||
}
|
||||
|
||||
return occurrences;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a date matches the recurrence pattern constraints
|
||||
*/
|
||||
function matchesPattern(date: Date, pattern: RecurrencePattern): boolean {
|
||||
// Check BYDAY
|
||||
if (pattern.byDay && pattern.byDay.length > 0) {
|
||||
const dayOfWeek = jsDateToWeekday(date.getDay());
|
||||
if (!pattern.byDay.includes(dayOfWeek)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check BYMONTHDAY
|
||||
if (pattern.byMonthDay && pattern.byMonthDay.length > 0) {
|
||||
if (!pattern.byMonthDay.includes(date.getDate())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check BYMONTH
|
||||
if (pattern.byMonth && pattern.byMonth.length > 0) {
|
||||
if (!pattern.byMonth.includes(date.getMonth() + 1)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next occurrence date based on frequency
|
||||
*/
|
||||
function getNextOccurrence(date: Date, frequency: RecurrenceFrequency, interval: number): Date {
|
||||
switch (frequency) {
|
||||
case 'DAILY':
|
||||
return addDays(date, interval);
|
||||
case 'WEEKLY':
|
||||
// For weekly, always move by 1 day to check each day of the week
|
||||
return addDays(date, 1);
|
||||
case 'MONTHLY':
|
||||
return addMonths(date, interval);
|
||||
case 'YEARLY':
|
||||
return addYears(date, interval);
|
||||
default:
|
||||
return addDays(date, 1);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue