Tushti - Daily Gratitude for iOS
Tushti is a gratitude journaling app for iPhone and iPad. Record what you are thankful for, track your streaks, and watch your practice grow. No accounts, no data collection, and free iCloud sync across all your devices. Simple, private, and designed to fit your life.
Tushti exists because of a throwaway comment someone made about writing down ten things they were grateful for every morning. The idea seemed a bit much at first. Gratitude journaling felt like something for people who collect crystals and align their chakras. But it stuck around, and curiosity eventually won out over cynicism.
The gap in the market
Some proper research made it clear that the gratitude app market had a real problem. Users were frustrated, loudly and consistently. The same complaints surfaced across App Store reviews and Reddit threads: apps stuffed with meditation tools, AI coaches, and vision boards nobody asked for. Aggressive subscriptions locking basic features behind paywalls. Notifications that never learned when to stop. Rigid daily streaks that turned a positive habit into a guilt trip. And underneath all of that, genuine anxiety about data loss and privacy. The space was full of apps trying to do everything, and none of them doing the simple thing well.
That gap felt worth filling, and it suited a personal philosophy too. There is something in Buddhist thinking about gratitude, humility, and appreciation that resonates without needing to be wrapped in doctrine. These are not radical ideas. They are just ones that get drowned out by the noise of modern life. Building an app around them was a way of putting that belief into practice.
The name Tushti comes from the Sanskrit word तुष्टि, meaning "contentment." It felt like the right destination for what the app should be: a calm, focused space to record what you are thankful for each day. No social features, no dark patterns nudging you into engagement. Just a daily habit with thoughtful defaults and a clean interface.
App Building
What Tushti actually delivers is more considered than its simplicity suggests. You open the app, type what you are grateful for, tap to add, and your entry is saved. The whole thing takes less than two minutes. But behind that simplicity sits a streak system that adapts to your chosen schedule, so journaling three times a week earns the same recognition as journaling daily. Milestones track your progress across entries recorded, days journaled, and time with the app. An activity view switches between a weekly bar chart and monthly heatmap to reveal your patterns over time. And everything syncs across your iPhone and iPad via iCloud, free for every user.
Privacy was non negotiable from the start. There are no accounts, no sign ups, no analytics SDKs, and no third party services. Your entries stay on your device and in your own iCloud account. The app ships in 12 languages with full accessibility support, because a gratitude practice should be open to everyone, not just those who fit a particular mould. Building all of this in nine days required a focused, systematic approach. Here is how it came together.
The design language draws from two natural elements: earth tones (warm linens and deep soils) and growth (sage greens as the primary accent and action colour). The palette and all adaptive colour definitions live in a single Color+Extension.swift file. No view ever checks colorScheme directly; it only references named semantic colours, which handle light/dark mode internally.
Technical Foundation
The app was built using:
- SwiftUI on iOS 17.0 and iPadOS 17.0 minimum
- Core Data with NSPersistentCloudKitContainer for storage and automatic iCloud sync
- StoreKit 2 for subscriptions (£2.99/month, £19.99/year, £49.99 lifetime)
- UNUserNotificationCenter for local notifications (no server required)
- NSUbiquitousKeyValueStore for fast cross-device onboarding state
- Security framework for tamper-proof trial period via Keychain
- Swift Charts for the activity bar chart and month heatmap in Insights
- Core Text for the animated handwriting effect in onboarding
Architecture follows MVVM throughout, with @Observable ViewModels and @FetchRequest in views for Core Data queries. All services are singletons or passed via the SwiftUI environment. The entire stack has zero third-party dependencies.
Development
Day 1: Project Setup, Data Model and Localisation (14 February 2026)
The first day established the structural foundations that every subsequent day would build on.
Core Data model
The Tushti.xcdatamodeld was populated with two entities. GratitudeEntry stores the text content, date, timestamps, and sort order. UserSettings acts as a singleton record holding the user's display name, schedule configuration, trial state, streak counts, and preferences.
The first significant problem appeared immediately: CloudKit requires that either all attributes have default values, or all attributes are optional. UUID and Date types have no meaningful defaults expressible in the model XML. The solution was to mark every attribute optional="YES" in the model and enforce non-nil requirements at the application layer when creating objects:
// GratitudeEntry attributes — all optional in Core Data, enforced in code
let entry = GratitudeEntry(context: context)
entry.id = UUID()
entry.text = text
entry.date = date.startOfDay
entry.createdAt = Date()
entry.updatedAt = Date()
entry.sortOrder = Int16(clamping: sortOrder)
A second early problem: the initial ContentView used Tab(_:systemImage:content:) which was introduced in iOS 18. The project targets iOS 17, so every tab had to be built with .tabItem { Label(...) } instead. This was later replaced entirely with a custom tab bar anyway (Day 2), but the build error was a good reminder to check API availability early.
Localisation infrastructure
All user-facing strings were externalised to Localizable.strings from day one, accessed through a LocalizationService.shared singleton. Keys use strict dot notation: entry.prompt.default, onboarding.name.title, paywall.annual.badge. No English strings appear in view files.
// All UI strings go through LocalizationService — never hardcoded
Text(localization.string(for: "greeting.morning"))
// Parameterised strings with named replacements
Text(localization.string(for: "paywall.stats.message",
replacing: ["count": "\(entries)", "days": "\(days)"]))
Colour system
Color+Extension.swift uses a two-tier approach: named hex constants at the bottom of the file, semantic adaptive colours referencing them above. This keeps the actual hex values visible while giving callers expressive names like Color.appBackground, Color.sageAccent, Color.destructiveRed.
Day 2: Entry System, Custom Tab Bar and Launch Screen (15 February 2026)
Day 2 built the entire entry experience: the DayView main screen, EntryInputView, EntryRowView, EntryEditView, the custom CalendarSheetView, and the tab navigation system.
Custom tab bar
The system TabView appearance did not match the design requirements. A fully custom TabBarView was built as an HStack of TabButtonView items inside a capsule-shaped container. Tab switching uses .id(currentTab) on the content area combined with .transition(.asymmetric(insertion:removal:)) and withAnimation to produce directional slide animations. A TabDirection enum tracks whether the transition should slide left or right.
Scroll bottom detection
The entry list needed a fade gradient at the bottom that disappeared when the user scrolled to the end, revealing the full last entry. Four approaches were tried before finding one that worked reliably on iOS 17. The solution was a sentinel view — Color.clear.frame(height: 1) — placed at the end of the LazyVStack, with onAppear and onDisappear toggling an isNearBottom binding.
Debounced text field validation
The add-entry button needs to be disabled when the text field is empty or contains only whitespace. Running trimmingCharacters(in: .whitespacesAndNewlines) on every keystroke is wasteful. The fix was .task(id: entryText) with a 250ms sleep — Swift's structured concurrency automatically cancels the previous task when entryText changes, so the check only runs when the user pauses typing:
.task(id: entryText)
{
try? await Task.sleep(for: .milliseconds(250))
hasContent = !entryText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
Sheet-based editing
EntryRowView uses @ObservedObject var entry: GratitudeEntry rather than let entry. This is critical: SwiftUI only re-renders the row after a save if it is observing the managed object's property changes. The edit sheet uses .presentationDetents([.fraction(0.25)]) for a quarter-height sheet. Auto-save fires on any form of dismissal — swipe down, tap outside, keyboard submit, or the checkmark button — via .onChange(of: isEditSheetPresented).
Swipe-to-delete
The delete gesture uses resistance physics: movement is linear up to 80% of the delete threshold, then damped beyond that point, giving the user tactile feedback that they are approaching the point of no return. The delete background fades in progressively as the user swipes.
Launch screen
New Xcode projects include INFOPLIST_KEY_UILaunchScreen_Generation = YES in project.pbxproj, which auto-generates a blank white launch screen and overrides any storyboard. This had to be replaced with INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen in both Debug and Release build configurations. iOS also caches launch screens aggressively — xcrun simctl erase all was needed to verify changes on the simulator.
Fetch predicate timing
Date navigation has a subtle ordering requirement. The contextual header text ("Today I am grateful for...", "Yesterday I was thankful for...") and the entry list must update simultaneously. If updateFetchPredicate() is called via .onChange(of: selectedDate), the header text updates on one render cycle and the fetch results on the next, causing a visible flash. The fix was to call updateFetchPredicate() synchronously inside navigateDate() before the state change propagates.
Day 3: Onboarding, Settings and Custom Time Picker (16 February 2026)
Onboarding architecture
The OnboardingService is an @Observable class managing all onboarding state. An OnboardingStep enum with Int raw values defines the sequence: .welcome, .name, .schedule, .reminder, .firstEntry, .complete, .trial. The OnboardingContainerView orchestrates all screens with directional slide transitions.
A skip logic requirement runs through both the forward and backward directions: if the user selects no scheduled days, the .reminder screen is irrelevant and must be skipped. Both advance() and goBack() check selectedDays.isEmpty and hop over .reminder in either direction:
func advance()
{
let next = OnboardingStep(rawValue: currentStep.rawValue + 1) ?? currentStep
if next == .reminder && selectedDays.isEmpty
{
currentStep = .firstEntry
}
else
{
currentStep = next
}
}
Custom time picker
The system DatePicker with .wheel style cannot be styled with custom fonts or colours. A custom TimeWheelPickerView was built from scratch with two TimeWheelColumnView instances (hours 0–23 and minutes 0–55 in 5-minute increments) separated by a colon. Each column uses:
scrollPosition(id:anchor: .center)for iOS 17 compatible position trackingscrollTargetBehavior(.viewAligned)combined with.scrollTargetLayout()for native snap-to-item behaviourLazyVStackwith leading and trailing spacers so the first and last items can scroll to centre- A
LinearGradientmask that fades items towards the top and bottom edges
A subtle initialisation bug appeared here: @State selectedMinute = 15 was being overwritten by .onAppear in a race condition. The fix was to initialise the state from reminderTime in init() rather than relying on .onAppear.
@FetchRequest` in Xcode previews
The @FetchRequest(sortDescriptors:) initialiser uses NSManagedObject.entity() to resolve the entity, which is ambiguous when multiple NSPersistentContainer instances share the same model — for example, when the app's CloudKit container and a preview's in-memory container both load Tushti.xcdatamodeld. The fix was to use the explicit form throughout:
@FetchRequest(
fetchRequest: NSFetchRequest<GratitudeEntry>(entityName: "GratitudeEntry")
)
private var entries: FetchedResults<GratitudeEntry>
PersistenceService also detects XCODE_RUNNING_FOR_PREVIEWS and switches to an in-memory store with shouldAddStoreAsynchronously = false to avoid async loading issues in canvas previews.
App-level colour scheme
Applying .preferredColorScheme() in RootView caused a re-render cascade through the entire view hierarchy every time the setting changed. Moving it to TushtiApp.swift using @AppStorage fixed this entirely.
Day 4: History Tab, Insights and Stats Infrastructure (17-18 February 2026)
History tab redesign
The original history design was an infinite scrolling list. This was rebuilt as a week-at-a-time view with navigation. Weeks run Monday to Sunday (respecting the user's week start preference set in Day 6). The HistoryWeekPickerSheet uses a calendar grid where tapping any day selects the entire week, computing startOfWeek and dismissing.
Cross-tab navigation was required: tapping "Add entries for this day" in History should switch to the Reflect tab and jump to that date. This was wired via an onNavigateToDate closure passed from ContentView down to HistoryView.
Insights tab
The StatsService was built as an @Observable singleton computing streaks, totals, and chart data from Core Data. The Insights tab contains four sections:
- Streak cards (current and longest, in a merged
StreakCardView) - Summary statistics (total entries, average per session)
- Activity charts: a 7-day bar chart using Swift Charts
BarMark, and a month heatmap using a calendar grid with four intensity levels - Milestones (added Day 7)
The activity month heatmap reuses the same grid infrastructure as the calendar sheet. A specific SwiftUI quirk appeared here: veryShortWeekdaySymbols produces duplicate single-letter strings (T/T for Tuesday/Thursday, S/S for Saturday/Sunday). Using these directly as ForEach identifiers caused SwiftUI to deduplicate the cells. The fix was .enumerated() to use the index as the identifier rather than the symbol string.
DateFormatter caching
A performance audit discovered that DateFormatter() was being instantiated inside computed properties on views, meaning a new formatter was created on every SwiftUI body evaluation. DateFormatter initialisation is expensive. A DateFormatterCache enum with static lazy properties was introduced in Date+Extension.swift:
enum DateFormatterCache
{
static let time: DateFormatter =
{
let f = DateFormatter()
f.setLocalizedDateFormatFromTemplate("jm")
return f
}()
static let shortDate: DateFormatter =
{
let f = DateFormatter()
f.setLocalizedDateFormatFromTemplate("EEEdMMM")
return f
}()
// ... 8 more static formatters
}
All views were migrated to use the cache. The formatters are locale-aware via setLocalizedDateFormatFromTemplate rather than hardcoded format strings, which matters for the 12-language release.
Scroll gradient unification
Each scrollable view was tracking scroll position independently with around 60 lines of duplicated geometry-tracking code. This was extracted into a reusable ScrollGradientModifier that applies top and bottom overlay gradients based on scroll position, with onGeometryChange tracking content height, scroll offset, and viewport height. All four scrollable views (DayView, HistoryView, InsightsView, SettingsView) were reduced to a single .scrollGradients() modifier call.
Day 5: StoreKit 2 and Paywall (20 February 2026)
SubscriptionService
The SubscriptionService is an @Observable @MainActor singleton. It loads products via Product.products(for:), handles purchases through StoreKit 2's product.purchase() API with VerificationResult checking, and listens for real-time transaction updates via Transaction.updates:
@Observable
@MainActor
class SubscriptionService
{
static let shared = SubscriptionService()
var status: AppSubscriptionStatus = .free
var isLoading: Bool = true
var purchaseError: String? = nil
var purchasePending: Bool = false
var isPremium: Bool { status.isPremium }
@ObservationIgnored private var updateListenerTask: Task<Void, Never>?
@ObservationIgnored private var initialLoadTask: Task<Void, Never>?
The AppSubscriptionStatus enum models the four possible states:
enum AppSubscriptionStatus
{
case free
case trial(daysRemaining: Int)
case subscribed(plan: String, expiryDate: Date?)
case lifetime
var isPremium: Bool
{
switch self
{
case .free: return false
case .trial(let days): return days > 0
case .subscribed: return true
case .lifetime: return true
}
}
}
refreshStatus() checks Transaction.currentEntitlements and collects all entitlements before deciding status — an important detail because returning on the first match could miss a lifetime purchase if a subscription record appeared first in the iterator. Lifetime always takes priority.
Paywall design
The paywall is presented as a fullScreenCover with a single instance in ContentView. Pricing cards are pre-selected on the yearly tier, which shows the saving percentage dynamically calculated from StoreKit product prices rather than being hardcoded. The legal links, restore purchases button, and pricing information are all mandatory for App Store compliance.
The paywall shows the user's own stats to create emotional investment before asking for money: "You've recorded 12 moments of gratitude over 14 days." These figures come from StatsService and UserSettings.
Tab gating
The Insights tab requires a subscription. TabBarView intercepts taps before updating the selection binding — this prevents the slide animation from firing for blocked tabs. An onTabBlocked callback triggers the paywall from ContentView, and after a successful purchase, the app navigates to the tab the user originally tried to access.
PaywallView task management
An early audit finding was that purchase, retry, and restore actions all spawned untracked fire-and-forget Task instances. If the user dismissed the paywall mid-purchase, the task kept running with no way to cancel it. Fixed by storing tasks in @State properties and cancelling them all in .onDisappear:
@State private var purchaseTask: Task<Void, Never>? = nil
@State private var retryTask: Task<Void, Never>? = nil
@State private var restoreTask: Task<Void, Never>? = nil
.onDisappear
{
purchaseTask?.cancel()
retryTask?.cancel()
restoreTask?.cancel()
}
Day 6: Codebase Audit and Quality Sweep (21 February 2026)
Day 6 was nine rounds of systematic codebase auditing, each covering concurrency, performance, crash prevention, accessibility, StoreKit correctness, code quality, and localisation. This was the most intensive quality pass of the build.
O(n²) streak calculation
The initial streak calculation used .contains(where: { calendar.isDate($0, inSameDayAs:) }) inside a while loop, scanning the full array of entry dates on every iteration. For a user with years of entries this would become noticeably slow. Converting the entry dates to a Set<Date> reduced the inner lookup from O(n) to O(1):
let daySet = Set(allDatesCache.map { $0.startOfDay })
while isScheduledDay(checkDate, calendar: calendar, scheduled: scheduled)
{
if daySet.contains(checkDate) // O(1) instead of O(n)
{
streak += 1
}
else
{
break
}
// ...
}
Calendar caching
Calendar.appCalendar is a computed property that reads the user's week start preference from UserDefaults and configures a Calendar accordingly. Every calendar grid — the month heatmap, the week picker sheet, the calendar date picker — calls this property dozens of times per render. The naive version read UserDefaults and constructed a new Calendar on every access. A static cache with a stored _cachedCalendar and a _cachedRawValue was added so the calendar is only rebuilt when the preference actually changes:
extension Calendar
{
@MainActor private static var _cachedCalendar: Calendar?
@MainActor private static var _cachedRawValue: Int = -1
@MainActor static var appCalendar: Calendar
{
let raw = UserDefaults.standard.integer(forKey: AppConstants.StorageKeys.weekStartPreference)
if let cached = _cachedCalendar, _cachedRawValue == raw
{
return cached
}
var cal = Calendar.current
cal.firstWeekday = WeekStartPreference(rawValue: Int16(raw))?.resolvedFirstWeekday ?? cal.firstWeekday
_cachedCalendar = cal
_cachedRawValue = raw
return cal
}
}
HapticService pre-allocation
The initial haptic implementation created new UIImpactFeedbackGenerator, UISelectionFeedbackGenerator, and UINotificationFeedbackGenerator instances on every call. Generator instantiation has a small cost and they need to be prepared before use. HapticService was refactored to use private static stored properties so each generator type is created once and reused:
@MainActor enum HapticService
{
private static let impact = UIImpactFeedbackGenerator(style: .light)
private static let selection = UISelectionFeedbackGenerator()
private static let warning = UINotificationFeedbackGenerator()
static func lightImpact() { impact.impactOccurred() }
static func selection() { selection.selectionChanged() }
static func warning() { warning.notificationOccurred(.warning) }
static func success() { warning.notificationOccurred(.success) }
}
Week start preference
A user-configurable week start was added (System default, Monday, or Sunday). This required a Calendar.appCalendar extension, a WeekStartPreference enum, a new SettingsWeekStartSection, and updates to every view that renders a calendar grid. The start-of-week date calculation was also updated from a hardcoded Monday formula to a preference-aware one:
// Old: hardcoded Monday
let daysBack = (weekday - 2 + 7) % 7
// New: respects user preference
let firstWeekday = Calendar.appCalendar.firstWeekday
let daysBack = (weekday - firstWeekday + 7) % 7
StatsService dirty flag
recalculateAll() was running on every appearance of the Insights tab, even when no entries had changed. An isDirty flag was added, set to true on init and via a NSManagedObjectContextDidSave observer. EntryActionService calls markDirty() before triggering recalculation. The observer closure runs on the main thread but the compiler cannot prove this without help:
NotificationCenter.default.addObserver(
forName: NSManagedObjectContext.didSaveObjectsNotification,
object: viewContext,
queue: .main
)
{
[weak self] _ in
MainActor.assumeIsolated
{
self?.isDirty = true
}
}
iCloud preference sync
preferredColorScheme and weekStartPreference are stored in both Core Data (for iCloud sync across devices) and UserDefaults (for @AppStorage in the UI). When a remote CloudKit change arrives, the Core Data values update but UserDefaults would not. PersistenceService was updated to listen for NSPersistentStoreRemoteChangeNotificationPostOptionKey and write the updated values to UserDefaults whenever a remote change arrives.
Reduce Motion compliance
An audit pass identified 17 animation sites across the codebase that were not checking @Environment(\.accessibilityReduceMotion). Every withAnimation, .animation(), and .transition() call was audited and updated. The pattern throughout the codebase became:
@Environment(\.accessibilityReduceMotion) private var reduceMotion
withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.25))
{
// state change
}
Day 7: Notifications, Trial Hardening, Dev Tools and Milestones (22 February 2026)
Tamper-proof trial
The original trial implementation stored trialStartDate in Core Data. This is vulnerable in two ways: changing the device clock can extend the trial indefinitely, and reinstalling the app from the App Store creates a fresh Core Data store, resetting the trial date. The fix uses Apple's AppTransaction.shared, which provides a cryptographically signed originalPurchaseDate that cannot be spoofed.
A KeychainService was introduced to cache this date locally, surviving reinstalls:
struct KeychainService
{
static func saveDate(_ date: Date, forKey key: String)
{
let interval = date.timeIntervalSince1970
let data = withUnsafeBytes(of: interval) { Data($0) }
let query: [String: Any] =
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
SecItemAdd(query as CFDictionary, nil)
}
static func loadDate(forKey key: String) -> Date?
{
// ...
let interval = data.withUnsafeBytes
{
$0.loadUnaligned(as: TimeInterval.self)
}
return Date(timeIntervalSince1970: interval)
}
}
The loadUnaligned(as:) call (instead of load(as:)) is deliberate — raw Keychain data is not guaranteed to be 8-byte aligned, so load(as: TimeInterval.self) would trap on misaligned access.
SubscriptionService.refreshStatus() now uses a three-tier lookup: Keychain cache first (fast path, survives reinstalls), then AppTransaction.shared (Apple-signed, cached on first fetch), then Core Data fallback for debug builds without a StoreKit configuration.
Notification infrastructure
NotificationService gained inactivity nudge scheduling (scheduleNudges) alongside the existing reminder scheduling (scheduleReminders). Nudges fire on the 2nd and 5th consecutively missed scheduled days, then stop. A NotificationDelegate conforming to UNUserNotificationCenterDelegate was added so notifications display as banners even when the app is in the foreground.
Trial-specific notifications fire on day 10 (reminder with the user's entry count) and day 14 (expiry notice). The notification body includes the entry count, so these are rescheduled after every new entry to keep the count current.
Animated handwriting
The welcome screen uses a HandwrittenTextView that draws the app title with a progressive handwriting animation using Core Text and SwiftUI's .trim() modifier:
// Convert text to glyph paths via Core Text
let glyphPath = CTFontCreatePathForGlyph(font, glyph, &transform)
// Animate stroke trim revealing the filled text
Path(glyphPath)
.trim(from: 0, to: progress)
.stroke(...)
For CJK and Cyrillic locales, the Caveat font does not cover the required glyphs. A usesSystemFont guard falls back to CTFontCreateUIFontForLanguage(.system, ...) for Chinese, Japanese, Korean, and Russian.
Debug dev tab
A hidden dev tab (accessible via a small hammer icon overlaid on the tab bar, only in #if DEBUG builds) provides a test harness with three sections:
DevRemindersView— fires any notification type after a 3-second delayDevPurchaseStateView— overridesSubscriptionService.shared.statusdirectly without requiring StoreKit transactions, simulating free, trial (14-day, 1-day, just-installed), monthly, annual, and lifetime statesDevLanguageSelectorView— switches the entire app to any of the 12 supported languages at runtime without changing the device locale
Milestones feature.
The Insights tab gained a Milestones section tracking three categories: grateful moments (total entries), days of reflection (unique days journaled), and time with Tushti (days since install). Each category has 7 milestone thresholds. Tapping a milestone icon reveals a description bubble below the icon row with a slide-and-fade animation. A hasTappedMilestone boolean is stored in Core Data and syncs via iCloud, so users on a second device do not see the "tap a milestone to learn more" hint if they have already interacted with it elsewhere.
Schedule-aware streaks
The streak calculation was rewritten to walk backwards through the user's scheduled days rather than calendar days. A user who journals every Monday, Wednesday, and Saturday has a streak that increments on those days only. Falling behind by one scheduled day does not immediately break the streak; the break happens when there is no entry for three consecutive scheduled days (to provide meaningful forgiveness):
nonisolated struct StatsCalculator
{
func currentStreak(from sortedDays: [Date], scheduled: Set<Int>,
calendar: Calendar = .current,
referenceDate: Date = Date()) -> Int
{
let daySet = Set(sortedDays.map { calendar.startOfDay(for: $0) })
var checkDate = calendar.startOfDay(for: referenceDate)
// If no entry today, start from most recent scheduled day
if !daySet.contains(checkDate)
{
checkDate = previousScheduledDay(before: checkDate,
calendar: calendar,
scheduled: scheduled) ?? checkDate
}
var streak = 0
while isScheduledDay(checkDate, calendar: calendar, scheduled: scheduled)
{
guard daySet.contains(checkDate) else { break }
streak += 1
guard let prev = previousScheduledDay(before: checkDate,
calendar: calendar,
scheduled: scheduled)
else { break }
checkDate = prev
}
return streak
}
}
Day 8: Multi-Device Sync and 12-Language Localisation (24 February 2026)
Notification preferences per device
When reminderTime was stored in Core Data and synced via iCloud, changing the reminder time on one device overwrote the other device's notification schedule. Notifications are inherently per-device: there is no reason an iPhone user at 8pm should also set their iPad to remind them at 8pm. The fix moved reminderTime to UserDefaults using a NotificationPreferencesService wrapper. A one-time migration reads the old Core Data value and writes it to UserDefaults on first launch after the update.
scheduledDays stays in Core Data because streak calculation must be consistent across devices — you need the same set of scheduled days to produce the same streak count everywhere.
Second-device onboarding
Installing Tushti on a second device ran full onboarding, which would overwrite the synced UserSettings (display name, scheduled days, etc.) before the CloudKit sync completed. The solution uses NSUbiquitousKeyValueStore as a fast-synchronising flag: when onboarding completes on the first device, iCloudKVService.markOnboardingCompleted() sets a key in the KV store. On a second install, RootView checks this flag before deciding which flow to show:
func checkOnboardingStatus()
{
if settings.hasCompletedOnboarding
{
appState = .main
}
else if iCloudKVService.hasCompletedOnboardingElsewhere()
{
appState = .welcomeBack
}
else
{
appState = .onboarding
}
}
The WelcomeBackView shown on the second device is a single screen: a personalised greeting using HandwrittenTextView, and a reminder time picker for the device-local notification preference. If no scheduled days are synced, the welcome back flow is skipped entirely and onboarding is auto-completed.
12-language localisation
English (US) and British English were already in place. Ten additional languages were added: Spanish (Mexico), Portuguese (Brazil), Simplified Chinese, Japanese, German, French, Korean, Italian, Dutch, and Russian. Translation decisions included:
- Informal register throughout ("tu" in French, "du" in German, "тебя" in Russian)
- Gender-neutral constructions where the language requires a choice
- Native terms for key concepts: "Séries" in French for streaks, "Serien" in German, "连续记录" in Chinese, "連続記録" in Japanese
After adding all translations, a Python script (scripts/check-translations.sh) was written to compare character counts against the English base and identify keys where translations were significantly longer. This caught several overflow issues: German tab labels, French paywall legal links, and Russian settings labels. Each was shortened with native-speaker intent preserved.
Milestone time labels
The DateComponentsFormatter with .abbreviated style returns Latin abbreviations ("1y", "1m") for the Japanese locale — an Apple CLDR data issue. Switching to .short style produces correct native text: "1年", "1か月". Portuguese and Dutch duration strings in .short style are too wide for the milestone circles even at minimum scale factor. These languages get a stacked layout with the number on one line and the unit below.
Day 9: Unit Test Suite and StatsCalculator Extraction (23 February 2026)
StatsCalculator extraction
StatsService combined Core Data queries with pure streak and statistics calculations in private methods behind a private init singleton. Nothing was testable. The calculation logic was extracted into a new nonisolated struct StatsCalculator with zero Core Data dependencies. All methods accept explicit Calendar and Date parameters so tests can pass in deterministic values rather than relying on the current date.
StatsService was reduced to data fetching and caching, delegating all calculations to an internal StatsCalculator instance.
Test infrastructure
A shared TestHelper provides three utilities. makeContext() returns a clean NSManagedObjectContext from a single shared in-memory PersistenceService — using multiple in-memory containers from the same model causes an "NSEntityDescriptions claim two or more NSManagedObjectModel objects" assertion failure. seedSettings(in:) and seedEntry(in:) create controllable test fixtures.
Tests requiring shared state (Core Data operations, Keychain, UserDefaults) use @Suite(.serialized) to disable Swift Testing's default parallel execution:
@Suite(.serialized)
struct EntryActionServiceTests
{
@Test func addEntry_createsWithCorrectFields() async throws
{
let context = TestHelper.makeContext()
let settings = TestHelper.seedSettings(in: context)
let service = await EntryActionService(viewContext: context)
let date = TestHelper.date(year: 2026, month: 2, day: 15)
await service.addEntry("Grateful for coffee", date: date, sortOrder: 0)
let request = NSFetchRequest<GratitudeEntry>(entityName: "GratitudeEntry")
let results = try context.fetch(request)
#expect(results.count == 1)
#expect(results[0].text == "Grateful for coffee")
#expect(results[0].date == date.startOfDay)
}
}
The suite covers 17 test files across four tiers:
- Pure logic (8 files):
SubscriptionStatus,Font+Extension,Array+Extension,Color+Extension,MilestoneItem,OnboardingStep,WeekStartPreference,ColorSchemePreference - StatsCalculator (1 file): schedule helpers, streak calculations, month and week data
- Light dependencies (4 files):
Date+Extension,LocalizationService,NotificationPreferencesService,KeychainService - Core Data business logic (4 files):
UserSettingsService,EntryActionService,ReviewService,PersistenceService,OnboardingService
NSBatchDeleteRequest (used in resetApp()) operates at the SQL layer and does not fire NSManagedObjectContextDidSave, causing crashes on in-memory stores. This was left as a known gap for integration testing.
Days 10 and 11: Final Audits and App Store Submission
Removing the iCloud sync toggle
The SettingsDataSection with its iCloud sync toggle was deleted. NSPersistentCloudKitContainer syncs automatically when the user is signed into iCloud and falls back gracefully to local storage when they are not — exactly as Apple's own apps (Notes, Reminders, Journal) behave. None of Apple's apps offer an in-app sync toggle. The existing iCloudSyncEnabled boolean in Core Data was cosmetic: it was stored but never read by PersistenceService to actually disable CloudKit. Removing the illusion of control was the correct decision.
InsightsView loading state
An isLoaded / resetLoaded() mechanism was added to StatsService so the Insights tab renders nothing until the initial calculation completes. This prevents layout jumps where placeholder zeroes appear for a frame before real stats load.
Dead code removal
updateUserSettings() in StatsService was writing currentStreak, longestStreak, and totalEntries back to Core Data UserSettings after every recalculation. These values were redundant — all three are always computed on the fly from entry records when needed. The denormalised copies were never read back from UserSettings. The method and all its callsites were removed, simplifying the data model and eliminating an unnecessary save cycle.
App Store submission prep
An archive build script was written to increment the build number, run a clean release build, create the archive, and print next-step instructions. The build was submitted via App Store Connect. The first submission was rejected due to a broken Terms of Use URL (stditunes in the App Store scheme should be stdeula). This was fixed in a patch commit and resubmitted successfully.
Key Technical Challenges
Core Data and CloudKit Compatibility
CloudKit imposes constraints on Core Data models that are not always obvious. Every attribute must be either optional or have a default value. Relationships must have delete rules set to cascade or nullify (never deny). Index configurations must not use compound indexes with transformation. All of these were encountered and resolved by marking attributes optional and handling nil at the application layer.
@FetchRequest and Multiple Containers
Using @FetchRequest(sortDescriptors:) in Xcode previews caused assertion failures when more than one NSPersistentContainer shared the same model. The entity() class method resolves ambiguously when the model has been loaded by multiple containers. Switching to NSFetchRequest<T>(entityName:) always resolves against the context's own coordinator, eliminating the ambiguity.
Trial Period Security
Local clock manipulation and app reinstallation both trivially bypass a trial period stored in Core Data. AppTransaction.originalPurchaseDate provides a cryptographically signed date from Apple's servers. The three-tier resolution (Keychain cache → AppTransaction → Core Data fallback) handles all scenarios: normal use (Keychain), fresh install (AppTransaction), debug builds without StoreKit config (Core Data).
Unmanaged Concurrency
Swift's structured concurrency model requires that tasks be stored and cancelled when their initiating view disappears. Fire-and-forget Task {} blocks are fine for synchronous fire-once work but are dangerous for ongoing operations like purchases or product loading. Every async operation in the app has a stored cancellable task handle.
Runtime Language Switching
Supporting 12 languages in development required the ability to switch languages at runtime without changing the device locale. LocalizationService gained a DEBUG-only overrideBundle and overrideLocale that point all string lookups, date formatters, and calendar instances to the selected language. DateFormatterCache.overrideLocale updates all 10 cached formatters when the language changes. Calendar.appCalendar applies the override locale so weekday labels render in the correct language in calendar grids.
iPad Layout and Sheet Environment Inheritance
SwiftUI sheets and fullScreenCover presentations do not inherit @Environment values from their parent, including .dynamicTypeSize. Applying a single .dynamicTypeSize(.xxLarge) at the root level on iPad was not enough — the modifier had to be applied individually to all five sheet presentations in the app.
The Architecture in Summary
TushtiApp
└── RootView
├── OnboardingContainerView (if not onboarded)
├── WelcomeBackView (if second device via iCloudKVService)
└── ContentView (main app)
├── DayView (Reflect tab)
│ ├── DateNavigationView + CalendarSheetView
│ ├── EntryListView → EntryRowView → EntryEditView
│ └── EntryInputView
├── HistoryView (History tab)
│ ├── HistoryDateHeaderView
│ └── HistoryWeekPickerSheet
├── InsightsView (Insights tab)
│ ├── StreakCardView
│ ├── TotalEntriesView / AveragePerDayView
│ ├── InsightsActivityWeekView / InsightsActivityMonthView
│ └── MilestonesView → MilestoneCategoryView
└── SettingsView (Settings tab)
├── SettingsProfileSection
├── SettingsScheduleSection
├── SettingsWeekStartSection
├── SettingsAppearanceSection
├── SettingsSubscriptionSection
└── SettingsResetSection
Services layer:
PersistenceService—NSPersistentCloudKitContainer, merge policy, remote change handling, preference syncEntryActionService— Core Data CRUD forGratitudeEntryStatsService— singleton, dirty-flag recalculation, delegates toStatsCalculatorStatsCalculator— pure, nonisolated, fully testable calculation engineSubscriptionService— StoreKit 2, three-tier trial resolutionNotificationService— reminder and nudge scheduling, trial notificationsNotificationPreferencesService— device-local reminder time inUserDefaultsiCloudKVService—NSUbiquitousKeyValueStorefor cross-device onboarding flagKeychainService— trial start date cache, survives reinstallsHapticService— pre-allocated feedback generatorsLocalizationService— string lookup with runtime language override in debugReviewService—SKStoreReviewControllerat day 14, 44, and 104
Test Coverage
The test suite contains 17 test files organised into four tiers by dependency complexity. Pure logic tests (enums, extensions, StatsCalculator) run in parallel. Stateful tests (Core Data, Keychain, UserDefaults) run serialised. The StatsCalculator tests are the most comprehensive, verifying streak logic against a wide range of scenarios: empty history, single entry, consecutive entries, gaps that break and do not break streaks, sparse schedules, entries on non-scheduled days.
Localisation
12 languages shipped at launch:
| Code | Language |
|---|---|
en |
English (US) — base |
en-GB |
English (British) |
de |
German |
es-MX |
Spanish (Mexico) |
fr |
French |
it |
Italian |
ja |
Japanese |
ko |
Korean |
nl |
Dutch |
pt-BR |
Portuguese (Brazil) |
ru |
Russian |
zh-Hans |
Simplified Chinese |
All UI strings go through LocalizationService.shared. No English text is hardcoded in view files. The scripts/check-translations.sh script was used extensively to catch overflow issues before they were visible on-device.
Pricing
| Tier | Price | Notes |
|---|---|---|
| Monthly | £2.99/mo | Below market median of $4.99–$9.99 |
| Yearly | £19.99/yr | Below the £20 psychological barrier, 44% saving vs monthly |
| Lifetime | £49.99 | One-time, 2.5× annual price, breaks even at 17 months |
All prices are loaded from StoreKit product objects at runtime. The "Save X%" badge on the yearly card is computed dynamically from the ratio of the yearly and monthly prices, so it stays accurate even if prices change in App Store Connect.
Lessons Learned
Establish the full project structure before writing feature code.
The localisation infrastructure, colour system, service layer conventions, and file structure rules were all in place on day one. This meant every subsequent feature was built into a consistent, well-understood codebase rather than accreting conventions organically.
Write tests for business logic before auditing for quality.
StatsCalculator could not be tested until it was extracted from StatsService. The extraction required understanding what was pure logic and what was data access. Having that boundary clearly defined made the audit passes more focused.
CloudKit is opinionated.
NSPersistentCloudKitContainer handles a great deal transparently, but it imposes hard constraints on the model (all optional attributes, no non-optional foreign keys, specific delete rules) that are easy to violate. Reading the CloudKit Core Data documentation in full before touching the model saves painful debugging later.
Audit your animations for Reduce Motion compliance from the start.
Seventeen animation sites had to be updated to check accessibilityReduceMotion. If this had been enforced from day one as a code convention (always check before animating), the audit round would have found zero issues here.
Test on all locales before any strings are final.
German, French, Dutch, and Russian strings are routinely 30–80% longer than their English equivalents. Discovering this on a shipped version means either truncated labels or another submission cycle. The character count comparison script would ideally run as part of the CI build.
Fire-and-forget tasks are a code smell.
Every Task {} in the codebase that modifies visible state needs a cancellable handle and .onDisappear cleanup. Swift's structured concurrency makes this straightforward — the discipline is just remembering to always do it.
There have been no updates to this project.
Comments (0)
Stay in the Loop
Occasional updates on new articles and projects. No spam guaranteed