Compare commits

..

5 Commits

Author SHA1 Message Date
kutesir
af2996876c Tests: recurring-task occurrence expansion (date mapping)
8 tests covering daily / weekly / every-weekday / monthly / yearly expansion,
repeat-until cutoff, time-of-day preservation on the mapped local day, and
non-recurring single-day placement. Uses Calendar.current so it holds in any
timezone. Closes the recurrence-coverage gap from the calendar fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 20:59:43 +03:00
kutesir
eb7d563bbd feat(voice): wire existing mic button to live speech recognition
The mic icon in AddTaskSheet was a static Image with no action and no
speech service behind it. Full implementation:

SpeechRecognizer.swift (new):
- @MainActor ObservableObject wrapping SFSpeechRecognizer + AVAudioEngine
- Requests speech recognition then microphone permission in sequence
- Streams partial results into @Published transcript; final result stops engine
- Suppresses transient error 301 (no speech detected)
- reset() cleans up on sheet dismiss
- [VOICE] log trail at every step: tapped → granted → started → transcript → saved

TodayView.swift — AddTaskSheet:
- @StateObject private var speech = SpeechRecognizer()
- onChange(speech.transcript) writes transcript to rawText (task field)
- Mic Image → Button { speech.toggle() } with mic.fill + pulse animation while recording
- .safeAreaInset shows a red dismissible error banner on permission denial
- submit() stops recording before saving; logs task title
- .onDisappear resets speech state

project.yml:
- NSMicrophoneUsageDescription
- NSSpeechRecognitionUsageDescription

Co-Authored-By: Kutesir <tqwyy79vzn@privaterelay.appleid.com>
2026-06-24 19:48:00 +03:00
kutesir
716e6e8f31 Notifications: drop decorative ✓ glyphs from action title and done body
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:24:54 +03:00
kutesir
38db257c4a Calendar: fix weekday-header/week-number alignment to firstWeekday + tests
Root cause: a data-integrity bug, not visual. The month grid is built from
.weekOfMonth (respects Calendar.current.firstWeekday), but the weekday header
was hardcoded Sunday-first ("S M T W T F S") and the week-number label was gated
on isSunday (weekday == 1). On any non-Sunday-first locale (or when the user sets
"Start Week On" = Monday) every column was mislabeled by one and the W-number
landed on the wrong column — e.g. June 1 2026 (a Monday) appearing under "S".

Fix:
- Extract pure, testable date math into CalendarGrid (monthGrid + weekdaySymbols).
- Header now derived from firstWeekday, so it always matches the grid.
- Week-number label gated on isWeekStart (weekday == firstWeekday), not Sunday.
- Document that week numbers are locale weekOfYear (consistent with the layout),
  not ISO.
- gridDays() and the year-view mini-month dedupe to CalendarGrid.

Verification:
- New KisaniCalTests target with 9 tests, all passing on simulator: exact
  Sunday-first June 2026 grid, header↔grid alignment for firstWeekday 1...7,
  cell date identity, month navigation (May/Jul/Feb 2026 + Feb 2028 leap +
  Dec 2026→Jan 2027), and event-day bucketing (all-day, late-night, midnight-
  crossing) across 4 timezones.
- Verified in the running app: header S M T W T F S, May 31 in the Sunday
  column, June 1 under Monday, June 24 (Wednesday) selected.

Adds a DEBUG-only KISANI_INITIAL_TAB launch env (compiled out of Release) used
to screenshot the calendar past the sign-in gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:24:49 +03:00
kutesir
eeb60dc51b fix(tasks): timed tasks become overdue immediately when time passes (TickTick behavior)
Previous logic compared dueDate against startOfDay for all tasks, so a task
due at 2:00 PM stayed in "Today" until midnight even after the time passed.

TickTick rule:
- hasTime=true  → overdue the moment dueDate < now (wall clock)
- hasTime=false → overdue at start of next day (dueDate < startOfDay)

overdueTasks: now uses `dueDate < now` for timed tasks, `dueDate < today`
for date-only tasks.

todayTasks: now excludes timed tasks whose time has already passed — those
belong in overdue, not today — while keeping date-only tasks in Today for
the full calendar day.

Co-Authored-By: Kutesir <tqwyy79vzn@privaterelay.appleid.com>
2026-06-23 19:00:57 +03:00
12 changed files with 660 additions and 34 deletions

View File

