Skip to content

Architecture

C4 Level 1 — System Context

C4Context
  title Count-E — System Context

  Person(user, "User", "Tracks daily habits and counts:\npushups, water glasses, steps —\nanything that needs a tally.\nAlso tracks days since/until\nimportant dates.")

  System(app, "Count-E iOS App", "Daily tally tracker with\nstreaks, goals, progress charts,\ndate counters, CSV export,\nand home screen widgets.\nLocal-first, SwiftData backed.")

  System_Ext(appstore, "Apple App Store", "App distribution.\nNo in-app purchases —\nCount-E is free.")

  System_Ext(apns, "Apple Push Notification\nService (APNs)", "Delivers user-configured\ndaily reminder notifications\nto log tallies.")

  System_Ext(widgetkit, "WidgetKit / Home Screen", "Displays primary activity\nprogress (count, goal, streak)\non the user's home screen\nor lock screen.")

  System_Ext(cutie_e, "Cuti-E SDK", "Character-driven in-app\nfeedback and review prompts.\nOpt-in consent flow.")

  Rel(user, app, "Taps to count,\nviews streaks,\nmanages activities")
  Rel(app, apns, "Schedules daily\nreminder notification", "UserNotifications")
  Rel(app, widgetkit, "Writes primary activity\ndata to App Group\nafter every count", "WidgetKit / UserDefaults")
  Rel(widgetkit, user, "Shows today'\''s count,\ngoal, and streak\non home screen")
  Rel(app, cutie_e, "Sends feedback events,\nshows review prompt", "Cuti-E SDK")
  Rel(user, appstore, "Downloads app")

C4 Level 2 — Containers

C4Container
  title Count-E — Containers

  Person(user, "User", "iPhone running Count-E")

  Container_Boundary(ios, "iOS Application") {
    Container(swiftui_views, "SwiftUI Views", "SwiftUI / iOS 17+", "4-tab MainTabView:\n• TallyTabView — activity list, tap to count\n• DatesTabView — days-since/until counters\n• StatsTabView — streaks + history charts\n• SettingsTabView — preferences, reminders\nSheets: AddActivityView, ActivityDetailView,\nAddDateCounterView, OnboardingView")

    Container(app_state, "AppState", "@MainActor ObservableObject\n@EnvironmentObject", "App-wide settings in UserDefaults:\nhapticEnabled, confettiEnabled,\ndefaultDailyGoal, defaultQuickButtons,\nhasCompletedOnboarding,\nreminderEnabled, reminderHour/Minute")

    Container(tally_svc, "TallyService", "@MainActor singleton\nObservableObject", "All tally business logic:\naddCount(amount, to activity) — appends TallyEntry,\nupdates DailyTally.count + goalReached flag.\ndeleteEntry / updateEntry — recalculates daily total\nfrom entries after mutation.\ncalculateStreak(for activity) — counts consecutive\ndays where goalReached = true.\nupdateWidgetData() — encodes WidgetData and\nwrites to App Group UserDefaults.\nCalls WidgetCenter.reloadAllTimelines().")

    ContainerDb(swiftdata, "SwiftData Store", "SwiftData / SQLite\niOS 17+", "3 entities (cascade hierarchy):\n\nTallyActivity\n  name, dailyGoal, quickButtons:[Int],\n  color, icon, sortOrder, isArchived\n  → DailyTally (cascade delete)\n\nDailyTally\n  date, count, goalReached,\n  lastUpdatedAt, progress (computed)\n  → TallyEntry (cascade delete)\n\nTallyEntry\n  timestamp, amount: Int\n  (immutable tap log)\n\nDateCounter (independent)\n  name, targetDate, type (daysSince/daysUntil),\n  icon, color, isArchived\n  dayCount computed at runtime from today")

    Container(export_svc, "ExportService", "Static struct", "generateCSV(for activity) — daily totals CSV\n(Date, Count, Goal, GoalReached).\ngenerateFullCSV(activities) — all activities.")

    Container(haptic_svc, "HapticService", "Utility", "Triggers UIImpactFeedbackGenerator\nfor count taps and goal celebrations.")

    Container(notification_svc, "NotificationService", "Singleton\nUNUserNotificationCenter", "scheduleDailyReminder(hour:minute:) —\nrepeating UNCalendarNotificationTrigger\nidentifier: 'daily-reminder'.\nscheduleCelebration() — one-shot\ngoal-reached notification.")

    Container(cutie_svc, "CutiEService", "Singleton\nCuti-E SDK wrapper", "Manages analytics consent prompt\nand in-app feedback inbox sheet.")
  }

  Container_Boundary(widget_ext, "CountEWidget Extension") {
    Container(widget_reader, "WidgetDataReader", "Singleton\n(read-only)", "Reads WidgetData from\nApp Group UserDefaults:\ngroup.no.invotek.CountE / 'widgetData'\nFallback: Activity 0/100 streak 0\nif no data written yet.")

    Container(widget_provider, "CountEProvider", "WidgetKit\nTimelineProvider", "getTimeline(): reads WidgetData,\ncreates single CountEEntry,\nrefreshes every 15 minutes\n(policy: .after(now + 900s)).")

    Container(small_widget, "SmallWidgetView", "SwiftUI\n.systemSmall", "Activity name, count/goal,\nprogress ring, streak badge.")

    Container(medium_widget, "MediumWidgetView", "SwiftUI\n.systemMedium", "Activity name + progress bar,\ncount/goal, streak, last\nupdated time.")
  }

  System_Ext(apns, "APNs", "Push notifications")
  System_Ext(cutie_e_sdk, "Cuti-E SDK", "github.com/cuti-e/ios-sdk")

  Rel(user, swiftui_views, "Taps to count,\nviews history,\nconfigures activities", "Touch / SwiftUI")
  Rel(swiftui_views, app_state, "Reads/writes\napp preferences", "@EnvironmentObject")
  Rel(swiftui_views, tally_svc, "addCount, deleteEntry,\ncalculateStreak,\narchive/unarchive", ".environmentObject")
  Rel(tally_svc, swiftdata, "Insert TallyEntry,\nupdate DailyTally,\nrecalculate totals,\nquery activities", "ModelContext")
  Rel(swiftui_views, swiftdata, "Query activities\nand date counters", "@Query")
  Rel(swiftui_views, export_svc, "Request CSV\nfor activity", "Swift call")
  Rel(swiftui_views, haptic_svc, "Trigger haptic\non count tap", "Swift call")
  Rel(swiftui_views, notification_svc, "Schedule/cancel\ndaily reminder", "Swift call")
  Rel(swiftui_views, cutie_svc, "Show feedback\ninbox sheet", "Swift call")
  Rel(notification_svc, apns, "Register daily\nreminder + celebration\nnotifications", "UserNotifications")
  Rel(tally_svc, widget_reader, "Writes WidgetData\nto App Group after\nevery count mutation", "UserDefaults\n(App Group)")
  Rel(widget_reader, widget_provider, "Reads WidgetData\nfor timeline entry", "Swift call")
  Rel(widget_provider, small_widget, "Renders small\nwidget view")
  Rel(widget_provider, medium_widget, "Renders medium\nwidget view")
  Rel(cutie_svc, cutie_e_sdk, "Sends feedback events", "SDK")
  UpdateLayoutConfig($c4ShapeInRow="3")

