# Calendite documentation, full text > Every documentation page in one file. The per-page index is https://calendite.com/llms.txt; each page's canonical Markdown URL is noted below. --- # Getting started Calendite is a calendar with a **rule engine** at its core. Instead of the rigid "repeats every week" options most calendars give you, you describe *when* something happens and Calendite turns it into a precise rule, worked out on your device. This page covers what happens the first time you open the app, and how to find your way around. --- ## First run: the setup wizard Five steps, in this order. You can change every one of them later. 1. **Import from your calendar**: reads the device's own calendar so you don't start from an empty grid. Needs the calendar permission. On iOS only *recurring* events are imported; one-off events are skipped. 2. **Permissions**: notifications, and exact alarms. Both matter: without exact alarms Android is free to fire your alarms late, and Calendite has to ask for that one separately because Android treats it as a special access. 3. **Country**: sets which days count as the weekend, how dates are ordered, and which bank-holiday calendar applies. UK default is Monday-first weeks and the England & Wales holiday list. 4. **Tags**: pick a starter set. Tags carry colour and can pre-fill new events. 5. **Work**: describes your working pattern, so `WD` and rules built on `Tag(Work)` are meaningful from day one. --- ## Getting around On a phone there are four things along the bottom: | | | |---|---| | **CALENDAR** | the grid: Month, Week or Day | | **EVENTS** | a scrolling list of what's coming up | | **NEW** (`+`) | create an event, reminder, deadline or time period | | **MORE** | everything else | On a tablet or desktop window the bottom bar is replaced by a sidebar down the left, and a **day agenda rail** appears on the right showing the selected day. The layout switches on window width, not device type, so a split-screen phone behaves like a phone and a resized desktop window adapts as you drag it. ### What's under MORE | Item | What it does | |---|---| | **Settings** | general preferences, including the sync server choice | | **Scan a QR code** | pairing invites, browser links | | **Country** | weekend days, date order, holiday calendar | | **Tags** | create, edit and colour tags; set their defaults | | **Calendar sync** | import from the device's calendar | | **Notes & files** | where notes are stored; you can point this at an Obsidian vault | | **Data sources** | feed-backed categories (bank holidays, term dates, fixtures) | | **Share calendar** | pair with someone (**Android only for now.**) | *Notifications*, *About* and *Send feedback* are listed but not available yet. --- ## Creating your first event Tap **NEW**. The form asks for a title and when it happens; everything else is optional and lives behind the **OPTIONS** grid below the fold: location, alerts, notes, colour, visibility, an app to launch, and so on. The **When** section is where Calendite differs from other calendars. Three modes: - **No Repeat**: a single date. - **Repeating Rules**: the picker-driven builder. Choose patterns and exceptions; the compiled rule shows in the RULE bar so you can see what you built. - **Manual Input**: type the schedule in plain English, or write the rule directly. Whatever you pick, tap the calendar preview to see the actual dates it produces before you commit. That preview is the fastest way to learn the rule language: build something with the pickers, then read what it compiled to. ### Which kind of thing? Tapping **NEW** asks **Choose a Calendar Item** before it shows you a form, because not everything on a calendar is an event. There are four: - an **Event**, something you attend, with a start and an end time; - a **Reminder**, something you have to *do* rather than somewhere you have to *be* — no duration, a mandatory alert, and it gets ticked off; - a **Deadline**, a moment something is due by, counting down to it and then up past it; - a **Time period**, a span of whole days such as a holiday or time off work. What you pick decides which fields the form then shows. See [Calendar items](/docs/everyday-use/calendar-items.html) for what each does differently. --- ## A good first hour To see what the app is designed for, in order: 1. Set up your **Work** pattern properly in the wizard; most of the interesting features build on it. 2. Add a **bank holidays** data source (More → Data sources; gov.uk publishes JSON), then change your Work rule to `WD, !BankHolidays` and watch the holidays drop out. 3. Add an **inner event** to Work (for example a stand-up) and see it appear only on work days. 4. If you work shifts, add a **variant** and see one series carry several sets of hours. That sequence covers the three ideas the rest of the app rests on. --- ## Where things are stored - **Events, tags, alerts and settings**: a local database on the device. - **Notes and descriptions**: plain `.md` files, either in app-private storage or a folder you choose (More → Notes & files). - **Nothing is uploaded anywhere** unless you turn on calendar sharing *and* choose a relay server. There is no account and no default cloud. --- ## Next - [Calendar views](/docs/everyday-use/calendar-views.html): Month, Week and Day, and what the week view is doing - [Building repeats](/docs/everyday-use/building-repeats.html): the picker, which is what you'll use day to day - [Repeating events](/docs/everyday-use/repeating-events.html): the rule language behind it - [Rule reference](/docs/rules-reference.html): the full syntax - [Editing and deleting](/docs/everyday-use/editing-and-deleting.html): this occurrence, or all of them - [How Calendite differs](/docs/how-calendite-differs.html): why any of this is shaped the way it is - [Troubleshooting](/docs/troubleshooting.html): if something isn't behaving --- # How Calendite differs from other calendar apps Most calendar apps are the same app. They differ in typography and sync backends, but underneath they all inherit the same 1998 model: an event is a title, a start, an end, and a repeat rule chosen from a dropdown. Calendite breaks with that model in a handful of specific places. This page is about those places: what is different, and what it lets you do that you could not do before. Everything below is describable in one sentence: **Calendite treats a schedule as something you can compute with, not something you fill in.** > **Platform note.** Calendite targets Android, iOS and the web, but the three are not at > parity. Android is complete. On iOS and the web, several of the features described here > aren't available yet; each section says so where it matters. Read this page as "what > Calendite is on Android today". --- ## 1. Recurrence is a language, not a dropdown Every mainstream calendar exposes recurrence through a fixed form: *every N days / weeks / months / years*, maybe *on the 2nd Tuesday*, and then it runs out. If your schedule doesn't fit the form, you are told to create several events and keep them in sync by hand. Calendite's recurrence is a small text language. You type the schedule: | You want | You write | |---|---| | Every weekday except bank holidays | `WD, !BankHolidays` | | Every Saturday except the first two of the month | `Sat, !1Sat, !2Sat` | | The last weekday of the month (payday) | `-1__WD__OF__MONTH` | | US Thanksgiving | `4__Thu__OF__Nov` | | Friday the 13th | `13Month__&__Fri` | | The nearest weekday to Christmas | `WD~=25Dec` | | Mondays and Thursdays, Q1 only | `Mon, Thu, LIMIT(Jan.Feb.Mar)` | | Every full moon | `MOON(FULL)` | | Every Good Friday, without a holiday feed | `EASTER(GoodFriday)` | | Bin day, every other Tuesday | `Tue2` | Several of those are not merely awkward in Google Calendar or Apple Calendar: they are **not expressible at all**. There is no way to say "except bank holidays" in an RRULE, because RRULE has no notion of a named set of dates it doesn't already know about. You are never forced into the syntax. The repeat sheet offers the ordinary pickers, and the rule bar shows you what they compiled to, so the language is a ceiling, not a floor. The full syntax is documented in the [rule reference](/docs/rules-reference.html), which is generated from and verified against the rule engine itself. ### The bit people miss: order matters Rules are applied left to right: normal rules add dates, `!` rules remove them. So: ``` WD, !Fri ✅ every weekday except Friday !Fri, WD ❌ removes Friday, then adds all weekdays back, so Friday returns ``` Write inclusions first, exclusions last. --- ## 2. Rules can refer to your other events This is the part with no equivalent anywhere else. A rule can name a **tag**, an **event series**, or an external **feed**, and use its dates as raw material: | Rule | Means | |---|---| | `Tag(Work)` | every day carrying the `Work` tag | | `Event(Work)` | the occurrence dates of the single event series named `Work` | | `Feed(BankHolidays)` | dates supplied by an external feed you configured | Which unlocks things like: ``` -1__Tag(Work)__OF__MONTH ``` *"The last **working** day of the month"*, where "working day" means whatever **your** Work events say, including the shift patterns and the holidays you booked. If you take the last Friday off, the rule moves. No other calendar can express a schedule that is defined relative to another schedule. `Tag(Work)` and `Event(Work)` are deliberately different sets: one is every event sharing a tag, the other is one specific series, which is why you have to say which you mean. > These rules need a data source to resolve. On their own they produce no dates. --- ## 3. Inner Events: things that only happen inside other things A **stand-up** happens at 09:15 on work days. Not on Mondays, on *work days*. If Tuesday is a bank holiday, there is no stand-up. If you swap to a late shift, the stand-up moves with it. In a normal calendar you model this as a second recurring event on a weekday repeat, and then spend the rest of the year deleting the instances that shouldn't have happened. In Calendite, the stand-up is an **Inner Event** of Work. Its rule is implicitly intersected with its parent's, so it *cannot* occur on a day its parent doesn't. Cancel a work day and the stand-up goes with it, permanently and by construction. Inner events can be anchored **relative** to the parent rather than to the clock ("15 minutes after it starts", "30 minutes before it ends"), so moving the parent moves them. They render inside the parent block rather than competing with it for space: a darkened strip along the bottom of the parent, carrying one pill per inner event, positioned proportionally within the parent's span. Nesting is one level deep, and an inner event belongs to exactly one parent. --- ## 4. Variant Events: one thing, several shapes You work Earlys, Lates and Nights. It is one job, one tag, one colour, one set of notes, but three different sets of hours. Other calendars make you choose between three unrelated recurring events (and maintain each one separately forever) or one event you edit instance by instance. Calendite has **Variant Events**: named variations of a single series that differ in a handful of attributes (usually times, sometimes location or alerts) and share the rest. ``` Work ├── Early Shift 06:00–14:00 Mon2, Wed2, Fri2 ├── Late Shift 14:00–22:00 Tue2, Thu2 └── Night Shift 22:00–06:00 -1Sat ``` **How the base and its variants interact:** a variant supersedes the base **only on the days that variant itself fires**. On every other day the base still fires normally. So a base rule of `WD` with a `Night Shift` variant on `-1Sat` gives you weekdays *and* the last Saturday; the base is not switched off. Most inherited fields are resolved and stored when the variant is saved, not looked up live. Editing the base event's time, duration, colour, tag or location afterwards will **not** change variants that already exist. Alerts are the exception: they resolve live, so an alert added to the base later reaches existing variants. --- ## 5. Natural language, parsed on your phone Type *"every other Tuesday except bank holidays"* and Calendite turns it into `Tue2, !BankHolidays`. Fantastical and Google Calendar do this too, by sending your sentence to a server. Calendite ships the models and runs them locally: roughly 57 MB of quantised ONNX (a sequence-to-sequence translator for the rule itself, and a MiniLM sentence embedder for resolving names). Your phrasing never leaves the device, and it works on a plane. The name-resolution step is the interesting half. The translator can't know whether "karate days" means a tag or an event you own, so it emits a placeholder and the app resolves it against your own data by embedding similarity, offering you a choice when the match is ambiguous and asking you to rephrase when it finds no match. That's why `Tag(X)` and `Event(X)` are separate: the resolver has to pick, and it tells you when it can't. > **Android only for now.** On-device parsing isn't available on iOS yet. --- ## 6. Your notes are just Markdown files in a folder you choose Calendite's notes are not rows in a database. They are `.md` files on disk, in a folder tree you can point at your Obsidian vault: ``` Events/ ├── Past/ ├── Today/ │ └── Work/ │ ├── Description.md │ ├── .templates/ ← masters for repeating notes │ └── 2026-06-15 -- Sunday/ │ ├── Handover.md │ └── Stand-up/ ← inner event's own notes └── Future/ ``` No frontmatter, no proprietary metadata, no export step: the files *are* the storage. Occurrence folders are filed into Past / Today / Future buckets and re-filed nightly, so the tree stays navigable outside the app. A daily sweep moves folders between buckets using copy → per-file SHA-256 verify → delete, so an interrupted move can't lose a note. > **Android only for now.** The nightly sweep only runs against the app's private storage, > so if you point Calendite at an Obsidian vault the automatic re-bucketing does not run > there. The editor's rendered view uses plain CommonMark, so GFM tables, task checkboxes > and `~~strikethrough~~` display as raw text. --- ## 7. Any JSON on the internet can become a set of dates Bank holidays are the obvious case, but the mechanism is general. Give Calendite a URL and a JSONPath (there's a point-and-click builder if you don't want to write one), map which key is the title and which is the date, and you have a named category usable in any rule: ``` WD, !BankHolidays ``` Term dates, fixture lists, bin collections, a rota published as JSON: anything with dates in it becomes something your schedules can subtract. Feeds that describe the same day merge into one event rather than stacking up, and an optional *qualifier* field keeps distinct entries apart (so a bank holiday and its substitute day stay separate). > **Android only for now.** There is also no refresh schedule: feeds re-sync when you save > one and when you bring the app to the foreground. There is no `.ics` URL subscription; > the only calendar import is from the device's own calendar. --- ## 8. Sharing with no account, and no server that can read anything There is no Calendite account. There is no sign-up. Two people pair by **being in the same room**: one phone shows a QR code, the other scans it, both phones then display the same six digits and both people must confirm they match. That confirmation is a hard gate: the calendar key does not move until both sides tap it. After that, phones sync **directly over your Wi-Fi**. A relay server is available for when you're not on the same network, but it is **off by default** and you choose it explicitly in Settings: the Calendite one, your own deployment, or none at all. When a relay is used, it holds sealed blobs it cannot open. Every change is encrypted twice over, under independent keys (ChaCha20-Poly1305 then AES-256-GCM), with key agreement that is post-quantum hybrid: an attacker has to break both classical *and* lattice cryptography, not either. The relay learns traffic shape: which mailbox, when, how big. Not contents, not keys, not names. Hiding an event from a partner is enforced by cryptography rather than by their app's UI: for a hidden event, only the timing is ever encrypted under the shared key, so a modified client has nothing to reveal. It shows as a "Busy" block because that is all that was sent. > **Limitations.** Sharing is **Android only for now.** Revocation and hiding protect the > *future*: what a partner has already synced is theirs. The key agreement is hybrid post-quantum, so traffic recorded today can't be decrypted later by a quantum computer. [How the encryption works](/docs/privacy/how-the-encryption-works.html) covers the details. --- ## 9. The week view is rotated Every calendar puts days in columns and time down the page. A one-hour meeting therefore gets a sliver of width, and its title is cut to about four characters. Calendite's week view is **transposed**: dates run down the left, time runs across. An event's **width is its duration**, so a four-hour meeting gets four hours of room for its title. All seven days always fit the screen height; the time axis scrolls sideways. The follow-on is a set of tricks to keep labels readable no matter how tight things get. A block picks the best of five modes for the room it has: title with times, title alone, label spilled onto empty grid to the right, spilled left, or the title rotated 90° to read down the block. Long events keep their title pinned within view as you scroll, so you can always tell what you're looking at. --- ## 10. Not everything on a calendar is an event Most calendars have one noun and bend it to fit everything. Calendite has four [calendar items](/docs/everyday-use/calendar-items.html) and asks which you mean before it shows you a form: an **event** you attend, a **reminder** you do, a **deadline** you work towards, and a **time period** — a holiday, or time off — measured in whole days rather than clock hours. The distinction earns its keep because each kind then behaves differently. A deadline counts down to its moment and then counts up past it. A time period carries a start and end *date* where an event carries times, and draws as one continuous band across every day it covers. And a reminder is a thing you must *do*, not a span of time you must *attend*: no duration, never all-day, and its alert is mandatory — a reminder that never fires is pointless. Two reminder behaviours that ordinary calendars don't have: - **Recurrence anchor.** A repeating reminder either stays *on schedule* (dates come straight from the rule; completing late changes nothing) or repeats *after completion* (the next one is computed from when you finished). "Water the plants every 3 days" means 3 days after you last watered them, not every third calendar day. - **Do-not-interrupt.** A reminder that would fire during an event you're currently in defers itself until that event ends, rather than ringing over your meeting. When one rings you get **Done** or **Remind Me Later**, and "later" means a time you choose, not a fixed snooze. --- ## 11. Alert text is templated An alert's title and body can carry values from the event it belongs to, using `%` codes: ``` %t starts in %tm minutes ``` → *"Stand-up starts in 10 minutes"* There are 27 variables: the event title, time until or since (in several units and formats), duration, formatted dates and times, location, tag name and colour, and the current clock. Time-relative ones re-render live while an alarm is ringing, so the count stays true. An alarm can also carry an **app to launch**: a badge on the ringing screen that opens the thing the alarm is *for*. --- ## What Calendite is not - **It is not a team calendar.** Sharing is peer-to-peer between people, currently one partner at a time. There are no rooms, no invitations with RSVPs, no free/busy lookup across an organisation. - **It is not cross-platform yet.** Android is complete. iOS and web run the UI, but sharing, notes, feeds and on-device parsing aren't available there yet. - **It does not sync with Google Calendar.** It can import from the device calendar once; it is not a two-way client for someone else's calendar service. - **It is not simple.** Inner events, variants and a rule language are more concepts than most people need. The pickers cover the ordinary cases, and the rest is there when the ordinary cases stop working. --- ## Where to go next | To learn | Read | |---|---| | The full rule syntax | [Rule reference](/docs/rules-reference.html) | | What the domain words mean | [Glossary](/docs/reference/glossary.html) | | Getting set up | [Getting started](/docs/getting-started.html) | | Repeating schedules, from scratch | [Repeating events](/docs/everyday-use/repeating-events.html) | | Inner and variant events in practice | [Inner and variant events](/docs/everyday-use/inner-and-variant-events.html) | | Sharing and what it does with your data | [Sharing your calendar](/docs/privacy/sharing-your-calendar.html) | | Exactly what the app sends, and when | [What leaves your device](/docs/privacy/what-leaves-your-device.html) | | Building a repeat without typing a rule | [Building repeats](/docs/everyday-use/building-repeats.html) | | Something isn't working | [Troubleshooting](/docs/troubleshooting.html) | --- # Rule reference The complete recurrence rule syntax. If you're new to the rule language, [repeating events](/docs/everyday-use/repeating-events.html) teaches it from the beginning and [building repeats](/docs/everyday-use/building-repeats.html) covers the picker that writes rules for you. > This page is generated from the rule engine's own specification, which is verified > against the engine: every example was run through it to confirm the dates it produces. > The full argument lists for `MOON(...)` and `EASTER(...)` have their own pages, > [Moon rules](/docs/reference/moon-rules.html) and [Easter rules](/docs/reference/easter-rules.html). --- ## Contents 1. [The three things to know first](#1-the-three-things-to-know-first) 2. [Every day](#2-every-day) 3. [Every N days / weeks / months / years](#3-every-n-days-weeks-months-years) 4. [A weekday every week](#4-a-weekday-every-week) 5. [A day of the month](#5-a-day-of-the-month) 6. [A day of the year](#6-a-day-of-the-year) 7. ["2nd Saturday of the month" style](#7-2nd-saturday-of-the-month-style) 8. [Weekdays and weekends](#8-weekdays-and-weekends) 9. [A one-off date](#9-a-one-off-date) 10. [Excluding dates with `!`](#10-excluding-dates-with) 11. [Categories, tags, events and feeds](#11-categories-tags-events-and-feeds) 12. [Move to a qualifying day with `~`](#12-move-to-a-qualifying-day-with) 13. [Moving dates by a fixed amount with `+` and `-`](#13-moving-dates-by-a-fixed-amount-with-and-) 14. [Powerful patterns with `__OF__`](#14-powerful-patterns-with-__of__) 15. [A specific ISO week of the year](#15-a-specific-iso-week-of-the-year) 16. [Limiting to certain months](#16-limiting-to-certain-months) 17. [Moon phases](#17-moon-phases) 18. [Easter and the days that move with it](#18-easter-and-the-days-that-move-with-it) 19. [Intersections](#19-intersections) 20. [Notes & limitations](#20-notes-limitations) 21. [Cheat sheet](#21-cheat-sheet) 22. [Recipes](#22-recipes) --- ## 1. The three things to know first **1. Combine rules with commas.** A schedule can be several rules separated by commas. The engine gathers the dates from each one: ```text Mon, Thu ``` → every Monday **and** every Thursday. **2. A `!` in front means "exclude".** A rule that starts with `!` *removes* dates instead of adding them. It's perfect for "…except": ```text WD, !BankHolidays ``` → every weekday **except** bank holidays. **3. Order matters — put inclusions first, exclusions last.** The engine reads your rules left to right, adding dates for normal rules and removing them for `!` rules. So write what you want first, then subtract: ```text WD, !Fri ✅ every weekday except Friday !Fri, WD ❌ removes Friday, then adds all weekdays back — Friday returns ``` If a schedule has **no** normal (non-`!`) rule at all, the event simply falls on its own start date. Everything below is just different ways to fill in those rules. ### The building-block codes **Days of the week** (exactly these, capitalised): | Mon | Tue | Wed | Thu | Fri | Sat | Sun | |-----|-----|-----|-----|-----|-----|-----| **Months** (exactly these, capitalised): | Jan | Feb | Mar | Apr | May | Jun | Jul | Aug | Sep | Oct | Nov | Dec | |-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----| --- ## 2. Every day | Rule | Meaning | | ----- | ---------------- | | `Day` | Every single day | That's it — `Day` on its own fires every day. --- ## 3. Every N days / weeks / months / years These repeat relative to the event's **start date**. Add a number for the interval; leave it off for "every one". | Rule | Meaning | | ---------- | ---------------------------------------------- | | `Day2` | Every 2nd day (`Day5` = every 5 days) | | `Weekly` | Every week, on the start date's weekday | | `Weekly2` | Every 2 weeks | | `Monthly` | Every month, on the start date's day-of-month | | `Monthly2` | Every 2 months | | `Yearly` | Every year, on the start date's month and day | | `Yearly2` | Every 2 years | > **Short months:** if the start date is the 31st, `Monthly` automatically falls on > the last day of shorter months (the 30th, or 28th/29th in February). *Example — `Day3` from 1 Jan 2025 →* `2025-01-01, 2025-01-04, 2025-01-07, …` --- ## 4. A weekday every week Just write the day code. Add a trailing number to skip weeks. | Rule | Meaning | | ------ | ------------------------------------ | | `Thu` | Every Thursday | | `Thu2` | Every **2nd** Thursday (fortnightly) | | `Sat3` | Every 3rd Saturday | *Example — `Thu2` from 1 Jan 2025 →* `2025-01-02, 2025-01-16, 2025-01-30, 2025-02-13, …` --- ## 5. A day of the month Write the day number followed by `Month`. | Rule | Meaning | | ---------- | ----------------------------------- | | `5Month` | The 5th of every month | | `5Month2` | The 5th of every **2nd** month | | `-1Month` | The **last day** of every month | | `-2Month` | The second-to-last day of the month | A negative number counts back from the end of the month. *Example — `-1Month` →* `2025-01-31, 2025-02-28, 2025-03-31, …` *Example — `5Month2` →* `2025-01-05, 2025-03-05, 2025-05-05, …` ### Months that are too short Not every month has a 31st, and February has no 30th. **By default the rule simply doesn't happen that month:** *Example — `31Month` →* `2025-01-31, 2025-03-31, 2025-05-31, 2025-07-31, 2025-08-31, 2025-10-31, 2025-12-31` — seven dates a year, and nothing in the five short months. That is deliberate. It is what the iCalendar standard requires, so it is what every calendar Calendite exchanges events with does, and an event imported from elsewhere can never mean anything else. It is often *not* what someone means by "the 31st of every month", though, so you can say what you want instead: | Rule | February 2025 | April 2025 | | ------------------ | ------------- | ---------- | | `31Month` | *(nothing)* | *(nothing)* | | `31Month(CLAMP)` | the 28th | the 30th | | `31Month(NEXT)` | 3 March | 1 May | `CLAMP` uses the last day the month does have. `NEXT` keeps counting past the end of the month. February 2025 runs out after the 28th, so its 29th, 30th and 31st are the 1st, 2nd and 3rd of March — the day number still means something once it has left the month, rather than everything past the end piling onto the 1st. A leap February is a day longer, so the 31st lands on 2 March instead. This is also how English law treats a 29 February birthday: age is attained on 1 March. Two things worth knowing: - **The rhythm doesn't change.** `31Month2(CLAMP)` stays on the odd months and clamps the short ones (`2025-01-31, 2025-03-31, 2025-05-31, 2025-07-31, 2025-09-30, 2025-11-30`). A date that runs over is still counted against the month it came *from*, so `NEXT` never nudges the schedule along a month at a time. - **`-1Month` is usually simpler.** If you mean "the end of the month", say that — `31Month(CLAMP)` produces exactly the same dates the long way round. The same suffix works on a day of the year (§6), so `29Feb` happens only in leap years, `29Feb(CLAMP)` falls back to the 28th, and `29Feb(NEXT)` counts on to 1 March. --- ## 6. A day of the year Write the day and month together. Add a trailing number for a multi-year interval. | Rule | Meaning | | --------- | ----------------------------------- | | `25Dec` | Every 25th December | | `1Jan` | Every 1st January | | `25Dec2` | Every 25th December, every 2nd year | A negative day counts back from the end of that month, the same way `-1Month` does for an ordinary month (§5): | Rule | Meaning | | -------- | ------------------------------------------ | | `-1Aug` | The last day of August (the 31st) | | `-2Aug` | The second-to-last day of August (the 30th) | | `-1Feb` | The last day of February | The date is worked out per year rather than fixed, so `-1Feb` lands on the 28th in a common year and the 29th in a leap year. **February 29th.** `29Feb` happens only in leap years — the other years are skipped, as §5 describes. Add a suffix to say otherwise: | Rule | 2024 (leap) | 2025 | | --------------- | ----------- | ------- | | `29Feb` | 29 Feb | *(nothing)* | | `29Feb(CLAMP)` | 29 Feb | 28 Feb | | `29Feb(NEXT)` | 29 Feb | 1 Mar | A day the month can never have is refused outright — there is no 31st of April in any year, so `31Apr` is a typo rather than a schedule. Saying what you want makes it meaningful again: `31Apr(CLAMP)` is the 30th and `31Apr(NEXT)` is 1 May. *Example — `-1Feb` →* `2024-02-29, 2025-02-28, 2026-02-28, …` --- ## 7. "2nd Saturday of the month" style Put an occurrence number **in front** of the day. | Rule | Meaning | | -------- | --------------------------------------- | | `2Sat` | The 2nd Saturday of the month | | `1Mon` | The 1st Monday of the month | | `-1Thu` | The **last** Thursday of the month | | `2Sat2` | The 2nd Saturday of every **2nd** month | *Example — `2Sat` →* `2025-01-11, 2025-02-08, 2025-03-08, …` **Occurrence vs. week — the underscore.** An underscore changes the meaning from "the Nth *occurrence*" to "the day that falls in the Nth *week* of the month": | Rule | Meaning | | ------- | --------------------------------------------------------- | | `2Wed` | The **2nd Wednesday** of the month | | `2_Wed` | The Wednesday that falls in the **2nd week** of the month | These differ when the month starts mid-week. For February 2024, `2Wed` is the 14th but `2_Wed` is the 7th. **Week 1 is the week containing the 1st**, even when most of it belongs to the previous month, and weeks begin on your locale's first day (UK: Monday). February 2025 opens on a Saturday, so its week 1 is Jan 27 – Feb 2 and its week 2 is Feb 3 – 9 — making `2_Mon` the 3rd, while `2Mon` is the 10th. The two forms agree whenever the day you ask for is not earlier in the week than the day the month opens on. A month starting on a Monday makes them identical throughout. **A day outside the month is not an occurrence.** `1_Mon` in January 2025 asks for the Monday of Dec 30 – Jan 5, which is 30 December — so January simply has none, and the rule resumes in months whose first week does contain a Monday. The same applies at the end: `-1_Sat` asks for the Saturday of the month's last week, which in January 2025 is 1 February, so again there is no occurrence. --- ## 8. Weekdays and weekends `WD` means weekdays, `WE` means weekend days. | Rule | Meaning | | ---- | ----------------- | | `WD` | Every weekday | | `WE` | Every weekend day | > Which days count as the weekend depends on your region — the engine uses your > locale's week configuration (UK: Mon–Fri weekdays, Sat–Sun weekend). You can also count **occurrences** of them within a month, exactly like named days, with an optional month interval: | Rule | Meaning | | ------- | --------------------------------------- | | `2WD` | The 2nd weekday of the month | | `-1WD` | The **last** weekday of the month | | `3WE` | The 3rd weekend day of the month | | `-1WD2` | The last weekday of every **2nd** month | ### Intervals and week-of-month A trailing number (no leading occurrence number) gives two more forms, following the same occurrence-vs-week underscore rule as `2Sat` / `2_Sat`: | Rule | Meaning | | ------- | -------------------------------------------------------- | | `WD2` | Every **2nd weekday**, counting weekdays from the start (Mon, Wed, Fri, Tue, …) | | `WD3` | Every 3rd weekday | | `WE2` | Every 2nd weekend day | | `WD_2` | Every weekday **in the 2nd week** of each month | | `WE_2` | Every weekend day in the 2nd week of each month | The **no-underscore** form counts individual days (`WD2` = every other weekday). The **underscore** form selects a whole week of the month (`WD_2` = all weekdays in its 2nd week), mirroring `2_Sat` = "the Saturday in the 2nd week of the month". *Example — `WD2` from Mon 6 Jan 2025 →* `Jan 6, 8, 10, 14, 16, 20, 22, 24, …` *Example — `WD_2` →* `Jan 6–10, Feb 3–7, …` (the weekdays of each month's 2nd week) ### Grouped day-sets Wrap a dot-separated set of days in parentheses to match any of them: | Rule | Meaning | | --------------- | -------------------------------- | | `(Mon.Wed.Fri)` | Every Monday, Wednesday **or** Friday | | `(WD)` | Every weekday (same as `WD`) | | `(WE)` | Every weekend day | Standalone, `(Mon.Wed.Fri)` is just shorthand for `Mon, Wed, Fri`. It's most useful as the inner part of an `__OF__` pattern — `2__(Mon.Wed.Fri)__OF__MONTH` (§14) — or an ISO-week rule (§15). --- ## 9. A one-off date Write a full date in `YYYY-MM-DD` form (zero-padded): ```text 2025-05-17 ``` → occurs on 17 May 2025, and only then. One-off dates are most useful combined with other rules — see exclusions below. --- ## 10. Excluding dates with `!` Put `!` in front of any rule to *remove* its dates. | Rule | Meaning | | --------------- | ------------------------------------ | | `!1Sat` | Not on the 1st Saturday of the month | | `!BankHolidays` | Not on any bank holiday | | `!2025-12-25` | Not on 25 December 2025 | Combine freely (remember: inclusions first, exclusions after): ```text Sat, !1Sat, !2Sat ``` → every Saturday **except** the first two of each month. *(Jan 2025 → 18th & 25th; Feb 2025 → 15th & 22nd.)* ```text WD, !2025-12-25 ``` → every weekday, but skip Christmas Day 2025. --- ## 11. Categories, tags, events and feeds These rules reference dates that live **outside** the rule itself. They only resolve when the app supplies the relevant data — on their own they contribute no dates. ### Categories A **category** is a named group of dates, written in `PascalCase` (it must start with a capital and contain at least one lowercase letter): ```text BankHolidays ``` Use it like any other rule — most often as an exclusion: ```text WD, !BankHolidays ``` → every working weekday. **Pick one member of a category** by naming it in parentheses (also PascalCase): ```text BankHolidays(Christmas) ``` → only the Christmas date(s) from the bank-holidays group. You can define your own categories too, e.g. `KarateDays` or `SchoolHolidays`, and reference them the same way. ### Named selectors: `Tag`, `Event`, `Feed` When you want to be explicit about *what kind* of thing you're referencing, use a named selector. The name inside the brackets starts with a capital and is letters/digits: | Rule | Means | | ---------------- | -------------------------------------------------------- | | `Tag(Work)` | The dates of **every event carrying the tag** `Work` | | `Event(Work)` | The occurrence dates of the **single event series** `Work` | | `Feed(BankHolidays)` | The dates supplied by the external **feed** `BankHolidays` | `Tag(Work)` and `Event(Work)` are deliberately different date sets — one is every event sharing a tag, the other is one specific event — which is why the kind is spelled out. *Example:* `WD, !Tag(Karate)` → every weekday except days carrying the Karate tag. > **`WORKDAY` alias:** `WORKDAY` is accepted as a shorthand for `Tag(Work)`. It's > rewritten before evaluation, so it behaves identically — standalone (`WORKDAY`, > `!WORKDAY`) and nested (`1__WORKDAY__OF__MONTH`). Like any selector it needs a > data source to resolve. --- ## 12. Move to a qualifying day with `~` Sometimes you want "the weekday nearest to a date" — e.g. a holiday observed on a weekday when the real date lands on a weekend. Use the `~` operator: ```text {type} ~ {direction} {anchor} ``` - **`{type}`** is the kind of day to land on. Usually `WD` or `WE`, but any rule works: `WORKDAY`, a weekday (`Mon`…`Sun`), a day-set (`(Mon.Fri)`), a tag (`Tag(Work)`), a moon phase (`MOON(FULL)`), or an interval (`Day`). - **`{anchor}`** is the occasion you are adjusting around — e.g. `25Dec`. - **`{direction}`** is one of: | Symbol | Meaning | | ------ | --------------------------------------------- | | `<` | Strictly before | | `<=` | On the date if it qualifies, otherwise before | | `>` | Strictly after | | `>=` | On the date if it qualifies, otherwise after | | `=` | Nearest | | Rule | Meaning | | ------------- | ------------------------------------------------------------ | | `WD~<25Dec` | The weekday just **before** Christmas | | `WD~<=25Dec` | Christmas itself if it's a weekday, else the weekday before | | `WD~>25Dec` | The weekday just **after** Christmas | | `WD~=25Dec` | The **nearest** weekday to Christmas | *Example — `WD~>25Dec` (2025) →* Christmas is Thursday, so the next weekday is `2025-12-26`. **The type can be any rule**, which is what makes the operator useful beyond weekdays: | Rule | Meaning | | --------------------- | ---------------------------------------------- | | `Tag(Work)~<25Dec` | The last **work day** before Christmas | | `(Mon.Fri)~<25Dec` | The last **Monday or Friday** before Christmas | | `MOON(FULL)~<25Dec` | The last **full moon** before Christmas | | `-1Fri~<25Dec` | The last **Friday of the month** before Christmas | `Tag(Work)` and `WORKDAY` need a data source, like any tag (§11). ### What can go in the type slot The test is whether the rule **produces days**. If it names days you could point at on a calendar, it can be adjusted around: | Can be the type | Example | | ------------------------------------------------- | -------------------- | | Weekday / weekend / every day | `WD`, `WE`, `Day` | | A named weekday, or a day-set | `Fri`, `(Mon.Fri)` | | An ordinal, **including a negative one** | `1Mon`, `-1Fri` | | A date, a yearly date, an interval | `2026-05-17`, `25Dec`, `Monthly` | | A moon phase | `MOON(FULL)` | | A tag, event, feed or category *(needs its data)* | `Tag(Work)`, `WORKDAY` | | An `__OF__` composite | `-1__WD__OF__MONTH` | An anchored period can be the type — `Fri__OF__WEEK(25Dec)~<1Jan` is the Friday of Christmas week, taken before New Year's Day — but only one bracketed group is allowed, so `(1..2)__WD__OF__WEEK(25Dec)~<1Jan` is out. | Cannot be the type | Why | | --------------------- | ------------------------------------------------------- | | `LIMIT(...)` | A filter, not a producer — see below | | `@&@(...)` | Resolved before the engine sees it (§11) | | Two bracketed groups | `(1..2)__WD__OF__WEEK(25Dec)` — one group is the limit | **A `LIMIT` cannot be adjusted around, on either side.** It filters days rather than producing them, so there is nothing to land on. Worse, asking it a question turns it inside out — a `LIMIT` matches the months it *removes* — so `LIMIT(Jan)~<25Dec` quietly meant "the last day not in January before Christmas" and answered the 24th. Both `LIMIT(Jan)~<25Dec` and `WD~ **"The X closest to Y" is meaningful when X and Y run on cycles that drift > against each other, and empty when they share a cycle.** That is the test to apply before writing a `~=`, and it is about the two rules' periods, not their frequencies. ### What `~` deliberately does not do Even where "closest" varies, English is ambiguous between two readings, and `~` only implements the first: 1. **Per anchor** — for each 22nd, the nearest full moon. Many answers. This is `~`. 2. **Superlative** — of all the candidate full moons, the single one nearest to a 22nd. One answer. Reading 2 needs a bounded set of candidates to mean anything: over June and July alone the answer might be 23 June, but widen the span and it changes. A rule has to answer `occursOn(date)` from a date alone, so a rule whose dates shift depending on how far you scroll is not a rule at all. There is no syntax for reading 2, and asking for it with `~` silently gets you reading 1 instead. See `Bounded_Superlative_Selection.md` in the rule engine's repository for what adding it would involve. **For "the X of the week of Y", use `__OF__WEEK(...)` instead (§14).** `~` finds the *nearest* match, which is not the same question and gives a different answer near a week boundary. Christmas 2028 falls on a Monday, so `Fri~=25Dec` returns Friday the 22nd — nearer, but the week *before* Christmas — while `Fri__OF__WEEK(25Dec)` returns the 29th. Use `~` when you mean "nearest", and the anchored period when you mean "in the same week". **Negate the type with a leading `!`.** Put `!` in front of the type to land on a day that is **not** that type — e.g. the next *non*-working day: | Rule | Meaning | | -------------- | ------------------------------------------------ | | `!WD~>25Dec` | The first **non-weekday** (weekend) after Christmas | | `!WD~<25Dec` | The first non-weekday before Christmas | Here the `!` negates the *type*, not the whole rule — `!WD~>25Dec` still **adds** a date (a weekend day), it doesn't exclude anything. *Example — `!WD~>25Dec` (2024) →* Christmas is Wednesday, so the next weekend day is `2024-12-28` (Saturday). --- ## 13. Moving dates by a fixed amount with `+` and `-` Sometimes you don't want the nearest *sort* of day — you want a plain "three days before that". Add or subtract a duration: ```text {rule} + P{n}{Unit} {rule} - P{n}{Unit} ``` `{Unit}` is `Day`, `Week`, `Month` or `Year`. Every date the rule produces moves by that amount. | Rule | Meaning | | ---------------------- | ------------------------------------------ | | `25Dec-P1Day` | Christmas Eve, every year | | `25Dec+P1Day` | Boxing Day | | `Mon-P1Day` | Every Sunday-before-a-Monday | | `Event(Karate)-P1Day` | The day before every karate class | | `Tag(Payday)-P1Week` | A week before each payday | | `-1Thu+P2Day` | Two days after the last Thursday of the month | *Example — `25Dec-P1Day` →* `2025-12-24, 2026-12-24, 2027-12-24, …` ### Why the `P` The `P` marks a **duration**, and without it the rule would be ambiguous. This grammar already uses `{number}{unit}` to mean a *position*: `1Month` is the 1st of the month (§5) and `-1Fri` is the last Friday (§7). So `25Dec-1Day` could not be told apart from those, and a bare `-` clashes with ISO dates too. With the `P` everything reads cleanly, including `2025-05-17-P1Day`. ### This is not the same as `~` The two look similar and answer very different questions. | | Asks | `25Dec`, 2028 | | --- | --- | --- | | `WD~<25Dec` | the nearest day *of a type* | **22nd** — Christmas is a Monday, so it skips back to the Friday | | `25Dec-P1Day` | exactly one day earlier | **24th** — always, whatever weekday that is | Use `~` when you mean "the working day before"; use `-P1Day` when you mean "the day before". A shift never changes its mind based on what weekday something lands on, which also means it can't quietly collapse the way an ill-posed `~` can (§12). ### Months clamp to the end of the month There is no 31st of February, so a month or year shift lands on the last day that exists: | Rule | Result | | ---------------- | ------------------------------- | | `31Jan+P1Month` | 28 Feb (29 Feb in a leap year) | Clamping loses the original day, so **shifts do not undo each other**: `31Jan+P1Month-P1Month` is the **28th** of January, not the 31st. This surprises people, so it is worth saying plainly — the 31st became the 28th on the way out and there is nothing left to say it was ever the 31st. ### Chaining Shifts apply left to right, so you can stack them: ```text 1Month+P1Month-P1Day ``` → the 1st of the month, forward a month, back a day = **the last day of every month**. (`-1Month` says the same thing more directly, §5.) ### Limits - A `LIMIT(...)` cannot be shifted. It removes days rather than producing them, so there is nothing to move — the same reason it cannot be adjusted around (§12). - A shift of zero (`25Dec+P0Day`) is rejected; write `25Dec`. - `~` and `__&__` bind **looser** than a shift, so `WD~<25Dec-P1Day` is the weekday before *Christmas Eve*, and `Mon__&__Fri-P1Day` intersects against the shifted Friday. --- ## 14. Powerful patterns with `__OF__` For advanced schedules, the engine has a "pick the Nth *thing* out of a *period*" pattern: ```text {which}__{inner}__OF__{period} ``` - **`{period}`** is `DAY`, `WEEK`, `MONTH`, `YEAR`, or a specific month (`Jan`…`Dec`). - **`{inner}`** is the kind of day to pick. Use `WD`, `WE`, a single day code (`Mon`…`Sun`), a grouped day-set (`(Mon.Wed.Fri)`), or a data-backed reference — a category (`BankHolidays`) or a selector (`Tag(X)`, `Event(X)`, `Feed(X)`). - **`{which}`** selects which occurrence(s): | Form | Meaning | | --------- | ------------------------------------------------ | | `1` | The 1st | | `-1` | The last (negative counts from the end) | | `(1..5)` | The 1st through 5th (a range) | | `(1.3.5)` | The 1st, 3rd and 5th only (a set) | | `ALL` | Every one in the period (**specific months only**) | Range/set members may be negative — `(-3..-1)` for the last three, `(1.-1)` for the first and the last. A range's two endpoints must share a sign (use a set for mixed). ### With `MONTH` / `YEAR` / `WEEK` / `DAY` | Rule | Meaning | | ---------------------------- | ------------------------------------------ | | `1__WD__OF__MONTH` | First weekday of the month | | `-1__WD__OF__MONTH` | Last weekday of the month | | `(1..5)__WD__OF__MONTH` | First through fifth weekday of the month | | `(1.3.5)__WD__OF__MONTH` | 1st, 3rd and 5th weekday of the month | | `-1__Fri__OF__MONTH` | Last Friday of the month | | `-2__WE__OF__YEAR` | Second-to-last weekend day of the year | *Example — `-1__Fri__OF__MONTH` →* `2025-01-31, 2025-02-28, 2025-03-28, …` **Positions are counted over the whole period**, whatever day the event begins on. An event created on the 4th Thursday of November with the rule `4__Thu__OF__Nov` fires that very day; one created on the 3rd of January with `2__WD__OF__MONTH` fires nothing in January (the 2nd weekday, the 2nd, is already past) and picks up in February. ### With a specific month (repeats yearly) Here the period is a month code, and you can use `ALL`, plus an optional year-interval suffix: | Rule | Meaning | | ------------------------- | ------------------------------------------- | | `4__Thu__OF__Nov` | 4th Thursday of November (US Thanksgiving) | | `-1__Mon__OF__May` | Last Monday of May (US Memorial Day) | | `1__Sun__OF__Nov` | First Sunday of November | | `ALL__Mon__OF__Jan` | Every Monday in January | | `ALL__Mon__OF__(Jan.Jul)` | Every Monday in January **and** July | | `4__Thu__OF__Nov2` | 4th Thursday of November, every 2nd year | *Example — `4__Thu__OF__Nov` →* `2023-11-23, 2024-11-28, 2025-11-27, …` ### With a tag, event, feed or category The inner rule can also be a data-backed reference (§11). This is how you express "the Nth **working day** of the month" — model your work days as a tag or event and pick from them: | Rule | Meaning | | --------------------------- | --------------------------------------------- | | `1__Tag(Work)__OF__MONTH` | First day carrying the `Work` tag, each month | | `-1__Tag(Work)__OF__MONTH` | **Last** work day of the month | | `1__Event(Work)__OF__MONTH` | First occurrence of the `Work` event, each month | | `-1__BankHolidays__OF__YEAR`| The last bank holiday of the year | *Example — `-1__Tag(Work)__OF__MONTH` →* the last `Work` day in each month. ### With a grouped day-set The inner rule can be a parenthesised day-set (§8), so you can pick from a chosen handful of weekdays: | Rule | Meaning | | ----------------------------- | --------------------------------------------- | | `2__(Mon.Wed.Fri)__OF__MONTH` | The 2nd day among Mon/Wed/Fri each month | | `-1__(Mon.Wed.Fri)__OF__MONTH`| The last Mon/Wed/Fri of the month | *Example — `2__(Mon.Wed.Fri)__OF__MONTH` (2024) →* `Jan 3, Feb 5, Mar 4, …` ### Anchoring the period to another rule Put a rule in brackets after the period and it stops repeating on the calendar and pins itself to **the period containing that rule's dates**: ```text {which}__{inner}__OF__{period}({anchor}) ``` `1__WD__OF__MONTH` is the first weekday of *every* month. `1__WD__OF__MONTH(25Dec)` is the first weekday of *December* — the month Christmas falls in. | Rule | Meaning | | --------------------------------- | ------------------------------------------------ | | `Fri__OF__WEEK(25Dec)` | The Friday of Christmas week | | `Mon__OF__WEEK(3Tue)` | The Monday of the week holding the 3rd Tuesday | | `WD__OF__WEEK(25Dec)` | Every weekday of Christmas week | | `1__WORKDAY__OF__WEEK(25Dec)` | The first work day of Christmas week | | `-1__WD__OF__MONTH(25Dec)` | The last weekday of December | | `1__Mon__OF__YEAR(Event(Launch))` | The first Monday of the launch year | The anchor is any rule at all — a date, another rule, or a data-backed reference (`Tag(X)`, `Event(X)`, `Feed(X)`, a category). Several anchors landing in one period describe the same period, so the result is not repeated. **Omitting `{which}` gives every matching day** of that period, which is why `Fri__OF__WEEK(25Dec)` reads as simply "the Friday of Christmas week" — there is only one. Supply `{which}` to pick by position, using the same grammar as above. **Why not just use `~`?** Because "the week of" and "the nearest" are different questions. `Mon~<3Tue` (§12) searches backwards for a Monday, so it needs you to know that Monday precedes Tuesday; `Mon__OF__WEEK(3Tue)` does not. More importantly, the nearest match can land in the wrong week: in 2028 Christmas is a Monday, so `Fri~=25Dec` gives Friday 22 December — nearer, but the week *before* Christmas — while `Fri__OF__WEEK(25Dec)` correctly gives the 29th. *Example — `Fri__OF__WEEK(25Dec)` →* `2025-12-26, 2026-12-25, 2027-12-24, 2028-12-29` --- ## 15. A specific ISO week of the year To land on days inside a numbered ISO week of the year, use `__OF__WEEK`: ```text {days} __OF__ WEEK{number} ``` `{days}` may be a single day code, a grouped set `(Mon.Wed.Fri)`, or `(WD)` / `(WE)`. | Rule | Meaning | | -------------------------- | ------------------------------ | | `Mon__OF__WEEK5` | The Monday of ISO week 5 | | `(Mon.Wed.Fri)__OF__WEEK5` | Mon, Wed and Fri of ISO week 5 | | `(WD)__OF__WEEK5` | Every weekday of ISO week 5 | Week numbers run 1–53. ISO weeks start on Monday, and week 1 is the week containing the year's first Thursday. This is "a day inside week 5", which is different from "the 5th Monday of the year". *Example — `Mon__OF__WEEK5` (2025) →* `2025-01-27`. --- ## 16. Limiting to certain months `LIMIT(...)` restricts **everything else** to only the listed months. Write it last. ```text Mon, Thu, LIMIT(Jan.Mar) ``` → every Monday and Thursday, but only in January and March. ```text WD, LIMIT(Jan.Feb.Mar) ``` → every weekday in Q1 only. Months inside `LIMIT(...)` are separated by dots. ### Everything *except* certain months with `!LIMIT(...)` Put a `!` in front to flip it: `!LIMIT(...)` keeps the **whole year except** the listed months. ```text Day, !LIMIT(Jan) ``` → every day of the year except in January. ```text Mon, !LIMIT(Jul.Aug) ``` → every Monday except during the summer break (July and August). The `!` here does **not** turn `LIMIT` into an inclusion — like plain `LIMIT` it only ever *removes* dates. It just flips **which** months are removed: `LIMIT(Jan)` removes everything but January; `!LIMIT(Jan)` removes only January. (See §20 on how `!` changes meaning depending on where it sits.) So `LIMIT(Jan)` and `!LIMIT(Jan)` are exact complements — together they cover the whole year with no overlap. ### Scoping a `LIMIT` to a group with `{ }` Normally a `LIMIT` applies to the **whole** schedule. Wrap some rules in braces `{ … }` to make a `LIMIT` (or any exclusion) apply only *within* that group: ```text {Mon, LIMIT(Jan.Mar)}, {Fri, LIMIT(Jul)} ``` → Mondays in Jan & March, **and** Fridays in July — each `LIMIT` stays inside its own group and doesn't touch the other. ```text {WD, !Fri}, Sun ``` → (weekdays except Friday) plus every Sunday — the `!Fri` only applies to the group, so it can't remove anything from the `Sun` rule. A group must contain at least one normal (non-`!`) rule, and groups can't be nested. --- ## 17. Moon phases For lunar schedules, use `MOON(...)`. ```text MOON(FULL) MOON(NEW) ``` You can target a **named phase**, an **illumination percentage** (0–100), or a **range**: | Rule | Meaning | | ---------------------- | -------------------------------- | | `MOON(FULL)` | The full moon | | `MOON(NEW)` | The new moon | | `MOON(100)` | Full moon (100% lit) | | `MOON(0)` | New moon (0% lit) | | `MOON(50..75)` | When the moon is 50–75% lit | | `MOON(50, WAXING)` | 50% lit while **growing** | | `MOON(50..75, WANING)` | 50–75% lit while **shrinking** | A given brightness happens twice a month (once growing, once shrinking), so adding `WAXING` or `WANING` picks just one side. (`FIRST_HALF` / `SECOND_HALF` are accepted as synonyms; `BOTH` is the default.) **Named phases:** `NEW`, `WAXING_CRESCENT`, `FIRST_QUARTER`, `WAXING_GIBBOUS`, `FULL`, `WANING_GIBBOUS`, `LAST_QUARTER`, `WANING_CRESCENT`. *Example — `MOON(FULL)` (2025) →* `2025-01-14, 2025-02-13, 2025-03-15, …` --- ## 18. Easter and the days that move with it Easter is worked out, not looked up. Write `EASTER` on its own for Easter Sunday, or name the day you want in brackets: ```text EASTER EASTER(GoodFriday) EASTER(Monday) ``` | Rule | Meaning | | --------------------------- | ------------------------------------------- | | `EASTER` | Easter Sunday | | `EASTER(GoodFriday)` | Good Friday | | `EASTER(Monday)` | Easter Monday | | `EASTER(AshWednesday)` | Ash Wednesday, the start of Lent | | `EASTER(MotheringSunday)` | Mothering Sunday | | `EASTER(Pentecost)` | Whit Sunday | | `EASTER(Orthodox)` | Orthodox Easter Sunday | | `EASTER(GoodFriday, Orthodox)` | Orthodox Good Friday | *Example — `EASTER` →* `2025-04-20, 2026-04-05, 2027-03-28, …` *Example — `EASTER(GoodFriday)` (2026) →* `2026-04-03`; *`EASTER(Monday)` →* `2026-04-06` **The days you can name**, as offsets from Easter Sunday: | Name | Days | Name | Days | | ----------------- | ---- | ---------------- | ---- | | `CleanMonday` | −48 | `Sunday` | 0 | | `ShroveTuesday` | −47 | `Monday` | +1 | | `AshWednesday` | −46 | `Ascension` | +39 | | `MotheringSunday` | −21 | `Pentecost` / `WhitSunday` | +49 | | `PalmSunday` | −7 | `WhitMonday` | +50 | | `MaundyThursday` | −3 | `TrinitySunday` | +56 | | `GoodFriday` | −2 | `CorpusChristi` | +60 | | `HolySaturday` | −1 | | | **Two reckonings:** `Western` (the default, and what the UK uses) or `Orthodox`. The bracket takes up to one day and one reckoning, in either order, each optional: `EASTER(Monday, Orthodox)` and `EASTER(Orthodox, Monday)` are the same rule. Any day name works with either reckoning — it is only an offset — though `AshWednesday`, `ShroveTuesday`, `MotheringSunday` and `CorpusChristi` are Western observances and `CleanMonday` an Orthodox one. *Example — `EASTER(Orthodox)` (2026) →* `2026-04-12` A few things worth knowing: - Names are spelled exactly as in the table, like a category member (`BankHolidays(GoodFriday)`). `EASTER(goodfriday)` and `EASTER(Tuesday)` are rejected. - `EASTER` is in capitals, like `MOON` and `WD`, because the engine computes it. PascalCase `Easter` is a *category* (§11) and still means whatever data you give it. - It is one date a year, so it composes with everything else here: `!EASTER(GoodFriday)` excludes it, `WD~25Dec` = a non-weekday, still additive). On an intersection it excludes the whole thing (§19). On a `LIMIT` it flips which months are removed (`!LIMIT(Jan)` = every month *except* January; §16) — it stays a filter, not an inclusion. You can't negate a single intersection operand, and you can't exclude a whole `~` rule. - **Brace groups** can't be nested, must hold at least one normal rule, and are always additive (a leading `!` on a group doesn't turn it into an exclusion). --- ## 21. Cheat sheet | Pattern | Example | Reads as | | ----------------------- | ------------------------- | ------------------------------------- | | `Day` | `Day` | every day | | `DayN` | `Day3` | every 3 days | | `Weekly/Monthly/Yearly` | `Monthly2` | every 2 months | | `Mon`…`Sun` | `Fri` | every Friday | | `N` | `Fri2` | every 2nd Friday | | `NMonth` | `5Month` | the 5th of each month | | `-1Month` | `-1Month` | last day of the month | | `NDec` | `25Dec` | every 25 December | | `-NDec` | `-1Feb` | last day of February | | `N` | `2Sat` | 2nd Saturday of the month | | `N_` | `2_Sat` | Saturday in the 2nd week | | `-N` | `-1Thu` | last Thursday of the month | | `WD` / `WE` | `WD` | every weekday / weekend day | | `NWD` / `NWE` | `-1WD` | last weekday of the month | | `WDn` / `WEn` | `WD2` | every 2nd weekday (by count) | | `WD_n` / `WE_n` | `WD_2` | weekdays in the 2nd week of the month | | `YYYY-MM-DD` | `2025-05-17` | that one date | | `!` | `!1Sat` | exclude | | `` | `BankHolidays` | a named group of dates | | `(Member)` | `BankHolidays(Christmas)` | one date from a group | | `Tag/Event/Feed(X)` | `Tag(Work)` | dates from a tag/event/feed | | `~=` | `WD~=25Dec` | nearest weekday to a date | | `!~` | `!WD~>25Dec` | non-weekday after a date | | `(Day.Day…)` | `(Mon.Wed.Fri)` | any day in the set | | `{ …, LIMIT(…) }` | `{Mon, LIMIT(Jan.Mar)}` | scope a LIMIT to a group | | `__OF__` | `-1__Fri__OF__MONTH` | last Friday of the month | | `__OF__` | `4__Thu__OF__Nov` | 4th Thursday of November | | `__OF__WEEK` | `Mon__OF__WEEK5` | Monday of ISO week 5 | | `__OF__(…)` | `Fri__OF__WEEK(25Dec)` | the period containing another rule | | `LIMIT(...)` | `LIMIT(Jan.Mar)` | restrict to these months | | `!LIMIT(...)` | `!LIMIT(Jan.Mar)` | every month except these | | `MOON(...)` | `MOON(FULL)` | on the full moon | | `EASTER(...)` | `EASTER(GoodFriday)` | Easter, or a day that moves with it | | `__&__` | `13Month__&__Fri` | matches both rules at once | | `+P` | `25Dec+P1Day` | that many days/weeks/months/years later | | `-P` | `25Dec-P1Day` | …and earlier — Christmas Eve | | `(CLAMP)` | `31Month(CLAMP)` | short months use their last day | | `(NEXT)` | `29Feb(NEXT)` | keep counting into the next month | --- ## 22. Recipes | What you want | Rule | | ------------------------------------------------ | ------------------------------ | | Every weekday except bank holidays | `WD, !BankHolidays` | | Every Saturday except the first two of the month | `Sat, !1Sat, !2Sat` | | Payday on the last weekday of the month | `-1__WD__OF__MONTH` | | Payday on the last **working** day of the month | `-1__Tag(Work)__OF__MONTH` | | Mondays and Thursdays, Q1 only | `Mon, Thu, LIMIT(Jan.Feb.Mar)` | | US Thanksgiving | `4__Thu__OF__Nov` | | Fortnightly team meeting on Wednesdays | `Wed2` | | Bin collection every other Tuesday | `Tue2` | | The 5th of every other month | `5Month2` | | Nearest weekday to Christmas | `WD~=25Dec` | | Friday the 13th | `13Month__&__Fri` | | Full-moon events | `MOON(FULL)` | | Good Friday / Easter Monday | `EASTER(GoodFriday)` / `EASTER(Monday)` | | Weekdays, minus the Easter break | `WD, !EASTER(GoodFriday), !EASTER(Monday)` | | Pancake day | `EASTER(ShroveTuesday)` | | Mothering Sunday | `EASTER(MotheringSunday)` | --- # Troubleshooting Things that go wrong, and why. Every entry here is known behaviour of the app as it ships today, not a guess. --- ## Alarms and reminders ### An alarm fired late, or not at all Almost always the **exact alarm** permission. Without it Android is free to batch your alarm into a convenient wake-up window to save battery, which can mean minutes late. Calendite has to ask for this separately from notifications because Android treats it as a special access. The setup wizard requests it; if you skipped that, it's in system settings under *Alarms & reminders*. Also check: - **Notification permission.** Without it nothing appears at all. - **Battery optimisation.** Aggressive vendor battery managers (common on Xiaomi, Huawei, OnePlus and Samsung phones) kill background work regardless of what Android promises. Exempt Calendite if alarms are unreliable. ### A reminder didn't ring while I was in a meeting That's **Do Not Interrupt**, and it's deliberate. A reminder with it switched on defers itself until the event you're in has ended, rather than ringing over you. It gives up after three automatic deferrals, so it can't be postponed forever. All-day events don't count as "in progress"; otherwise a single all-day event would silence an entire day. ### A repeating reminder isn't where I expect Check its **recurrence anchor**: - **On schedule**: dates come from the rule. Completing late changes nothing. - **After completion**: the next one is computed from when you actually finished. "Water the plants every 3 days" behaves completely differently under the two. See [Reminders and alerts](/docs/everyday-use/reminders-and-alerts.html). ### Alarms stopped after a reboot They shouldn't; alarms are re-armed on boot. If they didn't, Calendite was most likely blocked from running at startup by battery optimisation. --- ## Sharing and pairing ### Pairing fails and nothing explains why **This is a known bug.** On Android 13 and later, Calendite is currently missing a permission Android requires for the local network discovery that pairing uses. Without it, discovery **fails silently**: the two phones never find each other, and the app looks broken rather than blocked. Meanwhile, check the ordinary causes too: - **Both phones must be on the same Wi-Fi.** Not one on mobile data. Not on a guest network that isolates clients from each other. - **Client isolation** on some routers and most public Wi-Fi blocks phone-to-phone traffic entirely. - **The invite screen must be open.** The inviting phone only advertises while that screen is showing. ### The six digits don't match **Stop** and tap *They Don't Match*. Nothing has been exchanged yet (the calendar key isn't sent until both sides confirm), so nothing is lost by starting over. Matching digits are the whole security of the ceremony. Never tap through them. ### Sharing doesn't work when we're apart This is expected behaviour: the default is **same network only**, with no server involved. To sync when apart, pick a sync server at More → Settings → *Sync Server*. Until you do, the phones have no way to reach each other. See [What leaves your device](/docs/privacy/what-leaves-your-device.html) for what a relay can and can't see. ### I scanned an invite link and it told me to ask for a code Invite links you accept later **aren't available yet**. Use a live pairing: both phones present, QR scanned, digits confirmed. ### I removed someone but their events are still there That shouldn't happen. Removal purges their events and tags, revokes their device and rotates the calendar key. If events remain, report it as a bug. Note what removal *can't* do: anything they already synced is on their device. Revocation protects the future. ### A partner's events look exactly like mine This is a known gap. Owner markers, hatched "Busy" blocks and the read-only protection on partner events **aren't drawn yet**, even though the underlying data syncs correctly. So a partner's event currently looks like your own and appears editable. --- ## Notes ### My notes aren't moving between Past / Today / Future If you've pointed Calendite at your own folder (for example an Obsidian vault), **the nightly sweep doesn't run there**. It only ever sweeps the app's private storage. So bucket re-filing and description propagation silently don't happen for vault users. It's a known gap, not something you've misconfigured. Notes are still saved and readable; they just stay in the bucket they were created in. ### Tables and checkboxes show as raw text The rendered view uses plain CommonMark, so GitHub extensions (pipe tables, `- [ ]` task lists, `~~strikethrough~~`) don't render. Checkboxes aren't tappable either. ### A table or code block turned into `[Table · 0]` and then broke **This one can lose content.** Blocks the rich editor can't represent (code fences, tables, dividers, quotes, task lines) are swapped for placeholder lines like `[Table · 0]` while you're in **Edit** mode, and restored when you leave. But the placeholder is shown as ordinary editable text with nothing guarding it, so **editing or deleting that line destroys the block**. Edit those in **Markdown** mode instead. ### Toolbar buttons do nothing Five of them aren't available yet: **Heading**, **Strikethrough**, **Inline code**, **Link** and **Clear formatting**. The heading button draws a caret implying a menu that never opens. Working: Bold, Italic, Bullet list, Numbered list, and the four that insert literal markdown (Divider, Quote, Task, Table). For anything else use **Markdown** mode. --- ## Rules and repeats ### My rule produces no dates at all Most likely a **data-backed rule with no data**. `Tag(Work)`, `Event(Work)`, `Feed(BankHolidays)` and bare categories like `BankHolidays` resolve to *nothing* unless the app has the matching data. An event whose only rule is `Tag(Work)` with no Work events will never fire. Also check `WORKDAY`: it's an alias for `Tag(Work)` and has the same requirement. ### An excluded day came back **Order matters.** Rules apply left to right; `!` rules remove, ordinary rules add. ``` WD, !Fri ✅ weekdays except Friday !Fri, WD ❌ removes Friday, then adds all weekdays back ``` Put inclusions first, exclusions last. ### `2Sat` and `2_Sat` give different days They're different rules. `2Sat` is the **2nd Saturday**. `2_Sat` is the Saturday in the **2nd week**. They diverge whenever a month starts mid-week. ### My variant didn't replace the base event It isn't supposed to. **A variant supersedes the base only on the days the variant itself fires.** Every other day, the base still fires. If you want variants to be the whole schedule, give the base a rule that produces nothing on those days, or no rule at all. ### I changed the base event and the variants didn't follow This is expected behaviour. Times, duration, title, tag, colour and location are stored on the variant when it's saved. Only alerts, tags-by-fallback and visibility resolve live. Edit the variants. ### The "Applies to…" checkboxes on an inner event do nothing The checkboxes save and display correctly, but **aren't applied when occurrences are generated yet**. Don't rely on them. --- ## Feeds ### My feed isn't updating There's **no refresh schedule**. Feeds sync when you save one and when you bring the app to the foreground. No background polling, no "refresh now" button. ### The form saved but the rule doesn't work Probably the category name. The form only rejects empty names and ones starting with a digit, but the rule parser is stricter: it requires at least one lowercase letter. `UEFA` and `Sports2024` pass the form and then fail to parse. Use names like `BankHolidays`, `TermDates`, `BinCollections`. ### I can't save a feed at all Feeds are **Android only for now.** On iOS and web the form appears but can't be saved yet. --- ## Platform Several features aren't available outside Android yet. If something is missing rather than broken, check here first: | Feature | Android | iOS | Web | |---|:---:|:---:|:---:| | Calendar, events, rules | yes | yes | yes | | Alarms and notifications | yes | no | no | | Natural-language input | yes | no | no | | Notes | yes | no | no | | Data source feeds | yes | no | no | | Location search and map | yes | no | no | | Calendar sharing | yes | no | no | | Device calendar import | yes | recurring only | no | On iOS, *Change folder…* under Notes & files does nothing: the folder picker isn't available on iOS yet. --- ## Next - [What leaves your device](/docs/privacy/what-leaves-your-device.html) - [Editing and deleting](/docs/everyday-use/editing-and-deleting.html) - [Sharing your calendar](/docs/privacy/sharing-your-calendar.html) --- # Calendar views Three views (**Month**, **Week**, **Day**), swapped with the pill at the top of the Calendar tab. Plus the **Events** tab, which is a list rather than a grid. --- ## Month A seven-column grid, starting on whichever day your Country setting says the week starts (Monday in the UK). Multi-day events draw as a continuous bar across the days they cover, with a chevron end where the bar runs past the edge of a week and continues on the next row, so you can tell "ends Friday" from "carries on into next week" at a glance. Longer events get their lanes allocated first, so a one-day event can't cut a five-day bar in half. [Time periods](/docs/everyday-use/calendar-items.html) — holidays, time off — draw the same way, which is the point of them: a fortnight away reads as one bar with the title written once, not as fourteen identical chips. When a day has more events than fit, the surplus collapses into a dotted **+N** chip. Tapping a day selects it. On a phone that opens a sheet with the day's events; on a tablet or desktop the day agenda rail on the right updates instead. ### How a name is fitted A day is about eight letters wide on a phone, so a name like *Volunteering* is wider than the pill holding it. Rather than break the word wherever the space runs out, Calendite fits the name in three steps and stops at the first one that works. 1. **Shrink.** The text steps down a little, to a fixed smallest size, with every word kept whole. 2. **Borrow.** The pill reaches a few points into a neighbouring day, but only when that day has width to spare — nothing is squeezed to make room for something else. An empty Thursday gives freely; a Thursday whose own name only just fits gives nothing. 3. **Shorten.** Only now is a word cut, and it is cut the way an abbreviation is written: kept from the front, ended on a consonant, never fewer than three letters, and long enough that no other event on screen starts the same way. So *Volunteering - Ashford* reads **Volunt…** over **Ashford**, not "Voluntee" over "ring". *Appointment* becomes **App…**, the abbreviation you would have written yourself. *Physiotherapy* becomes **Phys…** rather than "Physiot…", because a cut never splits a pair of letters that make one sound. Two consequences worth knowing: - **The company a name keeps changes how it is cut.** A cut has to be long enough to tell an event apart from the others on screen, so *Volunteering* can shorten to **Vol…** in a quiet month, but keeps more of itself in a month that also holds *Volunteers Meeting*. - **A name with more words than will fit** keeps its first words whole and ends with an ellipsis, rather than squeezing every one. *Lunch with Mum* reads **Lunch** over **with…**, never "Lun…". The name is never dropped entirely. Even at its shortest a pill carries the start of the event's name, and the full name is one tap away. --- ## Week: the transposed view Every other calendar puts **days in columns and time down the page**. The consequence is that a one-hour meeting gets a sliver of width and its title is cut to a few characters. Calendite rotates it. **Dates run down the left edge, time runs across.** An event's **width is its duration**: a four-hour meeting gets four hours' worth of room for its title. The trade-off is that a whole day no longer fits a phone's width, so the time axis scrolls sideways. It opens at 08:00. What follows from the rotation: - **All seven days always fit** the screen height. Row height is the available space divided by seven. Opening the filter tray overlays the grid rather than squashing it. - **Overlapping events stack vertically.** Width is already spent representing time, so a clash splits the row into lanes. - **Labels adapt rather than truncate.** Each block picks the best of five modes for the room it has: title with times inside; title alone; label spilled onto the empty grid to the right; spilled to the left; or, when boxed in on both sides, the title rotated 90° to read down the block. - **Long events keep their titles in view.** The title tracks your scroll position along its own bar, clamped inside it, so it stays readable while any part of the block is on screen. - **Inner events are pills.** The bottom of a parent block darkens into a strip carrying one rounded pill per inner event, placed proportionally within the parent's span. - **All-day events** are full-width bands stacked on top of the row. - **Time periods appear on every day they cover**, not just the day they begin, so a holiday running into this week still shows here. - **Tap is peek, not jump.** Tapping any block, band or pill opens a small popover with the name and time; tapping *that* opens the event. So even a rotated, boxed-in event is two taps from its detail. - **Tapping a date** in the left column switches to Day view for that date. On a tablet the rows are more than twice as tall, so block text scales up with them (up to double). The chrome (the date column and hour ruler) deliberately doesn't scale. --- ## Day A classic vertical time grid: hours down the page, full midnight to midnight, opening at 08:00. Its distinctive trait is that it **scrolls continuously through days**. Keep scrolling past midnight and you roll into the next day, indefinitely: no paging, no swipe gesture to learn. Events that clash split the column side by side. An event with inner events gets a "folder tab" (a solid tab above a tinted body), and when scrolling carries the tab off screen the parent's label re-pins to the block's edge, so you always know which block you're inside. --- ## The Events tab A scrolling list of what's coming up, rather than a grid. It has the same tag filter as the calendar. A [time period](/docs/everyday-use/calendar-items.html) is listed once, on the day it starts, rather than once for each day it covers — a fortnight away would otherwise fill the list with fourteen copies of itself. It gives its length and the day it runs to instead of a time: *5 days · until 31/08/2026*. Note that an event can be **hidden from some views and not others**: visibility is set per view (Month / Week / Day / Events). A noisy daily recurrence can be visible in Day and hidden in Month so it doesn't drown the grid. See the event form's Visibility option. --- ## Filtering A pull-up tray at the bottom of the calendar holds your tags as chips. Tap one to filter. Filtering does something more useful than hiding: **it changes what the colours mean.** - **No filter**: every event is coloured by its **tag**, so you read the calendar by category at a glance. - **One tag selected**: events with that tag switch to their **own individual colours**, so you can tell Early Shift from Late Shift *within* Work. Everything else fades to a washed-out ghost, still visible but clearly out of focus. So filtering zooms in rather than deleting things. There's also a **Show Hidden** chip for temporarily revealing events hidden from the current view. On tablet and desktop the tray disappears and the filters live in the sidebar permanently. --- ## Large screens The layout responds to **window width**, not device type, so a split-screen phone behaves like a phone, and a desktop window adapts as you drag it. | Width | Layout | |---|---| | under 600dp | phone: bottom nav, pull-up filter tray, modal day sheet | | 600–839dp | icon rail replaces the bottom nav | | 840dp and up | full sidebar with filters + content + day agenda rail on the right | At the largest size all three views share a day agenda rail on the right showing the selected day's all-day and scheduled events, which replaces the phone's modal sheet. Phones stay portrait-locked; tablets are free to rotate. --- ## Large text If you turn your system font size up, several things change independently of window size: hour labels shorten from `08:00` to `8`, day headers stack, and month-grid pills drop their text and keep just the coloured bar, so the grid stays scannable and the full title is one tap away. --- ## Next - [Tags, colours and filtering](/docs/everyday-use/tags-colours-and-filtering.html) - [Inner and variant events](/docs/everyday-use/inner-and-variant-events.html) --- # Calendar items Not everything on a calendar is an event. A thing you have to *do* behaves differently from a thing you *attend*, and a fortnight in Spain behaves differently from both. Calendite makes that explicit: tapping **NEW** asks **Choose a Calendar Item** first, and what you pick decides which fields the form then shows you. There are four. | Item | What it is | Measured in | |---|---|---| | **Event** | Something that starts, and later ends. | Clock time | | **Reminder** | A nudge at a moment. Rings or notifies. | A single moment | | **Deadline** | A moment something is due by. Counts down. | A single moment | | **Time period** | A span of whole days — a holiday, or time off. | Days | You choose once, when you create the thing. What something *is* isn't a field you edit later, so the form never morphs under you mid-way through filling it in. --- ## Event The ordinary case. A start time, an end time, and a duration — or tick **All day** and it loses its times and sits in the band above the grid. Everything else in these docs — [repeats](/docs/everyday-use/repeating-events.html), [inner and variant events](/docs/everyday-use/inner-and-variant-events.html), [locations](/docs/everyday-use/location-and-places.html) — applies to events, and mostly to the other three as well. --- ## Reminder Something you have to do, rather than attend. It differs from an event in three ways that matter: - **No duration.** It's a point in time, not a span, and it's never all-day. - **The alert is mandatory.** A reminder that never fires is pointless, so it always carries exactly one — an **Alarm** or a **Notification**, chosen on the form. - **It can be completed.** Events happen to you; reminders get ticked off. A recurring reminder can also be anchored to *when you actually did it* rather than to the schedule. See [Reminders and alerts](/docs/everyday-use/reminders-and-alerts.html). --- ## Deadline A moment something is **due by**. Like a reminder it is a single point in time and can be ticked off — but its alerts are **optional**, because the countdown is the nag. Its detail screen leads with how long is left. Once the moment passes without being marked done, the same figure counts *up* instead, in the danger colour: how long it has been overdue. A deadline that quietly stopped mentioning it would have failed at the one job it has. A new deadline starts with three notifications already filled in — a week before, 9am the day before, and an hour before. They're seeded into the form, not forced, so edit or delete any of them like alerts you added yourself. --- ## Time period A span of **whole days**: a holiday you're going on, or time off work. Where an event has a start time and an end time, a time period has a start **date** and an end **date**. It is always all-day — a holiday that runs 09:00–17:00 isn't a thing — and its length is measured in days rather than hours. - **It can't be completed.** A holiday isn't a task. - **It doesn't count down.** That's what a deadline is for, and the app-wide countdown notification is skipped too — counting down to the start of something all-day would mean counting down to midnight. - **It appears on every day it covers**, not only the day it begins. ### Where it shows up | View | How it looks | |---|---| | **Month** | One continuous bar across the days it covers, titled once. Rounded ends where the period genuinely starts and finishes; a chevron where it simply carries on into the next week. | | **Week** | A band on each day's row. The week view is [transposed](/docs/everyday-use/calendar-views.html) — days are rows — so each row is named, keeping it readable when you're looking at one day on its own. | | **Day** | A band in the all-day strip, on every day of the period. | | **Events** | Listed once, on its start day, showing its length and the day it runs to. | ### Setting the dates Tapping either the start or the end date opens **one** picker showing the whole range, because a start and an end aren't two independent dates — an end before its start isn't something you should be able to express. Pick the start, then the end. Or type them: day, month and year each have their own box, so there's no separator to type, and the calendar above follows what you type. **Clear** empties the range and starts you again. Both the numeric date and the long form beneath it follow your device's locale, so the field order, the separator and the month names are whatever your phone already uses. ### Time periods can repeat Annual leave, or the same week off every year. A repeating time period has **two** different ends, and it's worth keeping them straight: | | What it means | |---|---| | **End date** | When *this* period finishes — the last day of the holiday. | | **Repeat ends** | When the *series* stops recurring, set in the Repeat sheet. | A trip running 24 December to 2 January, every year until 2030, uses both: the end date is 2 January, and the repeat ends in 2030. Each occurrence runs the same number of days from its own start, so the length travels with the period rather than every year ending on the first year's end date. --- ## Next - [Calendar views](/docs/everyday-use/calendar-views.html) — where each item shows up - [Reminders and alerts](/docs/everyday-use/reminders-and-alerts.html) — alarms, notifications and alert text - [Repeating events](/docs/everyday-use/repeating-events.html) — making any of these recur --- # Building repeats without typing [Repeating events](/docs/everyday-use/repeating-events.html) teaches the rule *language*. This page covers the *picker*, which is what most people will use and which can express nearly everything the language can. --- ## The repeat sheet On the event form, the **When** section has three modes: | Mode | For | |---|---| | **No Repeat** | a single date | | **Repeating Rules** | the picker (the default) | | **Manual Input** | plain English, or the rule written out | Choosing **Repeating Rules** opens a sheet with two lists, **patterns** (dates to include) and **exceptions** (dates to remove), plus a **RULE** bar showing what they've compiled to. Watch that bar. It's the fastest way to learn the language: build something with the pickers, read what it produced. --- ## The ten composers Adding a pattern or an exception offers ten kinds. Each has its own small editor. | Composer | Reads as | Example | |---|---|---| | **Every…** | a simple interval | Every 2 weeks | | **Days of week** | named weekdays | Mon, Wed & Fri | | **Day of month** | a date each month | 16th · last day | | **Nth weekday** | positional | 2nd Tue of the month | | **Date each year** | an anniversary | Every 25 Dec | | **Exact date** | one specific day | a single date | | **Category** | a named set of dates | Bank holidays | | **Around a date** | shifted to a working day | Workday before Christmas | | **Moon** | lunar | Full moon | | **Week of year** | ISO weeks | Mon of ISO week 5 | The same ten are available as **exceptions**: "every weekday" is a pattern, "bank holidays" is the same *Category* composer used as an exception, and together they give you `WD, !BankHolidays` without typing anything. ### Which to reach for - Something every N days/weeks/months → **Every…** - Specific weekdays → **Days of week** - "The 1st" or "the last day" → **Day of month** - "Second Tuesday" → **Nth weekday** - A birthday or anniversary → **Date each year** - Excluding holidays → **Category** as an exception - A date that must land on a working day → **Around a date** --- ## Order matters Patterns are applied first, then exceptions. The sheet keeps them in separate lists so you can't get this wrong, which you can if you hand-write a rule and put the `!` first. --- ## Limiting to certain months The sheet has a months control that compiles to `LIMIT(...)`. Tick January and March and the whole schedule only applies in those months. --- ## Previewing before you commit Open the **calendar preview** to see the actual dates the rule produces, month by month. Swipe between months to check the edges: the fifth Monday, February, the end of the year. Most rule mistakes are visible immediately here and invisible in the rule text. --- ## Add / Remove Events: bending the rule for individual days Some dates don't follow a pattern. Your Tuesday class is cancelled one week; you're covering a shift on a Sunday you don't normally work. **Add / Remove Events** opens a calendar you tap directly: - **Tap a day the event doesn't fall on** → it's added - **Tap a day it does** → it's removed Nothing is written until you press **Confirm**. Up to that point you're just marking days, and you can navigate between months freely; your marks are remembered. On Confirm, each change is folded into the event's rule as a literal inclusion or exclusion. So `WD` becomes `WD, !2025-12-25` or `WD, 2025-12-28`. ### Why these changes are reversible Because the changes live **in the rule**, they're visible and reversible. A date you removed can be tapped again to bring it back. There's no hidden pile of "modified instances" that you can never audit. It's also how you undo a **Delete this occurrence**: that does exactly the same thing, adding an exclusion, so the date reappears in this screen ready to be tapped back on. > **One caveat:** there's no validation step when you Confirm. A rule folded here is written > straight back. In normal use this screen can only produce valid rules, but nothing checks the > result. --- ## When to stop using the picker Reach for **Manual Input** when you want something the composers don't cover: - Combining two conditions at once: `13Month__&__Fri` for Friday the 13th - Picking out of your own events: `-1__Tag(Work)__OF__MONTH` for the last actual working day - Scoping an exception to part of a schedule with braces: `{WD, !Fri}, Sun` You can also type plain English and let the app translate it. And you can always go back: the picker and the rule bar stay in step. --- ## Next - [Repeating events](/docs/everyday-use/repeating-events.html): the rule language itself - [Editing and deleting](/docs/everyday-use/editing-and-deleting.html): one occurrence vs the whole series - [Rule reference](/docs/rules-reference.html): the full syntax, every form --- # Repeating events Calendite describes *when* something repeats with a short text rule. This page teaches the ideas and the forms you'll use most. For the exhaustive version, every form with worked examples, see the [rule reference](/docs/rules-reference.html). You never have to type a rule. The repeat sheet has ordinary pickers, and shows you the rule they compiled to; see [Building repeats](/docs/everyday-use/building-repeats.html) for that side of it. Reading the rule bar is the easiest way to learn the language. --- ## Three things first **1. Commas combine.** A schedule is a list of rules; the engine gathers the dates from each. ``` Mon, Thu → every Monday and every Thursday ``` **2. `!` removes.** A rule starting with `!` subtracts its dates. ``` WD, !BankHolidays → every weekday except bank holidays ``` **3. Order matters.** Rules apply left to right, adding and removing as they go. Put inclusions first and exclusions last. ``` WD, !Fri ✅ weekdays except Friday !Fri, WD ❌ removes Friday, then adds all weekdays back, so Friday returns ``` If a schedule has no positive rule at all, the event falls on its own start date. --- ## The everyday forms Day codes are `Mon Tue Wed Thu Fri Sat Sun`. Month codes are `Jan`…`Dec`. Both are capitalised exactly like that. | Rule | Meaning | |---|---| | `Day` | every day | | `Day3` | every 3 days | | `Fri` | every Friday | | `Fri2` | every 2nd Friday (fortnightly) | | `Weekly` / `Monthly` / `Yearly` | every week/month/year from the start date | | `Monthly2` | every 2 months | | `5Month` | the 5th of every month | | `-1Month` | the last day of every month | | `25Dec` | every 25 December | | `2Sat` | the 2nd Saturday of the month | | `-1Thu` | the **last** Thursday of the month | | `WD` / `WE` | every weekday / weekend day | | `2025-05-17` | that one date, and only then | `2Sat` is the 2nd *Saturday*. `2_Sat`, with an underscore, is the Saturday that falls in the 2nd *week*. They differ whenever a month starts mid-week. For February 2024, `2Wed` is the 14th but `2_Wed` is the 7th. Which days count as the weekend comes from your Country setting. --- ## Excluding things ``` Sat, !1Sat, !2Sat every Saturday except the first two of the month WD, !2025-12-25 every weekday, but not Christmas Day 2025 WD, !BankHolidays every working weekday ``` `BankHolidays` there is a **category**: a named set of dates that lives outside the rule. It only produces dates once you've given Calendite a source for it; see [Data sources and feeds](/docs/everyday-use/data-sources-and-feeds.html). --- ## Referring to your own events This is the part with no equivalent in other calendars. A rule can name a tag, an event series or a feed and use its dates: | Rule | Means | |---|---| | `Tag(Work)` | every day carrying the `Work` tag | | `Event(Work)` | the occurrence dates of the series named `Work` | | `Feed(BankHolidays)` | the dates a configured feed supplies | `Tag(Work)` and `Event(Work)` are different sets: one is every event sharing a tag, the other is one specific series. The rule names which you mean. The payoff is being able to pick out of them: ``` -1__Tag(Work)__OF__MONTH the last working day of the month ``` Here "working day" means whatever your Work events say. Change your shifts and the rule follows. > **Careful:** these need a data source. On their own they resolve to nothing, and an event > whose only rule is `Tag(Work)` with no Work events will never fire. --- ## Picking the Nth thing out of a period ``` {which}__{what}__OF__{period} ``` `{period}` is `DAY`, `WEEK`, `MONTH`, `YEAR`, or a specific month (`Jan`…`Dec`). `{what}` is `WD`, `WE`, a day code, a group like `(Mon.Wed.Fri)`, or a tag/event/feed reference. `{which}` is `1`, `-1` (counting from the end), a range `(1..5)`, a set `(1.3.5)`, or `ALL` (specific months only). | Rule | Meaning | |---|---| | `1__WD__OF__MONTH` | first weekday of the month | | `-1__Fri__OF__MONTH` | last Friday of the month | | `4__Thu__OF__Nov` | US Thanksgiving | | `ALL__Mon__OF__(Jan.Jul)` | every Monday in January and July | | `-1__Tag(Work)__OF__MONTH` | last work day of the month | --- ## The rest, briefly **Shift to a working day** with `~`. Useful when a fixed date lands on a weekend: ``` WD~<25Dec the weekday just before Christmas WD~=25Dec the nearest weekday to Christmas !WD~>25Dec the first non-weekday after Christmas ``` Note the type comes first: `WD~<25Dec`, not `25Dec~ Natural-language input is **Android only for now.** --- ## Ending a repeat Separate from the rule itself, in the form's **Ends** control: never, on a date, or after a number of occurrences. --- ## Cheat sheet | Want | Rule | |---|---| | Every weekday except bank holidays | `WD, !BankHolidays` | | Fortnightly team meeting on Wednesdays | `Wed2` | | Bin collection every other Tuesday | `Tue2` | | The 5th of every other month | `5Month2` | | Payday, last weekday of the month | `-1__WD__OF__MONTH` | | Payday, last actual working day | `-1__Tag(Work)__OF__MONTH` | | Every Saturday but not the first two | `Sat, !1Sat, !2Sat` | | Mondays and Thursdays, Q1 only | `Mon, Thu, LIMIT(Jan.Feb.Mar)` | | US Thanksgiving | `4__Thu__OF__Nov` | | Nearest weekday to Christmas | `WD~=25Dec` | | Friday the 13th | `13Month__&__Fri` | | Full moons | `MOON(FULL)` | | Good Friday, no feed needed | `EASTER(GoodFriday)` | --- ## Next - [Inner and variant events](/docs/everyday-use/inner-and-variant-events.html): schedules built on other schedules - [Data sources and feeds](/docs/everyday-use/data-sources-and-feeds.html): where `BankHolidays` comes from - [Rule reference](/docs/rules-reference.html): the complete syntax, every form and edge case --- # Editing and deleting Every edit to a repeating event applies either to one occurrence or to the whole series. Calendite asks which. --- ## Deleting A repeating event offers two delete options that do different things: | | What it does | |---|---| | **Delete this occurrence** | Removes **one date**. The series carries on. | | **Delete** | Removes the **whole series**: every past and future occurrence, and any inner or variant events hanging off it. | **Delete this occurrence only appears when the event repeats.** A one-off event has just the one option, because for it the two are the same thing. ### Deleting one occurrence doesn't really delete anything Removing a single date does not erase a record. It **adds an exclusion to the event's rule**. A `WD` event with 25 December removed becomes, in effect, `WD, !2025-12-25`. Two consequences: - **It's reversible.** The date can be brought back from **Add / Remove Events** (see [Building repeats](/docs/everyday-use/building-repeats.html)). - **You can read it.** Open the rule and the exclusions are right there, so it's obvious why a Thursday in March is missing. That's the opposite of how most calendars work, where a deleted instance becomes an invisible record you can never find again. --- ## Editing Editing an event edits **the whole series**. Change the time on your Monday standup and every Monday standup moves. If you want one day to be different, you have three options: | You want | Use | |---|---| | One date gone | **Delete this occurrence** | | One date added that the rule wouldn't produce | **Add / Remove Events** | | A recurring *shape* that differs, such as different hours on certain days | A **[variant event](/docs/everyday-use/inner-and-variant-events.html)** | There's deliberately no "edit just this one occurrence" that silently forks a copy. If the difference is a one-off, change the dates. If it's a pattern (Early Shift, Late Shift), use a variant, which stays linked and stays maintainable. --- ## Deleting inner and variant events - **Deleting an inner event** removes it. Its parent is untouched. - **Deleting a variant** removes that variant. The base event and the other variants are untouched, and the base will now fire on the days the variant used to cover, since nothing is suppressing them any more. - **Deleting a base event** takes its variants and inner events with it. An inner event only exists inside its parent, so there's nowhere for it to go. --- ## Editing a base doesn't update its variants Covered in more detail in [Inner and variant events](/docs/everyday-use/inner-and-variant-events.html), but it belongs here too because it surprises people: **Changing a base event's time, duration, title, tag, colour or location does *not* change variants that already exist.** Those values were resolved and stored when the variant was saved. The exceptions that *do* flow through: - **Alerts**: add an alert to the base and existing variants get it. - **Tags**: a variant with no tags of its own follows the base's. - **Visibility**: variants inherit per view by default. If you change a base's hours and want the variants to follow, edit them. --- ## Ending a repeat rather than deleting it If something has finished, you usually want to keep the history rather than delete it. Use the **Ends** control on the event instead: - **Never** - **On a date** - **After N occurrences** Past occurrences stay on the calendar; the series just stops producing new ones. --- ## Next - [Building repeats](/docs/everyday-use/building-repeats.html): the picker, and adding or removing individual dates - [Inner and variant events](/docs/everyday-use/inner-and-variant-events.html) --- # Inner and variant events Two ideas that don't exist in other calendars, and the reason Calendite can describe schedules that other calendars force you to maintain by hand. Both hang off an **Event Series**, the thing you think of as "an event": Work, Yoga, Book Club. A series has a rule that decides which days it lands on. --- ## Inner Events: things that only happen inside other things A stand-up at 09:15 on work days. Not "on weekdays": on **work days**. Bank holiday, no stand-up. Late shift, the stand-up moves with it. An **Inner Event** is a child series whose rule is implicitly intersected with its parent's. It *cannot* occur on a day its parent doesn't. You don't write the intersection; it's structural. ### Creating one From an event's detail screen, **Add inner event**. You get the ordinary event form plus one extra decision: how its timing relates to the parent. | Timing | Behaviour | |---|---| | **Absolute** | a fixed clock time: 09:15, whatever the parent does | | **Relative** | an offset: "15 minutes after it starts", "30 minutes before it ends" | Relative is usually what you want for anything attached to a shift, because moving the parent moves the child. ### Its rule Leave the rule blank and the inner event rides **every** parent occurrence. Give it a rule and it's intersected with the parent's, so `Mon` on a parent of `WD` means "Mondays that are also work days". ### How it looks Inner events don't compete with their parent for space. In the week view the bottom strip of the parent block darkens and carries one pill per inner event, positioned proportionally within the parent's span. In the day view the parent gets a "folder tab" and the inner events sit inside it. On the parent's detail screen they appear as a miniature day-view of the parent's window. ### Limits - One level deep: an inner event can't have its own inner events. - An inner event belongs to exactly one parent. - Inner events never appear on their own in the calendar; they're always inside a parent. - Inner events **do not inherit the parent's alerts**. The detail screen shows the parent's alerts for context, but nothing is scheduled from them; give the inner event its own. --- ## Variant Events: one thing, several shapes You work Earlys, Lates and Nights. One job. One tag, one colour, one set of notes, but three different sets of hours. A **Variant Event** is a named variation of a series that differs in a few attributes and shares the rest. ``` Work ├── Early Shift 06:00–14:00 Mon2, Wed2, Fri2 ├── Late Shift 14:00–22:00 Tue2, Thu2 └── Night Shift 22:00–06:00 -1Sat ``` Usually the difference is times, but it can be location, alerts, colour, tag or visibility. ### Creating one From the base event's detail screen, **Add variant**. You name it (*Late Shift*) and override only what differs. Anything you leave alone is taken from the base. Each variant carries its own rule, so it decides its own days. ### How base and variants interact A variant supersedes the base **only on the days that variant itself fires**. Every other day, the base still fires normally. So a base of `WD` with a Night Shift variant on `-1Sat` gives you weekdays **and** the last Saturday. The base is not switched off by the existence of variants. If you want the variants to be the *whole* schedule, give the base a rule that produces nothing on the days the variants cover, or don't give the base a rule at all. ### What inherits, and what doesn't | | Behaviour | |---|---| | Title, times, duration, location, colour | **Snapshot at save.** Editing the base later does **not** update variants that already exist. | | **Alerts** | **Live.** An alert added to the base afterwards does reach existing variants. A variant can also suppress a base alert or add its own. | | **Tags** | Falls back to the base's tags if the variant has none of its own. | | **Visibility** | Live: variants default to inheriting from the base per view. | If you change a base event's hours and want the variants to follow, you have to edit them. ### Variants and inner events A base event's inner events don't appear on days a variant fires: the base isn't running that day, so its children aren't either. If a variant needs a stand-up, give the variant its own inner event. > There is an *"Applies to…"* selector on inner events for choosing which variants they > apply to. It saves and displays your choice, but it doesn't change which occurrences > appear yet; it has no effect today. Don't rely on it. --- ## Which one do I want? | Situation | Use | |---|---| | Something that happens *during* a bigger thing | **Inner event** | | The same commitment with different hours on different days | **Variant** | | Two unrelated things | Two separate events | | Same thing, different people/place, same hours | One event, edit the occurrence | A rough test: if cancelling the parent should cancel it too, it's an **inner event**. If it *is* the parent wearing different clothes, it's a **variant**. --- ## Terminology The precise definitions live in the [glossary](/docs/reference/glossary.html): Event Series, Event Instance, Inner Event, Outer Event, Variant Event, Base Event, Draft Event. --- ## Next - [Repeating events](/docs/everyday-use/repeating-events.html): the rules these are built on - [Reminders and alerts](/docs/everyday-use/reminders-and-alerts.html): what fires and when --- # Reminders, alerts and alarms Three words that mean three different things in Calendite. - An **Event Alert** is a thing attached to an event that fires at a time. It is either an **Alarm** or a **Notification**. - An **Alarm** rings and takes over the screen, even locked, like a phone alarm clock. - A **Notification** is a normal Android notification. No takeover. - A **Reminder** is not an alert at all. It's one of the four [calendar items](/docs/everyday-use/calendar-items.html) — the one you have to do rather than attend. --- ## Alerts on an event Add them from the event form's **Alerts** tile. An alert fires either at an exact date and time, or relative to the event's occurrence: "10 minutes before it starts", "5 minutes after it ends". Choose **Alarm** or **Notification** per alert. An event can carry several. ### Alert templates An alert's title and body can pull values out of the event using `%` codes: ``` %t starts in %tm minutes ``` → *"Stand-up starts in 10 minutes"* There are 27 codes. The ones you'll use most: | Code | Gives | |---|---| | `%t` | the event title | | `%T` | the alert's own title | | `%tm` `%th` `%td` | minutes / hours / days until (or since) | | `%tf` `%tc` | time until, full (`2 hours 15 minutes`) or compact (`2h 15m`) | | `%dm` `%dh` `%df` `%dc` | the event's duration, same four ways | | `%Et` `%ET` | start time, 24-hour or AM/PM | | `%Ee` | end time | | `%Ed` `%ED` `%Ew` | date (locale), short date, day of week | | `%l` `%L` | location, full or short | | `%g` `%G` | tag name, tag colour | | `%n` `%N` | the current time / date | | `%%` | a literal `%` | The full list with descriptions is in the [alert template syntax reference](/docs/reference/alert-template-syntax.html). The same codes work whether the alert fires before, during or after the event: `%tm` says "in 10 minutes" beforehand and "10 minutes ago" after. On a ringing alarm the time-relative codes re-render about once a minute, so the count stays accurate while it rings. If a code has nothing to resolve to (`%l` on an event with no location), it renders as nothing rather than an error. ### Launching an app from an alarm An alarm can carry an app to open. The ringing screen shows it as a badge with **TAP TO OPEN** curved around it, useful when the alarm is *for* something: a workout app or a meditation timer. If the app has since been uninstalled the badge says so rather than failing silently. --- ## Reminders Pick **Reminder** on the **Choose a Calendar Item** step that opens when you tap **NEW**. A reminder differs from an event in three ways that matter: - **No duration.** It's a point in time, not a span. It's never all-day. - **The alert is mandatory.** A reminder that never fires is pointless, so you can't save one without an alert. - **It can be completed.** Events happen to you; reminders get ticked off. ### Recurrence anchor A repeating reminder computes its next occurrence one of two ways: | Anchor | Behaviour | |---|---| | **On schedule** (default) | dates come straight from the rule. Completing late, or deferring, never moves future occurrences. | | **After completion** | the next one is counted from your completion time plus the interval. | *"Water the plants every 3 days"* almost certainly wants **after completion**: three days after you last watered them, not every third calendar day. *"Submit timesheet every Friday"* wants **on schedule**: Friday is Friday whether or not you did last week's late. ### Do not interrupt A per-reminder switch. When on, a reminder that would fire while you're in the middle of an event defers itself until that event ends rather than ringing over your meeting. All-day events don't count as "in progress" for this; otherwise a single all-day event would silence a whole day. It gives up after three automatic deferrals so a reminder can't be postponed forever. ### When one rings **Done** or **Remind Me Later**. "Later" opens a picker so you choose a time, not a fixed snooze interval. Completed reminders stay visible on the calendar, struck through and faded, so you can see what you've done rather than having it vanish. --- ## Alarms in practice An alarm shows a full-screen ringing screen over the lock screen, with slide-to-dismiss rather than a button, so it is hard to silence by accident in your pocket. Two Android permissions matter: - **Notifications**: without it, nothing appears at all. - **Exact alarms**: without it Android may fire alarms late to save battery. Calendite has to request it separately because Android treats it as a special access. Granted in the setup wizard, or later from system settings. Alarms survive a reboot: they're re-armed on boot. --- ## Where alerts don't reach - **Inner events do not inherit their parent's alerts.** The inner event's detail screen shows the parent's alerts for context, but nothing is scheduled from them. Give an inner event its own alerts if it needs them. - **Alerts are not shared.** When you share a calendar, alerts and notes are deliberately excluded. Your partner sees the event, not your reminders about it. - **Variants do inherit alerts** from their base, live, and can suppress or add to them. --- ## Next - [Inner and variant events](/docs/everyday-use/inner-and-variant-events.html) - Full variable list: [alert template syntax](/docs/reference/alert-template-syntax.html) --- # Launching apps from alarms An alert can carry an app. When it fires, the screen shows that app's real icon with **TAP TO OPEN** curved underneath, and tapping it stops the alarm and opens the app. > **Android only for now.** iOS provides no way to list the apps on a device or to open one > by name, so the app option isn't available there yet. The case it was built for: a repeating reminder to order your repeat prescriptions. At 8am the alarm goes off showing the NHS App icon. You tap it, the ringing stops, the app opens. No URL to remember, no hunting through the launcher while half awake. It works the same way for anything an alarm is *for* rather than *about*: a workout app, a meditation timer, the banking app you set the alarm to make you use. --- ## Attaching an app The app is attached to an individual alert, not to the event, so it's set in the same place you set the alert's time and choose between **Alarm** and **Notification**. See [reminders and alerts](/docs/everyday-use/reminders-and-alerts.html) for how alerts work generally. 1. Add or edit an alert on an event. 2. Tap the **App** field. Unset, it reads *Choose app*. 3. Pick an app from the sheet that slides up. 4. The field now shows that app's own icon and name. One app per alert. To change it, tap the field again; to remove it, tap the **×** on the right of the field. --- ## The Choose App sheet A sheet over your calendar, not a separate screen. The calendar stays visible behind it. - Every app you can open from your home screen, listed alphabetically with its real icon. - **Search apps** filters as you type, and matches anywhere in the name: typing `amaz` finds *Amazon Shopping*. - An alphabet rail down the right edge jumps you to a letter. - **Cancel** in the header closes it, as does dragging the sheet down or tapping outside it. - Tapping an app selects it and closes the sheet. Re-open it later and the app you already chose is ticked. On a phone with a lot of apps installed the list takes a moment to appear the first time; it's reading every icon off disk. Calendite only asks Android for apps that have a home-screen entry. It does not request the broader permission to see everything installed on your device, and nothing about which app you pick ever leaves the phone. --- ## When the alarm rings The bell that normally sits at the top of the ringing screen is replaced by the app's own launcher icon in a circle, with the app's name curved above it and **TAP TO OPEN** curved below. Everything else behaves as it always does: the alert text, snooze, and slide-to-dismiss are unchanged, and an alarm with no app attached still shows the bell. Tapping the icon does three things in order: stops the ringtone, opens the app, and closes the alarm screen. Stopping first matters; an alarm still ringing over the app you just opened is the obvious way to get this wrong. Closing the alarm screen means pressing Back from the app takes you home rather than to a dead alarm. The icon is fetched fresh every time rather than stored, so it follows the app through redesigns and updates instead of going stale. --- ## If the app is uninstalled Calendite saves the app's name alongside it, so it can always tell you *which* app has gone missing rather than showing a blank. **While you're editing.** The alert's App field and the event's alarm card both read *NHS App uninstalled* in muted text, deliberately not styled as though it's set. The field stays tappable so you can swap in a replacement. You find out on the edit screen rather than at 8am. **On the ringing screen.** The badge dims and reads **App Uninstalled**, with no *Tap to Open* and no tap target. There's nothing to open, and offering a tap that does nothing is worse than saying so. The layout doesn't shift, so the alarm looks the same shape either way. --- ## What it doesn't do - **It opens the app, not a page inside it.** You land on wherever the app normally starts, not on its prescriptions tab or a specific screen. - **One app per alert.** One badge, one app. - **No store link.** When an app is missing the alarm screen says so; it won't offer to reinstall it for you. - **Home-screen apps only.** Shortcuts and widgets can't be attached. --- ## Next - [Reminders and alerts](/docs/everyday-use/reminders-and-alerts.html) - [What leaves your device](/docs/privacy/what-leaves-your-device.html) - [Troubleshooting](/docs/troubleshooting.html) --- # Tags, colours and filtering Two things share the word "grouping" in Calendite and are otherwise unrelated. - A **Tag** is for *display and filtering*. It has a colour and can pre-fill new events. - A **Category** is for *rules*. It's a named set of dates a rule can add or subtract. It has no colour and no screen of its own. You attach tags to events. You reference categories inside recurrence rules. They meet only at the `Tag(X)` selector, which lets a rule use a tag's dates. --- ## Tags Managed at More → **Tags**. A tag has: - a **name** - a **colour**, stored as a pair (one for light mode, one for dark) so it stays legible in both - optional **defaults**: values that pre-fill the form whenever you start a new event with that tag Your first tags come from the [setup wizard](/docs/getting-started.html). Skip the wizard and you start with none; you can add, edit and delete them at any time from More → Tags. --- ## Tag defaults A tag isn't only a colour. It can carry a set of default values that are applied to the New Event form the moment you pick it, so choosing `Work` gets you a form that already knows what a work shift looks like, and creating the event is typing a title and saving. Set them on the tag's own screen, below the **Defaults** divider: | Default | What a new event with this tag gets | |---|---| | **All Day** | it starts as an all-day event | | **Recurrence** | it arrives already repeating, on the rule you set | | **Default alarms** | the tag's alarms and notifications, copied onto the event | | **Visibility** | shown or hidden in Month, Week, Day and Events to match the tag | Every one of them is optional and independent. A tag that only cares about alarms leaves everything else alone. A `Work` tag that defaults to `WD, !BankHolidays`, a 15-minute alert and hidden-from-Month means creating a work shift is picking a tag and typing a title. ### How it plays out Tap **+**. If you have any tags at all, Calendite asks you to **choose a tag** before it opens the form, and tags carrying defaults are marked with a small dot. Pick one and the form opens pre-filled; pick **No tag** and you get the plain form. With no tags in the app the question is skipped entirely. Nothing is locked. Every pre-filled field is still yours to change before you save. Two rules apply. **Defaults apply on the way in, not afterwards.** They come from the tag you choose before the form opens. Adding or swapping a tag from inside the form (or on an event that already exists) changes only the tags, deliberately, so it can never overwrite something you've already typed. **Defaults seed, they don't bind.** They're copied into the event as it's created. Editing a tag's defaults later doesn't reach back and rewrite the events you've already made with it. --- ## Events with more than one tag An event isn't limited to one tag. The New Event form and an event's detail screen both show a **Tags** field: a row of pills with **+ Add tag** on the end. Add as many as make sense; remove one with the `×` on its pill. Order is priority order. The first pill is the **primary** tag, and it's the one that decides the colour drawn on the calendar. Remove the primary and the next tag along is promoted into its place; remove them all and the event is untagged. One colour per event, always: the grid stays legible, and a colour keeps meaning one real thing rather than "several things at once". Everything *other* than colour treats an event's tags as a set: - **Filtering** matches any of them. An event tagged `Work` then `Birthday` shows up under the Birthday filter as well as the Work filter, even though its pill is Work-coloured. - **Revealing hidden events** works from any of them too; see Visibility below. - **Rules see all of them.** A rule that excludes `BankHolidays` still excludes an event carrying that tag second rather than first. This matters: a day that is a bank holiday *and* a Christian holiday is still a bank holiday, whichever tag happens to own its colour. Tags also accumulate on their own. The same event arriving from more than one feed ends up as a single event holding a tag from each; see [data sources and feeds](/docs/everyday-use/data-sources-and-feeds.html). --- ## How an event gets its colour Two separate things happen. **When the event is created**, it's assigned its own permanent **event colour**. If it's the first event of its tag, it takes the tag's colour. Otherwise Calendite picks the colour *most visually distinct* from the ones already in use (comparing in a perceptual colour space, and avoiding the app background as well as the existing colours), so events within a tag stay tellable apart. That colour is keyed to a stable identifier derived from the event's title the first time it's created. Renaming the event doesn't change that identifier, so the colour survives renames. **When the calendar draws**, the colour it shows depends on the filter: | State | Shown colour | |---|---| | No filter | the **tag** colour: read the calendar by category | | One tag selected, event has it | the event's **own** colour: tell Earlys from Lates | | One tag selected, event doesn't have it | a washed-out ghost, still visible | | Untagged event | its own colour | | Reminder | always its own colour, never the tag's | This is why filtering feels like zooming in rather than hiding: it swaps the calendar from "what kind of thing is this" to "which specific thing is this". Text on a pill is black or white depending on the fill's brightness, so it stays readable whatever colour lands. --- ## Filtering The pull-up tray at the bottom of the calendar holds your tags as chips. Tap to select; tap again to clear. Selection is single: picking a second tag replaces the first. There's also a **Show Hidden** chip, which temporarily reveals events hidden from the current view. On tablet and desktop the tray is replaced by a permanent filter list in the sidebar. --- ## Visibility Separate from filtering, and per-view. An event can be set visible or hidden independently in **Month**, **Week**, **Day** and **Events**. Hidden means *hidden*, not ghosted. The event isn't drawn at all. That's deliberately different from tag filtering, which drains the colour out of an event but keeps it exactly where it is. The case this exists for: a daily pill reminder, or a Monday-to-Friday work shift. You already know you're at work on Tuesday, and thirty pill reminders turn the Month grid into noise. Visibility lets the event carry on existing (holding its details, firing its alarms, still counting for any rule that references it) whilst being absent from the surfaces where it would only clutter. Hide it in Month, keep it in Day. Hidden events aren't lost. Selecting a tag in the filter tray reveals that tag's hidden events, and **Show Hidden** reveals every hidden event. A revealed event is drawn exactly like any other (no dashed border, no marker) because the filtered view is showing you the true picture. Variants and inner events default to **inheriting** their parent's visibility per view, and a variant can override it: hide a `Work` base event from the Month grid but set its `Earlys` variant to visible, and the month shows early shifts and nothing else. --- ## Categories, briefly A category is a PascalCase name usable inside a rule: ``` WD, !BankHolidays ``` It resolves to a set of dates when the rule is evaluated. Categories come from [data source feeds](/docs/everyday-use/data-sources-and-feeds.html) you configure. The explicit forms are `Tag(X)`, `Event(X)` and `Feed(X)`; see [repeating events](/docs/everyday-use/repeating-events.html). --- ## Theme Colours are defined once and derived for the opposite theme automatically: pick a colour in light mode and Calendite works out a dark-mode counterpart that keeps enough contrast against the dark background, rather than just darkening it. --- ## Next - [Calendar views](/docs/everyday-use/calendar-views.html): where these colours land - [Data sources and feeds](/docs/everyday-use/data-sources-and-feeds.html): where categories come from --- # Location and places Attaching a place to an event, and what the app does with it. > **Android only for now.** On iOS and web the location sheet reports that search isn't > supported: address search and the map preview aren't available yet on those platforms. --- ## Adding a location Open an event's **Location** option. Type an address and suggestions appear as you go; pick one and the map preview updates. **Done** saves it, and clearing the field removes it. Two fields sit below the address: | Field | For | |---|---| | **Room** | "Meeting Room 3", "Ward B", "Studio 2" | | **Floor** | "4th floor", "Basement", "Mezzanine" | A geocoder can find the building. It cannot know which room you're supposed to be in. Those two fields are yours to fill and are stored alongside the address rather than merged into it. You don't have to search at all: typing a plain label and leaving it works. You just won't get coordinates or a map. --- ## What the suggestions are Address search is powered by **Photon**, an open-source geocoder over OpenStreetMap data. Each suggestion carries a badge saying what kind of place it is (house, street, venue, locality), which is also what sets the map preview's zoom, so a venue opens closer in than a town. The search is deliberately restrained: it waits **350 ms** after you stop typing, needs at least **2 characters**, and shows at most **5 results**. This limits the load on a free shared service. --- ## The home-region bias Address search is biased toward where you schedule things, so typing "the crown" surfaces the one near you rather than one four hundred miles away. Calendite works this out **without ever asking you and without using device location**: 1. **Cold start**: before it knows anything, it guesses a country from your time zone (falling back to your locale) and uses that country's centre. 2. **Learned**: it remembers the coordinates of your last **20** picks and uses their median. It uses a median, not an average, so one trip abroad doesn't drag the bias across a continent. This is a **ranking hint only**. It never excludes results; it only decides what floats to the top. Nothing about it is shared, and it is not your location: it is the middle of the places you've been choosing. --- ## The map preview A small non-interactive map under the address, styled to match the app in light and dark. You can zoom it; it supports no other interaction. Its only job is confirmation: that the address the geocoder matched is the place you meant, which is much easier to see on a map than to read in a string. --- ## Coordinates When you pick a suggestion, the latitude and longitude are stored with the event, not just the text. Nothing uses them today beyond the preview. They're kept because travel-time features ("leave by…") need them, and capturing them from the start means that work needs no migration later. A hand-typed location has no coordinates and no map; it is fully supported. --- ## Inner and variant events Both can override their parent's location, and both use this same sheet with full structured addresses. Leaving a child's location empty means **inherit the parent's**. --- ## Privacy Two things reach third-party servers when you use this feature. Neither is optional while the feature is in use, and neither is mentioned elsewhere in the app: - **What you type into the address field** goes to `photon.komoot.io` to be geocoded as you type, debounced. - **The area you're looking at** goes to `tiles.openfreemap.org`, which serves the map tiles, fonts and icons. This happens even though the map's styling is bundled with the app. Neither request carries your identity, your account (there isn't one) or anything about the event: only the query text or the tile coordinates, with a `Calendite/1.0 (Android)` user agent. If you'd rather not make either request, don't use address search: type a plain label instead. Both endpoints are configurable in one place for anyone self-hosting. See [What leaves your device](/docs/privacy/what-leaves-your-device.html) for the complete picture. --- ## Next - [What leaves your device](/docs/privacy/what-leaves-your-device.html) - [Getting started](/docs/getting-started.html) --- # Notes and descriptions Calendite's notes are **plain Markdown files in a folder you choose**. Not rows in a database, not a proprietary format. You can point it at an Obsidian vault and edit the same files from either side. > Notes are **Android only for now.** On iOS and web the notes UI doesn't appear. --- ## Description vs notes - A **description** is the one bit of prose attached to the event itself. One per event, shared across all its occurrences. Stored as `Description.md`. - A **note** is anything else you want to write. An event can have many, and a note can be attached to the whole event or to one specific occurrence. --- ## Three kinds of note When you create one, the **Make** control offers: | Kind | Where it lives | |---|---| | **Whole Event** | at the event's folder root: one copy, shared by every occurrence | | **This Instance** | inside a single occurrence's folder: only that day | | **Recurring** | a master template that's seeded fresh into every future occurrence | "This Instance" only appears when you're looking at a specific occurrence. **Recurring** is a template: each occurrence gets its own independent copy to fill in. A "Handover" note on a shift, a checklist on a weekly meeting. Seeding is **forward-only**: creating a recurring note today doesn't back-fill into occurrences that have already passed. --- ## Where the files go Set the location at More → **Notes & files** → *Storage location*. Either app-private storage (the default, invisible to other apps, deleted on uninstall) or a folder you pick, including an Obsidian vault. Inside, Calendite makes an `Events/` tree bucketed by time: ``` Events/ ├── Past/ ├── Today/ │ └── Work/ │ ├── Description.md │ ├── Contacts.md ← whole-event note │ ├── .templates/ │ │ └── Handover.md ← recurring master │ └── 2026-06-15 -- Sunday/ │ ├── Handover.md ← this occurrence's copy │ └── Stand-up/ ← inner event's notes │ └── Blockers.md └── Future/ └── 2026/ └── 08 - August/ └── Zoo Day/ ``` - Recurring events get a folder per bucket; one-off events are filed by year and month. - An occurrence is a **folder**, not a file, so it can hold several notes plus inner-event subfolders. - A variant day suffixes the folder: `2026-06-15 -- Sunday -- Late Shift/`. - File names are sanitised (`\ / : * ? " < > |` become `_`) and collisions get ` (2)`. - Month and weekday names in paths are English regardless of your locale. A nightly job re-files occurrence folders between Past / Today / Future as dates pass. It moves by copy → verify each file's checksum → delete the source, so an interrupted move can't lose anything. > **Caveat:** that nightly sweep only runs against app-private storage. If you've pointed > Calendite at an Obsidian vault, folders are **not** automatically re-bucketed there. --- ## The editor One card with a three-way pill: **View**, **Edit**, **Markdown**. - **View** renders the note. - **Edit** is a rich-text editor: what you type looks like what you get. - **Markdown** is the raw source. ### What actually works Not everything on the toolbar works yet. **In Edit, these toolbar buttons work:** Bold, Italic, Bullet list, Numbered list (real formatting); Divider, Quote, Task, Table (insert literal markdown at the cursor). **These are visible but don't do anything yet:** Heading, Strikethrough, Inline code, Link, Clear formatting. **In View, rendering is plain CommonMark.** Headings, bold, italic, links, code blocks, blockquotes, lists, rules and images all render. **Tables, task checkboxes and `~~strikethrough~~` do not**. They're GitHub extensions and show as raw text. Checkboxes aren't tappable. For anything the editor can't do, use **Markdown** mode and write it directly: the file is just markdown, and it round-trips. > **Caveat:** blocks the rich editor can't represent (code fences, tables, > dividers, quotes, task lines) are swapped for placeholder lines like `[Table · 0]` while > you're in Edit, and restored on the way out. Editing or deleting one of those bracketed > lines **destroys that block**. If you need to change a table, do it in Markdown mode. Only **Save** writes to disk. Switching modes keeps your edits in memory; Cancel discards. --- ## Templates A recurring note master can carry placeholders filled in when it's seeded into an occurrence: - `event.title` - `event.start_time` - `event.all_day` - `event.tags` - the occurrence date This makes a per-shift handover template that already knows which shift it's for. --- ## Using it with Obsidian Point Calendite's storage at your vault and the notes appear as ordinary files. There is no sync and no import; the vault folder **is** the store. Deliberately, Calendite writes no frontmatter and no Obsidian-specific syntax: no wikilinks, no Dataview, no properties. The files stay portable. Not built yet: pruning notes for deleted events, soft-delete to a `.trash`, and detecting that you edited a seeded note by hand. And on iOS choosing a folder isn't available yet, so *Change folder…* does nothing there. --- ## Next - [Troubleshooting](/docs/troubleshooting.html): the vault re-filing gap, and the placeholder hazard - [Getting started](/docs/getting-started.html) --- # Data sources and feeds How `WD, !BankHolidays` knows what a bank holiday is. A **feed** is a JSON file on the internet that Calendite turns into a named set of dates. That set becomes a **category** you can add or subtract in any recurrence rule. Bank holidays are the obvious case, but nothing about it is holiday-specific: anything with dates in it works, from term dates and fixture lists to bin collections and a rota published as JSON. > Data sources are **Android only for now.** On iOS and web the form appears but can't be > saved yet. --- ## Adding one More → **Data sources** → **+ New data source**. 1. **Tag name**: e.g. `Bank Holidays`. Feed events get this tag, so they get a colour and show on the calendar. 2. **Category**: auto-derived PascalCase and read-only, e.g. `BankHolidays`. This is what you type in rules. 3. **Tag colour**. 4. **URL**: e.g. `https://www.gov.uk/bank-holidays.json`. 5. **JSONPath**: where the list of items lives in that document. Type it, or tap **Build** and click through the fetched document to pick it. For gov.uk that's `$['england-and-wales'].events[*]`. 6. Calendite fetches after a short pause and reports *Found N items with K keys*. 7. **Map the keys**: which is the title, which is the date, and what format the date is in (ISO, day-month-year, month-day-year, or a Unix timestamp). 8. Optionally a **qualifier field** and **time fields**; see below. 9. Check the **preview** rows, then **Save**. --- ## Using it in a rule Once saved: ``` WD, !BankHolidays every working weekday BankHolidays just the holidays BankHolidays(Christmas) one member, matched on its title Feed(BankHolidays) the explicit form, same thing -1__Feed(BankHolidays)__OF__YEAR the last bank holiday of the year ``` The bare name and the `Feed(...)` form resolve identically. Use `Feed(...)` when you also have a tag or event by the same name and want to be unambiguous. --- ## The qualifier field Some feeds publish two entries for one day that mean different things: gov.uk marks a substitute day with a `notes` field reading *"Substitute day"*. Set that key as the **qualifier** and Calendite keeps those entries distinct instead of merging them. The event then displays as *"Boxing Day (Substitute day)"*. --- ## Merging Christmas arrives from a bank holidays feed, from a Christian holidays feed, and from a general UK events feed. You want one all-day event on 25 December, not three identical ones stacked on top of each other. Events from different feeds that describe the same day collapse into one rather than stacking up. Two entries merge when their **start date** and **title** both match; those two fields are the whole test. Title matching strips a trailing "Day", so `Christmas Day` and `Christmas` merge. Matching goes no further than this: there's no fuzzy matching beyond the trailing "Day", so `Christmas` and `Xmas` stay two events, and so do a feed's `Christmas` and your own `Dentist` on the same date. The stripping is only for the comparison; the stored title is never rewritten. When they merge, the second feed's tag is **added** to the existing event rather than replacing it. That matters for rules: a Christmas that arrived from three feeds is still a member of all three categories, so `!BankHolidays` still excludes it even though its pill takes the colour of whichever feed got there first. **Whichever feed creates the event owns it.** The feed that lands first sets the title and the primary tag (the one the colour comes from). Later feeds contribute only their tag, added to the end of the list. There's no way to give one feed priority over another; it's whichever imported first. Re-running the same feed next year is a no-op: it finds the existing event, adds no second copy and no duplicate tag. Open the event and you'll see the full list of tags it carries, in order, even though the bar on the calendar is a single colour. See [tags, colours and filtering](/docs/everyday-use/tags-colours-and-filtering.html) for what an event with several tags means elsewhere in the app. If two entries on the same day mean different things, use the qualifier field above. A qualifier becomes part of the event's identity: *"Boxing Day (Substitute day)"* and *"Boxing Day"* no longer match, so they stay separate events. --- ## Times Most date feeds are all-day, but if yours carries times, expand **Time Field Mapping**: start time key, end date key, end time key, duration key. The time pattern is usually auto-detected. > One gap: the **end time key** is saved but not currently read, so it has no effect. --- ## When feeds refresh **There is no refresh schedule.** Feeds re-sync when you save one, and when you bring the app to the foreground. There is no "refresh now" button and no background polling. That suits a bank-holiday list. For something that changes weekly, you're getting it at app launch, not live. --- ## What isn't supported - **No `.ics` subscription.** You can't paste a webcal/iCalendar URL. Feeds are JSON only. - **No file import.** There's no "open this .ics" flow. - **The only calendar import is your device's own**: More → Calendar sync. That reads the phone's calendar directly. On iOS it imports **only recurring events**; one-off events are skipped. --- ## Two things to watch **Category naming is validated loosely.** The form only rejects empty names and ones starting with a digit, but the rule parser is stricter: it wants at least one lowercase letter. So `UEFA` or `Sports2024` will save and then produce a rule token the parser rejects. Stick to names like `BankHolidays`, `TermDates`, `BinCollections`. **Feed URLs are stored as plain text** in the app's settings, not in your device's secure keychain. Don't put a URL containing a secret token in one. --- ## The JSONPath builder If you don't want to write a JSONPath, tap **Build** next to the field. Calendite fetches the document and shows it as an expandable tree: objects, arrays with their length, and leaf types. Click into it, hit **Select all items [\*]** at the array you want, and **Use this path**. It emits dot notation, bracket-quoting keys that need it: `$['england-and-wales'].events[*]`. The builder only offers whole arrays and the first element as a sample; it can't express filters or recursive descent. Those do work if you type them by hand. --- ## Next - [Repeating events](/docs/everyday-use/repeating-events.html): using categories in rules - [Tags, colours and filtering](/docs/everyday-use/tags-colours-and-filtering.html): tags vs categories --- # Shift planning Rotating shifts are the case ordinary calendars handle worst. In Calendite one **Work** event holds the whole rota as *variants* instead of a pile of separate entries. 1. Create a **Work** event with the rule `WD` (every weekday). 2. Add an **Early** variant: `Mon Wed Fri`, starting 06:00. 3. Add a **Late** variant: `Tue Thu`, starting 14:00. Variants replace the base schedule between them, so the same event shows the right time on the right day. See the [rule reference](/docs/rules-reference.html) for the syntax. --- # Sharing your calendar Calendite's sharing has no accounts, no sign-up, and no server that can read your events. This page explains how to use it, and exactly what does and doesn't happen to your data, so you can check the claims above. > Sharing is **Android only for now.** On iOS and the web the option shows a "not available > here" screen. Both phones must be on the **same Wi-Fi** to pair. --- ## Pairing with someone More → **Share calendar**. Three options: *Invite Someone*, *Been Invited?*, and *Use Calendite On A Computer*. The flow, in person: 1. **You** tap *Invite Someone*. Your phone shows a **QR code** and starts advertising on the local network. (There's a typed code as a fallback, but it's 24 characters: an emergency exit, not the main path.) 2. **They** tap *Been Invited?* and scan it. 3. Both phones now show **the same six digits**. Compare them out loud. 4. **Both** of you tap **They Match**. This is a hard gate: nothing has moved yet, and the calendar key isn't sent until both sides confirm. If the digits differ, tap *They Don't Match* and nothing has happened. 5. **You name them.** The name is stored on your device only and never transmitted. 6. A first sync runs. The six-digit check is the security of the whole thing. It's what makes it impossible for someone to interpose themselves between the two phones: they'd have to make two different key exchanges produce the same six digits. --- ## After pairing Their events appear on your calendar alongside yours, marked as theirs, and **read-only** to you. Yours appear on theirs. The tag filter grows a segmented control letting you look at your events or theirs. To stop: More → Share calendar → *People You Share With* → remove them. That purges their events, revokes their device, **rotates the calendar key** and dissolves the shared mailbox. --- ## Hiding individual events You can mark an event hidden from a partner. They see a **Busy** block: start, end, duration, nothing else. This is stronger than the equivalent in other apps: for a hidden event, **only the timing is ever encrypted under the shared key**. The title, location and everything else are never sent in any form. A modified client on their end has nothing to decrypt. The hiding is enforced by cryptography, not by their app choosing to respect a flag. Also: **everything you created before pairing never travels at all**, regardless of settings. --- ## Syncing when you're not together By default, sharing syncs **only when both phones are on the same network**. That's the `Same network only` setting. If you want it to work when you're apart, choose a **sync server** at More → Settings → *Sync Server*: | Choice | What it means | |---|---| | **Same network only** (default) | no server involved at all | | **The Calendite relay** | our server holds sealed blobs until the other phone collects them | | **Your own deployment** | the same, on infrastructure you control | --- ## What a sync server can and can't see If you enable one: **It cannot see:** your events, titles, times, locations, notes, tags, the names you gave people, or any encryption key. Everything it stores is sealed, and it has nothing to open it with. **It can see:** the traffic shape, meaning that *some* mailbox received *some* data of *some* size at *some* time, and 16-character device identifiers. **It holds data only until** every device has collected it, then drops it. Access to a mailbox is by knowledge of its address, which is derived from the calendar key. Only members can compute it. --- ## The encryption, briefly Every change is encrypted **twice**, under independently derived keys: ChaCha20-Poly1305 and then AES-256-GCM. Breaking one leaves you facing the other. Key agreement is **hybrid post-quantum**: X25519 (classical) combined with ML-KEM-768 (lattice). An attacker has to break **both**, so recording traffic today in the hope of decrypting it with a future quantum computer doesn't work. Signatures are likewise Ed25519 **and** ML-DSA-65, with both required. On your phone, keys are wrapped by hardware-backed storage (StrongBox where the device has it). **Alerts and notes are deliberately never shared.** Your partner sees the event, not your reminders about it or what you wrote. [How the encryption works](/docs/privacy/how-the-encryption-works.html) covers all of this properly, including what it doesn't protect against. --- ## Limitations - **Revocation protects the future, not the past.** What someone already synced is on their device. Removing them stops what comes next. - **Sharing is one partner at a time** in the current build. It's not a group calendar. - **Async invites** (sharing a link for someone to accept later) are recognised but **not yet redeemable**. The app will tell you to ask for a live code instead. - **Owner markers and Busy blocks are not drawn in the calendar views yet**, even though the data behind them syncs correctly. --- ## Using Calendite in a browser *Use Calendite On A Computer* links a browser to your phone, similar to WhatsApp Web: the page shows a QR, your phone scans it, and your calendar appears in the browser and stays in step. This is partly built. The linking steps work; the encrypted lock on browser storage is not yet in place. Treat it as unfinished. --- ## Next - [How the encryption works](/docs/privacy/how-the-encryption-works.html): two ciphers, hybrid post-quantum keys, and no key server - [What leaves your device](/docs/privacy/what-leaves-your-device.html): the complete list, including the two calls the location feature makes - [Troubleshooting](/docs/troubleshooting.html): pairing failures, including a known silent one on Android 13+ --- # What leaves your device Calendite has no account, no sign-up, and no default cloud. Your calendar lives on your phone. But "no account" isn't the same as "no network". This page is the complete list of everything the app can send anywhere, when, and to whom. It's written so you can check it rather than take our word for it. --- ## The short version | | Sends anything? | When | | ---------------------------------- | --------------- | ----------------------------------------- | | Using the calendar normally | **No** | never | | Creating, editing, deleting events | **No** | never | | Alarms, reminders, notifications | **No** | never | | Notes and descriptions | **No** | files on your device | | Natural-language input | **No** | the models run on your phone | | **Address search** | **Yes** | while you type in the location field | | **Map preview** | **Yes** | when a location with coordinates is shown | | Data source feeds | Yes | to the URL *you* configured | | Calendar sharing, same network | Yes | phone to phone, direct | | Calendar sharing, via relay | Yes, encrypted | only if you turn a relay on | | Push wake-ups | Yes, metadata | only if a relay is configured | The two rows in bold are the ones people don't expect. Everything else is either nothing, or something you switched on. --- ## The two you might not expect ### Address search → `photon.komoot.io` When you type into an event's location field, **what you type is sent to Photon**, an open-source geocoder over OpenStreetMap data, to be turned into suggestions. It's debounced (350 ms after you stop typing, minimum two characters), but it is still the text you're typing, as you type it. Nothing else goes with it: no identity, no event details, no other field. Just the query and a `Calendite/1.0 (Android)` user agent. ### Map preview → `tiles.openfreemap.org` The little map under a picked address fetches its tiles, fonts and icons from OpenFreeMap. So that server sees **the area you're looking at**. This happens even though the map's *styling* ships inside the app: the styling is local, the map data isn't. **To avoid both:** don't use address search. Type a plain label into the location field instead. It saves perfectly well; you just get no suggestions and no map. Both endpoints are defined in a single place, so a self-hosted geocoder or tile server can be swapped in. --- ## The ones you switch on ### Data source feeds A feed fetches the **URL you gave it**. It goes where you pointed it, and nowhere else. Feeds refresh when you save one and when you bring the app to the foreground; there's no background polling. > Feed URLs are currently stored as **plain text** in the app's preferences, not in the platform > keychain. Don't put a URL containing a secret token in one. ### Calendar sharing Off entirely until you pair with someone. Then: **On the same Wi-Fi**: the two phones talk **directly**. No server is involved at any point. This is the default and the only way to pair in the first place. **Through a relay****: off by default. You choose it explicitly in Settings → Sync Server: the Calendite relay, your own deployment, or none. When on, it holds sealed blobs it cannot open, and only until the other phone collects them. What a relay can see is traffic shape: that some mailbox received some data of some size at some time, plus 16-character device identifiers. It cannot see your events, titles, times, locations, notes, tags, the names you gave people, or any key. **Push wake-ups**: only if a relay is configured. Google's or iOS push service learns that a given install was pinged, and when. No calendar content, not even ciphertext. **\*Note:** The relay is only available via our paid service after the app moves out of preview. This is all encrypted though our encryption service for more details see. [How the encryption works](/docs/privacy/how-the-encryption-works.md) --- ## What is never shared, even when sharing is on - **Alerts and notes.** Deliberately excluded. Your partner sees the event, not your reminders about it or what you wrote. - **Anything created before you paired.** It never travels, whatever the settings say. - **Names you give people.** Stored on your device only. - **The details of a hidden event.** Only the timing is encrypted under the shared key, so a modified client on the other end has nothing to reveal. It renders as "Busy" because that is all that was sent. --- ## On-device, not cloud Two things other calendars send to a server, Calendite doesn't: - **Natural-language input.** Typing "every other Tuesday except bank holidays" is parsed by models bundled with the app (about 57 MB of them). Your phrasing never leaves the phone, and it works offline. - **Matching names to your own data.** Working out that "karate days" means your Karate event is done by comparing on-device vectors against your own tags and events. --- ## Limitations - Revocation and hiding protect the **future**. What a partner already synced is on their device. - Sharing is **Android only for now.** --- ## Next - [Sharing your calendar](/docs/privacy/sharing-your-calendar.html): the pairing flow and the encryption - [How the encryption works](/docs/privacy/how-the-encryption-works.html): why recording your traffic today doesn't help an attacker later - [Location and places](/docs/everyday-use/location-and-places.html): the geocoder in context - [Data sources and feeds](/docs/everyday-use/data-sources-and-feeds.html) --- # How the encryption works Calendite's sharing is built on an open, separately auditable library called the [Layered Encryption Protocol](https://github.com/Calendite/layered-encryption-protocol). It's a separate repository on purpose: cryptography you can't inspect is cryptography you have to take on faith. This page explains what it does in plain terms, including the part people ask about most: whether a future quantum computer changes anything. > Sharing is **Android only for now.** Everything below describes what happens when you pair two > phones and sync. --- ## Two ciphers, not one Your data is encrypted **twice**, in layers: ``` your calendar data └─ ChaCha20-Poly1305 (inner layer) └─ AES-256-GCM (outer layer) └─ sealed blob that gets stored or sent ``` The two layers use **independent keys**, each derived separately so the same key is never used twice. Reading your data means breaking ChaCha20-Poly1305 **and** AES-256. A break of one is not a break of your calendar. Encrypting twice is unusual in messaging apps and completely normal in disk encryption, where VeraCrypt and similar tools have done it for years. The cost is a few microseconds per operation. The benefit is that Calendite doesn't have to bet your calendar on any single cipher staying unbroken. Both layers are checked on the way back in, and a blob that fails either check is rejected outright. There is no path that hands back data which hasn't been verified. --- ## Quantum computers, and why "later" is the threat No quantum computer exists that could break this today. The relevant threat is **harvest now, decrypt later**: someone records encrypted traffic today, stores it, and waits for a machine that can open it in ten or twenty years. For a calendar that is a real threat model, because your appointments are still your appointments in 2046. Calendite agrees keys using **X-Wing**, a hybrid of two systems: | Leg | What it is | What breaks it | |---|---|---| | **X25519** | the classical elliptic-curve method used across the modern internet | a large quantum computer running Shor's algorithm | | **ML-KEM-768** | a lattice-based method designed to resist quantum attack | no known quantum algorithm | They're combined, not chosen between. **An attacker has to break both.** A quantum computer defeats the X25519 leg and still faces ML-KEM-768. Equally, if a flaw is ever found in ML-KEM, the classical leg is still standing. Neither is a single point of failure. Signatures work the same way: every one is **Ed25519 and ML-DSA-65**, and both must verify. The combining step is the part that's easy to get subtly wrong, so Calendite doesn't improvise it: it implements the published X-Wing construction exactly, checked against that specification's own official test vectors. It's worth being straight about X-Wing's status. It is a published, peer-reviewed construction, but it is not yet a finished standard. It's still working its way through the process, and along the way it changed names. The original X-Wing document was retired in September 2026 when the method was taken up by the IETF's cryptography research group, where it now appears under the name **MLKEM768-X25519**. That specification, [Concrete Hybrid PQ/T Key Encapsulation Mechanisms](https://datatracker.ietf.org/doc/draft-irtf-cfrg-concrete-hybrid-kems/04/), states in as many words that the construction is identical to X-Wing, and Calendite is tested directly against the official test vectors published in it. So nothing about Calendite's encryption is out of date or unsupported. The method is the same one, still being standardised, just under a newer name. Calendite stays locked to a fixed version of it, so nothing shifts underneath data that's already encrypted. If the finished standard ever changes the details, that would arrive as a new, clearly versioned encryption suite rather than a silent change to the existing one. ### What about the symmetric layers? Quantum computers do have a general speed-up against symmetric ciphers, but a much weaker one: it effectively halves the key length. Against 256-bit keys that leaves 128 bits of security, which is far beyond any practical attack under current understanding. The two-cipher cascade above is not the weak point. --- ## Nobody to impersonate The strongest cryptography in the world doesn't help if you've unknowingly agreed keys with the wrong person. Most systems solve this with a key server, which becomes something you have to trust and something an attacker can target. Calendite has none. Trust is established **once, in person**: 1. Both phones derive a shared secret directly. 2. Both display **the same six digits**. 3. You compare them out loud, and both tap to confirm. 4. Only then are the calendar keys released. There's no directory to look anyone up in, no key server to compromise, and nothing to impersonate. For someone to interpose themselves between two phones, they'd have to make two different key exchanges produce the same six digits, which is what those digits exist to make impossible. This is why the six-digit check matters, and why tapping through it without comparing gives away the security of the whole thing. --- ## Where keys live On your phone, keys are wrapped by hardware-backed storage (StrongBox on devices that have it). They aren't sitting in a file that a backup or another app can read. Removing someone rotates the calendar key, so everything from that point on is sealed with a key they don't have. --- ## What this protects against, and what it doesn't **It protects against:** - A sync server, including ours, reading anything. It holds sealed blobs and has nothing to open them with; see [what leaves your device](/docs/privacy/what-leaves-your-device.html). - Anyone recording your traffic now to decrypt it later, quantum computer or otherwise. - Someone interposing themselves during pairing, provided you compare the six digits. - A single cipher or key-agreement method turning out to be broken. **It does not protect against:** - **Someone with your unlocked phone.** Device security is the floor everything else stands on. If an attacker is past your lock screen, they're past Calendite too. - **What a partner already has.** Revocation protects the future, not the past: anything they synced before you removed them is on their device, and no key rotation reaches it. - **Traffic shape.** A relay can't read your events, but it can see that some mailbox received some data of some size at some time. That last group isn't a gap in the cryptography; it's the boundary of what cryptography can do. --- ## Next - [Sharing your calendar](/docs/privacy/sharing-your-calendar.html): the pairing flow, and hiding individual events - [What leaves your device](/docs/privacy/what-leaves-your-device.html): the complete list of everything that ever gets sent - [Troubleshooting](/docs/troubleshooting.html): if pairing isn't working --- # Glossary Calendite uses a handful of words in a specific way. This page says what each one means and points at the page that covers it properly. --- ## Events ### Event series Everything that shares the same details but can land on many different dates. All your "Yoga" events are one **event series**. A one-off event is still a series; it has a single [instance](#event-instance). ### Event instance One occurrence of an [event series](#event-series) on a particular day. The series is "Work"; the instance is Work on Tuesday the 14th. Deleting an instance removes one date and leaves the series running. See [Editing and deleting](/docs/everyday-use/editing-and-deleting.html). ### Inner event A smaller event that lives inside a bigger one and can only happen on days the bigger one happens. A "Stand-up" inner event only fires on the days its "Work" parent fires. - An inner event with **no rule of its own** rides every occurrence of its parent. - An inner event **with** a rule is worked out on its own and then trimmed down to the days the parent falls on. You never have to write the "…and only when Work is on" part yourself. Unlike a [variant event](#variant-event), an inner event **does not inherit its parent's [alerts](#alert)**. The parent's alerts shown on the inner event's detail screen are there for context; nothing is scheduled from them. See [Inner and variant events](/docs/everyday-use/inner-and-variant-events.html). ### Outer event The event that contains an [inner event](#inner-event): the parent. ### Inner event timing How an [inner event](#inner-event) places itself inside its parent's occurrence. - **Absolute**: a fixed clock time. 09:30 is 09:30 whatever the parent does. - **Relative**: an offset from the parent, either after it starts or before it ends. Move the parent and the inner event moves with it. ### Variant event A version of an [event series](#event-series) with something changed. Most often the start and end time, but it can be the location, the [alerts](#alert), the [tag](#tag) or anything else. "Early Shift", "Late Shift" and "Night Shift" are three variants of one "Work" series. Two points about variants: **A variant only supersedes its base on the days the variant itself falls on.** On every other date the base carries on as normal. So a base that repeats on weekdays, with a variant on the last Saturday of the month, gives you weekdays **and** that last Saturday. The variant adds a day; it doesn't replace the pattern. **Most of what a variant borrows from its base is copied when you save it, not looked up later.** Change the base's title, times, duration, tag, colour or location afterwards and variants that already exist keep what they had. Three things are the exception and do stay live: - **[Alerts](#alert)**: an alert added to the base later does reach existing variants. A variant can also drop an alert it inherited, and add alerts of its own. - **[Tags](#tag)**: a variant with no tags of its own shows its base's tags. - **[Visibility](#event-visibility)**: a variant set to inherit follows its base every time a view is drawn. See [Inner and variant events](/docs/everyday-use/inner-and-variant-events.html). ### Base event The event a [variant event](#variant-event) is based on: the original "Work" behind "Early Shift". ### Calendar item What a row on the calendar *is*: an event, a [reminder](#reminder), a [deadline](#deadline) or a [time period](#time-period). You choose which on the **Choose a Calendar Item** step when you tap NEW, and it isn't a field you edit afterwards — what something *is* isn't something that changes. Every kind is stored as an ordinary event row underneath; the kind only changes behaviour where it has to. ### Deadline A moment something is **due by**. Like a [reminder](#reminder) it is a point in time and can be ticked off, but its [alerts](#alert) are optional, because the countdown is the nag. Its detail screen shows how long is left — and once the moment passes without being marked done, how long it has been **overdue**, counting up in the danger colour. ### Time period A span of **whole days** — a holiday, or time off work. It has a start **date** and an end **date** rather than a start and end time, is always all-day, and measures its length in days. It can't be completed and doesn't count down. On the calendar it draws as one continuous band across every day it covers. See [Calendar items](/docs/everyday-use/calendar-items.html). ### Span end date The last day a [time period](#time-period) covers — the end of the holiday. Not the same thing as the date a repeat stops. A time period running 24 December to 2 January *and* recurring every year until 2030 has both: the span end date is 2 January, and the repeat ends in 2030. Each occurrence runs the same number of days from its own start. --- ## Repeats and rules ### Rule The pattern that decides which dates an [event series](#event-series) lands on, such as `WD, !BankHolidays`. You can build one with the pickers or type it directly. Full syntax is in the [rule reference](/docs/rules-reference.html); the gentler introduction is [Building repeats](/docs/everyday-use/building-repeats.html). ### Category A named group of dates you can use inside a [rule](#rule), written in PascalCase, for example `WD, !BankHolidays` for every weekday except bank holidays. Unlike a [tag](#tag), a category isn't for display or colour. It's a set of dates, resolved from a [feed](#feed) or a small built-in set when the rule is worked out. ### Tag, event and feed selectors The three ways a [rule](#rule) can point at dates belonging to something else, written with the kind spelled out so there's no ambiguity: - `Tag(X)`: the dates of every event carrying the [tag](#tag) `X`. - `Event(X)`: the dates of the [event series](#event-series) `X`. - `Feed(X)`: the dates supplied by the [feed](#feed) `X`. `Tag(Work)` and `Event(Work)` are deliberately different sets of dates, which is why the kind has to be explicit. See the [rule reference](/docs/rules-reference.html). ### Feed An external data source you point Calendite at: a URL plus a little configuration saying which bits of the response are the dates. Bank holidays from gov.uk are the usual example. A feed supplies the dates behind a [category](#category) or a `Feed(X)` selector. See [Data sources and feeds](/docs/everyday-use/data-sources-and-feeds.html). ### Computed rule A rule whose dates come from a calculation rather than from data you supply: `MOON(...)` for lunar phases and `EASTER(...)` for Easter Sunday and the days that move with it. Spelled in capitals to set it apart from a [category](#category), which is PascalCase and needs a feed or a built-in set behind it. A computed rule needs nothing and works offline. It has no iCalendar `RRULE` form, so an export writes its dates out to the export horizon instead. ### Occurrence limit The "After N occurrences" option in an event's *Ends* control: a cap on how many times the event fires. Not the same as `LIMIT(...)` in the rule language, which restricts which *months* a rule applies to. ### Region The country you pick in setup. It decides which days count as weekend and which as weekday for `WE` and `WD`, how dates are ordered, and which bank-holiday [feed](#feed) you're offered. The UK default is a week starting Monday, with England & Wales holidays. --- ## Alerts and reminders ### Alert A single reminder attached to an [event series](#event-series), either an [alarm](#alarm) or a [notification](#notification). It fires at an exact date and time, or relative to the event: "10 minutes before it starts", "5 minutes after it ends". See [Reminders and alerts](/docs/everyday-use/reminders-and-alerts.html). ### Alarm An [alert](#alert) that rings and takes over the screen even when the device is locked, like a phone alarm clock. For the things you cannot afford to scroll past. ### Notification An [alert](#alert) delivered as an ordinary notification. No ringing, no full-screen takeover. ### Alert template The `%` codes you can put in an alert's title or body to pull in values from the event it belongs to: `%t` for the title, `%tm` for the minutes until or since the event. The same codes work whether the alert fires before, during or after. Full list: [Alert template syntax](/docs/reference/alert-template-syntax.html). ### App launch action An app attached to an [alert](#alert), shown on the [alarm](#alarm) ringing screen as a "TAP TO OPEN" badge. For an alarm that exists to make you do something somewhere else. If the app has since been uninstalled the badge says so rather than failing. ### Reminder A task you need to do, rather than a block of time. A reminder is stored like any other event but behaves differently: it has **no duration**, is **never all-day**, and its [alert](#alert) is **mandatory**; a reminder that never fires is pointless. When one rings you can **defer** it to a time you choose, rather than snoozing a fixed interval. ### Recurrence anchor How a repeating [reminder](#reminder) works out its next occurrence: - **On schedule**: occurrences come from the [rule](#rule). Completing late, or deferring, never moves the ones after it. - **After completion**: the next one is counted from when you finished, so the schedule drifts along with real life. Reminders default to **On schedule**. ### Do not interrupt A per-[reminder](#reminder) setting. When it's on, a reminder that would fire in the middle of an event you're currently in defers itself until that event ends instead of ringing over it. All-day events don't count as "in progress" for this. --- ## Organising and appearance ### Tag An optional label attached to an [event series](#event-series), used for filtering and colour. An event can carry several; the first is the primary tag shown on the calendar when no tag filter is narrowing things down. See [Tags, colours and filtering](/docs/everyday-use/tags-colours-and-filtering.html). ### Tag colour The colour belonging to a [tag](#tag). It's what you normally see on the calendar and in the event view. ### Event colour A colour unique to one [event series](#event-series). You often won't see it at first, because the calendar shows the [tag colour](#tag-colour) instead. Once you filter by tag, the event's own colour comes through. ### Display colour Whichever of the two above is being drawn for an event at the moment. ### Event visibility Whether an [event series](#event-series) is drawn, decided **separately for each view**: Month, Week, Day and Events. Each can be set to visible, hidden, or inherited from the event above it. It exists for the daily repeat that's useful in Day view but turns the Month grid into noise. Ordinary events start out visible everywhere; [variants](#variant-event) start out inheriting from their [base](#base-event); [inner events](#inner-event) always inherit from their parent. **Not to be confused with [event audience](#event-audience)**, which decides who an event is published *to* when you share a calendar. Visibility is about your own screen; audience is about someone else's device. ### Location A single place attached to an event: an address, plus two optional details a map can't know for you, **room** and **floor**. "Meeting Room 3", "4th floor". See [Location and places](/docs/everyday-use/location-and-places.html). ### Home region The point address search is biased toward, so typing "the crown" surfaces the one near you rather than one three counties away. It's learned from where you schedule things; you're never asked for it, and it's only a ranking hint. ### Note A Markdown file attached to an event, or to one of its occurrences. Notes aren't rows in a database; they're `.md` files in a folder you choose, so you can point an Obsidian vault straight at them. A note can cover the whole event, one occurrence, or be a recurring template seeded fresh into each future occurrence. Distinct from the **description**, which is the single piece of prose attached to the event itself and shared by every occurrence. > **Android only for now.** On iOS and the web the notes interface doesn't appear. See [Notes and descriptions](/docs/everyday-use/notes-and-descriptions.html). --- ## Sharing ### Partner event An event belonging to someone you share calendars with, drawn on your calendar alongside your own. A partner event is **read-only** to you and carries a marker showing whose it is. Your own events have no owner. > Owner markers aren't drawn in the calendar views yet. See [Sharing your calendar](/docs/privacy/sharing-your-calendar.html). ### Busy block How a [partner event](#partner-event) appears when its creator has hidden it. Only the timing (start, end, duration) was ever shared. The title, location and everything else were never encrypted under the shared keys, so no app on your device could reveal them even if it wanted to. Hiding is enforced by the cryptography, not by the viewing app being polite. > Busy blocks aren't drawn in the calendar views yet. ### Event audience Who an event is published to when you share calendars. The default is to inherit. Anything created **before** you paired with someone has an audience that excludes them, which is why pairing doesn't retroactively hand over your back catalogue. Distinct from [event visibility](#event-visibility), which decides which of *your own* views an event appears in. ### Sharing member A person you share a calendar with. The name you give them is **stored on your device and never transmitted**; it's yours alone. ### Sync server The relay a shared calendar uses to pass sealed events between phones that aren't on the same network. Chosen at More → Settings → Sync Server: the Calendite relay, your own deployment, or none at all, which means same-network syncing only. A sync server only ever holds ciphertext it cannot open, and only until the other phone collects it. See [What leaves your device](/docs/privacy/what-leaves-your-device.html). --- # Alert template syntax Variables for customising alert titles and notifications in templates. See [Reminders and alerts](/docs/everyday-use/reminders-and-alerts.html) for where these templates are set and when they fire. **Format:** `%` --- ## Event identity | Code | Result | Example | |------|--------|---------| | `%t` | Event title | `"Team Standup"` | | `%T` | Alert title | `"10 min reminder"` | --- ## Time to event Time relative to the event, adapting to whichever phase the alert fires in: - **Before** the event starts: time remaining *until* it starts. - **During** the event (after it starts, before it ends): time elapsed *since* it started. - **After** the event ends: time elapsed *since* it ended. The same variables work whether the alert is configured to fire before, during, or after the event; no separate codes are needed for each phase. These come in two flavours: a **bare value** (just a number) and a **sentence form**, whose code ends in `l`, that reads as a phrase and picks its own tense — see [Sentence forms](#sentence-forms) below. | Code | Result | Example | |------|--------|---------| | `%tm` | Minutes until/since | `15` | | `%ts` | Seconds until/since | `900` | | `%th` | Hours until/since | `0.25` | | `%td` | Days until/since | `1` | | `%tf` | Broken into units | `"1 day, 3 hours, 10 minutes"` | `%tf` accepts a [style parameter](#style-parameter): `%tf{compact}` → `"1d 3h 10m"`. With the bare values you write the connecting words yourself, and you must phrase each phase separately — the number adapts, but "in"/"ago" won't: ``` "%t in %tm minutes" (before) -> "Client call in 45 minutes" "%t started %tm minutes ago" (during) -> "Client call started 12 minutes ago" "%t ended %tm minutes ago" (after) -> "Client call ended 5 minutes ago" ``` To have one code do all three, use the sentence form `%tml` instead. **Live updates:** an alert whose title contains any time-relative code (`%tm`, `%ts`, `%th`, `%td`, `%tf`, or a sentence form) is re-rendered **once a minute** while it is showing, so the value counts down/up rather than freezing at its fire-time snapshot. Updates pause while the phone is asleep and catch up the moment the screen comes back on. Note this cadence means the seconds codes (`%ts`, `%tsl`) still only refresh once a minute — they show exact seconds, not a ticking countdown. --- ## Event duration How long the event lasts. Duration isn't relative to "now", so its sentence forms carry no tense — they just add the unit word. | Code | Result | Example | |------|--------|---------| | `%dm` | Duration in minutes | `90` | | `%ds` | Duration in seconds | `5400` | | `%dh` | Duration in hours | `1.5` | | `%df` | Broken into units | `"1 hour, 30 minutes"` | | `%dml` | Minutes, worded | `"90 minutes"` | | `%dsl` | Seconds, worded | `"5400 seconds"` | | `%dhl` | Hours, worded | `"1.5 hours"` | | `%dfl` | Broken into units, worded | `"1 hour, 30 minutes"` | `%df`, `%dfl` and the worded forms all accept the [style parameter](#style-parameter): `%df{compact}` → `"1h 30m"`, `%dml{short}` → `"90 mins"`. --- ## Sentence forms Add `l` to any time code to get its **sentence form**: a ready-made phrase that picks the right tense automatically from the phase the alert fires in. No need to write separate templates for before, during and after. | Code | Before | During | After | |------|--------|--------|-------| | `%tml` | `in 15 minutes` | `started 15 minutes ago` | `ended 15 minutes ago` | | `%tsl` | `in 900 seconds` | `started 900 seconds ago` | `ended 900 seconds ago` | | `%thl` | `in 3 hours` | `started 3 hours ago` | `ended 3 hours ago` | | `%tdl` | `in 1 day` | `started 1 day ago` | `ended 1 day ago` | | `%tfl` | `in 1 day, 3 hours` | `started 1 day, 3 hours ago` | `ended 1 day, 3 hours ago` | ``` "%t %tml" (before) -> "Client call in 15 minutes" (during) -> "Client call started 15 minutes ago" (after) -> "Client call ended 15 minutes ago" ``` Words are pluralised for you (`1 minute` vs `15 minutes`). --- ## Style parameter The broken-into-units and sentence forms accept an optional `{…}` parameter that sets how verbose the unit words are. The default is `long`. | Style | second | minute | hour | day | |-------|--------|--------|------|-----| | `{long}` *(default)* | seconds | minutes | hours | days | | `{short}` | secs | mins | hrs | days | | `{compact}` | s | m | h | d | | `{clock}` | digits-only stopwatch reading — see below | | | | ``` "%tml" -> "in 15 minutes" "%tml{short}" -> "in 15 mins" "%tml{compact}" -> "in 15m" "%tf{compact}" -> "1d 3h 10m" "%df{short}" -> "1 hr, 30 mins" ``` An unrecognised style (e.g. `%tml{huge}`) is left in the text untouched, so typos are easy to spot. ### `{clock}` Renders `%tf`/`%df` as a stopwatch: `MM:SS` under an hour, `H:MM:SS` beyond it. Days roll up into hours, so a day and a half reads `36:00:00`. ``` "%tf{clock}" -> "04:59" "%tfl{clock}" -> "in 04:59" "%df{clock}" -> "1:30:00" ``` `{clock}` needs at least two fields to be a clock, so on a single-unit code (`%tm`, `%tml`, …) it falls back to the default wording rather than emitting a bare number. **Where seconds actually tick.** The ringing alarm screen re-resolves every second when the template asks for second-level detail (`%ts` or `{clock}`). **Notifications still refresh only once a minute**, so a `{clock}` there shows an exact value that then holds for a minute before jumping. For a genuinely ticking notification countdown, use a **Countdown** alert instead — the system ticks that one natively, at no battery cost. --- ## Event date and time Date and time of the event. | Code | Result | Example | |------|--------|---------| | `%Ed` | Event date (locale format) | `"29 May 2026"` | | `%ED` | Event date short | `"29/05/26"` | | `%Ei` | Event date ISO | `"2026-05-29"` | | `%Ew` | Day of week | `"Thursday"` | | `%EW` | Day of week short | `"Thu"` | | `%Et` | Event time 24-hour | `"14:00"` | | `%ET` | Event time AM/PM | `"2:00 PM"` | | `%Ee` | Event end time | `"15:00"` | --- ## Event properties | Code | Result | Example | |------|--------|---------| | `%l` | Location | `"Meeting Room A"` | | `%L` | Location short (before comma) | `"Meeting Room"` | | `%g` | Tag name | `"Work"` | | `%G` | Tag colour (hex) | `"#9C6B2E"` | --- ## System | Code | Result | Example | |------|--------|---------| | `%n` | Current time | `"09:45"` | | `%N` | Current date | `"29 May 2026"` | --- ## Examples ### Simple reminders ``` "%t alert" → "Team Standup alert" "%t in %tm minutes" → "Client call in 45 minutes" ``` ### Full descriptions ``` "%t - %tf to start" → "Q2 Planning - 1 day, 3 hours to start" "%t (%df{compact} total)" → "Sprint Review (2h 30m total)" ``` ### With date and time ``` "%t at %Et on %Ew" → "Conference trip at 09:00 on Saturday" "%t %Ew %Ed %ET" → "Board meeting Thursday 29 May 2:00 PM" ``` ### With location ``` "%t at %l" → "Team Standup at Meeting Room B" "%t @ %L" → "Client call @ Conference Room" ``` ### Complex example ``` "%t tomorrow at %Et (%tf away)" → "Project kickoff tomorrow at 14:00 (1 day, 2 hours away)" ``` --- ## Escaping To include a literal `%`, use `%%`: ``` "100%% done with %t" → "100% done with Team Standup" ``` --- ## Unknown variables If a variable is used but not available (e.g., `%l` when no location is set), it resolves to an empty string. ``` "%t at %l" → "Team Standup at " (if location not set) ``` --- # Moon rules `MOON(...)` is a rule expression you can use in a Calendite recurrence rule to schedule an event by lunar phase. See the [rule reference](/docs/rules-reference.html) for the surrounding syntax. Format: ```text MOON({phaseOrNumberOrRange}) MOON({phaseOrNumberOrRange}, {half}) ``` ## Supported phase values ```text NEW WAXING_CRESCENT FIRST_QUARTER WAXING_GIBBOUS FULL WANING_GIBBOUS LAST_QUARTER WANING_CRESCENT ``` Named phases represent the exact date of that moon phase. Examples: ```text MOON(FULL) MOON(NEW) MOON(FIRST_QUARTER) MOON(LAST_QUARTER) ``` ## Number values A number represents moon illumination as a percentage. ```text MOON(0) MOON(50) MOON(100) ``` Examples: ```text MOON(0) = new moon MOON(100) = full moon MOON(50) = 50% illumination, waxing or waning ``` For numbers between `1` and `99`, the rule may match twice in a lunar cycle: once while the moon is waxing and once while it is waning. ## Range values A range represents an illumination range. ```text MOON({start}..{end}) ``` Examples: ```text MOON(95..100) MOON(25..50) MOON(0..10) ``` Meaning: ```text MOON(95..100) ``` matches dates where the moon illumination is between `95%` and `100%`. ## Half values The optional second argument controls which half of the lunar cycle should be matched. Supported values: ```text WAXING WANING BOTH FIRST_HALF SECOND_HALF ``` Aliases: ```text WAXING = FIRST_HALF WANING = SECOND_HALF BOTH = default ``` Examples: ```text MOON(50, WAXING) MOON(50, WANING) MOON(50, BOTH) MOON(95..100, WAXING) MOON(95..100, WANING) MOON(95..100, BOTH) ``` Meaning: ```text MOON(50, WAXING) ``` matches the 50% illuminated moon during the first half of the lunar cycle. ```text MOON(50, WANING) ``` matches the 50% illuminated moon during the second half of the lunar cycle. ```text MOON(95..100, WAXING) ``` matches dates where the moon is between `95%` and `100%` illuminated while waxing. ```text MOON(95..100, WANING) ``` matches dates where the moon is between `95%` and `100%` illuminated while waning. ## Behaviour with named phases and half values When using a named phase, the half value is allowed but not needed. These are equivalent: ```text MOON(FULL) MOON(FULL, BOTH) MOON(FULL, WAXING) MOON(FULL, WANING) ``` They all mean: ```text the exact full moon date ``` The half argument only affects number and range rules. ## Examples ```text MOON(FULL) ``` The exact full moon date. ```text MOON(100) ``` The exact full moon date. ```text MOON(95..100) ``` Any date where the moon is between `95%` and `100%` illuminated, either waxing or waning. ```text MOON(95..100, WAXING) ``` Any date where the moon is between `95%` and `100%` illuminated during the waxing half of the lunar cycle. ```text MOON(95..100, WANING) ``` Any date where the moon is between `95%` and `100%` illuminated during the waning half of the lunar cycle. ```text MOON(50) ``` Any date where the moon is `50%` illuminated, either waxing or waning. ```text MOON(50, WAXING) ``` The waxing 50% moon. ```text MOON(50, WANING) ``` The waning 50% moon. --- # Easter rules `EASTER(...)` is a rule expression you can use in a Calendite recurrence rule to schedule an event on Easter Sunday or on any of the days that move with it — Good Friday, Easter Monday, Ash Wednesday, Whit Sunday and so on. It is worked out, not looked up: no feed, no data source, and it works offline. See the [rule reference](/docs/rules-reference.html) for the surrounding syntax. Format: ```text EASTER EASTER({day}) EASTER({reckoning}) EASTER({day}, {reckoning}) ``` The two arguments can be given in either order, and each is optional. ## Supported day values Each is an offset in days from Easter Sunday: ```text CleanMonday -48 ShroveTuesday -47 AshWednesday -46 MotheringSunday -21 PalmSunday -7 MaundyThursday -3 GoodFriday -2 HolySaturday -1 Sunday 0 (the default) Monday +1 Ascension +39 Pentecost +49 WhitSunday +49 (same day as Pentecost) WhitMonday +50 TrinitySunday +56 CorpusChristi +60 ``` Names are spelled exactly as above, the same way a category member is (`BankHolidays(GoodFriday)`). `EASTER(goodfriday)` and `EASTER(Tuesday)` are rejected. Examples: ```text EASTER EASTER(GoodFriday) EASTER(Monday) EASTER(AshWednesday) EASTER(Pentecost) ``` ## Supported reckoning values ```text Western (the default, and what the UK uses) Orthodox ``` `Western` is the Gregorian calculation. `Orthodox` is the Julian calculation, shown on the ordinary calendar. Any day name works with either reckoning, since a day is only an offset, though `AshWednesday`, `ShroveTuesday`, `MotheringSunday` and `CorpusChristi` are Western observances and `CleanMonday` an Orthodox one. Examples: ```text EASTER(Orthodox) EASTER(Sunday, Orthodox) EASTER(GoodFriday, Orthodox) EASTER(Orthodox, GoodFriday) ``` ## Why it is in capitals `EASTER` is spelled like `MOON` and `WD` because the engine computes it. PascalCase `Easter` is a *category*: a named set of dates you supply, which resolves to nothing unless you have given it data. The two never collide. ## Using it with other rules `EASTER(...)` produces one date a year, so it combines with everything else: ```text !EASTER(GoodFriday) exclude Good Friday WD, !EASTER(GoodFriday), !EASTER(Monday) weekdays, minus the Easter break WD~ # Checking a rule with Calendite's own validator This page exists for AI agents and other automated readers. If you have written a Calendite recurrence rule and want to know whether it is actually valid, do not infer it from the syntax documentation. Run the validator below. It is the same module Calendite uses to check the output of its own natural-language model, and its grammar is kept in step with the rule engine that ships in the app by an automated parity check, so a rule it accepts is a rule the app will parse. ## How to use this 1. Copy the Python module in the next section into whatever sandbox you can execute in. 2. Import it and call `DslValidator.is_valid(rule)` for a boolean, or `DslValidator.validate(rule)` to get an `InvalidDslException` whose message says which part failed and what was tried. 3. If a rule is rejected, fix it and check again rather than presenting it to the user. It needs only the Python standard library — no packages to install, no network, no data files — and runs on Python 3.9 or newer. ```python from dsl.dsl_validator import DslValidator, InvalidDslException DslValidator.is_valid("WD, !BankHolidays") # True DslValidator.is_valid("(1,5)__WD__OF__MONTH") # False try: DslValidator.validate("1__Mon__OF__DECADE") except InvalidDslException as error: print(error) # says which part was not recognised ``` ## How to write a rule 1. Work out which scheduling ideas the request contains, and check each one appears in this document. If one does not, say so — see *Unsupported constructs* below. 2. Build the rule from the forms listed here. Do not invent operators, functions, aliases or identifiers: there is no fallback syntax, and an invented token usually parses as something else rather than failing. 3. Run the validator. If it rejects the rule, read which part it names and fix that part rather than rewriting the whole rule. 4. Check the meaning as well as the validity. The vectors at the end of this page show what real rules produce; a rule can be perfectly valid and schedule the wrong days. 5. If the request cannot be expressed, say which part is unsupported and offer the nearest thing that is. Do not approximate silently. ## What it checks, and what it does not It checks that a rule is **structurally** valid: that every part matches a known rule form and that the pieces compose legally. That is the question worth asking, because a structurally invalid rule is one the app cannot parse at all. It deliberately does not check whether a rule is *sensible* or whether it will produce any dates. Some strings are well-formed but still resolve to nothing: - `Tag(Work)`, `Feed(BankHolidays)` and other data-backed rules produce dates only when the app has that data. On their own they are valid and empty. - A few nonsense values are the right *shape* and are accepted here even though the engine rejects them when it builds the rule, `MOON(BLUE)` and `2026-13-45` among them. So treat a `True` as "this parses", not as "this does what the user asked". The meaning is still yours to get right. ## The validator Embedded verbatim from `dsl_validator.py`, minus its self-test block. sha256 of the full module: `e208430f71ebd413cde9e169dd68482c279ee8cde3e3cb244ea6a698571b3b17` ```python """ DSL validator — Python port of DslValidator.kt. Same patterns, same routing logic. The Kotlin and Python validators should agree on every input. If they diverge, training and runtime will give inconsistent answers and the GRPO reward signal will mislead the model. Authority --------- Calendite-core is canonical. The leaf grammar below mirrors, rule class by rule class, the `when` block in `RuleFactory.isStructurallyValid` — each RulePattern records the Kotlin file its regex came from, and `dsl_parity/check_parity.py` diffs the two mechanically so this cannot silently drift again. The prose spec for all of it is Calendite-core/docs/Calendar_Rules.md. Where DslValidator.kt and the engine disagree, the engine wins: a string the validator accepts but `RuleFactory.getRuleForString` cannot build is a rule the app will reject at runtime, so teaching the model to emit it is worse than useless. The one live instance is the complex-rule index — see `_parse_indices`. Public API: DslValidator.validate(dsl) raises InvalidDslException on failure DslValidator.is_valid(dsl) returns bool """ import re from dataclasses import dataclass from typing import List, Optional class InvalidDslException(Exception): """Raised when a DSL string fails validation.""" def __init__(self, message: str, dsl: str): self.dsl = dsl super().__init__(f'{message} (dsl="{dsl}")') @dataclass(frozen=True) class RulePattern: """A single DSL rule type. `kotlin` is the Calendite-core file whose `REGEX` this mirrors, and `const` the value name in it. Both are what check_parity.py uses to prove the two sides still agree; leave them None only for rules Kotlin does not express as a single regex (GroupedRule tests startsWith/endsWith instead). """ name: str pattern: re.Pattern kotlin: Optional[str] = None const: str = "REGEX" # Month alternation, shared by LimitRule and ComplexMonthRule exactly as the # Kotlin does (`private const val MONTH` in each of those files). _MONTH = r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)" _DAY = r"(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)" # What a rule does in a month too short for the day it names. Absent means SKIP, # which is the default and all an imported RRULE can ever mean. Mirrors # `PATTERN` in MissingDayPolicy.kt. _MISSING_DAY = r"(?:\((CLAMP|NEXT)\))?" # The `~` operator's type slot. A whole rule, so it may carry brackets: # `Tag(Work)~<25Dec` is the last work day before Christmas. A name may be # followed by one bracketed group, or the group may stand alone — nested # brackets are excluded so this cannot swallow an anchored period on the far # side of the operator. The hyphen is deliberate: without it every rule that # counts backwards was shut out, so `-1Fri~<25Dec` — the last Friday of the # month before Christmas — did not parse. Mirrors `TYPE` in AdjustedRule.kt. _ADJUST_TYPE = r"[-\w@&]+\([^()]*\)|\([^()]*\)|[-\w@&]+" # The position selector that can precede an `__OF__` rule — a single signed # integer, a range `(1..5)`, or a set `(1.3.5)`. Shared by ComplexRule and # AnchoredPeriodRule exactly as `WHICH` is on the Kotlin side. _WHICH = r"\(?[-+0-9.]+(?:\.\.[-+0-9]+)?\)?|[-+]?\d+" class DslValidator: """ Validates that a DSL string is structurally sound. Each supported rule type is one RulePattern entry, mirroring one rule class in Calendite-core. Adding a rule type to the engine means adding its entry here (and check_parity.py will fail until you do). Special rule families handled by dedicated methods (not RULE_PATTERNS): - {index}__rule__OF__{DAY|WEEK|MONTH|YEAR} → _validate_complex_rule - {daySpec}__OF__WEEK{N} → _validate_week_of_year - {int|ALL}__rule__OF__{monthSpec} → _validate_complex_month - {rule1, rule2, ...} → _validate_grouped_rule - ruleA__&__ruleB → _validate_intersection """ # Mirrors the `when` in RuleFactory.isStructurallyValid. Order is irrelevant # (any match wins), so these are grouped to read alongside the Kotlin. RULE_PATTERNS: List[RulePattern] = [ # `{type}~{direction}{anchor}`, e.g. WD~<=25Dec. A whole clause is split on the # operator by _extract_parts before it reaches here, so this only matters when a # part is validated on its own — which is how RuleFactory sees it too. RulePattern("adjustment", re.compile(rf"^(!?)({_ADJUST_TYPE})~([<>]=?|=)(.+)$"), "rules/AdjustedRule.kt"), RulePattern("shift", re.compile(r"^(.+)([+-])P(\d+)(Day|Week|Month|Year)$"), "rules/ShiftedRule.kt"), RulePattern("day set", re.compile(r"^\(([A-Za-z]+(?:\.[A-Za-z]+)*)\)$"), "rules/DaySetRule.kt"), RulePattern("n-day interval", re.compile(r"^Day(\d+)$"), "rules/NthDayRule.kt"), RulePattern("every day", re.compile(r"^Day$"), "rules/DailyRule.kt"), RulePattern("nth of month", re.compile(rf"^(-?\d+)Month(\d*){_MISSING_DAY}$"), "rules/DayOfMonthRule.kt"), RulePattern("weekly day", re.compile(r"^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)(\d*)$"), "rules/DayOfWeekRule.kt"), RulePattern("nth weekday of month", re.compile(r"^(-?\d+)(_)?(Mon|Tue|Wed|Thu|Fri|Sat|Sun)(\d*)$"), "rules/OccurrenceRule.kt"), RulePattern("nth weekday/weekend of month", re.compile(r"^(-?\d+)(WD|WE)(\d*)$"), "rules/OrdinalWeekdayWeekendRule.kt"), RulePattern("weekday/weekend type", re.compile(r"^(WD|WE)$"), "rules/WeekdayWeekendRule.kt"), RulePattern("complex weekday/weekend interval", re.compile(r"^(WD|WE)(_)?(\d+)$"), "rules/ComplexWeekdayWeekendRule.kt"), # A negative day counts back from the end of that month: -1Aug is 31 August and # -1Feb is the 28th or 29th depending on the year. RulePattern("yearly date", re.compile(rf"^(-?\d{{1,2}})(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(\d*){_MISSING_DAY}$"), "rules/DayMonthRule.kt"), RulePattern("specific date", re.compile(r"^(\d{4}-\d{2}-\d{2})$"), "rules/SingleDateRule.kt"), RulePattern("limit modifier", re.compile(rf"^LIMIT\(({_MONTH}(?:\.{_MONTH})*)\)$"), "rules/LimitRule.kt"), RulePattern("week of year", re.compile(r"^(?:[A-Za-z]+|\([A-Za-z.]+\))__OF__WEEK(?:[1-9]|[1-4][0-9]|5[0-3])$"), "rules/WeekOfYearRule.kt"), RulePattern("complex month", re.compile(rf"^([-+]?\d+|ALL)__([^_]+)__OF__({_MONTH}|\({_MONTH}(?:\.{_MONTH})+\))(\d*)$"), "rules/ComplexMonthRule.kt"), RulePattern("complex rule", re.compile(r"^(\(?[-+0-9.]+(?:\.\.[-+0-9]+)?\)?|[-+]?\d+)__([^_]+)__OF__(DAY|WEEK|MONTH|YEAR)$"), "rules/ComplexRule.kt"), RulePattern("anchored period", re.compile(rf"^(?:({_WHICH})__)?(.+?)__OF__(DAY|WEEK|MONTH|YEAR)\((.+)\)$"), "rules/AnchoredPeriodRule.kt"), RulePattern("yearly interval", re.compile(r"^Yearly(\d*)$"), "rules/YearlyRule.kt"), RulePattern("monthly interval", re.compile(r"^Monthly(\d*)$"), "rules/MonthlyRule.kt"), RulePattern("weekly interval", re.compile(r"^Weekly(\d*)$"), "rules/Weekly.kt"), # Named phase, illumination percentage or range, with an optional # WAXING/WANING half. Structural only — the engine also accepts unknown # ALL-CAPS words here and rejects them when the rule is built. RulePattern("moon phase", re.compile(r"^MOON\(\s*([A-Z_]+|\d{1,3}(?:\.\.\d{1,3})?)\s*(?:,\s*([A-Z_]+)\s*)?\)$"), "rules/MoonRule.kt"), # Easter Sunday or a day that moves with it: `EASTER`, `EASTER(GoodFriday)`, # `EASTER(Monday, Orthodox)`. The regex is the shape only; which names are # allowed, and that neither kind repeats, is _reject_bad_easter_arguments — # the engine's `EasterRule.matches` checks both, so the validator must too. RulePattern("easter", re.compile(r"^EASTER(?:\(\s*([A-Za-z]+)\s*(?:,\s*([A-Za-z]+)\s*)?\))?$"), "rules/EasterRule.kt"), RulePattern("free-form category", re.compile(r"^@&@\([^)]+\)$"), "RuleFactory.kt", "FREE_FORM_CATEGORY_REGEX"), RulePattern("tag selector", re.compile(r"^Tag\(([A-Z][A-Za-z0-9]*)\)$"), "rules/TagRule.kt"), RulePattern("event selector", re.compile(r"^Event\(([A-Z][A-Za-z0-9]*)\)$"), "rules/EventRule.kt"), RulePattern("feed selector", re.compile(r"^Feed\(([A-Z][A-Za-z0-9]*)\)$"), "rules/FeedRule.kt"), # Must stay last: CategoryRule is the engine's fallback, and its # `Name(Member)` form would otherwise shadow Tag/Event/Feed. RulePattern("named category", re.compile(r"^([A-Z][a-zA-Z]*[a-z][a-zA-Z]*)(?:\(([A-Z][a-zA-Z]*[a-z][a-zA-Z]*)\))?$"), "rules/CategoryRule.kt"), ] # Friendly aliases rewritten to a real rule token before matching, mirroring # RuleFactory.RULE_ALIASES. WORKDAY is a synonym for the Work tag and works # both standalone and nested (`1__WORKDAY__OF__MONTH`) — Calendar_Rules.md §18. _RULE_ALIASES = {"WORKDAY": "Tag(Work)"} # ── Regex constants ────────────────────────────────────────────────────── # Splits on commas not inside parentheses or braces, and on adjustment # operators. Operators are ordered longest-first so <= is matched before <. # # The operator needs the same in-brace guard the comma has. Without it the # split reached inside a group and tore it in half: `{WD~<25Dec}` became the # parts `{WD` and `25Dec}`, neither of which is a rule, so every grouped # adjustment was rejected here while the engine built it happily. _NOT_INSIDE_BRACKETS = r"(?![^(]*\))(?![^{]*\})" _SPLIT_PATTERN = re.compile( rf",\s*{_NOT_INSIDE_BRACKETS}|~(?:<=|>=|<|>|=){_NOT_INSIDE_BRACKETS}" ) # {index}__rule__OF__{DAY|WEEK|MONTH|YEAR}. The index is checked separately # by _parse_indices rather than by this regex, which only has to be loose # enough to route; `(.+)` for the inner rule allows nesting, which the # engine's own `([^_]+)` does not, so this layer stays the permissive one. _COMPLEX_RULE_PATTERN = re.compile( r"^(\(?[-+0-9.]+(?:\.\.[-+0-9]+)?\)?|[-+]?\d+)__(.+)__OF__(DAY|WEEK|MONTH|YEAR)$" ) _VALID_TIME_UNITS = {"DAY", "WEEK", "MONTH", "YEAR"} # Intersection operator: ruleA__&__ruleB[__&__ruleC...] — a date matching # ALL operands. Flat (not nested); operands may not be negated, LIMITs, or # groups. Order carries no meaning (see dsl_normalise.canonical_compare_key). _INTERSECTION_DELIM = "__&__" # {daySpec or (daySpec.daySpec...)}__OF__WEEK{N} _WEEK_OF_YEAR_PATTERN = re.compile( r"^(\(?(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun|WD|WE)" r"(?:\.(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun|WD|WE))*\)?)__OF__WEEK(\d+)$" ) # {int|ALL}__rule__OF__{monthAbbr or (monthAbbr.monthAbbr...)}{yearInterval?} # A parenthesised month list needs at least two entries, matching # ComplexMonthRule: a single month is written bare, as `ALL__Mon__OF__Jan`. _COMPLEX_MONTH_PATTERN = re.compile( rf"^(-?\d+|ALL)__(.+)__OF__({_MONTH}|\({_MONTH}(?:\.{_MONTH})+\))(\d*)$" ) # {which}__{inner}__OF__{period}({anchor}) — the same __OF__ shape, but with # the period pinned to whichever one the anchor lands in instead of repeating # on the calendar. `{which}` is optional; without it every matching day of # that period is produced. # # The bracketed anchor is what separates this from the three patterns above: # WeekOfYear wants digits after WEEK, ComplexMonth wants a month, and # ComplexRule anchors the period to the end of the string. _ANCHORED_PERIOD_PATTERN = re.compile( rf"^(?:({_WHICH})__)?(.+?)__OF__(DAY|WEEK|MONTH|YEAR)\((.+)\)$" ) # ── Public API ─────────────────────────────────────────────────────────── @classmethod def validate(cls, dsl: str) -> None: """ Validate that `dsl` is a structurally valid DSL string. Raises InvalidDslException on failure. Returns None on success. """ if not dsl or dsl.isspace(): raise InvalidDslException("DSL string is blank", dsl) parts = cls._extract_parts(dsl) if not parts: raise InvalidDslException("DSL contains no rules", dsl) for part in parts: cls._validate_part(part, dsl) @classmethod def is_valid(cls, dsl: str) -> bool: """ Return True if `dsl` passes validate() without raising. This is what GRPO uses as part of its reward signal. """ try: cls.validate(dsl) return True except InvalidDslException: return False # ── Splitting ──────────────────────────────────────────────────────────── @classmethod def _reject_limit_adjustments(cls, dsl: str) -> None: """ A LIMIT cannot be adjusted around, on either side. It filters days rather than producing them, and asking it a question turns it inside out: its occursOn is true for the months it *removes*, so `LIMIT(Jan)~<25Dec` quietly meant "the last day not in January before Christmas" and answered the 24th. The engine rejects it in AdjustedRule's constructor; this mirrors that, after the pattern rather than inside it, because the pattern has to stay identical for the parity check to compare the two. """ if cls._LIMIT_ADJUSTED.search(dsl): raise InvalidDslException( "A LIMIT filters days, it does not produce them, so it cannot " "be adjusted around", dsl) @classmethod def _extract_parts(cls, dsl: str) -> List[str]: """ Split `dsl` on commas and adjustment operators, then strip a single leading `!` exclusion prefix from each part. Commas inside `(...)` or `{...}` are protected and not treated as separators. """ # Checked before splitting: the split hides the adjustment's shape, and # each half of `LIMIT(Jan)~<25Dec` is a perfectly good rule on its own. cls._reject_limit_adjustments(dsl) parts: List[str] = [] for raw in cls._SPLIT_PATTERN.split(dsl): if raw is None: continue # split() yields None for non-participating groups cleaned = raw.strip() if cleaned.startswith("!"): cleaned = cleaned[1:] if cleaned: parts.append(cleaned) return parts # ── Validation ─────────────────────────────────────────────────────────── @classmethod def _validate_part(cls, part: str, full_dsl: str) -> None: """ Validate a single part. Routes to the appropriate handler based on the part's structure. """ # Grouped rule: {rule1, rule2, ...} if part.startswith("{") and part.endswith("}"): cls._validate_grouped_rule(part, full_dsl) return # Intersection: ruleA__&__ruleB[...]. Checked before the __OF__ patterns # because an operand like 1__Mon__OF__MONTH__&__Fri contains __OF__ and # would otherwise be mis-routed to the complex-rule handler. operands = cls._split_intersection(part) if len(operands) > 1: cls._validate_intersection(operands, part, full_dsl) return # ShiftedRule: {rule}{+|-}P{n}{Unit}. Placed after the intersection split # and before the __OF__ forms, mirroring RuleFactory: `~` and `__&__` # bind looser than a shift, so `Mon__&__Fri-P1Day` intersects against the # shifted Friday rather than shifting the whole intersection. The base # has to be a rule in its own right, or `Nonsense-P1Day` would pass on # shape alone. shift = cls._SHIFT.match(part) if shift: base = shift.group(1) if int(shift.group(3)) == 0: raise InvalidDslException( f'A shift of zero moves nothing: "{part}"', full_dsl) if base.startswith("LIMIT("): raise InvalidDslException( "A LIMIT filters days, it does not produce them, so it " f'cannot be shifted: "{part}"', full_dsl) cls._validate_part(base, full_dsl) return # AnchoredPeriodRule: {which}__{inner}__OF__{period}({anchor}). # Checked before the three unanchored __OF__ forms, mirroring the order # RuleFactory dispatches in: they share the same shape and only the # bracketed anchor tells them apart. if cls._ANCHORED_PERIOD_PATTERN.match(part): cls._validate_anchored_period(part, full_dsl) return # WeekOfYearRule: {daySpec}__OF__WEEK{N} if cls._WEEK_OF_YEAR_PATTERN.match(part): cls._validate_week_of_year(part, full_dsl) return # ComplexMonthRule: {int|ALL}__rule__OF__{monthSpec} if cls._COMPLEX_MONTH_PATTERN.match(part): cls._validate_complex_month(part, full_dsl) return # Existing complex rule: {index}__rule__OF__{DAY|WEEK|MONTH|YEAR} if cls._COMPLEX_RULE_PATTERN.search(part): cls._validate_complex_rule(part, full_dsl) return # A yearly date naming a day its month never has (`31Apr`, `30Feb`). # Calendite-core rejects these in DayMonthRule's constructor: they can # never produce a date, so they are typos rather than schedules. Being # stricter than the shallow Kotlin validator here is deliberate and safe # — the engine cannot build them either, so no real DSL is lost, and the # reward signal never teaches the model a rule that can never fire. cls._reject_impossible_yearly_date(part, full_dsl) # `EASTER(Tuesday)` has the right shape and no meaning. The engine refuses # to build it, so the validator refuses it too. cls._reject_bad_easter_arguments(part, full_dsl) if not cls._leaf_matches(part): attempted = "\n".join( f" {rule.name}: {rule.pattern.pattern}" for rule in cls.RULE_PATTERNS ) raise InvalidDslException( f'Unrecognised DSL part "{part}".\nTried patterns:\n{attempted}', full_dsl, ) # The most days each month can ever have; only February varies by year, and # 29 is allowed there because a leap year reaches it. _LONGEST_MONTH = { "Jan": 31, "Feb": 29, "Mar": 31, "Apr": 30, "May": 31, "Jun": 30, "Jul": 31, "Aug": 31, "Sep": 30, "Oct": 31, "Nov": 30, "Dec": 31, } _YEARLY_DATE = re.compile(rf"^(-?\d{{1,2}})({_MONTH[3:-1]})(\d*)((?:\((?:CLAMP|NEXT)\))?)$") @classmethod def _reject_impossible_yearly_date(cls, part: str, full_dsl: str) -> None: match = cls._YEARLY_DATE.match(part) if not match: return day, month, policy = int(match.group(1)), match.group(2), match.group(4) # `31Apr` is a typo; `31Apr(CLAMP)` is an odd but unambiguous way to say # the 30th, so only the bare form is refused. Calendite-core draws the # same line in DayMonthRule's constructor. if day == 0 or (not policy and abs(day) > cls._LONGEST_MONTH[month]): raise InvalidDslException( f'{month} never has a day {abs(day)}: "{part}"', full_dsl) # The names `EASTER(...)` accepts, mirroring EasterDay.tokens and # EasterReckoning.token in rules/utils/easter/. A day is an offset from Easter # Sunday; a reckoning picks the Western (default) or Orthodox calculation. _EASTER_DAYS = frozenset({ "CleanMonday", "ShroveTuesday", "AshWednesday", "MotheringSunday", "PalmSunday", "MaundyThursday", "GoodFriday", "HolySaturday", "Sunday", "Monday", "Ascension", "Pentecost", "WhitSunday", "WhitMonday", "TrinitySunday", "CorpusChristi", }) _EASTER_RECKONINGS = frozenset({"Western", "Orthodox"}) _EASTER = re.compile(r"^!?EASTER(?:\(\s*([A-Za-z]+)\s*(?:,\s*([A-Za-z]+)\s*)?\))?$") @classmethod def _reject_bad_easter_arguments(cls, part: str, full_dsl: str) -> None: """Mirrors EasterRule.matches: at most one day and one reckoning, each a known name, in either order.""" m = cls._EASTER.match(part) if not m: return args = [a for a in m.groups() if a] days = [a for a in args if a in cls._EASTER_DAYS] reckonings = [a for a in args if a in cls._EASTER_RECKONINGS] unknown = [a for a in args if a not in cls._EASTER_DAYS and a not in cls._EASTER_RECKONINGS] if unknown: raise InvalidDslException( f'"{unknown[0]}" is not a day EASTER knows (try GoodFriday, Monday, ' f'AshWednesday …) nor a reckoning (Western, Orthodox): "{part}"', full_dsl) if len(days) > 1 or len(reckonings) > 1: raise InvalidDslException( f'EASTER takes at most one day and one reckoning: "{part}"', full_dsl) @classmethod def _alias_rule_token(cls, token: str) -> str: """Rewrite a whole rule token if it is a known alias, preserving `!`.""" negated = token.startswith("!") bare = token[1:] if negated else token replacement = cls._RULE_ALIASES.get(bare) if replacement is None: return token return f"!{replacement}" if negated else replacement # `0Mon`, `-0Thu`, `0_Wed`, `0Sat2` — an occurrence of zero. Positions are # 1-based, so these are checked here rather than excluded from the rule # pattern: that pattern has to stay identical to the engine's # OccurrenceRule.REGEX for the parity check to compare the two. The engine # rejects it the same way, in the constructor rather than the regex. _ZERO_OCCURRENCE = re.compile(r"^-?0+(_)?(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\d*$") # {rule}{+|-}P{n}{Unit} — every date a rule produces, moved by a fixed # amount. Re-matched here so the base can be validated recursively, which is # what RuleFactory.isStructurallyValid does; the leaf entry in RULE_PATTERNS # only has to agree on the shape. Mirrors `REGEX` in ShiftedRule.kt. _SHIFT = re.compile(r"^(.+)([+-])P(\d+)(Day|Week|Month|Year)$") # The adjustment form, re-matched so its two sides can be examined. _ADJUSTMENT = re.compile(rf"^(!?)({_ADJUST_TYPE})~([<>]=?|=)(.+)$") # A LIMIT on either side of a `~`. Matched directly rather than by splitting # into clauses, since the operator is what the splitter divides on and the # shape stops being visible once it has. _LIMIT_ADJUSTED = re.compile(r"LIMIT\([^)]*\)\s*~|~[<>]?=?\s*LIMIT\(") @classmethod def _leaf_matches(cls, part: str) -> bool: """Leaf-rule check, mirroring the `when` in RuleFactory.isStructurallyValid: alias first, then drop a leading `!`, then try every rule pattern.""" token = cls._alias_rule_token(part) if token.startswith("!"): token = token[1:] if cls._ZERO_OCCURRENCE.match(token): return False # `0Mon` builds nothing, so it is nothing return any(rule.pattern.match(token) for rule in cls.RULE_PATTERNS) # ── Complex-rule index ─────────────────────────────────────────────────── @classmethod def _parse_indices(cls, raw: str, part: str, full_dsl: str) -> List[int]: """Mirror of ComplexRule.parseIndices in Calendite-core. Accepts a bare signed integer, a range `(1..5)` / `(-3..-1)`, or a set `(1.3.5)` / `(1.-1)`. Positions are 1-based, so 0 is rejected, and a range's endpoints must share a sign and be ordered. Deliberately stricter than DslValidator.kt, which still carries a stale `(?:,\\d+)?` index pattern and so accepts `(1,5)` — a string the engine then throws on ("Invalid set member '1,5'"). Calendite-core is canonical; see Calendar_Rules.md §13, which lists only the range and set forms. """ trimmed = raw.strip() if not trimmed.startswith("("): try: single = int(trimmed) except ValueError: raise InvalidDslException( f'Invalid index "{raw}" in complex rule "{part}"', full_dsl) # Rejected here as well as in the range and set forms below, matching # the engine. It used to be checked only there, so `(0..5)` failed # while a bare `0` passed and then resolved to a position no period # has — a rule that validates, builds, and never fires. if single == 0: raise InvalidDslException( f'Index is 1-based; 0 is not a valid position in "{part}"', full_dsl) return [single] if not trimmed.endswith(")"): raise InvalidDslException( f'Unterminated index group "{raw}" in complex rule "{part}"', full_dsl) inner = trimmed[1:-1] def as_int(text: str) -> int: try: return int(text.strip()) except ValueError: raise InvalidDslException( f'Invalid index member "{text}" in complex rule "{part}"', full_dsl) if ".." in inner: bounds = inner.split("..") if len(bounds) != 2: raise InvalidDslException( f'Malformed range "{raw}" in complex rule "{part}"', full_dsl) start, end = as_int(bounds[0]), as_int(bounds[1]) if start == 0 or end == 0: raise InvalidDslException( f'Index is 1-based; 0 is not a valid position in "{part}"', full_dsl) if (start > 0) != (end > 0): raise InvalidDslException( f'Range endpoints must share a sign in "{part}" (use a set)', full_dsl) if start > end: raise InvalidDslException( f'Range start must not exceed end in "{part}"', full_dsl) return list(range(start, end + 1)) members = [as_int(m) for m in inner.split(".") if m.strip()] if not members: raise InvalidDslException(f'Empty index set in "{part}"', full_dsl) if any(m == 0 for m in members): raise InvalidDslException( f'Index is 1-based; 0 is not a valid position in "{part}"', full_dsl) return members @classmethod def _validate_complex_rule(cls, part: str, full_dsl: str) -> None: """Validate {index}__rule__OF__{DAY|WEEK|MONTH|YEAR}.""" match = cls._COMPLEX_RULE_PATTERN.match(part) if not match: raise InvalidDslException( f'Malformed complex rule "{part}" — ' f'expected {{index}}__{{rule}}__OF__{{timeUnit}}', full_dsl, ) index = match.group(1) inner = match.group(2) time_unit = match.group(3) cls._parse_indices(index, part, full_dsl) if time_unit not in cls._VALID_TIME_UNITS: raise InvalidDslException( f'Invalid time unit "{time_unit}" in "{part}" — ' f'expected one of {cls._VALID_TIME_UNITS}', full_dsl, ) cls._validate_part(inner, full_dsl) @classmethod def _validate_anchored_period(cls, part: str, full_dsl: str) -> None: """Validate {which}__{inner}__OF__{DAY|WEEK|MONTH|YEAR}({anchor}).""" match = cls._ANCHORED_PERIOD_PATTERN.match(part) if not match: raise InvalidDslException( f'Malformed anchored period rule "{part}" — ' f'expected [{{index}}__]{{rule}}__OF__{{timeUnit}}({{anchor}})', full_dsl, ) index, inner, time_unit, anchor = match.groups() # `{which}` is optional here, unlike the unanchored form: omitting it # means every matching day of the period rather than one position. if index: cls._parse_indices(index, part, full_dsl) if time_unit not in cls._VALID_TIME_UNITS: raise InvalidDslException( f'Invalid time unit "{time_unit}" in "{part}" — ' f'expected one of {cls._VALID_TIME_UNITS}', full_dsl, ) # Both halves are ordinary rules, so a bad one is reported where it is # rather than as a vague complaint about the whole expression. cls._validate_part(inner, full_dsl) cls._validate_part(anchor, full_dsl) @classmethod def _validate_week_of_year(cls, part: str, full_dsl: str) -> None: """Validate {daySpec}__OF__WEEK{N}.""" match = cls._WEEK_OF_YEAR_PATTERN.match(part) if not match: raise InvalidDslException( f'Malformed WeekOfYear rule "{part}"', full_dsl, ) week_num = int(match.group(2)) if not (1 <= week_num <= 53): raise InvalidDslException( f'Invalid week number {week_num} in "{part}" — expected 1..53', full_dsl, ) @classmethod def _validate_complex_month(cls, part: str, full_dsl: str) -> None: """Validate {int|ALL}__rule__OF__{monthSpec}.""" match = cls._COMPLEX_MONTH_PATTERN.match(part) if not match: raise InvalidDslException( f'Malformed ComplexMonth rule "{part}"', full_dsl, ) # ALL is stored as index 0 by the engine and has to stay allowed; a # written 0 is a position no month has. `0__Thu__OF__Nov` used to build # and return nothing, every November. index = match.group(1) if index != "ALL" and int(index) == 0: raise InvalidDslException( f'Occurrence is 1-based; 0 is not a valid position in "{part}"', full_dsl) inner = match.group(2) cls._validate_part(inner, full_dsl) @classmethod def _split_intersection(cls, part: str) -> List[str]: """Split on ``__&__`` occurrences that are not inside parentheses. Parenthesised operands (``MOON(FULL)``, ``@&@(karate days)``, ``(1..5)__Mon__OF__MONTH``) are kept intact. """ delim = cls._INTERSECTION_DELIM dl = len(delim) operands: List[str] = [] start = depth = i = 0 n = len(part) while i < n: ch = part[i] if ch == "(": depth += 1 elif ch == ")": depth = max(0, depth - 1) elif depth == 0 and part[i:i + dl] == delim: operands.append(part[start:i]) start = i + dl i += dl continue i += 1 operands.append(part[start:]) return operands @classmethod def _validate_intersection(cls, operands: List[str], part: str, full_dsl: str) -> None: """Validate an intersection clause ``ruleA__&__ruleB[...]``. Requires ≥2 non-empty operands; each operand must be a standalone rule that is not negated, a LIMIT modifier, or a group. The leading ``!`` of a negated *whole* intersection has already been stripped by ``_extract_parts``, so a ``!`` here is an illegal per-operand negation. """ if len(operands) < 2: raise InvalidDslException( f'Intersection "{part}" needs at least two operands', full_dsl) for raw in operands: operand = raw.strip() if not operand: raise InvalidDslException( f'Empty operand in intersection "{part}"', full_dsl) if operand.startswith("!"): raise InvalidDslException( f'Operand "{operand}" must not be negated; "!" applies to ' f'the whole intersection "{part}"', full_dsl) if operand.startswith("{") or operand.endswith("}"): raise InvalidDslException( f'Grouped operand "{operand}" not allowed in intersection ' f'"{part}"', full_dsl) if "LIMIT(" in operand: raise InvalidDslException( f'LIMIT is a clause modifier, not an intersection operand ' f'"{operand}" in "{part}"', full_dsl) # Flat only: a well-formed operand never contains the delimiter. if cls._INTERSECTION_DELIM in operand: raise InvalidDslException( f'Nested intersection in "{part}"', full_dsl) cls._validate_part(operand, full_dsl) @classmethod def _validate_grouped_rule(cls, part: str, full_dsl: str) -> None: """Validate {rule1, rule2, ...} grouped rules.""" body = part[1:-1] if "{" in body: raise InvalidDslException( f'Nested groups are not allowed in "{part}"', full_dsl, ) inner_parts = cls._extract_parts(body) if not inner_parts: raise InvalidDslException( f'Empty grouped rule "{part}"', full_dsl, ) for inner in inner_parts: cls._validate_part(inner, full_dsl) ``` ## Rule forms it recognises Each row is one entry in `RULE_PATTERNS` above. The third column names the class in Calendite's rule engine that the pattern mirrors; the two are diffed automatically, so this table describes the rules the app really implements. | Rule type | Pattern | Defined by | | --- | --- | --- | | adjustment | `^(!?)([-\w@&]+\([^()]*\)\|\([^()]*\)\|[-\w@&]+)~([<>]=?\|=)(.+)$` | `rules/AdjustedRule.kt` | | shift | `^(.+)([+-])P(\d+)(Day\|Week\|Month\|Year)$` | `rules/ShiftedRule.kt` | | day set | `^\(([A-Za-z]+(?:\.[A-Za-z]+)*)\)$` | `rules/DaySetRule.kt` | | n-day interval | `^Day(\d+)$` | `rules/NthDayRule.kt` | | every day | `^Day$` | `rules/DailyRule.kt` | | nth of month | `^(-?\d+)Month(\d*)(?:\((CLAMP\|NEXT)\))?$` | `rules/DayOfMonthRule.kt` | | weekly day | `^(Mon\|Tue\|Wed\|Thu\|Fri\|Sat\|Sun)(\d*)$` | `rules/DayOfWeekRule.kt` | | nth weekday of month | `^(-?\d+)(_)?(Mon\|Tue\|Wed\|Thu\|Fri\|Sat\|Sun)(\d*)$` | `rules/OccurrenceRule.kt` | | nth weekday/weekend of month | `^(-?\d+)(WD\|WE)(\d*)$` | `rules/OrdinalWeekdayWeekendRule.kt` | | weekday/weekend type | `^(WD\|WE)$` | `rules/WeekdayWeekendRule.kt` | | complex weekday/weekend interval | `^(WD\|WE)(_)?(\d+)$` | `rules/ComplexWeekdayWeekendRule.kt` | | yearly date | `^(-?\d{1,2})(Jan\|Feb\|Mar\|Apr\|May\|Jun\|Jul\|Aug\|Sep\|Oct\|Nov\|Dec)(\d*)(?:\((CLAMP\|NEXT)\))?$` | `rules/DayMonthRule.kt` | | specific date | `^(\d{4}-\d{2}-\d{2})$` | `rules/SingleDateRule.kt` | | limit modifier | `^LIMIT\(((?:Jan\|Feb\|Mar\|Apr\|May\|Jun\|Jul\|Aug\|Sep\|Oct\|Nov\|Dec)(?:\.(?:Jan\|Feb\|Mar\|Apr\|May\|Jun\|Jul\|Aug\|Sep\|Oct\|Nov\|Dec))*)\)$` | `rules/LimitRule.kt` | | week of year | `^(?:[A-Za-z]+\|\([A-Za-z.]+\))__OF__WEEK(?:[1-9]\|[1-4][0-9]\|5[0-3])$` | `rules/WeekOfYearRule.kt` | | complex month | `^([-+]?\d+\|ALL)__([^_]+)__OF__((?:Jan\|Feb\|Mar\|Apr\|May\|Jun\|Jul\|Aug\|Sep\|Oct\|Nov\|Dec)\|\((?:Jan\|Feb\|Mar\|Apr\|May\|Jun\|Jul\|Aug\|Sep\|Oct\|Nov\|Dec)(?:\.(?:Jan\|Feb\|Mar\|Apr\|May\|Jun\|Jul\|Aug\|Sep\|Oct\|Nov\|Dec))+\))(\d*)$` | `rules/ComplexMonthRule.kt` | | complex rule | `^(\(?[-+0-9.]+(?:\.\.[-+0-9]+)?\)?\|[-+]?\d+)__([^_]+)__OF__(DAY\|WEEK\|MONTH\|YEAR)$` | `rules/ComplexRule.kt` | | anchored period | `^(?:(\(?[-+0-9.]+(?:\.\.[-+0-9]+)?\)?\|[-+]?\d+)__)?(.+?)__OF__(DAY\|WEEK\|MONTH\|YEAR)\((.+)\)$` | `rules/AnchoredPeriodRule.kt` | | yearly interval | `^Yearly(\d*)$` | `rules/YearlyRule.kt` | | monthly interval | `^Monthly(\d*)$` | `rules/MonthlyRule.kt` | | weekly interval | `^Weekly(\d*)$` | `rules/Weekly.kt` | | moon phase | `^MOON\(\s*([A-Z_]+\|\d{1,3}(?:\.\.\d{1,3})?)\s*(?:,\s*([A-Z_]+)\s*)?\)$` | `rules/MoonRule.kt` | | easter | `^EASTER(?:\(\s*([A-Za-z]+)\s*(?:,\s*([A-Za-z]+)\s*)?\))?$` | `rules/EasterRule.kt` | | free-form category | `^@&@\([^)]+\)$` | `RuleFactory.kt` | | tag selector | `^Tag\(([A-Z][A-Za-z0-9]*)\)$` | `rules/TagRule.kt` | | event selector | `^Event\(([A-Z][A-Za-z0-9]*)\)$` | `rules/EventRule.kt` | | feed selector | `^Feed\(([A-Z][A-Za-z0-9]*)\)$` | `rules/FeedRule.kt` | | named category | `^([A-Z][a-zA-Z]*[a-z][a-zA-Z]*)(?:\(([A-Z][a-zA-Z]*[a-z][a-zA-Z]*)\))?$` | `rules/CategoryRule.kt` | Beyond these leaf forms it also handles exclusions with `!`, clause lists separated by commas, brace groups, `__&__` intersections, `~` adjustments, and the `__OF__` families for time units, specific months and ISO weeks. ## Composition, grouping and precedence A rule is a list of clauses separated by top-level commas. Each clause adds dates, unless it begins with `!`, which removes them: ```text Sat, !1Sat, !2Sat ``` Within a single clause: - `__&__` joins operands that must *all* match the same date (`13Month__&__Fri`). Intersections are flat — an operand may not itself contain `__&__`, be negated, or be a group. - `~` binds a day type to an anchor date: `{type}~{direction}{anchor}`, as in `WD~<=25Dec`. A leading `!` here negates the *type*, not the clause, so `!WD~>25Dec` still adds a date. - `LIMIT(...)` restricts everything else in its scope to the listed months, and is written last. `{ ... }` is the only grouping construct. Its purpose is scope: a `LIMIT` inside a group applies to that group alone. ```text {WD, LIMIT(Feb.Mar)}, {Mon, LIMIT(Jan.Oct)} ``` Groups do not nest, must contain at least one clause that adds dates, and are always additive — a `!` in front of a group does not turn it into an exclusion. **Parentheses are not grouping.** `( ... )` appears throughout the language, but never to group clauses. It marks a day-set (`(Mon.Wed.Fri)`), an index range or set (`(1..5)`, `(1.3.5)`), a month list (`(Jan.Jul)`), or the argument of a named form (`MOON(FULL)`, `Tag(Work)`, `@&@(karate days)`). Do not reach for it to control precedence; use braces. ### Lexical rules - **Keywords are case-sensitive.** `Mon` is Monday; `MON` and `mon` are not. - **A misspelling may not be an error.** `Mou` is *accepted* — it matches the pattern for a category name, so it silently becomes a reference to a data source called "Mou" rather than a broken Monday. Check spelling against the rule table; the validator cannot catch this for you. - **Whitespace is allowed around clause commas and nowhere else.** `WD , !Fri` is fine; `1__ Fri__OF__YEAR` is not, because the spaces fall inside a token. - There are no comments and no escape characters. A blank rule is invalid. ## Unsupported constructs These cannot be expressed in the rule language. Say so rather than inventing syntax: - **Times and durations.** A rule selects dates. When an event starts and how long it lasts belong to the event, not to its recurrence rule. - **An end date or an occurrence count.** There is no `UNTIL` or `COUNT`; a rule describes an open-ended pattern. "Every Monday for ten weeks" has no rule form. - **Nested brace groups.** `{WD, {Mon}}` is rejected. - **Negating one operand of an intersection.** `Mon__&__!Fri` is rejected; a `!` in front of the whole intersection excludes all of it. - **Excluding a whole `~` adjustment.** The `!` on a `~` rule negates the day type. - **Nested intersections.** Operands are flat. One more thing that is valid but empty: **data-backed rules need the app's data.** `Tag(X)`, `Event(X)`, `Feed(X)`, a bare category like `BankHolidays`, and the `WORKDAY` alias all resolve to nothing on their own. They are correct rules; they simply produce no dates until the app supplies the matching data. ## Worked examples Every verdict below was produced by running the embedded validator while this page was generated. ### Accepted | Rule | Means | Verdict | | --- | --- | --- | | `Day` | every day | valid | | `Mon` | every Monday | valid | | `Fri2` | every second Friday | valid | | `2Sat` | the 2nd Saturday of the month | valid | | `-1Fri` | the last Friday of the month | valid | | `-1Month` | the last day of the month | valid | | `25Dec` | every 25 December | valid | | `-1Feb` | the last day of February, leap years included | valid | | `WD, !BankHolidays` | every weekday except bank holidays | valid | | `WD~<=25Dec` | Christmas if it is a weekday, otherwise the weekday before | valid | | `4__Thu__OF__Nov` | the 4th Thursday of November | valid | | `(1..5)__WD__OF__MONTH` | the first through fifth weekday of the month | valid | | `(1.3.5)__WD__OF__MONTH` | the 1st, 3rd and 5th weekday of the month | valid | | `-1__Tag(Work)__OF__MONTH` | the last work day of the month | valid | | `Mon__OF__WEEK5` | the Monday of ISO week 5 | valid | | `MOON(FULL)` | every full moon | valid | | `MOON(50..75, WANING)` | 50 to 75 percent lit while shrinking | valid | | `13Month__&__Fri` | Friday the 13th | valid | | `{WD, LIMIT(Feb.Mar)}` | weekdays, restricted to February and March | valid | ### Rejected | Rule | Means | Verdict | | --- | --- | --- | | `(1,5)__WD__OF__MONTH` | a comma index; the range form is `(1..5)` | rejected | | `(5..1)__WD__OF__MONTH` | a range must not run backwards | rejected | | `(0..5)__WD__OF__MONTH` | positions are 1-based, so 0 is not one | rejected | | `1__Mon__OF__DECADE` | DAY, WEEK, MONTH and YEAR are the only time units | rejected | | `Mon__OF__WEEK54` | ISO week numbers stop at 53 | rejected | | `Mon__&__!Fri` | an intersection operand cannot be negated on its own | rejected | | `{WD, {Mon}}` | brace groups do not nest | rejected | | `25Dec=WD` | an adjustment needs the `~` operator | rejected | | `FooBar123` | a category name cannot carry digits | rejected | ## What a rule actually produces A valid rule can still be the wrong rule, so these are real evaluations, not worked examples: each was run through the engine that ships in the app over **2025-01-01 to 2026-12-31**, with the event starting on 2025-01-01. Use them to check that a rule you have written means what you intended. | Rule | Means | Occurrences | | --- | --- | --- | | `Day` | Every day | 2025-01-01, 2025-01-02, 2025-01-03, 2025-01-04, 2025-01-05, 2025-01-06, 2025-01-07, 2025-01-08 …and 722 more | | `Day3` | Every 3 days | 2025-01-01, 2025-01-04, 2025-01-07, 2025-01-10, 2025-01-13, 2025-01-16, 2025-01-19, 2025-01-22 …and 236 more | | `Mon` | Every Monday | 2025-01-06, 2025-01-13, 2025-01-20, 2025-01-27, 2025-02-03, 2025-02-10, 2025-02-17, 2025-02-24 …and 96 more | | `Fri2` | Every second Friday | 2025-01-03, 2025-01-17, 2025-01-31, 2025-02-14, 2025-02-28, 2025-03-14, 2025-03-28, 2025-04-11 …and 44 more | | `Monthly2` | Every 2 months | 2025-01-01, 2025-03-01, 2025-05-01, 2025-07-01, 2025-09-01, 2025-11-01, 2026-01-01, 2026-03-01 …and 4 more | | `2025-05-17` | That one date and no other | 2025-05-17 | | `5Month` | The 5th of each month | 2025-01-05, 2025-02-05, 2025-03-05, 2025-04-05, 2025-05-05, 2025-06-05, 2025-07-05, 2025-08-05 …and 16 more | | `-1Month` | The last day of the month | 2025-01-31, 2025-02-28, 2025-03-31, 2025-04-30, 2025-05-31, 2025-06-30, 2025-07-31, 2025-08-31 …and 16 more | | `2Sat` | The 2nd Saturday of the month | 2025-01-11, 2025-02-08, 2025-03-08, 2025-04-12, 2025-05-10, 2025-06-14, 2025-07-12, 2025-08-09 …and 16 more | | `-1Fri` | The last Friday of the month | 2025-01-31, 2025-02-28, 2025-03-28, 2025-04-25, 2025-05-30, 2025-06-27, 2025-07-25, 2025-08-29 …and 16 more | | `2_Sat` | The Saturday in the 2nd week of the month | 2025-01-11, 2025-02-08, 2025-03-08, 2025-04-12, 2025-05-10, 2025-06-07, 2025-07-12, 2025-08-09 …and 16 more | | `WD` | Every weekday | 2025-01-01, 2025-01-02, 2025-01-03, 2025-01-06, 2025-01-07, 2025-01-08, 2025-01-09, 2025-01-10 …and 514 more | | `WE` | Every weekend day | 2025-01-04, 2025-01-05, 2025-01-11, 2025-01-12, 2025-01-18, 2025-01-19, 2025-01-25, 2025-01-26 …and 200 more | | `WD2` | Every 2nd weekday, counted | 2025-01-01, 2025-01-03, 2025-01-07, 2025-01-09, 2025-01-13, 2025-01-15, 2025-01-17, 2025-01-21 …and 253 more | | `WD_2` | Weekdays in the 2nd week of the month | 2025-01-06, 2025-01-07, 2025-01-08, 2025-01-09, 2025-01-10, 2025-02-03, 2025-02-04, 2025-02-05 …and 112 more | | `-1WD` | The last weekday of the month | 2025-01-31, 2025-02-28, 2025-03-31, 2025-04-30, 2025-05-30, 2025-06-30, 2025-07-31, 2025-08-29 …and 16 more | | `25Dec` | Every 25 December | 2025-12-25, 2026-12-25 | | `-1Feb` | The last day of February, which moves with leap years | 2025-02-28, 2026-02-28 | | `-1__WD__OF__MONTH` | The last weekday of the month | 2025-01-31, 2025-02-28, 2025-03-31, 2025-04-30, 2025-05-30, 2025-06-30, 2025-07-31, 2025-08-29 …and 16 more | | `(1..5)__WD__OF__MONTH` | The first through fifth weekday of the month | 2025-01-01, 2025-01-02, 2025-01-03, 2025-01-06, 2025-01-07, 2025-02-03, 2025-02-04, 2025-02-05 …and 112 more | | `(1.3.5)__WD__OF__MONTH` | The 1st, 3rd and 5th weekday of the month | 2025-01-01, 2025-01-03, 2025-01-07, 2025-02-03, 2025-02-05, 2025-02-07, 2025-03-03, 2025-03-05 …and 64 more | | `2__(Mon.Wed.Fri)__OF__MONTH` | The 2nd day among Mon, Wed and Fri each month | 2025-01-03, 2025-02-05, 2025-03-05, 2025-04-04, 2025-05-05, 2025-06-04, 2025-07-04, 2025-08-04 …and 16 more | | `4__Thu__OF__Nov` | The 4th Thursday of November | 2025-11-27, 2026-11-26 | | `-1__Mon__OF__May` | The last Monday of May | 2025-05-26, 2026-05-25 | | `ALL__Mon__OF__Jan` | Every Monday in January | 2025-01-06, 2025-01-13, 2025-01-20, 2025-01-27, 2026-01-05, 2026-01-12, 2026-01-19, 2026-01-26 | | `Mon__OF__WEEK5` | The Monday of ISO week 5 | 2025-01-27, 2026-01-26 | | `(Mon.Wed.Fri)__OF__WEEK5` | Mon, Wed and Fri of ISO week 5 | 2025-01-27, 2025-01-29, 2025-01-31, 2026-01-26, 2026-01-28, 2026-01-30 | | `WD~<=25Dec` | Christmas if it is a weekday, otherwise the weekday before | 2025-12-25, 2026-12-25 | | `WD~>25Dec` | The weekday just after Christmas | 2025-12-26, 2026-12-28 | | `WD~=25Dec` | The nearest weekday to Christmas | 2025-12-25, 2026-12-25 | | `!WD~>25Dec` | The first non-weekday after Christmas | 2025-12-27, 2026-12-26 | | `MOON(FULL)` | Every full moon | 2025-01-14, 2025-02-13, 2025-03-15, 2025-04-13, 2025-05-13, 2025-06-11, 2025-07-11, 2025-08-09 …and 17 more | | `MOON(NEW)` | Every new moon | 2025-01-29, 2025-02-28, 2025-03-29, 2025-04-28, 2025-05-27, 2025-06-26, 2025-07-25, 2025-08-24 …and 16 more | | `EASTER` | Easter Sunday | 2025-04-20, 2026-04-05 | | `EASTER(GoodFriday)` | Good Friday | 2025-04-18, 2026-04-03 | | `EASTER(Monday)` | Easter Monday | 2025-04-21, 2026-04-06 | | `EASTER(AshWednesday)` | Ash Wednesday, the first day of Lent | 2025-03-05, 2026-02-18 | | `EASTER(Orthodox)` | Orthodox Easter Sunday | 2025-04-20, 2026-04-12 | | `13Month__&__Fri` | Friday the 13th | 2025-06-13, 2026-02-13, 2026-03-13, 2026-11-13 | | `{Sat, !1Sat, !2Sat}` | Every Saturday except the first two of the month | 2025-01-18, 2025-01-25, 2025-02-15, 2025-02-22, 2025-03-15, 2025-03-22, 2025-03-29, 2025-04-19 …and 48 more | | `{WD, LIMIT(Feb.Mar)}` | Weekdays, but only in February and March | 2025-02-03, 2025-02-04, 2025-02-05, 2025-02-06, 2025-02-07, 2025-02-10, 2025-02-11, 2025-02-12 …and 75 more | | `{Mon, Thu, LIMIT(Jan.Feb.Mar)}` | Mondays and Thursdays, first quarter only | 2025-01-02, 2025-01-06, 2025-01-09, 2025-01-13, 2025-01-16, 2025-01-20, 2025-01-23, 2025-01-27 …and 44 more | | `25Dec-P1Day` | Christmas Eve, every year, whatever weekday it is | 2025-12-24, 2026-12-24 | | `25Dec+P1Day` | Boxing Day, every year | 2025-12-26, 2026-12-26 | | `Mon-P1Day` | The Sunday before every Monday | 2025-01-05, 2025-01-12, 2025-01-19, 2025-01-26, 2025-02-02, 2025-02-09, 2025-02-16, 2025-02-23 …and 96 more | | `-1Thu+P2Day` | Two days after the last Thursday of the month | 2025-02-01, 2025-03-01, 2025-03-29, 2025-04-26, 2025-05-31, 2025-06-28, 2025-08-02, 2025-08-30 …and 15 more | | `31Jan+P1Month` | 31 January moved a month, clamped to the end of February | 2025-02-28, 2026-02-28 | | `31Jan+P1Month-P1Month` | Clamping is lossy, so this is the 28th of January, not the 31st | 2025-01-28, 2026-01-28 | | `1Month+P1Month-P1Day` | The last day of every month, reached by chaining | 2025-01-31, 2025-02-28, 2025-03-31, 2025-04-30, 2025-05-31, 2025-06-30, 2025-07-31, 2025-08-31 …and 16 more | | `2025-05-17-P1Day` | The day before that one date | 2025-05-16 | | `31Month` | The 31st, only in the seven months that have one | 2025-01-31, 2025-03-31, 2025-05-31, 2025-07-31, 2025-08-31, 2025-10-31, 2025-12-31, 2026-01-31 …and 6 more | | `29Month` | The 29th, including February in leap years but not otherwise | 2025-01-29, 2025-03-29, 2025-04-29, 2025-05-29, 2025-06-29, 2025-07-29, 2025-08-29, 2025-09-29 …and 14 more | | `31Month2` | Every second month's 31st, keeping the beat and dropping the misses | 2025-01-31, 2025-03-31, 2025-05-31, 2025-07-31, 2026-01-31, 2026-03-31, 2026-05-31, 2026-07-31 | | `31Month(CLAMP)` | The 31st, or the month's last day where it is shorter | 2025-01-31, 2025-02-28, 2025-03-31, 2025-04-30, 2025-05-31, 2025-06-30, 2025-07-31, 2025-08-31 …and 16 more | | `31Month(NEXT)` | The 31st, counting past the end where the month is shorter — 3 March, not the 1st | 2025-01-31, 2025-03-03, 2025-03-31, 2025-05-01, 2025-05-31, 2025-07-01, 2025-07-31, 2025-08-31 …and 16 more | | `31Month2(CLAMP)` | Every second month, clamped, keeping to the odd months | 2025-01-31, 2025-03-31, 2025-05-31, 2025-07-31, 2025-09-30, 2025-11-30, 2026-01-31, 2026-03-31 …and 4 more | | `29Feb(NEXT)` | 29 February, counting on to 1 March in a common year | 2025-03-01, 2026-03-01 | ## Related - [The rule reference](/docs/rules-reference.md) is the human-facing explanation of the same syntax, with what each form means. - [The documentation index](https://calendite.com/llms.txt) lists every page in Markdown.