@@ -13,6 +13,7 @@
13E4B9854F595394FC9D5912 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC5E179A9189B0A8C3F856F6 /* ContentView.swift */; }; 13E4B9854F595394FC9D5912 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC5E179A9189B0A8C3F856F6 /* ContentView.swift */; };
1A22EE21460821170E44B1DF /* ExerciseModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5722CC4B59E3939724142710 /* ExerciseModels.swift */; }; 1A22EE21460821170E44B1DF /* ExerciseModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5722CC4B59E3939724142710 /* ExerciseModels.swift */; };
205846F9651EDCE0D8207358 /* TaskLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.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 */; }; 2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF905C574F34B4EE51A8D21E /* AppGroup.swift */; };
32C63D81925FBFE51CAE1FB7 /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; }; 32C63D81925FBFE51CAE1FB7 /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; };
3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01A27D42E141DC056D32C1A3 /* TutorialManager.swift */; }; 3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01A27D42E141DC056D32C1A3 /* TutorialManager.swift */; };
@@ -21,11 +22,13 @@
3E9DE1CF20BAC479805DF940 /* MyTasksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */; }; 3E9DE1CF20BAC479805DF940 /* MyTasksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */; };
45AA93D76970B39DB8BA6A5B /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */; }; 45AA93D76970B39DB8BA6A5B /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */; };
497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */; }; 497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */; };
4FB79BBC1EE0F6C7EF844B02 /* RecurrenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */; };
552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */; }; 552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */; };
55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429806CE1021C8DE2EB770CE /* WidgetViews.swift */; }; 55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429806CE1021C8DE2EB770CE /* WidgetViews.swift */; };
5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */; }; 5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */; };
67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 106EEF572C6F8990408329F0 /* OnboardingView.swift */; }; 67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 106EEF572C6F8990408329F0 /* OnboardingView.swift */; };
6921CB73A3257502FF778381 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */; }; 6921CB73A3257502FF778381 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */; };
6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */; };
703E068364256D6F3F867961 /* ISSUES.md in Resources */ = {isa = PBXBuildFile; fileRef = 92824ED40ECD41EFD4F78BEC /* ISSUES.md */; }; 703E068364256D6F3F867961 /* ISSUES.md in Resources */ = {isa = PBXBuildFile; fileRef = 92824ED40ECD41EFD4F78BEC /* ISSUES.md */; };
754D3DE3CEB998E36E585A61 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0506183945D16EC443A69651 /* LiveActivityManager.swift */; }; 754D3DE3CEB998E36E585A61 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0506183945D16EC443A69651 /* LiveActivityManager.swift */; };
79FC1DF6762C6F02D01AB643 /* KisaniCalWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 79FC1DF6762C6F02D01AB643 /* KisaniCalWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@@ -38,6 +41,7 @@
9070521B1D36A5551976C275 /* CalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF6CCD95A587E26E30F5712 /* CalendarView.swift */; }; 9070521B1D36A5551976C275 /* CalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF6CCD95A587E26E30F5712 /* CalendarView.swift */; };
9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */; }; 9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */; };
A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.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 */; }; A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72308FEE0226F45414C04DDD /* SettingsView.swift */; };
A9FF93259AE8FF0ABF69D71A /* DesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4A35C0E1270E2E15C03F23 /* DesignTokens.swift */; }; A9FF93259AE8FF0ABF69D71A /* DesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4A35C0E1270E2E15C03F23 /* DesignTokens.swift */; };
AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */; }; AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */; };
@@ -53,6 +57,13 @@
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
BF401FDD105729FB67DF02D6 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AF6DE7A812408E3742522E90 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 297C0C2BFBA10AB52D5D49CE;
remoteInfo = KisaniCal;
};
D04FCEE0BDF30AEFD1C969B6 /* PBXContainerItemProxy */ = { D04FCEE0BDF30AEFD1C969B6 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
containerPortal = AF6DE7A812408E3742522E90 /* Project object */; containerPortal = AF6DE7A812408E3742522E90 /* Project object */;
@@ -83,10 +94,12 @@
0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskLiveActivity.swift; sourceTree = "<group>"; }; 0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskLiveActivity.swift; sourceTree = "<group>"; };
0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = "<group>"; }; 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = "<group>"; };
0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = KisaniCalWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; };
106EEF572C6F8990408329F0 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; }; 106EEF572C6F8990408329F0 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; sourceTree = "<group>"; }; 1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; sourceTree = "<group>"; };
208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyTasksWidget.swift; sourceTree = "<group>"; }; 208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyTasksWidget.swift; sourceTree = "<group>"; };
20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutView.swift; sourceTree = "<group>"; }; 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutView.swift; sourceTree = "<group>"; };
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 = "<group>"; }; 23A4491BFA50721082024756 /* SharedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedComponents.swift; sourceTree = "<group>"; };
326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutHandler.swift; sourceTree = "<group>"; }; 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutHandler.swift; sourceTree = "<group>"; };
35FF274EFF2361B7F3D2D227 /* WenzaWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WenzaWatch.entitlements; sourceTree = "<group>"; }; 35FF274EFF2361B7F3D2D227 /* WenzaWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WenzaWatch.entitlements; sourceTree = "<group>"; };
@@ -100,8 +113,10 @@
670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskContextMenu.swift; sourceTree = "<group>"; }; 670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskContextMenu.swift; sourceTree = "<group>"; };
72308FEE0226F45414C04DDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; 72308FEE0226F45414C04DDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
72FDF9C8DD37134576356B89 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 72FDF9C8DD37134576356B89 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarGridTests.swift; sourceTree = "<group>"; };
768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExerciseSheet.swift; sourceTree = "<group>"; }; 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExerciseSheet.swift; sourceTree = "<group>"; };
7A9D47B284FD6A217AEF813B /* WenzaWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WenzaWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; };
89550F2CD19B950CCC6AD37F /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; }; 89550F2CD19B950CCC6AD37F /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; };
8DC8687EA2FBA9FB2EEE51C6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 8DC8687EA2FBA9FB2EEE51C6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCalWidgets.entitlements; sourceTree = "<group>"; }; 8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCalWidgets.entitlements; sourceTree = "<group>"; };
@@ -118,6 +133,7 @@
C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; }; C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
C786EBC7DF879D64EB28165E /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = "<group>"; }; C786EBC7DF879D64EB28165E /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = "<group>"; };
D44530A77DF12A17E52AAF34 /* MatrixView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixView.swift; sourceTree = "<group>"; }; D44530A77DF12A17E52AAF34 /* MatrixView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixView.swift; sourceTree = "<group>"; };
D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurrenceTests.swift; sourceTree = "<group>"; };
DCC2AFB4FA765383740767CB /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = "<group>"; }; DCC2AFB4FA765383740767CB /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = "<group>"; };
ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayMenuFeatures.swift; sourceTree = "<group>"; }; ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayMenuFeatures.swift; sourceTree = "<group>"; };
FA3D5289C5F93484E22DEB63 /* TutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialView.swift; sourceTree = "<group>"; }; FA3D5289C5F93484E22DEB63 /* TutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialView.swift; sourceTree = "<group>"; };
@@ -128,6 +144,15 @@
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
068B77B2F01C399C7A430292 /* KisaniCalTests */ = {
isa = PBXGroup;
children = (
74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */,
D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */,
);
path = KisaniCalTests;
sourceTree = "<group>";
};
21B93C269F283F11B415B18C /* Components */ = { 21B93C269F283F11B415B18C /* Components */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -140,6 +165,7 @@
23CBCF100C5EF55E737379CA /* Models */ = { 23CBCF100C5EF55E737379CA /* Models */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0FCB2077D9509CB5C0978E58 /* CalendarGrid.swift */,
5722CC4B59E3939724142710 /* ExerciseModels.swift */, 5722CC4B59E3939724142710 /* ExerciseModels.swift */,
DCC2AFB4FA765383740767CB /* TaskItem.swift */, DCC2AFB4FA765383740767CB /* TaskItem.swift */,
); );
@@ -160,6 +186,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
F70DA4746C68CD405435DAB6 /* KisaniCal */, F70DA4746C68CD405435DAB6 /* KisaniCal */,
068B77B2F01C399C7A430292 /* KisaniCalTests */,
E8D727EEA0C3A4B8006FB087 /* KisaniCalWidgets */, E8D727EEA0C3A4B8006FB087 /* KisaniCalWidgets */,
48146B56E740528496663D47 /* WenzaWatch */, 48146B56E740528496663D47 /* WenzaWatch */,
51BD1B5DEDE9FAD9CA2FF6DA /* Products */, 51BD1B5DEDE9FAD9CA2FF6DA /* Products */,
@@ -170,6 +197,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */, 9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */,
20E29A9264577A89891DAEC1 /* KisaniCalTests.xctest */,
0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */, 0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */,
7A9D47B284FD6A217AEF813B /* WenzaWatch.app */, 7A9D47B284FD6A217AEF813B /* WenzaWatch.app */,
); );
@@ -256,6 +284,7 @@
0506183945D16EC443A69651 /* LiveActivityManager.swift */, 0506183945D16EC443A69651 /* LiveActivityManager.swift */,
93D045FE3DEB1D22D908A29F /* NotificationManager.swift */, 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */,
326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */, 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */,
86906905A4FA7A2384B3E24E /* SpeechRecognizer.swift */,
FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */, FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */,
01A27D42E141DC056D32C1A3 /* TutorialManager.swift */, 01A27D42E141DC056D32C1A3 /* TutorialManager.swift */,
); );
@@ -319,6 +348,24 @@
productReference = 7A9D47B284FD6A217AEF813B /* WenzaWatch.app */; productReference = 7A9D47B284FD6A217AEF813B /* WenzaWatch.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
75535A64153951A3C35F75FC /* KisaniCalTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = A0CF5E0496CC1077B2417541 /* Build configuration list for PBXNativeTarget "KisaniCalTests" */;
buildPhases = (
474D88E5A3251407875B5C24 /* Sources */,
);
buildRules = (
);
dependencies = (
EEF70F5652B35E046A79A5A3 /* PBXTargetDependency */,
);
name = KisaniCalTests;
packageProductDependencies = (
);
productName = KisaniCalTests;
productReference = 20E29A9264577A89891DAEC1 /* KisaniCalTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@@ -340,6 +387,9 @@
DevelopmentTeam = K8BLMMR883; DevelopmentTeam = K8BLMMR883;
ProvisioningStyle = Automatic; ProvisioningStyle = Automatic;
}; };
75535A64153951A3C35F75FC = {
DevelopmentTeam = K8BLMMR883;
};
}; };
}; };
buildConfigurationList = EF1C4F3BDA4AA7E008FC2CDE /* Build configuration list for PBXProject "KisaniCal" */; buildConfigurationList = EF1C4F3BDA4AA7E008FC2CDE /* Build configuration list for PBXProject "KisaniCal" */;
@@ -357,6 +407,7 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
297C0C2BFBA10AB52D5D49CE /* KisaniCal */, 297C0C2BFBA10AB52D5D49CE /* KisaniCal */,
75535A64153951A3C35F75FC /* KisaniCalTests */,
28B43516AD88946E21D9BFE0 /* KisaniCalWidgets */, 28B43516AD88946E21D9BFE0 /* KisaniCalWidgets */,
403D989BDC6268361CFFB479 /* WenzaWatch */, 403D989BDC6268361CFFB479 /* WenzaWatch */,
); );
@@ -378,6 +429,15 @@
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
474D88E5A3251407875B5C24 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */,
4FB79BBC1EE0F6C7EF844B02 /* RecurrenceTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
6DEFD550940764CA28D993CE /* Sources */ = { 6DEFD550940764CA28D993CE /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -394,6 +454,7 @@
2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */, 2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */,
3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */, 3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */,
ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */, ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */,
A292B0492135BA40F6B6A951 /* CalendarGrid.swift in Sources */,
9070521B1D36A5551976C275 /* CalendarView.swift in Sources */, 9070521B1D36A5551976C275 /* CalendarView.swift in Sources */,
06CA0F336E64D9F6D56F7472 /* CloudSyncManager.swift in Sources */, 06CA0F336E64D9F6D56F7472 /* CloudSyncManager.swift in Sources */,
13E4B9854F595394FC9D5912 /* ContentView.swift in Sources */, 13E4B9854F595394FC9D5912 /* ContentView.swift in Sources */,
@@ -410,6 +471,7 @@
A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */, A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */,
8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */, 8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */,
497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */, 497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */,
2885D000426D063F2125804C /* SpeechRecognizer.swift in Sources */,
6921CB73A3257502FF778381 /* SplashView.swift in Sources */, 6921CB73A3257502FF778381 /* SplashView.swift in Sources */,
32C63D81925FBFE51CAE1FB7 /* TaskActivityAttributes.swift in Sources */, 32C63D81925FBFE51CAE1FB7 /* TaskActivityAttributes.swift in Sources */,
EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */, EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */,
@@ -445,6 +507,11 @@
target = 28B43516AD88946E21D9BFE0 /* KisaniCalWidgets */; target = 28B43516AD88946E21D9BFE0 /* KisaniCalWidgets */;
targetProxy = D04FCEE0BDF30AEFD1C969B6 /* PBXContainerItemProxy */; targetProxy = D04FCEE0BDF30AEFD1C969B6 /* PBXContainerItemProxy */;
}; };
EEF70F5652B35E046A79A5A3 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 297C0C2BFBA10AB52D5D49CE /* KisaniCal */;
targetProxy = BF401FDD105729FB67DF02D6 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
@@ -463,6 +530,8 @@
INFOPLIST_KEY_NSCalendarsUsageDescription = "Wenza shows your calendar events alongside tasks."; INFOPLIST_KEY_NSCalendarsUsageDescription = "Wenza shows your calendar events alongside tasks.";
INFOPLIST_KEY_NSHealthShareUsageDescription = "Wenza reads your steps, energy, heart rate, and workouts to show health stats on your dashboard."; INFOPLIST_KEY_NSHealthShareUsageDescription = "Wenza reads your steps, energy, heart rate, and workouts to show health stats on your dashboard.";
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Wenza saves workouts you complete to the Health app."; INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Wenza saves workouts you complete to the Health app.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Wenza uses your microphone to convert speech into task text.";
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "Wenza uses speech recognition so you can add tasks by voice.";
INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_NSSupportsLiveActivities = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
@@ -481,6 +550,25 @@
}; };
name = Release; name = Release;
}; };
1092C43DF580318C5090D54B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCalTests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.9;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KisaniCal.app/KisaniCal";
};
name = Release;
};
20E23A89279CC26778E21856 /* Release */ = { 20E23A89279CC26778E21856 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -539,6 +627,25 @@
}; };
name = Release; name = Release;
}; };
4F022C5E53C268A49F093011 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCalTests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.9;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KisaniCal.app/KisaniCal";
};
name = Debug;
};
B60AA89AB378D4EA773989AA /* Release */ = { B60AA89AB378D4EA773989AA /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -689,6 +796,8 @@
INFOPLIST_KEY_NSCalendarsUsageDescription = "Wenza shows your calendar events alongside tasks."; INFOPLIST_KEY_NSCalendarsUsageDescription = "Wenza shows your calendar events alongside tasks.";
INFOPLIST_KEY_NSHealthShareUsageDescription = "Wenza reads your steps, energy, heart rate, and workouts to show health stats on your dashboard."; INFOPLIST_KEY_NSHealthShareUsageDescription = "Wenza reads your steps, energy, heart rate, and workouts to show health stats on your dashboard.";
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Wenza saves workouts you complete to the Health app."; INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Wenza saves workouts you complete to the Health app.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Wenza uses your microphone to convert speech into task text.";
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "Wenza uses speech recognition so you can add tasks by voice.";
INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_NSSupportsLiveActivities = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
@@ -728,6 +837,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug; defaultConfigurationName = Debug;
}; };
A0CF5E0496CC1077B2417541 /* Build configuration list for PBXNativeTarget "KisaniCalTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
4F022C5E53C268A49F093011 /* Debug */,
1092C43DF580318C5090D54B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
B60FDD2C88D1D33CF1EC02B3 /* Build configuration list for PBXNativeTarget "KisaniCal" */ = { B60FDD2C88D1D33CF1EC02B3 /* Build configuration list for PBXNativeTarget "KisaniCal" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (

View File

@@ -39,6 +39,17 @@
</BuildableReference> </BuildableReference>
</MacroExpansion> </MacroExpansion>
<Testables> <Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "75535A64153951A3C35F75FC"
BuildableName = "KisaniCalTests.xctest"
BlueprintName = "KisaniCalTests"
ReferencedContainer = "container:KisaniCal.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables> </Testables>
<CommandLineArguments> <CommandLineArguments>
</CommandLineArguments> </CommandLineArguments>

View File

@@ -11,7 +11,12 @@ struct ContentView: View {
@AppStorage("showWorkoutTab") private var showWorkoutTab = true @AppStorage("showWorkoutTab") private var showWorkoutTab = true
@AppStorage("hasOnboarded") private var hasOnboarded = false @AppStorage("hasOnboarded") private var hasOnboarded = false
@State private var showOnboarding = false @State private var showOnboarding = false
@State private var selectedTab = 0 @State private var selectedTab = {
#if DEBUG
if let t = ProcessInfo.processInfo.environment["KISANI_INITIAL_TAB"], let i = Int(t) { return i }
#endif
return 0
}()
@State private var showQuickAddTask = false @State private var showQuickAddTask = false
@StateObject private var tabState = FloatingTabState() @StateObject private var tabState = FloatingTabState()
@ObservedObject private var shortcuts = ShortcutHandler.shared @ObservedObject private var shortcuts = ShortcutHandler.shared

View File

@@ -40,7 +40,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
intentIdentifiers: [], options: .customDismissAction) intentIdentifiers: [], options: .customDismissAction)
let logAction = UNNotificationAction(identifier: Self.logWorkoutActId, let logAction = UNNotificationAction(identifier: Self.logWorkoutActId,
title: "Yes, I completed it", options: []) title: "Yes, I completed it", options: [])
let missedAction = UNNotificationAction(identifier: Self.missedWorkoutActId, let missedAction = UNNotificationAction(identifier: Self.missedWorkoutActId,
title: "No, I didn't", options: []) title: "No, I didn't", options: [])
let snoozeAction = UNNotificationAction(identifier: Self.snoozeWorkoutActId, let snoozeAction = UNNotificationAction(identifier: Self.snoozeWorkoutActId,
@@ -191,7 +191,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|| settings.authorizationStatus == .provisional else { return } || settings.authorizationStatus == .provisional else { return }
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = title content.title = title
content.body = nextOccurrence.map { "Done. Repeats \(Self.relativeRepeat(to: $0))." } ?? "Done." content.body = nextOccurrence.map { "Done. Repeats \(Self.relativeRepeat(to: $0))." } ?? "Done."
content.sound = nil // quiet confirmation, not an alert content.sound = nil // quiet confirmation, not an alert
self.center.add(UNNotificationRequest( self.center.add(UNNotificationRequest(
identifier: "kisani.recurdone.\(UUID().uuidString)", identifier: "kisani.recurdone.\(UUID().uuidString)",

View File

@@ -0,0 +1,140 @@
import Foundation
import Speech
import AVFoundation
@MainActor
final class SpeechRecognizer: ObservableObject {
@Published var transcript = ""
@Published var isRecording = false
@Published var error: String?
private var recognizer: SFSpeechRecognizer?
private var audioEngine = AVAudioEngine()
private var request: SFSpeechAudioBufferRecognitionRequest?
private var task: SFSpeechRecognitionTask?
init() {
recognizer = SFSpeechRecognizer(locale: .current)
}
func toggle() {
isRecording ? stop() : start()
}
func start() {
guard !isRecording else { return }
error = nil
print("[VOICE] Button tapped")
SFSpeechRecognizer.requestAuthorization { [weak self] status in
DispatchQueue.main.async {
guard let self else { return }
switch status {
case .authorized:
print("[VOICE] Permission granted — speech recognition")
self.requestMicThenRecord()
case .denied, .restricted:
self.error = "Speech recognition access denied. Enable it in Settings → Wenza."
print("[VOICE] Permission denied — speech recognition")
case .notDetermined:
self.error = "Speech recognition permission not yet determined."
print("[VOICE] Permission not determined")
@unknown default: break
}
}
}
}
private func requestMicThenRecord() {
AVAudioSession.sharedInstance().requestRecordPermission { [weak self] granted in
DispatchQueue.main.async {
guard let self else { return }
if granted {
print("[VOICE] Permission granted — microphone")
self.startEngine()
} else {
self.error = "Microphone access denied. Enable it in Settings → Wenza."
print("[VOICE] Permission denied — microphone")
}
}
}
}
private func startEngine() {
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.record, mode: .measurement, options: .duckOthers)
try session.setActive(true, options: .notifyOthersOnDeactivation)
guard let recognizer, recognizer.isAvailable else {
error = "Speech recognition is not available right now."
print("[VOICE] Recognizer unavailable")
return
}
let req = SFSpeechAudioBufferRecognitionRequest()
req.shouldReportPartialResults = true
self.request = req
task = recognizer.recognitionTask(with: req) { [weak self] result, err in
DispatchQueue.main.async {
guard let self else { return }
if let result {
let text = result.bestTranscription.formattedString
self.transcript = text
print("[VOICE] Transcript received: \"\(text)\"")
}
if result?.isFinal == true {
print("[VOICE] Recognition finished (final)")
self.stopEngine()
} else if let err {
let code = (err as NSError).code
// Code 301 = "no speech" not an error worth surfacing
if code != 301 {
print("[VOICE] Recognition error \(code): \(err.localizedDescription)")
}
}
}
}
let node = audioEngine.inputNode
let fmt = node.outputFormat(forBus: 0)
node.installTap(onBus: 0, bufferSize: 1024, format: fmt) { [weak self] buf, _ in
self?.request?.append(buf)
}
audioEngine.prepare()
try audioEngine.start()
isRecording = true
print("[VOICE] Recording started")
} catch {
self.error = "Could not start recording."
print("[VOICE] Engine start failed: \(error)")
stopEngine()
}
}
func stop() {
print("[VOICE] Recording stopped")
stopEngine()
}
private func stopEngine() {
guard audioEngine.isRunning || task != nil else { return }
audioEngine.stop()
audioEngine.inputNode.removeTap(onBus: 0)
request?.endAudio()
task?.finish()
request = nil
task = nil
isRecording = false
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
func reset() {
stopEngine()
transcript = ""
error = nil
}
}

View File

@@ -0,0 +1,37 @@
import Foundation
/// Pure, testable calendar-grid math. Kept free of SwiftUI/state so the month-grid
/// generation and weekday header can be unit-tested directly.
///
/// Correctness contract: every cell is a real `Date`, columns respect the supplied
/// calendar's `firstWeekday`, and the weekday header is derived from the SAME
/// `firstWeekday` so the two can never drift out of alignment.
enum CalendarGrid {
/// The days to render for the month containing `date`, including the leading days
/// from the previous month and trailing days from the next month needed to fill
/// complete weeks. Column placement follows `calendar.firstWeekday`.
static func monthGrid(for date: Date, calendar: Calendar) -> [Date] {
guard
let interval = calendar.dateInterval(of: .month, for: date),
let firstWeek = calendar.dateInterval(of: .weekOfMonth, for: interval.start),
let lastWeek = calendar.dateInterval(of: .weekOfMonth, for: interval.end - 1)
else { return [] }
var days: [Date] = []
var cur = firstWeek.start
while cur < lastWeek.end {
days.append(cur)
guard let next = calendar.date(byAdding: .day, value: 1, to: cur) else { break }
cur = next
}
return days
}
/// Single-letter weekday header rotated to the calendar's `firstWeekday`.
/// Sunday-first ["S","M","T","W","T","F","S"]; Monday-first ["M","T","W","T","F","S","S"].
static func weekdaySymbols(calendar: Calendar) -> [String] {
let syms = ["S", "M", "T", "W", "T", "F", "S"] // index 0 = Sunday
let first = calendar.firstWeekday - 1 // firstWeekday is 1...7 (1 = Sunday)
return (0..<7).map { syms[(first + $0) % 7] }
}
}

View File

@@ -238,12 +238,29 @@ class TaskViewModel: ObservableObject {
} }
var overdueTasks: [TaskItem] { var overdueTasks: [TaskItem] {
let today = Calendar.current.startOfDay(for: Date()) let now = Date()
// Non-recurring past-due only recurring surfaces its next occurrence instead. let today = Calendar.current.startOfDay(for: now)
return tasks.filter { !recurs($0) && !$0.isComplete && ($0.dueDate ?? .distantFuture) < today } // TickTick rule: timed tasks become overdue the moment the clock passes;
// date-only tasks become overdue at the start of the next day (midnight).
return tasks.filter {
guard !recurs($0) && !$0.isComplete, let due = $0.dueDate else { return false }
return $0.hasTime ? due < now : due < today
}
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
}
var todayTasks: [TaskItem] {
let now = Date()
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.
return activeRows().filter {
guard let d = $0.dueDate else { return false }
if $0.hasTime && d < now { return false }
return d >= start && d < end
}
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
} }
var todayTasks: [TaskItem] { bucket(from: 0, to: 1) } // due today
var tomorrowTasks: [TaskItem] { bucket(from: 1, to: 2) } // due tomorrow var tomorrowTasks: [TaskItem] { bucket(from: 1, to: 2) } // due tomorrow
var next7DaysTasks: [TaskItem] { bucket(from: 2, to: 8) } // due in 27 days var next7DaysTasks: [TaskItem] { bucket(from: 2, to: 8) } // due in 27 days
var laterTasks: [TaskItem] { bucket(from: 8, to: nil) } // beyond 7 days var laterTasks: [TaskItem] { bucket(from: 8, to: nil) } // beyond 7 days

View File

@@ -301,7 +301,11 @@ struct CalendarView: View {
@State private var editingTask: TaskItem? = nil @State private var editingTask: TaskItem? = nil
private let cal = Calendar.current private let cal = Calendar.current
private let weekdays = ["S","M","T","W","T","F","S"] /// Weekday header derived from the calendar's `firstWeekday` so it ALWAYS matches
/// the grid (which is built via `.weekOfMonth`, also respecting `firstWeekday`).
/// A hardcoded Sunday-first header mislabels every column on Monday-first locales
/// (e.g. June 1 2026, a Monday, would appear under "S"). See CalendarGrid.
private var weekdays: [String] { CalendarGrid.weekdaySymbols(calendar: cal) }
var body: some View { var body: some View {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
@@ -447,17 +451,7 @@ struct CalendarView: View {
} }
private func gridDays() -> [Date] { private func gridDays() -> [Date] {
guard CalendarGrid.monthGrid(for: displayMonth, calendar: cal)
let interval = cal.dateInterval(of: .month, for: displayMonth),
let firstWeek = cal.dateInterval(of: .weekOfMonth, for: interval.start),
let lastWeek = cal.dateInterval(of: .weekOfMonth, for: interval.end - 1)
else { return [] }
var days: [Date] = []; var cur = firstWeek.start
while cur < lastWeek.end {
days.append(cur)
cur = cal.date(byAdding: .day, value: 1, to: cur)!
}
return days
} }
private func eventDots(for date: Date) -> [Color] { private func eventDots(for date: Date) -> [Color] {
@@ -746,13 +740,7 @@ struct CalendarView: View {
} }
private func cvMiniGridDays(_ month: Date) -> [Date] { private func cvMiniGridDays(_ month: Date) -> [Date] {
guard let interval = cal.dateInterval(of: .month, for: month), CalendarGrid.monthGrid(for: month, calendar: cal)
let firstWeek = cal.dateInterval(of: .weekOfMonth, for: interval.start),
let lastWeek = cal.dateInterval(of: .weekOfMonth, for: interval.end - 1)
else { return [] }
var days: [Date] = []; var cur = firstWeek.start
while cur < lastWeek.end { days.append(cur); cur = cal.date(byAdding: .day, value: 1, to: cur)! }
return days
} }
// MARK: - Week // MARK: - Week
@@ -1586,8 +1574,12 @@ struct DayCell: View {
let date: Date; let isCurrentMonth: Bool; let isToday: Bool; let isSelected: Bool; let dots: [Color] let date: Date; let isCurrentMonth: Bool; let isToday: Bool; let isSelected: Bool; let dots: [Color]
private let cal = Calendar.current private let cal = Calendar.current
var dayNum: String { "\(cal.component(.day, from: date))" } var dayNum: String { "\(cal.component(.day, from: date))" }
/// Locale week-of-year (NOT ISO), intentionally matching the locale-driven grid
/// layout/`firstWeekday`. Each row shares one weekOfYear, so this is stable per row.
var weekNum: Int { cal.component(.weekOfYear, from: date) } var weekNum: Int { cal.component(.weekOfYear, from: date) }
var isSunday: Bool { cal.component(.weekday, from: date) == 1 } /// True on the first column of the week for the *current* locale, so the week-number
/// label lands on the leading cell regardless of firstWeekday (not hardcoded Sunday).
var isWeekStart: Bool { cal.component(.weekday, from: date) == cal.firstWeekday }
var body: some View { var body: some View {
VStack(spacing: 2) { VStack(spacing: 2) {
@@ -1600,7 +1592,7 @@ struct DayCell: View {
} }
.frame(width: 26, height: 26) .frame(width: 26, height: 26)
.overlay(alignment: .topLeading) { .overlay(alignment: .topLeading) {
if isSunday { if isWeekStart {
Text("W\(weekNum)").font(AppFonts.mono(6.5)).foregroundColor(AppColors.text3(cs)).offset(x: -2, y: -10) Text("W\(weekNum)").font(AppFonts.mono(6.5)).foregroundColor(AppColors.text3(cs)).offset(x: -2, y: -10)
} }
} }

View File

@@ -1814,6 +1814,7 @@ struct AddTaskSheet: View {
@State private var reminderDate: Date? = nil @State private var reminderDate: Date? = nil
@State private var constantReminder = false @State private var constantReminder = false
@State private var showDatePicker = false @State private var showDatePicker = false
@StateObject private var speech = SpeechRecognizer()
var prefilledDate: Date? var prefilledDate: Date?
var prefilledQuadrant: Quadrant var prefilledQuadrant: Quadrant
@@ -1849,6 +1850,11 @@ struct AddTaskSheet: View {
if p.isBirthday { selectedCategory = .birthday } if p.isBirthday { selectedCategory = .birthday }
} }
} }
.onChange(of: speech.transcript) { text in
guard !text.isEmpty else { return }
rawText = text
print("[VOICE] Transcript bound to task field")
}
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.top, 14) .padding(.top, 14)
.padding(.bottom, 10) .padding(.bottom, 10)
@@ -1947,10 +1953,16 @@ struct AddTaskSheet: View {
.buttonStyle(PressButtonStyle(scale: 0.90)) .buttonStyle(PressButtonStyle(scale: 0.90))
.transition(.scale(scale: 0.5).combined(with: .opacity)) .transition(.scale(scale: 0.5).combined(with: .opacity))
} else { } else {
Image(systemName: "mic") Button { speech.toggle() } label: {
Image(systemName: speech.isRecording ? "mic.fill" : "mic")
.font(.system(size: 18)) .font(.system(size: 18))
.foregroundColor(AppColors.text3(cs)) .foregroundColor(speech.isRecording ? AppColors.accent : AppColors.text3(cs))
.frame(width: 42, height: 44) .frame(width: 42, height: 44)
.scaleEffect(speech.isRecording ? 1.15 : 1.0)
.animation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true),
value: speech.isRecording)
}
.buttonStyle(.plain)
.transition(.scale(scale: 0.5).combined(with: .opacity)) .transition(.scale(scale: 0.5).combined(with: .opacity))
} }
} }
@@ -1959,6 +1971,23 @@ struct AddTaskSheet: View {
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 4) .padding(.vertical, 4)
} }
.safeAreaInset(edge: .top, spacing: 0) {
if let err = speech.error {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 13))
Text(err)
.font(AppFonts.sans(13, weight: .medium))
}
.foregroundColor(.white)
.padding(.horizontal, 14).padding(.vertical, 8)
.background(Color.red.opacity(0.85))
.clipShape(Capsule())
.padding(.top, 8)
.onTapGesture { speech.error = nil }
}
}
.onDisappear { speech.reset() }
.background(AppColors.surface(cs).ignoresSafeArea()) .background(AppColors.surface(cs).ignoresSafeArea())
.sheet(isPresented: $showDatePicker) { .sheet(isPresented: $showDatePicker) {
TaskDatePickerSheet( TaskDatePickerSheet(
@@ -1977,6 +2006,7 @@ struct AddTaskSheet: View {
private func submit() { private func submit() {
guard canAdd else { return } guard canAdd else { return }
if speech.isRecording { speech.stop() }
taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime, taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime,
quadrant: prefilledQuadrant, category: selectedCategory, quadrant: prefilledQuadrant, category: selectedCategory,
isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel, isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel,
@@ -1984,6 +2014,7 @@ struct AddTaskSheet: View {
priority: selectedPriority, priority: selectedPriority,
reminderDate: reminderDate, constantReminder: constantReminder, reminderDate: reminderDate, constantReminder: constantReminder,
recurrenceEnd: parsed.recurrenceEnd) recurrenceEnd: parsed.recurrenceEnd)
print("[VOICE] Task saved: \"\(cleanTitle)\"")
dismiss() dismiss()
} }
} }

View File

@@ -0,0 +1,158 @@
import XCTest
@testable import KisaniCal
/// Calendar correctness tests. These treat the month grid as a data-integrity concern:
/// every cell must be a real date, columns must respect firstWeekday, and the weekday
/// header must match the grid for any firstWeekday and timezone.
final class CalendarGridTests: XCTestCase {
private func makeCalendar(firstWeekday: Int, tz: String) -> Calendar {
var c = Calendar(identifier: .gregorian)
c.timeZone = TimeZone(identifier: tz)!
c.firstWeekday = firstWeekday
return c
}
private func iso(_ date: Date, _ cal: Calendar) -> String {
let c = cal.dateComponents([.year, .month, .day], from: date)
return String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!)
}
private func date(_ y: Int, _ m: Int, _ d: Int, _ cal: Calendar) -> Date {
cal.date(from: DateComponents(year: y, month: m, day: d))!
}
private let timezones = ["America/New_York", "Europe/London", "Pacific/Kiritimati", "UTC"]
// MARK: - Ground-truth weekday facts
func testJune2026WeekdayFacts() {
for tz in timezones {
let cal = makeCalendar(firstWeekday: 1, tz: tz)
let f = DateFormatter(); f.calendar = cal; f.timeZone = cal.timeZone; f.dateFormat = "EEEE"
XCTAssertEqual(f.string(from: date(2026, 6, 1, cal)), "Monday", tz)
XCTAssertEqual(f.string(from: date(2026, 6, 7, cal)), "Sunday", tz)
XCTAssertEqual(f.string(from: date(2026, 6, 24, cal)), "Wednesday", tz)
XCTAssertEqual(f.string(from: date(2026, 6, 27, cal)), "Saturday", tz)
XCTAssertEqual(f.string(from: date(2026, 6, 30, cal)), "Tuesday", tz)
XCTAssertEqual(f.string(from: date(2026, 7, 1, cal)), "Wednesday", tz)
}
}
// MARK: - Sunday-first June 2026 grid matches the exact spec
func testSundayFirstJune2026GridMatchesSpec() {
let expected = [
"2026-05-31","2026-06-01","2026-06-02","2026-06-03","2026-06-04","2026-06-05","2026-06-06",
"2026-06-07","2026-06-08","2026-06-09","2026-06-10","2026-06-11","2026-06-12","2026-06-13",
"2026-06-14","2026-06-15","2026-06-16","2026-06-17","2026-06-18","2026-06-19","2026-06-20",
"2026-06-21","2026-06-22","2026-06-23","2026-06-24","2026-06-25","2026-06-26","2026-06-27",
"2026-06-28","2026-06-29","2026-06-30","2026-07-01","2026-07-02","2026-07-03","2026-07-04",
]
for tz in timezones {
let cal = makeCalendar(firstWeekday: 1, tz: tz)
let grid = CalendarGrid.monthGrid(for: date(2026, 6, 15, cal), calendar: cal)
XCTAssertEqual(grid.map { iso($0, cal) }, expected, "Sunday-first grid wrong in \(tz)")
XCTAssertEqual(grid.count % 7, 0, "grid must be whole weeks")
}
}
// MARK: - Header always matches grid (the actual bug)
func testHeaderMatchesGridFirstColumnForAnyFirstWeekday() {
for tz in timezones {
for fw in 1...7 {
let cal = makeCalendar(firstWeekday: fw, tz: tz)
let grid = CalendarGrid.monthGrid(for: date(2026, 6, 15, cal), calendar: cal)
let header = CalendarGrid.weekdaySymbols(calendar: cal)
let letters = ["S","M","T","W","T","F","S"] // weekday 1...7
for col in 0..<7 {
let weekday = cal.component(.weekday, from: grid[col]) // 1=Sun..7=Sat
XCTAssertEqual(header[col], letters[weekday - 1],
"col \(col) header \(header[col]) != grid weekday \(weekday) (fw=\(fw), tz=\(tz))")
}
}
}
}
func testHeaderRotations() {
let sun = makeCalendar(firstWeekday: 1, tz: "UTC")
let mon = makeCalendar(firstWeekday: 2, tz: "UTC")
XCTAssertEqual(CalendarGrid.weekdaySymbols(calendar: sun), ["S","M","T","W","T","F","S"])
XCTAssertEqual(CalendarGrid.weekdaySymbols(calendar: mon), ["M","T","W","T","F","S","S"])
}
// MARK: - Cell date identity
func testCellIdentityIsRealDate() {
let cal = makeCalendar(firstWeekday: 1, tz: "America/New_York")
let grid = CalendarGrid.monthGrid(for: date(2026, 6, 1, cal), calendar: cal)
// June 24 must be a Wednesday and the 25th element (index 24) in the spec grid.
let june24 = grid.first { cal.isDate($0, inSameDayAs: date(2026, 6, 24, cal)) }
XCTAssertNotNil(june24)
XCTAssertEqual(cal.component(.weekday, from: june24!), 4) // Wednesday
// No duplicate dates, strictly increasing by one day.
for i in 1..<grid.count {
XCTAssertEqual(cal.dateComponents([.day], from: grid[i-1], to: grid[i]).day, 1)
}
}
// MARK: - Month navigation
func testMonthNavigationBoundaries() {
let cal = makeCalendar(firstWeekday: 1, tz: "UTC")
// Dec 2026 -> trails into Jan 2027
let dec = CalendarGrid.monthGrid(for: date(2026, 12, 10, cal), calendar: cal).map { iso($0, cal) }
XCTAssertTrue(dec.contains("2027-01-01"))
XCTAssertEqual(dec.first, "2026-11-29")
// Feb 2026 (non-leap): contains Feb 28, not Feb 29
let feb26 = CalendarGrid.monthGrid(for: date(2026, 2, 10, cal), calendar: cal).map { iso($0, cal) }
XCTAssertTrue(feb26.contains("2026-02-28"))
XCTAssertFalse(feb26.contains("2026-02-29"))
// Feb 2028 (leap): contains Feb 29
let feb28 = CalendarGrid.monthGrid(for: date(2028, 2, 10, cal), calendar: cal).map { iso($0, cal) }
XCTAssertTrue(feb28.contains("2028-02-29"))
// May & July 2026 endpoints
let may = CalendarGrid.monthGrid(for: date(2026, 5, 10, cal), calendar: cal).map { iso($0, cal) }
XCTAssertTrue(may.contains("2026-05-01") && may.contains("2026-05-31"))
let jul = CalendarGrid.monthGrid(for: date(2026, 7, 10, cal), calendar: cal).map { iso($0, cal) }
XCTAssertTrue(jul.contains("2026-07-01") && jul.contains("2026-07-31"))
}
// MARK: - Event-day bucketing (same logic CalendarStore.events(for:) uses)
/// Mirrors CalendarStore.events(for:) overlap test: s < endOfDay && e > startOfDay.
private func overlaps(start: Date, end: Date, day: Date, cal: Calendar) -> Bool {
let dayStart = cal.startOfDay(for: day)
let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart)!
return start < dayEnd && end > dayStart
}
func testAllDayEventMapsToSingleLocalDay() {
let cal = makeCalendar(firstWeekday: 1, tz: "America/New_York")
// All-day June 24: [Jun24 00:00, Jun25 00:00)
let s = cal.startOfDay(for: date(2026, 6, 24, cal))
let e = cal.date(byAdding: .day, value: 1, to: s)!
XCTAssertTrue(overlaps(start: s, end: e, day: date(2026, 6, 24, cal), cal: cal))
XCTAssertFalse(overlaps(start: s, end: e, day: date(2026, 6, 23, cal), cal: cal))
XCTAssertFalse(overlaps(start: s, end: e, day: date(2026, 6, 25, cal), cal: cal))
}
func testLateNightTimedEventStaysOnLocalDay() {
let cal = makeCalendar(firstWeekday: 1, tz: "America/New_York")
// 11:30pm11:45pm local on June 24 must not bleed into the 25th.
let s = cal.date(from: DateComponents(year: 2026, month: 6, day: 24, hour: 23, minute: 30))!
let e = cal.date(from: DateComponents(year: 2026, month: 6, day: 24, hour: 23, minute: 45))!
XCTAssertTrue(overlaps(start: s, end: e, day: date(2026, 6, 24, cal), cal: cal))
XCTAssertFalse(overlaps(start: s, end: e, day: date(2026, 6, 25, cal), cal: cal))
}
func testEventCrossingMidnightHitsBothDays() {
let cal = makeCalendar(firstWeekday: 1, tz: "America/New_York")
let s = cal.date(from: DateComponents(year: 2026, month: 6, day: 24, hour: 23, minute: 30))!
let e = cal.date(from: DateComponents(year: 2026, month: 6, day: 25, hour: 0, minute: 30))!
XCTAssertTrue(overlaps(start: s, end: e, day: date(2026, 6, 24, cal), cal: cal))
XCTAssertTrue(overlaps(start: s, end: e, day: date(2026, 6, 25, cal), cal: cal))
XCTAssertFalse(overlaps(start: s, end: e, day: date(2026, 6, 23, cal), cal: cal))
}
}

View File

@@ -0,0 +1,100 @@
import XCTest
@testable import KisaniCal
/// Verifies recurring-task occurrence expansion maps to the correct real calendar
/// days (the calendar's event-date mapping for tasks). Uses Calendar.current the
/// same calendar the production code uses so assertions hold in any timezone.
@MainActor
final class RecurrenceTests: XCTestCase {
private let cal = Calendar.current
private func day(_ y: Int, _ m: Int, _ d: Int, h: Int = 0, min: Int = 0) -> Date {
cal.date(from: DateComponents(year: y, month: m, day: d, hour: h, minute: min))!
}
private func vm(with tasks: [TaskItem]) -> TaskViewModel {
let v = TaskViewModel()
v.tasks = tasks
return v
}
private func task(_ label: String, start: Date, hasTime: Bool = false, end: Date? = nil) -> TaskItem {
var t = TaskItem(title: "T-\(label)", dueDate: start)
t.isRecurring = true
t.recurrenceLabel = label
t.hasTime = hasTime
t.recurrenceEnd = end
return t
}
private func hasOccurrence(_ v: TaskViewModel, _ id: UUID, on date: Date) -> Bool {
v.occurrences(on: date).contains { $0.id == id }
}
func testDailyExpandsEveryDayFromStart() {
let t = task("Daily", start: day(2026, 6, 1))
let v = vm(with: [t])
XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 5, 31)), "no occurrence before start")
XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 1)))
XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 24)))
}
func testWeeklyHitsEverySevenDaysOnly() {
let t = task("Weekly", start: day(2026, 6, 1)) // Monday
let v = vm(with: [t])
XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 8)))
XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 22)))
XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 9)), "off-cadence day must not match")
XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 24)))
}
func testEveryWeekdaySkipsWeekend() {
let t = task("Every Weekday", start: day(2026, 6, 1))
let v = vm(with: [t])
XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 24)), "Wed is a weekday")
XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 27)), "Sat excluded")
XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 28)), "Sun excluded")
}
func testMonthlyMatchesDayOfMonth() {
let t = task("Monthly", start: day(2026, 1, 15))
let v = vm(with: [t])
XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 15)))
XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 16)))
}
func testYearlyMatchesMonthAndDay() {
let t = task("Yearly", start: day(2025, 6, 24))
let v = vm(with: [t])
XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 24)))
XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 23)))
}
func testRecurrenceEndStopsExpansion() {
let t = task("Daily", start: day(2026, 6, 1), end: day(2026, 6, 10))
let v = vm(with: [t])
XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 10)), "inclusive of end day")
XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 11)), "past repeat-until")
XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 24)))
}
func testOccurrenceCopyPreservesTimeOnTargetLocalDay() {
let t = task("Daily", start: day(2026, 6, 1, h: 9, min: 30), hasTime: true)
let v = vm(with: [t])
let occ = v.occurrences(on: day(2026, 6, 24)).first { $0.id == t.id }
XCTAssertNotNil(occ)
XCTAssertTrue(cal.isDate(occ!.dueDate!, inSameDayAs: day(2026, 6, 24)), "mapped to June 24")
XCTAssertEqual(cal.component(.hour, from: occ!.dueDate!), 9)
XCTAssertEqual(cal.component(.minute, from: occ!.dueDate!), 30)
}
func testNonRecurringTaskAppearsOnlyOnItsDay() {
var t = TaskItem(title: "Dinner with Jay", dueDate: day(2026, 6, 24))
t.isRecurring = false
let v = vm(with: [t])
XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 24)))
XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 23)))
XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 25)))
}
}

View File

@@ -21,6 +21,8 @@ schemes:
config: Debug config: Debug
test: test:
config: Debug config: Debug
targets:
- KisaniCalTests
profile: profile:
config: Release config: Release
analyze: analyze:
@@ -64,6 +66,21 @@ targets:
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription: "Wenza shows your calendar events alongside tasks." INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription: "Wenza shows your calendar events alongside tasks."
INFOPLIST_KEY_NSHealthShareUsageDescription: "Wenza reads your steps, energy, heart rate, and workouts to show health stats on your dashboard." INFOPLIST_KEY_NSHealthShareUsageDescription: "Wenza reads your steps, energy, heart rate, and workouts to show health stats on your dashboard."
INFOPLIST_KEY_NSHealthUpdateUsageDescription: "Wenza saves workouts you complete to the Health app." INFOPLIST_KEY_NSHealthUpdateUsageDescription: "Wenza saves workouts you complete to the Health app."
INFOPLIST_KEY_NSMicrophoneUsageDescription: "Wenza uses your microphone to convert speech into task text."
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription: "Wenza uses speech recognition so you can add tasks by voice."
KisaniCalTests:
type: bundle.unit-test
platform: iOS
deploymentTarget: "16.0"
sources:
- path: KisaniCalTests
dependencies:
- target: KisaniCal
settings:
base:
SWIFT_VERSION: 5.9
GENERATE_INFOPLIST_FILE: YES
KisaniCalWidgets: KisaniCalWidgets:
type: app-extension type: app-extension