Pattern

MVVM with SwiftUI. Local-first using SwiftData.

Key Components

Views

Navigation is a 4-tab MainTabView:

Tab Root View Purpose
Tally TallyTabView List of activities; tap to count
Dates DatesTabView Days-since / days-until counters
Stats StatsTabView Streaks and history charts
Settings SettingsTabView App preferences

Supporting views: TallyCardView, ActivityDetailView, AddActivityView, DateCounterCardView, AddDateCounterView, OnboardingView. Shared components: ConfettiView, FeedbackToolbarButtons.

View Models / State

  • AppState@MainActor ObservableObject. App-wide settings (haptic, confetti, default goal, quick buttons, onboarding flag) persisted via UserDefaults. Injected as @EnvironmentObject.
  • TallyService@MainActor singleton ObservableObject. All tally business logic: counting, entry editing, streak calculation, widget data sync. Injected via .environmentObject after receiving a ModelContext from the SwiftData container.

Services

Service Type Responsibility
TallyService @MainActor singleton Count mutations, streak logic, widget sync via WidgetKit
ExportService Static struct CSV export for activity history
HapticService Haptic feedback triggers
NotificationService Local notification scheduling
CutiEService Integration with the Cutie-E review request flow

Data Models (SwiftData)

TallyActivity  ──(cascade)──▶  DailyTally  ──(cascade)──▶  TallyEntry
DateCounter    (independent)

TallyActivity — a named, countable activity. - name, dailyGoal, quickButtons: [Int], color, icon, sortOrder, isArchived - One-to-many to DailyTally (cascade delete)

DailyTally — the running total for one activity on one calendar day. - date, count, goalReached, lastUpdatedAt - Computed progress: Double (count / dailyGoal, 0.0–1.0+) - One-to-many to TallyEntry (cascade delete)

TallyEntry — a single tap event. - timestamp, amount: Int - TallyService recalculates DailyTally.count from entries on every mutation.

DateCounter — a "days since / days until" counter, independent of tallies. - name, targetDate, type: DateCounterType (.daysSince / .daysUntil), icon, color, isArchived - Computed dayCount: Int derived from today's date at runtime.

Widget

CountEWidget (WidgetKit extension) reads a WidgetData struct from the shared App Group group.no.invotek.CountE via UserDefaults. TallyService encodes and writes WidgetData (primary activity name, today's count, goal, streak) after every count mutation and calls WidgetCenter.shared.reloadAllTimelines(). Supports small and medium widget sizes.