Zero-infrastructure, open calendar data for West Africa.
DaysUnit provides per-day working day, public holiday, and calendar metadata for UEMOA member states as static JSON files served over a global CDN — no API keys, no rate limits, no backend costs.
Primary consumers: SaaS applications in the UEMOA zone — payroll, accounting (SYSCOHADA), HR, leave management, scheduling.
Runtime support: Browser, Node ≥ 18, Deno, Bun — zero bundled calendar data, fetched from CDN at runtime.
DaysUnit is a community calendar reference and is not affiliated with any government. For high-stakes use, double-check critical dates against official sources. See DISCLAIMER.md for the full notes.
- DaysUnit
- Table of Contents
- 1. What is DaysUnit?
- 2. Countries
- 3. Quick Start
- 4. CDN Endpoints
- 5. JSON Structure
- 6. JavaScript Library —
@claviscore/days - 7. CLI — Offline Cache
- 8. Data Quality & Confidence
- 9. Versioning
- 10. Repository Structure
- 11. Development Setup
- 12. Contributing
- 13. Maintainer Guide
- 14. Security
- 15. License
- Annex A — Field provenance
- Authorship & Support
DaysUnit is a zero-infrastructure, open calendar data service for West African countries, with an accompanying runtime-agnostic JavaScript client library.
Core design principles:
- Static data only — no server runtime for reads
- ISO 8601 date representation throughout
- Human-reviewable, AI-assisted update mechanism
- Community-maintained via PR model
- Zero bundling of calendar data into the JS package
- Runtime agnostic: browser, Node ≥ 18, Deno, Bun
V1 launch — Bénin and Togo. Calendar data for the remaining UEMOA member states is on the immediate roadmap and will be added under the same schema with no breaking changes.
⚠️ Early-access notice: Bénin (BJ) and Togo (TG) are the countries currently available. The datasets are still being reviewed and may contain inaccurate dates. Do not use them in production applications yet. We are actively fixing known issues — watch this repository for the first stable release announcement.
| Code | Country | Official Language | countryNames locales |
V1 status |
|---|---|---|---|---|
BJ |
Bénin | French | fr, en |
✅ available |
BF |
Burkina Faso | French | fr, en |
🟡 roadmap |
CI |
Côte d'Ivoire | French | fr, en |
🟡 roadmap |
GW |
Guinée-Bissau | Portuguese | fr, en, pt |
🟡 roadmap |
ML |
Mali | French | fr, en |
🟡 roadmap |
NE |
Niger | French | fr, en |
🟡 roadmap |
SN |
Sénégal | French | fr, en |
🟡 roadmap |
TG |
Togo | French | fr, en |
✅ available |
The manifest.json always reflects the actually-deployed coverage, so clients should consult it at runtime rather than hard-coding country lists. As each country is added, it will appear in manifest.json#/countries and become fetchable at /{country}/{year}/... without any client-library change.
Future scope: non-UEMOA ECOWAS states (GH, NG, LR, SL, GM, GN, CV, MR), then CEMAC.
# Is 2026-01-01 a working day in Bénin?
curl https://days.claviscore.com/bj/2026/01/01.json | jq .isWorkingDay
# Get all January 2026 data for Sénégal
curl https://days.claviscore.com/sn/2026/01.jsonnpm install @claviscore/daysimport days from '@claviscore/days'
const d = await days('BJ').on('2026-01-15')
console.log(d.isWorking()) // true
console.log(d.isHoliday()) // false
const m = await days('BJ').month(2026, 1)
console.log(m.workingDays()) // 21Base URL: https://days.claviscore.com
All paths use lowercase country codes and zero-padded two-digit month/day values.
| Granularity | Pattern | Example |
|---|---|---|
| Single day | /{country}/{year}/{month}/{day}.json |
/bj/2026/01/01.json |
| Month aggregate | /{country}/{year}/{month}.json |
/bj/2026/01.json |
| Year summary | /{country}/{year}.json |
/bj/2026.json |
| Manifest | /manifest.json |
/manifest.json |
| JSON Schemas | /schema/{type}.schema.json |
/schema/day.schema.json |
Rules:
- Country codes are lowercase ISO 3166-1 alpha-2 (
bj,ci,sn, …) - Month and day values are always zero-padded two-digit integers (
01–12,01–31) - Year values are four-digit integers
Recommendation: Fetch month aggregates for most use cases. A single month file contains all flag fields for every day in the month, minimising HTTP requests.
All responses include Access-Control-Allow-Origin: * — safe to call directly from browsers.
GET /bj/2026/01/01.json
{
"schemaVersion": "1.0",
"date": "2026-01-01",
"country": "BJ",
"countryName": "Bénin",
"countryNames": { "fr": "Bénin", "en": "Benin" },
"timezone": "Africa/Porto-Novo",
"dayOfWeek": 4,
"isWeekend": false,
"isPublicHoliday": true,
"isWorkingDay": false,
"isFirstWorkingDayOfMonth": false,
"isLastWorkingDayOfMonth": false,
"isRamadanPeriod": false,
"holidayName": { "fr": "Jour de l'An", "en": "New Year's Day" },
"holidayType": "national",
"religiousAffiliation": null,
"observedDate": null,
"legalBasis": "Loi n° 98-004 du 27 janvier 1998",
"source": "https://jo.gouv.bj/...",
"verifiedAt": "2025-11-01",
"confidence": "confirmed",
"weekNumber": 1,
"quarter": 1,
"workingDayOfMonth": null,
"workingDayOfYear": null
}Field reference:
| Field | Type | Description |
|---|---|---|
schemaVersion |
string |
MAJOR.MINOR — schema version in use |
date |
string (ISO 8601) |
The calendar date |
country |
string |
ISO 3166-1 alpha-2 uppercase |
countryName |
string |
Primary name in French |
countryNames |
object |
Localized names — fr and en always present; pt present for GW |
timezone |
string |
IANA timezone identifier |
dayOfWeek |
integer |
ISO: Monday=1 … Sunday=7 |
isWeekend |
boolean |
Saturday or Sunday |
isPublicHoliday |
boolean |
Legal public holiday |
isWorkingDay |
boolean |
Not weekend and not public holiday |
isFirstWorkingDayOfMonth |
boolean |
First working day of the calendar month |
isLastWorkingDayOfMonth |
boolean |
Last working day of the calendar month — key for payroll / SYSCOHADA |
isRamadanPeriod |
boolean |
Within the Ramadan month (relevant for ML, NE, SN, BF) |
holidayName |
object|null |
i18n map of the holiday name; null on non-holidays |
holidayType |
enum|null |
national | religious | observance | bridge | school |
religiousAffiliation |
enum|null |
christian | islamic | secular | animist |
observedDate |
string|null |
Observed rest date when holiday falls on a weekend |
legalBasis |
string|null |
Journal Officiel decree or law reference |
source |
string|null |
URL of the authoritative source document |
verifiedAt |
string (ISO 8601 date) |
Date data was last reviewed |
confidence |
enum |
confirmed | tentative | ai-generated |
weekNumber |
integer |
ISO week number (1–53) |
quarter |
integer |
Calendar quarter (1–4) |
workingDayOfMonth |
integer|null |
Ordinal among working days in the month |
workingDayOfYear |
integer|null |
Ordinal among working days in the year |
GET /bj/2026/01.json
{
"schemaVersion": "1.0",
"country": "BJ",
"countryName": "Bénin",
"countryNames": { "fr": "Bénin", "en": "Benin" },
"timezone": "Africa/Porto-Novo",
"year": 2026,
"month": 1,
"workingDaysCount": 21,
"weekendDaysCount": 8,
"publicHolidaysCount": 1,
"days": [
{
"date": "2026-01-01",
"dayOfWeek": 4,
"isWeekend": false,
"isPublicHoliday": true,
"isWorkingDay": false,
"isFirstWorkingDayOfMonth": false,
"isLastWorkingDayOfMonth": false,
"isRamadanPeriod": false,
"confidence": "confirmed"
}
]
}The days array contains exactly one entry per calendar day in the month, in ascending order.
GET /bj/2026.json
Same structure as the month aggregate but covers the full year (days has 365 or 366 entries). Verbose text fields (holidayName, legalBasis, source, etc.) are omitted to minimise file size — use individual day files when you need them.
GET /manifest.json
{
"schemaVersion": "1.0",
"lastUpdated": "2026-03-15",
"countries": ["BJ", "BF", "CI", "GW", "ML", "NE", "SN", "TG"],
"yearsAvailable": [2024, 2025, 2026, 2027],
"baseUrl": "https://days.claviscore.com",
"endpoints": {
"day": "/{country}/{year}/{month}/{day}.json",
"month": "/{country}/{year}/{month}.json",
"year": "/{country}/{year}.json",
"manifest": "/manifest.json"
},
"schemaUrls": {
"day": "/schema/day.schema.json",
"month": "/schema/month.schema.json",
"year": "/schema/year.schema.json",
"manifest": "/schema/manifest.schema.json"
}
}Runtime-agnostic TypeScript client. Works in browser, Node ≥ 18, Deno, and Bun. Ships zero calendar data — data is fetched from the CDN at runtime or from a local offline cache.
npm install @claviscore/days
# or
pnpm add @claviscore/days
# or
yarn add @claviscore/daysimport days from '@claviscore/days'
const d = await days('BJ').on('2026-01-01')
d.isWorking() // → boolean
d.isHoliday() // → boolean
d.isWeekend() // → boolean
d.dayOfWeek() // → 1–7 (ISO: Monday=1, Sunday=7)
d.isFirstWorkingDay() // → boolean
d.isLastWorkingDay() // → boolean
d.isRamadanPeriod() // → boolean
d.religiousAffiliation() // → 'christian' | 'islamic' | 'secular' | 'animist' | null
d.name() // → { fr: string; en: string; [locale: string]: string } | null
d.countryName() // → string (always French)
d.countryNames() // → { fr: string; en: string; pt?: string; [locale: string]: string }
d.timezone() // → string (IANA timezone identifier)
d.confidence() // → 'confirmed' | 'tentative' | 'ai-generated'
d.raw() // → DayRecord (full schema object)const m = await days('BJ').month(2026, 1)
m.workingDays() // → number
m.holidays() // → MonthDayEntry[]
m.each(fn) // → void — iterates all days
m.find('2026-01-15') // → MonthDayEntry | undefined
m.raw() // → MonthRecordconst r = await days('BJ').range('2026-01-01', '2026-03-31')
r.workingDays() // → number
r.holidays() // → MonthDayEntry[]
r.each(fn) // → void — iterates all days in rangeFetches the minimum set of month aggregates covering the range — not individual day files.
const batch = await days(['BJ', 'CI', 'SN']).on('2026-01-01')
// → Record<'BJ' | 'CI' | 'SN', DayResult>
batch.BJ.isWorking() // → boolean
batch.CI.isHoliday() // → booleanFetches are issued in parallel (Promise.all).
await days('BJ').nextWorkingDay('2026-01-01')
// → '2026-01-02' (string, ISO 8601)
await days('BJ').prevWorkingDay('2026-01-01')
// → '2025-12-31'
await days('BJ').workingDaysInRange('2026-01-01', '2026-01-31')
// → 21 (number)Navigation helpers fetch month aggregates, not individual day files.
import days, { configure } from '@claviscore/days'
configure({
baseUrl: 'https://days.claviscore.com', // override CDN base URL (e.g. for self-hosting)
cacheDir: './.days', // local cache path (Node/Deno/Bun only)
fallbackToCdn: true, // fall back to CDN on local cache miss
timeoutMs: 10_000 // per-request timeout (0 disables)
})Call configure() once at application startup before any days() calls.
The resolver always works at month-aggregate granularity. Single-day lookups fetch the parent month aggregate and extract the target day, minimising HTTP requests and enabling efficient HTTP caching.
Server runtimes (Node ≥ 18, Deno, Bun):
1. Local cache → {cacheDir}/{country}/{year}/{month}.json
2. CDN fetch → {baseUrl}/{country}/{year}/{month}.json
Browser:
1. CDN fetch only (no filesystem access)
All I/O errors throw a DaysError instance with a code string property:
import { DaysError } from '@claviscore/days'
try {
const d = await days('BJ').on('2026-01-01')
} catch (err) {
if (err instanceof DaysError) {
console.error(err.code, err.message)
}
}The @claviscore/days package ships a days binary for pre-downloading data into a local cache. This enables zero-latency lookups in server-side applications and works fully offline.
# Download BJ calendar for 2026
npx @claviscore/days pull --country BJ --year 2026
# Multiple countries and years (comma-separated)
npx @claviscore/days pull --country BJ,CI,SN --year 2026,2027
# All UEMOA countries (convenience alias)
npx @claviscore/days pull --uemoa --year 2026
# Override output path (default: .days/ at cwd)
npx @claviscore/days pull --country BJ --year 2026 --out ./src/static/days
# Show locally cached data
npx @claviscore/days cache list
# Clear entire cache
npx @claviscore/days cache clear
# Clear one country+year from cache
npx @claviscore/days cache clear --country BJ --year 2026Behaviour:
- For each
(country, year)pair, the CLI fetches all 12 month-aggregate files in parallel. - If a file already exists locally and the CDN reports no change, the download is skipped.
- The CLI exits with code
1on any fetch error and prints the failed URL.
Cache layout:
.days/
├── bj/
│ └── 2026/
│ ├── 01.json
│ └── ...
└── ci/
└── 2026/
└── ...
Add .days/ to your .gitignore.
Every record carries a confidence field:
| Value | Meaning |
|---|---|
confirmed |
Verified by a human maintainer against an official source document |
tentative |
Plausible but not fully verified (e.g. variable Islamic dates) |
ai-generated |
Produced by an AI agent; pending human review — do not rely on this in production |
The source field on every day record points to the authoritative legal document (Journal Officiel, government decree) when available.
Data is kept current through three mechanisms:
- Annual pre-generation — maintainers generate the following year's data each November from official sources (see Maintainer Guide).
- Exception monitoring (forthcoming) — a daily automated monitor will watch official gazettes and open AI-proposed PRs (labelled
ai-proposed) when changes are detected. All such PRs will require human approval before merge. Until this lands, exceptions are handled via mechanism (3) below. - Community corrections — anyone can open a PR to correct a data error (see CONTRIBUTING.md).
schemaVersion follows MAJOR.MINOR:
- MINOR — new optional field added; all existing files remain valid
- MAJOR — breaking change; old schema version kept at a new path for ≥ 12 months
@claviscore/days follows Semantic Versioning 2.0.0:
- PATCH — bug fixes
- MINOR — new API methods or config options (backward compatible)
- MAJOR — breaking API surface changes
| Type | Pattern | Example |
|---|---|---|
| Data release | data/v{year}-{sequence} |
data/v2026-01 |
| Package release | js/v{semver} |
js/v1.2.0 |
days/
├── packages/
│ ├── data/ ← Static JSON calendar files (CDN root)
│ │ ├── _headers ← CORS + Cache-Control rules
│ │ ├── manifest.json
│ │ ├── schema/ ← JSON Schema Draft 2020-12 definitions
│ │ └── {country}/{year}/{month}/{day}.json
│ └── js/ ← @claviscore/days npm package
│ └── src/
│ ├── index.ts ← public API entry
│ ├── builder.ts ← fluent chain root
│ ├── resolver.ts← local cache → CDN fallback
│ ├── fetcher.ts ← runtime-agnostic fetch
│ ├── types.ts ← all exported types
│ ├── errors.ts ← DaysError class
│ └── cli.ts ← days binary
└── tools/
├── generate-aggregates.ts ← day files → month + year aggregates
└── seed-country.ts ← seeds day files from sources.json
npm install -g pnpmgit clone https://github.com/Dahkenangnon/days.git
cd days
pnpm install# Validate all data files against JSON Schema
pnpm validate
# Build the @claviscore/days npm package
pnpm build:js
# Type-check the JS library
pnpm --filter @claviscore/days run typecheck
# Regenerate month + year aggregates for a given year
pnpm generate -- --year 2026
# Regenerate for a specific country only
pnpm generate -- --year 2026 --country BJ
# Dry run (validate without writing)
pnpm generate -- --year 2026 --dry-runpnpm validateThis runs ajv-cli against all day, month, year, and manifest files under packages/data/.
We welcome data corrections, new country data, and library improvements. Please read CONTRIBUTING.md before opening a PR.
Quick links:
| Type | Branch pattern | Example |
|---|---|---|
| Data correction | fix/data-{country}-{date} |
fix/data-bj-2026-03-20 |
| New country data | data/add-{country}-{year} |
data/add-sn-2027 |
| Annual pre-gen | data/pre-gen-{year} |
data/pre-gen-2027 |
| Library feature | feat/days-{description} |
feat/days-batch-range |
| Library fix | fix/days-{description} |
fix/days-resolver-cache |
All PRs touching packages/data/ must pass the validate.yml CI schema check.
This section covers tasks performed by project maintainers.
Run each November for the following calendar year. Target: merge before December 1.
# 1. Create a branch
git checkout -b data/pre-gen-2027
# 2. Update tools/sources.json with the new year's holiday definitions
# 3. Seed day files for each country
tsx tools/seed-country.ts --country BJ --year 2027
# ... repeat for all 8 countries
# 4. Generate month + year aggregates
pnpm generate -- --year 2027
# 5. Validate
pnpm validate
# 6. Update packages/data/manifest.json to add 2027 to yearsAvailable
# 7. Open a PR with label pre-generation
# At least one other maintainer must review the data against sources.json
# 8. Tag after merge
git tag data/v2027-01
git push --tags# Bump version in packages/js/package.json first
git tag js/v1.2.0
git push --tags
# → triggers release-pkg.yml: GitHub Release + npm publishThe release-pkg.yml workflow requires the NPM_TOKEN repository secret to be set.
- Check the PR body for the raw source excerpt and the monitor's reasoning.
- Verify the affected dates against the linked source URL.
- Change
confidencefromai-generatedtoconfirmedortentativeon each modified record. - Approve and merge.
Never auto-merge AI-proposed PRs. Human approval is always required.
See SECURITY.md for the full security policy, including how to report data integrity issues that could affect payroll or SYSCOHADA journal closings.
DaysUnit uses a dual licence so that code and data stay legally distinct:
| Asset | License |
|---|---|
Source code (packages/js/, tools/) |
Apache-2.0 |
Calendar data (packages/data/) |
CC BY 4.0 |
If you redistribute the calendar data, please attribute it as required by CC BY 4.0 — see packages/data/LICENSE for the suggested attribution string.
See DISCLAIMER.md for the standard "as-is, no warranty" notice that applies to both.
Every day record carries 25 fields. They split into three classes by how they are obtained — useful both for users (to know which values they can trust without an external source) and for contributors (to know which fields require a citation when adding a new country/year).
What
confidenceactually qualifies. Theconfidencefield describes only the legally-determined fields in §A.3 (and, by extension, the §A.2 fields that readisPublicHoliday). The §A.1 pure-computation fields —dayOfWeek,isWeekend,weekNumber,quarter,date, country/timezone identifiers — are always trustworthy regardless ofconfidence, because they are derived from the calendar itself and need no legal source. A record withconfidence: "tentative"still has a fully reliabledayOfWeekandisWeekend; only the holiday-related claims should be treated as provisional.
Deterministic from the date itself or from the country's static config. The confidence field does not qualify these — they are always safe to consume, even when the record is marked tentative or ai-generated.
| Field | Derivation |
|---|---|
schemaVersion |
constant "1.0" |
date |
input |
country |
static config (e.g. "BJ") |
countryName |
static config |
countryNames |
static config |
timezone |
static config (IANA tz) |
dayOfWeek |
ISO 8601 weekday (Mon=1 … Sun=7) |
isWeekend |
dayOfWeek ∈ {6, 7} |
weekNumber |
ISO 8601 week number |
quarter |
ceil(month / 3) |
verifiedAt |
metadata — date the record was generated/verified |
Derived by formula at aggregate-generation time, but the formula reads isPublicHoliday. They inherit confidence indirectly from the underlying holiday list.
| Field | Formula |
|---|---|
isWorkingDay |
!isWeekend && !isPublicHoliday |
isFirstWorkingDayOfMonth |
first day in month where isWorkingDay === true |
isLastWorkingDayOfMonth |
last day in month where isWorkingDay === true |
workingDayOfMonth |
rolling count within month (null on non-working days) |
workingDayOfYear |
rolling count within year (null on non-working days) |
Cannot be derived from the date — they come from a national law, government decree, or astronomical/religious observation. These are the only fields that confidence qualifies: a confirmed record means legalBasis + source have been verified against an official text; tentative means the legal text exists but the specific date depends on an unpublished annual decree or lunar observation; ai-generated means the values are model-proposed and pending human review.
| Field | Why a source is required |
|---|---|
isPublicHoliday |
set by national law or annual decree |
holidayName |
name as it appears in the official text |
holidayType |
national | religious | observance | bridge | school per the decree |
religiousAffiliation |
christian | islamic | secular | animist |
observedDate |
substitution rule — country-specific; only some jurisdictions shift weekend holidays |
legalBasis |
citation of the law (e.g. Loi n° 90-019 du 27 juillet 1990) |
source |
URL to the official text, gazette entry, or government communiqué |
isRamadanPeriod |
Islamic lunar calendar — depends on observation/announcement, not pure date arithmetic |
confidence |
confirmed | tentative | ai-generated — reflects the strength of the underlying source |
- A.1 fields → always trustworthy.
confidencedoes not apply to them; consume them directly even when the record istentative. No citation needed intools/sources.json. - A.3 fields → the only fields whose trust level is gated by
confidence. Must come from a verifiable source. Usetentativefor predicted Islamic dates or annual decrees not yet promulgated; downgrade toai-generatedfor AI-assisted proposals pending review (see §13 Reviewing AI-proposed PRs). - A.2 fields → set automatically by
tools/generate-aggregates.ts; never edit by hand. Inherit confidence from the §A.3 holiday data they depend on.
Built and maintained by Justin Dah-kenangnon — dah.kenangnon@gmail.com.
Supported by ClavisCore LLC, which hosts the CDN at days.claviscore.com.
