Today menu features, Matrix Move trim, widget auto-advance, Wenza logo (KC-23–26)

- Today ⋯ menu: implemented the placeholder actions.
  - Background: 5 selectable themes (Default/Warm/Cool/Mono/Paper), persisted.
  - Group & Sort: group by Date (default)/Priority/Category/Quadrant and sort by
    Smart/Due/Priority/Title; non-date groupings render via UpcomingSection.
  - Select: multi-select sheet with Select All/Deselect All and bulk
    Complete/Priority/Move/Delete.
  New isolated file TodayMenuFeatures.swift; added TaskViewModel.allActiveRows.
- Matrix "Move" submenu now lists only the other three quadrants (excludes the
  current one) using clear matrix labels.
- Event Countdown (Bars) widget auto-advances: each timeline entry re-resolves
  "nearest to finish" as of its own time and refreshes at the moment the current
  event expires, so it rolls to the next event on expiry/completion.
- Splash wordmark rebranded to "wenza." (regular-weight typewriter + orange dot).
- ISSUES.md: logged KC-23 (Pick Date + time-based reminders), KC-24 (Wenza
  rebrand), KC-25 (Move trim), KC-26 (widget auto-advance).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kutesir
2026-06-15 19:53:14 +03:00
parent 81813530c8
commit 87c999c676
9 changed files with 565 additions and 58 deletions

View File

