npm package: @saints-app/litcal
Description: Liturgical calendar resolution engine — resolves liturgical days from dates and calendar layers
Latest version: 1.5.0

# @saints-app/litcal

Liturgical calendar resolution engine — resolves liturgical days from dates and calendar layers.

Part of the **Saints App** ecosystem.

---

## 📦 Installation (for saints-app or other consumers)

```bash
npm install @saints-app/litcal
```

**That's it!** No need to install Romcal or country-specific calendar packages. Everything is included.

### 🔧 Local Development (if you have the repo cloned)

If you have both `litcal` and `saints-app` repos in the same parent directory and want to work on litcal locally:

1. In `saints-app/package.json`, change the dependency:
   ```json
   {
     "dependencies": {
       "@saints-app/litcal": "file:../litcal"
     }
   }
   ```

2. Install:
   ```bash
   cd saints-app
   npm install --legacy-peer-deps
   ```

3. When you make changes to litcal:
   ```bash
   cd litcal
   make build  # Compiles TypeScript
   ```

4. Refresh your browser in saints-app (Cmd+Shift+R)

**Important**: Don't commit the `file:../litcal` change to git. Revert to the npm version before committing:
```json
"@saints-app/litcal": "^1.1.2"
```

---

## 🎯 Quick Start

```typescript
import { createLitcal } from '@saints-app/litcal';

// Create resolver once (loads all calendars automatically)
const litcal = await createLitcal();

// Use with any calendar - just pass the ID
const result = await litcal.resolveDay({
  date: '2026-07-25',
  calendarId: 'spain'
});

console.log(result.celebrations[0].id);    // 'james_apostle'
console.log(result.celebrations[0].rank);  // 'SOLEMNITY' (elevated by Spain calendar)
console.log(result.liturgy.season);        // 'ORDINARY_TIME'
console.log(result.liturgy.psalterWeek);   // 4
```

**That's it!** No JSON imports, no manual calendar loading. Just `createLitcal()` and pass the calendar ID.

---

## 🏗️ How It Works

### Architecture

```
┌─────────────────────────────────────────────────┐
│  saints-app requests:                           │
│  Date: 2026-07-25, Calendar: 'spain'           │
└─────────────────┬───────────────────────────────┘
                  │
                  ▼
┌─────────────────────────────────────────────────┐
│  litcal                                         │
│  ┌────────────────────────────────────────┐    │
│  │ 1. RomcalSource                        │    │
│  │    → General Roman Calendar (base)     │    │
│  │    → Returns: james_apostle (FEAST)    │    │
│  └────────────────────────────────────────┘    │
│                  │                              │
│                  ▼                              │
│  ┌────────────────────────────────────────┐    │
│  │ 2. StaticCalendarSource                │    │
│  │    → spain.json rules                  │    │
│  │    → REPLACE: james_apostle            │    │
│  │      Elevate to SOLEMNITY              │    │
│  └────────────────────────────────────────┘    │
│                  │                              │
│                  ▼                              │
│  ┌────────────────────────────────────────┐    │
│  │ 3. Resolver                            │    │
│  │    → Merges sources                    │    │
│  │    → Applies precedence rules          │    │
│  │    → Returns final result              │    │
│  └────────────────────────────────────────┘    │
└─────────────────┬───────────────────────────────┘
                  │
                  ▼
┌─────────────────────────────────────────────────┐
│  Result: james_apostle (SOLEMNITY)             │
│  Holy Day of Obligation: true                   │
│  Source: spain calendar                         │
└─────────────────────────────────────────────────┘
```

### Key Concepts

1. **One dependency, all calendars**: Install `@saints-app/litcal` — that's it. No need for `@romcal/calendar.spain`, `@romcal/calendar.italy`, etc.

2. **Romcal only for General Roman**: We use Romcal's General Roman Calendar as the universal base. All other calendars (national, diocesan, religious orders) are JSON rules defined by us.

3. **Calendar layers**: 
   - `general-roman` → Universal base (from Romcal)
   - `spain`, `italy`, etc. → National calendars (our JSON rules)
   - `diocese-madrid` → Diocesan calendars (our JSON rules)
   - `dominicans` → Religious order calendars (our JSON rules)

