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:
Till-JS 2025-12-02 13:15:04 +01:00
parent 623b1a21b1
commit 00176a25e0
114 changed files with 9433 additions and 0 deletions

View 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;
}

View file

@ -0,0 +1,3 @@
// Calendar utilities
export * from './date';
export * from './recurrence';

View 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);
}
}