Roozaneh
A Persian (Jalali/Shamsi) calendar app for iPhone and iPad: daily and monthly views, a home-screen widget, birthdays, notes, and countdown counters, showing Persian, Gregorian, and Hijri dates together in both Persian (RTL) and English (LTR).
- Role
- Independent developer — personal project
- Platform
- iPhone & iPad · iOS 17+ · WidgetKit extension
- Stack
- Swift 5 · SwiftUI · SwiftData · WidgetKit
- Scale
- 60 Swift files · ~6,321 LOC · solo, Feb–Jun 2026
One calendar, three date systems, two directions.
Roozaneh is a Persian (Jalali/Shamsi) calendar app for iPhone and iPad: daily and monthly views, a home-screen widget, birthdays, notes, and countdown counters, for Persian- and English-speaking users who need Persian, Gregorian, and Hijri dates together. The UI ships fully in both Persian (RTL) and English (LTR).
It's a personal project. I designed, built, and maintain it independently, outside of any client or employer engagement, and it doesn't appear on my résumé. I own it end to end: the calendar-data pipeline, the SwiftUI app, the WidgetKit extension, localization, and the release-prep work needed to get it store-ready.
The problem
Persian-speaking users don't get by with one calendar. Any given day carries a Persian (Jalali/Shamsi) date, a Gregorian date, and, for many users, a Hijri (Qamari) date, each correctly converted and each correctly labeled with its own occasions and holidays. Getting the Persian half right meant shipping a pre-computed lookup table rather than computing Persian↔Gregorian conversion and occasion data at runtime.
The Hijri half has no reliable offline authority. iOS's built-in Umm al-Qura calendar is arithmetic, not sighting-based, and can drift up to a day from the calendar actually observed in Iran. It's a hard enough problem that I built a fix and reversed it in the same working window (see Engineering decisions).
The app also has to work in two directions at once. Persian users read right-to-left, English users read left-to-right, and that affects more than mirrored strings. Scroll direction, weekday-column order, and calendar paging all flip too, switchable at runtime.
And the home-screen widget has to show "today" (date, occasion, illustration, background color) with the same output as the app, from a separate compiled target that can't import the app's Swift source, and it has to stay correct at every midnight rollover even if I haven't opened the app that day.
Constraints
- iOS 17.0 minimum, iPhone and iPad only. No Mac Catalyst, no watchOS or visionOS target, and no pre-iOS-17
@Observableor WidgetKit APIs to fall back on. - The widget can't see the app's code. RoozaneWidget is a separate compilation unit that cannot import the app's Swift sources. Every piece of shared logic it needs, from hex-color parsing to calendar-day lookup, has to be duplicated rather than shared through a framework.
- One bundled year, not a formula.
calendar_1405.jsonhardcodes exactly one Persian year: 365 day entries. There's no year-agnostic Persian↔Gregorian/occasion computation, so a new year means new bundled data and an app update rather than a config change. - No offline authority for Iran's observed Hijri calendar. Only the arithmetic Umm al-Qura approximation is available without a network call or manual monthly maintenance.
- Fully offline, on purpose, which cuts both ways. There's no server-side fix for bad calendar data. Every occasion or holiday correction has to ship as a client update.
- App Store submission requirements, for two targets. An encryption-exemption declaration and a
PrivacyInfo.xcprivacymanifest were required for both the app and the widget extension.
Two targets, one shared file, no shared code.
The app is a feature-folder MVVM-ish structure: per-feature Models/ViewModels/Views/Data, SwiftData-backed repositories, @Observable view models talking to shared singletons. The widget extension can't compile against any of that, so it re-implements a minimal decoder over its own bundled copy of the same calendar data.
calendar_1405.json is its primary source for every date, including future midnights. The App Group bridge is read only as a fallback, so the widget never depends on the app having run that day.Viewing today in the app starts at launch: PersianCalendarService decodes and indexes calendar_1405.json into an O(1) dictionary keyed by a packed year/month/day integer. HomeScreen then fetches birthdays from the SwiftData store, resolves today's illustration and gradient, and hands both to WidgetDataSync, which writes them into the App Group and calls WidgetCenter.reloadAllTimelines(). DailyViewModel and MonthlyViewModel cache each page of calendar data by offset so repeat scrolling doesn't recompute Persian and Hijri dates on every frame.
The widget's timeline provider builds entries for "now" plus the next seven midnights, and for each one resolves the day's illustration and gradient from its own bundled calendar_1405.json first. The App Group is read only as a fallback, specifically so a future midnight entry still resolves correctly even if the app hasn't run that day.
Including the one I reversed.
A bundled JSON file, not a runtime XLSX parser
The calendar used to ship as a 15MB spreadsheet (RZN Digital Calender 29April 2026.xlsx), unzipped and parsed at runtime with a 206-line XLSXCalendarParser.swift and the ZIPFoundation dependency. Both were deleted in favor of calendar_1405.json, decoded directly into PersianDayEntry. Ruled out: shipping unzip/parse logic and a multi-megabyte spreadsheet in the runtime path.
A hand-pinned Hijri table, built and reverted the same day
Because iOS's Umm al-Qura calendar is arithmetic and drifts from Iran's sighting-based Hijri calendar, I built CorrectedHijriCalendar around a hand-maintained table of observed month lengths. Roughly seven hours later, I reverted it and deleted the table wholesale. Its last entry was flagged "placeholder; confirm before issuing," which is another way of saying I didn't trust it enough to ship it. Ruled out: a manually-extended lunar table that goes stale every month without upkeep.
The widget bundles its own calendar data
Rather than have the widget depend solely on whatever the app last pushed to the App Group, it decodes its own copy of calendar_1405.json through WidgetCalendarStore. A code comment sums up why: the midnight timeline entry shouldn't depend on the app having run that day. Ruled out: a widget that shows stale or wrong data at midnight if I haven't opened the app recently.
Feature folders, no formal architecture pattern
Every feature (BirthDays, Daily, DateCounter, Monthly, Note, Notification, OccasionsList) gets its own Models/ViewModels/Views/Data folder and, where it persists data, a Repository wrapping SwiftData's FetchDescriptor. No VIPER or Clean Architecture layering, no DI container: .shared singletons are used directly. For a solo project at this size, that's a deliberate trade of ceremony for speed.
Per-offset page caching and an O(1) calendar lookup
DailyViewModel and MonthlyViewModel cache computed page data in a dictionary keyed by day/month offset, invalidated on reload or language change. PersianCalendarService switched from a linear scan to a dictionary indexed by a packed (year*100+month)*100+day key, so scroll-driven recomputation stays cheap.
What offline-first costs.
Every architecture is a purchase. These are the prices that came with building fully offline, solo, for one calendar year at a time.
Offline-first, zero backend
Every lookup is a bundled-JSON dictionary hit: no server, no network dependency, nothing to keep running.
calendar_1405.json covers exactly one Persian year, 365 entries. There's no year-agnostic computation, so every new year needs a data update and an App Store release, not a config push.
A widget that's always correct at midnight
The home-screen widget renders the right day even if I haven't opened the app in days. It never depends on a recent app launch.
The calendar reference data ships twice in the binary, once per target. Any correction has to be re-synced into both bundles, and the widget duplicates a Color(hex:) helper and a day-lookup type instead of sharing a framework with the app.
iOS's built-in Umm al-Qura calendar for Hijri dates
Simple and reliably offline: no hand-maintained table to keep current, no monthly upkeep.
Up to a day of drift from Iran's actual observed, sighting-based Qamari calendar, documented as a known, unresolved limitation in CorrectedHijriCalendar's own doc comment rather than fixed.
Backwards in two files at once.
Visual parity without shared code
Because the widget target can't import the app's Swift files, cow-illustration and background-gradient parity had to be re-derived independently in two places (WidgetDataSync in the app, WidgetCalendarStore in the widget) plus a UserDefaults fallback channel. It took at least three separate commits to get right, rather than being solved once.
RTL and LTR, inverted, in two files, simultaneously
The commit that fixed scroll direction by language shows .environment(\.layoutDirection, ...) was originally set backwards (Persian mapped to left-to-right, English to right-to-left) in both DailyScreen.swift and MonthlyScreen.swift at the same time. Until the fix landed, the paged calendar scrolled the wrong physical direction for Persian users.
No reliable offline source for the observed Hijri calendar
Documented directly in the code: islamicUmmAlQura is arithmetic and can be off by a day from the sighting-based calendar Iran actually uses. I built a hand-maintained observed-month-length table and abandoned it the same day. Its last entry was flagged as unverified before I pulled it out entirely.
Paging ranges retrofitted to the data's actual bounds
Two separate commits show the initial pager was wider than calendar_1405.json actually supported: one bounding daily pages to the current Persian year, another clamping monthly paging the same way. DailyViewModel's year-offset fallback still defaults to a ±180-day range if the Persian year math fails, a reminder of where the constraint came from.
The numbers that exist, and the ones that don't.
Not much to defend, and that's fine.
Roozaneh has no backend, no network calls, no authentication, and no user accounts. It's entirely local and offline, which keeps the attack surface small by construction. Birthdays, notes, and counters are stored locally through SwiftData with iOS's default file protection; nothing in the app ever touches the network, so there's no transport layer to harden.
PrivacyInfo.xcprivacy, present for both the app and the widget target, declares no tracking, no collected data types, and exactly one required-reason API: UserDefaults, for the App Group. The App Group itself (group.rouzaneh.calendar.widget) only shares data between the app and its own widget extension, both signed under the same team. It's a standard, low-risk local IPC surface with no external exposure.
Bursts, with a two-month gap in the middle.
Scaffolded from Xcode's default SwiftData template
Initial commit: ContentView.swift and Item.swift boilerplate still visible in the diff.
First real feature work, then a two-month gap
Two dense commits lay down the first actual functionality. Then the repo goes quiet until May.
The feature-folder structure and widget target arrive at once
A single 2,428-file, +28,051-line commit introduces the Features/ directory structure, the RoozaneWidget extension target, and the bulk of the cow-illustration asset catalog.
Daily and Monthly get caching, the widget gets its first sync
Four commits in one day add paged, cached day and month views and wire up the first app-to-widget data sync.
Calendar data moves off the spreadsheet, app renamed Roozaneh
calendar_1405.json replaces the XLSX parser, the app is renamed "Roozaneh," the bundle ID is finalized to com.rouzaneh.calendar, and privacy manifests and App Store submission metadata are added.
The Hijri table, built and undone, plus the RTL fix
CorrectedHijriCalendar's hand-pinned month-length table is built, then reverted to plain Umm al-Qura within the same working window. The backwards RTL/LTR scroll-direction bug is also caught and fixed here.
Occasion notifications written, then shipped disabled
Notification toggles are synced with system permissions, and a fully-written occasion-reminder scheduler is commented out rather than shipped.
Small polish, then the repo goes quiet
"Adjust plist, numbering, and calendar gradients" is the last commit in the repository. Release-preparation work reads as complete, but I haven't logged further activity or confirmed a shipped status since.
What I'd tell myself before the reversal.
- A hand-maintained lunar table is a maintenance trap, even chasing one extra day of accuracy. I tried it, and reverted within the same working session. The platform-provided calendar was less accurate but far less fragile to keep correct.
- Duplicating reference data was the right trade for a two-target project. Sharing a framework between app and widget would have meant less duplication, but bundling
calendar_1405.jsonindependently into both is what makes the widget correct at midnight without the app having run. - RTL/LTR bugs can be symmetric. The scroll-direction bug was wrong in exactly the same way in two files at once, which is an easy way to ship a direction bug without a dedicated per-language regression pass.
Release-ready, not yet confirmed live.
Roozaneh is a personal project. I designed, built, and maintain it independently, and it isn't part of any client or employer engagement. Development ran in bursts from mid-February to late June 2026: an initial SwiftData scaffold, a two-month gap, then a dense stretch in May and June that added the widget extension, migrated calendar data off a 15MB spreadsheet, fixed a backwards RTL scroll-direction bug, and built then reversed a hand-maintained Hijri-calendar table.
By the last commit, release-preparation work (a finalized bundle ID, privacy manifests for both the app and widget targets, App Store submission metadata) reads as complete. I haven't confirmed a live App Store listing, so I'm describing this here as a release-ready, independently-built app rather than claiming it has shipped.