4. **Rule actions**:
   - `ADD` → Add a new celebration
   - `REPLACE` → Replace/upgrade an existing celebration (e.g., elevate FEAST → SOLEMNITY)
   - `SUPPRESS` → Remove a celebration
   - `TRANSFER` → Move a celebration to a different date

5. **Independent of language**: litcal returns celebration IDs and structural data. Your app handles translations via i18n using the IDs.

---

## 📚 Available Calendars

All calendars are loaded automatically when you call `createLitcal()`. Just pass the calendar ID to `resolveDay()`:

- `general-roman` — General Roman Calendar (from Romcal, universal base)
- `europe` — Europa (San Benito, Santa Brígida, Santa Catalina, Cirilo y Metodio, Teresa Benedicta)
- `spain` — España (Santiago Apóstol, Virgen del Pilar, Santa Teresa)
- `italy` — Italia
- `test` — Test calendar with examples of all rule actions

**Usage:**
```typescript
const litcal = await createLitcal();

// Use any calendar - they're all loaded
const spainResult = await litcal.resolveDay({ date: '2026-07-25', calendarId: 'spain' });
const italyResult = await litcal.resolveDay({ date: '2026-07-25', calendarId: 'italy' });
```

### Coming soon

- `diocese-madrid` — Diócesis de Madrid
- `diocese-barcelona` — Diócesis de Barcelona
- More national/diocesan/religious order calendars

### Creating custom calendars

