fix: recurring event display and AI query improvements
- Use occurrenceStart instead of startTime in getEventsInRange so recurring events show their actual occurrence date to the AI - Add lazy CalDAV sync in ChatService (syncOnce before first DB access) - Add CaldavService.sync() with internal config check (silent no-op) - Show German recurrence description (e.g. "Jede Woche") instead of generic "Wiederkehrend" in EventCardBase via formatRecurrenceRule() - Move RepeatType and REPEAT_TYPE_LABELS from editEvent to shared - Separate calendar overlay useFocusEffect from event loading
This commit is contained in:
16
CLAUDE.md
16
CLAUDE.md
@@ -334,7 +334,7 @@ src/
|
|||||||
├── index.ts
|
├── index.ts
|
||||||
├── dateHelpers.ts # getDay() - get date for specific weekday relative to today
|
├── dateHelpers.ts # getDay() - get date for specific weekday relative to today
|
||||||
├── formatters.ts # formatDate(), formatTime(), formatDateTime(), formatDateWithWeekday() - German locale
|
├── formatters.ts # formatDate(), formatTime(), formatDateTime(), formatDateWithWeekday() - German locale
|
||||||
└── rruleHelpers.ts # parseRRule() - parse RRULE strings to extract freq, until, count, interval, byDay
|
└── rruleHelpers.ts # parseRRule(), buildRRule(), formatRecurrenceRule() - RRULE parsing, building, and German formatting
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key Types:**
|
**Key Types:**
|
||||||
@@ -408,6 +408,10 @@ CalDAV sync with external calendar servers (e.g., Radicale) using `tsdav` and `i
|
|||||||
- **Calendar timer** (`calendar.tsx`): Every 10s while Calendar tab is focused, via `setInterval` in `useFocusEffect`
|
- **Calendar timer** (`calendar.tsx`): Every 10s while Calendar tab is focused, via `setInterval` in `useFocusEffect`
|
||||||
- **Sync button** (`settings.tsx`): Manual trigger in CaldavSettings
|
- **Sync button** (`settings.tsx`): Manual trigger in CaldavSettings
|
||||||
|
|
||||||
|
**Lazy sync (server-side in ChatService):**
|
||||||
|
- AI data access callbacks (`fetchEventsInRange`, `searchEvents`, `fetchEventById`) trigger `syncOnce()` before the first DB query
|
||||||
|
- Uses `CaldavService.sync()` which checks config internally (silent no-op without config)
|
||||||
|
|
||||||
**Single-event sync (server-side in controllers):**
|
**Single-event sync (server-side in controllers):**
|
||||||
- `EventController`: `pushToCaldav()` after create/update, `deleteFromCaldav()` after delete
|
- `EventController`: `pushToCaldav()` after create/update, `deleteFromCaldav()` after delete
|
||||||
- `ChatController`: `pushAll()` after confirming an event proposal
|
- `ChatController`: `pushAll()` after confirming an event proposal
|
||||||
@@ -419,7 +423,7 @@ CalDAV sync with external calendar servers (e.g., Radicale) using `tsdav` and `i
|
|||||||
|
|
||||||
**Architecture:**
|
**Architecture:**
|
||||||
- `CaldavService` depends on `CaldavRepository` (config storage) and `EventService` (event CRUD)
|
- `CaldavService` depends on `CaldavRepository` (config storage) and `EventService` (event CRUD)
|
||||||
- `ChatService` depends only on `EventService` (not EventRepository) for all event operations
|
- `ChatService` depends on `EventService` and `CaldavService` (lazy CalDAV sync on AI data access)
|
||||||
- `EventController` and `ChatController` both receive `CaldavService` for CalDAV push on mutations
|
- `EventController` and `ChatController` both receive `CaldavService` for CalDAV push on mutations
|
||||||
|
|
||||||
### Database Abstraction
|
### Database Abstraction
|
||||||
@@ -568,12 +572,12 @@ NODE_ENV=development # development = pretty logs, production = JSON
|
|||||||
- `utils/recurrenceExpander`: Handles RRULE parsing, strips `RRULE:` prefix if present (AI may include it), filters out exceptionDates
|
- `utils/recurrenceExpander`: Handles RRULE parsing, strips `RRULE:` prefix if present (AI may include it), filters out exceptionDates
|
||||||
- `logging/`: Structured logging with pino, pino-http middleware, @Logged decorator
|
- `logging/`: Structured logging with pino, pino-http middleware, @Logged decorator
|
||||||
- All repositories and GPTAdapter decorated with @Logged for automatic method logging
|
- All repositories and GPTAdapter decorated with @Logged for automatic method logging
|
||||||
- `CaldavService`: Full CalDAV sync (connect, pullEvents, pushEvent, pushAll, deleteEvent, getConfig, saveConfig, deleteConfig)
|
- `CaldavService`: Full CalDAV sync (connect, pullEvents, pushEvent, pushAll, deleteEvent, sync, getConfig, saveConfig, deleteConfig). `sync()` checks config internally and is a silent no-op without config.
|
||||||
- `CaldavController`: REST endpoints for config CRUD, pull, push
|
- `CaldavController`: REST endpoints for config CRUD, pull, push
|
||||||
- `MongoCaldavRepository`: Config persistence with createOrUpdate, findByUserId, deleteByUserId
|
- `MongoCaldavRepository`: Config persistence with createOrUpdate, findByUserId, deleteByUserId
|
||||||
- `EventController`: CalDAV push on create/update, CalDAV delete on delete (via pushToCaldav/deleteFromCaldav helpers)
|
- `EventController`: CalDAV push on create/update, CalDAV delete on delete (via pushToCaldav/deleteFromCaldav helpers)
|
||||||
- `ChatController`: CalDAV pushAll after confirmEvent (ensures chat-created events sync)
|
- `ChatController`: CalDAV pushAll after confirmEvent (ensures chat-created events sync)
|
||||||
- `ChatService`: Refactored to use only EventService (no direct EventRepository dependency)
|
- `ChatService`: Uses EventService + CaldavService (lazy sync on AI data access via syncOnce pattern)
|
||||||
- `EventService`: Extended with searchByTitle(), findByCaldavUUID()
|
- `EventService`: Extended with searchByTitle(), findByCaldavUUID()
|
||||||
- `utils/eventFormatters`: Refactored to use EventService instead of EventRepository
|
- `utils/eventFormatters`: Refactored to use EventService instead of EventRepository
|
||||||
- CORS configured to allow X-User-Id header
|
- CORS configured to allow X-User-Id header
|
||||||
@@ -584,7 +588,7 @@ NODE_ENV=development # development = pretty logs, production = JSON
|
|||||||
|
|
||||||
**Shared:**
|
**Shared:**
|
||||||
- Types, DTOs, constants (Day, Month with German translations), ExpandedEvent type, CaldavConfig, CaldavSyncStatus defined and exported
|
- Types, DTOs, constants (Day, Month with German translations), ExpandedEvent type, CaldavConfig, CaldavSyncStatus defined and exported
|
||||||
- `rruleHelpers.ts`: `parseRRule()` parses RRULE strings using rrule library, returns `ParsedRRule` with freq, until, count, interval, byDay
|
- `rruleHelpers.ts`: `parseRRule()` parses RRULE strings using rrule library, returns `ParsedRRule` with freq, until, count, interval, byDay. `buildRRule()` builds RRULE from RepeatType + interval. `formatRecurrenceRule()` formats RRULE into German description (e.g., "Jede Woche", "Alle 2 Monate"). Exports `REPEAT_TYPE_LABELS` and `RepeatType`.
|
||||||
- `formatters.ts`: German date/time formatters (`formatDate`, `formatTime`, `formatDateTime`, `formatDateWithWeekday`, `formatDateKey`) used by both client and server
|
- `formatters.ts`: German date/time formatters (`formatDate`, `formatTime`, `formatDateTime`, `formatDateWithWeekday`, `formatDateKey`) used by both client and server
|
||||||
- rrule library added as dependency for RRULE parsing
|
- rrule library added as dependency for RRULE parsing
|
||||||
|
|
||||||
@@ -634,7 +638,7 @@ NODE_ENV=development # development = pretty logs, production = JSON
|
|||||||
- `CustomTextInput`: Themed text input component with focus border highlight, supports controlled value via `text` prop
|
- `CustomTextInput`: Themed text input component with focus border highlight, supports controlled value via `text` prop
|
||||||
- `CardBase`: Reusable card component with header (title/subtitle), content area, and optional footer button - configurable padding, border, text size via props, ScrollView uses `nestedScrollEnabled` for Android
|
- `CardBase`: Reusable card component with header (title/subtitle), content area, and optional footer button - configurable padding, border, text size via props, ScrollView uses `nestedScrollEnabled` for Android
|
||||||
- `ModalBase`: Reusable modal wrapper with backdrop (absolute-positioned behind card), uses CardBase internally - provides click-outside-to-close, Android back button support, and proper scrolling on Android
|
- `ModalBase`: Reusable modal wrapper with backdrop (absolute-positioned behind card), uses CardBase internally - provides click-outside-to-close, Android back button support, and proper scrolling on Android
|
||||||
- `EventCardBase`: Event card with date/time/recurring icons - uses CardBase for structure
|
- `EventCardBase`: Event card with date/time/recurring icons - uses CardBase for structure. Accepts `recurrenceRule` string (not boolean) and displays German-formatted recurrence via `formatRecurrenceRule()`
|
||||||
- `EventCard`: Uses EventCardBase + edit/delete buttons (TouchableOpacity with delayPressIn for scroll-friendly touch handling)
|
- `EventCard`: Uses EventCardBase + edit/delete buttons (TouchableOpacity with delayPressIn for scroll-friendly touch handling)
|
||||||
- `ProposedEventCard`: Uses EventCardBase + confirm/reject/edit buttons for chat proposals, displays green highlighted text for new changes ("Neue Ausnahme: [date]" for single delete, "Neues Ende: [date]" for UNTIL updates), shows yellow conflict warnings when proposed time overlaps with existing events. Edit button allows modifying proposals before confirming.
|
- `ProposedEventCard`: Uses EventCardBase + confirm/reject/edit buttons for chat proposals, displays green highlighted text for new changes ("Neue Ausnahme: [date]" for single delete, "Neues Ende: [date]" for UNTIL updates), shows yellow conflict warnings when proposed time overlaps with existing events. Edit button allows modifying proposals before confirming.
|
||||||
- `DeleteEventModal`: Delete confirmation modal using ModalBase - shows three options for recurring events (single/future/all), simple confirm for non-recurring
|
- `DeleteEventModal`: Delete confirmation modal using ModalBase - shows three options for recurring events (single/future/all), simple confirm for non-recurring
|
||||||
|
|||||||
@@ -122,17 +122,22 @@ const Calendar = () => {
|
|||||||
// Load events when tab gains focus or month/year changes
|
// Load events when tab gains focus or month/year changes
|
||||||
// NOTE: Wrapper needed because loadEvents is async (returns Promise)
|
// NOTE: Wrapper needed because loadEvents is async (returns Promise)
|
||||||
// and useFocusEffect expects a sync function (optionally returning cleanup)
|
// and useFocusEffect expects a sync function (optionally returning cleanup)
|
||||||
// Also re-open overlay if selectedDate exists (for back navigation from editEvent)
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
loadEvents();
|
loadEvents();
|
||||||
if (selectedDate) {
|
|
||||||
setOverlayVisible(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const interval = setInterval(loadEvents, 10_000);
|
const interval = setInterval(loadEvents, 10_000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [loadEvents, selectedDate]),
|
}, [loadEvents]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-open overlay after back navigation from editEvent
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
if (selectedDate) {
|
||||||
|
setOverlayVisible(true);
|
||||||
|
}
|
||||||
|
}, [selectedDate]),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Group events by date (YYYY-MM-DD format)
|
// Group events by date (YYYY-MM-DD format)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { ScrollableDropdown } from "../components/ScrollableDropdown";
|
import { ScrollableDropdown } from "../components/ScrollableDropdown";
|
||||||
import { useDropdownPosition } from "../hooks/useDropdownPosition";
|
import { useDropdownPosition } from "../hooks/useDropdownPosition";
|
||||||
import { EventService, ChatService } from "../services";
|
import { EventService, ChatService } from "../services";
|
||||||
import { buildRRule, CreateEventDTO } from "@calchat/shared";
|
import { buildRRule, CreateEventDTO, REPEAT_TYPE_LABELS, RepeatType } from "@calchat/shared";
|
||||||
import { useChatStore } from "../stores";
|
import { useChatStore } from "../stores";
|
||||||
import CustomTextInput, {
|
import CustomTextInput, {
|
||||||
CustomTextInputProps,
|
CustomTextInputProps,
|
||||||
@@ -86,15 +86,6 @@ const PickerRow = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type RepeatType = "Tag" | "Woche" | "Monat" | "Jahr";
|
|
||||||
|
|
||||||
const REPEAT_TYPE_LABELS: Record<RepeatType, string> = {
|
|
||||||
Tag: "Tage",
|
|
||||||
Woche: "Wochen",
|
|
||||||
Monat: "Monate",
|
|
||||||
Jahr: "Jahre",
|
|
||||||
};
|
|
||||||
|
|
||||||
type RepeatPressableProps = {
|
type RepeatPressableProps = {
|
||||||
focused: boolean;
|
focused: boolean;
|
||||||
repeatType: RepeatType;
|
repeatType: RepeatType;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
|
|||||||
startTime={event.occurrenceStart}
|
startTime={event.occurrenceStart}
|
||||||
endTime={event.occurrenceEnd}
|
endTime={event.occurrenceEnd}
|
||||||
description={event.description}
|
description={event.description}
|
||||||
isRecurring={!!event.recurrenceRule}
|
recurrenceRule={event.recurrenceRule}
|
||||||
>
|
>
|
||||||
{/* Action buttons - TouchableOpacity with delayPressIn allows ScrollView to detect scroll gestures */}
|
{/* Action buttons - TouchableOpacity with delayPressIn allows ScrollView to detect scroll gestures */}
|
||||||
<View className="flex-row justify-end mt-3 gap-3">
|
<View className="flex-row justify-end mt-3 gap-3">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
formatDateWithWeekday,
|
formatDateWithWeekday,
|
||||||
formatDateWithWeekdayShort,
|
formatDateWithWeekdayShort,
|
||||||
formatTime,
|
formatTime,
|
||||||
|
formatRecurrenceRule,
|
||||||
} from "@calchat/shared";
|
} from "@calchat/shared";
|
||||||
|
|
||||||
type EventCardBaseProps = {
|
type EventCardBaseProps = {
|
||||||
@@ -16,7 +17,7 @@ type EventCardBaseProps = {
|
|||||||
startTime: Date;
|
startTime: Date;
|
||||||
endTime: Date;
|
endTime: Date;
|
||||||
description?: string;
|
description?: string;
|
||||||
isRecurring?: boolean;
|
recurrenceRule?: string;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ export const EventCardBase = ({
|
|||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
description,
|
description,
|
||||||
isRecurring,
|
recurrenceRule,
|
||||||
children,
|
children,
|
||||||
}: EventCardBaseProps) => {
|
}: EventCardBaseProps) => {
|
||||||
const { theme } = useThemeStore();
|
const { theme } = useThemeStore();
|
||||||
@@ -95,7 +96,7 @@ export const EventCardBase = ({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Recurring indicator */}
|
{/* Recurring indicator */}
|
||||||
{isRecurring && (
|
{recurrenceRule && (
|
||||||
<View className="flex-row items-center mb-1">
|
<View className="flex-row items-center mb-1">
|
||||||
<Feather
|
<Feather
|
||||||
name="repeat"
|
name="repeat"
|
||||||
@@ -103,7 +104,9 @@ export const EventCardBase = ({
|
|||||||
color={theme.textPrimary}
|
color={theme.textPrimary}
|
||||||
style={{ marginRight: 8 }}
|
style={{ marginRight: 8 }}
|
||||||
/>
|
/>
|
||||||
<Text style={{ color: theme.textPrimary }}>Wiederkehrend</Text>
|
<Text style={{ color: theme.textPrimary }}>
|
||||||
|
{formatRecurrenceRule(recurrenceRule)}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export const ProposedEventCard = ({
|
|||||||
startTime={event.startTime}
|
startTime={event.startTime}
|
||||||
endTime={event.endTime}
|
endTime={event.endTime}
|
||||||
description={event.description}
|
description={event.description}
|
||||||
isRecurring={!!event.recurrenceRule}
|
recurrenceRule={event.recurrenceRule}
|
||||||
>
|
>
|
||||||
{/* Show new exception date for delete/single actions */}
|
{/* Show new exception date for delete/single actions */}
|
||||||
{newExceptionDate && (
|
{newExceptionDate && (
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ export async function executeToolCall(
|
|||||||
|
|
||||||
const eventsText = events
|
const eventsText = events
|
||||||
.map((e) => {
|
.map((e) => {
|
||||||
const start = new Date(e.startTime);
|
const start = new Date(e.occurrenceStart);
|
||||||
const recurrenceInfo = e.recurrenceRule ? " (wiederkehrend)" : "";
|
const recurrenceInfo = e.recurrenceRule ? " (wiederkehrend)" : "";
|
||||||
return `- ${e.title} (ID: ${e.id}) am ${formatDate(start)} um ${formatTime(start)} Uhr${recurrenceInfo}`;
|
return `- ${e.title} (ID: ${e.id}) am ${formatDate(start)} um ${formatTime(start)} Uhr${recurrenceInfo}`;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -62,12 +62,13 @@ const aiProvider = new GPTAdapter();
|
|||||||
// Initialize services
|
// Initialize services
|
||||||
const authService = new AuthService(userRepo);
|
const authService = new AuthService(userRepo);
|
||||||
const eventService = new EventService(eventRepo);
|
const eventService = new EventService(eventRepo);
|
||||||
|
const caldavService = new CaldavService(caldavRepo, eventService);
|
||||||
const chatService = new ChatService(
|
const chatService = new ChatService(
|
||||||
chatRepo,
|
chatRepo,
|
||||||
eventService,
|
eventService,
|
||||||
aiProvider,
|
aiProvider,
|
||||||
|
caldavService,
|
||||||
);
|
);
|
||||||
const caldavService = new CaldavService(caldavRepo, eventService);
|
|
||||||
|
|
||||||
// Initialize controllers
|
// Initialize controllers
|
||||||
const authController = new AuthController(authService);
|
const authController = new AuthController(authService);
|
||||||
|
|||||||
@@ -257,4 +257,14 @@ export class CaldavService {
|
|||||||
async deleteConfig(userId: string) {
|
async deleteConfig(userId: string) {
|
||||||
return await this.caldavRepo.deleteByUserId(userId);
|
return await this.caldavRepo.deleteByUserId(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync with CalDAV server if config exists. Silent no-op if no config.
|
||||||
|
*/
|
||||||
|
async sync(userId: string): Promise<void> {
|
||||||
|
const config = await this.getConfig(userId);
|
||||||
|
if (!config) return;
|
||||||
|
await this.pushAll(userId);
|
||||||
|
await this.pullEvents(userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "@calchat/shared";
|
} from "@calchat/shared";
|
||||||
import { ChatRepository, AIProvider } from "./interfaces";
|
import { ChatRepository, AIProvider } from "./interfaces";
|
||||||
import { EventService } from "./EventService";
|
import { EventService } from "./EventService";
|
||||||
|
import { CaldavService } from "./CaldavService";
|
||||||
import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters";
|
import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters";
|
||||||
|
|
||||||
type TestResponse = {
|
type TestResponse = {
|
||||||
@@ -543,6 +544,7 @@ export class ChatService {
|
|||||||
private chatRepo: ChatRepository,
|
private chatRepo: ChatRepository,
|
||||||
private eventService: EventService,
|
private eventService: EventService,
|
||||||
private aiProvider: AIProvider,
|
private aiProvider: AIProvider,
|
||||||
|
private caldavService: CaldavService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async processMessage(
|
async processMessage(
|
||||||
@@ -573,17 +575,32 @@ export class ChatService {
|
|||||||
limit: 20,
|
limit: 20,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Lazy CalDAV sync: only sync once when the AI first accesses event data
|
||||||
|
let hasSynced = false;
|
||||||
|
const syncOnce = async () => {
|
||||||
|
if (hasSynced) return;
|
||||||
|
hasSynced = true;
|
||||||
|
try {
|
||||||
|
await this.caldavService.sync(userId);
|
||||||
|
} catch {
|
||||||
|
// CalDAV sync is not critical for AI responses
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
response = await this.aiProvider.processMessage(data.content, {
|
response = await this.aiProvider.processMessage(data.content, {
|
||||||
userId,
|
userId,
|
||||||
conversationHistory: history,
|
conversationHistory: history,
|
||||||
currentDate: new Date(),
|
currentDate: new Date(),
|
||||||
fetchEventsInRange: async (start, end) => {
|
fetchEventsInRange: async (start, end) => {
|
||||||
|
await syncOnce();
|
||||||
return this.eventService.getByDateRange(userId, start, end);
|
return this.eventService.getByDateRange(userId, start, end);
|
||||||
},
|
},
|
||||||
searchEvents: async (query) => {
|
searchEvents: async (query) => {
|
||||||
|
await syncOnce();
|
||||||
return this.eventService.searchByTitle(userId, query);
|
return this.eventService.searchByTitle(userId, query);
|
||||||
},
|
},
|
||||||
fetchEventById: async (eventId) => {
|
fetchEventById: async (eventId) => {
|
||||||
|
await syncOnce();
|
||||||
return this.eventService.getById(eventId, userId);
|
return this.eventService.getById(eventId, userId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,24 @@ const REPEAT_TYPE_TO_FREQ: Record<RepeatType, string> = {
|
|||||||
Jahr: "YEARLY",
|
Jahr: "YEARLY",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FREQ_TO_REPEAT_TYPE: Record<string, RepeatType> = Object.fromEntries(
|
||||||
|
Object.entries(REPEAT_TYPE_TO_FREQ).map(([k, v]) => [v, k as RepeatType]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const REPEAT_TYPE_LABELS: Record<RepeatType, string> = {
|
||||||
|
Tag: "Tage",
|
||||||
|
Woche: "Wochen",
|
||||||
|
Monat: "Monate",
|
||||||
|
Jahr: "Jahre",
|
||||||
|
};
|
||||||
|
|
||||||
|
const REPEAT_TYPE_SINGULAR: Record<RepeatType, string> = {
|
||||||
|
Tag: "Jeden Tag",
|
||||||
|
Woche: "Jede Woche",
|
||||||
|
Monat: "Jeden Monat",
|
||||||
|
Jahr: "Jedes Jahr",
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build an RRULE string from repeat count and type.
|
* Build an RRULE string from repeat count and type.
|
||||||
*
|
*
|
||||||
@@ -27,3 +45,30 @@ export function buildRRule(repeatType: RepeatType, interval: number = 1): string
|
|||||||
|
|
||||||
return `FREQ=${freq};INTERVAL=${interval}`;
|
return `FREQ=${freq};INTERVAL=${interval}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an RRULE string into a human-readable German description.
|
||||||
|
*
|
||||||
|
* @param rrule - RRULE string like "FREQ=WEEKLY;INTERVAL=2"
|
||||||
|
* @returns German description like "Alle 2 Wochen" or "Wöchentlich"
|
||||||
|
*/
|
||||||
|
export function formatRecurrenceRule(rrule: string): string {
|
||||||
|
const rule = rrule.replace(/^RRULE:/, "");
|
||||||
|
const parts = Object.fromEntries(
|
||||||
|
rule.split(";").map((p) => p.split("=") as [string, string]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const freq = parts.FREQ;
|
||||||
|
const interval = parts.INTERVAL ? parseInt(parts.INTERVAL, 10) : 1;
|
||||||
|
const repeatType = FREQ_TO_REPEAT_TYPE[freq];
|
||||||
|
|
||||||
|
if (!repeatType) {
|
||||||
|
return "Wiederkehrend";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interval <= 1) {
|
||||||
|
return REPEAT_TYPE_SINGULAR[repeatType];
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Alle ${interval} ${REPEAT_TYPE_LABELS[repeatType]}`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user