@@ -30,6 +30,7 @@
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, ); }; };
7CEBC340BFA9238D121946AC /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */; }; 7CEBC340BFA9238D121946AC /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */; };
83EA218392952885C97144D1 /* TodayMenuFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */; };
8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23A4491BFA50721082024756 /* SharedComponents.swift */; }; 8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23A4491BFA50721082024756 /* SharedComponents.swift */; };
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3D5289C5F93484E22DEB63 /* TutorialView.swift */; }; 8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3D5289C5F93484E22DEB63 /* TutorialView.swift */; };
8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; }; 8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; };
@@ -80,7 +81,7 @@
0506183945D16EC443A69651 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; }; 0506183945D16EC443A69651 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
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; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; 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; };
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>"; };
@@ -104,7 +105,7 @@
9100804DB1E61EA882CC54DA /* FloatingTabState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingTabState.swift; sourceTree = "<group>"; }; 9100804DB1E61EA882CC54DA /* FloatingTabState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingTabState.swift; sourceTree = "<group>"; };
92824ED40ECD41EFD4F78BEC /* ISSUES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ISSUES.md; sourceTree = "<group>"; }; 92824ED40ECD41EFD4F78BEC /* ISSUES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ISSUES.md; sourceTree = "<group>"; };
93D045FE3DEB1D22D908A29F /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = "<group>"; }; 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = "<group>"; };
9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KisaniCal.app; sourceTree = BUILT_PRODUCTS_DIR; }; 9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = KisaniCal.app; sourceTree = BUILT_PRODUCTS_DIR; };
ADF6CCD95A587E26E30F5712 /* CalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarView.swift; sourceTree = "<group>"; }; ADF6CCD95A587E26E30F5712 /* CalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarView.swift; sourceTree = "<group>"; };
AF905C574F34B4EE51A8D21E /* AppGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroup.swift; sourceTree = "<group>"; }; AF905C574F34B4EE51A8D21E /* AppGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroup.swift; sourceTree = "<group>"; };
BC5E179A9189B0A8C3F856F6 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; BC5E179A9189B0A8C3F856F6 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
@@ -114,6 +115,7 @@
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>"; };
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>"; };
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>"; };
FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventCountdownWidget.swift; sourceTree = "<group>"; }; FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventCountdownWidget.swift; sourceTree = "<group>"; };
FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskActivityAttributes.swift; sourceTree = "<group>"; }; FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskActivityAttributes.swift; sourceTree = "<group>"; };
@@ -169,6 +171,7 @@
72308FEE0226F45414C04DDD /* SettingsView.swift */, 72308FEE0226F45414C04DDD /* SettingsView.swift */,
BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */, BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */,
670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */, 670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */,
ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */,
C786EBC7DF879D64EB28165E /* TodayView.swift */, C786EBC7DF879D64EB28165E /* TodayView.swift */,
FA3D5289C5F93484E22DEB63 /* TutorialView.swift */, FA3D5289C5F93484E22DEB63 /* TutorialView.swift */,
20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */, 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */,
@@ -289,7 +292,7 @@
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
BuildIndependentTargetsInParallel = YES; BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 2620; LastUpgradeCheck = 1500;
TargetAttributes = { TargetAttributes = {
28B43516AD88946E21D9BFE0 = { 28B43516AD88946E21D9BFE0 = {
DevelopmentTeam = K8BLMMR883; DevelopmentTeam = K8BLMMR883;
@@ -364,6 +367,7 @@
32C63D81925FBFE51CAE1FB7 /* TaskActivityAttributes.swift in Sources */, 32C63D81925FBFE51CAE1FB7 /* TaskActivityAttributes.swift in Sources */,
EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */, EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */,
096804560A2F0A7D74E64780 /* TaskItem.swift in Sources */, 096804560A2F0A7D74E64780 /* TaskItem.swift in Sources */,
83EA218392952885C97144D1 /* TodayMenuFeatures.swift in Sources */,
C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */, C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */,
3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */, 3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */,
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */, 8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */,
@@ -469,7 +473,6 @@
DEVELOPMENT_TEAM = K8BLMMR883; DEVELOPMENT_TEAM = K8BLMMR883;
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@@ -484,7 +487,6 @@
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -500,11 +502,7 @@
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = KisaniCalWidgets/Info.plist; INFOPLIST_FILE = KisaniCalWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.0; IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets; PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_VERSION = 5.9; SWIFT_VERSION = 5.9;
@@ -551,7 +549,6 @@
DEVELOPMENT_TEAM = K8BLMMR883; DEVELOPMENT_TEAM = K8BLMMR883;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO; GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
@@ -573,7 +570,6 @@
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -589,11 +585,7 @@
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = KisaniCalWidgets/Info.plist; INFOPLIST_FILE = KisaniCalWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.0; IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets; PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_VERSION = 5.9; SWIFT_VERSION = 5.9;

View File

@@ -1,10 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "2620" LastUpgradeVersion = "1500"
version = "1.3"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"> buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"
@@ -26,7 +27,8 @@
buildConfiguration = "Debug" buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"> shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion> <MacroExpansion>
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
@@ -38,6 +40,8 @@
</MacroExpansion> </MacroExpansion>
<Testables> <Testables>
</Testables> </Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction> </TestAction>
<LaunchAction <LaunchAction
buildConfiguration = "Debug" buildConfiguration = "Debug"
@@ -59,6 +63,8 @@
ReferencedContainer = "container:KisaniCal.xcodeproj"> ReferencedContainer = "container:KisaniCal.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Release" buildConfiguration = "Release"
@@ -76,6 +82,8 @@
ReferencedContainer = "container:KisaniCal.xcodeproj"> ReferencedContainer = "container:KisaniCal.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction> </ProfileAction>
<AnalyzeAction <AnalyzeAction
buildConfiguration = "Debug"> buildConfiguration = "Debug">

View File

@@ -751,8 +751,126 @@ Files: `TaskContextMenu.swift`, `TaskItem.swift`, `TodayView.swift`,
### How to test ### How to test
1. Long-press a task in Today → Postpone → 15 Minutes; reminder/due time moves. 1. Long-press a task in Today → Postpone → 15 Minutes; reminder/due time moves.
2. Repeat from Matrix grid, Matrix detail, and Calendar day timeline. 2. Repeat from Matrix grid, Matrix detail, and Calendar day timeline.
3. Open Move from the same menu → all four quadrants are visible, current one checked. 3. Open Move from the same menu → only the other three quadrants are listed (see KC-25).
4. Trigger a task reminder notification on device → verify 15/30/60/120 minute 4. Trigger a task reminder notification on device → verify 15/30/60/120 minute
lock-screen actions appear. lock-screen actions appear.
5. Tap a lock-screen snooze action → task reminder updates and notification 5. Tap a lock-screen snooze action → task reminder updates and notification
re-delivers after the selected interval. re-delivers after the selected interval.
---
## KC-23 — "Pick Date" everywhere + sensible time-based reminders
**Status:** In Progress (implemented & build-verified, pending device QA)
**Reported by:** User
**Area:** Tasks / Matrix / Calendar / Reminders
### Problem
The Date submenu offered "Pick Date" only in the Today view — the Matrix grid
cards and the Calendar reschedule/move menu were missing it. Separately, the
reminder lead-time options were day/week based ("2 days early", "1 week early"),
which is unrealistic for timed events; the user wanted the standard time-based
set.
### Fix
- "Pick Date" (and Edit) now appear in the Matrix grid cards and the Calendar day
timeline menu — both open the shared `TaskEditSheet` via a new `editingTask`
sheet (`QuadrantCard` gained an `onEdit`; Calendar passes `onEdit` into the
task menu).
- Reworked the reminder picker (`TaskDatePickerSheet`, shared by every entry
point) from day-offsets to **minutes before the event time**: None, On time,
5 min, 30 min, 1 hour, 1 day, plus a Minutes/Hours/Days custom wheel. Existing
absolute `reminderDate`s migrate to the nearest minute offset on open.
Files: `MatrixView.swift`, `CalendarView.swift`, `TodayView.swift`.
### How to test
1. Long-press a task in Matrix and in Calendar → Date → "Pick Date" is present.
2. Create/edit a task → reminder options read On time / 5 min / 30 min / 1 hour /
1 day / Custom, relative to the event time.
3. Custom → Minutes/Hours/Days toggle + count wheel commits the right offset.
---
## KC-24 — App rebrand to "Wenza"
**Status:** In Progress (display name + copy done; App Store name set in ASC)
**Reported by:** User
**Area:** Branding
### Problem
The app is being rebranded from "Kisani Cal" to **Wenza**.
### Fix
- `CFBundleDisplayName` → "Wenza" (app) and "Wenza Widgets" (widget) in
`project.yml`; permission prompts and in-app copy (sidebar, Auth, Onboarding,
Settings, tutorial, widget description) updated to Wenza.
- **Deliberately unchanged** to preserve App Store identity and user data: bundle
ID `com.kutesir.KisaniCal`, App Group `group.com.kutesir.KisaniCal`,
`kisani.tasks.v2.*` storage keys, team ID, and internal Xcode target/project
names.
- App Store listing name set separately in App Store Connect (ships with the
next version).
Files: `project.yml`, `ContentView.swift`, `TutorialManager.swift`, `AuthView.swift`,
`OnboardingView.swift`, `SettingsView.swift`, `KisaniCalWidgets.swift`.
### How to test
1. Build & run → home-screen label and all in-app titles read "Wenza".
2. Confirm tasks/widget data survive (App Group + keys unchanged).
---
## KC-25 — Matrix "Move" lists only the other quadrants
**Status:** In Progress (implemented & build-verified, pending device QA)
**Reported by:** User
**Area:** Tasks / Matrix
### Problem
The Move submenu listed all four quadrants with a checkmark on the current one,
so the quadrant a task already sat in appeared as a no-op option.
### Fix
- `TaskMenuItems` Move submenu now excludes the task's current quadrant and lists
only the other three, using the clear matrix labels (e.g. a task in Urgent &
Important shows Not Urgent & Important, Urgent & Unimportant, Not Urgent &
Unimportant). Shared across Today, Matrix, and Calendar.
Files: `TaskContextMenu.swift`.
### How to test
1. Long-press a task in any quadrant → Move shows only the three other quadrants.
2. Pick one → task moves (priority/urgency updated per the TickTick model).
---
## KC-26 — Bars widget: auto-select advances to the next event
**Status:** In Progress (implemented & build-verified, pending device QA)
**Reported by:** User
**Area:** Widgets
### Problem
With "Upcoming queue" on + "Nearest to finish", the Event Countdown (Bars) widget
stayed stuck on an event after it expired (e.g. a passed deadline) instead of
rolling to the next-nearest. The selection was frozen: the timeline computed the
chosen event once and reused it for all 24 pre-rendered entries, only refreshing
every 12 hours.
### Fix
- Each timeline entry now re-resolves the nearest-to-finish event *as of that
entry's own time* (`upcomingEvents(asOf:)` filters `dueDate > entryDate`), so
the moment the current event expires the next entry rolls to the next one.
- The timeline refreshes the instant the current selection expires
(`currentSelectionExpiry`), not 12 hours later, giving the next event a fresh
full pre-render window.
- Completing/closing a task already reloads widget timelines, and the recompute
drops it and picks the next. Tracked-event and manual-pick paths unchanged.
Files: `EventCountdownWidget.swift`.
### How to test
1. Queue on + Nearest to finish; let the nearest event's due time pass → widget
rolls to the next-nearest without manual action.
2. Complete the nearest event → widget advances to the next.

View File

@@ -222,6 +222,10 @@ class TaskViewModel: ObservableObject {
return out return out
} }
/// All incomplete single-occurrence rows (dated and undated) for Group & Sort
/// and multi-select, which regroup the same task set the date sections draw from.
var allActiveRows: [TaskItem] { activeRows() }
/// Active rows whose due date falls in [today+lo, today+hi) days (hi nil = open). /// Active rows whose due date falls in [today+lo, today+hi) days (hi nil = open).
private func bucket(from lo: Int, to hi: Int?) -> [TaskItem] { private func bucket(from lo: Int, to hi: Int?) -> [TaskItem] {
let cal = Calendar.current; let base = cal.startOfDay(for: Date()) let cal = Calendar.current; let base = cal.startOfDay(for: Date())

View File

@@ -20,14 +20,11 @@ struct SplashView: View {
.scaleEffect(iconScale) .scaleEffect(iconScale)
.opacity(iconOpacity) .opacity(iconOpacity)
// kisaniCAL. single line, orange dot // wenza. single line, orange dot
HStack(spacing: 0) { HStack(spacing: 0) {
Text("kisani") Text("wenza")
.font(AppFonts.mono(22, weight: .regular)) .font(AppFonts.mono(22, weight: .regular))
.foregroundColor(AppColors.text(cs)) .foregroundColor(AppColors.text(cs))
Text("CAL")
.font(AppFonts.mono(22, weight: .bold))
.foregroundColor(AppColors.text(cs))
Text(".") Text(".")
.font(AppFonts.mono(22, weight: .bold)) .font(AppFonts.mono(22, weight: .bold))
.foregroundColor(AppColors.accent) .foregroundColor(AppColors.accent)

View File

@@ -89,9 +89,10 @@ struct TaskMenuItems: View {
} label: { Label("Postpone", systemImage: "clock.arrow.circlepath") } } label: { Label("Postpone", systemImage: "clock.arrow.circlepath") }
Menu { Menu {
ForEach(Quadrant.allCases) { q in // Only the quadrants the task is NOT currently in you can't move it to where it sits.
ForEach(Quadrant.allCases.filter { $0 != (currentQuadrant ?? task.quadrant) }) { q in
Button { onMove(task, q) } label: { Button { onMove(task, q) } label: {
Label(q.rawValue, systemImage: q == (currentQuadrant ?? task.quadrant) ? "checkmark" : "square") Label(q.matrixLabel, systemImage: "arrow.right")
} }
} }
} label: { Label("Move", systemImage: "arrow.right.square") } } label: { Label("Move", systemImage: "arrow.right.square") }

View File

@@ -0,0 +1,323 @@
import SwiftUI
// MARK: - Today background themes
enum TodayBackground {
static let presets: [(id: String, label: String)] = [
("default", "Default"),
("warm", "Warm"),
("cool", "Cool"),
("mono", "Mono"),
("paper", "Paper"),
]
/// The full-screen background for a given style. "default" is a flat surface;
/// the rest are subtle top-down tints that fade into the base background.
static func gradient(_ id: String, _ cs: ColorScheme) -> LinearGradient {
let base = AppColors.background(cs)
let top: Color
switch id {
case "warm": top = AppColors.accentSoft
case "cool": top = AppColors.blueSoft
case "mono": top = Color(.systemGray5)
case "paper": top = Color(red: 0.96, green: 0.94, blue: 0.89)
default: top = base
}
return LinearGradient(colors: [top, base], startPoint: .top, endPoint: .bottom)
}
}
// MARK: - Group & Sort model
enum TaskGrouping: String, CaseIterable, Identifiable {
case date, priority, category, quadrant
var id: String { rawValue }
var label: String {
switch self {
case .date: return "Date"
case .priority: return "Priority"
case .category: return "Category"
case .quadrant: return "Quadrant"
}
}
}
enum TaskSort: String, CaseIterable, Identifiable {
case smart, due, priority, title
var id: String { rawValue }
var label: String {
switch self {
case .smart: return "Smart (pinned, then date)"
case .due: return "Due date"
case .priority: return "Priority"
case .title: return "Title"
}
}
}
enum TaskOrganizer {
private static func priorityRank(_ p: Priority) -> Int {
switch p { case .high: return 0; case .medium: return 1; case .low: return 2; case .none: return 3 }
}
static func sorted(_ tasks: [TaskItem], by sort: String) -> [TaskItem] {
switch sort {
case "due":
return tasks.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
case "priority":
return tasks.sorted { priorityRank($0.priority) < priorityRank($1.priority) }
case "title":
return tasks.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
default: // smart
return tasks.sorted {
if $0.isPinned != $1.isPinned { return $0.isPinned }
return ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture)
}
}
}
/// Non-date groupings titled sections, each internally sorted. ("date" is handled
/// by the existing day sections, so it isn't produced here.)
@MainActor
static func sections(_ tasks: [TaskItem], grouping: String, sort: String,
vm: TaskViewModel) -> [(title: String, tasks: [TaskItem])] {
let s = sorted(tasks, by: sort)
switch grouping {
case "priority":
return [Priority.high, .medium, .low, .none].compactMap { p in
let g = s.filter { $0.priority == p }
return g.isEmpty ? nil : (p.label, g)
}
case "category":
return [TaskCategory.birthday, .domain, .annual, .reminder, .custom].compactMap { c in
let g = s.filter { $0.category == c }
return g.isEmpty ? nil : (c.rawValue, g)
}
case "quadrant":
return [Quadrant.urgent, .schedule, .delegate_, .eliminate].compactMap { q in
let g = s.filter { vm.displayQuadrant($0) == q }
return g.isEmpty ? nil : (q.matrixLabel, g)
}
default:
return [("Tasks", s)]
}
}
}
// MARK: - Background picker sheet
struct BackgroundPickerSheet: View {
@Binding var selected: String
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var cs
private let columns = [GridItem(.adaptive(minimum: 96), spacing: 14)]
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: columns, spacing: 14) {
ForEach(TodayBackground.presets, id: \.id) { preset in
Button {
withAnimation(KisaniSpring.snappy) { selected = preset.id }
} label: {
VStack(spacing: 8) {
RoundedRectangle(cornerRadius: 14)
.fill(TodayBackground.gradient(preset.id, cs))
.frame(height: 80)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(selected == preset.id ? AppColors.accent : AppColors.border(cs),
lineWidth: selected == preset.id ? 2.5 : 1)
)
.overlay(alignment: .topTrailing) {
if selected == preset.id {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(AppColors.accent)
.padding(6)
}
}
Text(preset.label)
.font(AppFonts.sans(12, weight: .medium))
.foregroundColor(AppColors.text2(cs))
}
}
.buttonStyle(.plain)
}
}
.padding(18)
}
.background(AppColors.background(cs).ignoresSafeArea())
.navigationTitle("Background")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
}
}
// MARK: - Group & Sort sheet
struct GroupSortSheet: View {
@Binding var grouping: String
@Binding var sort: String
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Section("Group by") {
ForEach(TaskGrouping.allCases) { g in
Button {
withAnimation(KisaniSpring.micro) { grouping = g.rawValue }
} label: {
HStack {
Text(g.label).foregroundColor(.primary)
Spacer()
if grouping == g.rawValue {
Image(systemName: "checkmark").foregroundColor(AppColors.accent)
}
}
}
}
}
Section("Sort by") {
ForEach(TaskSort.allCases) { s in
Button {
withAnimation(KisaniSpring.micro) { sort = s.rawValue }
} label: {
HStack {
Text(s.label).foregroundColor(.primary)
Spacer()
if sort == s.rawValue {
Image(systemName: "checkmark").foregroundColor(AppColors.accent)
}
}
}
}
}
}
.navigationTitle("Group & Sort")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
}
}
// MARK: - Multi-select sheet
struct SelectableTaskListSheet: View {
@EnvironmentObject var taskVM: TaskViewModel
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var cs
@State private var selection: Set<UUID> = []
private let fmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "d MMM"; return f
}()
private var rows: [TaskItem] { taskVM.allActiveRows }
private var selectedTasks: [TaskItem] { rows.filter { selection.contains($0.id) } }
var body: some View {
NavigationStack {
List {
ForEach(rows) { task in
Button { toggle(task.id) } label: {
HStack(spacing: 10) {
Image(systemName: selection.contains(task.id) ? "checkmark.circle.fill" : "circle")
.font(.system(size: 18))
.foregroundColor(selection.contains(task.id) ? AppColors.accent : AppColors.text3(cs))
Text(task.title)
.font(AppFonts.sans(14))
.foregroundColor(AppColors.text(cs))
.lineLimit(1)
Spacer()
if let d = task.dueDate {
Text(fmt.string(from: d))
.font(AppFonts.mono(10))
.foregroundColor(AppColors.text3(cs))
}
}
}
.buttonStyle(.plain)
}
}
.listStyle(.plain)
.navigationTitle(selection.isEmpty ? "Select" : "\(selection.count) Selected")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(selection.count == rows.count && !rows.isEmpty ? "Deselect All" : "Select All") { toggleAll() }
.disabled(rows.isEmpty)
}
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
.safeAreaInset(edge: .bottom) {
if !selection.isEmpty { bulkBar }
}
}
}
private var bulkBar: some View {
HStack(spacing: 18) {
bulkButton("Complete", "checkmark.circle") {
selectedTasks.forEach { taskVM.toggle($0) }
selection.removeAll()
}
Menu {
ForEach(Priority.allCases) { p in
Button(p.label) { selectedTasks.forEach { taskVM.setPriority($0, priority: p) } }
}
} label: { bulkLabel("Priority", "flag") }
Menu {
ForEach(Quadrant.allCases) { q in
Button(q.matrixLabel) { selectedTasks.forEach { taskVM.moveToQuadrant(taskID: $0.id, quadrant: q) } }
}
} label: { bulkLabel("Move", "arrow.right.square") }
bulkButton("Delete", "trash", role: .destructive) {
selectedTasks.forEach { taskVM.delete($0) }
selection.removeAll()
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.frame(maxWidth: .infinity)
.background(.ultraThinMaterial)
.overlay(Rectangle().frame(height: 1).foregroundColor(AppColors.border(cs)), alignment: .top)
}
private func bulkButton(_ title: String, _ icon: String,
role: ButtonRole? = nil, action: @escaping () -> Void) -> some View {
Button(role: role, action: action) { bulkLabel(title, icon, destructive: role == .destructive) }
}
private func bulkLabel(_ title: String, _ icon: String, destructive: Bool = false) -> some View {
VStack(spacing: 4) {
Image(systemName: icon).font(.system(size: 18))
Text(title).font(AppFonts.sans(10, weight: .medium))
}
.foregroundColor(destructive ? .red : AppColors.accent)
.frame(maxWidth: .infinity)
}
private func toggle(_ id: UUID) {
if selection.contains(id) { selection.remove(id) } else { selection.insert(id) }
}
private func toggleAll() {
if selection.count == rows.count { selection.removeAll() }
else { selection = Set(rows.map { $0.id }) }
}
}

View File

@@ -11,6 +11,12 @@ struct TodayView: View {
@State private var showWorkoutSession = false @State private var showWorkoutSession = false
@State private var editingTask: TaskItem? = nil @State private var editingTask: TaskItem? = nil
@AppStorage("todayHideCompleted") private var hideCompleted = false @AppStorage("todayHideCompleted") private var hideCompleted = false
@AppStorage("todayBackground") private var bgStyle = "default"
@AppStorage("todayGroupMode") private var groupMode = "date"
@AppStorage("todaySortMode") private var sortMode = "smart"
@State private var showBackgroundSheet = false
@State private var showGroupSortSheet = false
@State private var showSelectSheet = false
@ObservedObject private var tm = TutorialManager.shared @ObservedObject private var tm = TutorialManager.shared
@ObservedObject private var hk = HealthKitManager.shared @ObservedObject private var hk = HealthKitManager.shared
@@ -27,6 +33,29 @@ struct TodayView: View {
} }
} }
// Group & Sort: non-date groupings rendered as titled sections (reuses UpcomingSection).
@ViewBuilder private var groupedTaskSections: some View {
let sections = TaskOrganizer.sections(taskVM.allActiveRows, grouping: groupMode,
sort: sortMode, vm: taskVM)
ForEach(Array(sections.enumerated()), id: \.offset) { _, section in
UpcomingSection(
title: section.title,
tasks: section.tasks,
onToggle: { taskVM.toggle($0) },
onPostpone: { taskVM.postpone($0) },
onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($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) },
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) }
)
}
}
var body: some View { var body: some View {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
@@ -36,7 +65,7 @@ struct TodayView: View {
// Header // Header
HStack { HStack {
Menu { Menu {
Button { } label: { Label("Background", systemImage: "paintbrush") } Button { showBackgroundSheet = true } label: { Label("Background", systemImage: "paintbrush") }
Button { Button {
withAnimation(KisaniSpring.snappy) { hideCompleted.toggle() } withAnimation(KisaniSpring.snappy) { hideCompleted.toggle() }
} label: { } label: {
@@ -44,8 +73,8 @@ struct TodayView: View {
systemImage: hideCompleted ? "checkmark.circle" : "checkmark.circle.badge.xmark") systemImage: hideCompleted ? "checkmark.circle" : "checkmark.circle.badge.xmark")
} }
Divider() Divider()
Button { } label: { Label("Group & Sort", systemImage: "arrow.up.arrow.down") } Button { showGroupSortSheet = true } label: { Label("Group & Sort", systemImage: "arrow.up.arrow.down") }
Button { } label: { Label("Select", systemImage: "checkmark.circle") } Button { showSelectSheet = true } label: { Label("Select", systemImage: "checkmark.circle") }
} label: { } label: {
Image(systemName: "line.3.horizontal") Image(systemName: "line.3.horizontal")
.font(.system(size: 13, weight: .medium)) .font(.system(size: 13, weight: .medium))
@@ -81,6 +110,7 @@ struct TodayView: View {
.padding(.bottom, 10) .padding(.bottom, 10)
} }
if groupMode == "date" {
// Today timeline: active completed (green) // Today timeline: active completed (green)
let activeTasks = taskVM.todayTasks let activeTasks = taskVM.todayTasks
let completedTasks = hideCompleted ? [] : taskVM.completedTodayTasks let completedTasks = hideCompleted ? [] : taskVM.completedTodayTasks
@@ -197,6 +227,10 @@ struct TodayView: View {
) )
} }
} else {
groupedTaskSections
}
// Completed (all, collapsible) // Completed (all, collapsible)
if !taskVM.completedTasks.isEmpty { if !taskVM.completedTasks.isEmpty {
CompletedSection( CompletedSection(
@@ -207,9 +241,7 @@ struct TodayView: View {
.padding(.top, 10) .padding(.top, 10)
} }
let hasAnything = !taskVM.overdueTasks.isEmpty || !taskVM.todayTasks.isEmpty let hasAnything = !taskVM.allActiveRows.isEmpty || !taskVM.completedTodayTasks.isEmpty
|| !taskVM.tomorrowTasks.isEmpty || !taskVM.next7DaysTasks.isEmpty
|| !taskVM.laterTasks.isEmpty || !taskVM.completedTodayTasks.isEmpty
|| !todayCalEvents.isEmpty || !todayCalEvents.isEmpty
|| workoutVM.program(for: Date()) != nil || workoutVM.program(for: Date()) != nil
if !hasAnything { if !hasAnything {
@@ -250,13 +282,28 @@ struct TodayView: View {
.zIndex(10) .zIndex(10)
} }
} }
.background(AppColors.background(cs).ignoresSafeArea()) .background(TodayBackground.gradient(bgStyle, cs).ignoresSafeArea())
.sheet(isPresented: $showAddTask) { .sheet(isPresented: $showAddTask) {
AddTaskSheet() AddTaskSheet()
.environmentObject(taskVM) .environmentObject(taskVM)
.presentationDetents([.height(150)]) .presentationDetents([.height(150)])
.presentationDragIndicator(.visible) .presentationDragIndicator(.visible)
} }
.sheet(isPresented: $showBackgroundSheet) {
BackgroundPickerSheet(selected: $bgStyle)
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showGroupSortSheet) {
GroupSortSheet(grouping: $groupMode, sort: $sortMode)
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showSelectSheet) {
SelectableTaskListSheet()
.environmentObject(taskVM)
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showWorkoutSession) { .sheet(isPresented: $showWorkoutSession) {
WorkoutSessionSheet() WorkoutSessionSheet()
.environmentObject(workoutVM) .environmentObject(workoutVM)

View File

@@ -209,29 +209,46 @@ struct EventProvider: AppIntentTimelineProvider {
} }
func timeline(for configuration: EventConfigIntent, in context: Context) async -> Timeline<EventEntry> { func timeline(for configuration: EventConfigIntent, in context: Context) async -> Timeline<EventEntry> {
let base = entry(for: configuration) let now = Date()
// Live progress: pre-render an entry every 30 minutes for the next 12 hours let step: TimeInterval = 1800 // 30 minutes
// so the bar and time-left label keep moving between refreshes. let count = 24 // ~12-hour pre-render window
let entries: [EventEntry] = (0..<24).map { step in // Re-resolve the selection at EACH step's own time, so the moment the nearest
EventEntry(date: Date().addingTimeInterval(Double(step) * 1800), // event expires the next entry rolls to the next-nearest automatically.
eventName: base.eventName, let entries: [EventEntry] = (0..<count).map { i in
start: base.start, entry(for: configuration, asOf: now.addingTimeInterval(Double(i) * step))
target: base.target, }
unitMode: base.unitMode) // Refresh at the window end, or right when the current selection expires
// (whichever is sooner) so the next event gets a fresh full pre-render.
let windowEnd = now.addingTimeInterval(Double(count) * step)
var next = windowEnd
if let expiry = currentSelectionExpiry(for: configuration, asOf: now),
expiry > now, expiry < windowEnd {
next = expiry.addingTimeInterval(1)
} }
let next = Date().addingTimeInterval(24 * 1800)
return Timeline(entries: entries, policy: .after(next)) return Timeline(entries: entries, policy: .after(next))
} }
/// Upcoming events (incomplete tasks with a future due date), nearest first. /// Upcoming events (incomplete tasks with a due date still in the future *as of `now`*),
private func upcomingEvents() -> [EventEntity] { /// nearest first. Passing `now` lets future timeline steps drop just-expired events.
private func upcomingEvents(asOf now: Date) -> [EventEntity] {
loadWidgetTasks() loadWidgetTasks()
.filter { !$0.isComplete && ($0.dueDate ?? .distantPast) > Date() } .filter { !$0.isComplete && ($0.dueDate ?? .distantPast) > now }
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
.map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate, .map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate,
isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel) } isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel) }
} }
/// Due date of the event the auto-select queue is currently showing (nil if not in
/// queue mode, an event is tracked, or there's nothing upcoming).
private func currentSelectionExpiry(for config: EventConfigIntent, asOf now: Date) -> Date? {
guard config.upcomingQueue,
UserDefaults.kisani.string(forKey: "kisani.widget.trackedEvent") == nil else { return nil }
let upcoming = upcomingEvents(asOf: now)
let idx = max(0, config.autoSelect.rawValue - 1)
guard idx < upcoming.count else { return nil }
return upcoming[idx].dueDate
}
/// Resolve the progress window (start target) for any event. Recurring events /// Resolve the progress window (start target) for any event. Recurring events
/// (Mode B) span the current occurrence period: previous next occurrence /// (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 /// so a birthday 5 days away reads ~98% full. One-off events (Mode A) count
@@ -245,7 +262,7 @@ struct EventProvider: AppIntentTimelineProvider {
return (Self.storedAnchor(key: anchorKey, target: due), due) return (Self.storedAnchor(key: anchorKey, target: due), due)
} }
private func entry(for config: EventConfigIntent) -> EventEntry { private func entry(for config: EventConfigIntent, asOf now: Date = Date()) -> EventEntry {
let mode = UserDefaults.kisani.integer(forKey: countdownUnitKey) let mode = UserDefaults.kisani.integer(forKey: countdownUnitKey)
// An event tracked from the app ("Track Countdown") bypasses the widget // An event tracked from the app ("Track Countdown") bypasses the widget
@@ -257,35 +274,35 @@ struct EventProvider: AppIntentTimelineProvider {
let since = UserDefaults.kisani.object(forKey: "kisani.widget.trackedSince") as? Date let since = UserDefaults.kisani.object(forKey: "kisani.widget.trackedSince") as? Date
let s = resolveSpan(due: due, isRecurring: task.isRecurring, label: task.recurrenceLabel, let s = resolveSpan(due: due, isRecurring: task.isRecurring, label: task.recurrenceLabel,
anchorKey: trackedId, explicitStart: since) anchorKey: trackedId, explicitStart: since)
return EventEntry(date: .now, eventName: task.title, start: s.start, target: s.target, unitMode: mode) return EventEntry(date: now, eventName: task.title, start: s.start, target: s.target, unitMode: mode)
} }
// Configured (no tracked event) // Configured (no tracked event)
if config.upcomingQueue { if config.upcomingQueue {
let upcoming = upcomingEvents() let upcoming = upcomingEvents(asOf: now)
let idx = max(0, config.autoSelect.rawValue - 1) let idx = max(0, config.autoSelect.rawValue - 1)
guard idx < upcoming.count, let due = upcoming[idx].dueDate else { guard idx < upcoming.count, let due = upcoming[idx].dueDate else {
return EventEntry(date: .now, eventName: "No upcoming event", return EventEntry(date: now, eventName: "No upcoming event",
start: Date(), target: Date().addingTimeInterval(86400), unitMode: mode) start: now, target: now.addingTimeInterval(86400), unitMode: mode)
} }
let ev = upcoming[idx] let ev = upcoming[idx]
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel, let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
anchorKey: ev.id, explicitStart: nil) anchorKey: ev.id, explicitStart: nil)
return EventEntry(date: .now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode) return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode)
} }
if let ev = config.event, let due = ev.dueDate { if let ev = config.event, let due = ev.dueDate {
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel, let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
anchorKey: ev.id, explicitStart: config.startDate) anchorKey: ev.id, explicitStart: config.startDate)
return EventEntry(date: .now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode) return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode)
} }
// Fully custom event (no task behind it). // Fully custom event (no task behind it).
let name = config.eventName.isEmpty ? "My Event" : config.eventName let name = config.eventName.isEmpty ? "My Event" : config.eventName
let target = config.targetDate let target = config.targetDate
?? Calendar.current.startOfDay(for: Date()).addingTimeInterval(2 * 86400) ?? Calendar.current.startOfDay(for: now).addingTimeInterval(2 * 86400)
let anchorKey = "custom.\(name).\(Int(target.timeIntervalSince1970))" let anchorKey = "custom.\(name).\(Int(target.timeIntervalSince1970))"
let start = (config.startDate.map { $0 < target ? $0 : nil } ?? nil) let start = (config.startDate.map { $0 < target ? $0 : nil } ?? nil)
?? Self.storedAnchor(key: anchorKey, target: target) ?? Self.storedAnchor(key: anchorKey, target: target)
return EventEntry(date: .now, eventName: name, start: start, target: target, unitMode: mode) return EventEntry(date: now, eventName: name, start: start, target: target, unitMode: mode)
} }
/// First-seen date for a tracked event, persisted in the App Group so progress /// First-seen date for a tracked event, persisted in the App Group so progress