diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
new file mode 100644
index 0000000..8706419
--- /dev/null
+++ b/.gitea/workflows/ci.yml
@@ -0,0 +1,86 @@
+name: CI
+
+# Build + test on every PR into main or develop (and on direct pushes to develop).
+on:
+ pull_request:
+ branches: [main, develop]
+ push:
+ branches: [develop]
+
+# ⚠️ RUNNER LABELS — must match how your Mac runner was registered with act_runner.
+# Check your runner's labels in Gitea: Settings → Actions → Runners (click the runner).
+# If it registered as e.g. `macos` or `self-hosted`, change `runs-on` below to match.
+# `[self-hosted, macOS]` means "a runner that has BOTH labels".
+jobs:
+ build-and-test:
+ runs-on: [self-hosted, macos]
+
+ env:
+ SCHEME: KisaniCal
+ PROJECT: KisaniCal.xcodeproj
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ # The .xcodeproj is generated from project.yml via XcodeGen.
+ # Regenerate it so CI never builds a stale project. No-op if xcodegen isn't installed.
+ - name: Regenerate Xcode project (XcodeGen)
+ run: |
+ if command -v xcodegen >/dev/null 2>&1; then
+ xcodegen generate
+ else
+ echo "xcodegen not found on runner — using committed ${PROJECT}."
+ echo "Install with: brew install xcodegen"
+ fi
+
+ - name: Show toolchain
+ run: |
+ xcodebuild -version
+ xcrun simctl list runtimes | grep -i ios || true
+
+ # Pick a concrete, bootable iOS Simulator (needed for `xcodebuild test`).
+ - name: Select iOS Simulator destination
+ id: sim
+ run: |
+ # First available iPhone simulator on any installed iOS runtime.
+ UDID=$(xcrun simctl list devices available -j | python3 -c "import json,sys,re; d=json.load(sys.stdin)['devices']; devs=[x for k,v in d.items() if re.search('iOS',k) for x in v if x.get('isAvailable') and 'iPhone' in x['name']]; print(devs[0]['udid'] if devs else '')")
+ if [ -z "$UDID" ]; then
+ echo "No available iPhone simulator found on the runner." >&2
+ echo "Install an iOS runtime via Xcode → Settings → Components." >&2
+ exit 1
+ fi
+ echo "udid=$UDID" >> "$GITHUB_OUTPUT"
+ echo "Using simulator UDID: $UDID"
+
+ - name: Build
+ run: |
+ set -o pipefail
+ xcodebuild build \
+ -project "$PROJECT" \
+ -scheme "$SCHEME" \
+ -configuration Debug \
+ -destination "id=${{ steps.sim.outputs.udid }}" \
+ CODE_SIGNING_ALLOWED=NO \
+ | xcbeautify || xcodebuild build \
+ -project "$PROJECT" \
+ -scheme "$SCHEME" \
+ -configuration Debug \
+ -destination "id=${{ steps.sim.outputs.udid }}" \
+ CODE_SIGNING_ALLOWED=NO
+
+ - name: Test
+ run: |
+ set -o pipefail
+ xcodebuild test \
+ -project "$PROJECT" \
+ -scheme "$SCHEME" \
+ -configuration Debug \
+ -destination "id=${{ steps.sim.outputs.udid }}" \
+ CODE_SIGNING_ALLOWED=NO \
+ | xcbeautify || xcodebuild test \
+ -project "$PROJECT" \
+ -scheme "$SCHEME" \
+ -configuration Debug \
+ -destination "id=${{ steps.sim.outputs.udid }}" \
+ CODE_SIGNING_ALLOWED=NO
diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
new file mode 100644
index 0000000..b449820
--- /dev/null
+++ b/.gitea/workflows/release.yml
@@ -0,0 +1,66 @@
+name: Release
+
+# Runs only when code lands on main (i.e. after a PR is merged).
+on:
+ push:
+ branches: [main]
+
+# ⚠️ Same runner-label caveat as ci.yml — adjust `runs-on` to match your registered runner.
+jobs:
+ archive-and-export:
+ runs-on: [self-hosted, macos]
+
+ env:
+ SCHEME: KisaniCal
+ PROJECT: KisaniCal.xcodeproj
+ # A real distribution build MUST be signed. This requires an Apple
+ # Distribution certificate + provisioning profile installed in the
+ # runner's login keychain (see notes at the bottom of this file).
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Regenerate Xcode project (XcodeGen)
+ run: |
+ if command -v xcodegen >/dev/null 2>&1; then xcodegen generate; fi
+
+ - name: Archive
+ run: |
+ set -o pipefail
+ xcodebuild archive \
+ -project "$PROJECT" \
+ -scheme "$SCHEME" \
+ -configuration Release \
+ -destination 'generic/platform=iOS' \
+ -archivePath "$RUNNER_TEMP/KisaniCal.xcarchive" \
+ -allowProvisioningUpdates
+
+ # Write the App Store Connect API key to disk. xcodebuild's auto-discovery
+ # looks for exactly this filename pattern under ~/private_keys.
+ - name: Write App Store Connect API key
+ run: |
+ mkdir -p ~/private_keys
+ echo "${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }}" > ~/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}.p8
+
+ # Export AND upload in one step: passing the API key credentials to
+ # -exportArchive makes xcodebuild upload directly to App Store Connect
+ # (method "app-store" in ExportOptions.plist) — no separate altool/
+ # Transporter step needed. (altool's own upload path was retired by
+ # Apple in 2023; this is the current supported mechanism.)
+ - name: Export IPA and upload to TestFlight
+ run: |
+ set -o pipefail
+ xcodebuild -exportArchive \
+ -archivePath "$RUNNER_TEMP/KisaniCal.xcarchive" \
+ -exportOptionsPlist ExportOptions.plist \
+ -exportPath "$RUNNER_TEMP/export" \
+ -allowProvisioningUpdates \
+ -authenticationKeyPath ~/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}.p8 \
+ -authenticationKeyID ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} \
+ -authenticationKeyIssuerID ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
+ ls -la "$RUNNER_TEMP/export"
+
+ - name: Clean up API key
+ if: always()
+ run: rm -rf ~/private_keys
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..3aea7bd
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,61 @@
+# Contributing to KisaniCal
+
+## Branch workflow
+
+```
+feature/* ─PR→ develop ─PR→ main
+```
+
+- **`main`** — protected, release-ready. Never commit or push directly. Every
+ change arrives via a reviewed pull request. Pushes to `main` trigger the
+ release workflow (archive → IPA → TestFlight).
+- **`develop`** — the default working branch and integration target. CI runs on
+ every PR into it.
+- **`feature/*`** — short-lived branches for a single change, e.g.
+ `feature/voice-quick-add`, `fix/icloud-restore`. Branch off `develop`.
+
+## Day-to-day
+
+```bash
+git checkout develop
+git pull
+git checkout -b feature/my-change
+
+# ...work, commit...
+
+git push -u origin feature/my-change
+# then open a PR in Gitea: feature/my-change → develop
+```
+
+When `develop` is ready to ship, open a PR **`develop` → `main`**.
+
+## Pull request requirements
+
+A PR into `main` (and `develop`) cannot be merged until:
+
+1. **CI passes** — the `build-and-test` job in `.gitea/workflows/ci.yml` builds
+ the app and runs `KisaniCalTests` on an iOS Simulator.
+2. **At least 1 approval** — reviewed and approved.
+3. **No unresolved change requests** — a rejected review blocks the merge.
+4. Your branch is **up to date** with the base branch.
+
+Direct pushes, force-pushes, and deletion of `main` are blocked server-side by
+Gitea branch protection, and a local `pre-push` hook blocks accidental direct
+pushes to `main` from this clone.
+
+## CI / CD
+
+- **CI** (`.gitea/workflows/ci.yml`) — runs on every PR into `main`/`develop`
+ and on pushes to `develop`. Builds + tests. A red check blocks the merge.
+- **Release** (`.gitea/workflows/release.yml`) — runs only on push to `main`
+ (i.e. after a PR merges). Archives, exports an IPA via `ExportOptions.plist`,
+ and (once configured) uploads to TestFlight.
+
+Both workflows run on a **self-hosted macOS runner** with Xcode — there is no
+Gitea-hosted Mac runner.
+
+## Versioning
+
+`MARKETING_VERSION` (e.g. `2.0`) and `CURRENT_PROJECT_VERSION` (build number)
+live in `project.yml`. The build number must **increase** for every TestFlight
+upload — App Store Connect rejects a build number it has already seen.
diff --git a/ExportOptions.plist b/ExportOptions.plist
new file mode 100644
index 0000000..7bd125e
--- /dev/null
+++ b/ExportOptions.plist
@@ -0,0 +1,45 @@
+
+
+
+
+
+ method
+ app-store
+
+ teamID
+ K8BLMMR883
+
+ signingStyle
+ automatic
+
+ uploadSymbols
+
+
+ uploadBitcode
+
+
+
+
+
diff --git a/KisaniCal.xcodeproj/project.pbxproj b/KisaniCal.xcodeproj/project.pbxproj
index f3a0070..d2d1015 100644
--- a/KisaniCal.xcodeproj/project.pbxproj
+++ b/KisaniCal.xcodeproj/project.pbxproj
@@ -7,26 +7,33 @@
objects = {
/* Begin PBXBuildFile section */
+ 023A636FF533304DA5578D1C /* AnalyticsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4FB8B5BFF3FF62728E9498A /* AnalyticsCoordinator.swift */; };
06CA0F336E64D9F6D56F7472 /* CloudSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449C34805DC6B2CB66886544 /* CloudSyncManager.swift */; };
096804560A2F0A7D74E64780 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC2AFB4FA765383740767CB /* TaskItem.swift */; };
12E42CE8B8E535FAE6268A0C /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF905C574F34B4EE51A8D21E /* AppGroup.swift */; };
13E4B9854F595394FC9D5912 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC5E179A9189B0A8C3F856F6 /* ContentView.swift */; };
+ 152AECD42FCF554C582E2DEA /* AnalyticsEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78EEE7568265196447E54D6B /* AnalyticsEngine.swift */; };
16FFC465D8646DED9A5C69D1 /* NLRecurrenceParseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */; };
1A22EE21460821170E44B1DF /* ExerciseModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5722CC4B59E3939724142710 /* ExerciseModels.swift */; };
205846F9651EDCE0D8207358 /* TaskLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */; };
2885D000426D063F2125804C /* SpeechRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86906905A4FA7A2384B3E24E /* SpeechRecognizer.swift */; };
2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF905C574F34B4EE51A8D21E /* AppGroup.swift */; };
+ 30D155A1314CBD4BB1744B29 /* RecurrenceRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A0B871AB37123908CF0CB20 /* RecurrenceRule.swift */; };
32C63D81925FBFE51CAE1FB7 /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; };
3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01A27D42E141DC056D32C1A3 /* TutorialManager.swift */; };
3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89550F2CD19B950CCC6AD37F /* AuthManager.swift */; };
3C793FD5DA00D3E9C0D51FEC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 72FDF9C8DD37134576356B89 /* Assets.xcassets */; };
3E9DE1CF20BAC479805DF940 /* MyTasksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */; };
+ 42E3B90A981E75FFC10C221F /* AnalyticsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0379128BF8B6C1CA7DF026D0 /* AnalyticsModels.swift */; };
45AA93D76970B39DB8BA6A5B /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */; };
497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */; };
4FB79BBC1EE0F6C7EF844B02 /* RecurrenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */; };
+ 550E9A86BE36228C705E91C1 /* AnalyticsEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DEAB6A9224E02484292362A /* AnalyticsEngineTests.swift */; };
552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */; };
55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429806CE1021C8DE2EB770CE /* WidgetViews.swift */; };
+ 5D0E254AB5D195AFDE09FABF /* StopCountdownIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91A354921717711EB3C0EE8E /* StopCountdownIntent.swift */; };
5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */; };
+ 5F91DDE0B64C8AE1F142F434 /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A22FCC3FA1A10F52415E5AF /* AnalyticsService.swift */; };
67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 106EEF572C6F8990408329F0 /* OnboardingView.swift */; };
6921CB73A3257502FF778381 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */; };
6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */; };
@@ -36,25 +43,35 @@
7CEBC340BFA9238D121946AC /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */; };
8387093D19FB3397CCB8FEF8 /* WenzaWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF528FCC224EF283F95851AD /* WenzaWatchApp.swift */; };
83EA218392952885C97144D1 /* TodayMenuFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */; };
+ 87564C27F644ED9CFF1E517B /* AnalyticsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E8855698633CC9A1172AEF1 /* AnalyticsStoreTests.swift */; };
8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23A4491BFA50721082024756 /* SharedComponents.swift */; };
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3D5289C5F93484E22DEB63 /* TutorialView.swift */; };
8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; };
9070521B1D36A5551976C275 /* CalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF6CCD95A587E26E30F5712 /* CalendarView.swift */; };
+ 9AA3E4808E88CA639BF3F28B /* AnalyticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C6CEBCB6041EA41ED482F67 /* AnalyticsView.swift */; };
9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */; };
A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */; };
A292B0492135BA40F6B6A951 /* CalendarGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FCB2077D9509CB5C0978E58 /* CalendarGrid.swift */; };
A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72308FEE0226F45414C04DDD /* SettingsView.swift */; };
A9FF93259AE8FF0ABF69D71A /* DesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4A35C0E1270E2E15C03F23 /* DesignTokens.swift */; };
AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */; };
+ B0B83389E72D6DF4563D3DD4 /* AnalyticsAdapterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713DAF7EA146A779E4AC1D1A /* AnalyticsAdapterTests.swift */; };
+ B39F302F10FA4899AA0A5BAC /* AnalyticsDeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A32192C8620C6AB8499869F /* AnalyticsDeepLink.swift */; };
BD0DB4B0AA8A63D124EDFF2C /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5AFD143B693B77D07FBDA4 /* HealthKitManager.swift */; };
BEAFF968632A34C70B11C5AC /* MatrixView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D44530A77DF12A17E52AAF34 /* MatrixView.swift */; };
+ C69A21EC244513185A1F59BD /* WorkoutAnalyticsAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E839E4F5E6272515C3EA14D7 /* WorkoutAnalyticsAdapter.swift */; };
C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */; };
C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C786EBC7DF879D64EB28165E /* TodayView.swift */; };
CBE7295BF5ADE08FE93AFAAF /* FloatingTabState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9100804DB1E61EA882CC54DA /* FloatingTabState.swift */; };
+ CD3B0C436EA2D013FC3A6B5B /* RecurrenceRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A0B871AB37123908CF0CB20 /* RecurrenceRule.swift */; };
+ D1F2189D03CFF743A534A0C6 /* ActivityHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F0950E6F31016C848B2A63 /* ActivityHistoryView.swift */; };
D591A72235A53D4038FBC2B4 /* KisaniCalApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */; };
+ E135F5118E1D006F03AE2178 /* StreakLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D230156A72AAFBBAB7626629 /* StreakLogicTests.swift */; };
+ E6FCFC0D429627201A52C616 /* AnalyticsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10BD058FC3875B6BD6B10C90 /* AnalyticsStore.swift */; };
ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD014B7E3E30A34E18696A0 /* AuthView.swift */; };
EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */; };
EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */; };
+ FC3D7406706ADC96500AB764 /* AnalyticsCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0E5E96C6FC5DFC4E76452BF /* AnalyticsCoordinatorTests.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -91,19 +108,25 @@
/* Begin PBXFileReference section */
001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalApp.swift; sourceTree = ""; };
01A27D42E141DC056D32C1A3 /* TutorialManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialManager.swift; sourceTree = ""; };
+ 0379128BF8B6C1CA7DF026D0 /* AnalyticsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsModels.swift; sourceTree = ""; };
0506183945D16EC443A69651 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; };
0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskLiveActivity.swift; sourceTree = ""; };
0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = ""; };
0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = KisaniCalWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
0FCB2077D9509CB5C0978E58 /* CalendarGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarGrid.swift; sourceTree = ""; };
106EEF572C6F8990408329F0 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; };
+ 10BD058FC3875B6BD6B10C90 /* AnalyticsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsStore.swift; sourceTree = ""; };
1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; sourceTree = ""; };
+ 1C6CEBCB6041EA41ED482F67 /* AnalyticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsView.swift; sourceTree = ""; };
208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyTasksWidget.swift; sourceTree = ""; };
20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutView.swift; sourceTree = ""; };
20E29A9264577A89891DAEC1 /* KisaniCalTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = KisaniCalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
23A4491BFA50721082024756 /* SharedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedComponents.swift; sourceTree = ""; };
326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutHandler.swift; sourceTree = ""; };
35FF274EFF2361B7F3D2D227 /* WenzaWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WenzaWatch.entitlements; sourceTree = ""; };
+ 3A0B871AB37123908CF0CB20 /* RecurrenceRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurrenceRule.swift; sourceTree = ""; };
+ 3A22FCC3FA1A10F52415E5AF /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = ""; };
+ 3E8855698633CC9A1172AEF1 /* AnalyticsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsStoreTests.swift; sourceTree = ""; };
42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalWidgets.swift; sourceTree = ""; };
429806CE1021C8DE2EB770CE /* WidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetViews.swift; sourceTree = ""; };
449C34805DC6B2CB66886544 /* CloudSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSyncManager.swift; sourceTree = ""; };
@@ -112,20 +135,27 @@
57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetData.swift; sourceTree = ""; };
670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskContextMenu.swift; sourceTree = ""; };
+ 69F0950E6F31016C848B2A63 /* ActivityHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityHistoryView.swift; sourceTree = ""; };
+ 713DAF7EA146A779E4AC1D1A /* AnalyticsAdapterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsAdapterTests.swift; sourceTree = ""; };
72308FEE0226F45414C04DDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; };
72FDF9C8DD37134576356B89 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarGridTests.swift; sourceTree = ""; };
768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExerciseSheet.swift; sourceTree = ""; };
+ 78EEE7568265196447E54D6B /* AnalyticsEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsEngine.swift; sourceTree = ""; };
+ 7A32192C8620C6AB8499869F /* AnalyticsDeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsDeepLink.swift; sourceTree = ""; };
7A9D47B284FD6A217AEF813B /* WenzaWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WenzaWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
86906905A4FA7A2384B3E24E /* SpeechRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechRecognizer.swift; sourceTree = ""; };
89550F2CD19B950CCC6AD37F /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = ""; };
8DC8687EA2FBA9FB2EEE51C6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; };
8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCalWidgets.entitlements; sourceTree = ""; };
9100804DB1E61EA882CC54DA /* FloatingTabState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingTabState.swift; sourceTree = ""; };
+ 91A354921717711EB3C0EE8E /* StopCountdownIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopCountdownIntent.swift; sourceTree = ""; };
92824ED40ECD41EFD4F78BEC /* ISSUES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ISSUES.md; sourceTree = ""; };
93D045FE3DEB1D22D908A29F /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; };
9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NLRecurrenceParseTests.swift; sourceTree = ""; };
9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = KisaniCal.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 9DEAB6A9224E02484292362A /* AnalyticsEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsEngineTests.swift; sourceTree = ""; };
+ A0E5E96C6FC5DFC4E76452BF /* AnalyticsCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsCoordinatorTests.swift; sourceTree = ""; };
ADF6CCD95A587E26E30F5712 /* CalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarView.swift; sourceTree = ""; };
AF905C574F34B4EE51A8D21E /* AppGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroup.swift; sourceTree = ""; };
B4CFFDFE4653A9E901CEF28D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; };
@@ -134,9 +164,12 @@
BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = ""; };
C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; };
C786EBC7DF879D64EB28165E /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = ""; };
+ D230156A72AAFBBAB7626629 /* StreakLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakLogicTests.swift; sourceTree = ""; };
D44530A77DF12A17E52AAF34 /* MatrixView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixView.swift; sourceTree = ""; };
+ D4FB8B5BFF3FF62728E9498A /* AnalyticsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsCoordinator.swift; sourceTree = ""; };
D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurrenceTests.swift; sourceTree = ""; };
DCC2AFB4FA765383740767CB /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = ""; };
+ E839E4F5E6272515C3EA14D7 /* WorkoutAnalyticsAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutAnalyticsAdapter.swift; sourceTree = ""; };
ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayMenuFeatures.swift; sourceTree = ""; };
FA3D5289C5F93484E22DEB63 /* TutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialView.swift; sourceTree = ""; };
FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventCountdownWidget.swift; sourceTree = ""; };
@@ -149,9 +182,14 @@
068B77B2F01C399C7A430292 /* KisaniCalTests */ = {
isa = PBXGroup;
children = (
+ 713DAF7EA146A779E4AC1D1A /* AnalyticsAdapterTests.swift */,
+ A0E5E96C6FC5DFC4E76452BF /* AnalyticsCoordinatorTests.swift */,
+ 9DEAB6A9224E02484292362A /* AnalyticsEngineTests.swift */,
+ 3E8855698633CC9A1172AEF1 /* AnalyticsStoreTests.swift */,
74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */,
9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */,
D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */,
+ D230156A72AAFBBAB7626629 /* StreakLogicTests.swift */,
);
path = KisaniCalTests;
sourceTree = "";
@@ -165,6 +203,14 @@
path = Components;
sourceTree = "";
};
+ 2217B7EEC45957B820311EC7 /* Shared */ = {
+ isa = PBXGroup;
+ children = (
+ 3A0B871AB37123908CF0CB20 /* RecurrenceRule.swift */,
+ );
+ path = Shared;
+ sourceTree = "";
+ };
23CBCF100C5EF55E737379CA /* Models */ = {
isa = PBXGroup;
children = (
@@ -191,6 +237,7 @@
F70DA4746C68CD405435DAB6 /* KisaniCal */,
068B77B2F01C399C7A430292 /* KisaniCalTests */,
E8D727EEA0C3A4B8006FB087 /* KisaniCalWidgets */,
+ 2217B7EEC45957B820311EC7 /* Shared */,
48146B56E740528496663D47 /* WenzaWatch */,
51BD1B5DEDE9FAD9CA2FF6DA /* Products */,
);
@@ -210,7 +257,9 @@
5E9A0E064E153429180400E6 /* Views */ = {
isa = PBXGroup;
children = (
+ 69F0950E6F31016C848B2A63 /* ActivityHistoryView.swift */,
768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */,
+ 1C6CEBCB6041EA41ED482F67 /* AnalyticsView.swift */,
4AD014B7E3E30A34E18696A0 /* AuthView.swift */,
ADF6CCD95A587E26E30F5712 /* CalendarView.swift */,
D44530A77DF12A17E52AAF34 /* MatrixView.swift */,
@@ -235,6 +284,20 @@
path = "Preview Content";
sourceTree = "";
};
+ C7409CC354029293D424BEDA /* Analytics */ = {
+ isa = PBXGroup;
+ children = (
+ D4FB8B5BFF3FF62728E9498A /* AnalyticsCoordinator.swift */,
+ 7A32192C8620C6AB8499869F /* AnalyticsDeepLink.swift */,
+ 78EEE7568265196447E54D6B /* AnalyticsEngine.swift */,
+ 0379128BF8B6C1CA7DF026D0 /* AnalyticsModels.swift */,
+ 3A22FCC3FA1A10F52415E5AF /* AnalyticsService.swift */,
+ 10BD058FC3875B6BD6B10C90 /* AnalyticsStore.swift */,
+ E839E4F5E6272515C3EA14D7 /* WorkoutAnalyticsAdapter.swift */,
+ );
+ path = Analytics;
+ sourceTree = "";
+ };
E0A03E79A679740978E61BF1 /* Theme */ = {
isa = PBXGroup;
children = (
@@ -251,6 +314,7 @@
8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */,
42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */,
208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */,
+ 91A354921717711EB3C0EE8E /* StopCountdownIntent.swift */,
0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */,
61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */,
429806CE1021C8DE2EB770CE /* WidgetViews.swift */,
@@ -267,6 +331,7 @@
1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */,
001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */,
C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */,
+ C7409CC354029293D424BEDA /* Analytics */,
21B93C269F283F11B415B18C /* Components */,
FB9BF734B9E493EEB09ACE21 /* Managers */,
23CBCF100C5EF55E737379CA /* Models */,
@@ -436,9 +501,14 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ B0B83389E72D6DF4563D3DD4 /* AnalyticsAdapterTests.swift in Sources */,
+ FC3D7406706ADC96500AB764 /* AnalyticsCoordinatorTests.swift in Sources */,
+ 550E9A86BE36228C705E91C1 /* AnalyticsEngineTests.swift in Sources */,
+ 87564C27F644ED9CFF1E517B /* AnalyticsStoreTests.swift in Sources */,
6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */,
16FFC465D8646DED9A5C69D1 /* NLRecurrenceParseTests.swift in Sources */,
4FB79BBC1EE0F6C7EF844B02 /* RecurrenceTests.swift in Sources */,
+ E135F5118E1D006F03AE2178 /* StreakLogicTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -454,7 +524,15 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ D1F2189D03CFF743A534A0C6 /* ActivityHistoryView.swift in Sources */,
A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */,
+ 023A636FF533304DA5578D1C /* AnalyticsCoordinator.swift in Sources */,
+ B39F302F10FA4899AA0A5BAC /* AnalyticsDeepLink.swift in Sources */,
+ 152AECD42FCF554C582E2DEA /* AnalyticsEngine.swift in Sources */,
+ 42E3B90A981E75FFC10C221F /* AnalyticsModels.swift in Sources */,
+ 5F91DDE0B64C8AE1F142F434 /* AnalyticsService.swift in Sources */,
+ E6FCFC0D429627201A52C616 /* AnalyticsStore.swift in Sources */,
+ 9AA3E4808E88CA639BF3F28B /* AnalyticsView.swift in Sources */,
2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */,
3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */,
ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */,
@@ -472,6 +550,7 @@
EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */,
67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */,
5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */,
+ 30D155A1314CBD4BB1744B29 /* RecurrenceRule.swift in Sources */,
A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */,
8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */,
497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */,
@@ -484,6 +563,7 @@
C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */,
3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */,
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */,
+ C69A21EC244513185A1F59BD /* WorkoutAnalyticsAdapter.swift in Sources */,
9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -496,6 +576,8 @@
552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */,
C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */,
3E9DE1CF20BAC479805DF940 /* MyTasksWidget.swift in Sources */,
+ CD3B0C436EA2D013FC3A6B5B /* RecurrenceRule.swift in Sources */,
+ 5D0E254AB5D195AFDE09FABF /* StopCountdownIntent.swift in Sources */,
8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */,
205846F9651EDCE0D8207358 /* TaskLiveActivity.swift in Sources */,
AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */,
@@ -607,7 +689,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
- CURRENT_PROJECT_VERSION = 9;
+ CURRENT_PROJECT_VERSION = 10;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = K8BLMMR883;
ENABLE_NS_ASSERTIONS = NO;
@@ -701,7 +783,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
- CURRENT_PROJECT_VERSION = 9;
+ CURRENT_PROJECT_VERSION = 10;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = K8BLMMR883;
ENABLE_STRICT_OBJC_MSGSEND = YES;
diff --git a/KisaniCal/Analytics/AnalyticsCoordinator.swift b/KisaniCal/Analytics/AnalyticsCoordinator.swift
new file mode 100644
index 0000000..2e79cd0
--- /dev/null
+++ b/KisaniCal/Analytics/AnalyticsCoordinator.swift
@@ -0,0 +1,76 @@
+import Foundation
+
+/// Generates any missing immutable weekly/monthly reports for completed periods,
+/// then prunes beyond the retention window. Idempotent: already-generated periods
+/// are skipped (dedup by store key), so it is safe to run on every launch /
+/// foreground without depending on exact background execution times.
+///
+/// Sample providers are injected so the reconcile logic stays independent of app
+/// models / HealthKit and is unit-testable. The app wires the providers to
+/// `WorkoutAnalyticsAdapter` + `WorkoutViewModel` + `HealthKitManager`.
+final class AnalyticsCoordinator {
+
+ struct ReconcileResult: Equatable {
+ var weekliesGenerated: [String] = []
+ var monthliesGenerated: [String] = []
+ var isEmpty: Bool { weekliesGenerated.isEmpty && monthliesGenerated.isEmpty }
+ }
+
+ /// Samples for a completed period plus its comparison baseline.
+ typealias PeriodSamples = (days: [DaySample], previous: [DaySample])
+
+ private let store: AnalyticsStore
+ private let calendar: Calendar
+ private let now: () -> Date
+ private let retentionMonths: Int
+ private let weekSamples: (_ weekStartKey: String) -> PeriodSamples
+ private let monthSamples: (_ monthKey: String) -> PeriodSamples
+
+ init(store: AnalyticsStore,
+ calendar: Calendar,
+ retentionMonths: Int = 12,
+ now: @escaping () -> Date = Date.init,
+ weekSamples: @escaping (String) -> PeriodSamples,
+ monthSamples: @escaping (String) -> PeriodSamples) {
+ self.store = store
+ self.calendar = calendar
+ self.retentionMonths = retentionMonths
+ self.now = now
+ self.weekSamples = weekSamples
+ self.monthSamples = monthSamples
+ }
+
+ /// Backfill every completed week/month within the retention window that has
+ /// no report yet, then prune expired data. Returns which periods were newly
+ /// generated (empty on a no-op run → drives "should I notify?" decisions).
+ @discardableResult
+ func reconcile() -> ReconcileResult {
+ let ref = now()
+ var result = ReconcileResult()
+
+ let allWeeks = AnalyticsEngine.completedWeekStartKeys(now: ref, months: retentionMonths, calendar: calendar)
+ for key in AnalyticsEngine.missingKeys(allWeeks, existing: store.existingWeeklyKeys()) {
+ let s = weekSamples(key)
+ let report = AnalyticsEngine.weeklyReport(weekStartKey: key, days: s.days,
+ previousWeekDays: s.previous, generatedAt: ref)
+ if store.saveWeeklyReport(report) { result.weekliesGenerated.append(key) }
+ }
+
+ let allMonths = AnalyticsEngine.completedMonthKeys(now: ref, months: retentionMonths, calendar: calendar)
+ for key in AnalyticsEngine.missingKeys(allMonths, existing: store.existingMonthlyKeys()) {
+ let s = monthSamples(key)
+ let report = AnalyticsEngine.monthlyReport(monthKey: key, days: s.days,
+ previousMonthDays: s.previous, generatedAt: ref)
+ if store.saveMonthlyReport(report) { result.monthliesGenerated.append(key) }
+ }
+
+ store.prune(now: ref, months: retentionMonths, calendar: calendar)
+ return result
+ }
+
+ /// The most recent completed weekly / monthly report keys (for "notify about
+ /// the latest report" without duplicating). Deep links for these come from
+ /// `AnalyticsDeepLink`.
+ func latestWeeklyKey() -> String? { store.existingWeeklyKeys().max() }
+ func latestMonthlyKey() -> String? { store.existingMonthlyKeys().max() }
+}
diff --git a/KisaniCal/Analytics/AnalyticsDeepLink.swift b/KisaniCal/Analytics/AnalyticsDeepLink.swift
new file mode 100644
index 0000000..e785817
--- /dev/null
+++ b/KisaniCal/Analytics/AnalyticsDeepLink.swift
@@ -0,0 +1,58 @@
+import Foundation
+
+/// Deep links into the Analytics area — used by weekly/monthly report
+/// notifications to route straight to the relevant report. Pure value type with
+/// symmetric URL encode/decode so routing is testable without any UI.
+///
+/// Format: wenza://analytics → overview
+/// wenza://analytics/weekly/ → a weekly report
+/// wenza://analytics/monthly/ → a monthly report
+enum AnalyticsDeepLink: Equatable, Identifiable {
+ case overview
+ case weekly(weekStartKey: String)
+ case monthly(monthKey: String)
+
+ static let scheme = "wenza"
+ static let host = "analytics"
+
+ var id: String { stringValue }
+
+ /// Stable userInfo key carried on notifications.
+ static let userInfoKey = "kisani.analytics.deeplink"
+
+ var url: URL {
+ var c = URLComponents()
+ c.scheme = Self.scheme
+ c.host = Self.host
+ switch self {
+ case .overview: c.path = ""
+ case .weekly(let key): c.path = "/weekly/\(key)"
+ case .monthly(let key): c.path = "/monthly/\(key)"
+ }
+ return c.url!
+ }
+
+ init?(url: URL) {
+ guard url.scheme == Self.scheme, url.host == Self.host else { return nil }
+ let parts = url.path.split(separator: "/").map(String.init)
+ switch parts.first {
+ case nil:
+ self = .overview
+ case "weekly":
+ guard parts.count >= 2 else { return nil }
+ self = .weekly(weekStartKey: parts[1])
+ case "monthly":
+ guard parts.count >= 2 else { return nil }
+ self = .monthly(monthKey: parts[1])
+ default:
+ return nil
+ }
+ }
+
+ /// Build/parse via a plain string (for notification userInfo payloads).
+ var stringValue: String { url.absoluteString }
+ init?(string: String) {
+ guard let url = URL(string: string) else { return nil }
+ self.init(url: url)
+ }
+}
diff --git a/KisaniCal/Analytics/AnalyticsEngine.swift b/KisaniCal/Analytics/AnalyticsEngine.swift
new file mode 100644
index 0000000..2ee775e
--- /dev/null
+++ b/KisaniCal/Analytics/AnalyticsEngine.swift
@@ -0,0 +1,330 @@
+import Foundation
+
+// Deterministic, side-effect-free analytics calculations. No SwiftUI, no
+// persistence, no HealthKit, no LLM — pure functions over value types so every
+// formula is unit-testable and reproducible. All calendar math takes an injected
+// `Calendar` so locale / firstWeekday / timezone / DST behavior is testable.
+//
+// FORMULAS
+// volume = Σ (weight × reps) over completed sets [kg]
+// percentChange = (current − baseline) / |baseline| × 100 [%]
+// consistency = min(1, sessions / scheduled) [0…1]
+// trend = classify(least-squares slope / |mean|) vs threshold
+//
+// All comparisons degrade honestly: missing baseline → no %, tiny/zero baseline
+// → no % (avoids divide-by-tiny), new exercise / rest day → not meaningful.
+// Language is factual and supportive; nothing here diagnoses or advises medically.
+
+enum AnalyticsEngine {
+
+ /// Informational, non-medical disclaimer surfaced with every report.
+ static let disclaimer = """
+ These are informational summaries of your own logged activity — not medical \
+ advice, diagnosis, or treatment. Talk to a qualified professional for health \
+ decisions.
+ """
+
+ // MARK: - Exercise identity
+
+ /// Normalized identity so the same exercise matches across days regardless of
+ /// casing / whitespace. Deterministic and locale-independent.
+ static func exerciseIdentity(_ name: String) -> String {
+ name.trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased()
+ .replacingOccurrences(of: " ", with: " ")
+ }
+
+ // MARK: - Delta
+
+ /// Compare `current` against `baseline`, guarding percentage honesty.
+ /// - `isNew`: no prior baseline exists (e.g. a newly added exercise).
+ /// - `restDay`: baseline day was a rest day → % is not meaningful.
+ static func delta(current: Double,
+ baseline: Double?,
+ minMeaningfulBaseline: Double = 0.0001,
+ isNew: Bool = false,
+ restDay: Bool = false) -> MetricDelta {
+ guard let base = baseline, !isNew, !restDay else {
+ return MetricDelta(current: current,
+ baseline: baseline,
+ absolute: baseline.map { current - $0 },
+ percent: nil,
+ direction: current > 0 ? .up : .flat,
+ meaningful: false)
+ }
+ let abs = current - base
+ let dir: MetricDirection = abs > 0 ? .up : (abs < 0 ? .down : .flat)
+ // Only compute % when the baseline magnitude is meaningful.
+ let pct: Double? = Swift.abs(base) >= minMeaningfulBaseline ? (abs / Swift.abs(base)) * 100 : nil
+ return MetricDelta(current: current,
+ baseline: base,
+ absolute: abs,
+ percent: pct,
+ direction: dir,
+ meaningful: pct != nil)
+ }
+
+ /// Classify a single week/month-over-period change from its volume delta.
+ static func classifyChange(_ d: MetricDelta, plateauPct: Double = 3) -> TrendClassification {
+ guard d.meaningful, let pct = d.percent else { return .insufficientData }
+ if pct > plateauPct { return .improving }
+ if pct < -plateauPct { return .declining }
+ return .plateau
+ }
+
+ // MARK: - Consistency
+
+ static func consistency(sessions: Int, scheduled: Int) -> Double {
+ guard scheduled > 0 else { return 0 }
+ return min(1.0, Double(sessions) / Double(scheduled))
+ }
+
+ // MARK: - Volume
+
+ static func volume(weight: Double, reps: Int) -> Double { weight * Double(reps) }
+
+ // MARK: - Trend (multi-point, for 12-month views)
+
+ static func classifyTrend(_ values: [Double],
+ minPoints: Int = 3,
+ relativeThreshold: Double = 0.03) -> TrendResult {
+ let n = values.count
+ guard n >= minPoints else {
+ return TrendResult(classification: .insufficientData, slope: 0, points: n)
+ }
+ let slope = leastSquaresSlope(values)
+ let mean = values.reduce(0, +) / Double(n)
+ guard Swift.abs(mean) > 0 else {
+ let cls: TrendClassification = slope == 0 ? .plateau : (slope > 0 ? .improving : .declining)
+ return TrendResult(classification: cls, slope: slope, points: n)
+ }
+ let rel = slope / Swift.abs(mean)
+ let cls: TrendClassification
+ if rel > relativeThreshold { cls = .improving }
+ else if rel < -relativeThreshold { cls = .declining }
+ else { cls = .plateau }
+ return TrendResult(classification: cls, slope: slope, points: n)
+ }
+
+ static func leastSquaresSlope(_ y: [Double]) -> Double {
+ let n = Double(y.count)
+ guard n > 1 else { return 0 }
+ let xs = (0.. [Double] {
+ guard values.count >= 5 else { return values }
+ let sorted = values.sorted()
+ let lo = percentile(sorted, lower)
+ let hi = percentile(sorted, upper)
+ return values.map { min(max($0, lo), hi) }
+ }
+
+ static func percentile(_ sorted: [Double], _ p: Double) -> Double {
+ guard !sorted.isEmpty else { return 0 }
+ let idx = Int((Double(sorted.count - 1) * min(max(p, 0), 1)).rounded())
+ return sorted[min(max(idx, 0), sorted.count - 1)]
+ }
+
+ // MARK: - Exercise comparison
+
+ static func compareExercises(current: [ExerciseSample],
+ baseline: [ExerciseSample],
+ restDay: Bool = false) -> [ExerciseComparison] {
+ let baseByIdentity = Dictionary(baseline.map { ($0.identity, $0) }, uniquingKeysWith: { a, _ in a })
+ return current.map { cur in
+ let base = baseByIdentity[cur.identity]
+ let isNew = base == nil
+ return ExerciseComparison(
+ identity: cur.identity,
+ volume: delta(current: cur.volume, baseline: base?.volume, isNew: isNew, restDay: restDay),
+ sets: delta(current: Double(cur.sets), baseline: base.map { Double($0.sets) }, isNew: isNew, restDay: restDay),
+ reps: delta(current: Double(cur.reps), baseline: base.map { Double($0.reps) }, isNew: isNew, restDay: restDay),
+ isNew: isNew
+ )
+ }
+ }
+
+ // MARK: - Daily comparison
+
+ static func dailyComparison(day: DaySample,
+ previousDay: DaySample?,
+ sameWeekdayLastWeek: DaySample?) -> DailyComparison {
+ DailyComparison(
+ dateKey: day.dateKey,
+ restDay: !day.didWorkout,
+ vsPreviousDay: compareExercises(current: day.exercises,
+ baseline: previousDay?.exercises ?? [],
+ restDay: previousDay.map { !$0.didWorkout } ?? true),
+ vsSameWeekdayLastWeek: compareExercises(current: day.exercises,
+ baseline: sameWeekdayLastWeek?.exercises ?? [],
+ restDay: sameWeekdayLastWeek.map { !$0.didWorkout } ?? true)
+ )
+ }
+
+ // MARK: - Period summaries & reports
+
+ static func summarize(_ days: [DaySample]) -> PeriodSummary {
+ let sessions = days.filter { $0.didWorkout }.count
+ let scheduled = days.filter { $0.scheduled }.count
+ let missed = days.filter { $0.scheduled && !$0.didWorkout }.count
+ let sets = days.reduce(0) { $0 + $1.sets }
+ let volume = days.reduce(0) { $0 + $1.volume }
+ let duration = days.reduce(0) { $0 + $1.durationMinutes }
+ func avg(_ vals: [Int]) -> Int? { vals.isEmpty ? nil : Int((Double(vals.reduce(0, +)) / Double(vals.count)).rounded()) }
+ return PeriodSummary(sessions: sessions, sets: sets, volume: volume,
+ durationMinutes: duration, scheduled: scheduled,
+ missed: missed, consistency: consistency(sessions: sessions, scheduled: scheduled),
+ avgSteps: avg(days.compactMap { $0.steps }),
+ avgActiveCalories: avg(days.compactMap { $0.activeCalories }),
+ avgRestingHR: avg(days.compactMap { $0.restingHeartRate }))
+ }
+
+ private static func periodDeltas(current cur: PeriodSummary, previous prev: PeriodSummary) -> [String: MetricDelta] {
+ [
+ "sessions": delta(current: Double(cur.sessions), baseline: Double(prev.sessions)),
+ "sets": delta(current: Double(cur.sets), baseline: Double(prev.sets)),
+ "volume": delta(current: cur.volume, baseline: prev.volume),
+ "duration": delta(current: cur.durationMinutes, baseline: prev.durationMinutes),
+ "consistency": delta(current: cur.consistency * 100, baseline: prev.consistency * 100),
+ ]
+ }
+
+ static func weeklyReport(weekStartKey: String,
+ days: [DaySample],
+ previousWeekDays: [DaySample],
+ generatedAt: Date) -> WeeklyReport {
+ let cur = summarize(days)
+ let prev = summarize(previousWeekDays)
+ let deltas = periodDeltas(current: cur, previous: prev)
+ return WeeklyReport(
+ weekStartKey: weekStartKey,
+ generatedAt: generatedAt,
+ summary: cur,
+ vsPreviousWeek: deltas,
+ progression: classifyChange(deltas["volume"] ?? delta(current: 0, baseline: nil)),
+ disclaimer: disclaimer
+ )
+ }
+
+ static func monthlyReport(monthKey: String,
+ days: [DaySample],
+ previousMonthDays: [DaySample],
+ generatedAt: Date) -> MonthlyReport {
+ let cur = summarize(days)
+ let prev = summarize(previousMonthDays)
+ let deltas = periodDeltas(current: cur, previous: prev)
+ return MonthlyReport(
+ monthKey: monthKey,
+ generatedAt: generatedAt,
+ summary: cur,
+ vsPreviousMonth: deltas,
+ classification: classifyChange(deltas["volume"] ?? delta(current: 0, baseline: nil)),
+ disclaimer: disclaimer
+ )
+ }
+
+ // MARK: - Calendar helpers (injected Calendar → locale/TZ/DST-safe)
+
+ private static func keyFormatter(_ calendar: Calendar) -> DateFormatter {
+ let f = DateFormatter()
+ f.calendar = calendar
+ f.locale = Locale(identifier: "en_US_POSIX")
+ f.timeZone = calendar.timeZone
+ f.dateFormat = "yyyy-MM-dd"
+ return f
+ }
+
+ static func dateKey(for date: Date, calendar: Calendar) -> String {
+ keyFormatter(calendar).string(from: calendar.startOfDay(for: date))
+ }
+
+ static func monthKey(for date: Date, calendar: Calendar) -> String {
+ let f = keyFormatter(calendar); f.dateFormat = "yyyy-MM"
+ return f.string(from: date)
+ }
+
+ static func weekInterval(containing date: Date, calendar: Calendar) -> DateInterval? {
+ calendar.dateInterval(of: .weekOfYear, for: date)
+ }
+
+ static func monthInterval(containing date: Date, calendar: Calendar) -> DateInterval? {
+ calendar.dateInterval(of: .month, for: date)
+ }
+
+ /// The same weekday one week earlier — used for the day-vs-last-week compare.
+ static func sameWeekdayLastWeek(_ date: Date, calendar: Calendar) -> Date? {
+ calendar.date(byAdding: .weekOfYear, value: -1, to: date)
+ }
+
+ /// Whether `date` falls in a completed period (strictly before the period
+ /// containing `now`) — reports are only generated for completed periods.
+ static func isCompletedWeek(_ date: Date, now: Date, calendar: Calendar) -> Bool {
+ guard let w = weekInterval(containing: date, calendar: calendar) else { return false }
+ return w.end <= (weekInterval(containing: now, calendar: calendar)?.start ?? now)
+ }
+
+ static func isCompletedMonth(_ date: Date, now: Date, calendar: Calendar) -> Bool {
+ guard let m = monthInterval(containing: date, calendar: calendar) else { return false }
+ return m.end <= (monthInterval(containing: now, calendar: calendar)?.start ?? now)
+ }
+
+ // MARK: - Retention
+
+ /// Keys older than `months` before `now` (for safe pruning of raw data).
+ static func expiredDayKeys(_ keys: [String], now: Date, months: Int = 12, calendar: Calendar) -> [String] {
+ guard let cutoff = calendar.date(byAdding: .month, value: -months, to: calendar.startOfDay(for: now)) else { return [] }
+ let cutoffKey = dateKey(for: cutoff, calendar: calendar)
+ return keys.filter { $0 < cutoffKey }
+ }
+
+ // MARK: - Reconciliation selection (which completed periods need a report)
+
+ /// Week-start keys of every completed week within the last `months`, oldest→newest.
+ /// Only weeks whose start is on/after the retention cutoff are included, so
+ /// generation and `prune` agree — otherwise a boundary week would be
+ /// generated then immediately pruned, regenerating on every run.
+ static func completedWeekStartKeys(now: Date, months: Int = 12, calendar cal: Calendar) -> [String] {
+ guard let windowStart = cal.date(byAdding: .month, value: -months, to: now),
+ let thisWeek = weekInterval(containing: now, calendar: cal),
+ var cursor = weekInterval(containing: windowStart, calendar: cal)?.start else { return [] }
+ let cutoffKey = dateKey(for: cal.startOfDay(for: windowStart), calendar: cal)
+ var keys: [String] = []
+ var guardCount = 0
+ while cursor < thisWeek.start, guardCount < 70 {
+ let key = dateKey(for: cursor, calendar: cal)
+ if key >= cutoffKey { keys.append(key) }
+ guard let next = cal.date(byAdding: .weekOfYear, value: 1, to: cursor) else { break }
+ cursor = next; guardCount += 1
+ }
+ return keys
+ }
+
+ /// Month keys of every completed month within the last `months`, oldest→newest.
+ static func completedMonthKeys(now: Date, months: Int = 12, calendar cal: Calendar) -> [String] {
+ var keys: [String] = []
+ for m in stride(from: months, through: 1, by: -1) {
+ guard let d = cal.date(byAdding: .month, value: -m, to: now) else { continue }
+ let key = monthKey(for: d, calendar: cal)
+ if !keys.contains(key) { keys.append(key) }
+ }
+ return keys
+ }
+
+ /// Keys present in `all` but not in `existing` — the periods still needing a
+ /// report. Idempotent by construction: already-generated periods are skipped.
+ static func missingKeys(_ all: [String], existing: Set) -> [String] {
+ all.filter { !existing.contains($0) }
+ }
+}
diff --git a/KisaniCal/Analytics/AnalyticsModels.swift b/KisaniCal/Analytics/AnalyticsModels.swift
new file mode 100644
index 0000000..32c41fe
--- /dev/null
+++ b/KisaniCal/Analytics/AnalyticsModels.swift
@@ -0,0 +1,171 @@
+import Foundation
+
+// Pure value types for the analytics system. No SwiftUI, no persistence — these
+// are the inputs and outputs of `AnalyticsEngine`, kept decoupled from app
+// models so the engine is deterministic and unit-testable in isolation.
+// Adapters from WorkoutDayLog / HealthKit live in the persistence layer.
+
+// MARK: - Direction & trend
+
+/// Semantic direction of a change. Paired in UI with a non-color glyph so status
+/// is never conveyed by color alone.
+enum MetricDirection: String, Codable, Equatable {
+ case up, down, flat, none
+}
+
+enum TrendClassification: String, Codable, Equatable {
+ case improving, declining, plateau, insufficientData
+}
+
+struct TrendResult: Codable, Equatable {
+ let classification: TrendClassification
+ let slope: Double // least-squares slope, units per period
+ let points: Int
+}
+
+// MARK: - Unit compatibility
+
+/// Metrics are only comparable within the same physical dimension. Prevents
+/// nonsense like comparing kilograms to minutes.
+enum MetricUnit: String, Codable, Equatable {
+ case kilograms, pounds, count, minutes, kilometers, miles, kilocalories, bpm, percent, none
+
+ var dimension: String {
+ switch self {
+ case .kilograms, .pounds: return "mass"
+ case .kilometers, .miles: return "distance"
+ case .minutes: return "time"
+ case .kilocalories: return "energy"
+ case .bpm: return "rate"
+ case .count: return "count"
+ case .percent: return "ratio"
+ case .none: return "none"
+ }
+ }
+
+ func isComparable(with other: MetricUnit) -> Bool { dimension == other.dimension }
+}
+
+// MARK: - Delta
+
+/// One metric compared against a baseline. `percent` is nil when there is no
+/// baseline, the baseline is too small to be meaningful, or the comparison is
+/// otherwise not honest (new item / rest day). `meaningful` gates UI display.
+struct MetricDelta: Codable, Equatable {
+ let current: Double
+ let baseline: Double?
+ let absolute: Double?
+ let percent: Double?
+ let direction: MetricDirection
+ let meaningful: Bool
+}
+
+// MARK: - Samples (engine inputs)
+
+/// A single exercise's contribution on a day, keyed by a normalized identity.
+struct ExerciseSample: Codable, Equatable {
+ let identity: String // normalized identity key (see AnalyticsEngine.exerciseIdentity)
+ let sets: Int
+ let reps: Int // total reps across completed sets
+ let volume: Double // Σ weight×reps of completed sets, kg
+}
+
+/// A day of activity: app workout record + optional HealthKit reference values.
+/// HealthKit fields are optional because authorization/data may be missing.
+struct DaySample: Codable, Equatable {
+ let dateKey: String // "yyyy-MM-dd" in the reporting calendar
+ let didWorkout: Bool
+ let scheduled: Bool
+ let sessions: Int
+ let sets: Int
+ let volume: Double
+ let durationMinutes: Double
+ let exercises: [ExerciseSample]
+ // HealthKit reference (source of truth for health measurements) — may be nil.
+ let steps: Int?
+ let activeCalories: Int?
+ let restingHeartRate: Int?
+
+ init(dateKey: String, didWorkout: Bool = false, scheduled: Bool = false,
+ sessions: Int = 0, sets: Int = 0, volume: Double = 0, durationMinutes: Double = 0,
+ exercises: [ExerciseSample] = [], steps: Int? = nil,
+ activeCalories: Int? = nil, restingHeartRate: Int? = nil) {
+ self.dateKey = dateKey
+ self.didWorkout = didWorkout
+ self.scheduled = scheduled
+ self.sessions = sessions
+ self.sets = sets
+ self.volume = volume
+ self.durationMinutes = durationMinutes
+ self.exercises = exercises
+ self.steps = steps
+ self.activeCalories = activeCalories
+ self.restingHeartRate = restingHeartRate
+ }
+}
+
+// MARK: - Comparisons & reports (engine outputs; immutable snapshots)
+
+struct ExerciseComparison: Codable, Equatable {
+ let identity: String
+ let volume: MetricDelta
+ let sets: MetricDelta
+ let reps: MetricDelta
+ let isNew: Bool
+}
+
+struct DailyComparison: Codable, Equatable {
+ let dateKey: String
+ let restDay: Bool
+ let vsPreviousDay: [ExerciseComparison]
+ let vsSameWeekdayLastWeek: [ExerciseComparison]
+}
+
+struct PeriodSummary: Codable, Equatable {
+ let sessions: Int
+ let sets: Int
+ let volume: Double
+ let durationMinutes: Double
+ let scheduled: Int
+ let missed: Int
+ let consistency: Double // 0...1
+ // Available HealthKit trends — nil when no reference data in the period.
+ var avgSteps: Int? = nil
+ var avgActiveCalories: Int? = nil
+ var avgRestingHR: Int? = nil
+}
+
+/// Immutable weekly report. Once generated with `generatedAt`, later edits to
+/// source data never change it (the persistence layer stores it frozen).
+struct WeeklyReport: Codable, Equatable, Identifiable {
+ let weekStartKey: String // "yyyy-MM-dd" of the week's first day
+ let generatedAt: Date
+ let summary: PeriodSummary
+ let vsPreviousWeek: [String: MetricDelta]
+ let progression: TrendClassification
+ let disclaimer: String
+ var id: String { weekStartKey }
+}
+
+// Chart-friendly Identifiable points (tuples can't be keyed in SwiftUI Charts).
+struct MonthVolumePoint: Identifiable, Equatable {
+ let monthKey: String
+ let volume: Double
+ var id: String { monthKey }
+}
+
+struct ExerciseVolumePoint: Identifiable, Equatable {
+ let dateKey: String
+ let volume: Double
+ var id: String { dateKey }
+}
+
+struct MonthlyReport: Codable, Equatable, Identifiable {
+ let monthKey: String // "yyyy-MM"
+ let generatedAt: Date
+ let summary: PeriodSummary
+ let vsPreviousMonth: [String: MetricDelta]
+ let classification: TrendClassification
+ let disclaimer: String
+ var id: String { monthKey }
+}
diff --git a/KisaniCal/Analytics/AnalyticsService.swift b/KisaniCal/Analytics/AnalyticsService.swift
new file mode 100644
index 0000000..decc80e
--- /dev/null
+++ b/KisaniCal/Analytics/AnalyticsService.swift
@@ -0,0 +1,190 @@
+import Foundation
+
+// App-side glue for the analytics system. Owns the file store, builds the
+// reconcile coordinator from a snapshot of live workout data, and exposes read
+// APIs for the Analytics UI. Pure engine/store/coordinator/adapter are unit-
+// tested; this layer is thin wiring (compiles in-target; not standalone-tested).
+//
+// Sample building is done by nonisolated static functions over a captured
+// `WorkoutSnapshot` value, so the coordinator's closures don't touch main-actor
+// state. HealthKit per-day historical reference is optional and currently left
+// nil (honest — never fabricated); wiring it is a documented follow-up.
+@MainActor
+final class AnalyticsService: ObservableObject {
+ static let shared = AnalyticsService()
+
+ let store: AnalyticsStore
+ private let calendar: Calendar
+
+ @Published private(set) var lastReconcile: Date?
+ /// Snapshot from the most recent reconcile, for UI reads (daily comparison,
+ /// per-exercise history) without passing the view model around.
+ private var snapshot = WorkoutSnapshot()
+
+ init(store: AnalyticsStore = AnalyticsStore(), calendar: Calendar = .current) {
+ self.store = store
+ self.calendar = calendar
+ }
+
+ // MARK: - Snapshot of live workout data (a value type)
+
+ struct WorkoutSnapshot {
+ var logByDate: [String: WorkoutDayLog] = [:]
+ var workoutDates: Set = []
+ var scheduledWeekdays: Set = []
+ var missedDates: Set = []
+ var healthByDate: [String: RawHealthDay] = [:] // HealthKit reference
+
+ init() {}
+ @MainActor init(vm: WorkoutViewModel) {
+ self.logByDate = Dictionary(vm.history.map { ($0.date, $0) }, uniquingKeysWith: { a, _ in a })
+ self.workoutDates = Set(vm.workoutDates)
+ self.scheduledWeekdays = Set(vm.schedule.keys)
+ self.missedDates = Set(vm.missedDates)
+ }
+ }
+
+ /// Cached HealthKit per-day reference (refreshed async). Merged into snapshots.
+ private var healthByDate: [String: RawHealthDay] = [:]
+
+ // MARK: - Reconcile (call on launch + scenePhase active)
+
+ /// Snapshot recent days, backfill missing completed weekly/monthly reports,
+ /// prune. Idempotent — safe to call every launch/foreground.
+ @discardableResult
+ func reconcile(using vm: WorkoutViewModel, now: Date = Date()) -> AnalyticsCoordinator.ReconcileResult {
+ var snap = WorkoutSnapshot(vm: vm)
+ snap.healthByDate = healthByDate
+ snapshot = snap
+ freezeRecentSnapshots(snap, now: now)
+ let result = makeCoordinator(snap, now: now).reconcile()
+ lastReconcile = now
+ return result
+ }
+
+ /// Refresh the HealthKit per-day reference cache (~13 months). Call from the
+ /// app's async Health sync path, then reconcile so new snapshots capture it.
+ func refreshHealthReference() async {
+ guard let start = calendar.date(byAdding: .month, value: -13, to: Date()) else { return }
+ let raw = await HealthKitManager.shared.dailyReference(from: start, to: Date())
+ healthByDate = raw.mapValues { RawHealthDay(steps: $0.steps, activeCalories: $0.calories, restingHeartRate: $0.restingHR) }
+ }
+
+ private func makeCoordinator(_ snap: WorkoutSnapshot, now: Date) -> AnalyticsCoordinator {
+ let cal = calendar
+ return AnalyticsCoordinator(
+ store: store, calendar: cal, retentionMonths: 12, now: { now },
+ weekSamples: { key in
+ let start = Self.date(fromKey: key, calendar: cal) ?? now
+ let prevStart = cal.date(byAdding: .weekOfYear, value: -1, to: start) ?? start
+ return (Self.buildSamples(WorkoutAnalyticsAdapter.weekDayKeys(containing: start, calendar: cal), snap, cal),
+ Self.buildSamples(WorkoutAnalyticsAdapter.weekDayKeys(containing: prevStart, calendar: cal), snap, cal))
+ },
+ monthSamples: { key in
+ guard let start = Self.date(fromMonthKey: key, calendar: cal) else { return ([], []) }
+ let prevStart = cal.date(byAdding: .month, value: -1, to: start) ?? start
+ return (Self.buildSamples(WorkoutAnalyticsAdapter.monthDayKeys(containing: start, calendar: cal), snap, cal),
+ Self.buildSamples(WorkoutAnalyticsAdapter.monthDayKeys(containing: prevStart, calendar: cal), snap, cal))
+ })
+ }
+
+ /// Freeze immutable snapshots for completed recent days; refresh today's live.
+ private func freezeRecentSnapshots(_ snap: WorkoutSnapshot, now: Date) {
+ let today = calendar.startOfDay(for: now)
+ for back in 0...8 {
+ guard let d = calendar.date(byAdding: .day, value: -back, to: today) else { continue }
+ let key = AnalyticsEngine.dateKey(for: d, calendar: calendar)
+ store.saveSnapshot(Self.buildSamples([key], snap, calendar).first ?? DaySample(dateKey: key), isPast: back > 0)
+ }
+ }
+
+ // MARK: - Sample building (nonisolated, pure over the snapshot value)
+
+ nonisolated static func buildSamples(_ keys: [String], _ snap: WorkoutSnapshot, _ cal: Calendar) -> [DaySample] {
+ var workouts: [String: RawWorkoutDay] = [:]
+ var scheduled: Set = []
+ for key in keys {
+ if let d = date(fromKey: key, calendar: cal), snap.scheduledWeekdays.contains(cal.component(.weekday, from: d)) {
+ scheduled.insert(key)
+ }
+ if let log = snap.logByDate[key] {
+ workouts[key] = raw(from: log, done: snap.workoutDates.contains(key))
+ } else if snap.workoutDates.contains(key) {
+ workouts[key] = RawWorkoutDay(dateKey: key, didWorkout: true, sessions: 1, durationMinutes: 0, exercises: [])
+ }
+ }
+ return WorkoutAnalyticsAdapter.daySamples(dayKeys: keys, workouts: workouts,
+ scheduledKeys: scheduled, health: snap.healthByDate)
+ }
+
+ nonisolated private static func raw(from log: WorkoutDayLog, done: Bool) -> RawWorkoutDay {
+ let samples = log.exercises.map { ex -> ExerciseSample in
+ let completed = ex.sets.filter { $0.done }.map { (weight: $0.weight, reps: $0.reps) }
+ return WorkoutAnalyticsAdapter.exerciseSample(name: ex.name, completedSets: completed)
+ }
+ return RawWorkoutDay(dateKey: log.date, didWorkout: done || !samples.isEmpty,
+ sessions: 1, durationMinutes: 0, exercises: samples)
+ }
+
+ // MARK: - Read APIs for the UI
+
+ func weeklyReports() -> [WeeklyReport] { store.allWeeklyReports().sorted { $0.weekStartKey > $1.weekStartKey } }
+ func monthlyReports() -> [MonthlyReport] { store.allMonthlyReports().sorted { $0.monthKey > $1.monthKey } }
+
+ func monthlyVolumeTrend() -> [MonthVolumePoint] {
+ store.allMonthlyReports().sorted { $0.monthKey < $1.monthKey }
+ .map { MonthVolumePoint(monthKey: $0.monthKey, volume: $0.summary.volume) }
+ }
+
+ func monthlyTrendClassification() -> TrendResult {
+ AnalyticsEngine.classifyTrend(monthlyVolumeTrend().map(\.volume))
+ }
+
+ /// Daily comparison for `date`: vs previous day and vs same weekday last week.
+ func dailyComparison(on date: Date) -> DailyComparison {
+ let dayKey = AnalyticsEngine.dateKey(for: date, calendar: calendar)
+ let prev = calendar.date(byAdding: .day, value: -1, to: date)
+ let lastWeek = AnalyticsEngine.sameWeekdayLastWeek(date, calendar: calendar)
+ return AnalyticsEngine.dailyComparison(
+ day: daySample(forKey: dayKey),
+ previousDay: prev.map { daySample(forKey: AnalyticsEngine.dateKey(for: $0, calendar: calendar)) },
+ sameWeekdayLastWeek: lastWeek.map { daySample(forKey: AnalyticsEngine.dateKey(for: $0, calendar: calendar)) })
+ }
+
+ private func daySample(forKey key: String) -> DaySample {
+ Self.buildSamples([key], snapshot, calendar).first ?? DaySample(dateKey: key)
+ }
+
+ /// Per-exercise volume history (dateKey→volume) across stored snapshots.
+ func exerciseHistory(identity: String) -> [ExerciseVolumePoint] {
+ store.allSnapshotKeys().sorted().compactMap { key in
+ guard let sample = store.snapshot(dateKey: key),
+ let ex = sample.exercises.first(where: { $0.identity == identity }) else { return nil }
+ return ExerciseVolumePoint(dateKey: key, volume: ex.volume)
+ }
+ }
+
+ /// Distinct exercise identities across stored snapshots (for the picker).
+ func knownExerciseIdentities() -> [String] {
+ var set = Set()
+ for key in store.allSnapshotKeys() {
+ store.snapshot(dateKey: key)?.exercises.forEach { set.insert($0.identity) }
+ }
+ return set.sorted()
+ }
+
+ // MARK: - Key helpers (nonisolated, pure)
+
+ nonisolated private static func date(fromKey key: String, calendar: Calendar) -> Date? {
+ formatter("yyyy-MM-dd", calendar).date(from: key)
+ }
+ nonisolated private static func date(fromMonthKey key: String, calendar: Calendar) -> Date? {
+ formatter("yyyy-MM", calendar).date(from: key)
+ }
+ nonisolated private static func formatter(_ format: String, _ calendar: Calendar) -> DateFormatter {
+ let f = DateFormatter()
+ f.calendar = calendar; f.timeZone = calendar.timeZone
+ f.locale = Locale(identifier: "en_US_POSIX"); f.dateFormat = format
+ return f
+ }
+}
diff --git a/KisaniCal/Analytics/AnalyticsStore.swift b/KisaniCal/Analytics/AnalyticsStore.swift
new file mode 100644
index 0000000..f856df3
--- /dev/null
+++ b/KisaniCal/Analytics/AnalyticsStore.swift
@@ -0,0 +1,161 @@
+import Foundation
+
+// File-based JSON persistence for the analytics system (per KC-51 decision:
+// file store over SwiftData/Core Data — iOS 16-safe, unbounded local disk,
+// lowest migration risk, easily reversible).
+//
+// Layout (under Application Support/Analytics):
+// snapshots/.json immutable frozen DaySample per past day
+// reports/weekly/.json
+// reports/monthly/.json
+//
+// Guarantees:
+// - Immutability: a past day's snapshot and any report, once written, are never
+// overwritten (`writeIfAbsent`). Later edits to source data cannot change a
+// generated report.
+// - Idempotency + dedup: files are keyed by period; re-saving an existing key is
+// a no-op, so generation never duplicates.
+// - Retention: `prune` removes snapshots/reports older than the window.
+// - Nothing sensitive beyond what's needed is written (no raw HealthKit records,
+// only the derived reference values already in DaySample).
+final class AnalyticsStore {
+
+ private let root: URL
+ private let fm: FileManager
+ private let encoder: JSONEncoder
+ private let decoder: JSONDecoder
+
+ private var snapshotsDir: URL { root.appendingPathComponent("snapshots", isDirectory: true) }
+ private var weeklyDir: URL { root.appendingPathComponent("reports/weekly", isDirectory: true) }
+ private var monthlyDir: URL { root.appendingPathComponent("reports/monthly", isDirectory: true) }
+
+ init(root: URL, fileManager: FileManager = .default) {
+ self.root = root
+ self.fm = fileManager
+ let enc = JSONEncoder(); enc.dateEncodingStrategy = .iso8601; enc.outputFormatting = [.sortedKeys]
+ self.encoder = enc
+ let dec = JSONDecoder(); dec.dateDecodingStrategy = .iso8601
+ self.decoder = dec
+ ensureDirectories()
+ }
+
+ /// Default location under Application Support.
+ convenience init() {
+ let base = (try? FileManager.default.url(for: .applicationSupportDirectory,
+ in: .userDomainMask, appropriateFor: nil, create: true))
+ ?? URL(fileURLWithPath: NSTemporaryDirectory())
+ self.init(root: base.appendingPathComponent("Analytics", isDirectory: true))
+ }
+
+ private func ensureDirectories() {
+ for dir in [snapshotsDir, weeklyDir, monthlyDir] {
+ try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
+ }
+ }
+
+ // MARK: - Snapshots (immutable once the day is in the past)
+
+ /// Persist a day snapshot. A past day (`isPast == true`) is frozen: if a file
+ /// already exists it is kept unchanged. Today/future may be updated in place.
+ @discardableResult
+ func saveSnapshot(_ day: DaySample, isPast: Bool) -> Bool {
+ let url = snapshotsDir.appendingPathComponent("\(day.dateKey).json")
+ if isPast && fm.fileExists(atPath: url.path) { return false } // frozen
+ return write(day, to: url)
+ }
+
+ func snapshot(dateKey: String) -> DaySample? {
+ read(DaySample.self, from: snapshotsDir.appendingPathComponent("\(dateKey).json"))
+ }
+
+ func allSnapshotKeys() -> [String] {
+ listKeys(in: snapshotsDir)
+ }
+
+ // MARK: - Weekly / monthly reports (immutable, idempotent)
+
+ /// Save a weekly report only if absent (idempotent, never overwritten).
+ /// Returns true if newly written, false if it already existed.
+ @discardableResult
+ func saveWeeklyReport(_ report: WeeklyReport) -> Bool {
+ writeIfAbsent(report, to: weeklyDir.appendingPathComponent("\(report.weekStartKey).json"))
+ }
+
+ @discardableResult
+ func saveMonthlyReport(_ report: MonthlyReport) -> Bool {
+ writeIfAbsent(report, to: monthlyDir.appendingPathComponent("\(report.monthKey).json"))
+ }
+
+ func weeklyReport(weekStartKey: String) -> WeeklyReport? {
+ read(WeeklyReport.self, from: weeklyDir.appendingPathComponent("\(weekStartKey).json"))
+ }
+
+ func monthlyReport(monthKey: String) -> MonthlyReport? {
+ read(MonthlyReport.self, from: monthlyDir.appendingPathComponent("\(monthKey).json"))
+ }
+
+ func allWeeklyReports() -> [WeeklyReport] {
+ listKeys(in: weeklyDir).compactMap { weeklyReport(weekStartKey: $0) }
+ .sorted { $0.weekStartKey < $1.weekStartKey }
+ }
+
+ func allMonthlyReports() -> [MonthlyReport] {
+ listKeys(in: monthlyDir).compactMap { monthlyReport(monthKey: $0) }
+ .sorted { $0.monthKey < $1.monthKey }
+ }
+
+ func existingWeeklyKeys() -> Set { Set(listKeys(in: weeklyDir)) }
+ func existingMonthlyKeys() -> Set { Set(listKeys(in: monthlyDir)) }
+
+ // MARK: - Retention
+
+ /// Remove day snapshots (and weekly reports) whose key is older than the
+ /// retention cutoff. Monthly reports use the "yyyy-MM" cutoff. Returns the
+ /// number of files removed.
+ @discardableResult
+ func prune(now: Date, months: Int = 12, calendar: Calendar) -> Int {
+ var removed = 0
+ guard let cutoffDate = calendar.date(byAdding: .month, value: -months, to: calendar.startOfDay(for: now)) else { return 0 }
+ let dayCutoff = AnalyticsEngine.dateKey(for: cutoffDate, calendar: calendar)
+ let monthCutoff = AnalyticsEngine.monthKey(for: cutoffDate, calendar: calendar)
+
+ for key in listKeys(in: snapshotsDir) where key < dayCutoff {
+ if remove(snapshotsDir.appendingPathComponent("\(key).json")) { removed += 1 }
+ }
+ for key in listKeys(in: weeklyDir) where key < dayCutoff {
+ if remove(weeklyDir.appendingPathComponent("\(key).json")) { removed += 1 }
+ }
+ for key in listKeys(in: monthlyDir) where key < monthCutoff {
+ if remove(monthlyDir.appendingPathComponent("\(key).json")) { removed += 1 }
+ }
+ return removed
+ }
+
+ // MARK: - Low-level IO
+
+ private func listKeys(in dir: URL) -> [String] {
+ guard let items = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else { return [] }
+ return items.filter { $0.pathExtension == "json" }
+ .map { $0.deletingPathExtension().lastPathComponent }
+ .sorted()
+ }
+
+ private func write(_ value: T, to url: URL) -> Bool {
+ guard let data = try? encoder.encode(value) else { return false }
+ do { try data.write(to: url, options: .atomic); return true } catch { return false }
+ }
+
+ private func writeIfAbsent(_ value: T, to url: URL) -> Bool {
+ if fm.fileExists(atPath: url.path) { return false }
+ return write(value, to: url)
+ }
+
+ private func read(_ type: T.Type, from url: URL) -> T? {
+ guard let data = try? Data(contentsOf: url) else { return nil }
+ return try? decoder.decode(type, from: data)
+ }
+
+ private func remove(_ url: URL) -> Bool {
+ (try? fm.removeItem(at: url)) != nil
+ }
+}
diff --git a/KisaniCal/Analytics/WorkoutAnalyticsAdapter.swift b/KisaniCal/Analytics/WorkoutAnalyticsAdapter.swift
new file mode 100644
index 0000000..4ffda58
--- /dev/null
+++ b/KisaniCal/Analytics/WorkoutAnalyticsAdapter.swift
@@ -0,0 +1,93 @@
+import Foundation
+
+// Pure bridge from app workout data (+ HealthKit reference) into the engine's
+// `DaySample` inputs. Kept free of app-model / HealthKit types so it stays
+// deterministic and unit-testable; the app maps `WorkoutDayLog` → `RawWorkoutDay`
+// and supplies HealthKit reference values at the call site.
+
+/// One day's app-generated workout, already reduced to analysis primitives.
+/// The app builds this from `WorkoutDayLog` (volume = Σ weight×reps of done sets).
+struct RawWorkoutDay: Equatable {
+ let dateKey: String
+ let didWorkout: Bool
+ let sessions: Int
+ let durationMinutes: Double
+ let exercises: [ExerciseSample]
+
+ var sets: Int { exercises.reduce(0) { $0 + $1.sets } }
+ var volume: Double { exercises.reduce(0) { $0 + $1.volume } }
+}
+
+/// HealthKit reference values for a day (source of truth for health measurements).
+/// All optional — authorization or data may be missing.
+struct RawHealthDay: Equatable {
+ let steps: Int?
+ let activeCalories: Int?
+ let restingHeartRate: Int?
+}
+
+enum WorkoutAnalyticsAdapter {
+
+ /// Build ordered `DaySample`s for `dayKeys`. A day is:
+ /// - `didWorkout` if it has a workout record with `didWorkout`,
+ /// - `scheduled` if its key is in `scheduledKeys`,
+ /// - enriched with HealthKit reference values where present.
+ /// Missing days become rest/empty samples (honest — no fabricated data).
+ static func daySamples(dayKeys: [String],
+ workouts: [String: RawWorkoutDay],
+ scheduledKeys: Set,
+ health: [String: RawHealthDay] = [:]) -> [DaySample] {
+ dayKeys.map { key in
+ let w = workouts[key]
+ let h = health[key]
+ return DaySample(
+ dateKey: key,
+ didWorkout: w?.didWorkout ?? false,
+ scheduled: scheduledKeys.contains(key),
+ sessions: w?.sessions ?? 0,
+ sets: w?.sets ?? 0,
+ volume: w?.volume ?? 0,
+ durationMinutes: w?.durationMinutes ?? 0,
+ exercises: w?.exercises ?? [],
+ steps: h?.steps,
+ activeCalories: h?.activeCalories,
+ restingHeartRate: h?.restingHeartRate
+ )
+ }
+ }
+
+ /// Inclusive list of day keys from `start` to `end`, in the given calendar.
+ static func dayKeys(from start: Date, to end: Date, calendar: Calendar) -> [String] {
+ var keys: [String] = []
+ var cursor = calendar.startOfDay(for: start)
+ let last = calendar.startOfDay(for: end)
+ var guardCount = 0
+ while cursor <= last, guardCount < 800 { // ~2yr ceiling
+ keys.append(AnalyticsEngine.dateKey(for: cursor, calendar: calendar))
+ guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break }
+ cursor = next; guardCount += 1
+ }
+ return keys
+ }
+
+ /// Day keys for the calendar week/month containing `date`.
+ static func weekDayKeys(containing date: Date, calendar: Calendar) -> [String] {
+ guard let w = AnalyticsEngine.weekInterval(containing: date, calendar: calendar) else { return [] }
+ return dayKeys(from: w.start, to: calendar.date(byAdding: .day, value: -1, to: w.end) ?? w.start, calendar: calendar)
+ }
+
+ static func monthDayKeys(containing date: Date, calendar: Calendar) -> [String] {
+ guard let m = AnalyticsEngine.monthInterval(containing: date, calendar: calendar) else { return [] }
+ return dayKeys(from: m.start, to: calendar.date(byAdding: .day, value: -1, to: m.end) ?? m.start, calendar: calendar)
+ }
+
+ /// Reduce an exercise's completed sets to an `ExerciseSample` (app helper —
+ /// mirrors `WorkoutDayLog.volume`). Kept here so the identity + volume rule is
+ /// defined once and testable.
+ static func exerciseSample(name: String, completedSets: [(weight: Double, reps: Int)]) -> ExerciseSample {
+ let reps = completedSets.reduce(0) { $0 + $1.reps }
+ let volume = completedSets.reduce(0.0) { $0 + AnalyticsEngine.volume(weight: $1.weight, reps: $1.reps) }
+ return ExerciseSample(identity: AnalyticsEngine.exerciseIdentity(name),
+ sets: completedSets.count, reps: reps, volume: volume)
+ }
+}
diff --git a/KisaniCal/Components/SharedComponents.swift b/KisaniCal/Components/SharedComponents.swift
index 201d312..bd36666 100644
--- a/KisaniCal/Components/SharedComponents.swift
+++ b/KisaniCal/Components/SharedComponents.swift
@@ -10,6 +10,25 @@ struct PressButtonStyle: ButtonStyle {
}
}
+/// A quiet accent button: outlined/tinted at rest, fills solid only while
+/// pressed. Color is a response to touch, not a permanent decoration —
+/// matches "nothing shouts, color earns its place."
+struct QuietAccentButtonStyle: ButtonStyle {
+ @Environment(\.colorScheme) private var cs
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .foregroundColor(configuration.isPressed ? .white : AppColors.accent)
+ .background(configuration.isPressed ? AppColors.accent : AppColors.accentSoft)
+ .clipShape(RoundedRectangle(cornerRadius: 11))
+ .overlay(
+ RoundedRectangle(cornerRadius: 11)
+ .stroke(AppColors.accent.opacity(configuration.isPressed ? 0 : 0.35), lineWidth: 1)
+ )
+ .scaleEffect(configuration.isPressed ? 0.97 : 1.0)
+ .animation(KisaniSpring.micro, value: configuration.isPressed)
+ }
+}
+
struct FabButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
diff --git a/KisaniCal/ContentView.swift b/KisaniCal/ContentView.swift
index 968b7f1..9c5c8b9 100644
--- a/KisaniCal/ContentView.swift
+++ b/KisaniCal/ContentView.swift
@@ -18,6 +18,7 @@ struct ContentView: View {
return 0
}()
@State private var showQuickAddTask = false
+ @State private var analyticsLink: AnalyticsDeepLink?
@StateObject private var tabState = FloatingTabState()
@ObservedObject private var shortcuts = ShortcutHandler.shared
@ObservedObject private var tutorial = TutorialManager.shared
@@ -105,10 +106,16 @@ struct ContentView: View {
HealthKitManager.shared.bootstrap()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
consumeWidgetAddTask()
+ // Backfill any missing analytics reports (idempotent), then notify.
+ let generated = AnalyticsService.shared.reconcile(using: workoutVM)
+ NotificationManager.shared.scheduleAnalyticsReports(generated)
+ if let link = NotificationManager.shared.consumePendingAnalyticsDeepLink() { analyticsLink = link }
// Pull workouts from Health, then reschedule so a detected workout updates reminders.
Task {
await workoutVM.syncFromHealthKit()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
+ await AnalyticsService.shared.refreshHealthReference()
+ AnalyticsService.shared.reconcile(using: workoutVM)
}
}
.onChange(of: scenePhase) { phase in
@@ -120,6 +127,8 @@ struct ContentView: View {
workoutVM.checkPendingMissed()
CloudSyncManager.shared.backupSettings()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
+ let generated = AnalyticsService.shared.reconcile(using: workoutVM)
+ NotificationManager.shared.scheduleAnalyticsReports(generated)
Task {
await workoutVM.syncFromHealthKit()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
@@ -161,6 +170,11 @@ struct ContentView: View {
.presentationDetents([.height(150)])
.presentationDragIndicator(.visible)
}
+ .sheet(item: $analyticsLink) { link in
+ AnalyticsView(initialLink: link)
+ .presentationDetents([.large])
+ .presentationDragIndicator(.visible)
+ }
#if DEBUG
.onAppear {
// Test hook: auto-open Quick Add (used to verify the voice→field binding
diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md
index 708cbd1..f309ae4 100644
--- a/KisaniCal/ISSUES.md
+++ b/KisaniCal/ISSUES.md
@@ -1245,3 +1245,1448 @@ Files: `ExerciseModels.swift`.
Cross-device *live* refresh while the app is open isn't wired (`WorkoutViewModel`
doesn't observe `kisaniCloudDataRefreshed`) — out of scope; the restore-on-launch
path is what preserves state across updates.
+
+---
+
+## KC-40 — Workout streak resets on a missed day (should be a lifetime tally)
+
+**Status:** Implemented (not build-verified — sim runtime unavailable)
+**Reported by:** User
+**Area:** Workout
+
+### Description
+The profile showed a streak of `1` even after 5 workouts that week — a single
+skipped/rest day reset it. User wants the count to only ever grow: every
+workout achieved counts, a 4/5 week and a 5/5 week both count fully, misses
+never subtract.
+
+### Change
+`streakDays` is now a lifetime tally of distinct workout days
+(`Set(workoutDates).count`, `totalAchievedWorkoutDays`). Removed the
+consecutive/weekly-goal chain. Settings copy updated (goal no longer "maintains"
+a breakable streak). Unit tests in `StreakLogicTests` (4/5+5/5=9, blank-week,
+duplicates, skipped-day, empty) — algorithm also verified via standalone harness.
+
+Files: `ExerciseModels.swift`, `SettingsView.swift`, `KisaniCalTests/StreakLogicTests.swift`.
+
+---
+
+## KC-41 — 7-day bars empty & "this week" = 0 despite a nonzero done rate
+
+**Status:** Implemented (not build-verified)
+**Reported by:** User
+**Area:** Tasks / Profile stats
+
+### Description
+The profile's "COMPLETED LAST 7 DAYS" bars stayed flat and "this week" showed 0
+even with completed tasks, because only one-off `completedAt` tasks were counted
+— recurring occurrences were ignored.
+
+### Change
+Added `TaskViewModel.completionCount(on:)` / `completions(on:)` that count
+recurring occurrences (`completedOccurrences`) plus one-offs. Profile bars,
+"this week", and a new "day streak" cell now use them.
+
+Files: `TaskItem.swift`, `SettingsView.swift`.
+
+---
+
+## KC-42 — No way to browse historical task/workout activity
+
+**Status:** Implemented (not build-verified)
+**Reported by:** User ("we need to be able to dig through the data")
+**Area:** Tasks / Workout / History
+
+### Change
+New `ActivityHistoryView`: a 90-day, day-by-day archive of completed tasks (with
+times, recurring markers), workout logs (sets + volume), and missed / made-up
+days. Opened via a "History" button on the profile Tasks card.
+
+Files: `ActivityHistoryView.swift` (new), `SettingsView.swift`.
+
+---
+
+## KC-43 — Today-list checkbox color duplicates the row accent
+
+**Status:** Implemented (not build-verified)
+**Reported by:** User
+**Area:** Tasks / Today
+
+### Change
+`TaskRowView` checkbox stroke changed from `task.quadrant.color` to neutral
+`text3` — the row already carries its color on the left accent bar and the
+category chip. Calendar day-list checkbox left colored (no accent bar there).
+
+Files: `TodayView.swift`.
+
+---
+
+## KC-44 — Workout streak banner should show week, then year, then comparison
+
+**Status:** Implemented (not build-verified)
+**Reported by:** User
+**Area:** Workout
+
+### Change
+`StreakBannerView` is now a swipeable 3-page TabView: (1) this week + 7 day
+ticks, (2) this year + 12 month ticks + all-time total, (3) this-week-vs-last-week
+delta. Added VM breakdowns: `workoutsThisWeek/LastWeek/ThisYear`,
+`currentWeekDays`, `currentYearMonths`.
+
+Files: `WorkoutView.swift`, `ExerciseModels.swift`.
+
+---
+
+## KC-45 — Calendar year view should scroll continuously (TickTick-style)
+
+**Status:** Implemented (not build-verified)
+**Reported by:** User
+**Area:** Calendar
+
+### Change
+Replaced the single paged year (`◀ 2026 ▶`, dead space below December) with an
+infinite `LazyVStack` of years, anchored on the current year (accent-colored).
+The top header now follows the scrolled-to year via a `YearTopKey` preference
+(iOS 16-safe). Removed `navigateYear` + the year nav header.
+
+Files: `CalendarView.swift`.
+
+---
+
+## KC-46 — Calendar month view: TickTick bars + collapse (REVERTED)
+
+**Status:** Reverted (user preferred the original)
+**Reported by:** User
+**Area:** Calendar
+
+### Notes
+Implemented TickTick-style day bars, collapse-to-week on selection (tap/drag),
+and a full-column selection highlight (commits `2ec3278`, `bd43eec`). User
+preferred the original month view, so both were reverted (commits `0a87769`,
+`e8d74d4`). Month view is back to dots + full grid + agenda below.
+
+Files: `CalendarView.swift`.
+
+---
+
+## KC-47 — Calendar week view should be a TickTick-style timeline
+
+**Status:** Implemented (not build-verified)
+**Reported by:** User (screen recording)
+**Area:** Calendar
+
+### Change
+Replaced the mini-month + agenda-list split with a 7-day timeline (hours down
+the left, one column per weekday, events as positioned blocks) reusing
+`cvTimeGrid`/`cvDayColumnHeader`. Added:
+- **Drag-to-reschedule**: task blocks drag vertically to change time, snapped to
+ 15 min (`CVTimeBlock`, `rescheduleTask`; events + recurring tasks stay fixed).
+- **Layout toggle**: timeline ⇄ grid of day-cards (`weekGrid`, `weekGridLayout`).
+- Week-swipe navigation (`navigateWeek`).
+
+Known gaps: drag moves time only (not across days); calendar events not draggable.
+
+Files: `CalendarView.swift`.
+
+---
+
+## KC-48 — Week / 3-day views float to the bottom (dead space on top)
+
+**Status:** Fixed (not build-verified)
+**Reported by:** User
+**Area:** Calendar / Layout
+
+### Root cause
+The timeline views are a thin day-header + a greedy `ScrollView` (≈0 ideal
+height), so the top-level `VStack` shrank and the `ZStack(alignment:
+.bottomTrailing)` floated it to the bottom, leaving a large empty gap above the
+header. Month view has a tall fixed grid, so it never surfaced.
+
+### Fix
+Pinned the top-level content `VStack` with
+`.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)`.
+
+Files: `CalendarView.swift`.
+
+---
+
+## KC-49 — Week/3-day dead space persisted after KC-48
+
+**Status:** Fixed (not build-verified) — supersedes KC-48
+**Reported by:** User ("DEAD SPACE STILL EXISTS")
+**Area:** Calendar / Layout
+
+### Root cause (refined)
+Pinning only the outer container (`.frame(maxHeight: .infinity)`, KC-48) merely
+*allows* fill — it doesn't *force* it. The timeline `ScrollView` returned its
+content size instead of expanding, so the week/3-day root stayed short and the
+`.bottomTrailing` ZStack floated it down, leaving the day header stranded mid-
+screen. Day view happened to fill, so it never showed.
+
+### Fix
+Force the timeline `ScrollView` to be greedy with
+`.frame(maxWidth: .infinity, maxHeight: .infinity)`, and pin each timeline
+view's root `VStack` to fill + top-align. Applied to `weekView` and
+`threeDayView`.
+
+Files: `CalendarView.swift`.
+
+---
+
+## KC-50 — Long-press on a timeline event has no actions
+
+**Status:** Implemented (not build-verified)
+**Reported by:** User
+**Area:** Calendar / Tasks
+
+### Description
+Long-pressing a task block on the day/week/3-day timeline did nothing — it
+should offer the same actions as elsewhere (Complete, Snooze, reschedule, etc.).
+
+### Change
+`CVTimeBlock` now takes `@ViewBuilder menu` content and shows it via
+`.contextMenu`. `cvBlockMenu(for:)` builds it for task-backed items: a Complete
+toggle, a Snooze submenu (15 min / 1 hr / tomorrow), then the shared
+`TaskMenuItems` (pin, reschedule, move, priority, category, edit, delete).
+Events (no `taskID`) get no menu.
+
+Files: `CalendarView.swift`.
+
+---
+
+## KC-51 — Health & Workout Analytics system (12-month, immutable reports) — PLAN
+
+**Status:** Planning (blocked on 2 decisions + build-verification environment)
+**Reported by:** User (/goal)
+**Area:** Analytics / Persistence / HealthKit / Notifications
+
+### Reusable existing functionality (survey)
+- `WorkoutViewModel`: `history: [WorkoutDayLog]`, `workoutDates`, `schedule`,
+ `missedDates`, `compensations`; `StatPeriod`/`WorkoutStat`/`StatBucket`
+ aggregation already exists (`stat(period:offset:)`, `statBuckets`).
+- `WorkoutDayLog` → `LoggedExercise` → `LoggedSet(weight, reps, done)` with
+ computed `volume` (Σ weight×reps for done sets), `totalSets`, `doneSets`.
+- `HealthKitManager`: steps, active calories, resting HR, workoutsThisWeek,
+ `fetchWorkoutDates(from:to:)`, `saveWorkout`, `sumQuery`/`latestQuery`.
+- `NotificationManager`: categories/actions, foreground `reschedule(...)`, and
+ **`schedulePeriodMilestones()`** (week/month/year-close nudges) — extend this.
+- Lifecycle reconcile pattern already used: `checkPendingMissed` /
+ `checkPendingHealthConfirm` on `onAppear` + `scenePhase == .active`.
+- Persistence: `UserDefaults` mirrored to iCloud KVS (`CloudSyncManager`).
+
+### Persistence evaluation (SwiftData / Core Data / current)
+- **Current (JSON blobs in UserDefaults + iCloud KVS):** iCloud KVS has a hard
+ **1 MB total** ceiling (see KC-39 context). 12 months of daily snapshots +
+ HealthKit reference cache **will exceed it** → silent sync failure. Not viable
+ as the analytics store.
+- **SwiftData:** cleanest modelling/migration, but requires **iOS 17+**. App
+ deploymentTarget is **iOS 16.0** → SwiftData is UNAVAILABLE unless the min
+ target is raised (drops iOS 16 devices — a product call).
+- **Core Data:** works at iOS 16, unbounded local storage, supports lightweight
+ migration; more boilerplate. Local-only (analytics need not sync via KVS;
+ reports are device-derived and reconstructable).
+
+### Proposed architecture (pending the decision below)
+- **Stores:** `WorkoutRecordStore` (app-generated, mirrors `WorkoutDayLog`) +
+ `HealthReferenceStore` (HealthKit cache; HealthKit = source of truth) +
+ `ReportStore` (immutable daily snapshots, weekly, monthly). Keep app records
+ and HealthKit reference separate; analysis reads across both.
+- **Immutability:** reports store a frozen snapshot at generation; later edits to
+ source data never mutate a generated report. Retention: prune raw beyond 12
+ months; keep report summaries.
+- **Analysis engine (`AnalyticsEngine`)**: pure, no SwiftUI/persistence deps,
+ deterministic. Formulas documented: volume = Σ(weight×reps) of completed sets;
+ %Δ = (cur−base)/|base| guarded for base≈0/new/rest; consistency = sessions ÷
+ scheduled over window; trend = classify(slope, threshold) → improve / decline /
+ plateau / insufficient-data. Handles missing/partial HK, deloads, outliers
+ (winsorize/IQR), unit compatibility, divide-by-zero. Non-medical language +
+ disclaimer; never diagnoses.
+- **Comparisons:** daily vs previous comparable day + same weekday −1 week;
+ weekly vs previous completed week; monthly vs previous month.
+- **Reconciliation:** idempotent generators keyed by period id; on launch/active,
+ backfill any missing completed week/month; never duplicate reports or
+ notifications. Do not depend on exact BG execution.
+- **Notifications:** extend `schedulePeriodMilestones()`; one permission-aware,
+ user-scheduled local notification per weekly/monthly report; deep-link via
+ `userInfo` → route to the specific report (new deep-link cases).
+- **UI (`Analytics` area):** Overview · Daily comparison · Weekly reports ·
+ Monthly reports · 12-month trends · Per-exercise history. Full loading / empty /
+ denied-permission / partial-data / error states; Dynamic Type, VoiceOver,
+ light/dark, compact/regular; Wenza design tokens; charts only where they aid
+ understanding; non-color status indicators.
+- **Tests:** date boundaries (daily/weekly/monthly), calendar/locale/firstWeekday/
+ timezone/DST, volume/%/consistency/trend math, missing/partial HK, migration/
+ dedup/reconcile/retention, exercise identity + unit compat, report+notification
+ idempotency, deep-link routing.
+
+### BLOCKERS (must resolve before build-verified completion)
+1. **Build/test verification is impossible in this environment.** The iOS 26.5
+ simulator runtime was removed this session and CLI builds are broken, so
+ completion conditions #10 (tests pass) and #11 (Xcode build succeeds) CANNOT
+ be verified here. Per "never claim verification not performed", any code
+ produced now is inspection-only until a runtime is restored / built on device.
+2. **Persistence + min-iOS decision (irreversible):** SwiftData needs iOS 17
+ (app is iOS 16). Migrating live workout history to a new store is a one-way
+ data migration. Needs the founder's call before implementation.
+
+### Next action
+Awaiting decision on persistence backend / deployment target, then implement the
+pure `AnalyticsEngine` + models + tests first (most inspection-verifiable), UI
+last. Device/build QA tracked here.
+
+### KC-51 progress — Phase 1: analysis engine (DONE, engine-verified)
+- Added pure, deterministic `AnalyticsEngine` + value-type models
+ (`AnalyticsModels.swift`) — no SwiftUI/persistence/HealthKit deps.
+- Formulas documented in-source: volume, %change (guarded), consistency, trend.
+- Honest degradation: nil/zero/tiny baseline → no %; new exercise / rest day →
+ not meaningful; multi-point trend vs least-squares slope; winsorized outliers;
+ unit-dimension compatibility; divide-by-zero guarded.
+- Calendar helpers take an injected `Calendar` → locale/firstWeekday/TZ/DST-safe.
+- `AnalyticsEngineTests.swift` (35 cases). **Verified independently**: compiled
+ with `swiftc` and ran a 42-assertion harness → all pass (Xcode test target not
+ runnable here — no sim runtime; same assertions live in the XCTest file).
+- Next: Phase 2 file-based persistence (report/record stores), adapters from
+ `WorkoutDayLog` + HealthKit, reconciliation, retention, dedup — with tests.
+
+### KC-51 progress — Phase 2: persistence + reconciliation (DONE, store-verified)
+- `AnalyticsStore` (file-based JSON under Application Support): immutable frozen
+ daily snapshots + immutable weekly/monthly reports (writeIfAbsent →
+ idempotent, deduped by period key), retention prune (>12mo), no raw HealthKit
+ persisted (only derived reference values in DaySample).
+- Engine reconciliation selectors: `completedWeekStartKeys`, `completedMonthKeys`,
+ `missingKeys` (idempotent — already-generated periods skipped).
+- `AnalyticsStoreTests` (6 cases). **Verified independently**: compiled store +
+ engine with swiftc and ran a 23-assertion temp-dir harness → all pass.
+- Next: Phase 3 app integration — adapter (`WorkoutDayLog` + HealthKit →
+ DaySample), reconcile coordinator on launch/active, notifications + deep links.
+ Phase 4: Analytics UI (6 areas + states + accessibility). Both require an Xcode
+ build / device to verify (no sim runtime here).
+
+### KC-51 progress — Phase 3: adapter + reconcile coordinator + deep links (verified)
+- `WorkoutAnalyticsAdapter` (pure): `WorkoutDayLog`/HealthKit primitives →
+ `DaySample`; exercise reduction (identity + volume), inclusive day-key ranges,
+ week/month key sets. Rest/missing days become honest empty samples.
+- `AnalyticsCoordinator` (injectable sample providers → testable): backfills
+ every missing completed week/month, saves immutably (dedup), prunes retention.
+ **Idempotent** — a second run generates nothing.
+- `AnalyticsDeepLink`: `wenza://analytics[/weekly/|/monthly/]` with
+ symmetric URL/string encode-decode for report notifications.
+- **Bug caught + fixed by tests:** `completedWeekStartKeys` included a boundary
+ week that `prune` then deleted → the report regenerated (and would re-notify)
+ on every launch. Aligned generation to the retention cutoff → idempotent.
+- Tests: `AnalyticsAdapterTests`, `AnalyticsCoordinatorTests`. Verified
+ standalone via swiftc (adapter 17 + coordinator/deeplink 10 assertions, all
+ pass). Total analytics assertions verified standalone: ~90.
+
+### KC-51 — remaining (device/Xcode-build required; NOT verified here)
+- App wiring: inject real providers (adapter + WorkoutViewModel + HealthKit) into
+ the coordinator; call `reconcile()` on launch + `scenePhase == .active`
+ (reuse existing checkPending* pattern). App-coupled — needs Xcode build.
+- Notifications: extend `NotificationManager.schedulePeriodMilestones()` to fire
+ one permission-aware weekly + monthly local notification carrying an
+ `AnalyticsDeepLink` in `userInfo`; route it on tap.
+- Analytics UI (6 areas: Overview / Daily / Weekly / Monthly / 12-month trends /
+ Per-exercise) + loading/empty/denied/partial/error states + Dynamic Type,
+ VoiceOver, light/dark, size classes. SwiftUI — not build-verifiable here.
+- BLOCKER unchanged: no iOS runtime in this env → cannot run the Xcode test
+ target or build (goal conditions #10/#11, and thus UI #8/#9). Restore a runtime,
+ then wire + build + run the suite incrementally.
+
+### KC-51 progress — Phase 4a: app integration wiring (NOT build-verified)
+- `AnalyticsService` (@MainActor): owns the store, builds the coordinator from a
+ `WorkoutViewModel` snapshot (history/workoutDates/schedule/missedDates →
+ RawWorkoutDay → DaySample), freezes recent daily snapshots, exposes UI reads
+ (weekly/monthly reports, 12-month volume trend, daily comparison, per-exercise
+ history). HealthKit per-day historical reference left nil (honest) — follow-up.
+- Lifecycle: `ContentView` calls `AnalyticsService.shared.reconcile(using:)` +
+ `NotificationManager.scheduleAnalyticsReports(...)` on launch and scenePhase
+ `.active` (reuses existing pending-action pattern).
+- Notifications: `scheduleAnalyticsReports` fires one permission-aware,
+ setting-gated weekly + monthly notification with an `AnalyticsDeepLink` in
+ userInfo, deduped by fixed per-period id (remove-then-add). Tap captured in the
+ UNUserNotificationCenter delegate → `consumePendingAnalyticsDeepLink()`.
+- ⚠️ NOT compiled/build-verified (no iOS runtime + app-module/SwiftUI deps).
+ Time-of-day "user-scheduled" report notification (vs deliver-on-detect) and
+ historical HealthKit reference are documented follow-ups.
+
+### KC-51 progress — Phase 4b: Analytics UI (NOT build-verified)
+- `AnalyticsView` (self-contained): 6 areas — Overview / Daily / Weekly / Monthly
+ / 12-Month / Exercises — via a chip selector. Reads `AnalyticsService`.
+ - Status by BOTH color and glyph (arrow.up.right / arrow.down.right / equal /
+ minus) — never color alone. Empty + partial ("not enough data") states.
+ - Swift Charts for 12-month volume (bar) + per-exercise volume (line).
+ - Informational non-medical disclaimer (`AnalyticsEngine.disclaimer`) footer.
+ - VoiceOver labels on badges/chips/charts; Wenza tokens (auto light/dark).
+- Entry point: an Analytics `IButton` (chart.line.uptrend) in the Workout header
+ → presents `AnalyticsView` as a sheet (mirrors the existing history/stats).
+- Added Chart-friendly `MonthVolumePoint` / `ExerciseVolumePoint` (tuples can't be
+ keyed in Charts).
+- Self-review caught + fixed pre-emptively (no build here): tuple key-paths in
+ `Chart(id:)`, accessibility-traits array literal, `%@` format string.
+- ⚠️ NOT compiled — SwiftUI + app-module deps; needs an Xcode build. Likely still
+ has issues only a compiler will surface. Remaining UI polish: deep-link routing
+ into the presented view, Dynamic-Type audit, denied-permission state for future
+ HealthKit trends.
+
+### KC-51 progress — Phase 4c: deep-link routing end-to-end (core-verified)
+- `AnalyticsDeepLink` now `Identifiable` (id = url string) → usable with
+ `.sheet(item:)`. Round-trip re-verified standalone.
+- Notification tap → stores pending link (delegate) → `ContentView` consumes it on
+ launch + `.active` → presents `AnalyticsView(initialLink:)` opened to the
+ weekly/monthly area. Completes goal condition #7 (report notifications
+ deep-link to their report). Wiring not build-verified (SwiftUI/app deps).
+
+### KC-51 progress — Phase 4d: concurrency hardening (compile-risk reduction)
+- Refactored `AnalyticsService`: coordinator sample-building moved to nonisolated
+ static functions over a captured `WorkoutSnapshot` value, so the coordinator's
+ escaping closures no longer call `@MainActor` methods (avoids a main-actor
+ isolation error under strict concurrency). Verified `@MainActor` + `static let
+ shared` is the codebase's existing pattern (HealthKitManager).
+- Self-review confirmed the singleton/actor pattern matches the app; remaining
+ first-build risk is concentrated in `AnalyticsView.swift` (SwiftUI Charts /
+ FlowRow generics) — surface-level, not logic.
+
+### KC-51 progress — Phase 4e: HealthKit historical reference wired (core-verified)
+- `HealthKitManager.dailyReference(from:to:)`: per-day steps/active-calories/
+ resting-HR over a range via 3 parallel `HKStatisticsCollectionQuery` (one
+ bucketed query per metric — efficient over a year, not per-day fetches).
+ Matches the file's existing `@MainActor` + continuation query pattern.
+- `AnalyticsService.refreshHealthReference()`: fetches ~13mo of reference,
+ caches it, merged into every `DaySample` built afterward. Called from the
+ existing Health-sync `Task` in `ContentView` (after `syncFromHealthKit`),
+ then reconciles so new/updated reports capture it.
+- `PeriodSummary` gained `avgSteps`/`avgActiveCalories`/`avgRestingHR` (nil when
+ no reference data in the period — honest, never fabricated). Weekly/monthly
+ cards in `AnalyticsView` show them when present.
+- **Verified standalone** (swiftc, 8/8): HK averages aggregate correctly when
+ present, stay nil when absent, workout metrics unaffected either way, reports
+ remain deterministic. Added `testSummarizeAggregatesHealthKitAveragesWhenPresent`
+ + `testSummarizeHealthKitNilWhenAbsent` to `AnalyticsEngineTests`.
+- This completes "available HealthKit trends" for weekly/monthly reports (goal
+ requirement). HealthKit remains the sole source for these values — nothing is
+ derived or estimated by the app.
+
+### KC-51 — status: all logic complete; awaiting first Xcode build (device, user-run)
+Every core + integration piece is now written: engine, store, adapter,
+coordinator, deep links, HealthKit reference, notifications, and a 6-area UI.
+Pure-Swift pieces (~110 assertions total) are compiler-verified standalone;
+SwiftUI/app-module pieces are self-reviewed but never compiled (no iOS runtime
+in this environment). User will build on device next. Awaiting: first-build
+error list (expected — SwiftUI Charts/generics are the likely spots), then
+XCTest run, then close out goal conditions #10/#11 and any remaining polish.
+
+---
+
+## KC-52 — Bring workout completion onto the main page (not buried in "…" menu)
+
+**Status:** Implemented (not build-verified)
+**Reported by:** User
+**Area:** Workout
+
+### Description
+"Mark All Complete" / "Clear All Sets" were hidden in the header's "…" menu.
+User wants completion controls front-and-center on the main Workout page, and
+a way to finish a workout even if not every set was ticked — since Health/Watch
+already independently confirms a workout happened, the app shouldn't gate
+"did you train today" on checking every box.
+
+### Change
+Redesigned `DotProgressCard` (the "X of Y sets done" card, already at the top of
+the main page) into a 3-state completion card:
+- **Pending:** progress dots + a primary **Finish Workout / Finish Anyway**
+ button (captures today via `markWorkoutDone`, regardless of set completion —
+ honest capture, not gated on 100%), plus two secondary actions: **Check all
+ sets** (mechanical, ticks every box) and **Didn't train** (marks missed).
+- **Done today:** green "Workout logged" state + **Undo**.
+- **Missed today:** muted "Marked as not completed" state + **Undo**.
+- Added `unmarkWorkoutDone(on:)` / `unmarkWorkoutMissed(on:)` to
+ `WorkoutViewModel` — clean "back to pending" without conflating done/missed.
+- Removed "Mark All Complete" from the header's "…" menu (now the card's primary
+ action); kept "Clear All Sets" there as a distinct reset utility.
+
+Files: `WorkoutView.swift`, `ExerciseModels.swift`.
+
+### KC-52 — follow-up: restraint, confirmation, clear (user feedback)
+User pushed back on the first pass: the primary button was a permanently-filled
+accent pill ("giving AI," violates PRODUCT.md "nothing shouts / color earns its
+place"), "Finish Anyway" skipped confirming the % logged before overriding
+progress, and there was no quick way to undo an accidental "Check all sets."
+
+- New `QuietAccentButtonStyle` (SharedComponents.swift): outlined/tinted at
+ rest, fills solid accent only while pressed — color as a response to touch,
+ not a static decoration. Applied to the Finish button.
+- `confirmationDialog` before finishing with partial sets: "Finish with X of Y
+ sets logged?" + the logged % in the message. Skipped when already 100% (no
+ need to ask about something already true).
+- "Check all sets" now flips to "Clear all sets" once every set is checked —
+ the undo is the button itself, always one tap away, no separate menu needed.
+
+Files: `WorkoutView.swift`, `Components/SharedComponents.swift`.
+
+### KC-52 — follow-up 2: drop the "seal" icon, confirm every destructive action
+User: "whats with the ai tick also add clear all sets with a prompt to confirm
+on all these to avoid accidentals."
+
+- `checkmark.seal.fill` (a generic "verified badge" glyph, unused anywhere else
+ in the app) replaced with `checkmark.circle.fill` — the codebase's actual
+ convention (TodayView, TaskContextMenu, etc.).
+- All four state-changing quick actions on the card now confirm first via a
+ single `PendingAction` enum + one `confirmationDialog` (Finish Anyway,
+ Check All Sets, Clear All Sets, Didn't Train) — each with its own message;
+ Clear All / Didn't Train use `.destructive` role. "Finish Workout" at 100%
+ still skips the dialog (nothing to override).
+- The header's "…" menu **Clear All Sets** (works at any completion level, not
+ just 100%) now also confirms before executing — previously instant/silent.
+
+Files: `WorkoutView.swift`.
+
+### KC-52 — follow-up 3: Check/Clear all no longer auto-jumps to "done" (real bug)
+User: "when you check all or clear all it shouldnt load the screen shot [the
+green 'Workout logged' card]. just show the workout widget."
+
+### Root cause
+Not a display bug — `setAllSetsDone(true)` ("Check all sets"), `toggleSet`, and
+`setExerciseDone` all still auto-called `logWorkoutCompleted()` the instant
+every set hit done (`doneSets == totalSets`). That's pre-redesign behavior that
+now directly fights KC-52: the whole point of the new card was to make "Finish"
+a single, deliberate, confirmable action — but ticking the last checkbox (or
+tapping "Check all sets") silently counted the day anyway, snapping the card
+straight to the green done takeover the user never asked for.
+
+### Fix
+Removed the auto-`logWorkoutCompleted()` call from all three sites
+(`toggleSet`, `setExerciseDone`, `setAllSetsDone`). Checking sets — by any
+path — now only ever updates progress; **only** the card's explicit "Finish
+Workout"/"Finish Anyway" (→ `markWorkoutDone`) counts the day as done. "Check
+all sets" now does exactly what it says: checks the boxes, nothing more.
+
+Files: `ExerciseModels.swift`.
+
+---
+
+## KC-53 — Add "This Year" to the Progress Dots lock-screen widget
+
+**Status:** Implemented (not build-verified)
+**Reported by:** User
+**Area:** Widgets (KisaniCalWidgets)
+
+### Context
+The "Progress Dots" widget (`DayProgressWidget`, kind `KisaniDayProgress`) is a
+single configurable widget — long-press → Edit Widget lets you pick a mode.
+It already supports **Day**, **Week**, and **Month** (the one the user said is
+"already good") on the lock screen (`.accessoryRectangular`) and home screen.
+Day and Week needed no code change — just add another widget instance and pick
+that mode. **Year was the only mode that didn't exist.**
+
+### Change
+Added `.year` to `ProgressDotMode` ("This Year"), a matching branch in
+`ProgressDotProvider.entry(for:at:)` using `Calendar.dateInterval(of: .year,
+for:)` (same pattern as month), and a `yearTitle(for:)` formatter ("yyyy") —
+mirrors day/week/month exactly.
+
+Files: `KisaniCalWidgets/KisaniCalWidgets.swift`.
+
+### Note
+Not build-verified (no iOS runtime here). To use: on the Lock Screen, tap the
+widget area → Add Widgets → Wenza → Progress Dots → long-press → Edit Widget →
+set Mode to This Day / This Week / This Year (This Month already configured).
+
+---
+
+## KC-54 — Timed recurring tasks vanish from Today once their time passes
+
+**Status:** Fixed (not build-verified)
+**Reported by:** User (screenshots: Calendar day view showed "Look into DPO
+certificate" 11 AM and "Read Azure 104" 5 PM; neither appeared anywhere in the
+Today tab — not in Today, not in Overdue, not in Completed)
+
+### Root cause
+Two rules interacted badly for **recurring** tasks:
+- `todayTasks` deliberately excludes timed tasks whose time has passed today
+ ("belongs in overdue, not today").
+- `overdueTasks` deliberately excludes ALL recurring tasks (`!recurs($0)`),
+ because it feeds `postpone`/`postponeAllOverdue`, which mutate a task's
+ `dueDate` directly by id — for a recurring task that field is the
+ **recurrence rule's anchor date**, not just "today's occurrence." Including
+ recurring occurrences there would let "Postpone All" shift/corrupt the whole
+ series.
+
+Net effect: once a recurring task's today-occurrence time passed, it was
+excluded from Today (rule 1) but never picked up by Overdue (rule 2) — it fell
+into a gap and disappeared from the Today tab entirely, while Calendar's day
+view (`occurrences(on:)`, which has no time-of-day gating) kept showing it
+normally. Not a display bug — real data was correctly stored, just not
+surfaced.
+
+### Fix
+`todayTasks`' "past time → excluded" rule now only applies to non-recurring
+tasks (`!$0.isRecurring`). A recurring task's today-occurrence always stays in
+Today regardless of time — matching what Calendar already shows, and avoiding
+the postpone/recurrence-anchor risk entirely (recurring rows are never routed
+through `overdueTasks`). One-line, additive filter change; `activeRows()` /
+`occurrenceCopy` already preserve `isRecurring` on the occurrence copy.
+
+Files: `TaskItem.swift`.
+
+---
+
+## KC-55 — Habit reminders: Prayer, Bed-making, Coffee/Caffeine
+
+**Status:** Implemented (not build-verified)
+**Reported by:** User
+**Area:** Settings / Onboarding / Notifications
+
+### Description
+Add three new opt-in daily local-notification habits, each toggleable in both
+Settings and Onboarding:
+1. **Prayer** — up to 4 independently-toggleable times a day (Morning 7:00,
+ Afternoon 13:00, Evening 18:00, Night 21:00 by default). Message: "Have you
+ had a chance to pray and give thanks?"
+2. **Bed-making** — a single daily nudge, default 8:00 AM (per the user's note
+ that research shows it's an effective first win of the day).
+3. **Coffee / Caffeine** — up to 4 named times (Morning 8:00 on by default,
+ Midday/Afternoon/Evening off by default) plus one custom-labeled reminder.
+
+### Design decisions
+- **Onboarding stays restrained**: 3 simple master toggles (Prayer/Bed/Coffee)
+ with sensible pre-filled defaults — no per-slot time editors there, per the
+ app's "nothing shouts" principle and to avoid a long onboarding form. Full
+ per-slot control (and the custom coffee reminder) lives in Settings →
+ **Habit Reminders**, which onboarding points to via existing "change later
+ in Settings" copy. The SAME `UserDefaults.kisani` keys back both surfaces,
+ so a toggle in onboarding is identical to the one in Settings.
+- **Store correctness**: discovered the existing `workoutCheckInEnabled` /
+ `workoutConfirmEnabled` / etc. `@AppStorage` declarations have **no explicit
+ `store:`**, so they write to `UserDefaults.standard` — but
+ `NotificationManager` reads them via `UserDefaults.kisani` (the App Group
+ suite). That's a pre-existing mismatch (not fixed here — out of scope,
+ flagging for a future look). All NEW keys for this feature explicitly use
+ `store: UserDefaults.kisani` everywhere (Settings, Onboarding, and the
+ reusable `HabitSlotRow`), matching the one place in the codebase that
+ already does this correctly (`kisani.periodMilestones`) — so these
+ reminders are guaranteed to actually fire.
+- **All daily, not weekday-scoped**: unlike workout notifications (tied to a
+ weekly schedule), these use a plain `UNCalendarNotificationTrigger` with
+ only hour/minute set (no weekday) — fires every day, same pattern as
+ `schedulePeriodMilestones`'s "Every midnight" trigger.
+
+### Implementation
+- `NotificationManager.scheduleHabitReminders()`: reads all keys from
+ `UserDefaults.kisani`, schedules/cancels 9 possible daily notifications
+ (bed ×1, prayer ×4, coffee ×4 + custom ×1) with fixed identifiers so
+ re-scheduling is idempotent (no duplicates); explicitly cancels any
+ previously-scheduled slot that's no longer wanted. Wired into the existing
+ `reschedule(workoutVM:taskVM:)` orchestration (same place as
+ `schedulePeriodMilestones()`), so it refreshes on every app foreground.
+- Settings: new "Habits" section → **Habit Reminders** sheet
+ (`HabitRemindersSheet`), one card per habit, following the exact visual
+ pattern of the existing Workout Settings sheet. `HabitSlotRow` (reusable,
+ used 9×) is a named toggle + time-picker row that owns its own
+ `@AppStorage` backing via a manual `init` (dynamic key per slot).
+- Onboarding: a new "Habits" card with 3 `OnboardingHabitToggle` rows
+ (reusable, used 3×) right after the Workout section.
+
+Files: `NotificationManager.swift`, `SettingsView.swift`, `OnboardingView.swift`.
+
+### Note
+Not build-verified — no iOS runtime in this environment. Self-reviewed for
+compile correctness (balanced braces/parens checked programmatically; the
+manual `@AppStorage` backing-storage init in `HabitSlotRow` is the highest-risk
+spot syntactically — standard, documented pattern, but worth a close look on
+first build).
+
+---
+
+## KC-56 — Habit reminders: "Done" notification action + private day count
+
+**Status:** Implemented (not build-verified)
+**Reported by:** User (follow-up to KC-55: "should they appear in tasks, or
+stay reminders only?")
+
+### Decision
+Discussed with the user: keep habit reminders (Prayer/Bed/Coffee, KC-55) OUT
+of Today/Matrix/Activity History — turning them into recurring TaskItems would
+add 3–9 extra daily checkboxes forever, against PRODUCT.md's "nothing shouts /
+data is primary" principle, and a prayer nudge isn't really a "deliverable."
+Middle ground: add a **"Done" action directly on the notification** (like the
+workout confirm flow's "Yes, log it") that logs a private timestamp for a
+lightweight day count — no task, no Activity History entry, nothing shown
+outside Settings.
+
+### Implementation
+- New `HABIT_LOG` notification category + `HABIT_DONE` action ("Done"),
+ registered alongside the existing task/workout categories.
+- Every habit notification (bed, all 4 prayer slots, all 4 coffee slots + the
+ custom one) carries `userInfo["habitType"]` = "bed"/"prayer"/"coffee" (slot-
+ level detail collapses to the 3 habit TYPES — tapping Done on any prayer
+ slot counts as "prayed today," not per-slot).
+- `logHabitDone(type:)`: appends today's date to `UserDefaults.kisani["kisani.
+ habit.doneDates."]`, deduped (idempotent — tapping Done twice same day
+ is a no-op). Mirrors the "lifetime tally, never breaks" philosophy already
+ established for the workout streak (KC-40), not a fragile consecutive
+ streak.
+- `habitDoneCount(type:)`: reads the count back.
+- Settings → Habit Reminders now shows a quiet "Xd" badge next to each
+ habit's title (Prayer/Bed/Coffee) once count > 0 — the only place this
+ number appears.
+
+Files: `NotificationManager.swift`, `SettingsView.swift`.
+
+### Note
+Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/
+parens checked programmatically; `logHabitDone`/`habitDoneCount` intentionally
+left without `@MainActor` to match the existing nonisolated handler functions
+(`markTaskComplete`, `snoozeTask`) they run alongside in the notification
+delegate callback.
+
+---
+
+## KC-57 — Build error: Color(r:g:b:) used outside its file (coffee brown)
+
+**Status:** Fixed (not build-verified)
+**Reported by:** User (Xcode build errors: "Cannot convert value of type
+'Int' to expected argument type 'Color'", "Extra argument 'b' in call")
+
+### Root cause
+`Color(r:g:b:)` (Double) is declared as `private extension Color` inside
+`DesignTokens.swift` — invisible outside that file by design (only `UIColor`'s
+`init(r:g:b:)` sibling is `private` too, both file-scoped). KC-55/56 used
+`Color(r: 122, g: 84, b: 52)` directly in `SettingsView.swift` and
+`OnboardingView.swift` for the coffee icon's brown background — neither file
+can see the private initializer, so the compiler fell back to unrelated
+overloads and produced the confusing "Cannot convert Int to Color" errors.
+
+### Fix
+Added `AppColors.coffeeBrown` (defined inside `DesignTokens.swift`, where the
+private init IS visible) alongside the other fixed accent colors
+(accent/green/blue/yellow/red). Both call sites now reference
+`AppColors.coffeeBrown` instead of constructing the color inline — matches
+how every other color in the app is centralized, and is accessible from any
+file.
+
+Files: `DesignTokens.swift`, `SettingsView.swift`, `OnboardingView.swift`.
+
+---
+
+## KC-58 — Custom coffee reminder row was unlabeled/confusing
+
+**Status:** Implemented (not build-verified)
+**Reported by:** User (screenshot: last row in "Coffee reminders" just said
+"Coffee" — indistinguishable from the card's own title, no hint it's an
+editable custom slot)
+
+### Fix
+- The custom-reminder row now has a small "✎ YOUR OWN REMINDER" label above
+ it, so it reads as a distinct, nameable 5th slot rather than a redundant
+ duplicate of the "Coffee reminders" card title.
+- Default label changed from the confusing pre-filled "Coffee" to empty, so
+ the field's placeholder ("Name it, e.g. \"Second coffee\"") actually guides
+ the user instead of looking like a static, already-filled-in option.
+- `NotificationManager` falls back to "Coffee" for the notification title only
+ if the user never actually typed a name (empty string), so an un-customized
+ custom reminder still reads sensibly if enabled.
+
+Files: `SettingsView.swift`, `NotificationManager.swift`.
+
+---
+
+## KC-59 — "Complete & Stop Series" for recurring tasks (distinct from Delete)
+
+**Status:** Implemented (not build-verified)
+**Reported by:** User: "for any reoccurring tasks in calendar and tasks view
+can we put a complete and also stop series and not delete — for example if i
+want to complete and stop a series from running"
+
+### Description
+Recurring tasks previously had only two menu actions relevant here: toggle
+today's occurrence (series continues forever), or **Delete** (erases the task
+and its entire `completedOccurrences` history — no way to "wind down" a
+series while keeping its record).
+
+### Change
+- `TaskViewModel.completeAndStopSeries(_:on:)`: marks the given occurrence
+ (default: today) complete, then sets `recurrenceEnd` to that day — which
+ `isOccurrence` already respects (a field that existed in the model but had
+ no UI to set it). The task itself, its title, and its full
+ `completedOccurrences` history are all preserved — only future occurrences
+ stop generating. Genuinely different from `delete(_:)`.
+- New "Complete & Stop Series" item in the shared `TaskMenuItems` (used by
+ Today, Matrix, and Calendar's day/week/3-day timelines) — shown only when
+ `task.isRecurring`.
+- Wired through **all 8** `TaskMenuItems` call sites across `TodayView`
+ (`OverdueCard`, `UpcomingSection`), `MatrixView` (`QuadrantCard`,
+ `QuadrantDetailView` ×3), and `CalendarView` (main view, `DayTimelineView`,
+ the timeline-block context menu) — some needed a new optional closure
+ threaded through an intermediate wrapper view, others had `taskVM` directly.
+
+Files: `TaskItem.swift`, `TaskContextMenu.swift`, `TodayView.swift`,
+`MatrixView.swift`, `CalendarView.swift`.
+
+### Note
+Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/
+parens confirmed per-file via `git diff` (one file had a pre-existing +1 paren
+offset from an unrelated comment, verified via stash — not introduced by this
+change). `OverdueCard`'s wiring is technically unreachable today since
+`overdueTasks` already excludes recurring tasks by design — wired anyway for
+consistency in case that changes.
+
+---
+
+## KC-60 — Habit reminders also introduced in the in-app tutorial, not just onboarding
+
+**Status:** Implemented (not build-verified)
+**Reported by:** User: "do we have the behavior reminders as part of onboarding
+and also in the tutorial"
+
+### Description
+Habit reminders (KC-55) were added to Onboarding, but **not** to the app's
+separate tutorial system (`TutorialManager` — the spotlight/coachmark walkthrough
+shown per-tab: intro tour, Today tips, Workout tips). Someone who skips or
+doesn't linger on onboarding's toggles had no second chance to discover the
+feature exists.
+
+### Change
+- New fourth tutorial track in `TutorialManager`: `settingsTips` /
+ `settingsStep` / `settingsDone` (persisted key `kisani.tutorial.settings.v1`),
+ following the exact same pattern as the existing Today/Workout tip tracks
+ (`advanceSettings()`/`skipSettings()`).
+- One tip: "Habit Reminders" — introduces Prayer/Bed/Coffee and the private
+ streak via the notification's "Done" action.
+- Wired into `SettingsView` via the same `InViewTutorialCard` component
+ Today/Workout already use — shown once, the first time Settings is opened.
+
+Files: `TutorialManager.swift`, `SettingsView.swift`.
+
+### Note
+Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/
+parens confirmed; wiring mirrors `TodayView`/`WorkoutView`'s existing
+`tm.xIsActive` + `InViewTutorialCard` pattern exactly, reusing the same shared
+component rather than introducing a new one.
+
+---
+
+## KC-61 — Three fixes: onboarding note, Live Activity circle/stop control, habit notification actions
+
+**Status:** Implemented (not build-verified)
+**Reported by:** User (screenshot: a real device Live Activity + delivered
+"A moment to pause" prayer notification — confirms KC-55/56 are actually
+firing correctly on-device)
+
+### 1. Onboarding: clearer "change in Settings" note
+The Habits card's caption only mentioned times/custom-reminder, not the
+toggles themselves. Reworded: "You can turn any of these on or off — or
+fine-tune the exact times, or add a custom coffee reminder — anytime in
+Settings → Habits."
+
+### 2. Live Activity: removed the checkbox-look circle, added a Stop control
+`TaskLiveActivity`'s lock-screen card used `circle`/`checkmark.circle.fill` —
+read as an unchecked checkbox on a pure countdown display. Two honest
+technical notes acted on:
+- iOS gives apps **no API to block the system's own dismiss gesture** on a
+ Live Activity — not something any app can control, confirmed against
+ ActivityKit's public surface. Not attempted.
+- True "long-press reveals a menu" isn't how Live Activities work (that's
+ specific to `UNNotificationCategory` actions). The correct iOS 17+
+ equivalent is a **button embedded directly in the card**.
+
+Changes:
+- Circle → `hourglass` (informational, not checkbox-shaped) when incomplete.
+- New `StopCountdownIntent` (`LiveActivityIntent`, widget-extension target)
+ ends the activity via `Activity...end()` directly
+ from the card — no app launch needed.
+- Wired as an "×" button on the lock-screen card and a labeled "Stop
+ Countdown" button in the Dynamic Island's expanded region, both iOS 17+
+ gated (graceful no-op below that, matching the file's existing
+ `@available(iOS 16.1, *)` pattern).
+
+### 3. Habit notifications: two more actions beyond "Done"
+Consolidated the requested "not done / not today / don't track for today /
+mute for today / stop tracking" into two clear, non-redundant actions:
+- **"Not Today"** — dismisses only; intentionally a no-op beyond that, since
+ the habit tally only ever counts "Done" taps (KC-40's lifetime-tally
+ philosophy has no concept of a logged miss). Gives an explicit, discoverable
+ "I saw it, skipping" action instead of relying on a swipe.
+- **"Stop Tracking"** (`.destructive` style) — flips that habit's master
+ toggle off (so Settings reflects it) and immediately cancels every pending
+ notification for that type across all its slots, not just the one tapped.
+
+Files: `OnboardingView.swift`, `TaskLiveActivity.swift`,
+`StopCountdownIntent.swift` (new), `NotificationManager.swift`.
+
+### Note
+Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/
+parens confirmed per-file. `StopCountdownIntent` needs to land in the
+KisaniCalWidgets target (already covered by `project.yml`'s `sources: -path:
+KisaniCalWidgets` glob — no project.yml change needed).
+
+## KC-62 — Task row category pill replaced with TickTick-style icon glyphs
+
+Status: Implemented (not build-verified — no iOS runtime here)
+Reported by: User, from device screenshots ("reminder labeling a bit too
+much, let's do it just like TickTick... show countdown info too")
+Area: Today (task rows, both the top mini-timeline and the Tomorrow/Next 7
+Days/Later list)
+
+### Description
+Every task row rendered a `TagChip` text badge showing `task.category
+.rawValue` — for the vast majority of tasks (the default `.reminder`
+category) this just says "Reminder" on nearly every row, adding no real
+information. Confirmed only 2 call sites exist app-wide (`grep -rn
+"TagChip(" KisaniCal/Views/*.swift`), both in `TodayView.swift` — Matrix and
+Calendar don't render this pill at all, so "everywhere" (user's chosen
+scope) resolves to these two.
+
+### Change
+- Added `TaskCategory.glyph: String?` (`TaskItem.swift`) — maps each
+ category to a small SF Symbol, `nil` for `.reminder` (the generic default
+ stays unmarked, matching TickTick's "no badge unless it says something"
+ density): `.birthday → gift.fill`, `.domain → briefcase.fill`, `.annual →
+ calendar`, `.custom → tag.fill`.
+- **`TaskRowView`** (Tomorrow/Next 7 Days/Later rows — matches the user's
+ screenshot exactly): removed the `TagChip` call outright and folded the
+ category glyph into the row's existing ⏰/↻ icon `HStack`. This row
+ already carried a countdown (`countdownLabel(to:)`) next to the due date,
+ so "show countdown info too" was already satisfied — no countdown change
+ needed here.
+- **`TodayTLRow`** (top mini-timeline — Overdue/Today/Next 3 Days/Upcoming):
+ same glyph swap, and while in there also added the ⏰/↻ indicators this
+ row was missing (it only had the category pill before). No standalone
+ countdown text added here — the leading time-track column already shows
+ either the time-of-day or the short date (`MMM d`) for non-today entries,
+ so a duplicate "in X days" string would be redundant clutter in an already
+ dense timeline row.
+
+Files: `KisaniCal/Models/TaskItem.swift`, `KisaniCal/Views/TodayView.swift`.
+
+### Note
+Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/
+parens confirmed per-file (`TaskItem.swift` has one pre-existing stray `(`
+imbalance confirmed via `git stash` to predate this change — a comment
+artifact, not introduced here). `TagChip` itself is left intact in
+`SharedComponents.swift` since it's a generic shared component, just no
+longer called from these two sites.
+
+### Follow-up — right side was dead space
+Device screenshot of the "Later" list showed the icon column sitting empty
+for most rows (default `.reminder` tasks have no glyph, no alarm, no
+series), leaving a lone floating repeat icon on recurring rows and a big
+gap everywhere else. Rebalanced `TaskRowView`: the countdown (`in 1 wk`
+etc., previously crammed under the title on the left) moved to a trailing
+`VStack` on the right, with the icon row underneath it — so the row now
+reads title/date on the left, countdown/indicators on the right, instead of
+all metadata piled on the left and the right column empty. `TodayTLRow` was
+left as-is (its leading time-track column already fills that role there).
+
+### Follow-up 2 — typography polish
+User asked for the row text to be "easy on the eye" without increasing any
+font sizes. Impeccable was considered but is a no-op here (its `detect`
+engine only parses HTML, not Swift/SwiftUI — confirmed limitation from the
+F1 Pitt project setup). Applied by hand instead, sizes unchanged: title
+bumped from `.regular` to `.medium` weight for stronger hierarchy against
+the dimmer date/countdown line; both mono metadata lines (date and
+countdown) got `.tracking(0.2)` for legibility at 9.5pt; column spacing
+loosened slightly (2→3 left, 3→4 right) for breathing room between the two
+lines.
+
+## KC-63 — Workout card: keep the progress dots visible after finishing; confirm every Finish tap
+
+Status: Implemented (not build-verified — no iOS runtime here)
+Reported by: User, from device screenshots
+Area: Workout (`DotProgressCard`)
+
+### Description
+Two regressions from KC-52's completion-flow redesign, per the user directly:
+1. Tapping "Finish Workout" replaced the progress-dots view (33/33 sets,
+ 100%) with a green "Workout logged / Undo" takeover — and there was no
+ way back to the dots short of Undo. User: "it gets stuck here and i cant
+ go back to 100% progress view... please only maintain this view of
+ progress."
+2. "Finish Workout" skipped the confirmation dialog whenever all sets were
+ already checked (KC-52 follow-up 2's stated behavior: "nothing to
+ override"). User now wants every Finish tap confirmed, to avoid
+ accidental toggles — same bar as Clear All / Didn't Train already have.
+
+### Change
+- `DotProgressCard.body`: the `PROGRESS` label + `DotGridProgress` now
+ render unconditionally, outside the `isDoneToday`/`isMissedToday` branch.
+ Only the row below it switches between `actionRow` (pending) and
+ `undoRow` (done/missed) — the dots themselves never disappear.
+- Finish button now always sets `pendingAction = .finish` instead of
+ short-circuiting straight to `onFinish()` at 100% sets — every Finish tap
+ goes through the existing `confirmationDialog`. Clear All Sets already
+ confirmed (KC-52 follow-up 2); no change needed there.
+
+Files: `KisaniCal/Views/WorkoutView.swift`.
+
+### Note
+Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/
+parens confirmed for the file.
+
+### Follow-up — Finish-at-100% confirmation reverted
+User pushed back on the "confirm every Finish tap" part: at 100% sets,
+tapping "Finish Workout" going through a confirmation dialog was
+unwanted friction, not a fix — restored the KC-52 behavior where Finish
+skips the dialog only when `done == total` (nothing to override at that
+point). Partial-completion "Finish Anyway" still confirms. The
+always-visible progress dots from this issue's first change are untouched.
+
+## KC-64 — Xcode warning: deprecated `Activity.end(using:dismissalPolicy:)`
+
+Status: Implemented (not build-verified — no iOS runtime here)
+Reported by: User, Xcode warning screenshot ("'end(using:dismissalPolicy:)'
+was deprecated in iOS 16.2: Use end(content:dismissalPolicy:)")
+Area: Live Activities
+
+### Description
+Both Live Activity "stop" call sites called `activity.end(dismissalPolicy:
+.immediate)` with no content argument. ActivityKit has two overloads —
+`end(_:dismissalPolicy:)` (current) and the deprecated `end(using:
+dismissalPolicy:)` — and omitting the first argument entirely resolved to
+the deprecated one.
+
+### Change
+Pass `nil` explicitly as the unlabeled first argument so it binds to the
+non-deprecated overload: `activity.end(nil, dismissalPolicy: .immediate)`.
+
+Files: `KisaniCal/Managers/LiveActivityManager.swift`,
+`KisaniCalWidgets/StopCountdownIntent.swift`.
+
+## KC-65 — TaskItem never actually stored a creation date
+
+Status: Implemented (not build-verified — no iOS runtime here)
+Reported by: User, from a Track Countdown lock-screen widget stuck at 0%
+Area: Model / widgets (Track Countdown progress dots)
+
+### Description
+The countdown-dots widget (`KisaniCalWidgets.swift`'s `eventStartDate`) was
+already written to prefer `task.createdAt` as the progress bar's start
+date — but `TaskItem` never had a `createdAt` property at all, so it always
+decoded to `nil` and every task fell through to a fallback chain: a
+per-task "first render" anchor persisted in `UserDefaults.kisani`
+(`kisani.widget.progressDots.anchor.`), or failing that, an inferred
+legacy start (`remainingDays * 2`, capped 30–365 days). That anchor can
+reset to "now" under several conditions (widget re-added, storage cleared,
+the `looksLikeUpgradeAnchor` heuristic firing) — which is exactly the
+"starting from scratch on every update" behavior the user was seeing.
+
+### Change
+Added `var createdAt: Date? = Date()` to `TaskItem` (`Models/TaskItem.swift`)
+— optional so existing saved tasks decode safely (missing key → `nil`,
+same pattern as `recurrenceEnd`), but the `Date()` default is evaluated
+fresh at each construction site, so every newly created task now genuinely
+records its creation moment. No call sites needed updating: `addTask` uses
+the compiler-synthesized memberwise init and doesn't pass `createdAt`
+explicitly, so it gets the live default; `occurrenceCopy` copies the
+struct (`var c = t`), so recurring-task instances correctly inherit the
+series' original `createdAt` rather than getting a fresh one.
+
+The widget-side plumbing (`WidgetData.swift`'s `RawTask`/`WidgetTask`
+decode, `eventStartDate`'s preference order) already existed and needed no
+change — it was just never being fed a real value.
+
+Files: `KisaniCal/Models/TaskItem.swift`.
+
+### Note
+Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/
+parens (one pre-existing stray `(` predates this change, confirmed earlier
+in KC-62). Existing tasks (created before this fix) still have no real
+creation date and will keep using the anchor/inferred fallback — there's
+no way to retroactively know when they were actually created.
+
+## KC-66 — Live Activity build error + second countdown widget missing createdAt
+
+Status: Implemented (not build-verified — no iOS runtime here)
+Reported by: User, Xcode error screenshot + follow-up request to audit all
+widgets for creation-date usage
+Area: Live Activities, Widgets
+
+### 1. Build error: `end(_:dismissalPolicy:)` needs iOS 16.2, file targets 16.1
+KC-64's fix picked the non-deprecated ActivityKit overload, but
+`LiveActivityManager.end(taskID:)` is only gated `@available(iOS 16.1, *)`
+— matching Live Activities' own minimum — so the call didn't compile for
+16.1.x. Branched on `#available(iOS 16.2, *)`: the new overload when
+available, falling back to the deprecated `end(using:dismissalPolicy:)`
+below that. `StopCountdownIntent.swift` needed no change — it's already
+gated `@available(iOS 17.0, *)`, well above 16.2.
+
+Files: `KisaniCal/Managers/LiveActivityManager.swift`.
+
+### 2. Audit: does every countdown widget use the task's real creation date?
+KC-65 fixed `KisaniCalWidgets.swift`'s `ProgressDotProvider` (the "Track
+Countdown" dot-grid widget, driven by App Group task data) to prefer
+`task.createdAt`. Auditing the rest turned up a second, separate countdown
+widget — `EventCountdownWidget.swift`'s `EventBarsWidget`/`EventProvider`
+(the "Event Countdown (Bars)" widget family, config-driven via
+`EventConfigIntent`/`EventEntity`) — whose `resolveSpan` had its own
+independent start-date resolution that never looked at `createdAt` at all:
+explicit start (tracked-since date, or the widget's configured "Counting
+from") → `storedAnchor` (first-seen fallback) only. Confirmed via `grep
+createdAt EventCountdownWidget.swift` returning nothing before this fix.
+`WidgetViews.swift`'s progress-rendering views (`ProgressDotView`,
+`BarGrid`, etc.) only consume an already-computed `progress` value, so
+they needed no change — the two `TimelineProvider`s are the only places
+that resolve a start date, and both are now fixed.
+
+### Change
+- Added `createdAt: Date? = nil` to `EventEntity` (the App-Group-backed
+ event picker's entity type), populated in both places that build it:
+ `EventQuery.allEvents()` and `EventProvider.upcomingEvents(asOf:)`.
+- `resolveSpan(...)` gained a `taskCreatedAt: Date? = nil` parameter,
+ checked after `explicitStart` (an explicit user choice, e.g. the
+ widget's "Counting from" field, still wins) and before `storedAnchor`
+ (the last-resort fallback for tasks created before KC-65, or fully
+ custom events with no task behind them at all).
+- All three call sites that resolve a real task's span (tracked event,
+ upcoming-queue auto-select, manually configured event) now pass
+ `taskCreatedAt:`. The fully-custom-event branch (no task, just a typed
+ name + date) is unaffected — there's no task to have a creation date.
+
+Files: `KisaniCalWidgets/EventCountdownWidget.swift`.
+
+### Note
+Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/
+parens confirmed per-file. Same caveat as KC-65: tasks created before that
+fix still have no real `createdAt` and fall through to the anchor/inferred
+heuristics in both widgets.
+
+## KC-67 — Watch/Health workout streak got stomped by a stale iCloud sync
+
+Status: Implemented (not build-verified — no iOS runtime here)
+Reported by: User — streak reset to 3 after a workout was already logged
+by the Watch, plus a request for Finish/Clear-All confirmations and to
+remove Undo
+Area: Workout (`DotProgressCard`, `WorkoutViewModel`, `CloudSyncManager`)
+
+### Root cause
+`workoutDates` is a lifetime tally (KC-40 — `Set(workoutDates).count`,
+append-only by design) and every local write path already respects that:
+`logWorkoutCompleted` dedups by key before appending, `syncFromHealthKit`
+only unions HealthKit's dates in. But `CloudSyncManager.kvStoreChanged` —
+the handler for "iCloud KV store changed externally" — did a **blind
+overwrite**: `UserDefaults.kisani.set(val, forKey: key)` for every changed
+key, no merge. Since `save()` pushes `workoutDates` to iCloud on every
+write (including the Watch/HealthKit sync path), a stale iCloud snapshot
+arriving after a fresh local write (races are inherent to `NSUbiquitous
+KeyValueStore` — it's eventually consistent) would silently replace the
+correct, larger local array with an older, smaller one — exactly matching
+"the workout was already logged by the Watch, then tapping Finish reset
+the streak."
+
+### Change
+- `CloudSyncManager.kvStoreChanged`: for keys matching
+ `kisani.workout.completedDates.*`, union the incoming iCloud value with
+ whatever's already local instead of overwriting — an external change can
+ now only ever add days, never erase ones this device already knows
+ about. Every other synced key keeps the existing overwrite behavior
+ (unaffected — this tally is the only append-only-by-design value among
+ them).
+- `DotProgressCard`: removed the `onUndo` action, its row, and the
+ `isDoneToday`/`isMissedToday` branch that showed it — the done/missed
+ state now shows the status header + progress dots with no action row
+ underneath at all (per "remove the Undo action from the logged workout
+ state"). Watch/Health is the source of truth once a day is logged;
+ there's no in-card way to contest that.
+- Removed `onUndo:` at the `DotProgressCard(...)` call site in
+ `WorkoutView.swift`, and deleted `WorkoutViewModel.unmarkWorkoutDone`/
+ `unmarkWorkoutMissed` (now dead — Undo was their only caller).
+- Re-added the confirmation dialog on "Finish Workout" (previously
+ reverted in KC-63's follow-up at the user's request; re-requested here
+ alongside the streak fix) — every state-changing action on the card
+ (Finish, Check All, Clear All, Didn't Train) now confirms first.
+
+Files: `KisaniCal/Managers/CloudSyncManager.swift`,
+`KisaniCal/Views/WorkoutView.swift`, `KisaniCal/Models/ExerciseModels.swift`.
+
+### Note
+Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/
+parens confirmed per-file. Structurally, the action row (with the Finish
+button) is only ever rendered when `isDoneToday`/`isMissedToday` are both
+false, so a watch-logged day already hides Finish entirely rather than
+needing a runtime guard against double-logging — the real gap was the
+CloudSync race above, not the button itself.
+
+## KC-68 — Bring back Undo, but only for manually-logged days
+
+Status: Implemented (not build-verified — no iOS runtime here)
+Reported by: User — tested Finish Workout to confirm KC-67 and got stuck
+with no way back since KC-67 removed Undo entirely
+Area: Workout (`DotProgressCard`, `WorkoutViewModel`)
+
+### Description
+KC-67 removed Undo outright on the reasoning "Watch/Health is the source of
+truth once a day is logged." That's correct for a Watch/Health-detected
+day, but it also blocked undoing a day the user logged manually through
+the in-app Finish button (e.g. while testing) — there was no way back
+short of waiting for a new calendar day. User wants Undo back, but only
+when the day wasn't Watch/Health-sourced, and confirmed first.
+
+### Change
+- `workoutDates` (the lifetime tally) didn't previously track *how* a day
+ was logged. Added `manualWorkoutDates: [String]` (own storage key
+ `kisani.workout.manualDates.`, loaded/saved/pushed to iCloud
+ alongside the other workout keys) — the subset of `workoutDates` logged
+ via the in-app Finish button.
+- `logWorkoutCompleted` (only ever called from the manual `markWorkoutDone`
+ path, never from `syncFromHealthKit`) now also records the date into
+ `manualWorkoutDates`. If the day is already in `workoutDates` (e.g.
+ Watch/Health got there first), the existing dedup guard makes this whole
+ function a no-op — so a Watch-logged day can never retroactively become
+ "manual."
+- Added `WorkoutViewModel.isManuallyLogged(on:)` and restored
+ `unmarkWorkoutDone(on:)` (removes from both `workoutDates` and
+ `manualWorkoutDates`).
+- `DotProgressCard` gained `isManuallyLogged`/`onUndo` params back. Undo is
+ now shown only when `isDoneToday && isManuallyLogged` (a Watch/Health day
+ still shows nothing extra below the dots, per KC-67), and tapping it
+ goes through a new `.undo` case in the existing `confirmationDialog`
+ ("Undo today's logged workout? ... won't affect anything recorded by
+ Health or your Watch") rather than acting immediately — the bare Undo
+ button KC-67 removed had no confirmation at all.
+
+Files: `KisaniCal/Models/ExerciseModels.swift`,
+`KisaniCal/Views/WorkoutView.swift`.
+
+### Note
+Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/
+parens confirmed per-file. Deliberately did NOT extend the KC-67 iCloud
+union-merge fix to the new `manualDates` key — unlike the lifetime tally,
+this set is meant to shrink when the user undoes, and merging it
+across-devices-only-grows would make an undo silently un-do itself on
+another device. Worst case for this key without that protection is a rare
+multi-device timing quirk in whether Undo is offered, not a data-integrity
+bug like KC-67's.
+
+## KC-69 — Consolidate 3 duplicate recurrence implementations before adding "every N"
+
+Status: Implemented and verified via standalone `swiftc` test run (see below)
+— the pure-logic part of this session's only change that's actually been
+executed, not just read
+Reported by: User — asked for confidence to be improved before building a
+requested "every 2 weeks / every N months" recurrence feature
+Area: Recurrence engine (main app + both widget extensions)
+
+### Description
+Before adding interval support, audited every place recurrence-window math
+lives and found THREE independent hand-written copies that would all need
+identical interval logic added, with no shared code to guarantee they
+stayed in sync:
+1. `TaskItem.isOccurrence` (main app — occurrence matching for Calendar,
+ Matrix, Today's list; everything else in the model funnels through
+ this one function, so it was already well-centralized on the app side)
+2. `KisaniCalWidgets.swift`'s `recurrenceSpan` (two overloads — the "Track
+ Countdown" dot-grid widget's progress window)
+3. `EventCountdownWidget.swift`'s `recurrenceSpan` — a near-identical copy
+ of #2, for the separate "Event Countdown (Bars)" widget family
+
+### Change
+- New `Shared/RecurrenceRule.swift` — pure Foundation, zero SwiftUI/
+ WidgetKit/UIKit dependencies. `isOccurrence(label:interval:start:date:
+ end:calendar:)` and `span(label:interval:due:now:calendar:)`, both
+ defaulting `interval` to 1. Added to BOTH targets' `sources` in
+ `project.yml` (a new top-level `Shared/` path, alongside the existing
+ precedent of individual files shared into `KisaniCalWidgets` from
+ `KisaniCal/Managers/`) — compiled into the app and the widget extension
+ as the literal same code, not copy-pasted.
+- All three call sites above now delegate to `RecurrenceRule` instead of
+ each having their own switch/date-walk. `EventCountdownWidget.swift`'s
+ `recurrenceSpan` shrank from a ~25-line implementation to a 1-line
+ delegation.
+- Verified every case is mathematically identical to the pre-existing
+ behavior at `interval == 1` (the implicit default before this change) —
+ e.g. Weekly's `days % 7 == 0` becomes `days % (7×N) == 0`, identical at
+ N=1. `KisaniCalTests/RecurrenceTests.swift` (an existing XCTest suite
+ covering `TaskItem.isOccurrence`) exercises exactly this path and should
+ still pass unchanged once run on a simulator.
+- Wrote a standalone test driver (not committed — scratch file) and
+ compiled+ran it directly against the real `Shared/RecurrenceRule.swift`
+ via `swiftc` (no Xcode/simulator needed, since this file has no
+ framework dependencies) — 38/38 tests passing, including the edge cases
+ that mattered most for confidence: a task due Jan 31 repeating monthly
+ (Feb has no 31st), a yearly task anchored on a Feb 29 leap day, interval
+ backward-compat at N=1 for every label, and `recurrenceEnd` bounding.
+ This is the one piece of SwiftUI-adjacent work this session that's been
+ actually executed and verified rather than only self-reviewed by reading.
+
+Files: `Shared/RecurrenceRule.swift` (new), `project.yml`,
+`KisaniCal/Models/TaskItem.swift`, `KisaniCalWidgets/KisaniCalWidgets.swift`,
+`KisaniCalWidgets/EventCountdownWidget.swift`.
+
+### Note
+This is groundwork only — `TaskItem` still has no `recurrenceInterval`
+field and nothing calls `RecurrenceRule` with `interval != 1` yet. The
+actual "every N weeks/months" feature (model field, picker UI, quick-add
+NLP) is still pending, to be built on top of this now-consolidated,
+now-tested engine. `xcodegen generate` re-run cleanly after the
+`project.yml` change; not build-verified in Xcode itself (no iOS runtime
+here), though the standalone test above is real, executed verification of
+the actual shipped file.
+
+## KC-70 — "Every N weeks/months/days/years" recurrence
+
+Status: Implemented (not build-verified — no iOS runtime here, but the core
+recurrence math and the quick-add regex are both real, executed-and-passing
+standalone verification — see Note)
+Reported by: User — "Look into TRU housing options every two weeks," and
+generalized to "every 1 week or 2 or 3," "every 2 months," etc.
+Area: Task model, task editor (Matrix + quick-add), quick-add NLP, both
+countdown widgets
+
+### Description
+Recurrence previously only supported 5 fixed rules (Daily/Weekly/Monthly/
+Yearly/Every Weekday) with no interval — "every 2 weeks" or "every 3
+months" had no representation. Built on top of KC-69's consolidated
+`RecurrenceRule` engine (which already accepted an `interval` parameter in
+anticipation of this).
+
+### Change
+- `TaskItem.recurrenceInterval: Int? = nil` — optional, same safe-decode
+ pattern as every other recurrence field; nil means 1 everywhere, so
+ every existing task is unaffected.
+- `TaskItem.recurrenceDisplayLabel` — "Weekly" stays "Weekly" at interval
+ 1, becomes "Every 2 Weeks" etc. above that. Delegates to a new
+ `RecurrenceRule.displayLabel(label:interval:)` (Shared/) rather than a
+ 4th hand-written copy of this formatting.
+- `TaskDatePickerSheet` (the shared create/edit sheet used by both Matrix's
+ task editor and quick-add): new "Every N" `Stepper` row (1...30),
+ visible whenever a repeat unit other than "Every Weekday" is selected.
+ `repeatDisplayValue` switches to the "Every N X" format once N > 1.
+- `addTask`/`updateTask` (`TaskItem.swift`) and both `TaskDatePickerSheet`
+ call sites (`MatrixView.swift`, `TodayView.swift`) thread the new field
+ through.
+- Quick-add NLP (`NLTaskParser.parseRecurrence`): new
+ `parseIntervalRecurrence` recognizes "every 2 weeks," "every three
+ months," "biweekly," "fortnightly," "bimonthly" (checked before the
+ existing plain-unit patterns, which stay untouched). "every 1 X" falls
+ through unrecognized — matches how "every week" behaves today, not a
+ regression.
+- Widgets: `WidgetTask`/`RawTask` (`WidgetData.swift`) and `EventEntity`
+ (`EventCountdownWidget.swift`) gained `recurrenceInterval`, threaded
+ through both countdown widgets' span calculations (`KisaniCalWidgets
+ .swift`'s `recurrenceSpan(for:)`, `EventCountdownWidget.swift`'s
+ `resolveSpan`/`recurrenceSpan`) so a biweekly task's countdown bar sizes
+ correctly in both widget families, not just the main app's Calendar/
+ Matrix/Today views.
+
+Files: `KisaniCal/Models/TaskItem.swift`, `KisaniCal/Views/TodayView.swift`,
+`KisaniCal/Views/MatrixView.swift`, `Shared/RecurrenceRule.swift`,
+`KisaniCalWidgets/WidgetData.swift`, `KisaniCalWidgets/KisaniCalWidgets.swift`,
+`KisaniCalWidgets/EventCountdownWidget.swift`.
+
+### Note
+Not build-verified in Xcode (no iOS runtime here). Two things WERE
+actually verified by compiling and running standalone `swiftc` drivers
+against the real logic (not copies, not just read):
+1. `RecurrenceRule` itself — 44/44 cases (see KC-69), unchanged by this
+ commit.
+2. The new quick-add regex patterns — extracted into a standalone driver
+ and run against realistic phrases including the user's own example
+ ("look into tru housing options every two weeks" → Weekly/2) plus
+ biweekly/fortnightly/bimonthly and negative cases (plain "every week"
+ correctly does NOT trigger the interval path) — 11/11 passing.
+The SwiftUI layer itself (the Stepper row, bindings, sheet wiring) is
+self-reviewed only — balanced braces/parens confirmed per-file, one
+pre-existing stray `(` in `TaskItem.swift` predates this (see KC-62).
+
+## KC-71 — Finish release.yml's TestFlight upload (Xcode Cloud ruled out for this repo)
+
+Status: Implemented — `release.yml` updated; live end-to-end run still
+pending the user adding the App Store Connect API key as Gitea secrets
+Reported by: User — wanted automated TestFlight delivery on merge to main
+Area: CI/CD (`.gitea/workflows/release.yml`)
+
+### Description
+Spent a long session trying to connect Apple's Xcode Cloud directly to this
+repo's self-hosted Gitea instance. Concluded it's not achievable:
+- Gitea was only reachable on the LAN (`10.10.1.21:3002`, plain HTTP) —
+ fixed by standing up Tailscale Funnel on the host (`titantwo`), giving
+ real public HTTPS at `https://titantwo.tail05af18.ts.net`. Also switched
+ the local `origin` remote to this URL (user's explicit call — the old
+ LAN address is no longer used anywhere).
+- Xcode Cloud's "GitHub Enterprise" provider option 404s against Gitea —
+ GitHub Enterprise's API lives at a fixed `/api/v3/` path, Gitea's is
+ `/api/v1/`.
+- Its "GitLab Self-Managed" option rejects Gitea's OAuth2 Client IDs — App
+ Store Connect strictly requires a 64-character hex string (real GitLab's
+ ID format); Gitea's OAuth2 provider generates 36-character UUIDs. Not
+ fixable from the Gitea side — confirmed by actually creating a real
+ Gitea OAuth2 application and having App Store Connect reject its ID.
+- Bitbucket Server wasn't tried — the same class of hard-coded-provider-
+ format problem was already proven twice, not worth a third confirmation.
+
+Conclusion: Apple only supports Gitea if it happens to be API/URL-
+compatible with GitHub/GitLab/Bitbucket's *specific* hosted or enterprise
+products, and it isn't. Pivoted to finishing the automation this repo
+already had a placeholder for instead.
+
+### Change
+`release.yml`'s TestFlight upload step was a commented-out placeholder
+(altool-based) since it was originally set up. Replaced it with the
+currently-supported mechanism — `xcrun altool`'s own upload path was
+retired by Apple in 2023, so the placeholder would not have worked even if
+uncommented as-is. Now: write the App Store Connect API key (from a new
+Gitea secret) to `~/private_keys/AuthKey_.p8`, then pass
+`-authenticationKeyPath`/`-authenticationKeyID`/`-authenticationKeyIssuerID`
+directly to the existing `xcodebuild -exportArchive` call — this exports
+AND uploads to TestFlight in one step, no separate upload tool needed.
+Key file is deleted in an `if: always()` cleanup step regardless of
+success/failure.
+
+Needs 3 new Gitea secrets (Settings → Actions → Secrets), from an App
+Store Connect API key with App Manager role:
+`APP_STORE_CONNECT_API_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`,
+`APP_STORE_CONNECT_API_KEY_P8`.
+
+Files: `.gitea/workflows/release.yml`.
+
+### Note
+Not run end-to-end yet — needs the user to generate the App Store Connect
+API key and add the 3 secrets before the next merge to `main` actually
+exercises this path. `ExportOptions.plist` needed no changes (already
+`method: app-store` with the correct team ID).
+
+## KC-72 — Fix StreakLogicTests compile error + a genuinely broken CI runner
+
+Status: Implemented and build-verified (see Note — this is the first entry
+all session with real `xcodebuild` verification, not just self-review)
+Reported by: Discovered while testing KC-71's release pipeline
+Area: CI infra + `KisaniCalTests/StreakLogicTests.swift`
+
+### Description
+Two separate, real problems surfaced while trying to actually exercise the
+CI/CD pipeline for the first time all session:
+
+1. **The self-hosted runner (`act_runner` on Serenity) was broken all
+ session.** It registered with Gitea successfully ("declare successfully"
+ in its log) but its job-polling loop never actually started — zero log
+ output afterward even at debug level, confirmed across multiple
+ restarts. Its log showed no successful job since 01:52 that morning,
+ meaning it silently missed every one of ~20 pushes to `develop`
+ throughout the session. Root cause not fully isolated (stuck goroutine,
+ suspected); fixed by a full re-registration from scratch (fresh token,
+ delete `.runner`, `act_runner register --no-interactive`, reload the
+ LaunchAgent) rather than continuing to debug the stuck process.
+2. **Once CI actually ran for the first time, it immediately caught a
+ real, pre-existing compile error**, unrelated to anything built
+ tonight: `StreakLogicTests.swift` calls `WorkoutViewModel
+ .totalAchievedWorkoutDays(...)` and `TaskViewModel.completionCount
+ (...)`, both `@MainActor`-isolated, from a test class that wasn't
+ itself marked `@MainActor` — a Swift 6 strict-concurrency error.
+ `RecurrenceTests.swift` in the same target already uses the correct
+ pattern (`@MainActor final class RecurrenceTests: XCTestCase`);
+ `StreakLogicTests` was just missing the same annotation.
+
+### Change
+- Added `@MainActor` to `StreakLogicTests`'s class declaration.
+- Fixed the runner registration (infra only, no code change).
+
+Files: `KisaniCalTests/StreakLogicTests.swift`.
+
+### Note
+**This is the one entry all session that's actually build-verified, not
+self-reviewed.** Ran the full test suite locally via `xcodebuild test`
+on a real simulator (iPhone 17 Pro, `DEVELOPER_DIR` pointed at the
+external-volume Xcode) — **78/78 tests pass, 0 failures** — which
+incidentally also confirms every other change from KC-51 through KC-71
+this session actually compiles cleanly, since they're all in the same
+build. Long-pending session task "Run XCTest suite on simulator" is now
+done.
diff --git a/KisaniCal/Managers/CloudSyncManager.swift b/KisaniCal/Managers/CloudSyncManager.swift
index 60b1e78..12f1201 100644
--- a/KisaniCal/Managers/CloudSyncManager.swift
+++ b/KisaniCal/Managers/CloudSyncManager.swift
@@ -84,7 +84,17 @@ final class CloudSyncManager {
else { return }
for key in changedKeys {
- if let val = kv.object(forKey: key) {
+ guard let val = kv.object(forKey: key) else { continue }
+ if key.hasPrefix("kisani.workout.completedDates."), let incoming = val as? [String] {
+ // Workout dates are an append-only lifetime tally, and the Watch/
+ // HealthKit sync writes here independently of this device — a
+ // blind overwrite can race a fresh local write with a stale
+ // iCloud snapshot and silently drop already-logged days. Union
+ // instead, so an external change can only ever add days, never
+ // erase ones this device already knows about.
+ let existing = Set(UserDefaults.kisani.stringArray(forKey: key) ?? [])
+ UserDefaults.kisani.set(Array(existing.union(incoming)), forKey: key)
+ } else {
UserDefaults.kisani.set(val, forKey: key)
}
}
diff --git a/KisaniCal/Managers/HealthKitManager.swift b/KisaniCal/Managers/HealthKitManager.swift
index ef35b49..7ef94f7 100644
--- a/KisaniCal/Managers/HealthKitManager.swift
+++ b/KisaniCal/Managers/HealthKitManager.swift
@@ -160,6 +160,51 @@ final class HealthKitManager: ObservableObject {
}
}
+ // MARK: - Historical daily reference (for analytics)
+
+ /// Per-day steps / active calories / resting heart rate over a range, keyed by
+ /// "yyyy-MM-dd". One bucketed statistics query per metric (efficient over a
+ /// year). HealthKit is the source of truth; values are read-only reference.
+ func dailyReference(from start: Date, to end: Date) async -> [String: (steps: Int?, calories: Int?, restingHR: Int?)] {
+ guard authorized, isAvailable else { return [:] }
+ let anchor = Calendar.current.startOfDay(for: start)
+ let interval = DateComponents(day: 1)
+
+ async let steps = statsCollection(.stepCount, unit: .count(), options: .cumulativeSum, anchor: anchor, end: end, interval: interval)
+ async let cals = statsCollection(.activeEnergyBurned, unit: .kilocalorie(), options: .cumulativeSum, anchor: anchor, end: end, interval: interval)
+ async let hr = statsCollection(.restingHeartRate, unit: HKUnit(from: "count/min"), options: .discreteAverage, anchor: anchor, end: end, interval: interval)
+ let (s, c, h) = await (steps, cals, hr)
+
+ var out: [String: (steps: Int?, calories: Int?, restingHR: Int?)] = [:]
+ for key in Set(s.keys).union(c.keys).union(h.keys) {
+ out[key] = (s[key].map(Int.init), c[key].map(Int.init), h[key].map(Int.init))
+ }
+ return out
+ }
+
+ private func statsCollection(_ id: HKQuantityTypeIdentifier, unit: HKUnit, options: HKStatisticsOptions,
+ anchor: Date, end: Date, interval: DateComponents) async -> [String: Double] {
+ guard let type = HKQuantityType.quantityType(forIdentifier: id) else { return [:] }
+ return await withCheckedContinuation { cont in
+ let q = HKStatisticsCollectionQuery(quantityType: type, quantitySamplePredicate: nil,
+ options: options, anchorDate: anchor, intervalComponents: interval)
+ q.initialResultsHandler = { _, results, _ in
+ var dict: [String: Double] = [:]
+ let fmt = DateFormatter()
+ fmt.calendar = Calendar.current; fmt.timeZone = Calendar.current.timeZone
+ fmt.locale = Locale(identifier: "en_US_POSIX"); fmt.dateFormat = "yyyy-MM-dd"
+ results?.enumerateStatistics(from: anchor, to: end) { stat, _ in
+ let quantity = options.contains(.cumulativeSum) ? stat.sumQuantity() : stat.averageQuantity()
+ if let v = quantity?.doubleValue(for: unit), v > 0 {
+ dict[fmt.string(from: stat.startDate)] = v
+ }
+ }
+ cont.resume(returning: dict)
+ }
+ store.execute(q)
+ }
+ }
+
// MARK: - Type sets
private var readTypes: Set {
diff --git a/KisaniCal/Managers/LiveActivityManager.swift b/KisaniCal/Managers/LiveActivityManager.swift
index 0723c76..270d53f 100644
--- a/KisaniCal/Managers/LiveActivityManager.swift
+++ b/KisaniCal/Managers/LiveActivityManager.swift
@@ -61,7 +61,14 @@ enum LiveActivityManager {
Task {
for activity in Activity.activities
where activity.attributes.taskID == taskID {
- await activity.end(dismissalPolicy: .immediate)
+ // end(_:dismissalPolicy:) needs iOS 16.2; this file supports
+ // 16.1 (Live Activities' own minimum), so fall back to the
+ // deprecated using:dismissalPolicy: overload below that.
+ if #available(iOS 16.2, *) {
+ await activity.end(nil, dismissalPolicy: .immediate)
+ } else {
+ await activity.end(using: nil, dismissalPolicy: .immediate)
+ }
}
}
}
diff --git a/KisaniCal/Managers/NotificationManager.swift b/KisaniCal/Managers/NotificationManager.swift
index 0172d28..60f4099 100644
--- a/KisaniCal/Managers/NotificationManager.swift
+++ b/KisaniCal/Managers/NotificationManager.swift
@@ -20,6 +20,10 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
private static let logWorkoutActId = "LOG_WORKOUT"
private static let missedWorkoutActId = "MISSED_WORKOUT"
private static let snoozeWorkoutActId = "SNOOZE_WORKOUT"
+ private static let habitCatId = "HABIT_LOG"
+ private static let habitDoneActId = "HABIT_DONE"
+ private static let habitNotTodayActId = "HABIT_NOT_TODAY"
+ private static let habitStopTrackingActId = "HABIT_STOP_TRACKING"
override private init() {
super.init()
@@ -49,7 +53,15 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
actions: [logAction, missedAction, snoozeAction],
intentIdentifiers: [], options: .customDismissAction)
- center.setNotificationCategories([taskCat, confirmCat])
+ let habitDoneAction = UNNotificationAction(identifier: Self.habitDoneActId, title: "Done", options: [])
+ let habitNotTodayAction = UNNotificationAction(identifier: Self.habitNotTodayActId, title: "Not Today", options: [])
+ let habitStopAction = UNNotificationAction(identifier: Self.habitStopTrackingActId, title: "Stop Tracking",
+ options: .destructive)
+ let habitCat = UNNotificationCategory(identifier: Self.habitCatId,
+ actions: [habitDoneAction, habitNotTodayAction, habitStopAction],
+ intentIdentifiers: [], options: .customDismissAction)
+
+ center.setNotificationCategories([taskCat, confirmCat, habitCat])
}
// MARK: - Complete task directly in UserDefaults (called from background)
@@ -138,6 +150,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
self.scheduleTasks(snapshot: tasks, urgentIds: urgentIds)
self.scheduleCalendarEvents(snapshot: calEvents)
self.schedulePeriodMilestones()
+ self.scheduleHabitReminders()
self.updateBadge(snapshot: tasks)
}
}
@@ -180,6 +193,198 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
add(Self.periodIds[3], "This Year", "One more year passed.", year)
}
+ // MARK: - Habit reminders (prayer, bed-making, coffee/caffeine)
+
+ private static let habitPrefix = "kisani.habit"
+
+ /// Named slot definitions shared by Prayer and Coffee — (storage key
+ /// fragment, default hour, default minute, default enabled-when-master-on).
+ private static let prayerSlots: [(key: String, hour: Int, minute: Int, defaultOn: Bool)] = [
+ ("Morning", 7, 0, true), ("Afternoon", 13, 0, true), ("Evening", 18, 0, true), ("Night", 21, 0, true),
+ ]
+ private static let coffeeSlots: [(key: String, hour: Int, minute: Int, defaultOn: Bool)] = [
+ ("Morning", 8, 0, true), ("Midday", 12, 0, false), ("Afternoon", 15, 0, false), ("Evening", 18, 0, false),
+ ]
+
+ /// Daily (every day, not weekday-scoped) habit nudges. Reads from
+ /// `UserDefaults.kisani` — the App Group suite — so it must match wherever
+ /// the Settings/Onboarding toggles actually write (see the `store:` param
+ /// on those `@AppStorage` declarations).
+ private func scheduleHabitReminders() {
+ let ud = UserDefaults.kisani
+ var wanted = Set()
+
+ // `habitType` drives the "Done" action's log bucket ("bed"/"prayer"/
+ // "coffee") — tapping Done on any slot of a type logs one entry for
+ // that type today (deduped), feeding a simple lifetime "days done"
+ // count. Never touches the task list or Activity History.
+ func addDaily(id: String, enabled: Bool, hour: Int, minute: Int, title: String, body: String, habitType: String) {
+ guard enabled else { return }
+ wanted.insert(id)
+ let content = UNMutableNotificationContent()
+ content.title = title; content.body = body; content.sound = .default
+ content.categoryIdentifier = Self.habitCatId
+ content.userInfo = ["habitType": habitType]
+ var comps = DateComponents(); comps.hour = hour; comps.minute = minute
+ center.add(UNNotificationRequest(
+ identifier: id, content: content,
+ trigger: UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)
+ ))
+ }
+
+ // Bed-making — a single daily nudge, the simplest possible "start the
+ // day with a win."
+ addDaily(
+ id: "\(Self.habitPrefix).bed",
+ enabled: ud.object(forKey: "bedReminderEnabled") as? Bool ?? false,
+ hour: ud.object(forKey: "bedReminderHour") as? Int ?? 8,
+ minute: ud.object(forKey: "bedReminderMinute") as? Int ?? 0,
+ title: "Rise and shine",
+ body: "Have you made your bed? Small win, great start to the day.",
+ habitType: "bed"
+ )
+
+ // Prayer — up to 4 independently-toggleable times a day.
+ let prayerOn = ud.object(forKey: "prayerReminderEnabled") as? Bool ?? false
+ for slot in Self.prayerSlots {
+ addDaily(
+ id: "\(Self.habitPrefix).prayer.\(slot.key)",
+ enabled: prayerOn && (ud.object(forKey: "prayer\(slot.key)Enabled") as? Bool ?? slot.defaultOn),
+ hour: ud.object(forKey: "prayer\(slot.key)Hour") as? Int ?? slot.hour,
+ minute: ud.object(forKey: "prayer\(slot.key)Minute") as? Int ?? slot.minute,
+ title: "A moment to pause",
+ body: "Have you had a chance to pray and give thanks?",
+ habitType: "prayer"
+ )
+ }
+
+ // Coffee / caffeine — up to 4 named times plus one custom reminder.
+ let coffeeOn = ud.object(forKey: "coffeeReminderEnabled") as? Bool ?? false
+ for slot in Self.coffeeSlots {
+ addDaily(
+ id: "\(Self.habitPrefix).coffee.\(slot.key)",
+ enabled: coffeeOn && (ud.object(forKey: "coffee\(slot.key)Enabled") as? Bool ?? slot.defaultOn),
+ hour: ud.object(forKey: "coffee\(slot.key)Hour") as? Int ?? slot.hour,
+ minute: ud.object(forKey: "coffee\(slot.key)Minute") as? Int ?? slot.minute,
+ title: "\(slot.key) coffee",
+ body: "Time for your \(slot.key.lowercased()) coffee?",
+ habitType: "coffee"
+ )
+ }
+ // Empty until the user names it (the field's placeholder guides that) —
+ // fall back to a sensible default rather than an empty notification title.
+ let rawCustomLabel = ud.string(forKey: "coffeeCustomLabel") ?? ""
+ let customLabel = rawCustomLabel.isEmpty ? "Coffee" : rawCustomLabel
+ addDaily(
+ id: "\(Self.habitPrefix).coffee.custom",
+ enabled: coffeeOn && (ud.object(forKey: "coffeeCustomEnabled") as? Bool ?? false),
+ hour: ud.object(forKey: "coffeeCustomHour") as? Int ?? 16,
+ minute: ud.object(forKey: "coffeeCustomMinute") as? Int ?? 30,
+ title: customLabel,
+ body: "Time for \(customLabel.lowercased())?",
+ habitType: "coffee"
+ )
+
+ // Cancel anything previously scheduled that isn't wanted this pass
+ // (toggled off, or a slot that's no longer enabled). `add` with an
+ // existing identifier already replaces in place for the ones we kept.
+ let allPossibleIds = Set(
+ ["\(Self.habitPrefix).bed", "\(Self.habitPrefix).coffee.custom"]
+ + Self.prayerSlots.map { "\(Self.habitPrefix).prayer.\($0.key)" }
+ + Self.coffeeSlots.map { "\(Self.habitPrefix).coffee.\($0.key)" }
+ )
+ center.removePendingNotificationRequests(withIdentifiers: Array(allPossibleIds.subtracting(wanted)))
+ }
+
+ // MARK: - Habit "Done" logging (private, lightweight — no task-list footprint)
+
+ private static let habitLogKeyPrefix = "kisani.habit.doneDates."
+
+ /// Log today as done for a habit type ("bed"/"prayer"/"coffee"), once per
+ /// day (idempotent — tapping Done twice doesn't double-count). Mirrors the
+ /// app's "lifetime tally, never breaks" streak philosophy (see workout
+ /// streakDays) rather than a fragile consecutive-day streak.
+ private func logHabitDone(type: String) {
+ let ud = UserDefaults.kisani
+ let key = Self.habitLogKeyPrefix + type
+ let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
+ let today = fmt.string(from: Date())
+ var dates = Set(ud.stringArray(forKey: key) ?? [])
+ guard !dates.contains(today) else { return }
+ dates.insert(today)
+ ud.set(Array(dates), forKey: key)
+ }
+
+ /// Turn off future reminders for a habit type (from its notification's
+ /// "Stop Tracking" action) — flips the master toggle so Settings reflects
+ /// it too, and immediately cancels every pending notification for that
+ /// type (all its slots), not just the one just tapped.
+ private func stopTrackingHabit(type: String) {
+ UserDefaults.kisani.set(false, forKey: "\(type)ReminderEnabled")
+ let prefix = "\(Self.habitPrefix).\(type)"
+ center.getPendingNotificationRequests { [weak self] requests in
+ let ids = requests.map(\.identifier).filter { $0.hasPrefix(prefix) }
+ self?.center.removePendingNotificationRequests(withIdentifiers: ids)
+ }
+ }
+
+ /// Total distinct days logged done for a habit type — for a small "X days"
+ /// badge in Settings. Never surfaced in Today/Matrix/Activity History.
+ func habitDoneCount(type: String) -> Int {
+ (UserDefaults.kisani.stringArray(forKey: Self.habitLogKeyPrefix + type) ?? []).count
+ }
+
+ // MARK: - Analytics report notifications (deep-linked)
+
+ /// Fire one local notification for each newly-generated weekly / monthly
+ /// report, deep-linking to it. Permission-aware and gated on the same
+ /// "Period milestones" setting. `result` only lists reports created this run
+ /// (reconcile is idempotent) and each uses a fixed per-period identifier with
+ /// remove-then-add, so notifications are never duplicated.
+ func scheduleAnalyticsReports(_ result: AnalyticsCoordinator.ReconcileResult) {
+ guard !result.isEmpty,
+ UserDefaults.kisani.object(forKey: "kisani.periodMilestones") as? Bool ?? true else { return }
+ center.getNotificationSettings { [weak self] settings in
+ guard let self,
+ settings.authorizationStatus == .authorized
+ || settings.authorizationStatus == .provisional else { return }
+ if let week = result.weekliesGenerated.max() {
+ self.postAnalyticsReport(id: "kisani.analytics.weekly.\(week)",
+ title: "Your week in review",
+ body: "Your weekly training summary is ready.",
+ link: .weekly(weekStartKey: week))
+ }
+ if let month = result.monthliesGenerated.max() {
+ self.postAnalyticsReport(id: "kisani.analytics.monthly.\(month)",
+ title: "Your month in review",
+ body: "Your monthly training summary is ready.",
+ link: .monthly(monthKey: month))
+ }
+ }
+ }
+
+ private func postAnalyticsReport(id: String, title: String, body: String, link: AnalyticsDeepLink) {
+ center.removePendingNotificationRequests(withIdentifiers: [id])
+ let content = UNMutableNotificationContent()
+ content.title = title
+ content.body = body
+ content.sound = .default
+ content.userInfo = [AnalyticsDeepLink.userInfoKey: link.stringValue]
+ // Reconcile already runs at an appropriate moment (app opened after the
+ // period closed) — not tied to exact background execution. Deliver shortly.
+ center.add(UNNotificationRequest(
+ identifier: id, content: content,
+ trigger: UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)))
+ }
+
+ /// The app reads this on active to route to a tapped report (mirrors the
+ /// existing pending-action pattern used for workout confirm/missed).
+ func consumePendingAnalyticsDeepLink() -> AnalyticsDeepLink? {
+ guard let s = UserDefaults.kisani.string(forKey: "kisani.analytics.pendingDeepLink") else { return nil }
+ UserDefaults.kisani.removeObject(forKey: "kisani.analytics.pendingDeepLink")
+ return AnalyticsDeepLink(string: s)
+ }
+
// MARK: - Recurring task completion confirmation
/// Posts a quiet "✓ Done. Repeats …" confirmation when a recurring task's
@@ -534,6 +739,11 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
let deeplink = info["deeplink"] as? String
let taskId = info["taskId"] as? String
+ // Analytics report tap → store the deep link for the app to route on active.
+ if let s = info[AnalyticsDeepLink.userInfoKey] as? String, AnalyticsDeepLink(string: s) != nil {
+ UserDefaults.kisani.set(s, forKey: "kisani.analytics.pendingDeepLink")
+ }
+
switch response.actionIdentifier {
case Self.actionMarkId:
@@ -565,6 +775,19 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 3600, repeats: false)
))
+ case Self.habitDoneActId:
+ if let habitType = info["habitType"] as? String { logHabitDone(type: habitType) }
+
+ case Self.habitNotTodayActId:
+ // Intentionally a no-op beyond dismissing — the habit tally only
+ // ever counts "Done" taps (KC-40's lifetime-tally philosophy), so
+ // there's nothing to log for a skip. This just gives an explicit,
+ // discoverable "I saw it, skipping" action instead of only a swipe.
+ break
+
+ case Self.habitStopTrackingActId:
+ if let habitType = info["habitType"] as? String { stopTrackingHabit(type: habitType) }
+
default:
if let taskId { markTaskComplete(taskId: taskId, userId: userId) }
Task { @MainActor in
diff --git a/KisaniCal/Managers/TutorialManager.swift b/KisaniCal/Managers/TutorialManager.swift
index 27eec35..44bfa14 100644
--- a/KisaniCal/Managers/TutorialManager.swift
+++ b/KisaniCal/Managers/TutorialManager.swift
@@ -104,4 +104,24 @@ final class TutorialManager: ObservableObject {
else { withAnimation(KisaniSpring.entrance) { workoutStep += 1 } }
}
func skipWorkout() { withAnimation(KisaniSpring.exit) { workoutDone = true } }
+
+ // MARK: - Settings view tutorial
+ @AppStorage("kisani.tutorial.settings.v1") var settingsDone: Bool = false
+ @Published var settingsStep: Int = 0
+
+ let settingsTips: [PageTip] = [
+ .init(title: "Habit Reminders",
+ body: "Prayer, making your bed, coffee — under Habits, set daily nudges for the small routines you don't want to forget. Tap Done on the notification to keep a private streak.",
+ icon: "hands.sparkles"),
+ ]
+
+ var settingsIsActive: Bool { !settingsDone }
+ var settingsCurrent: PageTip { settingsTips[min(settingsStep, settingsTips.count - 1)] }
+ var settingsIsLast: Bool { settingsStep == settingsTips.count - 1 }
+
+ func advanceSettings() {
+ if settingsIsLast { withAnimation(KisaniSpring.exit) { settingsDone = true } }
+ else { withAnimation(KisaniSpring.entrance) { settingsStep += 1 } }
+ }
+ func skipSettings() { withAnimation(KisaniSpring.exit) { settingsDone = true } }
}
diff --git a/KisaniCal/Models/ExerciseModels.swift b/KisaniCal/Models/ExerciseModels.swift
index 7240523..f06b29a 100644
--- a/KisaniCal/Models/ExerciseModels.swift
+++ b/KisaniCal/Models/ExerciseModels.swift
@@ -233,6 +233,10 @@ class WorkoutViewModel: ObservableObject {
@Published var activeProgramId: UUID
@Published var activeSections: [WorkoutSection]
@Published var workoutDates: [String] = []
+ /// Subset of `workoutDates` logged via the in-app Finish button rather than
+ /// detected from Apple Health/Watch — only these are eligible for Undo, since
+ /// Watch/Health is the source of truth once it has recorded a day itself.
+ @Published var manualWorkoutDates: [String] = []
@Published var missedDates: [String] = [] // days marked "didn't do it"
@Published var compensations: [String: String] = [:] // rest-day "yyyy-MM-dd" → programId
@Published var askCompensate: MissedDay? = nil // triggers the compensation sheet
@@ -249,19 +253,69 @@ class WorkoutViewModel: ObservableObject {
@Published var restTimerTotal: Int = 0
@Published var sessionStartDate: Date? = nil
+ /// Lifetime tally of workout days achieved. Per the user's model this only ever
+ /// grows — a missed day, an off week, even a fully-blank week never reduce it.
+ /// A 4/5 week still contributes its 4; there is no "break." Not a consecutive
+ /// streak: it's the sum of everything you've actually done.
var streakDays: Int {
+ Self.totalAchievedWorkoutDays(workoutDates)
+ }
+
+ /// Count of distinct workout days ever logged (unit-tested in StreakLogicTests).
+ static func totalAchievedWorkoutDays(_ workoutDates: [String]) -> Int {
+ Set(workoutDates).count
+ }
+
+ // MARK: - Streak-banner breakdowns (this week / this year / comparison)
+
+ /// Workout days in the week `offset` weeks from now (0 = current, -1 = last).
+ private func workoutCount(inWeekOffset offset: Int) -> Int {
let cal = Calendar.current
- let fmt = iso; let dateSet = Set(workoutDates)
- var streak = 0
- var check = cal.startOfDay(for: Date())
- if !dateSet.contains(fmt.string(from: check)) {
- check = cal.date(byAdding: .day, value: -1, to: check)!
+ guard let ref = cal.date(byAdding: .weekOfYear, value: offset, to: Date()),
+ let wk = cal.dateInterval(of: .weekOfYear, for: ref) else { return 0 }
+ let set = Set(workoutDates)
+ return (0..<7).reduce(0) { acc, i in
+ guard let d = cal.date(byAdding: .day, value: i, to: wk.start) else { return acc }
+ return acc + (set.contains(iso.string(from: d)) ? 1 : 0)
}
- while dateSet.contains(fmt.string(from: check)) {
- streak += 1
- check = cal.date(byAdding: .day, value: -1, to: check)!
+ }
+
+ var workoutsThisWeek: Int { workoutCount(inWeekOffset: 0) }
+ var workoutsLastWeek: Int { workoutCount(inWeekOffset: -1) }
+
+ var workoutsThisYear: Int {
+ let cal = Calendar.current
+ let year = cal.component(.year, from: Date())
+ return Set(workoutDates).filter {
+ guard let d = iso.date(from: $0) else { return false }
+ return cal.component(.year, from: d) == year
+ }.count
+ }
+
+ /// 7 flags for the current week (calendar week order) — true if worked out that day.
+ var currentWeekDays: [Bool] {
+ let cal = Calendar.current
+ guard let wk = cal.dateInterval(of: .weekOfYear, for: Date()) else {
+ return Array(repeating: false, count: 7)
}
- return streak
+ let set = Set(workoutDates)
+ return (0..<7).map { i in
+ guard let d = cal.date(byAdding: .day, value: i, to: wk.start) else { return false }
+ return set.contains(iso.string(from: d))
+ }
+ }
+
+ /// 12 flags for the current year — true if ≥1 workout that month.
+ var currentYearMonths: [Bool] {
+ let cal = Calendar.current
+ let year = cal.component(.year, from: Date())
+ var months = Array(repeating: false, count: 12)
+ for s in workoutDates {
+ guard let d = iso.date(from: s), cal.component(.year, from: d) == year else { continue }
+ let m = cal.component(.month, from: d) - 1
+ if (0..<12).contains(m) { months[m] = true }
+ }
+ return months
}
private let iso: DateFormatter = {
@@ -274,6 +328,7 @@ class WorkoutViewModel: ObservableObject {
private let kSchedule: String
private let kActivePid: String
private let kWorkoutDates: String
+ private let kManualWorkoutDates: String
private let kLastActiveDay: String
private let kWorkoutHistory: String
private let kMissedDates: String
@@ -285,6 +340,7 @@ class WorkoutViewModel: ObservableObject {
self.kSchedule = "kisani.workout.schedule.\(uid)"
self.kActivePid = "kisani.workout.activePid.\(uid)"
self.kWorkoutDates = "kisani.workout.completedDates.\(uid)"
+ self.kManualWorkoutDates = "kisani.workout.manualDates.\(uid)"
self.kLastActiveDay = "kisani.workout.lastActiveDay.\(uid)"
self.kWorkoutHistory = "kisani.workout.history.\(uid)"
self.kMissedDates = "kisani.workout.missedDates.\(uid)"
@@ -296,6 +352,7 @@ class WorkoutViewModel: ObservableObject {
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.schedule.\(uid)")
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.activePid.\(uid)")
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.completedDates.\(uid)")
+ CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.manualDates.\(uid)")
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.history.\(uid)")
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.missedDates.\(uid)")
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.compensations.\(uid)")
@@ -362,6 +419,7 @@ class WorkoutViewModel: ObservableObject {
activeSections = blank.sections
}
workoutDates = UserDefaults.kisani.stringArray(forKey: kWorkoutDates) ?? []
+ manualWorkoutDates = UserDefaults.kisani.stringArray(forKey: kManualWorkoutDates) ?? []
missedDates = UserDefaults.kisani.stringArray(forKey: kMissedDates) ?? []
compensations = (UserDefaults.kisani.dictionary(forKey: kCompensations) as? [String: String]) ?? [:]
if let hData = UserDefaults.kisani.data(forKey: kWorkoutHistory),
@@ -380,6 +438,7 @@ class WorkoutViewModel: ObservableObject {
UserDefaults.kisani.set(schedDict, forKey: kSchedule)
UserDefaults.kisani.set(activeProgramId.uuidString, forKey: kActivePid)
UserDefaults.kisani.set(workoutDates, forKey: kWorkoutDates)
+ UserDefaults.kisani.set(manualWorkoutDates, forKey: kManualWorkoutDates)
UserDefaults.kisani.set(missedDates, forKey: kMissedDates)
UserDefaults.kisani.set(compensations, forKey: kCompensations)
CloudSyncManager.shared.push(value: missedDates, forKey: kMissedDates)
@@ -391,6 +450,7 @@ class WorkoutViewModel: ObservableObject {
CloudSyncManager.shared.push(value: schedDict, forKey: kSchedule)
CloudSyncManager.shared.push(value: activeProgramId.uuidString, forKey: kActivePid)
CloudSyncManager.shared.push(value: workoutDates, forKey: kWorkoutDates)
+ CloudSyncManager.shared.push(value: manualWorkoutDates, forKey: kManualWorkoutDates)
}
// MARK: - Daily Reset
@@ -513,6 +573,10 @@ class WorkoutViewModel: ObservableObject {
snapshotDay(key) // record today's session in history
guard !workoutDates.contains(key) else { return }
workoutDates.append(key)
+ // Only called from the manual Finish path (never from the HealthKit sync
+ // below), so a day only lands here — undo-eligible — when the app itself
+ // is the one claiming it, not Watch/Health.
+ if !manualWorkoutDates.contains(key) { manualWorkoutDates.append(key) }
save()
let start = sessionStartDate ?? date.addingTimeInterval(-3600)
Task { await HealthKitManager.shared.saveWorkout(start: start, end: date) }
@@ -583,6 +647,13 @@ class WorkoutViewModel: ObservableObject {
workoutDates.contains(iso.string(from: date))
}
+ /// True only if THIS date's completion came from the in-app Finish button —
+ /// the one case where Undo is offered. A Watch/Health-detected day is never
+ /// undo-eligible, since that record lives outside the app.
+ func isManuallyLogged(on date: Date) -> Bool {
+ manualWorkoutDates.contains(iso.string(from: date))
+ }
+
// MARK: - Per-date status (Pending / Done / Missed / Compensation)
enum WorkoutDayStatus { case none, pending, done, missed }
@@ -605,6 +676,16 @@ class WorkoutViewModel: ObservableObject {
logWorkoutCompleted(on: date) // adds to workoutDates, snapshots history, saves
}
+ /// Undo a manually-logged "done" mark — back to pending. Only ever offered
+ /// for days in `manualWorkoutDates`; a Watch/Health-sourced day has no undo
+ /// path here (that record isn't this app's to remove).
+ func unmarkWorkoutDone(on date: Date) {
+ let key = iso.string(from: date)
+ workoutDates.removeAll { $0 == key }
+ manualWorkoutDates.removeAll { $0 == key }
+ save()
+ }
+
/// Mark one specific day's workout missed (undoes a done mark for that day only).
func markWorkoutMissed(on date: Date) {
let key = iso.string(from: date)
@@ -613,6 +694,7 @@ class WorkoutViewModel: ObservableObject {
save()
}
+
// MARK: - Compensation (recover a missed workout on a rest day)
/// Upcoming rest days (no scheduled program, no compensation yet), starting tomorrow.
@@ -753,7 +835,9 @@ class WorkoutViewModel: ObservableObject {
sessionStartDate = Date()
}
syncBack()
- if doneSets == totalSets && totalSets > 0 { logWorkoutCompleted() }
+ // Ticking the last box no longer silently logs the day as done — the
+ // main Workout card's explicit "Finish" is the single, confirmable way
+ // to count today (so mis-taps here can't jump the UI into the done state).
if wasMarkedDone && restTimerEnabled { startRestTimer() }
}
@@ -767,11 +851,13 @@ class WorkoutViewModel: ObservableObject {
}
if done, sessionStartDate == nil { sessionStartDate = Date() }
syncBack()
- if doneSets == totalSets && totalSets > 0 { logWorkoutCompleted() }
+ // See toggleSet: no longer auto-logs the day — "Finish" is explicit.
}
- /// Mark every set of the active workout done/undone — the "Select all & mark
- /// complete" quick action. Logs completion when everything is checked.
+ /// Mark every set of the active workout done/undone — the "Check/Clear all
+ /// sets" quick action. Ticking every box no longer auto-logs the day as
+ /// done on its own; "Finish Workout" on the main card is the single,
+ /// confirmable action that counts today.
func setAllSetsDone(_ done: Bool) {
for si in activeSections.indices {
for ei in activeSections[si].exercises.indices {
@@ -785,7 +871,6 @@ class WorkoutViewModel: ObservableObject {
UserDefaults.kisani.set(iso.string(from: Date()), forKey: kLastActiveDay)
}
syncBack()
- if done, doneSets == totalSets && totalSets > 0 { logWorkoutCompleted() }
}
func addSet(sectionId: UUID, to exerciseId: UUID) {
diff --git a/KisaniCal/Models/TaskItem.swift b/KisaniCal/Models/TaskItem.swift
index d1ca5e2..a39bb85 100644
--- a/KisaniCal/Models/TaskItem.swift
+++ b/KisaniCal/Models/TaskItem.swift
@@ -5,6 +5,10 @@ import WidgetKit
struct TaskItem: Identifiable, Codable, Equatable {
var id = UUID()
var title: String
+ // Optional so existing saved tasks decode safely (missing key → nil); the
+ // `Date()` default only fires for genuinely new tasks constructed after
+ // this field existed — it's evaluated fresh at each construction site.
+ var createdAt: Date? = Date()
var dueDate: Date?
var hasTime: Bool = false
var isComplete: Bool = false
@@ -14,6 +18,9 @@ struct TaskItem: Identifiable, Codable, Equatable {
var taskColor: TaskColor = .orange
var isRecurring: Bool = false
var recurrenceLabel: String? = nil
+ // "Every N [unit]" — optional so existing saved tasks decode safely (missing
+ // key → nil, treated as 1 everywhere). "Every Weekday" ignores this.
+ var recurrenceInterval: Int? = nil
var isPinned: Bool = false
var endDate: Date? = nil
var isAllDay: Bool = false
@@ -26,6 +33,15 @@ struct TaskItem: Identifiable, Codable, Equatable {
// Matrix: horizontal manual moves override urgency only.
// nil = auto from deadline, true = force urgent, false = force not-urgent.
var urgencyOverride: Bool? = nil
+
+ /// User-facing recurrence text — "Weekly" stays "Weekly" at interval 1 (no
+ /// visual change for any existing task), but becomes "Every 2 Weeks" etc.
+ /// once an interval is set. Use this anywhere recurrence text is shown to
+ /// the user; `recurrenceLabel` itself stays a plain rule key for matching.
+ var recurrenceDisplayLabel: String? {
+ guard let label = recurrenceLabel else { return nil }
+ return RecurrenceRule.displayLabel(label: label, interval: recurrenceInterval ?? 1)
+ }
}
enum Priority: String, Codable, CaseIterable, Identifiable {
@@ -104,6 +120,18 @@ enum TaskCategory: String, Codable {
case domain = "Domain"
case annual = "Annual"
case custom = "Custom"
+
+ /// Icon-only glyph for task rows — `.reminder` is the generic default
+ /// and stays unmarked so the badge only appears when it adds information.
+ var glyph: String? {
+ switch self {
+ case .reminder: return nil
+ case .birthday: return "gift.fill"
+ case .domain: return "briefcase.fill"
+ case .annual: return "calendar"
+ case .custom: return "tag.fill"
+ }
+ }
}
enum TaskColor: String, Codable {
@@ -253,10 +281,15 @@ class TaskViewModel: ObservableObject {
let cal = Calendar.current
let start = cal.startOfDay(for: now)
let end = cal.date(byAdding: .day, value: 1, to: start)!
- // Timed tasks whose time has already passed belong in overdue, not today.
+ // Timed ONE-OFF tasks whose time has already passed belong in overdue,
+ // not today. Recurring occurrences always stay in Today regardless of
+ // time: overdueTasks intentionally excludes recurring tasks (postponing
+ // one would shift the recurrence rule's anchor date, not just today's
+ // occurrence), so without this a past-time recurring task would vanish
+ // from the app entirely instead of showing here like Calendar shows it.
return activeRows().filter {
guard let d = $0.dueDate else { return false }
- if $0.hasTime && d < now { return false }
+ if $0.hasTime && d < now && !$0.isRecurring { return false }
return d >= start && d < end
}
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
@@ -280,6 +313,56 @@ class TaskViewModel: ObservableObject {
.sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
}
+ // MARK: - Completion stats (counts recurring occurrences, not just one-offs)
+
+ /// Completions on a given day: one-off tasks by `completedAt`, recurring
+ /// tasks by their `completedOccurrences` entry for that day.
+ func completionCount(on day: Date) -> Int {
+ Self.completionCount(in: tasks, on: day, calendar: Calendar.current)
+ }
+
+ /// Pure counting (unit-tested in StreakLogicTests).
+ static func completionCount(in tasks: [TaskItem], on day: Date, calendar cal: Calendar) -> Int {
+ let d0 = cal.startOfDay(for: day)
+ let next = cal.date(byAdding: .day, value: 1, to: d0)!
+ let key = occFmt.string(from: d0)
+ return tasks.reduce(0) { n, t in
+ let isRecurring = t.isRecurring && (t.recurrenceLabel ?? "None") != "None" && t.dueDate != nil
+ if isRecurring {
+ return n + ((t.completedOccurrences?.contains(key) ?? false) ? 1 : 0)
+ }
+ if t.isComplete, let at = t.completedAt, at >= d0, at < next { return n + 1 }
+ return n
+ }
+ }
+
+ /// Completed tasks on a day, recurring occurrences included (for history views).
+ func completions(on day: Date) -> [TaskItem] {
+ let cal = Calendar.current
+ let d0 = cal.startOfDay(for: day)
+ let next = cal.date(byAdding: .day, value: 1, to: d0)!
+ let nonRec = tasks.filter { !recurs($0) && $0.isComplete && (($0.completedAt ?? .distantPast) >= d0) && (($0.completedAt ?? .distantPast) < next) }
+ let rec = tasks.filter { recurs($0) && occurrenceComplete($0, on: d0) }
+ .map { occurrenceCopy($0, on: d0) }
+ return (nonRec + rec).sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
+ }
+
+ /// Consecutive days with ≥1 completion, walking back from today.
+ /// A quiet day today doesn't break the streak — it just isn't counted yet.
+ var taskStreakDays: Int {
+ let cal = Calendar.current
+ var check = cal.startOfDay(for: Date())
+ var streak = 0
+ if completionCount(on: check) == 0 {
+ check = cal.date(byAdding: .day, value: -1, to: check)!
+ }
+ while completionCount(on: check) > 0 {
+ streak += 1
+ check = cal.date(byAdding: .day, value: -1, to: check)!
+ }
+ return streak
+ }
+
func toggle(_ task: TaskItem) {
// A recurring row carries its occurrence date in dueDate — toggle just that day.
if recurs(task), let d = task.dueDate {
@@ -310,24 +393,13 @@ class TaskViewModel: ObservableObject {
}
/// Whether a recurring task has an occurrence on `date` — aligned to its rule,
- /// on/after its start, and on/before its "repeat until" (if set).
+ /// on/after its start, and on/before its "repeat until" (if set). Delegates to
+ /// `RecurrenceRule` (Shared/) so this exact math is also what both widget
+ /// extensions use for their countdown windows — one implementation, not three.
func isOccurrence(_ t: TaskItem, on date: Date) -> Bool {
guard recurs(t), let start = t.dueDate else { return false }
- let cal = Calendar.current
- let d0 = cal.startOfDay(for: date), s0 = cal.startOfDay(for: start)
- guard d0 >= s0 else { return false }
- if let end = t.recurrenceEnd, d0 > cal.startOfDay(for: end) { return false }
- switch t.recurrenceLabel {
- case "Daily": return true
- case "Every Weekday":
- let wd = cal.component(.weekday, from: d0) // 1=Sun … 7=Sat
- return wd >= 2 && wd <= 6
- case "Weekly": return ((cal.dateComponents([.day], from: s0, to: d0).day ?? 0) % 7) == 0
- case "Monthly": return cal.component(.day, from: s0) == cal.component(.day, from: d0)
- case "Yearly": return cal.component(.day, from: s0) == cal.component(.day, from: d0)
- && cal.component(.month, from: s0) == cal.component(.month, from: d0)
- default: return false
- }
+ return RecurrenceRule.isOccurrence(label: t.recurrenceLabel, interval: t.recurrenceInterval ?? 1,
+ start: start, date: date, end: t.recurrenceEnd)
}
func occurrenceComplete(_ t: TaskItem, on date: Date) -> Bool {
@@ -350,6 +422,21 @@ class TaskViewModel: ObservableObject {
}
}
+ /// Complete a recurring task's occurrence AND stop the series — distinct
+ /// from Delete, which erases the task (and every past completion) outright.
+ /// The task, its title, and its full completedOccurrences history stay
+ /// intact; only occurrences after `date` stop being generated (via
+ /// `recurrenceEnd`, which `isOccurrence` already respects).
+ func completeAndStopSeries(_ t: TaskItem, on date: Date = Date()) {
+ guard let idx = tasks.firstIndex(where: { $0.id == t.id }) else { return }
+ let key = occurrenceKey(date)
+ var set = Set(tasks[idx].completedOccurrences ?? [])
+ set.insert(key) // ensure done — never toggles off
+ tasks[idx].completedOccurrences = Array(set)
+ tasks[idx].recurrenceEnd = Calendar.current.startOfDay(for: date)
+ save()
+ }
+
/// A display copy of a recurring task pinned to one occurrence date, with that
/// occurrence's completion baked into `isComplete` (so existing rows just work).
func occurrenceCopy(_ t: TaskItem, on date: Date) -> TaskItem {
@@ -546,7 +633,7 @@ class TaskViewModel: ObservableObject {
func addTask(title: String, dueDate: Date?, hasTime: Bool = false,
quadrant: Quadrant = .urgent, category: TaskCategory = .reminder,
taskColor: TaskColor = .orange, isRecurring: Bool = false,
- recurrenceLabel: String? = nil,
+ recurrenceLabel: String? = nil, recurrenceInterval: Int? = nil,
endDate: Date? = nil, isAllDay: Bool = false,
priority: Priority = .none, reminderDate: Date? = nil,
constantReminder: Bool = false, recurrenceEnd: Date? = nil) {
@@ -559,6 +646,7 @@ class TaskViewModel: ObservableObject {
endDate: endDate, isAllDay: isAllDay,
priority: resolvedPriority, reminderDate: reminderDate,
constantReminder: constantReminder)
+ item.recurrenceInterval = recurrenceInterval
item.recurrenceEnd = recurrenceEnd
item.quadrant = displayQuadrant(item) // color/widget identity follows placement
tasks.append(item)
@@ -568,13 +656,15 @@ class TaskViewModel: ObservableObject {
func updateTask(_ task: TaskItem, title: String, dueDate: Date?, hasTime: Bool,
isRecurring: Bool, recurrenceLabel: String?,
endDate: Date?, isAllDay: Bool, reminderDate: Date?,
- constantReminder: Bool = false, recurrenceEnd: Date? = nil) {
+ constantReminder: Bool = false, recurrenceEnd: Date? = nil,
+ recurrenceInterval: Int? = nil) {
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
tasks[idx].title = title
tasks[idx].dueDate = dueDate
tasks[idx].hasTime = hasTime
tasks[idx].isRecurring = isRecurring
tasks[idx].recurrenceLabel = recurrenceLabel
+ tasks[idx].recurrenceInterval = recurrenceInterval
tasks[idx].endDate = endDate
tasks[idx].isAllDay = isAllDay
tasks[idx].reminderDate = reminderDate
diff --git a/KisaniCal/Theme/DesignTokens.swift b/KisaniCal/Theme/DesignTokens.swift
index 613a94f..88743fa 100644
--- a/KisaniCal/Theme/DesignTokens.swift
+++ b/KisaniCal/Theme/DesignTokens.swift
@@ -9,6 +9,7 @@ struct AppColors {
static let blue = Color(r: 77, g: 157, b: 224)
static let yellow = Color(r: 232, g: 184, b: 42)
static let red = Color(r: 232, g: 66, b: 66)
+ static let coffeeBrown = Color(r: 122, g: 84, b: 52)
static let accentSoft = Color(UIColor { $0.userInterfaceStyle == .dark
? UIColor(r: 52, g: 28, b: 14) // dark: deep warm orange
diff --git a/KisaniCal/Views/ActivityHistoryView.swift b/KisaniCal/Views/ActivityHistoryView.swift
new file mode 100644
index 0000000..e61596a
--- /dev/null
+++ b/KisaniCal/Views/ActivityHistoryView.swift
@@ -0,0 +1,161 @@
+import SwiftUI
+
+// MARK: - Activity History
+// Day-by-day archive of everything logged: tasks completed (recurring
+// occurrences included), workouts done/missed/compensated. Only days with
+// activity are shown — quiet days stay quiet.
+struct ActivityHistoryView: View {
+ @EnvironmentObject var taskVM: TaskViewModel
+ @EnvironmentObject var workoutVM: WorkoutViewModel
+ @Environment(\.colorScheme) var cs
+ @Environment(\.dismiss) var dismiss
+
+ /// How far back the archive reaches.
+ private let daysBack = 90
+
+ private static let keyFmt: DateFormatter = {
+ let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f
+ }()
+ private static let pretty: DateFormatter = {
+ let f = DateFormatter(); f.dateFormat = "EEE, MMM d"; return f
+ }()
+ private static let timeFmt: DateFormatter = {
+ let f = DateFormatter(); f.dateFormat = "HH:mm"; return f
+ }()
+
+ private struct DayEntry: Identifiable {
+ let id: String // "yyyy-MM-dd"
+ let date: Date
+ let tasks: [TaskItem]
+ let workout: WorkoutDayLog? // full log if one was saved
+ let workoutDone: Bool // in workoutDates (streak record)
+ let missed: Bool // marked "didn't do it"
+ let compensated: Bool // rest-day makeup scheduled
+ }
+
+ private var entries: [DayEntry] {
+ let cal = Calendar.current
+ let today = cal.startOfDay(for: Date())
+ let logByDate = Dictionary(uniqueKeysWithValues: workoutVM.history.map { ($0.date, $0) })
+ let doneSet = Set(workoutVM.workoutDates)
+ let missedSet = Set(workoutVM.missedDates)
+
+ return (0.. DayEntry? in
+ guard let day = cal.date(byAdding: .day, value: -ago, to: today) else { return nil }
+ let key = Self.keyFmt.string(from: day)
+ let tasks = taskVM.completions(on: day)
+ let log = logByDate[key]
+ let done = doneSet.contains(key)
+ let missed = missedSet.contains(key)
+ let comp = workoutVM.compensations[key] != nil
+ guard !tasks.isEmpty || log != nil || done || missed || comp else { return nil }
+ return DayEntry(id: key, date: day, tasks: tasks, workout: log,
+ workoutDone: done, missed: missed, compensated: comp)
+ }
+ }
+
+ private func label(_ date: Date) -> String {
+ if Calendar.current.isDateInToday(date) { return "Today" }
+ if Calendar.current.isDateInYesterday(date) { return "Yesterday" }
+ return Self.pretty.string(from: date)
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ HStack {
+ Text("Activity").font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs))
+ Spacer()
+ Button("Done") { dismiss() }
+ .font(AppFonts.sans(13, weight: .semibold))
+ .foregroundColor(AppColors.accent).buttonStyle(.plain)
+ }
+ .padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14)
+ Divider().background(AppColors.border(cs))
+
+ if entries.isEmpty {
+ VStack(spacing: 8) {
+ Image(systemName: "clock.arrow.circlepath").font(.system(size: 28)).foregroundColor(AppColors.text3(cs))
+ Text("Nothing here yet").font(AppFonts.sans(14, weight: .semibold)).foregroundColor(AppColors.text2(cs))
+ Text("Completed tasks and workouts show up here, day by day.")
+ .font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)).multilineTextAlignment(.center)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity).padding()
+ } else {
+ ScrollView(showsIndicators: false) {
+ VStack(spacing: 12) {
+ ForEach(entries) { day in
+ dayCard(day)
+ }
+ }
+ .padding(.horizontal, 16).padding(.top, 14).padding(.bottom, 30)
+ }
+ }
+ }
+ .background(AppColors.background(cs).ignoresSafeArea())
+ }
+
+ @ViewBuilder
+ private func dayCard(_ day: DayEntry) -> some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Text(label(day.date))
+ .font(AppFonts.sans(13, weight: .bold)).foregroundColor(AppColors.text(cs))
+ Spacer()
+ if !day.tasks.isEmpty {
+ Text("\(day.tasks.count) task\(day.tasks.count == 1 ? "" : "s")")
+ .font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.green)
+ }
+ }
+
+ // Workout line
+ if let log = day.workout {
+ HStack(spacing: 8) {
+ Image(systemName: "dumbbell.fill").font(.system(size: 10)).foregroundColor(AppColors.green).frame(width: 18)
+ Text(log.programName).font(AppFonts.sans(12, weight: .semibold)).foregroundColor(AppColors.text2(cs))
+ Spacer()
+ Text("\(log.doneSets)/\(log.totalSets) sets\(log.volume > 0 ? " · \(Int(log.volume)) kg" : "")")
+ .font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
+ }
+ } else if day.workoutDone {
+ HStack(spacing: 8) {
+ Image(systemName: "dumbbell.fill").font(.system(size: 10)).foregroundColor(AppColors.green).frame(width: 18)
+ Text("Workout done").font(AppFonts.sans(12)).foregroundColor(AppColors.text2(cs))
+ Spacer()
+ }
+ }
+ if day.missed {
+ HStack(spacing: 8) {
+ Image(systemName: "minus.circle").font(.system(size: 10)).foregroundColor(AppColors.accent).frame(width: 18)
+ Text("Workout missed\(day.compensated ? " · made up on a rest day" : "")")
+ .font(AppFonts.sans(12)).foregroundColor(AppColors.text3(cs))
+ Spacer()
+ }
+ } else if day.compensated && !day.workoutDone {
+ HStack(spacing: 8) {
+ Image(systemName: "arrow.uturn.forward.circle").font(.system(size: 10)).foregroundColor(AppColors.blue).frame(width: 18)
+ Text("Rest-day makeup scheduled").font(AppFonts.sans(12)).foregroundColor(AppColors.text3(cs))
+ Spacer()
+ }
+ }
+
+ // Task lines
+ ForEach(day.tasks) { t in
+ HStack(spacing: 8) {
+ Image(systemName: "checkmark.square").font(.system(size: 10)).foregroundColor(AppColors.green).frame(width: 18)
+ Text(t.title).font(AppFonts.sans(12)).foregroundColor(AppColors.text2(cs)).lineLimit(1)
+ if t.isRecurring {
+ Image(systemName: "repeat").font(.system(size: 8)).foregroundColor(AppColors.text3(cs))
+ }
+ Spacer()
+ if t.hasTime, let at = t.completedAt {
+ Text(Self.timeFmt.string(from: at)).font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
+ }
+ }
+ }
+ }
+ .padding(14)
+ .background(AppColors.surface(cs))
+ .clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
+ .overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
+ }
+}
diff --git a/KisaniCal/Views/AnalyticsView.swift b/KisaniCal/Views/AnalyticsView.swift
new file mode 100644
index 0000000..85bacf3
--- /dev/null
+++ b/KisaniCal/Views/AnalyticsView.swift
@@ -0,0 +1,427 @@
+import SwiftUI
+import Charts
+
+// Analytics area: Overview · Daily · Weekly · Monthly · 12-Month · Exercises.
+// Reads from AnalyticsService (which reconciles reports elsewhere). Status is
+// conveyed by BOTH color and a glyph (never color alone). Includes empty/partial
+// states and an informational, non-medical disclaimer.
+//
+// NOTE: written without an available build in this environment — verify on device.
+struct AnalyticsView: View {
+ @Environment(\.colorScheme) private var cs
+ @ObservedObject private var service = AnalyticsService.shared
+ @State private var area: Area = .overview
+ @State private var selectedExercise: String?
+
+ /// Optional deep link (from a report notification) picks the initial area.
+ init(initialLink: AnalyticsDeepLink? = nil) {
+ switch initialLink {
+ case .weekly: _area = State(initialValue: .weekly)
+ case .monthly: _area = State(initialValue: .monthly)
+ default: break // keep default .overview
+ }
+ }
+
+ enum Area: String, CaseIterable, Identifiable {
+ case overview = "Overview", daily = "Daily", weekly = "Weekly"
+ case monthly = "Monthly", trends = "12-Month", exercises = "Exercises"
+ var id: String { rawValue }
+ }
+
+ private var hasAnyReports: Bool {
+ !service.weeklyReports().isEmpty || !service.monthlyReports().isEmpty
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ header
+ areaSelector
+ Divider().background(AppColors.border(cs))
+ ScrollView(showsIndicators: false) {
+ VStack(alignment: .leading, spacing: 16) {
+ content
+ disclaimer
+ }
+ .padding(16)
+ .padding(.bottom, 40)
+ }
+ }
+ .background(AppColors.background(cs).ignoresSafeArea())
+ }
+
+ private var header: some View {
+ Text("Analytics")
+ .font(AppFonts.sans(20, weight: .bold))
+ .foregroundColor(AppColors.text(cs))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 16).padding(.top, 18).padding(.bottom, 8)
+ }
+
+ private var areaSelector: some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ ForEach(Area.allCases) { a in
+ let on = a == area
+ Text(a.rawValue)
+ .font(AppFonts.sans(12, weight: .semibold))
+ .foregroundColor(on ? .white : AppColors.text2(cs))
+ .padding(.horizontal, 12).padding(.vertical, 7)
+ .background(on ? AppColors.accent : AppColors.surface2(cs))
+ .clipShape(Capsule())
+ .contentShape(Capsule())
+ .onTapGesture { withAnimation(KisaniSpring.micro) { area = a } }
+ .accessibilityLabel(a.rawValue)
+ .accessibilityAddTraits(on ? [.isButton, .isSelected] : [.isButton])
+ }
+ }
+ .padding(.horizontal, 16).padding(.vertical, 10)
+ }
+ }
+
+ @ViewBuilder private var content: some View {
+ if !hasAnyReports && area != .daily {
+ emptyState
+ } else {
+ switch area {
+ case .overview: overview
+ case .daily: dailySection
+ case .weekly: weeklySection
+ case .monthly: monthlySection
+ case .trends: trendsSection
+ case .exercises: exercisesSection
+ }
+ }
+ }
+
+ // MARK: - Empty state
+
+ private var emptyState: some View {
+ VStack(spacing: 8) {
+ Image(systemName: "chart.line.uptrend.xyaxis")
+ .font(.system(size: 30)).foregroundColor(AppColors.text3(cs))
+ Text("No reports yet")
+ .font(AppFonts.sans(15, weight: .semibold)).foregroundColor(AppColors.text2(cs))
+ Text("As each week and month completes, an immutable summary appears here.")
+ .font(AppFonts.sans(12)).foregroundColor(AppColors.text3(cs))
+ .multilineTextAlignment(.center)
+ }
+ .frame(maxWidth: .infinity).padding(.vertical, 60)
+ }
+
+ // MARK: - Overview
+
+ private var overview: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ if let w = service.weeklyReports().first {
+ sectionTitle("Latest week")
+ weeklyCard(w)
+ }
+ if let m = service.monthlyReports().first {
+ sectionTitle("Latest month")
+ monthlyCard(m)
+ }
+ sectionTitle("12-month direction")
+ HStack {
+ trendBadge(service.monthlyTrendClassification().classification)
+ Spacer()
+ Text("\(service.monthlyReports().count) mo · \(service.weeklyReports().count) wk")
+ .font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
+ }
+ .padding(12).cardBG(cs)
+ }
+ }
+
+ // MARK: - Daily
+
+ private var dailySection: some View {
+ let cmp = service.dailyComparison(on: Date())
+ return VStack(alignment: .leading, spacing: 16) {
+ if cmp.restDay {
+ infoCard("Rest day", "No workout logged today — nothing to compare.")
+ }
+ comparisonGroup("vs. yesterday", cmp.vsPreviousDay)
+ comparisonGroup("vs. same day last week", cmp.vsSameWeekdayLastWeek)
+ }
+ }
+
+ private func comparisonGroup(_ title: String, _ comps: [ExerciseComparison]) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ sectionTitle(title)
+ if comps.isEmpty {
+ infoCard("Nothing to compare", "No matching exercises for this comparison.")
+ } else {
+ ForEach(comps, id: \.identity) { c in
+ HStack {
+ Text(c.identity.capitalized)
+ .font(AppFonts.sans(13, weight: .medium)).foregroundColor(AppColors.text(cs))
+ if c.isNew {
+ Text("NEW").font(AppFonts.mono(8, weight: .bold))
+ .foregroundColor(AppColors.blue)
+ .padding(.horizontal, 5).padding(.vertical, 2)
+ .background(AppColors.blueSoft).clipShape(Capsule())
+ }
+ Spacer()
+ deltaBadge(c.volume, unit: "kg")
+ }
+ .padding(12).cardBG(cs)
+ }
+ }
+ }
+ }
+
+ // MARK: - Weekly / Monthly
+
+ private var weeklySection: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ ForEach(service.weeklyReports()) { weeklyCard($0) }
+ }
+ }
+
+ private var monthlySection: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ ForEach(service.monthlyReports()) { monthlyCard($0) }
+ }
+ }
+
+ private func weeklyCard(_ r: WeeklyReport) -> some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Text("Week of \(r.weekStartKey)")
+ .font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
+ Spacer()
+ trendBadge(r.progression)
+ }
+ summaryRow(r.summary)
+ healthRow(r.summary)
+ deltaChips(r.vsPreviousWeek)
+ }
+ .padding(14).cardBG(cs)
+ }
+
+ private func monthlyCard(_ r: MonthlyReport) -> some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Text(r.monthKey)
+ .font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
+ Spacer()
+ trendBadge(r.classification)
+ }
+ summaryRow(r.summary)
+ healthRow(r.summary)
+ deltaChips(r.vsPreviousMonth)
+ }
+ .padding(14).cardBG(cs)
+ }
+
+ private func summaryRow(_ s: PeriodSummary) -> some View {
+ HStack(spacing: 14) {
+ stat("\(s.sessions)", "sessions")
+ stat("\(s.sets)", "sets")
+ stat("\(Int(s.volume))", "kg")
+ stat("\(Int(s.consistency * 100))%", "consistent")
+ if s.missed > 0 { stat("\(s.missed)", "missed") }
+ }
+ }
+
+ /// Available HealthKit trends for the period (shown only when present).
+ @ViewBuilder private func healthRow(_ s: PeriodSummary) -> some View {
+ if s.avgSteps != nil || s.avgActiveCalories != nil || s.avgRestingHR != nil {
+ HStack(spacing: 14) {
+ if let v = s.avgSteps { stat("\(v)", "avg steps") }
+ if let v = s.avgActiveCalories { stat("\(v)", "avg kcal") }
+ if let v = s.avgRestingHR { stat("\(v)", "avg bpm") }
+ }
+ }
+ }
+
+ private func deltaChips(_ deltas: [String: MetricDelta]) -> some View {
+ let order = ["volume", "sessions", "sets", "duration", "consistency"]
+ return FlowRow(order.compactMap { key -> (String, MetricDelta)? in
+ guard let d = deltas[key], d.meaningful, d.percent != nil else { return nil }
+ return (key, d)
+ }) { pair in
+ HStack(spacing: 4) {
+ Text(pair.0).font(AppFonts.mono(9)).foregroundColor(AppColors.text3(cs))
+ deltaBadge(pair.1, unit: "")
+ }
+ .padding(.horizontal, 8).padding(.vertical, 4)
+ .background(AppColors.surface2(cs)).clipShape(Capsule())
+ }
+ }
+
+ // MARK: - 12-Month trends
+
+ private var trendsSection: some View {
+ let series = service.monthlyVolumeTrend()
+ return VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ sectionTitle("Monthly volume")
+ Spacer()
+ trendBadge(service.monthlyTrendClassification().classification)
+ }
+ if series.isEmpty {
+ infoCard("Not enough data", "A few completed months are needed to chart a trend.")
+ } else {
+ Chart(series) { point in
+ BarMark(
+ x: .value("Month", point.monthKey),
+ y: .value("Volume", point.volume)
+ )
+ .foregroundStyle(AppColors.accent)
+ }
+ .frame(height: 200)
+ .padding(12).cardBG(cs)
+ .accessibilityLabel("Monthly training volume for the last year")
+ }
+ }
+ }
+
+ // MARK: - Per-exercise
+
+ private var exercisesSection: some View {
+ let names = service.knownExerciseIdentities()
+ return VStack(alignment: .leading, spacing: 12) {
+ if names.isEmpty {
+ infoCard("No exercise history", "Logged exercises will appear here over time.")
+ } else {
+ Menu {
+ ForEach(names, id: \.self) { n in
+ Button(n.capitalized) { selectedExercise = n }
+ }
+ } label: {
+ HStack {
+ Text((selectedExercise ?? names.first ?? "").capitalized)
+ .font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
+ Image(systemName: "chevron.down").font(.system(size: 11)).foregroundColor(AppColors.text3(cs))
+ Spacer()
+ }
+ .padding(12).cardBG(cs)
+ }
+ let id = selectedExercise ?? names.first ?? ""
+ let history = service.exerciseHistory(identity: id)
+ if history.count < 2 {
+ infoCard("Not enough history", "Log this exercise on more days to see a trend.")
+ } else {
+ Chart(history) { point in
+ LineMark(x: .value("Date", point.dateKey), y: .value("Volume", point.volume))
+ .foregroundStyle(AppColors.accent)
+ PointMark(x: .value("Date", point.dateKey), y: .value("Volume", point.volume))
+ .foregroundStyle(AppColors.accent)
+ }
+ .frame(height: 200)
+ .padding(12).cardBG(cs)
+ .accessibilityLabel("Volume history for \(id)")
+ }
+ }
+ }
+ }
+
+ // MARK: - Reusable bits
+
+ private func sectionTitle(_ t: String) -> some View {
+ Text(t.uppercased())
+ .font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
+ }
+
+ private func stat(_ value: String, _ label: String) -> some View {
+ VStack(alignment: .leading, spacing: 1) {
+ Text(value).font(AppFonts.sans(15, weight: .bold)).foregroundColor(AppColors.text(cs))
+ Text(label).font(AppFonts.mono(8)).foregroundColor(AppColors.text3(cs))
+ }
+ }
+
+ private func infoCard(_ title: String, _ body: String) -> some View {
+ VStack(alignment: .leading, spacing: 3) {
+ Text(title).font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text2(cs))
+ Text(body).font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(12).cardBG(cs)
+ }
+
+ /// Direction shown by glyph + sign + color (glyph works without color).
+ private func deltaBadge(_ d: MetricDelta, unit: String) -> some View {
+ let (glyph, color): (String, Color) = {
+ switch d.direction {
+ case .up: return ("arrow.up.right", AppColors.green)
+ case .down: return ("arrow.down.right", AppColors.red)
+ case .flat: return ("equal", AppColors.text3(cs))
+ case .none: return ("minus", AppColors.text3(cs))
+ }
+ }()
+ let text: String = {
+ if let p = d.percent { return String(format: "%+.0f%%", p) }
+ if let a = d.absolute {
+ let u = unit.isEmpty ? "" : " " + unit
+ return String(format: "%+.0f", a) + u
+ }
+ return "—"
+ }()
+ return HStack(spacing: 3) {
+ Image(systemName: glyph).font(.system(size: 9, weight: .bold))
+ Text(text).font(AppFonts.mono(11, weight: .semibold))
+ }
+ .foregroundColor(d.meaningful ? color : AppColors.text3(cs))
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel("\(d.direction.rawValue) \(text)")
+ }
+
+ private func trendBadge(_ t: TrendClassification) -> some View {
+ let (glyph, color, label): (String, Color, String) = {
+ switch t {
+ case .improving: return ("arrow.up.right", AppColors.green, "Improving")
+ case .declining: return ("arrow.down.right", AppColors.red, "Declining")
+ case .plateau: return ("equal", AppColors.text2(cs), "Plateau")
+ case .insufficientData: return ("minus", AppColors.text3(cs), "Not enough data")
+ }
+ }()
+ return HStack(spacing: 4) {
+ Image(systemName: glyph).font(.system(size: 9, weight: .bold))
+ Text(label).font(AppFonts.mono(9, weight: .bold))
+ }
+ .foregroundColor(color)
+ .padding(.horizontal, 8).padding(.vertical, 4)
+ .background(color.opacity(0.12)).clipShape(Capsule())
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel("Trend: \(label)")
+ }
+
+ private var disclaimer: some View {
+ Text(AnalyticsEngine.disclaimer)
+ .font(AppFonts.sans(10)).foregroundColor(AppColors.text3(cs))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.top, 8)
+ }
+}
+
+// A minimal wrapping row (chips flow to new lines).
+private struct FlowRow: View {
+ let items: Data
+ let content: (Data.Element) -> Content
+ init(_ items: Data, @ViewBuilder content: @escaping (Data.Element) -> Content) {
+ self.items = items; self.content = content
+ }
+ var body: some View {
+ // Simple 2-per-row grid is enough for the small delta-chip set.
+ let arr = Array(items)
+ return VStack(alignment: .leading, spacing: 6) {
+ ForEach(Array(stride(from: 0, to: arr.count, by: 2)), id: \.self) { i in
+ HStack(spacing: 6) {
+ content(arr[i])
+ if i + 1 < arr.count { content(arr[i + 1]) }
+ Spacer(minLength: 0)
+ }
+ }
+ }
+ }
+}
+
+private extension View {
+ /// Standard card background used across the analytics screens.
+ func cardBG(_ cs: ColorScheme) -> some View {
+ self.frame(maxWidth: .infinity, alignment: .leading)
+ .background(AppColors.surface(cs))
+ .clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
+ .overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
+ }
+}
diff --git a/KisaniCal/Views/CalendarView.swift b/KisaniCal/Views/CalendarView.swift
index b9d58c1..763a66d 100644
--- a/KisaniCal/Views/CalendarView.swift
+++ b/KisaniCal/Views/CalendarView.swift
@@ -294,6 +294,10 @@ struct CalendarView: View {
}()
@State private var viewMode: CalendarViewMode = .month
@State private var calTaskListMode: Bool = false
+ /// Year currently at the top of the continuous year scroll (drives the header in .year mode).
+ @State private var visibleYear: Int = Calendar.current.component(.year, from: Date())
+ /// Week view: timeline when false, grid of day-cards when true.
+ @State private var weekGrid: Bool = false
@State private var showAddTask = false
@State private var showCalendarConnect = false
@State private var showWorkoutSession = false
@@ -333,7 +337,7 @@ struct CalendarView: View {
// Center: month + year
Spacer()
- Text(monthTitle)
+ Text(headerTitle)
.font(AppFonts.sans(17, weight: .semibold))
.foregroundColor(AppColors.text(cs))
Spacer()
@@ -388,6 +392,9 @@ struct CalendarView: View {
calDayTaskListView
}
}
+ // Pin to the top so views whose content doesn't fill the height
+ // (week / 3-day timelines) don't float to the bottom of the ZStack.
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
FAB { showAddTask = true }
.padding(.trailing, 20).padding(.bottom, 10)
@@ -434,6 +441,12 @@ struct CalendarView: View {
}
}
+ /// In the continuous year view the header follows the scrolled-to year;
+ /// otherwise it's the month + year of the displayed month.
+ private var headerTitle: String {
+ viewMode == .year ? String(visibleYear) : monthTitle
+ }
+
private var monthTitle: String {
let f = DateFormatter(); f.dateFormat = "MMMM yyyy"
return f.string(from: displayMonth)
@@ -602,6 +615,7 @@ struct CalendarView: View {
onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
+ onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
@@ -673,42 +687,77 @@ struct CalendarView: View {
// MARK: - Year
- private var yearView: some View {
- let year = cal.component(.year, from: displayMonth)
- let months: [Date] = (1...12).compactMap { cal.date(from: DateComponents(year: year, month: $0, day: 1)) }
- return VStack(spacing: 0) {
- // Year header with navigation (unbounded — go as far forward/back as you like).
- HStack {
- Button { navigateYear(-1) } label: {
- Image(systemName: "chevron.left").font(.system(size: 14, weight: .semibold))
- .foregroundColor(AppColors.text(cs)).frame(width: 40, height: 40)
- }
- Spacer()
- Text(String(year))
- .font(AppFonts.sans(20, weight: .bold)).foregroundColor(AppColors.text(cs))
- Spacer()
- Button { navigateYear(1) } label: {
- Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold))
- .foregroundColor(AppColors.text(cs)).frame(width: 40, height: 40)
- }
- }
- .padding(.horizontal, 16).padding(.top, 4)
+ // Reports each year block's top offset within the year scroll, so the
+ // header can follow whichever year is currently at the top.
+ private struct YearTopKey: PreferenceKey {
+ static var defaultValue: [Int: CGFloat] = [:]
+ static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) {
+ value.merge(nextValue()) { _, new in new }
+ }
+ }
+ // Continuous, TickTick-style: years flow one after another in an infinite
+ // scroll. Opens anchored on the current year; scroll up for past years,
+ // down for future ones — no paging, no dead space below December.
+ private var yearRange: [Int] {
+ let now = cal.component(.year, from: Date())
+ return Array((now - 8)...(now + 20))
+ }
+
+ private var yearView: some View {
+ let currentYear = cal.component(.year, from: Date())
+ return ScrollViewReader { proxy in
ScrollView(showsIndicators: false) {
- LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 24) {
- ForEach(months, id: \.self) { m in
- cvMiniMonth(m)
+ LazyVStack(alignment: .leading, spacing: 28) {
+ ForEach(yearRange, id: \.self) { yr in
+ yearBlock(yr).id(yr)
}
}
- .padding(16)
+ .padding(.top, 8)
+ .padding(.bottom, 96) // clear the floating tab bar / + button
+ }
+ .coordinateSpace(name: "yearScroll")
+ .onPreferenceChange(YearTopKey.self) { tops in
+ // The header year is the topmost block that has reached the top edge:
+ // among blocks at/above a small threshold, the one nearest the top.
+ let threshold: CGFloat = 60
+ let reached = tops.filter { $0.value <= threshold }
+ if let y = reached.max(by: { $0.value < $1.value })?.key
+ ?? tops.min(by: { $0.value < $1.value })?.key,
+ y != visibleYear {
+ visibleYear = y
+ }
+ }
+ .onAppear {
+ visibleYear = currentYear
+ proxy.scrollTo(currentYear, anchor: .top)
}
}
}
- private func navigateYear(_ offset: Int) {
- withAnimation(KisaniSpring.snappy) {
- displayMonth = cal.date(byAdding: .year, value: offset, to: displayMonth) ?? displayMonth
+ private func yearBlock(_ year: Int) -> some View {
+ let months: [Date] = (1...12).compactMap { cal.date(from: DateComponents(year: year, month: $0, day: 1)) }
+ let isCurrent = year == cal.component(.year, from: Date())
+ return VStack(alignment: .leading, spacing: 14) {
+ Text(String(year))
+ .font(AppFonts.sans(26, weight: .bold))
+ .foregroundColor(isCurrent ? AppColors.accent : AppColors.text(cs))
+ .padding(.horizontal, 16)
+ LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 24) {
+ ForEach(months, id: \.self) { m in
+ cvMiniMonth(m)
+ }
+ }
+ .padding(.horizontal, 16)
}
+ .background(
+ GeometryReader { geo in
+ Color.clear.preference(
+ key: YearTopKey.self,
+ value: [year: geo.frame(in: .named("yearScroll")).minY]
+ )
+ }
+ )
}
private func cvMiniMonth(_ month: Date) -> some View {
@@ -745,84 +794,111 @@ struct CalendarView: View {
// MARK: - Week
+ // Week: a 7-day timeline (TickTick-style) — hours down the left, one column
+ // per weekday, events as positioned blocks. Toggle to a grid of day-cards.
private var weekView: some View {
- HStack(alignment: .top, spacing: 0) {
- VStack(spacing: 0) {
- HStack(spacing: 0) {
- ForEach(Array(weekdays.enumerated()), id: \.offset) { _, d in
- Text(d).font(AppFonts.mono(7, weight: .semibold))
- .foregroundColor(AppColors.text3(cs)).frame(maxWidth: .infinity)
- }
- }
- .padding(.horizontal, 6).padding(.top, 10)
- LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 0), count: 7), spacing: 2) {
- ForEach(Array(gridDays().enumerated()), id: \.offset) { _, date in
- let isTod = cal.isDateInToday(date)
- let isSel = cal.isDate(date, inSameDayAs: selectedDate)
- let inMon = cal.isDate(date, equalTo: displayMonth, toGranularity: .month)
- ZStack {
- if isTod { Circle().fill(AppColors.accent).frame(width: 20, height: 20) }
- else if isSel { Circle().fill(AppColors.accentSoft).frame(width: 20, height: 20) }
- Text("\(cal.component(.day, from: date))")
- .font(AppFonts.sans(9, weight: isTod ? .bold : .regular))
- .foregroundColor(isTod ? .white : inMon ? AppColors.text(cs) : AppColors.text3(cs))
- }
- .frame(height: 22)
- .onTapGesture { withAnimation(KisaniSpring.micro) { selectedDate = date } }
- }
- }
- .padding(.horizontal, 4)
- .gesture(DragGesture(minimumDistance: 40).onEnded { v in
- if v.translation.width < -40 { navigateMonth(1) }
- else if v.translation.width > 40 { navigateMonth(-1) }
- })
+ let days = cvWeekDays()
+ return VStack(spacing: 0) {
+ // Layout toggle: timeline ⇄ grid
+ HStack {
Spacer()
+ Button {
+ withAnimation(KisaniSpring.snappy) { weekGrid.toggle() }
+ } label: {
+ Image(systemName: weekGrid ? "calendar.day.timeline.left" : "square.grid.2x2")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundColor(AppColors.text2(cs))
+ .padding(.horizontal, 10).padding(.vertical, 5)
+ .background(AppColors.surface2(cs))
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ }
+ .buttonStyle(.plain)
}
- .frame(maxWidth: .infinity)
+ .padding(.horizontal, 14).padding(.top, 6).padding(.bottom, 2)
- Rectangle().fill(AppColors.border(cs)).frame(width: 1)
+ if weekGrid {
+ weekGridLayout(days)
+ } else {
+ cvDayColumnHeader(days)
+ .contentShape(Rectangle())
+ .gesture(DragGesture(minimumDistance: 40).onEnded { v in
+ if v.translation.width < -40 { navigateWeek(1) }
+ else if v.translation.width > 40 { navigateWeek(-1) }
+ })
+ Divider().background(AppColors.border(cs))
+ ScrollView(showsIndicators: false) {
+ cvTimeGrid(days: days).padding(.bottom, 40)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+ }
- ScrollView(showsIndicators: false) {
- VStack(spacing: 0) {
- ForEach(cvWeekDays(), id: \.self) { date in
- let isTod = cal.isDateInToday(date)
- let isSel = cal.isDate(date, inSameDayAs: selectedDate)
- let items = cvAgendaItems(date)
- VStack(alignment: .leading, spacing: 5) {
- HStack(spacing: 4) {
- Text(cvDayFmt.string(from: date))
- .font(AppFonts.mono(9, weight: .semibold))
- .foregroundColor(isTod ? AppColors.accent : AppColors.text2(cs))
- Text("\(cal.component(.day, from: date))")
- .font(AppFonts.sans(12, weight: isTod ? .bold : .medium))
- .foregroundColor(isTod ? AppColors.accent : AppColors.text(cs))
- }
- if items.isEmpty {
- Text("Free").font(AppFonts.sans(9)).foregroundColor(AppColors.text3(cs))
- } else {
- ForEach(Array(items.prefix(4).enumerated()), id: \.offset) { _, item in
- HStack(spacing: 5) {
- RoundedRectangle(cornerRadius: 1.5).fill(item.color)
- .frame(width: 2.5, height: 11)
- Text(item.title).font(AppFonts.sans(9))
- .foregroundColor(AppColors.text2(cs)).lineLimit(1)
- }
- }
- if items.count > 4 {
- Text("+\(items.count - 4) more").font(AppFonts.sans(8))
- .foregroundColor(AppColors.text3(cs))
- }
- }
- }
- .padding(10)
- .background(isSel ? AppColors.accentSoft : Color.clear)
- .contentShape(Rectangle())
- .onTapGesture { withAnimation(KisaniSpring.micro) { selectedDate = date } }
- Divider().background(AppColors.border(cs))
- }
+ // Grid layout: the 7 days as cards (2 columns), each listing its schedule.
+ private func weekGridLayout(_ days: [Date]) -> some View {
+ ScrollView(showsIndicators: false) {
+ LazyVGrid(columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)], spacing: 10) {
+ ForEach(days, id: \.self) { date in
+ weekDayCard(date)
}
}
- .frame(maxWidth: .infinity)
+ .padding(16)
+ }
+ .gesture(DragGesture(minimumDistance: 40).onEnded { v in
+ if v.translation.width < -40 { navigateWeek(1) }
+ else if v.translation.width > 40 { navigateWeek(-1) }
+ })
+ }
+
+ private func weekDayCard(_ date: Date) -> some View {
+ let items = (cvAllDayItems(date) + cvTimeItems(date)).sorted { $0.startMin < $1.startMin }
+ let isTod = cal.isDateInToday(date)
+ let isSel = cal.isDate(date, inSameDayAs: selectedDate)
+ return VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 5) {
+ Text(cvDayFmt.string(from: date))
+ .font(AppFonts.mono(9, weight: .semibold))
+ .foregroundColor(isTod ? AppColors.accent : AppColors.text3(cs))
+ Text("\(cal.component(.day, from: date))")
+ .font(AppFonts.sans(14, weight: isTod ? .bold : .semibold))
+ .foregroundColor(isTod ? AppColors.accent : AppColors.text(cs))
+ Spacer(minLength: 0)
+ }
+ if items.isEmpty {
+ Text("Free").font(AppFonts.sans(9)).foregroundColor(AppColors.text3(cs))
+ } else {
+ ForEach(Array(items.prefix(5).enumerated()), id: \.offset) { _, item in
+ HStack(spacing: 4) {
+ RoundedRectangle(cornerRadius: 2).fill(item.color).frame(width: 3, height: 12)
+ Text(item.title).font(AppFonts.sans(9))
+ .foregroundColor(AppColors.text2(cs)).lineLimit(1)
+ Spacer(minLength: 0)
+ }
+ }
+ if items.count > 5 {
+ Text("+\(items.count - 5) more").font(AppFonts.sans(8)).foregroundColor(AppColors.text3(cs))
+ }
+ }
+ }
+ .padding(10)
+ .frame(maxWidth: .infinity, minHeight: 92, alignment: .topLeading)
+ .background(AppColors.surface(cs))
+ .clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
+ .overlay(
+ RoundedRectangle(cornerRadius: AppRadius.large)
+ .stroke(isSel ? AppColors.accent : AppColors.border(cs), lineWidth: isSel ? 1.5 : 1)
+ )
+ .contentShape(Rectangle())
+ .onTapGesture {
+ withAnimation(KisaniSpring.micro) { selectedDate = date; viewMode = .day }
+ }
+ }
+
+ private func navigateWeek(_ offset: Int) {
+ withAnimation(KisaniSpring.snappy) {
+ selectedDate = cal.date(byAdding: .day, value: offset * 7, to: selectedDate) ?? selectedDate
+ displayMonth = cal.date(from: cal.dateComponents([.year, .month], from: selectedDate)) ?? displayMonth
}
}
@@ -836,7 +912,9 @@ struct CalendarView: View {
ScrollView(showsIndicators: false) {
cvTimeGrid(days: days).padding(.bottom, 40)
}
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
}
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
// MARK: - Day
@@ -902,11 +980,6 @@ struct CalendarView: View {
return items
}
- /// Everything happening on a date — all-day items first, then timed — for compact
- /// agenda listings (e.g. the Week column). Recurrence-aware.
- private func cvAgendaItems(_ date: Date) -> [CVTimeItem] {
- (cvAllDayItems(date) + cvTimeItems(date)).sorted { $0.startMin < $1.startMin }
- }
@ViewBuilder
private func cvAllDayBand(days: [Date]) -> some View {
@@ -970,27 +1043,60 @@ struct CalendarView: View {
ForEach(cvTimeItems(date)) { item in
let top = max(0, CGFloat(item.startMin - cvStartH * 60) / 60.0 * cvHourH)
let dur = max(CGFloat(item.endMin - item.startMin) / 60.0 * cvHourH, 28)
- RoundedRectangle(cornerRadius: 4)
- .fill(item.color.opacity(0.28))
- .overlay(alignment: .leading) {
- Rectangle().fill(item.color).frame(width: 3)
- .clipShape(RoundedRectangle(cornerRadius: 2))
- }
- .overlay(alignment: .topLeading) {
- Text(item.title)
- .font(AppFonts.sans(9, weight: .medium))
- .foregroundColor(item.color)
- .padding(.horizontal, 6).padding(.top, 4)
- .lineLimit(2)
- }
- .frame(maxWidth: .infinity).frame(height: dur)
- .padding(.trailing, 2)
- .offset(y: top)
+ CVTimeBlock(
+ item: item, top: top, height: dur, hourH: cvHourH,
+ draggable: item.taskID != nil && !item.recurring,
+ onReschedule: { newStartMin in
+ rescheduleTask(id: item.taskID, on: date, toStartMin: newStartMin)
+ },
+ menu: { cvBlockMenu(for: item) }
+ )
}
}
.frame(maxWidth: .infinity)
}
+ /// Long-press menu for a timeline block. Task items get the full shared menu
+ /// (Complete / Snooze / date / move / edit / delete); events get nothing.
+ @ViewBuilder
+ private func cvBlockMenu(for item: CVTimeItem) -> some View {
+ if let id = item.taskID, let task = taskVM.tasks.first(where: { $0.id == id }) {
+ Button {
+ withAnimation(KisaniSpring.snappy) { taskVM.toggle(task) }
+ } label: {
+ Label(task.isComplete ? "Mark Incomplete" : "Complete", systemImage: "checkmark.circle")
+ }
+ Menu {
+ Button { taskVM.postpone(task, minutes: 15) } label: { Label("15 minutes", systemImage: "clock") }
+ Button { taskVM.postpone(task, minutes: 60) } label: { Label("1 hour", systemImage: "clock") }
+ Button { taskVM.postpone(task, days: 1) } label: { Label("Tomorrow", systemImage: "sun.max") }
+ } label: {
+ Label("Snooze", systemImage: "clock.arrow.circlepath")
+ }
+ TaskMenuItems(
+ task: task,
+ onPin: { taskVM.pin($0) },
+ onSetDate: { taskVM.setDate($0, date: $1) },
+ onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
+ onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
+ onSetPriority: { taskVM.setPriority($0, priority: $1) },
+ onSetCategory: { taskVM.setCategory($0, category: $1) },
+ onEdit: { editingTask = $0 },
+ onDelete: { taskVM.delete($0) },
+ onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }
+ )
+ }
+ }
+
+ /// Move a timed task to a new start minute on the same day (drag-to-reschedule).
+ private func rescheduleTask(id: UUID?, on date: Date, toStartMin newStartMin: Int) {
+ guard let id, let task = taskVM.tasks.first(where: { $0.id == id }) else { return }
+ let clamped = min(max(newStartMin, cvStartH * 60), cvEndH * 60 - 15)
+ let h = clamped / 60, m = clamped % 60
+ guard let newDate = cal.date(bySettingHour: h, minute: m, second: 0, of: date) else { return }
+ withAnimation(KisaniSpring.snappy) { taskVM.setDate(task, date: newDate) }
+ }
+
// MARK: - Shared helpers
private var cvWeekStrip: some View {
@@ -1050,7 +1156,9 @@ struct CalendarView: View {
for task in taskVM.occurrences(on: date) where !task.isComplete && task.hasTime {
guard let d = task.dueDate else { continue }
let h = cal.component(.hour, from: d), m = cal.component(.minute, from: d)
- items.append(CVTimeItem(title: task.title, color: task.quadrant.color, startMin: h * 60 + m, endMin: h * 60 + m + 30))
+ items.append(CVTimeItem(title: task.title, color: task.quadrant.color,
+ startMin: h * 60 + m, endMin: h * 60 + m + 30,
+ taskID: task.id, recurring: taskVM.recurs(task)))
}
for ev in calStore.isAuthorized ? calStore.events(for: date) : [] {
guard let s = ev.startDate, let e = ev.endDate, !ev.isAllDay else { continue }
@@ -1073,13 +1181,69 @@ struct CVTimeItem: Identifiable {
let color: Color
let startMin: Int
let endMin: Int
+ let taskID: UUID? // non-nil for task-backed items (draggable)
+ let recurring: Bool // recurring tasks aren't drag-rescheduled
- init(title: String, color: Color, startMin: Int, endMin: Int) {
- self.id = "\(title)-\(startMin)"
- self.title = title
- self.color = color
- self.startMin = startMin
- self.endMin = endMin
+ init(title: String, color: Color, startMin: Int, endMin: Int,
+ taskID: UUID? = nil, recurring: Bool = false) {
+ self.id = taskID?.uuidString ?? "\(title)-\(startMin)"
+ self.title = title
+ self.color = color
+ self.startMin = startMin
+ self.endMin = endMin
+ self.taskID = taskID
+ self.recurring = recurring
+ }
+}
+
+// A single timed block on the week/day timeline. Task-backed blocks can be
+// dragged vertically to reschedule (snapped to 15-minute steps); events and
+// recurring tasks render fixed.
+private struct CVTimeBlock: View {
+ let item: CVTimeItem
+ let top: CGFloat
+ let height: CGFloat
+ let hourH: CGFloat
+ let draggable: Bool
+ let onReschedule: (Int) -> Void
+ @ViewBuilder let menu: () -> MenuContent
+ @GestureState private var dragY: CGFloat = 0
+
+ private var block: some View {
+ RoundedRectangle(cornerRadius: 4)
+ .fill(item.color.opacity(dragY == 0 ? 0.28 : 0.45))
+ .overlay(alignment: .leading) {
+ Rectangle().fill(item.color).frame(width: 3)
+ .clipShape(RoundedRectangle(cornerRadius: 2))
+ }
+ .overlay(alignment: .topLeading) {
+ Text(item.title)
+ .font(AppFonts.sans(9, weight: .medium))
+ .foregroundColor(item.color)
+ .padding(.horizontal, 6).padding(.top, 4)
+ .lineLimit(2)
+ }
+ .frame(maxWidth: .infinity).frame(height: height)
+ .padding(.trailing, 2)
+ .offset(y: top + dragY)
+ }
+
+ var body: some View {
+ Group {
+ if draggable {
+ block.gesture(
+ DragGesture(minimumDistance: 6)
+ .updating($dragY) { value, state, _ in state = value.translation.height }
+ .onEnded { value in
+ let deltaMin = Int((Double(value.translation.height) / Double(hourH) * 60).rounded() / 15) * 15
+ onReschedule(item.startMin + deltaMin)
+ }
+ )
+ } else {
+ block
+ }
+ }
+ .contextMenu { menu() } // empty for events → no menu; task actions otherwise
}
}
@@ -1645,6 +1809,7 @@ struct DayTimelineView: View {
var onPostpone: ((TaskItem) -> Void)? = nil
var onEdit: ((TaskItem) -> Void)? = nil
var onDelete: ((TaskItem) -> Void)? = nil
+ var onCompleteAndStopSeries: ((TaskItem) -> Void)? = nil
var onSetDate: ((TaskItem, Date?) -> Void)? = nil
var onPostponeMinutes: ((TaskItem, Int) -> Void)? = nil
var onMove: ((TaskItem, Quadrant) -> Void)? = nil
@@ -1817,7 +1982,8 @@ struct DayTimelineView: View {
onResetMatrixUrgency: onResetMatrixUrgency,
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
onEdit: onEdit,
- onDelete: { onDelete?($0) }
+ onDelete: { onDelete?($0) },
+ onCompleteAndStopSeries: { onCompleteAndStopSeries?($0) }
)
}
diff --git a/KisaniCal/Views/MatrixView.swift b/KisaniCal/Views/MatrixView.swift
index ddc900d..e99700f 100644
--- a/KisaniCal/Views/MatrixView.swift
+++ b/KisaniCal/Views/MatrixView.swift
@@ -72,6 +72,7 @@ struct MatrixView: View {
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
+ onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onTapHeader: { drillQuadrant = .urgent; showDrill = true }
)
QuadrantCard(
@@ -93,6 +94,7 @@ struct MatrixView: View {
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
+ onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onTapHeader: { drillQuadrant = .schedule; showDrill = true }
)
}
@@ -118,6 +120,7 @@ struct MatrixView: View {
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
+ onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onTapHeader: { drillQuadrant = .delegate_; showDrill = true }
)
QuadrantCard(
@@ -139,6 +142,7 @@ struct MatrixView: View {
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
+ onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onTapHeader: { drillQuadrant = .eliminate; showDrill = true }
)
}
@@ -211,6 +215,7 @@ struct QuadrantCard: View {
var onResetMatrixUrgency: ((TaskItem) -> Void)? = nil
var onEdit: ((TaskItem) -> Void)? = nil
var onDelete: ((TaskItem) -> Void)? = nil
+ var onCompleteAndStopSeries: ((TaskItem) -> Void)? = nil
var onTapHeader: (() -> Void)? = nil
private let shortFmt: DateFormatter = {
@@ -326,7 +331,8 @@ struct QuadrantCard: View {
onResetMatrixUrgency: { t in withAnimation(KisaniSpring.snappy) { onResetMatrixUrgency?(t) } },
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
onEdit: onEdit.map { cb in { cb($0) } },
- onDelete: { t in withAnimation(KisaniSpring.snappy) { onDelete?(t) } }
+ onDelete: { t in withAnimation(KisaniSpring.snappy) { onDelete?(t) } },
+ onCompleteAndStopSeries: { t in withAnimation(KisaniSpring.snappy) { onCompleteAndStopSeries?(t) } }
)
}
}
@@ -487,7 +493,8 @@ struct QuadrantDetailView: View {
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
onEdit: { editingTask = $0 },
- onDelete: { taskVM.delete($0) }
+ onDelete: { taskVM.delete($0) },
+ onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }
)
}
if task.id != overdueTasks.last?.id { Divider().padding(.leading, 46) }
@@ -514,7 +521,8 @@ struct QuadrantDetailView: View {
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
onEdit: { editingTask = $0 },
- onDelete: { taskVM.delete($0) }
+ onDelete: { taskVM.delete($0) },
+ onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }
)
}
if task.id != laterTasks.last?.id { Divider().padding(.leading, 46) }
@@ -542,7 +550,8 @@ struct QuadrantDetailView: View {
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
onEdit: { editingTask = $0 },
- onDelete: { taskVM.delete($0) }
+ onDelete: { taskVM.delete($0) },
+ onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }
)
}
if task.id != displayed.last?.id { Divider().padding(.leading, 46) }
@@ -729,6 +738,7 @@ struct TaskEditSheet: View {
@State private var isRecurring: Bool
@State private var recurrenceLabel: String?
@State private var recurrenceEnd: Date?
+ @State private var recurrenceInterval: Int?
@State private var endDate: Date?
@State private var isAllDay: Bool
@State private var reminderDate: Date?
@@ -743,6 +753,7 @@ struct TaskEditSheet: View {
_isRecurring = State(initialValue: task.isRecurring)
_recurrenceLabel = State(initialValue: task.recurrenceLabel)
_recurrenceEnd = State(initialValue: task.recurrenceEnd)
+ _recurrenceInterval = State(initialValue: task.recurrenceInterval)
_endDate = State(initialValue: task.endDate)
_isAllDay = State(initialValue: task.isAllDay)
_reminderDate = State(initialValue: task.reminderDate)
@@ -804,7 +815,7 @@ struct TaskEditSheet: View {
.font(.system(size: 12))
.foregroundColor(task.quadrant.color)
if let lbl = recurrenceLabel {
- Text(lbl)
+ Text(RecurrenceRule.displayLabel(label: lbl, interval: recurrenceInterval ?? 1))
.font(AppFonts.sans(12))
.foregroundColor(AppColors.text3(cs))
}
@@ -894,7 +905,8 @@ struct TaskEditSheet: View {
isAllDay: $isAllDay,
reminderDate: $reminderDate,
constantReminder: $constantReminder,
- recurrenceEnd: $recurrenceEnd
+ recurrenceEnd: $recurrenceEnd,
+ recurrenceInterval: $recurrenceInterval
)
}
}
@@ -905,7 +917,8 @@ struct TaskEditSheet: View {
taskVM.updateTask(task, title: t, dueDate: dueDate, hasTime: hasTime,
isRecurring: isRecurring, recurrenceLabel: recurrenceLabel,
endDate: endDate, isAllDay: isAllDay, reminderDate: reminderDate,
- constantReminder: constantReminder, recurrenceEnd: recurrenceEnd)
+ constantReminder: constantReminder, recurrenceEnd: recurrenceEnd,
+ recurrenceInterval: recurrenceInterval)
dismiss()
}
}
diff --git a/KisaniCal/Views/OnboardingView.swift b/KisaniCal/Views/OnboardingView.swift
index ed55a13..c1e9f8a 100644
--- a/KisaniCal/Views/OnboardingView.swift
+++ b/KisaniCal/Views/OnboardingView.swift
@@ -16,6 +16,11 @@ struct OnboardingView: View {
@AppStorage("heightCm") private var heightCm: Double = 0
@AppStorage("weightUnit") private var weightUnit: Int = 0
@AppStorage("preferredWorkoutTime") private var preferredWorkoutTime: Int = 0
+ // Habit reminders — same App Group keys HabitRemindersSheet and
+ // NotificationManager use, so a toggle here is identical to Settings.
+ @AppStorage("bedReminderEnabled", store: UserDefaults.kisani) private var bedReminderEnabled = false
+ @AppStorage("prayerReminderEnabled", store: UserDefaults.kisani) private var prayerReminderEnabled = false
+ @AppStorage("coffeeReminderEnabled", store: UserDefaults.kisani) private var coffeeReminderEnabled = false
// Local (uncommitted) state
@State private var localAppearance: Int = 2 // dark by default
@@ -507,6 +512,41 @@ struct OnboardingView: View {
.padding(.horizontal, 20)
.animation(KisaniSpring.snappy, value: workoutEnabled)
+ // ══════════════════════════════
+ // MARK: Habits
+ // ══════════════════════════════
+ OnboardingSectionHeader(title: "Habits")
+
+ VStack(spacing: 0) {
+ OnboardingHabitToggle(
+ icon: "hands.sparkles.fill", iconColor: AppColors.yellow, iconBg: AppColors.yellowSoft,
+ title: "Prayer reminders",
+ subtitle: "\"Have you had a chance to pray and give thanks?\"",
+ isOn: $prayerReminderEnabled)
+ Divider().background(AppColors.border(cs)).padding(.leading, 53)
+ OnboardingHabitToggle(
+ icon: "bed.double.fill", iconColor: AppColors.blue, iconBg: AppColors.blueSoft,
+ title: "Make your bed",
+ subtitle: "The simplest task that starts the day with a win",
+ isOn: $bedReminderEnabled)
+ Divider().background(AppColors.border(cs)).padding(.leading, 53)
+ OnboardingHabitToggle(
+ icon: "cup.and.saucer.fill", iconColor: .white, iconBg: AppColors.coffeeBrown,
+ title: "Coffee reminders",
+ subtitle: "Track when you usually reach for a cup",
+ isOn: $coffeeReminderEnabled)
+ }
+ .cardStyle()
+ .padding(.horizontal, 20)
+ .padding(.bottom, 8)
+
+ Text("You can turn any of these on or off — or fine-tune the exact times, or add a custom coffee reminder — anytime in Settings → Habits.")
+ .font(AppFonts.sans(11))
+ .foregroundColor(AppColors.text3(cs))
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 32)
+ .padding(.bottom, 8)
+
Text("All settings can be changed later in Settings.")
.font(AppFonts.sans(11))
.foregroundColor(AppColors.text3(cs))
@@ -577,6 +617,37 @@ struct OnboardingView: View {
}
}
+// MARK: - Habit Toggle Row
+
+/// A single habit reminder's opt-in row (icon, title, one-line description,
+/// toggle) — used for Prayer / Bed-making / Coffee in onboarding. Per-slot
+/// times and the custom coffee reminder are Settings-only, kept out of
+/// onboarding to stay restrained.
+private struct OnboardingHabitToggle: View {
+ @Environment(\.colorScheme) private var cs
+ let icon: String; let iconColor: Color; let iconBg: Color
+ let title: String; let subtitle: String
+ @Binding var isOn: Bool
+
+ var body: some View {
+ HStack(spacing: 12) {
+ Image(systemName: icon)
+ .font(.system(size: 14))
+ .foregroundColor(iconColor)
+ .frame(width: 32, height: 32)
+ .background(iconBg)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title).font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
+ Text(subtitle).font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)).lineLimit(2)
+ }
+ Spacer()
+ Toggle("", isOn: $isOn).labelsHidden().tint(AppColors.accent)
+ }
+ .padding(.horizontal, 14).padding(.vertical, 12)
+ }
+}
+
// MARK: - Section Header
private struct OnboardingSectionHeader: View {
diff --git a/KisaniCal/Views/SettingsView.swift b/KisaniCal/Views/SettingsView.swift
index 3c8f3b7..ed3809d 100644
--- a/KisaniCal/Views/SettingsView.swift
+++ b/KisaniCal/Views/SettingsView.swift
@@ -15,6 +15,7 @@ struct SettingsView: View {
@EnvironmentObject var taskVM: TaskViewModel
@ObservedObject private var auth = AuthManager.shared
@ObservedObject private var calStore = CalendarStore.shared
+ @ObservedObject private var tm = TutorialManager.shared
// Persisted settings
@AppStorage("appearanceMode") private var appearanceMode = 0
@@ -33,6 +34,9 @@ struct SettingsView: View {
@AppStorage("streakGoal") private var streakGoal = 5
// App-Group store so NotificationManager (which reads UserDefaults.kisani) sees it.
@AppStorage("kisani.periodMilestones", store: UserDefaults.kisani) private var periodMilestones = true
+ @AppStorage("bedReminderEnabled", store: UserDefaults.kisani) private var bedReminderEnabled = false
+ @AppStorage("prayerReminderEnabled", store: UserDefaults.kisani) private var prayerReminderEnabled = false
+ @AppStorage("coffeeReminderEnabled", store: UserDefaults.kisani) private var coffeeReminderEnabled = false
// Sheet presentation
@State private var showProfile = false
@@ -45,6 +49,7 @@ struct SettingsView: View {
@State private var showCalendar = false
@State private var showBackup = false
@State private var showWorkoutSettings = false
+ @State private var showHabitReminders = false
@State private var showRestTimer = false
@State private var showHelp = false
@State private var showFollow = false
@@ -54,6 +59,10 @@ struct SettingsView: View {
private var restLabel: String { workoutVM.restTimerEnabled ? "\(workoutVM.restTimerSeconds)s" : "Off" }
private var weightLabel: String { weightUnit == 0 ? "kg" : "lbs" }
private var dateLabel: String { ["MMM d", "MM/DD", "DD/MM"][dateFormat] }
+ private var habitsLabel: String? {
+ let on = [bedReminderEnabled, prayerReminderEnabled, coffeeReminderEnabled].filter { $0 }.count
+ return on == 0 ? nil : "\(on) on"
+ }
var body: some View {
ZStack {
@@ -137,6 +146,13 @@ struct SettingsView: View {
HealthToggleRow(isLast: true)
}
+ // ── HABITS ──
+ SttSection(label: "Habits") {
+ SttNavRow(icon: "hands.sparkles", bg: AppColors.yellowSoft, fg: AppColors.yellow,
+ label: "Habit Reminders", value: habitsLabel,
+ valueColor: habitsLabel != nil ? AppColors.green : nil, isLast: true) { showHabitReminders = true }
+ }
+
// ── ACCOUNT ──
SttSection(label: "Account", secondary: true) {
if let user = AuthManager.shared.currentUser {
@@ -174,6 +190,21 @@ struct SettingsView: View {
Spacer().frame(height: 40)
}
}
+
+ if tm.settingsIsActive {
+ VStack {
+ Spacer()
+ InViewTutorialCard(
+ tips: tm.settingsTips,
+ step: $tm.settingsStep,
+ onAdvance: { tm.advanceSettings() },
+ onSkip: { tm.skipSettings() }
+ )
+ .padding(.bottom, 84)
+ }
+ .transition(.opacity)
+ .zIndex(10)
+ }
}
.sheet(isPresented: $showProfile) { ProfileStatsSheet().environmentObject(taskVM).environmentObject(workoutVM).presentationDetents([.large]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showTabBar) { TabBarSheet(showMatrix: $showMatrixTab, showWorkout: $showWorkoutTab).presentationDetents([.height(320)]).presentationDragIndicator(.visible) }
@@ -185,6 +216,7 @@ struct SettingsView: View {
.sheet(isPresented: $showCalendar) { CalendarConnectSheet(calStore: calStore).presentationDetents([.large]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showBackup) { BackupSheet(iCloudSync: $iCloudSync).presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showWorkoutSettings) { WorkoutSettingsSheet(unit: $weightUnit, sets: $defaultSets, reps: $defaultReps).environmentObject(workoutVM).presentationDetents([.large]).presentationDragIndicator(.visible) }
+ .sheet(isPresented: $showHabitReminders) { HabitRemindersSheet().environmentObject(workoutVM).environmentObject(taskVM).presentationDetents([.large]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showRestTimer) { RestTimerSheet(seconds: $workoutVM.restTimerSeconds, enabled: $workoutVM.restTimerEnabled).presentationDetents([.height(360)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showHelp) { HelpSheet().presentationDetents([.height(300)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showFollow) { FollowSheet().presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
@@ -781,7 +813,7 @@ private struct WorkoutSettingsSheet: View {
}.buttonStyle(.plain)
}
}
- Text("Reach your goal every week to maintain your streak.")
+ Text("Your weekly workout target. Every workout you log adds to your total — a missed day never takes it away.")
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
}
@@ -880,6 +912,261 @@ private struct WorkoutSettingsSheet: View {
}
}
+// MARK: - Habit Reminders Sheet
+//
+// Three independent daily reminders: prayer (up to 4 named times a day),
+// bed-making (one nudge — research says it's the easiest first win of the
+// day), and coffee/caffeine (up to 4 named times plus one custom reminder).
+// All keys use the App Group store (`UserDefaults.kisani`) — the same store
+// NotificationManager reads from — so toggling here actually schedules.
+private struct HabitRemindersSheet: View {
+ @Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs
+ @EnvironmentObject var workoutVM: WorkoutViewModel
+ @EnvironmentObject var taskVM: TaskViewModel
+
+ @AppStorage("bedReminderEnabled", store: UserDefaults.kisani) private var bedReminderEnabled = false
+ @AppStorage("bedReminderHour", store: UserDefaults.kisani) private var bedReminderHour = 8
+ @AppStorage("bedReminderMinute", store: UserDefaults.kisani) private var bedReminderMinute = 0
+
+ @AppStorage("prayerReminderEnabled", store: UserDefaults.kisani) private var prayerReminderEnabled = false
+ @AppStorage("coffeeReminderEnabled", store: UserDefaults.kisani) private var coffeeReminderEnabled = false
+
+ @AppStorage("coffeeCustomEnabled", store: UserDefaults.kisani) private var coffeeCustomEnabled = false
+ @AppStorage("coffeeCustomLabel", store: UserDefaults.kisani) private var coffeeCustomLabel = ""
+ @AppStorage("coffeeCustomHour", store: UserDefaults.kisani) private var coffeeCustomHour = 16
+ @AppStorage("coffeeCustomMinute", store: UserDefaults.kisani) private var coffeeCustomMinute = 30
+
+ private let prayerSlots = ["Morning", "Afternoon", "Evening", "Night"]
+ private let coffeeSlots = ["Morning", "Midday", "Afternoon", "Evening"]
+
+ var body: some View {
+ VStack(spacing: 0) {
+ SheetHeader(title: "Habit Reminders") {
+ dismiss()
+ NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
+ }
+ ScrollView(showsIndicators: false) {
+ VStack(alignment: .leading, spacing: 20) {
+
+ // ── Prayer ──
+ VStack(alignment: .leading, spacing: 8) {
+ Text("PRAYER").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
+ VStack(spacing: 0) {
+ HStack(spacing: 11) {
+ Image(systemName: "hands.sparkles.fill")
+ .font(.system(size: 13)).foregroundColor(AppColors.yellow)
+ .frame(width: 28, height: 28).background(AppColors.yellowSoft).cornerRadius(8)
+ VStack(alignment: .leading, spacing: 1) {
+ HStack(spacing: 6) {
+ Text("Prayer reminders").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
+ habitBadge("prayer")
+ }
+ Text("\"Have you had a chance to pray and give thanks?\"")
+ .font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
+ }
+ Spacer()
+ Toggle("", isOn: $prayerReminderEnabled).labelsHidden().tint(AppColors.accent)
+ }
+ .padding(.horizontal, 14).padding(.vertical, 12)
+ if prayerReminderEnabled {
+ ForEach(prayerSlots, id: \.self) { slot in
+ HabitSlotRow(keyPrefix: "prayer\(slot)", label: slot,
+ defaultHour: Self.defaultHour(for: slot, prayer: true),
+ defaultMinute: 0, defaultEnabled: true)
+ }
+ }
+ }
+ .cardStyle()
+ .animation(KisaniSpring.snappy, value: prayerReminderEnabled)
+ }
+
+ // ── Bed-making ──
+ VStack(alignment: .leading, spacing: 8) {
+ Text("MORNING").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
+ VStack(spacing: 0) {
+ HStack(spacing: 11) {
+ Image(systemName: "bed.double.fill")
+ .font(.system(size: 13)).foregroundColor(AppColors.blue)
+ .frame(width: 28, height: 28).background(AppColors.blueSoft).cornerRadius(8)
+ VStack(alignment: .leading, spacing: 1) {
+ HStack(spacing: 6) {
+ Text("Make your bed").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
+ habitBadge("bed")
+ }
+ Text("The simplest task that starts the day with a win.")
+ .font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
+ }
+ Spacer()
+ Toggle("", isOn: $bedReminderEnabled).labelsHidden().tint(AppColors.accent)
+ }
+ .padding(.horizontal, 14).padding(.vertical, 12)
+ if bedReminderEnabled {
+ Divider().background(AppColors.border(cs)).padding(.leading, 53)
+ HabitTimeRow(label: "Remind me at", hour: $bedReminderHour, minute: $bedReminderMinute)
+ }
+ }
+ .cardStyle()
+ .animation(KisaniSpring.snappy, value: bedReminderEnabled)
+ }
+
+ // ── Coffee / Caffeine ──
+ VStack(alignment: .leading, spacing: 8) {
+ Text("CAFFEINE").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
+ VStack(spacing: 0) {
+ HStack(spacing: 11) {
+ Image(systemName: "cup.and.saucer.fill")
+ .font(.system(size: 13)).foregroundColor(.white)
+ .frame(width: 28, height: 28).background(AppColors.coffeeBrown).cornerRadius(8)
+ VStack(alignment: .leading, spacing: 1) {
+ HStack(spacing: 6) {
+ Text("Coffee reminders").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
+ habitBadge("coffee")
+ }
+ Text("Track when you usually reach for a cup.")
+ .font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
+ }
+ Spacer()
+ Toggle("", isOn: $coffeeReminderEnabled).labelsHidden().tint(AppColors.accent)
+ }
+ .padding(.horizontal, 14).padding(.vertical, 12)
+ if coffeeReminderEnabled {
+ ForEach(coffeeSlots, id: \.self) { slot in
+ HabitSlotRow(keyPrefix: "coffee\(slot)", label: slot,
+ defaultHour: Self.defaultHour(for: slot, prayer: false),
+ defaultMinute: 0, defaultEnabled: slot == "Morning")
+ }
+ Divider().background(AppColors.border(cs)).padding(.leading, 14)
+ VStack(alignment: .leading, spacing: 5) {
+ HStack(spacing: 4) {
+ Image(systemName: "pencil").font(.system(size: 9))
+ Text("YOUR OWN REMINDER").font(AppFonts.mono(8, weight: .bold))
+ }
+ .foregroundColor(AppColors.text3(cs))
+ HStack(spacing: 11) {
+ TextField("Name it, e.g. \"Second coffee\"", text: $coffeeCustomLabel)
+ .font(AppFonts.sans(12.5)).foregroundColor(AppColors.text(cs))
+ Spacer()
+ if coffeeCustomEnabled {
+ DatePicker("", selection: Binding(
+ get: { Calendar.current.date(bySettingHour: coffeeCustomHour, minute: coffeeCustomMinute, second: 0, of: Date()) ?? Date() },
+ set: { v in
+ let c = Calendar.current.dateComponents([.hour, .minute], from: v)
+ coffeeCustomHour = c.hour ?? coffeeCustomHour
+ coffeeCustomMinute = c.minute ?? coffeeCustomMinute
+ }
+ ), displayedComponents: .hourAndMinute)
+ .labelsHidden().tint(AppColors.accent)
+ }
+ Toggle("", isOn: $coffeeCustomEnabled).labelsHidden().tint(AppColors.accent).scaleEffect(0.85)
+ }
+ }
+ .padding(.horizontal, 14).padding(.vertical, 9)
+ }
+ }
+ .cardStyle()
+ .animation(KisaniSpring.snappy, value: coffeeReminderEnabled)
+ }
+
+ Text("All reminders are local to this device and never share your responses — they're just gentle nudges.")
+ .font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
+ }
+ .padding(.horizontal, 20)
+ .padding(.bottom, 20)
+ }
+ }
+ .background(AppColors.background(cs).ignoresSafeArea())
+ }
+
+ private static func defaultHour(for slot: String, prayer: Bool) -> Int {
+ let table: [String: Int] = prayer
+ ? ["Morning": 7, "Afternoon": 13, "Evening": 18, "Night": 21]
+ : ["Morning": 8, "Midday": 12, "Afternoon": 15, "Evening": 18]
+ return table[slot] ?? 9
+ }
+
+ /// A quiet "X days" badge from tapping "Done" on a habit's notifications —
+ /// a private lifetime tally, never shown in Today/Matrix/Activity History.
+ @ViewBuilder
+ private func habitBadge(_ type: String) -> some View {
+ let count = NotificationManager.shared.habitDoneCount(type: type)
+ if count > 0 {
+ AppBadge(text: "\(count)d", bg: AppColors.greenSoft, fg: AppColors.green)
+ }
+ }
+}
+
+/// One named, independently-toggleable daily time slot (e.g. "Morning") — used
+/// for both Prayer and Coffee's dense multi-slot lists. Reads/writes
+/// `UserDefaults.kisani` directly under `Enabled/Hour/Minute` so it
+/// stays in lockstep with what `NotificationManager` schedules from.
+private struct HabitSlotRow: View {
+ @Environment(\.colorScheme) private var cs
+ let keyPrefix: String
+ let label: String
+ let defaultHour: Int
+ let defaultMinute: Int
+ let defaultEnabled: Bool
+
+ @AppStorage private var enabled: Bool
+ @AppStorage private var hour: Int
+ @AppStorage private var minute: Int
+
+ init(keyPrefix: String, label: String, defaultHour: Int, defaultMinute: Int, defaultEnabled: Bool) {
+ self.keyPrefix = keyPrefix; self.label = label
+ self.defaultHour = defaultHour; self.defaultMinute = defaultMinute; self.defaultEnabled = defaultEnabled
+ _enabled = AppStorage(wrappedValue: defaultEnabled, "\(keyPrefix)Enabled", store: UserDefaults.kisani)
+ _hour = AppStorage(wrappedValue: defaultHour, "\(keyPrefix)Hour", store: UserDefaults.kisani)
+ _minute = AppStorage(wrappedValue: defaultMinute, "\(keyPrefix)Minute", store: UserDefaults.kisani)
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Divider().background(AppColors.border(cs)).padding(.leading, 14)
+ HStack(spacing: 11) {
+ Text(label).font(AppFonts.sans(12.5)).foregroundColor(AppColors.text2(cs))
+ Spacer()
+ if enabled {
+ DatePicker("", selection: Binding(
+ get: { Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: Date()) ?? Date() },
+ set: { v in
+ let c = Calendar.current.dateComponents([.hour, .minute], from: v)
+ hour = c.hour ?? hour; minute = c.minute ?? minute
+ }
+ ), displayedComponents: .hourAndMinute)
+ .labelsHidden().tint(AppColors.accent)
+ }
+ Toggle("", isOn: $enabled).labelsHidden().tint(AppColors.accent).scaleEffect(0.85)
+ }
+ .padding(.horizontal, 14).padding(.vertical, 9)
+ }
+ }
+}
+
+/// A plain "label + time picker" row (no toggle) — used where the toggle
+/// already lives on the parent row (e.g. bed-making's single time).
+private struct HabitTimeRow: View {
+ @Environment(\.colorScheme) private var cs
+ let label: String
+ @Binding var hour: Int
+ @Binding var minute: Int
+
+ var body: some View {
+ HStack {
+ Text(label).font(AppFonts.sans(12)).foregroundColor(AppColors.text2(cs))
+ Spacer()
+ DatePicker("", selection: Binding(
+ get: { Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: Date()) ?? Date() },
+ set: { v in
+ let c = Calendar.current.dateComponents([.hour, .minute], from: v)
+ hour = c.hour ?? hour; minute = c.minute ?? minute
+ }
+ ), displayedComponents: .hourAndMinute)
+ .labelsHidden().tint(AppColors.accent)
+ }
+ .padding(.horizontal, 14).padding(.vertical, 8)
+ }
+}
+
private struct StepperRow: View {
@Environment(\.colorScheme) private var cs
let label: String; @Binding var value: Int; let range: ClosedRange; var isLast: Bool = false
@@ -1053,6 +1340,7 @@ private struct ProfileStatsSheet: View {
@AppStorage("streakGoal") private var streakGoal = 5
@ObservedObject private var hk = HealthKitManager.shared
@ObservedObject private var auth = AuthManager.shared
+ @State private var showActivityHistory = false
// ── Task metrics ──
private var totalTasks: Int { taskVM.tasks.count }
@@ -1063,24 +1351,19 @@ private struct ProfileStatsSheet: View {
return Int(Double(doneTasks) / Double(totalTasks) * 100)
}
private var completedThisWeek: Int {
- let start = Calendar.current.date(byAdding: .day, value: -6, to: Calendar.current.startOfDay(for: Date()))!
- return taskVM.tasks.filter { $0.isComplete && ($0.completedAt ?? .distantPast) >= start }.count
+ // Sum of per-day counts — includes recurring occurrences.
+ last7.reduce(0) { $0 + $1.1 }
}
private var urgentOpen: Int {
taskVM.tasks.filter { !$0.isComplete && taskVM.displayQuadrant($0) == .urgent }.count
}
- // ── 7-day task activity ──
+ // ── 7-day task activity (recurring occurrences included) ──
private var last7: [(Date, Int)] {
let cal = Calendar.current
return (0..<7).reversed().map { ago -> (Date, Int) in
let day = cal.date(byAdding: .day, value: -ago, to: cal.startOfDay(for: Date()))!
- let next = cal.date(byAdding: .day, value: 1, to: day)!
- let n = taskVM.tasks.filter { t in
- guard t.isComplete, let at = t.completedAt else { return false }
- return at >= day && at < next
- }.count
- return (day, n)
+ return (day, taskVM.completionCount(on: day))
}
}
private var maxDay: Int { max(1, last7.map { $0.1 }.max() ?? 1) }
@@ -1121,9 +1404,19 @@ private struct ProfileStatsSheet: View {
// ── Tasks card ──
VStack(alignment: .leading, spacing: 12) {
- Text("TASKS").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
+ HStack {
+ Text("TASKS").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
+ Spacer()
+ Button { showActivityHistory = true } label: {
+ Text("History")
+ .font(AppFonts.mono(9, weight: .bold))
+ .foregroundColor(AppColors.text2(cs))
+ }
+ .buttonStyle(.plain)
+ }
HStack(spacing: 8) {
+ PStatCell(value: "\(taskVM.taskStreakDays)", label: "day streak", color: taskVM.taskStreakDays > 0 ? AppColors.green : AppColors.text3(cs))
PStatCell(value: "\(completedThisWeek)", label: "this week", color: AppColors.green)
PStatCell(value: "\(overdueCount)", label: "overdue", color: overdueCount > 0 ? AppColors.accent : AppColors.text3(cs))
PStatCell(value: "\(completionRate)%", label: "done rate", color: AppColors.blue)
@@ -1296,6 +1589,11 @@ private struct ProfileStatsSheet: View {
}
}
.background(AppColors.background(cs).ignoresSafeArea())
+ .sheet(isPresented: $showActivityHistory) {
+ ActivityHistoryView()
+ .environmentObject(taskVM)
+ .environmentObject(workoutVM)
+ }
}
private func dayLetter(_ date: Date) -> String {
diff --git a/KisaniCal/Views/TaskContextMenu.swift b/KisaniCal/Views/TaskContextMenu.swift
index ac47335..ca62f4f 100644
--- a/KisaniCal/Views/TaskContextMenu.swift
+++ b/KisaniCal/Views/TaskContextMenu.swift
@@ -40,6 +40,9 @@ struct TaskMenuItems: View {
var onLiveActivity:(TaskItem) -> Void = { LiveActivityManager.toggle(for: $0) }
var onEdit: ((TaskItem) -> Void)? = nil
var onDelete: (TaskItem) -> Void = { _ in }
+ /// Complete today's occurrence AND stop the series — keeps the task and its
+ /// full completion history (unlike Delete, which erases both).
+ var onCompleteAndStopSeries: (TaskItem) -> Void = { _ in }
private var cal: Calendar { .current }
@@ -132,6 +135,12 @@ struct TaskMenuItems: View {
Button { onEdit(task) } label: { Label("Edit", systemImage: "pencil") }
}
+ if task.isRecurring {
+ Button { onCompleteAndStopSeries(task) } label: {
+ Label("Complete & Stop Series", systemImage: "checkmark.circle.badge.xmark")
+ }
+ }
+
Divider()
Button(role: .destructive) { onDelete(task) } label: {
diff --git a/KisaniCal/Views/TodayView.swift b/KisaniCal/Views/TodayView.swift
index d158b76..1e53b9a 100644
--- a/KisaniCal/Views/TodayView.swift
+++ b/KisaniCal/Views/TodayView.swift
@@ -46,6 +46,7 @@ struct TodayView: View {
onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
+ onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
@@ -159,6 +160,7 @@ struct TodayView: View {
onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
+ onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
@@ -180,6 +182,7 @@ struct TodayView: View {
onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
+ onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
@@ -199,6 +202,7 @@ struct TodayView: View {
onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
+ onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
@@ -218,6 +222,7 @@ struct TodayView: View {
onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
+ onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
@@ -559,11 +564,25 @@ private struct TodayTLRow: View {
}
}
Spacer(minLength: 0)
- TagChip(
- text: entry.task.category.rawValue,
- color: entry.color,
- bg: entry.task.quadrant.softColor
- )
+ // TickTick-style — icon only, no text badge; date/time already
+ // shown in the leading time-track column for this row.
+ HStack(spacing: 5) {
+ if let glyph = entry.task.category.glyph {
+ Image(systemName: glyph)
+ .font(.system(size: 11))
+ .foregroundColor(entry.color)
+ }
+ if entry.task.reminderDate != nil || entry.task.constantReminder {
+ Image(systemName: "alarm")
+ .font(.system(size: 11))
+ .foregroundColor(AppColors.text3(cs))
+ }
+ if entry.task.isRecurring {
+ Image(systemName: "repeat")
+ .font(.system(size: 11))
+ .foregroundColor(AppColors.text3(cs))
+ }
+ }
Button(action: onToggle) {
ZStack {
RoundedRectangle(cornerRadius: 5, style: .continuous)
@@ -601,6 +620,7 @@ struct OverdueCard: View {
var onPin: (TaskItem) -> Void = { _ in }
var onEdit: (TaskItem) -> Void = { _ in }
var onDelete: (TaskItem) -> Void = { _ in }
+ var onCompleteAndStopSeries: (TaskItem) -> Void = { _ in }
var onSetDate: (TaskItem, Date?) -> Void = { _, _ in }
var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in }
var onMove: (TaskItem, Quadrant) -> Void = { _, _ in }
@@ -712,7 +732,8 @@ struct OverdueCard: View {
onResetMatrixUrgency: onResetMatrixUrgency,
onLiveActivity: { LiveActivityManager.toggle(for: $0) },
onEdit: onEdit,
- onDelete: onDelete
+ onDelete: onDelete,
+ onCompleteAndStopSeries: onCompleteAndStopSeries
)
}
}
@@ -1018,6 +1039,7 @@ struct UpcomingSection: View {
var onPin: (TaskItem) -> Void = { _ in }
var onEdit: (TaskItem) -> Void = { _ in }
let onDelete: (TaskItem) -> Void
+ var onCompleteAndStopSeries: (TaskItem) -> Void = { _ in }
var onSetDate: (TaskItem, Date?) -> Void = { _, _ in }
var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in }
var onMove: (TaskItem, Quadrant) -> Void = { _, _ in }
@@ -1091,7 +1113,8 @@ struct UpcomingSection: View {
onResetMatrixUrgency: onResetMatrixUrgency,
onLiveActivity: { LiveActivityManager.toggle(for: $0) },
onEdit: onEdit,
- onDelete: { task in withAnimation(KisaniSpring.snappy) { onDelete(task) } }
+ onDelete: { task in withAnimation(KisaniSpring.snappy) { onDelete(task) } },
+ onCompleteAndStopSeries: { task in withAnimation(KisaniSpring.snappy) { onCompleteAndStopSeries(task) } }
)
}
.padding(.horizontal, 18)
@@ -1316,7 +1339,10 @@ struct TaskRowView: View {
}
} label: {
RoundedRectangle(cornerRadius: 5, style: .continuous)
- .strokeBorder(task.quadrant.color, lineWidth: 1.5)
+ // Neutral stroke — the row already carries its quadrant color
+ // on the left accent bar and the category chip, so a tinted
+ // checkbox is redundant.
+ .strokeBorder(AppColors.text3(cs), lineWidth: 1.5)
.frame(width: 16, height: 16)
.frame(width: 36, height: 44)
.padding(.leading, 4)
@@ -1324,46 +1350,48 @@ struct TaskRowView: View {
}
.buttonStyle(PressButtonStyle(scale: 0.82))
- VStack(alignment: .leading, spacing: 2) {
+ VStack(alignment: .leading, spacing: 3) {
Text(task.title)
- .font(AppFonts.sans(13))
+ .font(AppFonts.sans(13, weight: .medium))
.foregroundColor(AppColors.text(cs))
if let d = task.dueDate, showDetails {
- HStack(spacing: 4) {
- Text("\(dueFmt.string(from: d))\(task.isRecurring ? " · \(task.recurrenceLabel ?? "Repeat")" : "")")
- .font(AppFonts.mono(9.5))
+ Text("\(dueFmt.string(from: d))\(task.isRecurring ? " · \(task.recurrenceDisplayLabel ?? "Repeat")" : "")")
+ .font(AppFonts.mono(9.5))
+ .tracking(0.2)
+ .foregroundColor(AppColors.text3(cs))
+ }
+ }
+
+ Spacer(minLength: 8)
+
+ // Right column mirrors the left: countdown metadata on top,
+ // icon-only category/reminder/series indicators below — fills
+ // what used to be dead space instead of a lone floating icon.
+ VStack(alignment: .trailing, spacing: 4) {
+ if let d = task.dueDate, showDetails {
+ Text(countdownLabel(to: d))
+ .font(AppFonts.mono(9.5, weight: .semibold))
+ .tracking(0.2)
+ .foregroundColor(countdownColor(to: d))
+ }
+ HStack(spacing: 5) {
+ if let glyph = task.category.glyph {
+ Image(systemName: glyph)
+ .font(.system(size: 10.5))
+ .foregroundColor(task.quadrant.color)
+ }
+ if task.reminderDate != nil || task.constantReminder {
+ Image(systemName: "alarm")
+ .font(.system(size: 10.5))
+ .foregroundColor(AppColors.text3(cs))
+ }
+ if task.isRecurring {
+ Image(systemName: "repeat")
+ .font(.system(size: 10.5))
.foregroundColor(AppColors.text3(cs))
- Text("·")
- .font(AppFonts.mono(9.5))
- .foregroundColor(AppColors.text3(cs).opacity(0.4))
- Text(countdownLabel(to: d))
- .font(AppFonts.mono(9.5, weight: .semibold))
- .foregroundColor(countdownColor(to: d))
}
}
}
-
- Spacer()
-
- // ⏰ reminder · 🔁 recurring indicators
- HStack(spacing: 5) {
- if task.reminderDate != nil || task.constantReminder {
- Image(systemName: "alarm")
- .font(.system(size: 11))
- .foregroundColor(AppColors.text3(cs))
- }
- if task.isRecurring {
- Image(systemName: "repeat")
- .font(.system(size: 11))
- .foregroundColor(AppColors.text3(cs))
- }
- }
-
- TagChip(
- text: task.category.rawValue,
- color: task.quadrant.color,
- bg: task.quadrant.softColor
- )
.padding(.trailing, 9)
}
.frame(minHeight: 48)
@@ -1487,6 +1515,7 @@ struct NLParsed {
var tokenRanges: [NSRange] = []
var isRecurring: Bool = false
var recurrenceLabel: String? = nil
+ var recurrenceInterval: Int? = nil
var recurrenceEnd: Date? = nil
var endDate: Date? = nil
var isAllDay: Bool = false
@@ -1536,9 +1565,10 @@ struct NLTaskParser {
}
// ── Recurrence ──────────────────────────────────────────────────────
- if let (r, label) = parseRecurrence(in: lower) {
+ if let (r, label, interval) = parseRecurrence(in: lower) {
result.isRecurring = true
result.recurrenceLabel = label
+ result.recurrenceInterval = interval > 1 ? interval : nil
rawRanges.append(r)
}
@@ -1663,7 +1693,11 @@ struct NLTaskParser {
// MARK: - Recurrence
- private static func parseRecurrence(in text: String) -> (NSRange, String)? {
+ private static func parseRecurrence(in text: String) -> (NSRange, String, Int)? {
+ // Interval phrases ("every 2 weeks", "biweekly") are checked first since
+ // they're more specific than the plain unit phrases below.
+ if let (r, label, n) = parseIntervalRecurrence(in: text) { return (r, label, n) }
+
// Order matters: more specific phrases ("every weekday") must precede the
// broader ones ("every week" / "every day") so they win.
let pats: [(String, String)] = [
@@ -1683,7 +1717,36 @@ struct NLTaskParser {
("\\bdaily\\b", "Daily"),
]
for (pat, label) in pats {
- if let r = match(pat, in: text) { return (r, label) }
+ if let r = match(pat, in: text) { return (r, label, 1) }
+ }
+ return nil
+ }
+
+ /// "every 2 weeks", "every three months", "biweekly", "fortnightly",
+ /// "bimonthly" — anything with an actual N > 1 attached. "every 1 week"
+ /// falls through to the plain patterns above rather than being handled here.
+ private static func parseIntervalRecurrence(in text: String) -> (NSRange, String, Int)? {
+ if let r = match("\\b(biweekly|fortnightly)\\b", in: text) { return (r, "Weekly", 2) }
+ if let r = match("\\bbimonthly\\b", in: text) { return (r, "Monthly", 2) }
+
+ let wordNums = ["a": 1, "an": 1, "one": 1, "two": 2, "three": 3, "four": 4,
+ "five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10]
+ let units: [(String, String)] = [
+ ("days?", "Daily"),
+ ("weeks?", "Weekly"),
+ ("months?", "Monthly"),
+ ("years?", "Yearly"),
+ ]
+ for (unitPat, label) in units {
+ let pat = "\\bevery\\s+(\\d+|a|an|one|two|three|four|five|six|seven|eight|nine|ten)\\s+\(unitPat)\\b"
+ guard let rx = try? NSRegularExpression(pattern: pat),
+ let m = rx.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)),
+ m.numberOfRanges >= 2,
+ let nr = Range(m.range(at: 1), in: text) else { continue }
+ let ns = String(text[nr])
+ let n = Int(ns) ?? wordNums[ns] ?? 1
+ guard n > 1 else { continue }
+ return (m.range, label, n)
}
return nil
}
@@ -1927,7 +1990,9 @@ struct AddTaskSheet: View {
HStack(spacing: 4) {
Image(systemName: "arrow.clockwise")
.font(.system(size: 11, weight: .semibold))
- Text(parsed.recurrenceLabel ?? "Recurring")
+ Text(parsed.recurrenceLabel.map {
+ RecurrenceRule.displayLabel(label: $0, interval: parsed.recurrenceInterval ?? 1)
+ } ?? "Recurring")
.font(AppFonts.sans(13, weight: .semibold))
}
.foregroundColor(AppColors.accent)
@@ -2043,7 +2108,8 @@ struct AddTaskSheet: View {
isAllDay: $parsed.isAllDay,
reminderDate: $reminderDate,
constantReminder: $constantReminder,
- recurrenceEnd: $parsed.recurrenceEnd
+ recurrenceEnd: $parsed.recurrenceEnd,
+ recurrenceInterval: $parsed.recurrenceInterval
)
}
}
@@ -2054,6 +2120,7 @@ struct AddTaskSheet: View {
taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime,
quadrant: prefilledQuadrant, category: selectedCategory,
isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel,
+ recurrenceInterval: parsed.recurrenceInterval,
endDate: parsed.endDate, isAllDay: parsed.isAllDay,
priority: selectedPriority,
reminderDate: reminderDate, constantReminder: constantReminder,
@@ -2071,6 +2138,7 @@ struct TaskDatePickerSheet: View {
@Binding var isRecurring: Bool
@Binding var recurrenceLabel: String?
@Binding var recurrenceEnd: Date?
+ @Binding var recurrenceInterval: Int?
@Binding var endDate: Date?
@Binding var isAllDay: Bool
@Binding var reminderDate: Date?
@@ -2092,6 +2160,7 @@ struct TaskDatePickerSheet: View {
@State private var customNumber: Int
@State private var customUnit: Int // 0 = minutes, 1 = hours, 2 = days
@State private var localRepeat: String
+ @State private var localRecurrenceInterval: Int
@State private var localRecurrenceEnd: Date?
@State private var showRecurrenceEndPicker = false
@State private var localIsAllDay: Bool
@@ -2102,12 +2171,14 @@ struct TaskDatePickerSheet: View {
endDate: Binding, isAllDay: Binding,
reminderDate: Binding = .constant(nil),
constantReminder: Binding = .constant(false),
- recurrenceEnd: Binding = .constant(nil)) {
+ recurrenceEnd: Binding = .constant(nil),
+ recurrenceInterval: Binding = .constant(nil)) {
_selectedDate = selectedDate
_hasTime = hasTime
_isRecurring = isRecurring
_recurrenceLabel = recurrenceLabel
_recurrenceEnd = recurrenceEnd
+ _recurrenceInterval = recurrenceInterval
_endDate = endDate
_isAllDay = isAllDay
_reminderDate = reminderDate
@@ -2118,6 +2189,7 @@ struct TaskDatePickerSheet: View {
_localEndDate = State(initialValue: endDate.wrappedValue ?? start)
_localTime = State(initialValue: hasTime.wrappedValue ? selectedDate.wrappedValue : nil)
_localRepeat = State(initialValue: recurrenceLabel.wrappedValue ?? "None")
+ _localRecurrenceInterval = State(initialValue: recurrenceInterval.wrappedValue ?? 1)
_localRecurrenceEnd = State(initialValue: recurrenceEnd.wrappedValue)
_localIsAllDay = State(initialValue: isAllDay.wrappedValue)
_pickerMode = State(initialValue: endDate.wrappedValue != nil ? 1 : 0)
@@ -2264,6 +2336,13 @@ struct TaskDatePickerSheet: View {
}
.buttonStyle(.plain)
+ // ── Every N (only for units where an interval means anything —
+ // "Every Weekday" is always Mon–Fri, no interval concept) ──
+ if localRepeat != "None" && localRepeat != "Every Weekday" {
+ Divider().padding(.leading, 54)
+ intervalRow
+ }
+
// ── Repeat until (only when a recurrence is set) ──
if localRepeat != "None" {
Divider().padding(.leading, 54)
@@ -2627,6 +2706,37 @@ struct TaskDatePickerSheet: View {
.padding(.horizontal, 16).frame(height: 52)
}
+ private var intervalUnitLabel: String {
+ let plural = localRecurrenceInterval != 1
+ switch localRepeat {
+ case "Daily": return plural ? "Days" : "Day"
+ case "Weekly": return plural ? "Weeks" : "Week"
+ case "Monthly": return plural ? "Months" : "Month"
+ case "Yearly": return plural ? "Years" : "Year"
+ default: return ""
+ }
+ }
+
+ @ViewBuilder
+ private var intervalRow: some View {
+ HStack(spacing: 14) {
+ Image(systemName: "number")
+ .font(.system(size: 18))
+ .foregroundColor(localRecurrenceInterval > 1 ? AppColors.accent : .secondary)
+ .frame(width: 24)
+ Text("Every")
+ .font(AppFonts.sans(16)).foregroundColor(.primary)
+ Spacer()
+ Stepper(value: $localRecurrenceInterval, in: 1...30) {
+ Text("\(localRecurrenceInterval) \(intervalUnitLabel)")
+ .font(AppFonts.sans(15))
+ .foregroundColor(localRecurrenceInterval > 1 ? AppColors.accent : .secondary)
+ }
+ .fixedSize()
+ }
+ .padding(.horizontal, 16).frame(height: 52)
+ }
+
private struct RepeatOption { let key: String; let display: String }
private var repeatOptions: [RepeatOption] {
@@ -2654,7 +2764,12 @@ struct TaskDatePickerSheet: View {
}
private var repeatDisplayValue: String {
- repeatOptions.first(where: { $0.key == localRepeat })?.display ?? localRepeat
+ // At interval 1 keep the richer "Weekly (Tuesday)" style text; once an
+ // interval is set, "Every 2 Weeks" is clearer than trying to combine both.
+ if localRecurrenceInterval > 1 {
+ return RecurrenceRule.displayLabel(label: localRepeat, interval: localRecurrenceInterval)
+ }
+ return repeatOptions.first(where: { $0.key == localRepeat })?.display ?? localRepeat
}
private var timeFmt: DateFormatter {
@@ -2698,6 +2813,11 @@ struct TaskDatePickerSheet: View {
isRecurring = localRepeat != "None"
recurrenceLabel = localRepeat != "None" ? localRepeat : nil
recurrenceEnd = localRepeat != "None" ? localRecurrenceEnd : nil
+ // Store nil (not 1) at the default interval — keeps "no interval set"
+ // and "explicitly every 1" indistinguishable, matching every other
+ // task's data shape and RecurrenceRule's own nil-means-1 default.
+ recurrenceInterval = (localRepeat != "None" && localRepeat != "Every Weekday"
+ && localRecurrenceInterval > 1) ? localRecurrenceInterval : nil
// Reminder is a lead-time offset from the task date at the chosen time-of-day.
if let off = localReminderOffset {
reminderDate = computedReminderDate(offset: off)
diff --git a/KisaniCal/Views/WorkoutView.swift b/KisaniCal/Views/WorkoutView.swift
index 6b36c87..079da26 100644
--- a/KisaniCal/Views/WorkoutView.swift
+++ b/KisaniCal/Views/WorkoutView.swift
@@ -64,8 +64,10 @@ struct WorkoutView: View {
@State private var showAddSection = false
@State private var showHistory = false
@State private var showStats = false
+ @State private var showAnalytics = false
@State private var isReordering = false
@State private var trainAnyway = false // override the rest-day state for today
+ @State private var showClearAllConfirm = false
@ObservedObject private var tm = TutorialManager.shared
private let restTick = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
@@ -103,13 +105,11 @@ struct WorkoutView: View {
.foregroundColor(AppColors.text3(cs))
}
Spacer()
+ IButton(icon: "chart.line.uptrend.xyaxis") { showAnalytics = true }
IButton(icon: "chart.bar.xaxis") { showStats = true }
IButton(icon: "clock.arrow.circlepath") { showHistory = true }
Menu {
- Button { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(true) } } label: {
- Label("Mark All Complete", systemImage: "checkmark.circle")
- }
- Button { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(false) } } label: {
+ Button(role: .destructive) { showClearAllConfirm = true } label: {
Label("Clear All Sets", systemImage: "circle")
}
Divider()
@@ -138,15 +138,25 @@ struct WorkoutView: View {
.moveDisabled(true)
} else {
- // ── Dot Grid Progress ──
- DotProgressCard(progress: vm.progress, done: vm.doneSets, total: vm.totalSets)
+ // ── Dot Grid Progress + Completion ──
+ DotProgressCard(
+ progress: vm.progress, done: vm.doneSets, total: vm.totalSets,
+ isDoneToday: vm.isWorkoutComplete(on: Date()),
+ isMissedToday: vm.isWorkoutMissed(on: Date()),
+ onFinish: { withAnimation(KisaniSpring.snappy) { vm.markWorkoutDone(on: Date()) } },
+ onMarkAllSets: { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(true) } },
+ onClearAllSets: { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(false) } },
+ onNotCompleted: { withAnimation(KisaniSpring.snappy) { vm.markWorkoutMissed(on: Date()) } },
+ isManuallyLogged: vm.isManuallyLogged(on: Date()),
+ onUndo: { withAnimation(KisaniSpring.snappy) { vm.unmarkWorkoutDone(on: Date()) } }
+ )
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 14, bottom: 6, trailing: 14))
.moveDisabled(true)
// ── Streak ──
- StreakBannerView(streak: vm.streakDays)
+ StreakBannerView(vm: vm)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 14, bottom: 10, trailing: 14))
@@ -324,11 +334,28 @@ struct WorkoutView: View {
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
+ .sheet(isPresented: $showAnalytics) {
+ AnalyticsView()
+ .presentationDetents([.large])
+ .presentationDragIndicator(.visible)
+ }
.sheet(isPresented: $showStats) {
WorkoutStatsView().environmentObject(vm)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
+ .confirmationDialog(
+ "Clear all logged sets?",
+ isPresented: $showClearAllConfirm,
+ titleVisibility: .visible
+ ) {
+ Button("Clear All", role: .destructive) {
+ withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(false) }
+ }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ Text("This un-checks every set in today's workout. It won't undo an already-finished workout.")
+ }
.sheet(isPresented: $showAddSection) {
AddSectionSheet().environmentObject(vm)
.presentationDetents([.height(240)])
@@ -564,34 +591,223 @@ struct AddSectionSheet: View {
struct DotProgressCard: View {
@Environment(\.colorScheme) private var cs
let progress: Double; let done: Int; let total: Int
+ let isDoneToday: Bool
+ let isMissedToday: Bool
+ /// Capture today as done regardless of how many sets are checked — the app's
+ /// own set-ticking is a nice-to-have log, not the source of truth for whether
+ /// a workout happened (Health/Watch already knows that independently).
+ let onFinish: () -> Void
+ let onMarkAllSets: () -> Void
+ let onClearAllSets: () -> Void
+ let onNotCompleted: () -> Void
+ /// Whether today's "done" mark came from this app's own Finish button rather
+ /// than Watch/Health — only manually-logged days offer an Undo (see body).
+ let isManuallyLogged: Bool
+ let onUndo: () -> Void
+
+ /// Every quick action that changes today's state is confirmed first — a
+ /// stray tap here shouldn't silently rewrite the day.
+ private enum PendingAction: Identifiable {
+ case finish, checkAll, clearAll, notCompleted, undo
+ var id: Self { self }
+
+ var confirmLabel: String {
+ switch self {
+ case .finish: return "Finish Anyway"
+ case .checkAll: return "Check All"
+ case .clearAll: return "Clear All"
+ case .notCompleted: return "Mark Not Completed"
+ case .undo: return "Undo"
+ }
+ }
+ var isDestructive: Bool { self == .clearAll || self == .notCompleted || self == .undo }
+ }
+ @State private var pendingAction: PendingAction?
var body: some View {
- VStack(spacing: 10) {
+ VStack(spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 2) {
- Text("\(done) of \(total) sets done")
+ Text(statusTitle)
.font(AppFonts.sans(13, weight: .semibold))
.foregroundColor(AppColors.text(cs))
- Text(total == 0 ? "Add exercises to get started"
- : done == total && total > 0 ? "All sets done"
- : "\(total - done) sets remaining")
+ Text(statusSubtitle)
.font(AppFonts.mono(9))
.foregroundColor(AppColors.text3(cs))
}
Spacer()
- Text(total > 0 ? "\(Int(progress * 100))%" : "0%")
- .font(AppFonts.mono(13, weight: .bold))
- .foregroundColor(progress >= 1 ? AppColors.green : AppColors.text2(cs))
+ statusBadge
}
+
+ // Progress dots stay visible regardless of state — finishing the
+ // day shouldn't replace the one view that shows how it went.
VStack(alignment: .leading, spacing: 6) {
Text("PROGRESS")
.font(AppFonts.mono(8, weight: .bold))
.foregroundColor(AppColors.text3(cs))
DotGridProgress(progress: total > 0 ? progress : 0, cs: cs)
}
+
+ // Watch/Health is the source of truth once it has logged a day —
+ // no in-card way to undo that. A manually-logged day (this app's
+ // own Finish button) is the one case Undo is still offered for,
+ // confirmed first like every other state change here.
+ if !(isDoneToday || isMissedToday) {
+ actionRow
+ } else if isDoneToday && isManuallyLogged {
+ manualUndoRow
+ }
}
.padding(14)
- .cardStyle()
+ .cardStyle(borderColor: isDoneToday ? AppColors.green.opacity(0.3) : nil)
+ .confirmationDialog(
+ confirmTitle,
+ isPresented: Binding(get: { pendingAction != nil }, set: { if !$0 { pendingAction = nil } }),
+ titleVisibility: .visible,
+ presenting: pendingAction
+ ) { action in
+ Button(action.confirmLabel, role: action.isDestructive ? .destructive : nil) {
+ perform(action)
+ }
+ Button("Cancel", role: .cancel) {}
+ } message: { action in
+ Text(confirmMessage(action))
+ }
+ }
+
+ private var confirmTitle: String {
+ switch pendingAction {
+ case .finish: return "Finish with \(done) of \(total) sets logged?"
+ case .checkAll: return "Check every set as done?"
+ case .clearAll: return "Clear all logged sets?"
+ case .notCompleted: return "Mark today as not completed?"
+ case .undo: return "Undo today's logged workout?"
+ case nil: return ""
+ }
+ }
+
+ private func confirmMessage(_ action: PendingAction) -> String {
+ switch action {
+ case .finish:
+ return total > 0
+ ? "You've logged \(Int(progress * 100))% of sets. This still counts as today's workout."
+ : "No sets logged yet. This still counts as today's workout."
+ case .checkAll:
+ return "This ticks all \(total) sets as done — useful if you trained but didn't log along the way."
+ case .clearAll:
+ return "This un-checks all \(total) sets. It won't undo today's logged workout."
+ case .notCompleted:
+ return isDoneToday ? "This replaces today's logged workout with \"not completed.\"" : "You can undo this afterward."
+ case .undo:
+ return "This removes today's manually logged workout and goes back to pending. It won't affect anything recorded by Health or your Watch."
+ }
+ }
+
+ private func perform(_ action: PendingAction) {
+ switch action {
+ case .finish: onFinish()
+ case .checkAll: onMarkAllSets()
+ case .clearAll: onClearAllSets()
+ case .notCompleted: onNotCompleted()
+ case .undo: onUndo()
+ }
+ }
+
+ private var statusTitle: String {
+ if isDoneToday { return "Workout logged" }
+ if isMissedToday { return "Marked as not completed" }
+ return "\(done) of \(total) sets done"
+ }
+
+ private var statusSubtitle: String {
+ if isDoneToday { return "Counted toward today · \(done)/\(total) sets logged" }
+ if isMissedToday { return "Doesn't count toward today" }
+ if total == 0 { return "Add exercises to get started" }
+ if done == total { return "All sets done" }
+ return "\(total - done) sets remaining"
+ }
+
+ @ViewBuilder private var statusBadge: some View {
+ if isDoneToday {
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 18)).foregroundColor(AppColors.green)
+ } else if isMissedToday {
+ Image(systemName: "moon.zzz.fill")
+ .font(.system(size: 16)).foregroundColor(AppColors.text3(cs))
+ } else {
+ Text(total > 0 ? "\(Int(progress * 100))%" : "0%")
+ .font(AppFonts.mono(13, weight: .bold))
+ .foregroundColor(progress >= 1 ? AppColors.green : AppColors.text2(cs))
+ }
+ }
+
+ /// Primary: finish the workout as-is (honest capture, not gated on 100% sets).
+ /// Secondary: the mechanical "tick every box" action (toggles to a clear once
+ /// all are checked), and the explicit miss. Every action here that changes
+ /// today's state is confirmed first — see `pendingAction`.
+ private var actionRow: some View {
+ VStack(spacing: 8) {
+ Button {
+ pendingAction = .finish
+ } label: {
+ Label(done == total && total > 0 ? "Finish Workout" : "Finish Anyway",
+ systemImage: "checkmark.circle.fill")
+ .font(AppFonts.sans(13, weight: .semibold))
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 11)
+ }
+ .buttonStyle(QuietAccentButtonStyle())
+
+ HStack(spacing: 8) {
+ if done == total && total > 0 {
+ Button { pendingAction = .clearAll } label: {
+ Label("Clear all sets", systemImage: "circle")
+ .font(AppFonts.sans(11, weight: .medium))
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 8)
+ }
+ .foregroundColor(AppColors.text2(cs))
+ .background(AppColors.surface2(cs))
+ .clipShape(RoundedRectangle(cornerRadius: 9))
+ .buttonStyle(PressButtonStyle(scale: 0.97))
+ } else {
+ Button { pendingAction = .checkAll } label: {
+ Label("Check all sets", systemImage: "checkmark.circle")
+ .font(AppFonts.sans(11, weight: .medium))
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 8)
+ }
+ .foregroundColor(AppColors.text2(cs))
+ .background(AppColors.surface2(cs))
+ .clipShape(RoundedRectangle(cornerRadius: 9))
+ .buttonStyle(PressButtonStyle(scale: 0.97))
+ }
+
+ Button { pendingAction = .notCompleted } label: {
+ Label("Didn't train", systemImage: "moon.zzz")
+ .font(AppFonts.sans(11, weight: .medium))
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 8)
+ }
+ .foregroundColor(AppColors.text3(cs))
+ .background(AppColors.surface2(cs))
+ .clipShape(RoundedRectangle(cornerRadius: 9))
+ .buttonStyle(PressButtonStyle(scale: 0.97))
+ }
+ }
+ }
+
+ /// Only shown for a manually-logged day — tapping this always confirms first
+ /// (see `PendingAction.undo`), so a stray tap can't silently un-log a workout.
+ private var manualUndoRow: some View {
+ HStack {
+ Spacer()
+ Button { pendingAction = .undo } label: {
+ Label("Undo", systemImage: "arrow.uturn.backward")
+ .font(AppFonts.sans(11, weight: .semibold))
+ }
+ .foregroundColor(AppColors.accent)
+ }
}
}
@@ -908,28 +1124,28 @@ struct RestDayCard: View {
}
// MARK: - Streak Banner
+// Swipeable: this week → this year → this-week-vs-last-week.
struct StreakBannerView: View {
@Environment(\.colorScheme) private var cs
- let streak: Int
+ @ObservedObject var vm: WorkoutViewModel
+ @State private var page = 0
var body: some View {
- HStack(spacing: 0) {
- VStack(alignment: .leading, spacing: 2) {
- Text("\(streak)")
- .font(AppFonts.mono(28, weight: .bold))
- .foregroundColor(AppColors.text(cs))
- Text("day streak")
- .font(AppFonts.sans(11))
- .foregroundColor(AppColors.text3(cs))
+ VStack(spacing: 8) {
+ TabView(selection: $page) {
+ weekPage.tag(0)
+ yearPage.tag(1)
+ comparePage.tag(2)
}
- Spacer()
- // 7-day tick bar — current week progress
- HStack(spacing: 3) {
- ForEach(0..<7, id: \.self) { i in
- let filled = streak > 0 && i < (streak % 7 == 0 ? 7 : streak % 7)
- RoundedRectangle(cornerRadius: 1.5)
- .fill(filled ? AppColors.accent : AppColors.borderHi(cs))
- .frame(width: 18, height: 3)
+ .tabViewStyle(.page(indexDisplayMode: .never))
+ .frame(height: 50)
+
+ // Page dots
+ HStack(spacing: 5) {
+ ForEach(0..<3, id: \.self) { i in
+ Circle()
+ .fill(i == page ? AppColors.accent : AppColors.borderHi(cs))
+ .frame(width: 5, height: 5)
}
}
}
@@ -937,6 +1153,76 @@ struct StreakBannerView: View {
.padding(.vertical, 13)
.cardStyle()
}
+
+ // Page 1 — this week (per-day ticks)
+ private var weekPage: some View {
+ HStack(spacing: 0) {
+ statBlock(value: "\(vm.workoutsThisWeek)", label: "this week")
+ Spacer()
+ HStack(spacing: 3) {
+ ForEach(Array(vm.currentWeekDays.enumerated()), id: \.offset) { _, done in
+ RoundedRectangle(cornerRadius: 1.5)
+ .fill(done ? AppColors.accent : AppColors.borderHi(cs))
+ .frame(width: 18, height: 3)
+ }
+ }
+ }
+ }
+
+ // Page 2 — this year (month ticks) + all-time total
+ private var yearPage: some View {
+ HStack(spacing: 0) {
+ statBlock(value: "\(vm.workoutsThisYear)", label: "this year")
+ Spacer()
+ VStack(alignment: .trailing, spacing: 5) {
+ HStack(spacing: 2) {
+ ForEach(Array(vm.currentYearMonths.enumerated()), id: \.offset) { _, active in
+ RoundedRectangle(cornerRadius: 1)
+ .fill(active ? AppColors.accent : AppColors.borderHi(cs))
+ .frame(width: 9, height: 3)
+ }
+ }
+ Text("\(vm.streakDays) all-time")
+ .font(AppFonts.mono(9))
+ .foregroundColor(AppColors.text3(cs))
+ }
+ }
+ }
+
+ // Page 3 — this week vs last week
+ private var comparePage: some View {
+ let delta = vm.workoutsThisWeek - vm.workoutsLastWeek
+ let deltaColor = delta > 0 ? AppColors.green : (delta < 0 ? AppColors.accent : AppColors.text3(cs))
+ return HStack(spacing: 0) {
+ statBlock(value: "\(vm.workoutsThisWeek)", label: "this week")
+ Spacer()
+ VStack(alignment: .trailing, spacing: 3) {
+ HStack(spacing: 4) {
+ Image(systemName: delta > 0 ? "arrow.up.right" : (delta < 0 ? "arrow.down.right" : "equal"))
+ .font(.system(size: 10, weight: .bold))
+ Text(delta == 0
+ ? "same as last week"
+ : "\(abs(delta)) \(delta > 0 ? "more" : "fewer") than last week")
+ .font(AppFonts.mono(10, weight: .semibold))
+ }
+ .foregroundColor(deltaColor)
+ Text("last week: \(vm.workoutsLastWeek)")
+ .font(AppFonts.mono(9))
+ .foregroundColor(AppColors.text3(cs))
+ }
+ }
+ }
+
+ private func statBlock(value: String, label: String) -> some View {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(value)
+ .font(AppFonts.mono(28, weight: .bold))
+ .foregroundColor(AppColors.text(cs))
+ Text(label)
+ .font(AppFonts.sans(11))
+ .foregroundColor(AppColors.text3(cs))
+ }
+ }
}
// MARK: - Exercise Card
diff --git a/KisaniCalTests/AnalyticsAdapterTests.swift b/KisaniCalTests/AnalyticsAdapterTests.swift
new file mode 100644
index 0000000..167dc2c
--- /dev/null
+++ b/KisaniCalTests/AnalyticsAdapterTests.swift
@@ -0,0 +1,61 @@
+import XCTest
+@testable import KisaniCal
+
+/// Adapter tests: exercise reduction (identity + volume), inclusive day-key
+/// ranges, and app-data → DaySample mapping (rest/missing/scheduled handling).
+final class AnalyticsAdapterTests: XCTestCase {
+
+ private func cal() -> Calendar {
+ var c = Calendar(identifier: .gregorian)
+ c.timeZone = TimeZone(identifier: "UTC")!
+ c.locale = Locale(identifier: "en_US_POSIX")
+ return c
+ }
+
+ func testExerciseSampleReduction() {
+ let es = WorkoutAnalyticsAdapter.exerciseSample(name: " Bench Press ",
+ completedSets: [(60, 10), (60, 8), (70, 6)])
+ XCTAssertEqual(es.identity, "bench press")
+ XCTAssertEqual(es.sets, 3)
+ XCTAssertEqual(es.reps, 24)
+ XCTAssertEqual(es.volume, 1500) // 600 + 480 + 420
+ }
+
+ func testInclusiveDayKeys() {
+ let c = cal()
+ let start = Date(timeIntervalSince1970: 1_783_500_000) // 2026-07-08
+ let keys = WorkoutAnalyticsAdapter.dayKeys(from: start, to: c.date(byAdding: .day, value: 6, to: start)!, calendar: c)
+ XCTAssertEqual(keys.count, 7)
+ XCTAssertEqual(keys, keys.sorted())
+ }
+
+ func testDaySampleMapping() {
+ let c = cal()
+ let start = Date(timeIntervalSince1970: 1_783_500_000)
+ let keys = WorkoutAnalyticsAdapter.dayKeys(from: start, to: c.date(byAdding: .day, value: 6, to: start)!, calendar: c)
+ let es = WorkoutAnalyticsAdapter.exerciseSample(name: "Squat", completedSets: [(100, 5), (100, 5)])
+ let raw = RawWorkoutDay(dateKey: keys[0], didWorkout: true, sessions: 1, durationMinutes: 45, exercises: [es])
+ let samples = WorkoutAnalyticsAdapter.daySamples(
+ dayKeys: keys,
+ workouts: [keys[0]: raw],
+ scheduledKeys: [keys[0], keys[2]],
+ health: [keys[0]: RawHealthDay(steps: 8000, activeCalories: 500, restingHeartRate: 55)])
+
+ XCTAssertEqual(samples.count, 7)
+ XCTAssertTrue(samples[0].didWorkout)
+ XCTAssertEqual(samples[0].volume, 1000)
+ XCTAssertTrue(samples[0].scheduled)
+ XCTAssertEqual(samples[0].steps, 8000)
+ XCTAssertFalse(samples[1].didWorkout) // rest / missing
+ XCTAssertEqual(samples[1].volume, 0)
+ XCTAssertNil(samples[1].steps) // honest: no fabricated data
+ XCTAssertTrue(samples[2].scheduled && !samples[2].didWorkout) // scheduled but missed
+ }
+
+ func testWeekAndMonthDayKeys() {
+ let c = cal()
+ let d = Date(timeIntervalSince1970: 1_783_500_000)
+ XCTAssertEqual(WorkoutAnalyticsAdapter.weekDayKeys(containing: d, calendar: c).count, 7)
+ XCTAssertEqual(WorkoutAnalyticsAdapter.monthDayKeys(containing: d, calendar: c).count, 31) // July
+ }
+}
diff --git a/KisaniCalTests/AnalyticsCoordinatorTests.swift b/KisaniCalTests/AnalyticsCoordinatorTests.swift
new file mode 100644
index 0000000..e5b6afe
--- /dev/null
+++ b/KisaniCalTests/AnalyticsCoordinatorTests.swift
@@ -0,0 +1,53 @@
+import XCTest
+@testable import KisaniCal
+
+/// Deep-link routing + reconcile idempotency (no duplicate reports across runs).
+final class AnalyticsCoordinatorTests: XCTestCase {
+
+ private func cal() -> Calendar {
+ var c = Calendar(identifier: .gregorian)
+ c.timeZone = TimeZone(identifier: "UTC")!
+ c.locale = Locale(identifier: "en_US_POSIX")
+ return c
+ }
+ private let now = Date(timeIntervalSince1970: 1_783_500_000) // 2026-07-08
+
+ // MARK: - Deep links
+
+ func testDeepLinkRoundTrips() {
+ XCTAssertEqual(AnalyticsDeepLink(url: AnalyticsDeepLink.overview.url), .overview)
+ XCTAssertEqual(AnalyticsDeepLink(url: AnalyticsDeepLink.weekly(weekStartKey: "2026-06-29").url),
+ .weekly(weekStartKey: "2026-06-29"))
+ XCTAssertEqual(AnalyticsDeepLink(string: "wenza://analytics/monthly/2026-05"),
+ .monthly(monthKey: "2026-05"))
+ XCTAssertEqual(AnalyticsDeepLink.weekly(weekStartKey: "2026-06-29").url.absoluteString,
+ "wenza://analytics/weekly/2026-06-29")
+ XCTAssertNil(AnalyticsDeepLink(string: "http://x/weekly/2026-06-29"))
+ }
+
+ // MARK: - Reconcile idempotency
+
+ func testReconcileGeneratesThenIsIdempotent() {
+ let tmp = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("coord_\(UUID().uuidString)")
+ defer { try? FileManager.default.removeItem(at: tmp) }
+ let store = AnalyticsStore(root: tmp)
+ func sample(_ v: Double) -> [DaySample] {
+ [DaySample(dateKey: "x", didWorkout: true, scheduled: true, sessions: 1, sets: 10, volume: v)]
+ }
+ let coord = AnalyticsCoordinator(
+ store: store, calendar: cal(), retentionMonths: 12, now: { self.now },
+ weekSamples: { _ in (sample(1000), sample(800)) },
+ monthSamples: { _ in (sample(4000), sample(3500)) })
+
+ let first = coord.reconcile()
+ XCTAssertGreaterThanOrEqual(first.weekliesGenerated.count, 50)
+ XCTAssertEqual(first.monthliesGenerated.count, 12)
+
+ let second = coord.reconcile()
+ XCTAssertTrue(second.isEmpty, "second run must generate nothing (no duplicate reports)")
+
+ XCTAssertEqual(store.allWeeklyReports().count, first.weekliesGenerated.count)
+ XCTAssertEqual(store.allMonthlyReports().count, 12)
+ XCTAssertEqual(store.allWeeklyReports().first?.progression, .improving) // 1000 vs 800
+ }
+}
diff --git a/KisaniCalTests/AnalyticsEngineTests.swift b/KisaniCalTests/AnalyticsEngineTests.swift
new file mode 100644
index 0000000..2c04f47
--- /dev/null
+++ b/KisaniCalTests/AnalyticsEngineTests.swift
@@ -0,0 +1,282 @@
+import XCTest
+@testable import KisaniCal
+
+/// Deterministic tests for the pure analytics engine — formulas, honest-degradation,
+/// trend classification, and calendar/timezone/DST/firstWeekday boundary behavior.
+final class AnalyticsEngineTests: XCTestCase {
+
+ // MARK: - Delta / percentage change
+
+ func testDeltaNormal() {
+ let d = AnalyticsEngine.delta(current: 120, baseline: 100)
+ XCTAssertEqual(d.absolute, 20)
+ XCTAssertEqual(d.percent!, 20, accuracy: 0.0001)
+ XCTAssertEqual(d.direction, .up)
+ XCTAssertTrue(d.meaningful)
+ }
+
+ func testDeltaDecrease() {
+ let d = AnalyticsEngine.delta(current: 80, baseline: 100)
+ XCTAssertEqual(d.percent!, -20, accuracy: 0.0001)
+ XCTAssertEqual(d.direction, .down)
+ }
+
+ func testDeltaNoBaselineIsNotMeaningful() {
+ let d = AnalyticsEngine.delta(current: 50, baseline: nil)
+ XCTAssertNil(d.percent)
+ XCTAssertNil(d.absolute)
+ XCTAssertFalse(d.meaningful)
+ }
+
+ func testDeltaZeroBaselineNoPercent() {
+ // Divide-by-zero guard: zero baseline yields an absolute but no percent.
+ let d = AnalyticsEngine.delta(current: 30, baseline: 0)
+ XCTAssertEqual(d.absolute, 30)
+ XCTAssertNil(d.percent)
+ XCTAssertFalse(d.meaningful)
+ }
+
+ func testDeltaTinyBaselineNoPercent() {
+ let d = AnalyticsEngine.delta(current: 10, baseline: 0.00001)
+ XCTAssertNil(d.percent, "percentage against a negligible baseline is suppressed")
+ }
+
+ func testDeltaNewExercise() {
+ let d = AnalyticsEngine.delta(current: 500, baseline: 400, isNew: true)
+ XCTAssertNil(d.percent)
+ XCTAssertFalse(d.meaningful)
+ }
+
+ func testDeltaRestDayBaseline() {
+ let d = AnalyticsEngine.delta(current: 500, baseline: 400, restDay: true)
+ XCTAssertFalse(d.meaningful)
+ }
+
+ // MARK: - Consistency
+
+ func testConsistencyNormal() {
+ XCTAssertEqual(AnalyticsEngine.consistency(sessions: 4, scheduled: 5), 0.8, accuracy: 0.0001)
+ }
+
+ func testConsistencyZeroScheduled() {
+ XCTAssertEqual(AnalyticsEngine.consistency(sessions: 3, scheduled: 0), 0)
+ }
+
+ func testConsistencyCappedAtOne() {
+ XCTAssertEqual(AnalyticsEngine.consistency(sessions: 6, scheduled: 5), 1.0)
+ }
+
+ // MARK: - Volume
+
+ func testVolume() {
+ XCTAssertEqual(AnalyticsEngine.volume(weight: 60, reps: 10), 600)
+ }
+
+ // MARK: - Trend classification (multi-point)
+
+ func testTrendImproving() {
+ XCTAssertEqual(AnalyticsEngine.classifyTrend([100, 110, 120, 130]).classification, .improving)
+ }
+
+ func testTrendDeclining() {
+ XCTAssertEqual(AnalyticsEngine.classifyTrend([130, 120, 110, 100]).classification, .declining)
+ }
+
+ func testTrendPlateau() {
+ XCTAssertEqual(AnalyticsEngine.classifyTrend([100, 101, 99, 100]).classification, .plateau)
+ }
+
+ func testTrendInsufficientData() {
+ XCTAssertEqual(AnalyticsEngine.classifyTrend([100, 110]).classification, .insufficientData)
+ }
+
+ func testTrendAllZeroIsPlateau() {
+ XCTAssertEqual(AnalyticsEngine.classifyTrend([0, 0, 0, 0]).classification, .plateau)
+ }
+
+ // MARK: - classifyChange (week/month over period)
+
+ func testClassifyChangeImproving() {
+ let d = AnalyticsEngine.delta(current: 120, baseline: 100)
+ XCTAssertEqual(AnalyticsEngine.classifyChange(d), .improving)
+ }
+
+ func testClassifyChangePlateau() {
+ let d = AnalyticsEngine.delta(current: 101, baseline: 100)
+ XCTAssertEqual(AnalyticsEngine.classifyChange(d), .plateau)
+ }
+
+ func testClassifyChangeInsufficient() {
+ let d = AnalyticsEngine.delta(current: 100, baseline: nil)
+ XCTAssertEqual(AnalyticsEngine.classifyChange(d), .insufficientData)
+ }
+
+ // MARK: - Outliers
+
+ func testWinsorizeClampsExtremes() {
+ let w = AnalyticsEngine.winsorize([1, 2, 3, 4, 5, 6, 7, 8, 9, 1000])
+ XCTAssertLessThan(w.max()!, 1000, "extreme high value should be clamped")
+ }
+
+ func testWinsorizeSmallSampleNoop() {
+ let input = [1.0, 999.0]
+ XCTAssertEqual(AnalyticsEngine.winsorize(input), input)
+ }
+
+ // MARK: - Exercise identity & comparison
+
+ func testExerciseIdentityNormalizes() {
+ XCTAssertEqual(AnalyticsEngine.exerciseIdentity(" Bench Press "),
+ AnalyticsEngine.exerciseIdentity("bench press"))
+ }
+
+ func testCompareExercisesMarksNew() {
+ let cur = [ExerciseSample(identity: "squat", sets: 3, reps: 15, volume: 900),
+ ExerciseSample(identity: "curl", sets: 3, reps: 30, volume: 300)]
+ let base = [ExerciseSample(identity: "squat", sets: 3, reps: 12, volume: 720)]
+ let comps = AnalyticsEngine.compareExercises(current: cur, baseline: base)
+ let squat = comps.first { $0.identity == "squat" }!
+ let curl = comps.first { $0.identity == "curl" }!
+ XCTAssertFalse(squat.isNew)
+ XCTAssertEqual(squat.volume.percent!, 25, accuracy: 0.0001) // 900 vs 720
+ XCTAssertTrue(curl.isNew)
+ XCTAssertNil(curl.volume.percent)
+ }
+
+ // MARK: - Unit compatibility
+
+ func testUnitComparability() {
+ XCTAssertTrue(MetricUnit.kilograms.isComparable(with: .pounds))
+ XCTAssertFalse(MetricUnit.kilograms.isComparable(with: .minutes))
+ XCTAssertTrue(MetricUnit.kilometers.isComparable(with: .miles))
+ XCTAssertFalse(MetricUnit.bpm.isComparable(with: .kilocalories))
+ }
+
+ // MARK: - Period summary
+
+ func testSummarizeCountsSessionsAndMissed() {
+ let days = [
+ DaySample(dateKey: "2026-07-06", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000, durationMinutes: 60),
+ DaySample(dateKey: "2026-07-07", didWorkout: false, scheduled: true), // missed
+ DaySample(dateKey: "2026-07-08", didWorkout: true, scheduled: true, sessions: 1, sets: 18, volume: 900, durationMinutes: 55),
+ DaySample(dateKey: "2026-07-09", didWorkout: false, scheduled: false), // rest
+ ]
+ let s = AnalyticsEngine.summarize(days)
+ XCTAssertEqual(s.sessions, 2)
+ XCTAssertEqual(s.scheduled, 3)
+ XCTAssertEqual(s.missed, 1)
+ XCTAssertEqual(s.sets, 38)
+ XCTAssertEqual(s.volume, 1900)
+ XCTAssertEqual(s.consistency, 2.0/3.0, accuracy: 0.0001)
+ }
+
+ // MARK: - HealthKit reference aggregation (partial/missing data honest)
+
+ func testSummarizeAggregatesHealthKitAveragesWhenPresent() {
+ let days = [
+ DaySample(dateKey: "2026-07-06", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000,
+ steps: 8000, activeCalories: 500, restingHeartRate: 55),
+ DaySample(dateKey: "2026-07-07", didWorkout: true, scheduled: true, sessions: 1, sets: 18, volume: 900,
+ steps: 10000, activeCalories: 600, restingHeartRate: 57),
+ DaySample(dateKey: "2026-07-08", didWorkout: false, scheduled: false) // no HK reference
+ ]
+ let s = AnalyticsEngine.summarize(days)
+ XCTAssertEqual(s.avgSteps, 9000)
+ XCTAssertEqual(s.avgActiveCalories, 550)
+ XCTAssertEqual(s.avgRestingHR, 56)
+ }
+
+ func testSummarizeHealthKitNilWhenAbsent() {
+ let s = AnalyticsEngine.summarize([DaySample(dateKey: "2026-07-06", didWorkout: true, scheduled: true, sets: 20, volume: 1000)])
+ XCTAssertNil(s.avgSteps)
+ XCTAssertNil(s.avgActiveCalories)
+ XCTAssertNil(s.avgRestingHR)
+ XCTAssertEqual(s.volume, 1000) // workout metrics unaffected
+ }
+
+ // MARK: - Reports (idempotency / determinism)
+
+ func testWeeklyReportDeterministic() {
+ let gen = Date(timeIntervalSince1970: 1_780_000_000)
+ let days = [DaySample(dateKey: "2026-07-06", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000)]
+ let prev = [DaySample(dateKey: "2026-06-29", didWorkout: true, scheduled: true, sessions: 1, sets: 18, volume: 800)]
+ let r1 = AnalyticsEngine.weeklyReport(weekStartKey: "2026-07-06", days: days, previousWeekDays: prev, generatedAt: gen)
+ let r2 = AnalyticsEngine.weeklyReport(weekStartKey: "2026-07-06", days: days, previousWeekDays: prev, generatedAt: gen)
+ XCTAssertEqual(r1, r2, "same inputs → identical report (idempotent)")
+ XCTAssertEqual(r1.vsPreviousWeek["volume"]!.percent!, 25, accuracy: 0.0001)
+ XCTAssertEqual(r1.progression, .improving)
+ XCTAssertFalse(r1.disclaimer.isEmpty)
+ }
+
+ func testMonthlyReportInsufficientWhenNoPrevious() {
+ let days = [DaySample(dateKey: "2026-07-06", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000)]
+ let r = AnalyticsEngine.monthlyReport(monthKey: "2026-07", days: days, previousMonthDays: [], generatedAt: Date())
+ // previous volume is 0 → no meaningful %, classification insufficient.
+ XCTAssertEqual(r.classification, .insufficientData)
+ }
+
+ // MARK: - Calendar / timezone / firstWeekday / DST boundaries
+
+ private func calendar(tz: String, firstWeekday: Int = 1) -> Calendar {
+ var c = Calendar(identifier: .gregorian)
+ c.timeZone = TimeZone(identifier: tz)!
+ c.firstWeekday = firstWeekday
+ c.locale = Locale(identifier: "en_US_POSIX")
+ return c
+ }
+
+ func testDateKeyRespectsTimezone() {
+ // 2026-07-07T02:30Z is still 2026-07-06 (22:30) in New York (UTC-4).
+ let instant = Date(timeIntervalSince1970: 1_783_391_400) // 2026-07-07T02:30:00Z
+ let utc = AnalyticsEngine.dateKey(for: instant, calendar: calendar(tz: "UTC"))
+ let ny = AnalyticsEngine.dateKey(for: instant, calendar: calendar(tz: "America/New_York"))
+ XCTAssertEqual(utc, "2026-07-07")
+ XCTAssertEqual(ny, "2026-07-06")
+ }
+
+ func testFirstWeekdayChangesWeekStart() {
+ let date = Date(timeIntervalSince1970: 1_783_500_000) // a Tuesday
+ let sundayFirst = calendar(tz: "UTC", firstWeekday: 1)
+ let mondayFirst = calendar(tz: "UTC", firstWeekday: 2)
+ let wSun = AnalyticsEngine.weekInterval(containing: date, calendar: sundayFirst)!
+ let wMon = AnalyticsEngine.weekInterval(containing: date, calendar: mondayFirst)!
+ XCTAssertNotEqual(wSun.start, wMon.start, "week start differs by firstWeekday")
+ }
+
+ func testDSTSpringForwardDayKeyStable() {
+ // US DST spring-forward 2026-03-08. The 23-hour day must still key correctly.
+ let cal = calendar(tz: "America/New_York")
+ var comps = DateComponents(); comps.year = 2026; comps.month = 3; comps.day = 8; comps.hour = 12
+ let noon = cal.date(from: comps)!
+ XCTAssertEqual(AnalyticsEngine.dateKey(for: noon, calendar: cal), "2026-03-08")
+ // start-of-day is well-defined despite the missing hour
+ let interval = AnalyticsEngine.weekInterval(containing: noon, calendar: cal)
+ XCTAssertNotNil(interval)
+ }
+
+ func testMonthKey() {
+ let cal = calendar(tz: "UTC")
+ let d = Date(timeIntervalSince1970: 1_783_500_000)
+ XCTAssertEqual(AnalyticsEngine.monthKey(for: d, calendar: cal), "2026-07")
+ }
+
+ // MARK: - Completed-period gating & retention
+
+ func testCompletedWeekGating() {
+ let cal = calendar(tz: "UTC")
+ let now = Date(timeIntervalSince1970: 1_783_500_000) // "this week"
+ let lastWeek = cal.date(byAdding: .weekOfYear, value: -1, to: now)!
+ XCTAssertTrue(AnalyticsEngine.isCompletedWeek(lastWeek, now: now, calendar: cal))
+ XCTAssertFalse(AnalyticsEngine.isCompletedWeek(now, now: now, calendar: cal))
+ }
+
+ func testRetentionExpiry() {
+ let cal = calendar(tz: "UTC")
+ let now = Date(timeIntervalSince1970: 1_783_500_000) // 2026-07
+ let old = AnalyticsEngine.dateKey(for: cal.date(byAdding: .month, value: -13, to: now)!, calendar: cal)
+ let recent = AnalyticsEngine.dateKey(for: cal.date(byAdding: .month, value: -1, to: now)!, calendar: cal)
+ let expired = AnalyticsEngine.expiredDayKeys([old, recent], now: now, months: 12, calendar: cal)
+ XCTAssertTrue(expired.contains(old))
+ XCTAssertFalse(expired.contains(recent))
+ }
+}
diff --git a/KisaniCalTests/AnalyticsStoreTests.swift b/KisaniCalTests/AnalyticsStoreTests.swift
new file mode 100644
index 0000000..408cf8a
--- /dev/null
+++ b/KisaniCalTests/AnalyticsStoreTests.swift
@@ -0,0 +1,94 @@
+import XCTest
+@testable import KisaniCal
+
+/// Persistence tests: snapshot immutability, report idempotency/dedup,
+/// reconciliation selection, and retention pruning. Each test runs against a
+/// fresh temp directory. (Verified standalone via swiftc; mirrored here for the
+/// Xcode test target.)
+final class AnalyticsStoreTests: XCTestCase {
+
+ private var tmp: URL!
+ private var store: AnalyticsStore!
+ private let now = Date(timeIntervalSince1970: 1_783_500_000) // 2026-07-08
+
+ private func cal(_ tz: String = "UTC") -> Calendar {
+ var c = Calendar(identifier: .gregorian)
+ c.timeZone = TimeZone(identifier: tz)!
+ c.locale = Locale(identifier: "en_US_POSIX")
+ return c
+ }
+
+ override func setUp() {
+ super.setUp()
+ tmp = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("analytics_test_\(UUID().uuidString)")
+ store = AnalyticsStore(root: tmp)
+ }
+
+ override func tearDown() {
+ try? FileManager.default.removeItem(at: tmp)
+ super.tearDown()
+ }
+
+ func testPastSnapshotIsFrozen() {
+ let past = DaySample(dateKey: "2026-06-01", didWorkout: true, scheduled: true, sessions: 1, sets: 10, volume: 500)
+ XCTAssertTrue(store.saveSnapshot(past, isPast: true))
+ let edited = DaySample(dateKey: "2026-06-01", didWorkout: true, scheduled: true, sessions: 9, sets: 99, volume: 9999)
+ XCTAssertFalse(store.saveSnapshot(edited, isPast: true), "past snapshot must not be overwritten")
+ XCTAssertEqual(store.snapshot(dateKey: "2026-06-01")?.sets, 10)
+ }
+
+ func testTodaySnapshotUpdatable() {
+ _ = store.saveSnapshot(DaySample(dateKey: "2026-07-08", didWorkout: false, scheduled: true), isPast: false)
+ XCTAssertTrue(store.saveSnapshot(DaySample(dateKey: "2026-07-08", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000), isPast: false))
+ XCTAssertEqual(store.snapshot(dateKey: "2026-07-08")?.sets, 20)
+ }
+
+ func testWeeklyReportIdempotentAndImmutable() {
+ let wr = AnalyticsEngine.weeklyReport(
+ weekStartKey: "2026-06-29",
+ days: [DaySample(dateKey: "2026-06-29", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000)],
+ previousWeekDays: [DaySample(dateKey: "2026-06-22", didWorkout: true, scheduled: true, sessions: 1, sets: 18, volume: 800)],
+ generatedAt: now)
+ XCTAssertTrue(store.saveWeeklyReport(wr))
+ XCTAssertFalse(store.saveWeeklyReport(wr), "re-saving must be a no-op (dedup)")
+ XCTAssertEqual(store.weeklyReport(weekStartKey: "2026-06-29")?.progression, .improving)
+ }
+
+ func testMonthlyReportDedup() {
+ let mr = AnalyticsEngine.monthlyReport(
+ monthKey: "2026-06",
+ days: [DaySample(dateKey: "2026-06-01", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000)],
+ previousMonthDays: [DaySample(dateKey: "2026-05-01", didWorkout: true, scheduled: true, sessions: 1, sets: 18, volume: 800)],
+ generatedAt: now)
+ XCTAssertTrue(store.saveMonthlyReport(mr))
+ XCTAssertFalse(store.saveMonthlyReport(mr))
+ XCTAssertNotNil(store.monthlyReport(monthKey: "2026-06"))
+ }
+
+ func testReconciliationSelection() {
+ let c = cal()
+ let weekKeys = AnalyticsEngine.completedWeekStartKeys(now: now, months: 12, calendar: c)
+ XCTAssertTrue((50...53).contains(weekKeys.count))
+ let currentWeekKey = AnalyticsEngine.dateKey(for: AnalyticsEngine.weekInterval(containing: now, calendar: c)!.start, calendar: c)
+ XCTAssertFalse(weekKeys.contains(currentWeekKey), "current (incomplete) week excluded")
+
+ let monthKeys = AnalyticsEngine.completedMonthKeys(now: now, months: 12, calendar: c)
+ XCTAssertEqual(monthKeys.count, 12)
+ XCTAssertFalse(monthKeys.contains("2026-07"), "current month excluded")
+
+ // A saved report is not re-selected (idempotent reconciliation).
+ let wr = AnalyticsEngine.weeklyReport(weekStartKey: weekKeys.last!, days: [], previousWeekDays: [], generatedAt: now)
+ _ = store.saveWeeklyReport(wr)
+ XCTAssertFalse(AnalyticsEngine.missingKeys(weekKeys, existing: store.existingWeeklyKeys()).contains(weekKeys.last!))
+ }
+
+ func testRetentionPrune() {
+ let c = cal()
+ _ = store.saveSnapshot(DaySample(dateKey: "2025-01-01", didWorkout: true), isPast: true) // >12mo old
+ _ = store.saveSnapshot(DaySample(dateKey: "2026-07-08", didWorkout: true), isPast: false)
+ let removed = store.prune(now: now, months: 12, calendar: c)
+ XCTAssertGreaterThanOrEqual(removed, 1)
+ XCTAssertNil(store.snapshot(dateKey: "2025-01-01"), "expired snapshot pruned")
+ XCTAssertNotNil(store.snapshot(dateKey: "2026-07-08"), "recent snapshot survives")
+ }
+}
diff --git a/KisaniCalTests/StreakLogicTests.swift b/KisaniCalTests/StreakLogicTests.swift
new file mode 100644
index 0000000..a7ad7c2
--- /dev/null
+++ b/KisaniCalTests/StreakLogicTests.swift
@@ -0,0 +1,122 @@
+import XCTest
+@testable import KisaniCal
+
+@MainActor
+final class StreakLogicTests: XCTestCase {
+
+ private let cal = Calendar.current
+ private let fmt: DateFormatter = {
+ let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f
+ }()
+
+ private func day(_ offset: Int, from ref: Date) -> String {
+ fmt.string(from: cal.date(byAdding: .day, value: offset, to: ref)!)
+ }
+
+ // MARK: - Workout tally (lifetime; only ever grows, never resets)
+
+ /// The field scenario: 6-day week, Thursday skipped, 5 workouts done →
+ /// the count is 5, not 1. A missed day never reduces it.
+ func testSkippedDayStillCountsAchieved() {
+ let today = cal.startOfDay(for: Date())
+ var dates: [String] = []
+ guard let week = cal.dateInterval(of: .weekOfYear, for: today) else { return XCTFail() }
+ var d = week.start, added = 0
+ while d <= today {
+ let key = fmt.string(from: d)
+ if key != day(-2, from: today) { dates.append(key); added += 1 } // skip one day
+ d = cal.date(byAdding: .day, value: 1, to: d)!
+ }
+ XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays(dates), added,
+ "every logged day counts; the skipped day is simply absent")
+ }
+
+ /// A below-goal week (4/5) and a full week (5/5) both contribute all their
+ /// achieved days — 9 total, nothing lost to the 4/5 week "failing."
+ func testBelowGoalAndFullWeekBothCountFully() {
+ let today = cal.startOfDay(for: Date())
+ guard let prevRef = cal.date(byAdding: .weekOfYear, value: -1, to: today),
+ let prevWeek = cal.dateInterval(of: .weekOfYear, for: prevRef),
+ let thisWeek = cal.dateInterval(of: .weekOfYear, for: today)
+ else { return XCTFail() }
+
+ var dates: [String] = []
+ var d = prevWeek.start
+ for i in 0..<7 { if i < 4 { dates.append(fmt.string(from: d)) }; d = cal.date(byAdding: .day, value: 1, to: d)! } // 4/5
+ d = thisWeek.start
+ for i in 0..<7 { if i < 5 { dates.append(fmt.string(from: d)) }; d = cal.date(byAdding: .day, value: 1, to: d)! } // 5/5
+
+ XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays(dates), 9,
+ "4/5 + 5/5 = 9; a below-goal week is never discarded")
+ }
+
+ /// A completely blank week between two active stretches does not reset the
+ /// tally — earlier achievements are preserved.
+ func testBlankWeekDoesNotReset() {
+ let today = cal.startOfDay(for: Date())
+ guard let twoBack = cal.date(byAdding: .weekOfYear, value: -2, to: today),
+ let oldWeek = cal.dateInterval(of: .weekOfYear, for: twoBack),
+ let thisWeek = cal.dateInterval(of: .weekOfYear, for: today)
+ else { return XCTFail() }
+
+ var dates: [String] = []
+ var d = oldWeek.start
+ for i in 0..<7 { if i < 3 { dates.append(fmt.string(from: d)) }; d = cal.date(byAdding: .day, value: 1, to: d)! }
+ // (previous week: nothing — a blank week)
+ dates.append(fmt.string(from: thisWeek.start))
+
+ XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays(dates), 4,
+ "3 old + 1 now; the empty week in between changes nothing")
+ }
+
+ /// Duplicate day entries (e.g. HealthKit merge) never double-count.
+ func testDuplicatesCountOnce() {
+ let key = fmt.string(from: cal.startOfDay(for: Date()))
+ XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays([key, key, key]), 1)
+ }
+
+ func testEmptyDataIsZero() {
+ XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays([]), 0)
+ }
+
+ // MARK: - Task completion counting (recurring occurrences)
+
+ private func makeRecurring(title: String, completedOn keys: [String]) -> TaskItem {
+ var t = TaskItem(title: title, dueDate: cal.date(byAdding: .day, value: -30, to: Date()))
+ t.isRecurring = true
+ t.recurrenceLabel = "Daily"
+ t.completedOccurrences = keys
+ return t
+ }
+
+ /// Recurring completions must count toward per-day stats even though the
+ /// stored task's isComplete/completedAt stay untouched.
+ func testRecurringOccurrencesCountedPerDay() {
+ let today = cal.startOfDay(for: Date())
+ let todayKey = fmt.string(from: today)
+ let recurring = makeRecurring(title: "Morning routine", completedOn: [todayKey])
+
+ var oneOff = TaskItem(title: "Buy milk")
+ oneOff.isComplete = true
+ oneOff.completedAt = today.addingTimeInterval(3600)
+
+ let n = TaskViewModel.completionCount(in: [recurring, oneOff], on: today, calendar: cal)
+ XCTAssertEqual(n, 2, "one recurring occurrence + one one-off completion")
+ }
+
+ func testRecurringNotCountedOnOtherDays() {
+ let today = cal.startOfDay(for: Date())
+ let yesterdayKey = day(-1, from: today)
+ let recurring = makeRecurring(title: "Morning routine", completedOn: [yesterdayKey])
+
+ XCTAssertEqual(TaskViewModel.completionCount(in: [recurring], on: today, calendar: cal), 0)
+ let yesterday = cal.date(byAdding: .day, value: -1, to: today)!
+ XCTAssertEqual(TaskViewModel.completionCount(in: [recurring], on: yesterday, calendar: cal), 1)
+ }
+
+ func testIncompleteOneOffNotCounted() {
+ let today = cal.startOfDay(for: Date())
+ let t = TaskItem(title: "Open task")
+ XCTAssertEqual(TaskViewModel.completionCount(in: [t], on: today, calendar: cal), 0)
+ }
+}
diff --git a/KisaniCalWidgets/EventCountdownWidget.swift b/KisaniCalWidgets/EventCountdownWidget.swift
index bbed562..65191d6 100644
--- a/KisaniCalWidgets/EventCountdownWidget.swift
+++ b/KisaniCalWidgets/EventCountdownWidget.swift
@@ -78,6 +78,8 @@ struct EventEntity: AppEntity {
let dueDate: Date?
var isRecurring: Bool = false
var recurrenceLabel: String? = nil
+ var recurrenceInterval: Int? = nil
+ var createdAt: Date? = nil
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Event"
static var defaultQuery = EventQuery()
@@ -105,7 +107,9 @@ struct EventQuery: EntityQuery {
.filter { !$0.isComplete && $0.dueDate != nil }
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
.map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate,
- isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel) }
+ isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel,
+ recurrenceInterval: $0.recurrenceInterval,
+ createdAt: $0.createdAt) }
}
}
@@ -235,7 +239,9 @@ struct EventProvider: AppIntentTimelineProvider {
.filter { !$0.isComplete && ($0.dueDate ?? .distantPast) > now }
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
.map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate,
- isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel) }
+ isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel,
+ recurrenceInterval: $0.recurrenceInterval,
+ createdAt: $0.createdAt) }
}
/// The tracked task ("Track Countdown"), but only while it's still live: not
@@ -267,13 +273,16 @@ struct EventProvider: AppIntentTimelineProvider {
/// Resolve the progress window (start → target) for any event. Recurring events
/// (Mode B) span the current occurrence period: previous → next occurrence —
/// so a birthday 5 days away reads ~98% full. One-off events (Mode A) count
- /// from an explicit start, the tracking date, or first-seen, up to the due date.
- private func resolveSpan(due: Date, isRecurring: Bool, label: String?,
- anchorKey: String, explicitStart: Date?) -> (start: Date, target: Date) {
- if isRecurring, let label, let span = Self.recurrenceSpan(label: label, due: due) {
+ /// from an explicit start, the task's real creation date, the tracking date,
+ /// or first-seen, up to the due date — in that priority order.
+ private func resolveSpan(due: Date, isRecurring: Bool, label: String?, interval: Int? = nil,
+ anchorKey: String, explicitStart: Date?,
+ taskCreatedAt: Date? = nil) -> (start: Date, target: Date) {
+ if isRecurring, let label, let span = Self.recurrenceSpan(label: label, interval: interval ?? 1, due: due) {
return (span.prev, span.next)
}
if let s = explicitStart, s < due { return (s, due) }
+ if let created = taskCreatedAt, created < due { return (created, due) }
return (Self.storedAnchor(key: anchorKey, target: due), due)
}
@@ -288,7 +297,9 @@ struct EventProvider: AppIntentTimelineProvider {
let trackedId = task.id.uuidString
let since = UserDefaults.kisani.object(forKey: "kisani.widget.trackedSince") as? Date
let s = resolveSpan(due: task.dueDate ?? now, isRecurring: task.isRecurring,
- label: task.recurrenceLabel, anchorKey: trackedId, explicitStart: since)
+ label: task.recurrenceLabel, interval: task.recurrenceInterval,
+ anchorKey: trackedId, explicitStart: since,
+ taskCreatedAt: task.createdAt)
return EventEntry(date: now, eventName: task.title, start: s.start, target: s.target, unitMode: mode)
}
@@ -302,12 +313,15 @@ struct EventProvider: AppIntentTimelineProvider {
}
let ev = upcoming[idx]
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
- anchorKey: ev.id, explicitStart: nil)
+ interval: ev.recurrenceInterval,
+ anchorKey: ev.id, explicitStart: nil, taskCreatedAt: ev.createdAt)
return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode)
}
if let ev = config.event, let due = ev.dueDate {
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
- anchorKey: ev.id, explicitStart: config.startDate)
+ interval: ev.recurrenceInterval,
+ anchorKey: ev.id, explicitStart: config.startDate,
+ taskCreatedAt: ev.createdAt)
return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode)
}
// Fully custom event (no task behind it).
@@ -333,30 +347,11 @@ struct EventProvider: AppIntentTimelineProvider {
/// Mode B window for a recurring event: the occurrence span containing "now"
/// (previous occurrence → next occurrence), derived from the rule label.
- private static func recurrenceSpan(label: String, due: Date) -> (prev: Date, next: Date)? {
- let cal = Calendar.current
- let now = Date()
- let comp: Calendar.Component
- let step: Int
- switch label {
- case "Daily", "Every Weekday": comp = .day; step = 1
- case "Weekly": comp = .day; step = 7
- case "Monthly": comp = .month; step = 1
- case "Yearly": comp = .year; step = 1
- default: return nil
- }
- var next = due
- var guardRail = 0
- if next > now {
- // Walk back until the previous occurrence is in the past.
- while let prev = cal.date(byAdding: comp, value: -step, to: next),
- prev > now, guardRail < 4000 { next = prev; guardRail += 1 }
- } else {
- while next <= now, let n = cal.date(byAdding: comp, value: step, to: next),
- guardRail < 4000 { next = n; guardRail += 1 }
- }
- guard let prev = cal.date(byAdding: comp, value: -step, to: next) else { return nil }
- return (prev, next)
+ /// The actual math lives in RecurrenceRule (Shared/) — shared with the main
+ /// app's occurrence matching and the other countdown widget's copy of this
+ /// same calculation, so none of the three can independently drift.
+ private static func recurrenceSpan(label: String, interval: Int = 1, due: Date) -> (prev: Date, next: Date)? {
+ RecurrenceRule.span(label: label, interval: interval, due: due, now: Date())
}
}
diff --git a/KisaniCalWidgets/KisaniCalWidgets.swift b/KisaniCalWidgets/KisaniCalWidgets.swift
index 3481472..98eb5cb 100644
--- a/KisaniCalWidgets/KisaniCalWidgets.swift
+++ b/KisaniCalWidgets/KisaniCalWidgets.swift
@@ -40,6 +40,7 @@ enum ProgressDotMode: String, AppEnum {
case day
case week
case month
+ case year
case customEvent
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Progress Mode"
@@ -47,6 +48,7 @@ enum ProgressDotMode: String, AppEnum {
.day: "This Day",
.week: "This Week",
.month: "This Month",
+ .year: "This Year",
.customEvent: "Custom Event",
]
}
@@ -115,6 +117,13 @@ struct ProgressDotProvider: AppIntentTimelineProvider {
return ProgressDotEntry(date: date, title: Self.monthTitle(for: date),
progress: Self.progress(now: date, start: start, end: end),
totalDots: 100)
+ case .year:
+ let interval = Calendar.current.dateInterval(of: .year, for: date)
+ let start = interval?.start ?? date
+ let end = interval?.end ?? date
+ return ProgressDotEntry(date: date, title: Self.yearTitle(for: date),
+ progress: Self.progress(now: date, start: start, end: end),
+ totalDots: 100)
case .customEvent:
guard let event = configuration.event,
let task = loadWidgetTasks().first(where: { $0.id.uuidString == event.id }),
@@ -188,45 +197,16 @@ struct ProgressDotProvider: AppIntentTimelineProvider {
return Calendar.current.date(byAdding: .day, value: -totalDays, to: target) ?? now
}
+ // Recurrence-window math itself lives in RecurrenceRule (Shared/) so this
+ // widget's countdown can never drift from the main app's occurrence logic
+ // or from the other countdown widget's copy of the same calculation.
private static func recurrenceSpan(for task: WidgetTask, due: Date, now: Date) -> (prev: Date, next: Date)? {
let category = task.category.lowercased()
if category == "birthday" || category == "annual" {
- return recurrenceSpan(label: "Yearly", due: due, now: now)
+ return RecurrenceRule.span(label: "Yearly", due: due, now: now)
}
guard task.isRecurring, let label = task.recurrenceLabel else { return nil }
- return recurrenceSpan(label: label, due: due, now: now)
- }
-
- private static func recurrenceSpan(label: String, due: Date, now: Date) -> (prev: Date, next: Date)? {
- let cal = Calendar.current
- let comp: Calendar.Component
- let step: Int
- switch label {
- case "Daily", "Every Weekday": comp = .day; step = 1
- case "Weekly": comp = .day; step = 7
- case "Monthly": comp = .month; step = 1
- case "Yearly": comp = .year; step = 1
- default: return nil
- }
-
- var next = due
- var guardRail = 0
- if next > now {
- while let prev = cal.date(byAdding: comp, value: -step, to: next),
- prev > now, guardRail < 4000 {
- next = prev
- guardRail += 1
- }
- } else {
- while next <= now, let candidate = cal.date(byAdding: comp, value: step, to: next),
- guardRail < 4000 {
- next = candidate
- guardRail += 1
- }
- }
-
- guard let prev = cal.date(byAdding: comp, value: -step, to: next) else { return nil }
- return (prev, next)
+ return RecurrenceRule.span(label: label, interval: task.recurrenceInterval ?? 1, due: due, now: now)
}
private static func startOfISOWeek(containing date: Date) -> Date {
@@ -251,6 +231,12 @@ struct ProgressDotProvider: AppIntentTimelineProvider {
formatter.dateFormat = "MMMM yyyy"
return formatter.string(from: date)
}
+
+ private static func yearTitle(for date: Date) -> String {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy"
+ return formatter.string(from: date)
+ }
}
struct DayProgressWidget: Widget {
diff --git a/KisaniCalWidgets/StopCountdownIntent.swift b/KisaniCalWidgets/StopCountdownIntent.swift
new file mode 100644
index 0000000..8e10da6
--- /dev/null
+++ b/KisaniCalWidgets/StopCountdownIntent.swift
@@ -0,0 +1,26 @@
+import AppIntents
+import ActivityKit
+
+/// Ends a running task Live Activity directly from its Lock Screen / Dynamic
+/// Island card — no need to open the app. Runs in the widget extension
+/// process (that's how LiveActivityIntent works), so it talks to ActivityKit
+/// directly rather than going through LiveActivityManager (app target only).
+@available(iOS 17.0, *)
+struct StopCountdownIntent: LiveActivityIntent {
+ static var title: LocalizedStringResource = "Stop Countdown"
+ static var description = IntentDescription("Ends this task's live countdown.")
+
+ @Parameter(title: "Task ID")
+ var taskID: String
+
+ init() {}
+ init(taskID: String) { self.taskID = taskID }
+
+ func perform() async throws -> some IntentResult {
+ for activity in Activity.activities
+ where activity.attributes.taskID == taskID {
+ await activity.end(nil, dismissalPolicy: .immediate)
+ }
+ return .result()
+ }
+}
diff --git a/KisaniCalWidgets/TaskLiveActivity.swift b/KisaniCalWidgets/TaskLiveActivity.swift
index 278ff30..bc54ff8 100644
--- a/KisaniCalWidgets/TaskLiveActivity.swift
+++ b/KisaniCalWidgets/TaskLiveActivity.swift
@@ -10,8 +10,8 @@ struct TaskLiveActivity: Widget {
ActivityConfiguration(for: TaskActivityAttributes.self) { context in
// ── Lock screen / banner ──
HStack(spacing: 12) {
- Image(systemName: context.state.isComplete ? "checkmark.circle.fill" : "circle")
- .font(.system(size: 22))
+ Image(systemName: context.state.isComplete ? "checkmark.circle.fill" : "hourglass")
+ .font(.system(size: 20))
.foregroundColor(context.state.isComplete ? .green : accent)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
@@ -40,6 +40,18 @@ struct TaskLiveActivity: Widget {
.frame(width: 92, alignment: .trailing)
.fixedSize(horizontal: false, vertical: true)
}
+
+ // Explicit stop control — a Live Activity can't be prevented
+ // from being swiped away by the system, so this gives an
+ // intentional, in-card way to end tracking instead.
+ if #available(iOS 17.0, *), !context.state.isComplete {
+ Button(intent: StopCountdownIntent(taskID: context.attributes.taskID)) {
+ Image(systemName: "xmark.circle.fill")
+ .font(.system(size: 18))
+ .foregroundColor(.white.opacity(0.5))
+ }
+ .buttonStyle(.plain)
+ }
}
.padding(.leading, 14)
.padding(.trailing, 10)
@@ -69,6 +81,16 @@ struct TaskLiveActivity: Widget {
.font(.system(.callout, design: .monospaced))
.lineLimit(1)
}
+ DynamicIslandExpandedRegion(.bottom) {
+ if #available(iOS 17.0, *), !context.state.isComplete {
+ Button(intent: StopCountdownIntent(taskID: context.attributes.taskID)) {
+ Label("Stop Countdown", systemImage: "xmark.circle")
+ .font(.system(.caption, design: .monospaced))
+ }
+ .buttonStyle(.plain)
+ .tint(accent)
+ }
+ }
} compactLeading: {
Image(systemName: "checklist").foregroundColor(accent)
} compactTrailing: {
diff --git a/KisaniCalWidgets/WidgetData.swift b/KisaniCalWidgets/WidgetData.swift
index 75ee4de..bc0a130 100644
--- a/KisaniCalWidgets/WidgetData.swift
+++ b/KisaniCalWidgets/WidgetData.swift
@@ -12,6 +12,7 @@ struct WidgetTask: Codable, Identifiable {
let category: String
var isRecurring: Bool = false
var recurrenceLabel: String? = nil
+ var recurrenceInterval: Int? = nil
var createdAt: Date? = nil
var reminderDate: Date? = nil
var progress: Double? = nil
@@ -67,6 +68,7 @@ func loadWidgetTasks() -> [WidgetTask] {
WidgetTask(id: $0.id, title: $0.title, dueDate: $0.dueDate,
isComplete: $0.isComplete, quadrant: $0.quadrant, category: $0.category,
isRecurring: $0.isRecurring ?? false, recurrenceLabel: $0.recurrenceLabel,
+ recurrenceInterval: $0.recurrenceInterval,
createdAt: $0.createdAt, reminderDate: $0.reminderDate,
progress: $0.countdownProgress ?? $0.progress)
}
@@ -82,13 +84,14 @@ private struct RawTask: Codable {
var category: String = "reminder"
var isRecurring: Bool? = nil
var recurrenceLabel: String? = nil
+ var recurrenceInterval: Int? = nil
var createdAt: Date? = nil
var reminderDate: Date? = nil
var progress: Double? = nil
var countdownProgress: Double? = nil
enum CodingKeys: String, CodingKey {
case id, title, dueDate, isComplete, quadrant, category, isRecurring, recurrenceLabel
- case createdAt, reminderDate, progress, countdownProgress
+ case recurrenceInterval, createdAt, reminderDate, progress, countdownProgress
}
}
diff --git a/STRUCTURE.md b/STRUCTURE.md
new file mode 100644
index 0000000..feada7b
--- /dev/null
+++ b/STRUCTURE.md
@@ -0,0 +1,108 @@
+# Project Structure
+
+KisaniCal (display name **Wenza**) is a personal productivity + fitness iOS app,
+built with SwiftUI and generated from `project.yml` via [XcodeGen]. It ships an
+iOS app, a WidgetKit extension, and an optional watchOS companion.
+
+## Build targets
+
+Defined in [`project.yml`](project.yml):
+
+| Target | Type | Platform | Bundle ID |
+|---|---|---|---|
+| `KisaniCal` | application | iOS 16+ | `com.kutesir.KisaniCal` |
+| `KisaniCalWidgets` | app-extension | iOS 18+ | `com.kutesir.KisaniCal.KisaniCalWidgets` |
+| `WenzaWatch` | application | watchOS 10+ | `com.kutesir.KisaniCal.watchkitapp` |
+| `KisaniCalTests` | unit-test | iOS 16+ | — |
+
+`WenzaWatch` is built/embedded only when the watchOS platform is installed
+(see the commented `embed` note in `project.yml`).
+
+## Directory layout
+
+```
+KisaniCal/ repo root (Gitea: kutesir/KisaniCal)
+│
+├── project.yml XcodeGen spec — source of truth for the .xcodeproj
+├── KisaniCal.xcodeproj generated (do not hand-edit)
+│
+├── KisaniCal/ 📱 main iOS app target
+│ ├── KisaniCalApp.swift @main entry point
+│ ├── ContentView.swift root view / tab shell
+│ ├── KisaniCal.entitlements
+│ ├── LaunchScreen.storyboard
+│ ├── Views/ SwiftUI screens (13)
+│ │ ├── TodayView, CalendarView, MatrixView tasks
+│ │ ├── WorkoutView, AddExerciseSheet fitness
+│ │ ├── AuthView, OnboardingView, ProfileSetupView, SplashView
+│ │ ├── SettingsView, TutorialView
+│ │ └── TaskContextMenu, TodayMenuFeatures
+│ ├── Managers/ stateful service singletons (10)
+│ │ ├── AuthManager, CloudSyncManager auth + iCloud KVS
+│ │ ├── HealthKitManager, SpeechRecognizer system frameworks
+│ │ ├── NotificationManager, LiveActivityManager, ShortcutHandler
+│ │ ├── TutorialManager
+│ │ └── AppGroup, TaskActivityAttributes shared with widgets
+│ ├── Models/ TaskItem, ExerciseModels, CalendarGrid
+│ ├── Components/ SharedComponents, FloatingTabState
+│ └── Theme/ DesignTokens
+│
+├── KisaniCalWidgets/ 🧩 WidgetKit extension (iOS 18+)
+│ ├── KisaniCalWidgets.swift, MyTasksWidget, EventCountdownWidget
+│ ├── TaskLiveActivity, WidgetViews, WidgetData
+│ └── Info.plist, .entitlements
+│
+├── WenzaWatch/ ⌚️ watchOS companion (build-optional)
+│ └── WenzaWatchApp.swift, Info.plist, .entitlements
+│
+├── KisaniCalTests/ 🧪 unit tests (the CI test target)
+│ └── CalendarGridTests, RecurrenceTests, NLRecurrenceParseTests
+│
+├── .gitea/workflows/ 🔧 CI/CD (Gitea Actions)
+│ ├── ci.yml build + test on PRs into main/develop
+│ └── release.yml archive + export IPA on push to main
+│
+├── scripts/gitea-setup.sh Gitea branch-protection / default-branch setup
+├── ExportOptions.plist IPA export template
+│
+├── CONTRIBUTING.md branch workflow + PR gate rules
+├── PRODUCT.md product brief, users, design principles
+├── SERVICES.md backing services / infra notes
+├── ISSUES.md running issue log (KC-## ids)
+└── Sanctum-Auto-FailOver-Uptime-Kuma.md
+```
+
+## Architecture
+
+A flat SwiftUI **MVVM-lite** layout, organized by role rather than by feature:
+
+- **Views** — SwiftUI screens, one file per screen.
+- **Managers** — `ObservableObject` service singletons holding cross-cutting
+ state (auth, iCloud sync, HealthKit, notifications, speech, Live Activities).
+- **Models** — plain data types (`TaskItem`, exercise/workout models, calendar
+ grid math).
+- **Components / Theme** — shared UI and design tokens.
+
+Three product domains coexist in one app:
+
+1. **Tasks** — Today list, Calendar, Eisenhower Matrix; natural-language quick
+ add (with voice via `SpeechRecognizer`).
+2. **Fitness** — workout logging with weighted/bodyweight sets; reads/writes
+ Health via `HealthKitManager`.
+3. **Calendar / events** — surfaced on the dashboard, in **widgets**, and as
+ **Live Activities**.
+
+### Cross-target code sharing
+
+`KisaniCal/Managers/AppGroup.swift` and `TaskActivityAttributes.swift` are
+compiled into **both** the app and the widget extension (see the widget target's
+`sources` in `project.yml`), so task/activity data is shared through the App
+Group container.
+
+### Persistence & sync
+
+State is persisted locally and mirrored to **iCloud key-value store** via
+`CloudSyncManager`. Restore-on-launch rehydrates data after reinstall/update
+(see ISSUES.md KC-39 for the save/restore symmetry the sync layer must keep).
+
+[XcodeGen]: https://github.com/yonaskolb/XcodeGen
diff --git a/Shared/RecurrenceRule.swift b/Shared/RecurrenceRule.swift
new file mode 100644
index 0000000..2b2a56e
--- /dev/null
+++ b/Shared/RecurrenceRule.swift
@@ -0,0 +1,109 @@
+import Foundation
+
+/// Shared, framework-free recurrence math. Compiled into the main app
+/// (`TaskItem.isOccurrence`) AND both widget extensions (their countdown
+/// progress-window calculations) from `Shared/`, so all three can never
+/// independently drift from each other the way three hand-written copies did.
+///
+/// `interval` defaults to 1 everywhere — every case below is written so that
+/// interval == 1 produces results identical to this app's pre-interval
+/// behavior (verified in RecurrenceRuleTests.swift).
+enum RecurrenceRule {
+
+ /// Whether `date` is a valid occurrence of a rule starting at `start`,
+ /// repeating every `interval` units of `label`, bounded by `end` if set.
+ static func isOccurrence(label: String?, interval: Int = 1, start: Date, date: Date,
+ end: Date? = nil, calendar: Calendar = .current) -> Bool {
+ let cal = calendar
+ let d0 = cal.startOfDay(for: date), s0 = cal.startOfDay(for: start)
+ guard d0 >= s0 else { return false }
+ if let end, d0 > cal.startOfDay(for: end) { return false }
+ let n = max(1, interval)
+
+ switch label {
+ case "Daily":
+ let days = cal.dateComponents([.day], from: s0, to: d0).day ?? 0
+ return days % n == 0
+
+ case "Every Weekday":
+ // No interval concept — always Mon–Fri, same as before intervals existed.
+ let wd = cal.component(.weekday, from: d0) // 1=Sun … 7=Sat
+ return wd >= 2 && wd <= 6
+
+ case "Weekly":
+ let days = cal.dateComponents([.day], from: s0, to: d0).day ?? 0
+ return days % (7 * n) == 0
+
+ case "Monthly":
+ guard cal.component(.day, from: s0) == cal.component(.day, from: d0) else { return false }
+ let months = cal.dateComponents([.month], from: s0, to: d0).month ?? 0
+ return months % n == 0
+
+ case "Yearly":
+ guard cal.component(.day, from: s0) == cal.component(.day, from: d0),
+ cal.component(.month, from: s0) == cal.component(.month, from: d0)
+ else { return false }
+ let years = cal.dateComponents([.year], from: s0, to: d0).year ?? 0
+ return years % n == 0
+
+ default:
+ return false
+ }
+ }
+
+ /// The occurrence window (prev, next) containing `now` — used by the
+ /// countdown-dot widgets to size their progress bar. `prev` is the most
+ /// recent occurrence on/before `now`; `next` is the following one.
+ /// Returns nil for an unrecognized label.
+ static func span(label: String, interval: Int = 1, due: Date, now: Date,
+ calendar: Calendar = .current) -> (prev: Date, next: Date)? {
+ let cal = calendar
+ let n = max(1, interval)
+ let comp: Calendar.Component
+ let step: Int
+ switch label {
+ case "Daily": comp = .day; step = n
+ case "Every Weekday": comp = .day; step = 1 // no interval concept, see isOccurrence
+ case "Weekly": comp = .day; step = 7 * n
+ case "Monthly": comp = .month; step = n
+ case "Yearly": comp = .year; step = n
+ default: return nil
+ }
+
+ var next = due
+ var guardRail = 0
+ if next > now {
+ while let prev = cal.date(byAdding: comp, value: -step, to: next),
+ prev > now, guardRail < 4000 {
+ next = prev
+ guardRail += 1
+ }
+ } else {
+ while next <= now,
+ let candidate = cal.date(byAdding: comp, value: step, to: next),
+ guardRail < 4000 {
+ next = candidate
+ guardRail += 1
+ }
+ }
+ guard let prev = cal.date(byAdding: comp, value: -step, to: next) else { return nil }
+ return (prev, next)
+ }
+
+ /// User-facing recurrence text — "Weekly" at interval 1 (unchanged from
+ /// before intervals existed), "Every 2 Weeks" once N > 1. One shared
+ /// formatter so the main app and the create/edit picker UI can't drift.
+ static func displayLabel(label: String, interval: Int = 1) -> String {
+ let n = max(1, interval)
+ guard n > 1, label != "Every Weekday" else { return label }
+ let unit: String
+ switch label {
+ case "Daily": unit = "Days"
+ case "Weekly": unit = "Weeks"
+ case "Monthly": unit = "Months"
+ case "Yearly": unit = "Years"
+ default: return label
+ }
+ return "Every \(n) \(unit)"
+ }
+}
diff --git a/docs/CICD.md b/docs/CICD.md
new file mode 100644
index 0000000..9d31580
--- /dev/null
+++ b/docs/CICD.md
@@ -0,0 +1,75 @@
+# CI/CD Flow
+
+How a change travels from a local branch to TestFlight, and where the gates are.
+
+```mermaid
+flowchart TD
+ A["feature/* branch · local
pre-push hook blocks main"]
+ B["Pull request → develop
develop is the default branch"]
+ C["CI · ci.yml (macOS runner)
xcodebuild build + test"]
+ D{"Merge gate
CI green + 1 approval"}
+ E["Pull request develop → main
same gate"]
+ F["Release · release.yml
archive → IPA → TestFlight"]
+
+ A -->|git push · open PR| B
+ B -->|PR triggers CI| C
+ C -->|status check: build-and-test| D
+ D -->|promote when ready| E
+ E -->|on merge to main| F
+```
+
+## Steps
+
+1. **`feature/*` branch (local).** Branch off `develop`. A local
+ `.git/hooks/pre-push` hook rejects any direct push to `main`, so nothing
+ reaches the protected branch outside a PR.
+
+2. **PR → `develop`.** `develop` is the default branch and the integration
+ target for all feature work.
+
+3. **CI runs** — [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml) on the
+ self-hosted **macOS runner**. It runs `xcodebuild build` then
+ `xcodebuild test` (the `KisaniCalTests` target on an iOS simulator). The
+ result is published as a commit status named **`build-and-test`**.
+
+4. **Merge gate.** Gitea branch protection blocks the merge until:
+ - the `build-and-test` status check is **green**,
+ - there is **≥ 1 approval**,
+ - there are no rejected reviews, and
+ - the branch is up to date with its base.
+
+ The same gate guards both `develop` and `main`.
+
+5. **Promote `develop` → `main`** via a second PR when a release is ready. It
+ passes through the identical gate.
+
+6. **Release runs** — [`.gitea/workflows/release.yml`](../.gitea/workflows/release.yml)
+ fires **only** on push to `main` (i.e. after a PR merges). It archives,
+ exports an IPA via [`ExportOptions.plist`](../ExportOptions.plist), and
+ uploads to **TestFlight** (currently a commented-out placeholder awaiting the
+ App Store Connect API-key secrets).
+
+## Two automation halves
+
+- **CI** (steps 3–4) gates **every** PR into `main`/`develop`.
+- **Release** (step 6) runs **only** on `main`.
+
+Blue/manual steps are actions you take; CI and Release run unattended on the
+runner.
+
+## Requirements & gotchas
+
+- **Runner labels.** Both workflows use `runs-on: [self-hosted, macOS]`. The
+ registered runner must carry **both** labels or jobs queue forever. Check at
+ Settings → Actions → Runners.
+- **Status-check name.** Branch protection requires a check literally named
+ `build-and-test` (the CI job name). If the rendered context differs, update
+ the rule (`scripts/gitea-setup.sh`, `STATUS_CONTEXT`).
+- **Simulator.** The runner needs an installed iOS runtime (Xcode → Settings →
+ Components) — CI auto-selects the first available iPhone simulator.
+- **Signing.** CI builds with `CODE_SIGNING_ALLOWED=NO`. The Release job needs a
+ distribution cert + profile in the runner's login keychain.
+- **Build number.** `CURRENT_PROJECT_VERSION` in `project.yml` must increase for
+ every TestFlight upload — App Store Connect rejects a repeated build number.
+
+See also [CONTRIBUTING.md](../CONTRIBUTING.md) for the branch workflow.
diff --git a/project.yml b/project.yml
index 42e11d6..5565211 100644
--- a/project.yml
+++ b/project.yml
@@ -10,7 +10,7 @@ settings:
base:
DEVELOPMENT_TEAM: K8BLMMR883
MARKETING_VERSION: "2.0"
- CURRENT_PROJECT_VERSION: "9"
+ CURRENT_PROJECT_VERSION: "10"
schemes:
KisaniCal:
@@ -37,6 +37,7 @@ targets:
deploymentTarget: "16.0"
sources:
- path: KisaniCal
+ - path: Shared
dependencies:
- target: KisaniCalWidgets
embed: true
@@ -90,6 +91,7 @@ targets:
- path: KisaniCalWidgets
- path: KisaniCal/Managers/AppGroup.swift
- path: KisaniCal/Managers/TaskActivityAttributes.swift
+ - path: Shared
info:
path: KisaniCalWidgets/Info.plist
properties:
diff --git a/scripts/gitea-setup.sh b/scripts/gitea-setup.sh
new file mode 100755
index 0000000..579409d
--- /dev/null
+++ b/scripts/gitea-setup.sh
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+#
+# One-shot Gitea setup for KisaniCal:
+# 1. Creates the `develop` branch on the server (from main) if missing.
+# 2. Sets `develop` as the repo's default branch.
+# 3. Adds branch protection on `main` (require PR + 1 approval + status check,
+# block direct pushes / force pushes / deletion).
+#
+# USAGE:
+# export GITEA_TOKEN=xxxxxxxxxxxxxxxxxxxx # a Gitea access token with repo scope
+# ./scripts/gitea-setup.sh
+#
+# Create the token in Gitea: click your avatar → Settings → Applications →
+# "Generate New Token" (scopes: at least write:repository ).
+#
+set -euo pipefail
+
+GITEA_URL="http://10.10.1.21:3002"
+OWNER="kutesir"
+REPO="KisaniCal"
+
+# The commit-status context that must pass before merge into main.
+# For Gitea Actions this is the JOB name from ci.yml ("build-and-test").
+# After your first CI run, confirm the exact context string at:
+# ${GITEA_URL}/${OWNER}/${REPO}/commits (hover the check) and adjust if needed.
+STATUS_CONTEXT="build-and-test"
+
+: "${GITEA_TOKEN:?Set GITEA_TOKEN env var first (see header).}"
+
+API="${GITEA_URL}/api/v1"
+AUTH=(-H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json")
+
+echo "==> 1/3 Ensuring 'develop' branch exists on server..."
+if curl -fsS "${AUTH[@]}" "${API}/repos/${OWNER}/${REPO}/branches/develop" >/dev/null 2>&1; then
+ echo " develop already exists."
+else
+ curl -fsS -X POST "${AUTH[@]}" \
+ "${API}/repos/${OWNER}/${REPO}/branches" \
+ -d '{"new_branch_name":"develop","old_branch_name":"main"}' >/dev/null
+ echo " created develop from main."
+fi
+
+echo "==> 2/3 Setting default branch to 'develop'..."
+curl -fsS -X PATCH "${AUTH[@]}" \
+ "${API}/repos/${OWNER}/${REPO}" \
+ -d '{"default_branch":"develop"}' >/dev/null
+echo " default branch = develop."
+
+echo "==> 3/3 Applying branch protection on 'main'..."
+# If a rule already exists this POST returns 409; we then PUT-update it.
+PROTECT_PAYLOAD=$(cat </dev/null 2>&1; then
+ echo " created protection rule for main."
+else
+ echo " rule may already exist — updating it..."
+ curl -fsS -X PATCH "${AUTH[@]}" \
+ "${API}/repos/${OWNER}/${REPO}/branch_protections/main" \
+ -d "${PROTECT_PAYLOAD}" >/dev/null
+ echo " updated protection rule for main."
+fi
+
+echo ""
+echo "Done. Note: Gitea automatically blocks force-pushes and deletion of a"
+echo "protected branch, so 'main' is now safe from both."