See [Creating Custom Calendars](#creating-custom-calendars) below.

---

## 🎨 Complete Example

```typescript
import { createLitcal } from '@saints-app/litcal';

// Create resolver — all calendars loaded automatically
const litcal = await createLitcal();

// Example 1: Santiago Apóstol (Spain patron)
const santiago = await litcal.resolveDay({
  date: '2026-07-25',
  calendarId: 'spain'
});

console.log(santiago.celebrations[0].id);                    // 'james_apostle'
console.log(santiago.celebrations[0].rank);                  // 'SOLEMNITY'
console.log(santiago.celebrations[0].isHolyDayOfObligation); // true

// Example 2: Nuestra Señora del Pilar (unique to Spain)
const pilar = await litcal.resolveDay({
  date: '2026-10-12',
  calendarId: 'spain'
});

console.log(pilar.celebrations[0].id);   // 'our_lady_of_the_pillar'
console.log(pilar.celebrations[0].rank); // 'FEAST'

// Example 3: Same day, different calendars
const spainDay = await litcal.resolveDay({ date: '2026-07-25', calendarId: 'spain' });
const italyDay = await litcal.resolveDay({ date: '2026-07-25', calendarId: 'italy' });

// Spain: SOLEMNITY (national patron)
// Italy: Regular celebration from general-roman
```

---

## 🛠️ Creating Custom Calendars

### Quick Start

Adding a new calendar is simple:

1. **Create** `src/data/calendars/your-calendar.json`
2. **Run** `npm run build`
3. **Done** — Your calendar is automatically available

Example `france.json`:
```json
{
  "id": "france",
  "parent": "europe",
  "rules": [
    {
      "id": "france-joan-of-arc-solemnity",
      "action": "REPLACE",
      "targetCelebrationId": "joan-of-arc-virgin",
      "celebration": {
        "id": "joan-of-arc-virgin",
        "rank": "SOLEMNITY",
        "precedence": "PROPER_SOLEMNITY_PRINCIPAL_PATRON_4A",
        "colors": ["WHITE"]
      },
      "dateRule": { "type": "fixed", "month": 5, "day": 30 }
    }
  ]
}
```

After build:
```typescript
const litcal = await createLitcal();
const result = await litcal.resolveDay({
  date: '2026-05-30',
  calendarId: 'france'  // Automatically available
});
```

The build script (`scripts/generate-calendar-loader.ts`) automatically:
- Scans `src/data/calendars/*.json`
- Generates `src/generated/calendar-loader.ts` with all calendars
- No code changes needed

### Calendar JSON Format

Create a JSON file with an array of `CalendarRule`:

```json
[
  {
    "id": "spain-santiago-apostle-solemnity",
    "calendarId": "spain",
    "action": "REPLACE",
    "targetCelebrationId": "james_apostle",
    "celebration": {
      "id": "james_apostle",
      "rank": "SOLEMNITY",
      "precedence": "PROPER_SOLEMNITY_PRINCIPAL_PATRON_4A",
      "colors": ["RED"],
      "isHolyDayOfObligation": true
    },
    "dateRule": {
      "type": "fixed",
      "month": 7,
      "day": 25
    }
  }
]
```

### Rule Action Types

**ADD** — Add a new celebration
```json
{
  "action": "ADD",
  "celebration": { "id": "our_lady_of_the_pillar", ... },
  "dateRule": { "type": "fixed", "month": 10, "day": 12 }
}
```

**REPLACE** — Upgrade an existing celebration
```json
{
  "action": "REPLACE",
  "targetCelebrationId": "james_apostle",
  "celebration": { "id": "james_apostle", "rank": "SOLEMNITY", ... }
}
```

**SUPPRESS** — Remove a celebration
```json
{
  "action": "SUPPRESS",
  "targetCelebrationId": "some-celebration-id"
}
```

**TRANSFER** — Move to another date
```json
{
  "action": "TRANSFER",
  "targetCelebrationId": "some-celebration-id",
  "transferToDate": "2026-08-01"
}
```

---

## 🧪 Development (for litcal contributors)

### Setup

```bash
git clone https://github.com/Saints-App/litcal.git
cd litcal
npm install
npm run build
npm run test
```

### Run Examples

```bash
npm run example         # Basic usage example
npm run resolve 2026-12-25   # CLI resolver tool
npm run demo            # Interactive web demo
```

### Project Structure

```
src/
├── domain/           # Core types, enums (zero dependencies)
├── sources/          # Data source adapters
│   ├── romcal-source.ts       # Romcal adapter (General Roman)
│   ├── static-source.ts       # JSON calendar rules
│   └── backend-source.ts      # API-based rules (optional)
├── resolver/         # Core resolution engine
│   ├── resolver.ts            # Main LiturgicalResolver class
│   ├── precedence.ts          # UNLY §59 precedence rules
│   └── date-resolver.ts       # DateRule DSL → ISO date
├── data/calendars/   # Calendar JSON files
│   └── spain.json
├── litcal.ts         # Simplified API (createLitcal)
└── index.ts          # Public API exports
```

### Adding a New Calendar

1. Create `src/data/calendars/mycalendar.json`
2. Define rules (see format above)
3. Build: `npm run build` (copies JSON to `dist/`)
4. Use in code:
   ```typescript
   import myRules from '@saints-app/litcal/calendars/mycalendar.json' assert { type: 'json' };
   const litcal = await createLitcal({ calendars: { mycalendar: myRules } });
   ```

---

## 📖 API Reference

### `createLitcal(config?)`

Simplified API — creates a fully configured resolver.

```typescript
const litcal = await createLitcal({
  calendars: {
    spain: spainRules,
    italy: italyRules
  },
  enableTrace: true  // Include resolution trace (default: true)
});
```

Returns a `LiturgicalResolver` instance.

### `resolver.resolveDay(params)`

Resolve a single liturgical day.

```typescript
const result = await litcal.resolveDay({
  date: '2026-07-25',              // ISO date string
  calendarIds: ['general-roman', 'spain']  // Calendar layers to apply
});
```

Returns `ResolvedLiturgicalDay`:
```typescript
{
  date: string;
  primary: CelebrationCandidate | undefined;
  secondaries: CelebrationCandidate[];
  season: Season;
  seasonWeek: number;
  dayOfSeason: number;
  cycles: { sundayCycle, weekdayCycle, psalterWeek };
  trace: ResolutionTrace;
}
```

### Advanced: Manual Resolver Configuration

```typescript
import { LiturgicalResolver, RomcalSource, StaticCalendarSource } from '@saints-app/litcal';

// Manual configuration (if you need more control)
const resolver = new LiturgicalResolver([
  new RomcalSource({ ... }),
  new StaticCalendarSource({ ... })
]);
```

---

## ✅ Testing

```bash
npm run test           # Run all tests
npm run test:watch     # Watch mode
npm run typecheck      # Type checking only
```

43 tests covering:
- Date resolution (Easter, fixed dates, relative dates)
- Precedence sorting (UNLY §59 table)
- Multi-source resolution
- Rule actions (ADD, REPLACE, SUPPRESS, TRANSFER)

---

## 📝 Commits & Versioning

Use **conventional commits** — automatic versioning on merge to `main`:
- `feat: add Italy calendar` → minor bump
- `fix: correct Easter calculation` → patch bump
- `BREAKING CHANGE:` in commit body → major bump

---

## 🤝 Contributing

See [AGENTS.md](AGENTS.md) for architecture and conventions.

---

## 📄 License

MIT
  new BackendRulesSource({ fetchRules: async () => [...] })
]);

