A Jalali (Shamsi / Persian) calendar for Java, built as a mirror of java.time.
If you know LocalDate, you already know LocalDateJalali — the same factory methods, the same
plus/minus/with family, the same Temporal interfaces, the same formatter model. No
java.util.Calendar, no third-party date type leaking into your domain, no new mental model.
LocalDateJalali today = LocalDateJalali.now(); // 1404-06-17
LocalDate gregorian = today.toLocalDate(); // 2025-09-08Only runtime dependency is slf4j-api. Nothing else from Bard Framework is required — this library
is usable entirely on its own.
Most Jalali libraries for Java give you a converter and a formatter: a class with getYear(),
toGregorian(), maybe a format(pattern). That is enough for printing a date, and not enough for
anything else. The moment your domain needs to compute with dates — add three months to a
subscription, take the last day of the billing period, sort a list, measure a duration, feed a value
into an API that expects a Temporal — you are back to writing conversion code by hand, and you have
introduced a second date type your codebase must be careful about.
This library takes the other route: it implements the JSR-310 contracts. LocalDateJalali is a
Temporal, a TemporalAdjuster, a ChronoLocalDate and Comparable, and ChronologyJalali is a
real Chronology. Everything built on the java.time SPI therefore works, unchanged:
ChronoUnit.MONTHS.between(from, to);
date.with(TemporalAdjusters.lastDayOfMonth());
dates.stream().sorted().toList();
Duration.between(startJalali.atStartOfDay(), endJalali.atStartOfDay());The API surface is deliberately a mirror, not an invention. If you have to look up how to do
something, the answer is "the same way you would with LocalDate" — the factory names, the
plus/minus/with family, the formatter model and even the ISO constants are the same. Nothing
new to learn, nothing extra to remember at a call site.
<dependency>
<groupId>org.bardframework</groupId>
<artifactId>jalali-date</artifactId>
<version>5.6.7</version>
</dependency>Java 21 or newer.
LocalDateJalali today = LocalDateJalali.now(); // 1404-06-17
LocalDate iso = today.toLocalDate(); // 2025-09-08That is the whole idea: a Jalali date is a first-class java.time value. Everything below follows
from that.
LocalDateJalali.now(); // from the system clock
LocalDateJalali.now(ZoneId.of("Asia/Tehran")); // in a specific zone
LocalDateJalali.of(1404, 1, 1); // 1404-01-01, Nowruz
LocalDateJalali.of(1370, MonthJalali.MEHR, 12); // 1370-07-12
LocalDateJalali.ofYearDay(1404, 172); // 1404-06-17
LocalDateJalali.ofEpochDay(20339); // 1404-06-17
LocalDateJalali.of("1404-06-17"); // lenient: keeps the digits, ignores separators
LocalDateJalali.parse("1404-06-17"); // strict ISO layout
LocalDateJalali.parse("1404/06/17", DateTimeFormatterJalali.ofPattern("yyyy/MM/dd"));
LocalDateJalali.of(LocalDate.of(2025, 3, 21)); // from a Gregorian date → 1404-01-01LocalDateJalali d = LocalDateJalali.of(1404, 6, 17);
d.getYear(); // 1404
d.getMonthValue(); // 6
d.getMonth(); // SHAHRIVAR
d.getDayOfMonth(); // 17
d.getDayOfYear(); // 172
d.getDayOfWeek(); // MONDAY (java.time.DayOfWeek)
d.isLeapYear(); // false
d.lengthOfMonth(); // 31
d.lengthOfYear(); // 365
d.get(ChronoField.YEAR);d.plusDays(21); // 1404-07-08
d.plusMonths(1); // 1404-07-17
d.plusYears(1); // 1405-06-17
d.minusWeeks(2); // 1404-06-03
d.plus(Period.ofMonths(3)); // 1404-09-17
d.withYear(1405);
d.withMonth(12);
d.withDayOfMonth(1);Because it is a real Temporal, the standard adjusters work — and they respect the Jalali month
lengths:
d.with(TemporalAdjusters.firstDayOfMonth()); // 1404-06-01
d.with(TemporalAdjusters.lastDayOfMonth()); // 1404-06-31 ← Shahrivar has 31 days
d.with(TemporalAdjusters.firstDayOfNextMonth()); // 1404-07-01
d.with(TemporalAdjusters.lastDayOfYear()); // 1404-12-29
d.with(TemporalAdjusters.next(DayOfWeek.FRIDAY)); // 1404-06-21d.isBefore(LocalDateJalali.of(1405, 1, 1)); // true
d.isAfter(LocalDateJalali.of(1403, 1, 1)); // true
d.isEqual(LocalDateJalali.of(1404, 6, 17)); // true
d.compareTo(other); // Comparable, so sorting just works
ChronoUnit.DAYS.between(birthday, today); // 12758
d.until(LocalDateJalali.of(1405, 8, 20)); // P1Y2M3D
d.until(LocalDateJalali.of(1405, 8, 20), ChronoUnit.MONTHS); // 14d.atStartOfDay(); // 1404-06-17T00:00
LocalDateTimeJalali.of(d, LocalTime.of(9, 30)); // 1404-06-17T09:30
ZonedDateTimeJalali.of(dateTime, ZoneId.of("Asia/Tehran"));
// 1404-06-17T09:30+03:30[Asia/Tehran]
ZonedDateTimeJalali.now(ZoneId.of("Asia/Tehran"));
OffsetDateTimeJalali.now();
LocalDateTimeJalali.of(LocalDateTime.now()).toLocalDateTime(); // round-trips exactlyYearJalali.of(1403).isLeap(); // true
YearMonthJalali.of(1404, 12).lengthOfMonth(); // 29
YearMonthJalali.of(1404, 12).atEndOfMonth(); // 1404-12-29
MonthJalali.SHAHRIVAR.length(false); // 31
MonthJalali.ESFAND.plus(1); // FARVARDIN — wraps around the year
MonthJalali.SHAHRIVAR.getDisplayName(TextStyle.FULL, faLocale); // شهریور| This library | java.time counterpart |
|---|---|
LocalDateJalali |
LocalDate |
LocalDateTimeJalali |
LocalDateTime |
ZonedDateTimeJalali |
ZonedDateTime |
OffsetDateTimeJalali |
OffsetDateTime |
YearJalali, YearMonthJalali, MonthDayJalali |
Year, YearMonth, MonthDay |
MonthJalali |
Month — FARVARDIN … ESFAND |
ChronologyJalali |
Chronology — the leap-year rule and the epoch |
DateTimeFormatterJalali, DateTimeFormatterBuilderJalali |
DateTimeFormatter, DateTimeFormatterBuilder |
IsoFieldsJalali, TemporalQueriesJalali |
IsoFields, TemporalQueries |
time.zone.*, TextStyle, DecimalStyle, SignStyle |
the matching java.time support types |
LocalDateJalali implements Temporal, TemporalAdjuster, ChronoLocalDate and Comparable, so
ChronoUnit, TemporalAdjusters and anything else built on the java.time SPI work unchanged. All
types are immutable and thread-safe.
Month lengths follow the Jalali rule, and lengthOfMonth(), the validity checks and
TemporalAdjusters all agree on it:
| Months | Length |
|---|---|
| Farvardin – Shahrivar (1–6) | 31 |
| Mehr – Bahman (7–11) | 30 |
| Esfand (12) | 29, or 30 in a leap year |
LocalDateJalali.of(1404, 6, 17).with(TemporalAdjusters.lastDayOfMonth()); // 1404-06-31
LocalDateJalali.of(1404, 12, 1).with(TemporalAdjusters.lastDayOfMonth()); // 1404-12-29
LocalDateJalali.of(1403, 12, 1).with(TemporalAdjusters.lastDayOfMonth()); // 1403-12-30DateTimeFormatterJalali is a separate type, not a subclass of java.time.format.DateTimeFormatter.
So you format by calling the formatter, not the date:
DateTimeFormatterJalali.ofPattern("yyyy/MM/dd").format(date); // ✔
// date.format(jalaliFormatter) // ✘ won't compileParsing works from either direction — parse accepts both formatter types:
LocalDateJalali.parse("1404/06/17", DateTimeFormatterJalali.ofPattern("yyyy/MM/dd"));Text is Jalali, not Gregorian. parse("1404-06-17") reads the year 1404 of the Jalali calendar,
so formatting and parsing round-trip:
String text = DateTimeFormatterJalali.ISO_LOCAL_DATE.format(LocalDateJalali.of(1404, 6, 17));
LocalDateJalali.parse(text).equals(LocalDateJalali.of(1404, 6, 17)); // trueChanged in 6.1 — month lengths.
LocalDateJalali.lengthOfMonth()previously returned the Gregorian month lengths (31, 28, 31, 30, …). Anything built on it was wrong: the last day of Shahrivar came back as the 30th instead of the 31st, andTemporalAdjusters.lastDayOfMonth()on Esfand threwDateTimeException. Date construction andlengthOfYear()were always correct, so the bug only surfaced throughlengthOfMonthand the adjusters built on it.
Changed in 6.1 — month display names.
MonthJalali.getDisplayName(...)built its text withjava.time's formatter builder, which resolves month number 6 to the Gregorian June — soSHAHRIVAR.getDisplayName(FULL, fa)returned «ژوئن». It now uses the Jalali text provider and returns «شهریور», matching what theMMMMpattern has always produced.
Changed in 6.1 — parsing. Before that,
LocalDateJalali.parse,ZonedDateTimeJalali.parseandOffsetDateTimeJalali.parse(the single-argument overloads) parsed their input with the Gregorian ISO formatter, soparse("1404-06-17")returned0783-03-27and a format→parse round trip silently changed the date.LocalDateTimeJalaliwas always correct. All four now read Jalali text. If you were relying on the old behaviour, parse intoLocalDateyourself and convert withLocalDateJalali.of(LocalDate).
DateTimeFormatterJalali mirrors DateTimeFormatter — ofPattern, withLocale, withChronology,
and the ISO constants (ISO_LOCAL_DATE, ISO_LOCAL_DATE_TIME, ISO_OFFSET_DATE_TIME,
ISO_ZONED_DATE_TIME, RFC_1123_DATE_TIME, …). DateTimeFormatterBuilderJalali is there when a
pattern is not enough.
LocalDateJalali d = LocalDateJalali.of(1404, 6, 17);
Locale fa = Locale.forLanguageTag("fa");
DateTimeFormatterJalali.ofPattern("yyyy/MM/dd").format(d); // 1404/06/17
DateTimeFormatterJalali.ISO_LOCAL_DATE.format(d); // 1404-06-17
DateTimeFormatterJalali.ofPattern("d MMM yyyy").withLocale(fa).format(d); // 17 شهریور 1404
DateTimeFormatterJalali.ofPattern("EEEE d MMMM yyyy").withLocale(fa).format(d);
// دوشنبه 17 شهریور 1404
DateTimeFormatterJalali.ofPattern("yyyy/MM/dd HH:mm")
.format(LocalDateTimeJalali.of(d, LocalTime.of(9, 30))); // 1404/06/17 09:30
new DateTimeFormatterBuilderJalali()
.appendValue(ChronoField.YEAR, 4).appendLiteral('/')
.appendValue(ChronoField.MONTH_OF_YEAR, 2)
.toFormatter().format(d); // 1404/06Names follow the locale — Persian for fa, transliterated Latin elsewhere:
| pattern | fa |
en |
|---|---|---|
MMMM |
شهریور | Shahrivar |
EEEE |
دوشنبه | DoShanbe |
Digits are always ASCII. For Persian-Indic digits (۱۴۰۴), convert the formatted string yourself or
supply a DecimalStyle.
The calendars are anchored at 0001-01-01 Jalali = 0622-03-22 Gregorian, and every conversion is
exact and reversible:
LocalDateJalali.of(LocalDate.now()); // Gregorian → Jalali
date.toLocalDate(); // Jalali → Gregorian
LocalDateJalali.ofEpochDay(epochDay);
date.toEpochDay();LocalDateTimeJalali.of(LocalDateTime) and .toLocalDateTime() do the same while preserving the
time component.
You rarely call this library directly in a Bard app — it is wired in for you:
bard-form-parentdate fields and table headers format through it, so a Persian user sees Jalali dates while the database keeps ISO values.bard-commonscommon-utils-persianuses it for Persian date helpers.- On the client,
@bard/angularships a matchingBardDateAdapterJalalifor the Material date picker.
- Storage. Keep ISO/Gregorian in the database and convert at the edges.
toLocalDate()andof(LocalDate)are exact, so nothing is lost — and your data stays queryable by every other tool. - JSON. Serialise as ISO or epoch and convert in the DTO, or register a converter. A Jalali string on the wire is a choice you can make for a UI-facing API, but not one to make by accident.
- Sorting and indexing work directly:
LocalDateJalaliisComparableand its natural order is chronological. java.util.Date/Calendarare not supported and deliberately so — go throughjava.time(Date.toInstant()→LocalDate→LocalDateJalali).
mvn clean verifyThe suite covers the calendar rules (month lengths, leap years, adjusters), text parsing and formatting, and the month enum — plus a batch test that generates 10,000 random Jalali dates between years 1200 and 1500, converts each to Gregorian and back, and asserts the round trip. That last one is the cheapest way to catch an off-by-one in the leap-year rule.
Pull requests are welcome — see CONTRIBUTING.md. Add JUnit tests for your change
and run mvn clean test before submitting.
Apache License 2.0.