- name
- shiny-calendarstore
- description
- Generate code using Shiny.Calendar for cross-platform device calendar & event access with CRUD, a fluent async query builder, and Shiny.Core permissions
- auto_invoke
- true
- triggers
- ["calendar store","calendar","calendars","calendar event","ICalendarStore","CalendarStore","AddCalendarStore","Shiny.Calendar","device calendar","read calendar","write calendar","create event","update event","delete event","calendar query","CalendarEventQuery","CalendarEventSortField","search events","EventKit","CalendarContract","AppointmentStore","calendar AI tools","Shiny.Calendar.Extensions.AI","AddCalendarAITools","CalendarAITools","CalendarAICapabilities","ICalendarAIToolBuilder","AddCalendars","calendar AI filter","per-calendar AI access"]
# Shiny.Calendar Skill
You are an expert in Shiny.Calendar, a cross-platform library for accessing device calendars and
events on iOS, Mac Catalyst, macOS, Android, and Windows.
## When to Use This Skill
Invoke this skill when the user wants to:
- Access device calendars and events (read, create, update, delete)
- Query events (filter/sort/page over a date window)
- Request calendar permissions using Shiny's AccessState model
- Register the calendar store in DI
- Work with calendar models (events, attendees, reminders)
## Library Overview
**GitHub**: https://github.com/shinyorg/shiny
**NuGet**: `Shiny.Calendar`
**Namespace**: `Shiny.Calendar`
Shiny.Calendar provides:
- Full CRUD operations on device calendars and events
- A fluent async query builder with native fetch translation (calendar id + start/end window are
pushed to the native query; other filters, sorting and paging run in-memory)
- Permission handling via Shiny.Core's `AccessState` model
- Dependency injection integration
- AOT and trimmer compatible
**Platform backends:** EventKit (iOS/Mac Catalyst/macOS), CalendarContract (Android),
`Windows.ApplicationModel.Appointments.AppointmentStore` (Windows).
## Setup
### 1. Install NuGet Package
```bash
dotnet add package Shiny.Calendar
```
### 2. Register in MauiProgram.cs
The app must call `.UseShiny()` (from Shiny.Hosting.Maui) so platform services like permissions are wired up.
```csharp
using Shiny;
builder.UseShiny();
builder.Services.AddCalendarStore();
```
### 3. Platform Permissions
**Android** — Add to `AndroidManifest.xml`:
```xml
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
```
**iOS 17+ / Mac Catalyst / macOS** — Add to `Info.plist`:
```xml
<key>NSCalendarsFullAccessUsageDescription</key>
<string>This app needs access to your calendar.</string>
<!-- If only adding events, you can request write-only access instead: -->
<key>NSCalendarsWriteOnlyAccessUsageDescription</key>
<string>This app needs to add events to your calendar.</string>
```
**iOS < 17** — Add `NSCalendarsUsageDescription`.
**Mac Catalyst / sandboxed macOS** — the `Info.plist` keys are not enough. The App Sandbox (which
Mac Catalyst enables by default) also requires the calendar entitlement, or `RequestAccess` returns
`Denied` with no prompt ever appearing:
```xml
<CustomEntitlements Include="com.apple.security.personal-information.calendars"
Type="Boolean" Value="true" />
```
**Windows** — Add to `Package.appxmanifest`:
```xml
<uap:Capability Name="appointments" />
```
## Permissions
Permissions use Shiny.Core's `Shiny.AccessState` model. `ICalendarStore` exposes:
```csharp
// Request access (triggers OS prompt if needed). Pass the access level you need.
var access = await calendarStore.RequestAccess(CalendarAccessType.ReadWrite);
if (access != AccessState.Available)
return; // handle denied / restricted
// Check current state without prompting
var current = calendarStore.GetCurrentAccess();
```
`CalendarAccessType`: `ReadOnly`, `WriteOnly` (iOS 17+ add-only), `ReadWrite` (default).
- `AccessState.Available` — access granted
- `AccessState.Restricted` — partial (e.g. iOS write-only, or Android read-only/write-only)
- `AccessState.Denied` — denied
- `AccessState.Unknown` — not yet determined (also the Windows `GetCurrentAccess()` result — call `RequestAccess`)
## API Reference
### ICalendarStore Interface
```csharp
public interface ICalendarStore
{
AccessState GetCurrentAccess();
Task<AccessState> RequestAccess(CalendarAccessType accessType = CalendarAccessType.ReadWrite, CancellationToken ct = default);
// Calendars (the entity is implied by the interface name, so no "Calendar" suffix)
Task<IReadOnlyList<Calendar>> GetAll(CancellationToken ct = default);
Task<Calendar?> GetById(string calendarId, CancellationToken ct = default);
Task<string> Create(string name, string? color = null, CancellationToken ct = default);
Task Update(string calendarId, string newName, string? newColor = null, CancellationToken ct = default);
Task Delete(string calendarId, CancellationToken ct = default);
// Events
Task<IReadOnlyList<CalendarEvent>> GetEvents(string? calendarId = null, DateTimeOffset? start = null, DateTimeOffset? end = null, CancellationToken ct = default);
Task<CalendarEvent?> GetEvent(string eventId, CancellationToken ct = default);
CalendarEventQuery Query();
Task<string> CreateEvent(CalendarEvent calendarEvent, CancellationToken ct = default);
Task UpdateEvent(CalendarEvent calendarEvent, CancellationToken ct = default);
Task DeleteEvent(string eventId, bool deleteSeries = false, CancellationToken ct = default);
}
```
### Convenience Extension Methods
```csharp
Task<string> store.CreateEvent(string? calendarId, string title, string? description, string? location,
DateTimeOffset start, DateTimeOffset end, bool isAllDay = false,
IEnumerable<EventReminder>? reminders = null, CancellationToken ct = default);
Task<string> store.CreateAllDayEvent(string? calendarId, string title, string? description, string? location,
DateTimeOffset startDate, DateTimeOffset endDate, CancellationToken ct = default);
// Bridge to Shiny.Contacts — resolve an attendee to a device contact by email
Task<Shiny.Contacts.Contact?> attendee.ResolveContact(IContactStore contactStore, CancellationToken ct = default);
```
### Querying events
`Query()` returns a **`CalendarEventQuery`** builder. It is lazy — nothing runs until `ToListAsync` /
`FirstOrDefaultAsync` / `CountAsync`, and the native calendar read happens off the calling thread, so
awaiting it from a UI/view-model method is correct (do NOT wrap it in `Task.Run`). There is **no
`IQueryable`** — do not write `.Where(e => …).ToList()` LINQ against `Query()`.
```csharp
public sealed class CalendarEventQuery
{
CalendarEventQuery ForCalendar(string? calendarId); // native hint
CalendarEventQuery From(DateTimeOffset start); // native hint
CalendarEventQuery To(DateTimeOffset end); // native hint
CalendarEventQuery Between(DateTimeOffset start, DateTimeOffset end);
CalendarEventQuery Where(Func<CalendarEvent, bool> predicate); // in-memory, ANDed
CalendarEventQuery TitleContains(string text); // in-memory, case-insensitive
CalendarEventQuery OrderBy(CalendarEventSortField field, bool descending = false);
CalendarEventQuery ThenBy(CalendarEventSortField field, bool descending = false);
CalendarEventQuery Skip(int count);
CalendarEventQuery Take(int count);
Task<IReadOnlyList<CalendarEvent>> ToListAsync(CancellationToken ct = default);
Task<CalendarEvent?> FirstOrDefaultAsync(CancellationToken ct = default);
Task<int> CountAsync(CancellationToken ct = default);
}
```
```csharp
// Events on a calendar within a date window (both hints pushed to the native fetch)
var events = await store.Query()
.ForCalendar(calId)
.Between(DateTimeOffset.Now, DateTimeOffset.Now.AddDays(7))
.OrderBy(CalendarEventSortField.Start)
.ToListAsync(ct);
// Free-text title (in-memory)
var standups = await store.Query()
.From(DateTimeOffset.Now.AddDays(-30))
.TitleContains("standup")
.ToListAsync(ct);
// Attendee filter (in-memory) + paging
var withAlice = await store.Query()
.Where(e => e.Attendees.Any(a => a.Email == "alice@example.com"))
.Skip(0).Take(20)
.ToListAsync(ct);
// Count
var busy = await store.Query()
.Between(from, to)
.Where(e => e.Availability == EventAvailability.Busy)
.CountAsync(ct);
```
**Native fetch hints:** `ForCalendar`, `From`, `To`, `Between`. Everything else — `Where`,
`TitleContains`, sorting, `Skip`/`Take` — is applied to the fetched events.
**`CalendarEventSortField`:** `Start`, `End`, `Title`. `ThenBy` throws without a preceding `OrderBy`.
### Create an Event
```csharp
var evt = new CalendarEvent("Team Sync", DateTimeOffset.Now.AddHours(1), DateTimeOffset.Now.AddHours(2))
{
CalendarId = calId, // optional; default calendar is used if null
Location = "Room 3",
Description = "Weekly sync"
};
evt.Reminders.Add(new EventReminder(TimeSpan.FromMinutes(15))); // 15 min before
evt.Attendees.Add(new EventAttendee("Alice", "alice@example.com"));
string id = await store.CreateEvent(evt);
// or the convenience overload
string id2 = await store.CreateEvent(calId, "Lunch", null, "Cafe",
DateTimeOffset.Now.AddHours(4), DateTimeOffset.Now.AddHours(5));
```
### Update / Delete an Event
```csharp
var evt = await store.GetEvent(id);
evt.Location = "Room 5";
await store.UpdateEvent(evt);
// Deletes only this occurrence when the event recurs.
await store.DeleteEvent(id);
```
#### Deleting a recurring event
`deleteSeries` decides whether a recurring event loses one occurrence or the rest of the series. It
is ignored for non-recurring events, so it is always safe to pass. **Never guess on a recurring
event — prompt the user**, keyed off `CalendarEvent.IsRecurring`:
```csharp
if (evt.IsRecurring)
{
var choice = await dialogs.ActionSheet(
$"Delete \"{evt.Title}\"?", "Cancel", "Delete All Future Events", "Delete This Event");
if (choice is not ("Delete This Event" or "Delete All Future Events"))
return;
await store.DeleteEvent(evt.Id!, choice == "Delete All Future Events");
}
else
{
await store.DeleteEvent(evt.Id!);
}
```
Platform behaviour:
| Platform | `deleteSeries: false` | `deleteSeries: true` |
|---|---|---|
| iOS / Mac Catalyst / macOS | `EKSpan.ThisEvent` | `EKSpan.FutureEvents` |
| Android | Inserts a cancellation exception for the occurrence | Deletes the `Events` row (whole series) |
| Windows | Deletes the appointment — no per-instance delete exists in `AppointmentStore`, so the flag has no effect | Same |
Android reads series masters from the `Events` table rather than expanded instances, so
`deleteSeries: false` cancels the series' own `DTSTART` — i.e. the first occurrence.
## Models
### Calendar
| Property | Type |
|------------|-----------|
| Id | `string?` |
| Name | `string` |
| Color | `string?` (hex, e.g. `#FF3B30`) |
| IsReadOnly | `bool` |
| Account | `string?` |
### CalendarEvent
| Property | Type |
|-----------------|-------------------------|
| Id | `string?` |
| CalendarId | `string?` |
| Title | `string` |
| Description | `string?` |
| Location | `string?` |
| Start / End | `DateTimeOffset` |
| IsAllDay | `bool` |
| Availability | `EventAvailability` |
| Url | `string?` |
| IsRecurring | `bool` (read-only) |
| RecurrenceRule | `string?` (read-only) |
| Reminders | `List<EventReminder>` |
| Attendees | `List<EventAttendee>` |
| Organizer | `EventAttendee?` (read-only) |
### EventReminder
`Offset` (`TimeSpan`) — how far before the start the reminder fires.
### EventAttendee
`Name`, `Email`, `Role` (`AttendeeRole`), `Status` (`AttendeeStatus`), `IsOrganizer`.
### Enums
- **EventAvailability:** Busy, Free, Tentative, Unavailable
- **AttendeeRole:** Required, Optional, Resource, Unknown
- **AttendeeStatus:** Unknown, Pending, Accepted, Declined, Tentative
- **CalendarAccessType:** ReadOnly, WriteOnly, ReadWrite
## Platform Notes & Caveats
1. **Recurrence is read-only** on all platforms (`IsRecurring` / `RecurrenceRule` are surfaced but not written).
2. **Apple (EventKit):** attendees **cannot be written** — `Attendees` you set on create/update are ignored. Reminders use a relative lead-time.
3. **Android (CalendarContract):** event CRUD works with permissions. Creating/modifying **calendars** goes through sync-adapter semantics and may behave differently across OEMs.
4. **Windows (AppointmentStore):** best-effort. Reads/queries cover all calendars; **create/update/delete only work inside an app-owned calendar** — writes targeting a system calendar throw `NotSupportedException`. Requires the `appointments` capability.
## Best Practices
1. **Always request access first** — `await store.RequestAccess(...)` and check `AccessState.Available`.
2. **Always set a date window** — `Between(from, to)` (or `From`/`To`) plus `ForCalendar(id)` are the only hints pushed to the native fetch; unbounded reads default to a ~4-month window.
3. **Await the query, don't wrap it** — `ToListAsync` already does the native read off the calling thread. Do not add `Task.Run` around it.
4. **Handle `Restricted`** — iOS write-only and Android partial grants surface as `AccessState.Restricted`.
5. **Don't rely on attendee writes on Apple** — set attendees where supported (Android), and use `ResolveContact` to map an attendee's email back to a device contact.
6. **Use primary constructors** — inject `ICalendarStore` via primary constructor.
## AI Tool Integration (Shiny.Calendar.Extensions.AI)
The optional `Shiny.Calendar.Extensions.AI` package exposes `ICalendarStore` as
`Microsoft.Extensions.AI` tool functions (`AIFunction`s) for LLM agents. You opt-in **per operation**
(read / create / update / delete) and, optionally, **per calendar id** — an allow-list you control on
behalf of the agent (**not** an OS permission prompt; the platform calendar permission must already be
granted). AOT-compatible (hand-built schemas, `JsonNode` results — no reflection).
GitHubで見る