// Resolve a single day
const day = await resolver.resolveDay({
  date: '2026-03-02',
  calendarIds: ['general-roman', 'spain']
});

// Resolve a full year
const year = await resolver.resolveYear({
  year: 2026,
  calendarIds: ['general-roman']
});
```

**`BackendRulesSource`** — Fetch rules from an API

```typescript
import { BackendRulesSource, type CalendarRule } from '@saints-app/litcal';

const source = new BackendRulesSource({
  fetchRules: async (calendarIds) => {
    const response = await fetch(`/api/rules?ids=${calendarIds.join(',')}`);
    return response.json() as CalendarRule[];
  }
});
```

**`RomcalSource`** — Use Romcal as a data source (optional)

```typescript
import { RomcalSource } from '@saints-app/litcal';
import Spain from '@romcal/calendar.spain';

const source = new RomcalSource({
  'spain': { localizedCalendar: Spain }
});
```

### Date Rule DSL

All date rules are plain JSON objects (no functions):

```typescript
// Fixed date
{ type: 'fixed', month: 12, day: 25 }

// Easter-relative (e.g., Ash Wednesday = -46 days)
{ type: 'easter-relative', offset: -46 }

// Nth weekday of month (e.g., 4th Thursday of November)
{ type: 'nth-weekday-of-month', month: 11, weekday: 4, nth: 4 }

// Last weekday of month (e.g., last Sunday of November)
{ type: 'last-weekday-of-month', month: 11, weekday: 0 }

// Relative to another celebration
{ type: 'relative-to-celebration', celebrationId: 'christmas', offset: 8 }

// Conditional (with fallback)
{
  type: 'conditional',
  baseRule: { type: 'fixed', month: 3, day: 19 },
  condition: { type: 'is-weekday', weekday: 0 }, // Sunday
  fallback: { type: 'fixed', month: 3, day: 20 } // Move to Monday if Sunday
}
```

---

## 🐛 Debugging

### Enable verbose trace

The resolver produces a trace of all decisions:

```typescript
const result = await resolver.resolveDay({ ... });

console.log(result.trace.steps);
// [
//   { action: 'suppress', candidateId: 'saint-x', reason: '...' },
//   { action: 'deduplicate', candidateId: 'saint-y', reason: '...' },
//   { action: 'select-primary', candidateId: 'saint-z', reason: '...' }
// ]
```

### Test a specific date rule

```typescript
import { resolveDateRule, computeEasterDate } from '@saints-app/litcal';

const year = 2026;
const easterDate = computeEasterDate(year);
const context = { easterDate, resolvedCelebrations: new Map() };

const rule = { type: 'easter-relative', offset: -46 };
const date = resolveDateRule(rule, year, context);

console.log(date); // '2026-02-18' (Ash Wednesday 2026)
```

### Watch test output

```bash
npm run test:watch
```

Edit a test file and save — tests re-run automatically.

---

## 📖 More Documentation

- **[SETUP.md](SETUP.md)** — Publishing setup & next steps
- **[.github/copilot-instructions.md](.github/copilot-instructions.md)** — AI agent instructions & conventions
- **Architecture details**: See comments in `src/resolver/resolver.ts`

---

## 🤝 Contributing

1. Fork the repo
2. Create a feature branch: `git checkout -b feat/my-feature`
3. Make changes
4. Run tests: `npm run test`
5. Commit with conventional commits: `feat: add new date rule type`
6. Push and open a PR

The CI will run on your PR. If tests pass, it can be merged to `main`.

---

## 📦 Publishing

Publishing is automated via GitHub Actions. When a PR is merged to `main`:

1. Version is auto-bumped based on commit messages (`fix:` → patch, `feat:` → minor)
2. Built and tested
3. Published to npm as `@saints-app/litcal`
4. Git tag created: `v0.x.x`

See [SETUP.md](SETUP.md) for initial npm token setup.

---

## 📄 License

MIT