Case study · Productivity

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
Overview

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 @Observable or 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.json hardcodes 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.xcprivacy manifest were required for both the app and the widget extension.
Architecture

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.

Roozaneh architecture: the RoozaneCalendar app target and the RoozaneWidget extension each bundle their own copy of calendar_1405.json; the App Group UserDefaults bridge carries the app's daily data to the widget as a fallback only. APP TARGET HomeScreen Daily/Monthly VMs Calendar Services SwiftData Store WidgetDataSync SHARED DATA BRIDGE App Group UserDefaults group.rouzaneh.calendar.widget Birthdays · cow art · gradient · language calendar_1405.json 365 days · Persian year 1405 Bundled independently into both targets WIDGET EXTENSION Provider TimelineProvider · now + 7 midnights WidgetCalendarStore Independent decode of calendar_1405.json SimpleEntry Cow art · gradient · birthday per entry writes + reloads fallback only
The widget's own bundled copy of 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.

Engineering decisions

Including the one I reversed.

01

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.

02

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.

03

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.

04

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.

05

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.

Trade-offs

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.

The cost

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 cost

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.

The cost

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.

Technical challenges

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.

Performance & reliability

The numbers that exist, and the ones that don't.

60
Swift files
~6,321 lines across the app and RoozaneWidget targets. No automated tests or CI are configured yet.
365
Bundled calendar days
One full Persian year (1405), indexed for O(1) lookup by a packed year/month/day key.
43
Commits, solo
February through June 2026. No crash reporting or analytics is wired up, so there's no usage data to report here.
Security

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.

Engineering journal

Bursts, with a two-month gap in the middle.

Feb 15, 2026 · Decision

Scaffolded from Xcode's default SwiftData template

Initial commit: ContentView.swift and Item.swift boilerplate still visible in the diff.

Mar 1–2, 2026 · Build

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.

May 5, 2026 · Migration

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.

May 17, 2026 · Build

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.

Jun 17–18, 2026 · Migration

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.

Jun 18–19, 2026 · Reversal

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.

Jun 19, 2026 · Scope cut

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.

Jun 23, 2026 · Last commit

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.

Lessons learned

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.json independently 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.
Outcome

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.