diff --git a/.github/wiki/_Sidebar.md b/.github/wiki/_Sidebar.md index 7ad4056..85ca9b0 100644 --- a/.github/wiki/_Sidebar.md +++ b/.github/wiki/_Sidebar.md @@ -6,9 +6,16 @@ **Examples** - [Basic Usage](examples/basic-usage) +- [Scheduling Display](examples/scheduling-display) + +**API** +- [HijriCalendar](api/HijriCalendar) +- [UaqCalendar](api/UaqCalendar) +- [FcnaCalendar](api/FcnaCalendar) +- [Singletons](api/singletons) +- [Full API Reference](API-Reference) **Reference** -- [API Reference](API-Reference) - [Architecture](Architecture) - [Benchmarks](benchmarks/index) diff --git a/.github/wiki/examples/scheduling-display.md b/.github/wiki/examples/scheduling-display.md new file mode 100644 index 0000000..cc45801 --- /dev/null +++ b/.github/wiki/examples/scheduling-display.md @@ -0,0 +1,99 @@ +# Example: Scheduling Display with Hijri Dates + +A common need in calendaring apps for Muslim communities is displaying both the +Gregorian and Hijri dates for an event. This example shows how to take a list of +event dates, annotate each with its Hijri date, and display it in a human-readable +format. + +## Setup + +```typescript +import { Temporal } from '@js-temporal/polyfill'; +import { uaqCalendar } from 'temporal-hijri'; +``` + +## Month name lookup + +The calendar returns numeric months (1-12). Map them to names: + +```typescript +const HIJRI_MONTHS = [ + 'Muharram', 'Safar', 'Rabi al-Awwal', 'Rabi al-Thani', + 'Jumada al-Ula', 'Jumada al-Akhira', 'Rajab', 'Shaban', + 'Ramadan', 'Shawwal', 'Dhul-Qadah', 'Dhul-Hijja', +]; + +function hijriMonthName(month: number): string { + return HIJRI_MONTHS[month - 1] ?? 'Unknown'; +} +``` + +## Format a single date + +```typescript +function formatWithHijri(isoDateStr: string): string { + const isoDate = Temporal.PlainDate.from(isoDateStr); + const hy = uaqCalendar.year(isoDate); + const hm = uaqCalendar.month(isoDate); + const hd = uaqCalendar.day(isoDate); + + const monthName = hijriMonthName(hm); + return `${isoDateStr} (${hd} ${monthName} ${hy} AH)`; +} +``` + +## Annotate a schedule + +```typescript +const events = [ + { title: 'Project kickoff', date: '2025-01-01' }, + { title: 'Mid-year review', date: '2025-06-15' }, + { title: 'Year-end summary', date: '2025-12-31' }, +]; + +for (const event of events) { + console.log(`${event.title}: ${formatWithHijri(event.date)}`); +} +``` + +Output: + +``` +Project kickoff: 2025-01-01 (2 Rajab 1446 AH) +Mid-year review: 2025-06-15 (19 Dhul-Hijja 1446 AH) +Year-end summary: 2025-12-31 (11 Jumada al-Akhira 1447 AH) +``` + +## Find the start of Ramadan for a given Hijri year + +```typescript +function ramadanStart(hijriYear: number): Temporal.PlainDate { + // 1 Ramadan = month 9, day 1 + return uaqCalendar.dateFromFields({ year: hijriYear, month: 9, day: 1 }); +} + +const ramadan1447 = ramadanStart(1447); +console.log(ramadan1447.toString()); // 2026-02-18 (approximate) +``` + +## Count days until an event in Hijri months + +```typescript +const today = Temporal.Now.plainDateISO(); +const eid = uaqCalendar.dateFromFields({ year: 1447, month: 10, day: 1 }); +const diff = uaqCalendar.dateUntil(today, eid, { largestUnit: 'months' }); + +console.log(`Eid al-Fitr 1447 is in ${diff.months} month(s) and ${diff.days} day(s)`); +``` + +## Notes + +- Month names are transliterated from Arabic. Adapt the spelling to your style guide. +- UAQ covers 1318-1500 AH. For dates outside that range, substitute `fcnaCalendar`. +- `Temporal.Now.plainDateISO()` returns the current date in the host's local calendar. + It does not return a Hijri date directly; pass the result to the calendar methods + to get Hijri coordinates. + +--- + +[Home](../Home) · [Basic Usage](basic-usage) · [API Reference](../API-Reference) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5743612..4159d1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,3 +78,25 @@ jobs: grep "README.md" pack-output.txt grep "CHANGELOG.md" pack-output.txt grep "LICENSE" pack-output.txt + + coverage: + name: Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Enable corepack + run: corepack enable + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run build + - name: Coverage + run: pnpm run coverage + - name: Upload to Codecov + uses: codecov/codecov-action@v4 + with: + files: ./coverage/lcov.info + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false diff --git a/README.md b/README.md index 640d513..9ea2509 100644 --- a/README.md +++ b/README.md @@ -4,208 +4,64 @@ # temporal-hijri -Temporal Calendar Protocol implementation for the Hijri calendar. Works with the TC39 Temporal proposal (Stage 3) and `@js-temporal/polyfill`. +Temporal Calendar Protocol implementation for the Hijri calendar. Works with the TC39 +Temporal proposal (Stage 3) and `@js-temporal/polyfill`. -Provides `UaqCalendar` (Umm al-Qura) and `FcnaCalendar` (FCNA/ISNA) as plug-in calendars for `Temporal.PlainDate` and related types. The underlying conversion logic comes from [hijri-core](https://github.com/acamarata/hijri-core), a zero-dependency Hijri engine with table-driven UAQ data and astronomical FCNA calculations. - ---- +Provides `UaqCalendar` (Umm al-Qura) and `FcnaCalendar` (FCNA/ISNA) as plug-in +calendars for `Temporal.PlainDate`. The underlying conversion logic comes from +[hijri-core](https://github.com/acamarata/hijri-core). ## Installation ```bash pnpm add temporal-hijri hijri-core +# Add the polyfill if native Temporal is unavailable: +pnpm add @js-temporal/polyfill ``` -If you are using the polyfill instead of the native `Temporal` API: - -```bash -pnpm add temporal-hijri hijri-core @js-temporal/polyfill -``` - ---- - ## Quick Start ```typescript -import { Temporal } from '@js-temporal/polyfill'; // or use native Temporal +import { Temporal } from '@js-temporal/polyfill'; import { uaqCalendar } from 'temporal-hijri'; -// Convert an ISO date to Hijri coordinates const isoDate = Temporal.PlainDate.from('2023-03-23'); console.log(uaqCalendar.year(isoDate)); // 1444 console.log(uaqCalendar.month(isoDate)); // 9 (Ramadan) console.log(uaqCalendar.day(isoDate)); // 1 -console.log(uaqCalendar.monthCode(isoDate)); // "M09" -console.log(uaqCalendar.inLeapYear(isoDate)); // false (1444 is 354 days) -// Convert Hijri coordinates back to ISO +// Convert Hijri coordinates to ISO const ramadan = uaqCalendar.dateFromFields({ year: 1444, month: 9, day: 1 }); console.log(ramadan.toString()); // "2023-03-23" -// Arithmetic in Hijri space +// Date arithmetic in Hijri space const { Duration } = Temporal; const nextMonth = uaqCalendar.dateAdd(isoDate, new Duration(0, 1)); console.log(uaqCalendar.month(nextMonth)); // 10 (Shawwal) -console.log(nextMonth.toString()); // "2023-04-21" ``` ---- +## Calendars -## Calendar Classes - -### `UaqCalendar` - -Implements the Umm al-Qura calendar, the official calendar of Saudi Arabia. Month boundaries come from pre-calculated tables covering 1318-1500 AH (Gregorian 1900-2076). The most widely used Hijri calendar standard for civil and religious purposes. - -```typescript -import { UaqCalendar } from 'temporal-hijri'; -const cal = new UaqCalendar(); // cal.id === 'hijri-uaq' -``` - -### `FcnaCalendar` - -Implements the FCNA/ISNA calendar used by the Fiqh Council of North America and the Islamic Society of North America. Month starts are determined by astronomical new moon calculation (Meeus Chapter 49): if conjunction occurs before 12:00 UTC, the month begins the next day; if at or after noon, it begins the day after that. - -```typescript -import { FcnaCalendar } from 'temporal-hijri'; -const cal = new FcnaCalendar(); // cal.id === 'hijri-fcna' -``` - -### `HijriCalendar` (base class) - -The base implementation. Accepts any `CalendarEngine` from hijri-core. Use this to build a Temporal calendar from a custom engine registered via `hijri-core`'s `registerCalendar()`. - -```typescript -import { HijriCalendar } from 'temporal-hijri'; -import { getCalendar, registerCalendar } from 'hijri-core'; - -// Register a custom engine first -registerCalendar('my-calendar', myEngine); -const cal = new HijriCalendar(getCalendar('my-calendar')); -// cal.id === 'hijri-my-calendar' -``` - -### Convenience singletons - -`uaqCalendar` and `fcnaCalendar` are pre-constructed instances. They are shared objects and safe to reuse across calls. - -```typescript -import { uaqCalendar, fcnaCalendar } from 'temporal-hijri'; -``` - ---- - -## API - -All methods receive a `Temporal.PlainDate` with an ISO (Gregorian) calendar. The PlainDate carries the ISO year/month/day; the calendar object interprets those coordinates. - -| Method | Returns | Description | -|---|---|---| -| `year(date)` | `number` | Hijri year | -| `month(date)` | `number` | Hijri month (1-12) | -| `monthCode(date)` | `string` | Month code: `"M01"` through `"M12"` | -| `day(date)` | `number` | Day of the Hijri month (1-29 or 1-30) | -| `daysInMonth(date)` | `number` | Length of the Hijri month (29 or 30) | -| `daysInYear(date)` | `number` | Days in the Hijri year (354 or 355) | -| `monthsInYear(date)` | `number` | Always `12` | -| `inLeapYear(date)` | `boolean` | `true` if the year has 355 days | -| `dayOfWeek(date)` | `number` | ISO weekday: 1=Monday, 7=Sunday | -| `dayOfYear(date)` | `number` | Day position within the Hijri year | -| `weekOfYear(date)` | `number` | Week position within the Hijri year | -| `daysInWeek(date)` | `number` | Always `7` | -| `dateFromFields(fields)` | `Temporal.PlainDate` | Construct ISO PlainDate from `{year, month, day}` in Hijri | -| `yearMonthFromFields(fields)` | `Temporal.PlainYearMonth` | Construct from `{year, month}` in Hijri | -| `monthDayFromFields(fields)` | `Temporal.PlainMonthDay` | Construct from `{month, day}` in Hijri | -| `dateAdd(date, duration)` | `Temporal.PlainDate` | Add a duration; years/months applied in Hijri space, days in ISO space | -| `dateUntil(one, two, options)` | `Temporal.Duration` | Difference between two dates; supports `largestUnit: 'years'|'months'|'days'|'weeks'` | -| `mergeFields(fields, additional)` | `Record` | Merge field objects (Temporal protocol requirement) | -| `toString()` | `string` | Calendar identifier (`"hijri-uaq"` or `"hijri-fcna"`) | - ---- - -## Calendar Systems - -| System | ID | Authority | Method | Coverage | -|---|---|---|---|---| -| Umm al-Qura | `hijri-uaq` | KACST / Saudi Arabia | Pre-calculated tables | 1318-1500 AH (1900-2076 CE) | -| FCNA/ISNA | `hijri-fcna` | Fiqh Council of North America | Astronomical new moon (Meeus) | Unlimited (calculated) | - -UAQ dates outside 1318-1500 AH throw `RangeError`. FCNA is unbounded but loses precision for very early dates. - ---- - -## Custom Calendars - -Any engine registered in hijri-core can be wrapped in a Temporal calendar: - -```typescript -import { HijriCalendar } from 'temporal-hijri'; -import { registerCalendar, getCalendar } from 'hijri-core'; -import type { CalendarEngine } from 'hijri-core'; - -const myEngine: CalendarEngine = { - id: 'local-sighting', - toHijri(date) { /* ... */ return { hy, hm, hd }; }, - toGregorian(hy, hm, hd) { /* ... */ return new Date(...); }, - isValid(hy, hm, hd) { /* ... */ return true; }, - daysInMonth(hy, hm) { /* ... */ return 29; }, -}; - -registerCalendar('local-sighting', myEngine); -const cal = new HijriCalendar(getCalendar('local-sighting')); -// cal.id === 'hijri-local-sighting' -``` - ---- - -## TypeScript - -All types are exported: - -```typescript -import type { HijriDate, ConversionOptions, HijriCalendarOptions } from 'temporal-hijri'; -``` - -The package ships dual CJS/ESM builds with full `.d.ts` and `.d.mts` declarations. - ---- - -## Architecture - -A thin adapter over [hijri-core](https://github.com/acamarata/hijri-core) that maps the Temporal Calendar Protocol to Hijri calendar engine calls. The `UaqCalendar` and `FcnaCalendar` classes implement `Temporal.CalendarProtocol`: the package itself adds no calendar math. - -For more detail see the [Architecture wiki page](https://github.com/acamarata/temporal-hijri/wiki/Architecture). - -## Compatibility - -- Node.js 20, 22, 24 -- Any bundler supporting `exports` field (`Vite`, `Webpack 5`, `Rollup`, `esbuild`) -- ESM (`import`) and CommonJS (`require`): both provided -- No native `Temporal` required: works entirely with `@js-temporal/polyfill` - ---- +| Calendar | ID | Authority | Method | Coverage | +|-------------|--------------|--------------------|--------------------------|------------------| +| Umm al-Qura | `hijri-uaq` | KACST, Saudi Arabia | Pre-calculated tables | 1318-1500 AH | +| FCNA/ISNA | `hijri-fcna` | Fiqh Council of NA | Astronomical new moon | Unbounded | ## Documentation -Full reference, architecture notes, and algorithmic detail in the [wiki](https://github.com/acamarata/temporal-hijri/wiki). +Full reference in the [wiki](https://github.com/acamarata/temporal-hijri/wiki). ---- +- [API Reference](https://github.com/acamarata/temporal-hijri/wiki/API-Reference) +- [Architecture](https://github.com/acamarata/temporal-hijri/wiki/Architecture) +- [Examples](https://github.com/acamarata/temporal-hijri/wiki/examples/basic-usage) ## Related -- [hijri-core](https://github.com/acamarata/hijri-core): zero-dependency Hijri engine powering this package -- [luxon-hijri](https://github.com/acamarata/luxon-hijri): Hijri/Gregorian conversion for Luxon +- [hijri-core](https://github.com/acamarata/hijri-core): the underlying calendar engine +- [luxon-hijri](https://github.com/acamarata/luxon-hijri): Hijri support for Luxon - [pray-calc](https://github.com/acamarata/pray-calc): Islamic prayer times ---- - -## Acknowledgments - -Calendar data and algorithms provided by [hijri-core](https://github.com/acamarata/hijri-core). The Umm al-Qura table is derived from data published by the King Abdulaziz City for Science and Technology (KACST). FCNA new moon calculations follow Jean Meeus, "Astronomical Algorithms," 2nd ed., Chapter 49. - ---- - ## License MIT. Copyright (c) 2026 Aric Camarata. See [LICENSE](LICENSE). diff --git a/coverage/lcov-report/base.css b/coverage/lcov-report/base.css new file mode 100644 index 0000000..f418035 --- /dev/null +++ b/coverage/lcov-report/base.css @@ -0,0 +1,224 @@ +body, html { + margin:0; padding: 0; + height: 100%; +} +body { + font-family: Helvetica Neue, Helvetica, Arial; + font-size: 14px; + color:#333; +} +.small { font-size: 12px; } +*, *:after, *:before { + -webkit-box-sizing:border-box; + -moz-box-sizing:border-box; + box-sizing:border-box; + } +h1 { font-size: 20px; margin: 0;} +h2 { font-size: 14px; } +pre { + font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace; + margin: 0; + padding: 0; + -moz-tab-size: 2; + -o-tab-size: 2; + tab-size: 2; +} +a { color:#0074D9; text-decoration:none; } +a:hover { text-decoration:underline; } +.strong { font-weight: bold; } +.space-top1 { padding: 10px 0 0 0; } +.pad2y { padding: 20px 0; } +.pad1y { padding: 10px 0; } +.pad2x { padding: 0 20px; } +.pad2 { padding: 20px; } +.pad1 { padding: 10px; } +.space-left2 { padding-left:55px; } +.space-right2 { padding-right:20px; } +.center { text-align:center; } +.clearfix { display:block; } +.clearfix:after { + content:''; + display:block; + height:0; + clear:both; + visibility:hidden; + } +.fl { float: left; } +@media only screen and (max-width:640px) { + .col3 { width:100%; max-width:100%; } + .hide-mobile { display:none!important; } +} + +.quiet { + color: #7f7f7f; + color: rgba(0,0,0,0.5); +} +.quiet a { opacity: 0.7; } + +.fraction { + font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; + font-size: 10px; + color: #555; + background: #E8E8E8; + padding: 4px 5px; + border-radius: 3px; + vertical-align: middle; +} + +div.path a:link, div.path a:visited { color: #333; } +table.coverage { + border-collapse: collapse; + margin: 10px 0 0 0; + padding: 0; +} + +table.coverage td { + margin: 0; + padding: 0; + vertical-align: top; +} +table.coverage td.line-count { + text-align: right; + padding: 0 5px 0 20px; +} +table.coverage td.line-coverage { + text-align: right; + padding-right: 10px; + min-width:20px; +} + +table.coverage td span.cline-any { + display: inline-block; + padding: 0 5px; + width: 100%; +} +.missing-if-branch { + display: inline-block; + margin-right: 5px; + border-radius: 3px; + position: relative; + padding: 0 4px; + background: #333; + color: yellow; +} + +.skip-if-branch { + display: none; + margin-right: 10px; + position: relative; + padding: 0 4px; + background: #ccc; + color: white; +} +.missing-if-branch .typ, .skip-if-branch .typ { + color: inherit !important; +} +.coverage-summary { + border-collapse: collapse; + width: 100%; +} +.coverage-summary tr { border-bottom: 1px solid #bbb; } +.keyline-all { border: 1px solid #ddd; } +.coverage-summary td, .coverage-summary th { padding: 10px; } +.coverage-summary tbody { border: 1px solid #bbb; } +.coverage-summary td { border-right: 1px solid #bbb; } +.coverage-summary td:last-child { border-right: none; } +.coverage-summary th { + text-align: left; + font-weight: normal; + white-space: nowrap; +} +.coverage-summary th.file { border-right: none !important; } +.coverage-summary th.pct { } +.coverage-summary th.pic, +.coverage-summary th.abs, +.coverage-summary td.pct, +.coverage-summary td.abs { text-align: right; } +.coverage-summary td.file { white-space: nowrap; } +.coverage-summary td.pic { min-width: 120px !important; } +.coverage-summary tfoot td { } + +.coverage-summary .sorter { + height: 10px; + width: 7px; + display: inline-block; + margin-left: 0.5em; + background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent; +} +.coverage-summary .sorted .sorter { + background-position: 0 -20px; +} +.coverage-summary .sorted-desc .sorter { + background-position: 0 -10px; +} +.status-line { height: 10px; } +/* yellow */ +.cbranch-no { background: yellow !important; color: #111; } +/* dark red */ +.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 } +.low .chart { border:1px solid #C21F39 } +.highlighted, +.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{ + background: #C21F39 !important; +} +/* medium red */ +.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE } +/* light red */ +.low, .cline-no { background:#FCE1E5 } +/* light green */ +.high, .cline-yes { background:rgb(230,245,208) } +/* medium green */ +.cstat-yes { background:rgb(161,215,106) } +/* dark green */ +.status-line.high, .high .cover-fill { background:rgb(77,146,33) } +.high .chart { border:1px solid rgb(77,146,33) } +/* dark yellow (gold) */ +.status-line.medium, .medium .cover-fill { background: #f9cd0b; } +.medium .chart { border:1px solid #f9cd0b; } +/* light yellow */ +.medium { background: #fff4c2; } + +.cstat-skip { background: #ddd; color: #111; } +.fstat-skip { background: #ddd; color: #111 !important; } +.cbranch-skip { background: #ddd !important; color: #111; } + +span.cline-neutral { background: #eaeaea; } + +.coverage-summary td.empty { + opacity: .5; + padding-top: 4px; + padding-bottom: 4px; + line-height: 1; + color: #888; +} + +.cover-fill, .cover-empty { + display:inline-block; + height: 12px; +} +.chart { + line-height: 0; +} +.cover-empty { + background: white; +} +.cover-full { + border-right: none !important; +} +pre.prettyprint { + border: none !important; + padding: 0 !important; + margin: 0 !important; +} +.com { color: #999 !important; } +.ignore-none { color: #999; font-weight: normal; } + +.wrapper { + min-height: 100%; + height: auto !important; + height: 100%; + margin: 0 auto -48px; +} +.footer, .push { + height: 48px; +} diff --git a/coverage/lcov-report/block-navigation.js b/coverage/lcov-report/block-navigation.js new file mode 100644 index 0000000..530d1ed --- /dev/null +++ b/coverage/lcov-report/block-navigation.js @@ -0,0 +1,87 @@ +/* eslint-disable */ +var jumpToCode = (function init() { + // Classes of code we would like to highlight in the file view + var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no']; + + // Elements to highlight in the file listing view + var fileListingElements = ['td.pct.low']; + + // We don't want to select elements that are direct descendants of another match + var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > ` + + // Selector that finds elements on the page to which we can jump + var selector = + fileListingElements.join(', ') + + ', ' + + notSelector + + missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b` + + // The NodeList of matching elements + var missingCoverageElements = document.querySelectorAll(selector); + + var currentIndex; + + function toggleClass(index) { + missingCoverageElements + .item(currentIndex) + .classList.remove('highlighted'); + missingCoverageElements.item(index).classList.add('highlighted'); + } + + function makeCurrent(index) { + toggleClass(index); + currentIndex = index; + missingCoverageElements.item(index).scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center' + }); + } + + function goToPrevious() { + var nextIndex = 0; + if (typeof currentIndex !== 'number' || currentIndex === 0) { + nextIndex = missingCoverageElements.length - 1; + } else if (missingCoverageElements.length > 1) { + nextIndex = currentIndex - 1; + } + + makeCurrent(nextIndex); + } + + function goToNext() { + var nextIndex = 0; + + if ( + typeof currentIndex === 'number' && + currentIndex < missingCoverageElements.length - 1 + ) { + nextIndex = currentIndex + 1; + } + + makeCurrent(nextIndex); + } + + return function jump(event) { + if ( + document.getElementById('fileSearch') === document.activeElement && + document.activeElement != null + ) { + // if we're currently focused on the search input, we don't want to navigate + return; + } + + switch (event.which) { + case 78: // n + case 74: // j + goToNext(); + break; + case 66: // b + case 75: // k + case 80: // p + goToPrevious(); + break; + } + }; +})(); +window.addEventListener('keydown', jumpToCode); diff --git a/coverage/lcov-report/favicon.png b/coverage/lcov-report/favicon.png new file mode 100644 index 0000000..c1525b8 Binary files /dev/null and b/coverage/lcov-report/favicon.png differ diff --git a/coverage/lcov-report/index.html b/coverage/lcov-report/index.html new file mode 100644 index 0000000..162c0d9 --- /dev/null +++ b/coverage/lcov-report/index.html @@ -0,0 +1,131 @@ + + + + +
++ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| src | +
+
+ |
+ 100% | +12/12 | +100% | +0/0 | +100% | +0/0 | +100% | +12/12 | +
| src/calendars | +
+
+ |
+ 92.7% | +381/411 | +80.35% | +45/56 | +89.28% | +25/28 | +92.7% | +381/411 | +