Compare commits

...

45 Commits

Author SHA1 Message Date
kutesir
887a75cc4f docs: log KC-72 (StreakLogicTests fix + runner recovery)
All checks were successful
CI / build-and-test (push) Successful in 1m33s
CI / build-and-test (pull_request) Successful in 1m40s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 01:36:19 +03:00
kutesir
3f8c9e1456 test: mark StreakLogicTests @MainActor to fix Swift 6 compile error (KC-72)
Some checks failed
CI / build-and-test (push) Has been cancelled
CI / build-and-test (pull_request) Successful in 1m51s
The CI runner was broken all session (registered but never actually
polled for jobs - separate infra bug, fixed alongside this). First
real CI run surfaced a genuine, pre-existing compile error unrelated
to tonight's work: StreakLogicTests called @MainActor-isolated
WorkoutViewModel/TaskViewModel static methods from a non-isolated
test class. RecurrenceTests.swift in the same target already uses
the correct @MainActor class annotation - StreakLogicTests was just
missing it.

Verified locally: full suite now passes, 78/78 tests, 0 failures,
on a real simulator (iPhone 17 Pro) - not just self-review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 01:35:26 +03:00
kutesir
266c3a6dda ci: finish release.yml's TestFlight upload, rule out Xcode Cloud for this repo (KC-71)
Some checks failed
CI / build-and-test (push) Failing after 39s
CI / build-and-test (pull_request) Failing after 30s
Spent considerable effort trying to connect Xcode Cloud directly to
this repo's self-hosted Gitea - confirmed not achievable. GitHub
Enterprise's provider option hits Gitea's missing /api/v3/ path;
GitLab Self-Managed rejects Gitea's UUID-format OAuth2 Client IDs
against App Store Connect's 64-char-hex requirement (verified by
actually creating a real OAuth2 app and having it rejected). Also
stood up Tailscale Funnel on the Gitea host for real public HTTPS
access regardless (was LAN-only before), and switched origin to it.

Replaced release.yml's commented-out altool placeholder (defunct -
Apple retired that upload path in 2023) with the current supported
approach: xcodebuild -exportArchive with API-key auth flags handles
export and TestFlight upload in a single step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 00:03:19 +03:00
kutesir
c992554f99 tasks: add "every N weeks/months/days/years" recurrence (KC-70)
Some checks failed
CI / build-and-test (push) Has been cancelled
Recurrence previously only supported 5 fixed rules with no interval.
Adds TaskItem.recurrenceInterval (optional, nil = 1, so every existing
task is unaffected), a stepper in the shared create/edit sheet, quick-add
NLP for phrases like "every 2 weeks"/"biweekly"/"fortnightly", and
threads the interval through both countdown widgets so their progress
bars size correctly for biweekly/etc tasks too.

Built on KC-69's consolidated RecurrenceRule engine. Verified two things
by actually compiling and running standalone swiftc drivers rather than
just reading: the core interval math (44/44, unchanged from KC-69) and
the new quick-add regex patterns against realistic phrases including
"look into tru housing options every two weeks" (11/11 passing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 00:15:45 +03:00
kutesir
6915e64b67 recurrence: consolidate 3 duplicate implementations into one shared, tested engine (KC-69)
Some checks failed
CI / build-and-test (push) Has been cancelled
Before adding "every N weeks/months" support, found the recurrence
window math was independently hand-written in three places (main app's
TaskItem.isOccurrence, and two separate widget extensions' countdown
progress calculations) with no shared code to keep them in sync.

Extracted the math into Shared/RecurrenceRule.swift (pure Foundation,
no framework deps) compiled into both the app and widget extension
targets via a new project.yml Shared/ source path, and pointed all
three call sites at it. Verified every case is identical to prior
behavior at interval==1 by compiling and running a standalone test
driver directly against the real file with swiftc - 38/38 passing,
including Jan-31-monthly and Feb-29-leap-year-yearly edge cases.

Groundwork only - no recurrenceInterval field on TaskItem yet, the
actual interval feature comes next on top of this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:56:58 +03:00
kutesir
eff5cc9330 workout: restore confirmed Undo for manually-logged days only (KC-68)
Some checks failed
CI / build-and-test (push) Has been cancelled
KC-67 removed Undo entirely on the reasoning that Watch/Health is the
source of truth once a day is logged - correct for a Watch-detected
day, but it also blocked undoing a day logged manually via the
in-app Finish button, leaving no way back (hit while testing KC-67
itself). Added manualWorkoutDates to track which completions came
from the app's own Finish button vs HealthKit sync, and only offer
Undo (now confirmed via a dialog) for those.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 03:16:53 +03:00
kutesir
b0afa17a28 workout: fix iCloud sync stomping the Watch-logged streak, remove Undo (KC-67)
Some checks failed
CI / build-and-test (push) Has been cancelled
Root cause: CloudSyncManager.kvStoreChanged blindly overwrote any
externally-changed iCloud key, including workoutDates - a lifetime
tally that every local write path (HealthKit sync, manual finish)
carefully dedups/unions. A stale iCloud snapshot racing a fresh
Watch-sync write could silently replace the correct array with an
older, smaller one. Now unions instead of overwriting for that key
specifically.

Also: removed the Undo action/row from DotProgressCard entirely (Watch/
Health is the source of truth once a day is logged), and restored the
confirmation dialog on Finish Workout so every state-changing action
confirms first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 01:52:56 +03:00
kutesir
3512d55503 liveactivity+widgets: fix iOS 16.2 build error, extend createdAt to Bars widget (KC-66)
Some checks failed
CI / build-and-test (push) Has been cancelled
end(_:dismissalPolicy:) requires iOS 16.2 but LiveActivityManager.end
only gated to 16.1 (Live Activities' own minimum) - branch on
#available with the deprecated overload as fallback. Separately,
auditing all countdown widgets per user request found the "Event
Countdown (Bars)" widget family had its own independent start-date
resolution that never considered the task's real creation date at
all - EventEntity now carries createdAt and resolveSpan prefers it
the same way KC-65 already does for the dot-grid widget.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:52:12 +03:00
kutesir
b1b33b6014 model: give TaskItem a real createdAt so countdown widgets stop resetting (KC-65)
Some checks failed
CI / build-and-test (push) Has been cancelled
The Track Countdown widget already preferred task.createdAt as its
progress start date, but TaskItem never had that field — it was
always nil, so every task fell back to a fragile "first render"
anchor that can reset to now. New tasks now record a real creation
timestamp; old tasks keep the existing fallback since there's no way
to know when they were actually made.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:46:58 +03:00
kutesir
3c71a233d7 liveactivity: fix deprecated end(using:dismissalPolicy:) warning (KC-64)
Some checks failed
CI / build-and-test (push) Has been cancelled
Omitting the content argument on activity.end(dismissalPolicy:)
resolved to the deprecated iOS 16.1 overload. Pass nil explicitly to
bind to end(content:dismissalPolicy:) instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:43:06 +03:00
kutesir
f9852751da workout: revert Finish-at-100% confirmation, keep dots-always-visible fix (KC-63 follow-up)
Some checks failed
CI / build-and-test (push) Has been cancelled
Confirming every Finish tap was unwanted friction once all sets are
already checked. Restored the one-tap Finish at 100% (partial
completion still confirms via "Finish Anyway"); the always-visible
progress dots from the first part of KC-63 are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:40:54 +03:00
kutesir
9e06b56a43 workout: keep progress dots visible after finish, confirm every Finish tap (KC-63)
Some checks failed
CI / build-and-test (push) Has been cancelled
The KC-52 redesign swapped the whole progress-dots view for a green
"Workout logged" takeover on finish, with no way back short of Undo,
and let Finish skip confirmation whenever all sets were already
checked. Dots now stay visible in every state; Finish always confirms,
matching Clear All / Didn't Train.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:35:58 +03:00
kutesir
c8723714af tasks: typography polish on task rows, no size changes (KC-62 follow-up 2)
Some checks failed
CI / build-and-test (push) Has been cancelled
Title weight bumped to medium for hierarchy, tracking added to the
mono date/countdown lines for legibility at small size, slightly
looser line spacing. Impeccable's detect engine only parses HTML, not
Swift, so this was done by hand rather than via the skill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 14:38:34 +03:00
kutesir
57e8c5a34b tasks: rebalance row layout, countdown+icons move to right column (KC-62 follow-up)
Some checks failed
CI / build-and-test (push) Has been cancelled
The category icon column sat empty for most rows (default tasks have
no glyph/alarm/series), leaving a lone repeat icon floating in dead
space on the right per user's device screenshot. Move the countdown
label out from under the title into a trailing column paired with the
indicator icons, so both sides of the row carry equal weight.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 14:26:40 +03:00
kutesir
d8f7abff92 tasks: replace category text pill with TickTick-style icon glyphs (KC-62)
Some checks failed
CI / build-and-test (push) Has been cancelled
Every task row was tagging itself "Reminder" via a text badge for the
default category, adding no information. Swapped for icon-only glyphs
(nil for the generic .reminder case) folded into the existing alarm/
repeat indicator row; countdown/date info was already present or
redundant with the leading time-track, so no separate countdown text
was added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 14:17:18 +03:00
kutesir
9723775cb6 onboarding+liveactivity+habits: 3 fixes from device feedback (KC-61)
Some checks failed
CI / build-and-test (push) Has been cancelled
1. Onboarding Habits caption now explicitly mentions the toggles are
   changeable in Settings, not just times/custom reminder.

2. TaskLiveActivity: removed the checkbox-look circle glyph (hourglass
   instead). iOS has no API for an app to block the system's swipe/dismiss
   gesture on a Live Activity -- not attempted. Added a real Stop control via
   a new StopCountdownIntent (LiveActivityIntent, iOS 17+) embedded directly
   in the card (lock screen '×' + Dynamic Island 'Stop Countdown' button) --
   the actual iOS-supported equivalent of 'long press for options', since
   Live Activities don't support notification-style long-press menus.

3. Habit notifications gain 'Not Today' (dismiss only, intentional no-op --
   the lifetime-tally philosophy has no concept of a logged miss) and 'Stop
   Tracking' (destructive-styled; flips the habit's master toggle off and
   cancels every pending notification for that type across all its slots).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 13:33:32 +03:00
kutesir
dd69a6a2e4 tutorial: introduce habit reminders in the Settings tutorial too (KC-60)
Some checks failed
CI / build-and-test (push) Has been cancelled
Habit reminders (KC-55) were in Onboarding but not the app's separate
spotlight/coachmark tutorial system -- skipping onboarding meant no second
chance to discover Prayer/Bed/Coffee reminders exist.

Adds a fourth TutorialManager track (settingsTips/settingsStep/settingsDone),
matching the existing Today/Workout tip-track pattern exactly, and wires it
into SettingsView via the same shared InViewTutorialCard component -- shown
once, first time Settings opens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 12:47:23 +03:00
kutesir
a5f7bce09f tasks: add 'Complete & Stop Series' for recurring tasks (KC-59)
Some checks failed
CI / build-and-test (push) Has been cancelled
Recurring tasks only had Delete (erases the task + its entire completion
history) as a way to stop a series -- no in-between option to wind one down
while keeping its record.

TaskViewModel.completeAndStopSeries(_🔛) marks the occurrence complete and
sets recurrenceEnd to that day -- a field that already existed in the model
and was already respected by isOccurrence, just had no UI. Task, title, and
full completedOccurrences history stay intact; only future occurrences stop.

New menu item in the shared TaskMenuItems (Today/Matrix/Calendar), shown only
for recurring tasks. Wired through all 8 call sites, several via a new
optional closure threaded through intermediate wrapper views (QuadrantCard,
DayTimelineView, OverdueCard, UpcomingSection).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 03:32:37 +03:00
kutesir
467045365d Bump build to 2.0 (10)
Some checks failed
CI / build-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 03:09:14 +03:00
kutesir
a3a1a46afd habits: clarify the custom coffee reminder row (KC-58)
Some checks failed
CI / build-and-test (push) Has been cancelled
The custom-slot row just said 'Coffee' -- identical-looking to the card's own
'Coffee reminders' title, with no affordance signaling it's an editable,
user-nameable 5th slot (distinct from the 4 fixed Morning/Midday/Afternoon/
Evening ones).

Added a small pencil + 'YOUR OWN REMINDER' label above the field; changed the
default from pre-filled 'Coffee' to empty so the placeholder ('Name it, e.g.
"Second coffee"') actually does its job. NotificationManager falls back to
'Coffee' for the notification text only if the user never typed a name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 02:39:35 +03:00
kutesir
8f6980df13 fix(theme): add AppColors.coffeeBrown -- Color(r:g:b:) is file-private (KC-57)
Some checks failed
CI / build-and-test (push) Has been cancelled
Build error: Color(r:g:b:) is a private extension scoped to DesignTokens.swift
(only UIColor's sibling init is also private, both file-scoped by design).
KC-55/56 called it directly from SettingsView.swift and OnboardingView.swift
for the coffee icon color -- invisible there, so the compiler fell back to
unrelated overloads ('Cannot convert Int to Color', 'Extra argument b').

Added AppColors.coffeeBrown (defined where the private init is visible),
replacing both inline Color(r:g:b:) call sites -- matches how every other
color in the app is centralized and accessible everywhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 02:19:32 +03:00
kutesir
97782a82bc habits: add a 'Done' notification action + private day-count badge (KC-56)
Some checks failed
CI / build-and-test (push) Has been cancelled
Follow-up to KC-55's design question: habit reminders (Prayer/Bed/Coffee) stay
out of Today/Matrix/Activity History (would add 3-9 daily checkboxes forever,
against 'nothing shouts'), but now get a 'Done' action on the notification
itself -- like the workout confirm flow's 'Yes, log it' -- that logs a private
timestamp.

New HABIT_LOG category/HABIT_DONE action; every habit notification carries
userInfo['habitType'] (bed/prayer/coffee -- slot detail collapses to the type).
logHabitDone/habitDoneCount store a deduped date array in UserDefaults.kisani,
mirroring the lifetime-tally streak philosophy from KC-40 rather than a
breakable streak. Settings shows a quiet 'Xd' badge per habit -- the only
place the count appears; nothing touches the task list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 02:12:16 +03:00
kutesir
dea8d313e4 habits: add Prayer, Bed-making, and Coffee/Caffeine reminders (KC-55)
Some checks failed
CI / build-and-test (push) Has been cancelled
Three new opt-in daily local-notification habits, toggleable in both Settings
and Onboarding, backed by the same UserDefaults.kisani (App Group) keys:

- Prayer: up to 4 independently-toggleable times/day (Morning/Afternoon/
  Evening/Night), 'Have you had a chance to pray and give thanks?'
- Bed-making: one daily nudge, default 8am (research-backed first win of the
  day per the user's note).
- Coffee/Caffeine: up to 4 named times + one custom-labeled reminder.

NotificationManager.scheduleHabitReminders() schedules/cancels all slots
idempotently via fixed identifiers, wired into the existing reschedule()
orchestration. Onboarding gets 3 restrained master toggles with sensible
defaults (no per-slot editors, to avoid a long form); Settings gets the full
Habit Reminders sheet with per-slot times and the custom coffee reminder.

Found and worked around a pre-existing store mismatch: workoutCheckInEnabled/
workoutConfirmEnabled etc. write to UserDefaults.standard (no explicit store:)
but NotificationManager reads them from UserDefaults.kisani -- not fixed here
(out of scope), but all new keys explicitly use store: UserDefaults.kisani so
this feature actually fires.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 02:04:48 +03:00
kutesir
083a51065b tasks: fix timed recurring tasks vanishing from Today after their time passes (KC-54)
Some checks failed
CI / build-and-test (push) Has been cancelled
Real bug: todayTasks excludes timed tasks whose time has passed ('belongs in
overdue'), but overdueTasks deliberately excludes ALL recurring tasks (it feeds
postpone/postponeAllOverdue, which mutate dueDate by id -- for a recurring task
that's the recurrence anchor, not just today's occurrence; including them there
would let Postpone All corrupt the whole series). Recurring tasks fell into the
gap between the two rules and vanished from the Today tab entirely once their
time passed, while Calendar's day view (no time-of-day gating) kept showing
them fine.

Fix: todayTasks only excludes past-time NON-recurring tasks. Recurring
occurrences always stay in Today, matching Calendar, without ever touching the
overdue/postpone path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 23:05:18 +03:00
kutesir
7bfa16aec4 widgets: add 'This Year' mode to Progress Dots (KC-53)
Some checks failed
CI / build-and-test (push) Has been cancelled
The Progress Dots lock-screen widget already supports Day/Week/Month via its
configuration picker — no code needed for those. Year was the only mode
missing. Added ProgressDotMode.year, a matching provider branch
(Calendar.dateInterval(of: .year)), and yearTitle(for:), mirroring the
existing day/week/month pattern exactly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 22:59:23 +03:00
kutesir
017937bbff workout: stop auto-completing the day when sets are ticked (KC-52 follow-up 3)
Some checks failed
CI / build-and-test (push) Has been cancelled
Real bug, not cosmetic: setAllSetsDone/toggleSet/setExerciseDone still
auto-called logWorkoutCompleted() whenever doneSets == totalSets — leftover
pre-redesign behavior that fought the new completion card. Ticking the last
box (or tapping 'Check all sets') silently counted the day and snapped the UI
to the green 'done' takeover without the user ever hitting Finish.

Removed the auto-log from all three set-mutation methods. Only the card's
explicit Finish Workout/Finish Anyway (-> markWorkoutDone) now counts a day as
done. Check all sets just checks boxes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 13:54:13 +03:00
kutesir
5b3d4e5f9e workout: drop seal icon, confirm every destructive quick action (KC-52 follow-up 2)
Some checks failed
CI / build-and-test (push) Has been cancelled
checkmark.seal.fill (unused elsewhere, reads as a generic 'AI verified badge')
replaced with checkmark.circle.fill, the app's actual convention. All four
state-changing card actions (Finish Anyway, Check All Sets, Clear All Sets,
Didn't Train) now confirm via one PendingAction enum + confirmationDialog
before executing, each with a tailored message; destructive ones use the
.destructive role. The header menu's Clear All Sets (any completion level)
also now confirms instead of executing instantly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 13:48:26 +03:00
kutesir
5123a38ea9 workout: restrained button, finish confirmation, clear toggle (KC-52 follow-up)
Some checks failed
CI / build-and-test (push) Has been cancelled
Addresses feedback on the completion card: the primary button was a
permanently-filled accent pill (violates 'nothing shouts'/'color earns its
place') and Finish Anyway skipped confirming before overriding real progress.

- QuietAccentButtonStyle: outlined at rest, fills solid accent only while
  pressed.
- confirmationDialog on Finish Anyway showing the logged % (skipped at 100%).
- Check all sets flips to Clear all sets once fully checked — undo is the
  button itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 13:23:56 +03:00
kutesir
c521c43f9a workout: bring completion controls onto the main page (KC-52)
Some checks failed
CI / build-and-test (push) Has been cancelled
DotProgressCard becomes a 3-state completion card (pending/done/missed) with a
primary Finish Workout/Finish Anyway action that captures today's workout
honestly — regardless of how many sets got ticked, since Health/Watch already
confirms the workout independently. Secondary actions: check all sets, mark
not completed. Done/missed states show an Undo.

Adds WorkoutViewModel.unmarkWorkoutDone/unmarkWorkoutMissed. Removes 'Mark All
Complete' from the header '...' menu (superseded by the card); keeps 'Clear
All Sets' as a distinct reset utility.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 10:57:05 +03:00
kutesir
708bf95e2a fix(healthkit): use let for interval captured across async let tasks
Some checks failed
CI / build-and-test (push) Has been cancelled
Build error (Xcode, Swift 6): 'interval' was a var built via two statements
then captured by 3 concurrent async let tasks in dailyReference — Swift 6
requires captured values to be immutable. It was never mutated after setup;
switched to a single-expression let (DateComponents(day: 1)).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 10:46:25 +03:00
kutesir
eba39b0475 fix(analytics): mark WorkoutSnapshot.init(vm:) @MainActor
Some checks failed
CI / build-and-test (push) Has been cancelled
Build error (Xcode, Swift 6 strict concurrency): WorkoutSnapshot.init(vm:) read
history/workoutDates/schedule/missedDates from @MainActor WorkoutViewModel in a
nonisolated init. The init is only ever called from AnalyticsService.reconcile
(@MainActor) — mark it @MainActor to match its actual call site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 10:44:31 +03:00
kutesir
839ac72846 analytics(healthkit): wire historical HealthKit reference into reports (KC-51 ph4e)
Some checks failed
CI / build-and-test (push) Has been cancelled
Completes 'available HealthKit trends' for weekly/monthly reports. Adds
HealthKitManager.dailyReference (3 parallel bucketed HKStatisticsCollectionQuery
for steps/active-calories/resting-HR over ~13 months), AnalyticsService.
refreshHealthReference to cache and merge it into DaySamples, and avgSteps/
avgActiveCalories/avgRestingHR on PeriodSummary (nil when absent — honest).
Surfaced in AnalyticsView's weekly/monthly cards.

Verified standalone (swiftc, 8/8): aggregation correct when present, nil when
absent, workout metrics unaffected, reports stay deterministic. Tests added to
AnalyticsEngineTests. This completes all analytics logic — remaining work is the
first Xcode build (device, user-run) to catch any SwiftUI compile issues.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 10:39:00 +03:00
kutesir
67863fca44 analytics(service): capture snapshot value in coordinator closures (concurrency hardening)
Some checks failed
CI / build-and-test (push) Has been cancelled
Moves sample-building to nonisolated static funcs over a captured WorkoutSnapshot
so the coordinator's escaping closures don't touch @MainActor state — avoids a
main-actor isolation error. Matches the app's existing @MainActor + static shared
pattern (HealthKitManager).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:39:39 +03:00
kutesir
c56a6a9d21 analytics(deeplink): route report notification tap to its report (KC-51 ph4c)
Some checks failed
CI / build-and-test (push) Has been cancelled
Completes deep-link routing (goal #7): AnalyticsDeepLink is Identifiable; a
tapped weekly/monthly report notification is consumed in ContentView on
launch/active and presents AnalyticsView opened to that area. Core round-trip
re-verified standalone; SwiftUI wiring needs an Xcode build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:36:45 +03:00
kutesir
7f11b0e8c1 analytics(ui): AnalyticsView (6 areas + states + charts) + Workout entry (KC-51 ph4b)
Some checks failed
CI / build-and-test (push) Has been cancelled
Self-contained Analytics UI: Overview/Daily/Weekly/Monthly/12-Month/Exercises
with color+glyph status, empty/partial states, Swift Charts, VoiceOver labels,
Wenza tokens, and the non-medical disclaimer. Entry via an IButton in the
Workout header (sheet). Added Identifiable chart points.

Self-reviewed for compile issues (fixed tuple key-paths, a11y trait literal,
format string) but NOT build-verified — no iOS runtime; SwiftUI/app deps. Logged
in ISSUES.md as device-required.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:23:54 +03:00
kutesir
d25cf9ba80 analytics(app): AnalyticsService + lifecycle reconcile + deep-linked notifications (KC-51 ph4a)
Some checks failed
CI / build-and-test (push) Has been cancelled
Wires the verified analytics core into the app: AnalyticsService feeds live
WorkoutViewModel data into the reconcile coordinator and exposes UI reads;
ContentView reconciles on launch + foreground; NotificationManager fires one
permission-aware, deduped weekly/monthly report notification carrying an
AnalyticsDeepLink, captured on tap for routing.

NOT build-verified (no iOS runtime; app-module/SwiftUI deps). Logged in
ISSUES.md; historical HealthKit reference + user-scheduled notification time are
follow-ups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:05:02 +03:00
kutesir
065066c8f7 analytics(integration): adapter + reconcile coordinator + deep links (KC-51 ph3)
Some checks failed
CI / build-and-test (push) Has been cancelled
Phase 3 core (verifiable): WorkoutAnalyticsAdapter (app data → DaySample),
AnalyticsCoordinator (idempotent backfill of missing weekly/monthly reports +
retention prune, injectable providers), AnalyticsDeepLink (wenza://analytics
report routing).

Fixed a reconcile bug caught by tests: a retention-boundary week was generated
then immediately pruned, regenerating every run — aligned completedWeekStartKeys
to the prune cutoff → idempotent.

Tests: AnalyticsAdapterTests + AnalyticsCoordinatorTests. Verified standalone via
swiftc (~90 analytics assertions total across engine/store/adapter/coordinator).
Remaining (app wiring, notifications, UI) requires an Xcode build — logged in
ISSUES.md as device-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:53:20 +03:00
kutesir
80591c31a6 analytics(persistence): file-based store + reconciliation selection (KC-51 ph2)
Some checks failed
CI / build-and-test (push) Has been cancelled
Phase 2: AnalyticsStore (JSON files under Application Support) with immutable
frozen daily snapshots + immutable, idempotent, deduped weekly/monthly reports,
and 12-month retention pruning. Engine gains completedWeekStartKeys/
completedMonthKeys/missingKeys for idempotent reconciliation selection.

AnalyticsStoreTests: 6 XCTest cases. Verified standalone via swiftc — 23-
assertion temp-dir harness all pass. Registered files via xcodegen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:34:32 +03:00
kutesir
dd4d196129 analytics(engine): pure deterministic AnalyticsEngine + models + tests (KC-51 ph1)
Some checks failed
CI / build-and-test (push) Has been cancelled
Phase 1 of the analytics system (/goal): the framework-independent analysis
engine. Value-type models (MetricDelta, TrendResult, DaySample, WeeklyReport,
MonthlyReport, ...) and AnalyticsEngine with documented formulas (volume,
%change, consistency, trend) and honest degradation (nil/zero/tiny baseline,
new exercise, rest day, outliers via winsorize, unit-dimension compatibility,
divide-by-zero). Calendar math takes an injected Calendar (locale/firstWeekday/
timezone/DST-safe).

AnalyticsEngineTests: 35 XCTest cases. Independently verified by compiling the
engine with swiftc and running a 42-assertion harness — all pass. (Xcode test
target not runnable in this env; assertions mirror the XCTest file.)

Registered the new files via xcodegen. Logs KC-51 phase-1 progress in ISSUES.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:06:59 +03:00
kutesir
cc0dc02be0 docs(issues): KC-51 — analytics system plan, persistence eval, blockers
Some checks failed
CI / build-and-test (push) Has been cancelled
Records the /goal analytics-system implementation plan: reusable-functionality
survey, SwiftData/CoreData/current persistence evaluation, proposed architecture
(stores, immutable reports, pure analysis engine, reconciliation, notifications,
UI, tests), and the two blockers (build-verification unavailable; persistence +
min-iOS decision).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 01:42:45 +03:00
kutesir
532bd57866 Calendar: force timeline fill (dead space) + long-press menu on blocks
Some checks failed
CI / build-and-test (push) Has been cancelled
KC-49: week/3-day dead space — the outer .frame only allowed fill; force the
timeline ScrollView greedy (.frame maxHeight .infinity) and pin each timeline
root VStack top, so the header pins to the top instead of floating mid-screen.

KC-50: timeline blocks now have a long-press context menu — Complete, Snooze
(15m/1h/tomorrow), and the shared TaskMenuItems (reschedule/move/priority/
category/edit/delete). Events get no menu. CVTimeBlock takes @ViewBuilder menu.

Logs KC-49 (supersedes KC-48) and KC-50 in ISSUES.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 01:35:25 +03:00
kutesir
543a1c3ee0 docs(issues): log KC-40–48 (streak tally, stats, history, banner, calendar rework)
Some checks failed
CI / build-and-test (push) Has been cancelled
Records this session's work in the issue tracker: workout streak → lifetime
tally (KC-40), recurring-aware stats (KC-41), Activity History (KC-42), neutral
Today checkbox (KC-43), swipeable streak banner (KC-44), continuous year view
(KC-45), month TickTick bars+collapse reverted (KC-46), week timeline + drag +
grid toggle (KC-47), week/3-day dead-space fix (KC-48).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 01:17:18 +03:00
kutesir
3134c064e9 Calendar: fix week/3-day dead space (pin content to top)
Some checks failed
CI / build-and-test (push) Has been cancelled
The timeline views' content is a thin header + a greedy ScrollView (≈0 ideal
height), so the top-level VStack shrank and the bottomTrailing ZStack floated
it to the bottom — leaving a big empty gap above the day header. Month view
has a tall fixed grid so it never surfaced. Pin the VStack to fill height and
top-align.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 01:13:23 +03:00
kutesir
0a87769561 Revert "Calendar month view: TickTick-style bars + collapse-to-week"
This reverts commit 2ec3278632.
2026-07-07 01:12:14 +03:00
kutesir
e8d74d4f59 Revert "Calendar month: drag-to-collapse + full-column selection highlight"
This reverts commit bd43eecfbe.
2026-07-07 01:09:58 +03:00
40 changed files with 5059 additions and 309 deletions

View File

@@ -36,51 +36,31 @@ jobs:
-archivePath "$RUNNER_TEMP/KisaniCal.xcarchive" \
-allowProvisioningUpdates
- name: Export IPA
# Write the App Store Connect API key to disk. xcodebuild's auto-discovery
# looks for exactly this filename pattern under ~/private_keys.
- name: Write App Store Connect API key
run: |
mkdir -p ~/private_keys
echo "${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }}" > ~/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}.p8
# Export AND upload in one step: passing the API key credentials to
# -exportArchive makes xcodebuild upload directly to App Store Connect
# (method "app-store" in ExportOptions.plist) — no separate altool/
# Transporter step needed. (altool's own upload path was retired by
# Apple in 2023; this is the current supported mechanism.)
- name: Export IPA and upload to TestFlight
run: |
set -o pipefail
xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/KisaniCal.xcarchive" \
-exportOptionsPlist ExportOptions.plist \
-exportPath "$RUNNER_TEMP/export" \
-allowProvisioningUpdates
-allowProvisioningUpdates \
-authenticationKeyPath ~/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}.p8 \
-authenticationKeyID ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} \
-authenticationKeyIssuerID ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
ls -la "$RUNNER_TEMP/export"
# ------------------------------------------------------------------
# PLACEHOLDER: Upload to TestFlight / App Store Connect.
#
# Add these as Gitea repo secrets: Settings → Actions → Secrets
# APP_STORE_CONNECT_API_KEY_ID (the "Key ID" from App Store Connect)
# APP_STORE_CONNECT_ISSUER_ID (the "Issuer ID")
# APP_STORE_CONNECT_API_KEY_P8 (contents of the AuthKey_XXXX.p8 file)
#
# Then uncomment ONE of the approaches below.
# ------------------------------------------------------------------
# --- Option A: modern notarytool / App Store Connect API key (recommended) ---
# - name: Write API key to file
# run: |
# mkdir -p ~/private_keys
# echo "${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }}" > ~/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}.p8
#
# - name: Upload to TestFlight (altool with API key)
# run: |
# xcrun altool --upload-app \
# --type ios \
# --file "$RUNNER_TEMP/export/KisaniCal.ipa" \
# --apiKey "${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}" \
# --apiIssuer "${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}"
# --- Option B: notarytool (for notarizing a macOS build, not iOS TestFlight) ---
# - name: Notarize
# run: |
# xcrun notarytool submit "$RUNNER_TEMP/export/KisaniCal.ipa" \
# --key ~/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}.p8 \
# --key-id "${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}" \
# --issuer "${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}" \
# --wait
- name: Notice
run: |
echo "IPA exported. TestFlight upload step is a commented-out placeholder."
echo "Add App Store Connect API-key secrets and uncomment Option A in release.yml."
- name: Clean up API key
if: always()
run: rm -rf ~/private_keys

View File

@@ -7,26 +7,33 @@
objects = {
/* Begin PBXBuildFile section */
023A636FF533304DA5578D1C /* AnalyticsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4FB8B5BFF3FF62728E9498A /* AnalyticsCoordinator.swift */; };
06CA0F336E64D9F6D56F7472 /* CloudSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449C34805DC6B2CB66886544 /* CloudSyncManager.swift */; };
096804560A2F0A7D74E64780 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC2AFB4FA765383740767CB /* TaskItem.swift */; };
12E42CE8B8E535FAE6268A0C /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF905C574F34B4EE51A8D21E /* AppGroup.swift */; };
13E4B9854F595394FC9D5912 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC5E179A9189B0A8C3F856F6 /* ContentView.swift */; };
152AECD42FCF554C582E2DEA /* AnalyticsEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78EEE7568265196447E54D6B /* AnalyticsEngine.swift */; };
16FFC465D8646DED9A5C69D1 /* NLRecurrenceParseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */; };
1A22EE21460821170E44B1DF /* ExerciseModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5722CC4B59E3939724142710 /* ExerciseModels.swift */; };
205846F9651EDCE0D8207358 /* TaskLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */; };
2885D000426D063F2125804C /* SpeechRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86906905A4FA7A2384B3E24E /* SpeechRecognizer.swift */; };
2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF905C574F34B4EE51A8D21E /* AppGroup.swift */; };
30D155A1314CBD4BB1744B29 /* RecurrenceRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A0B871AB37123908CF0CB20 /* RecurrenceRule.swift */; };
32C63D81925FBFE51CAE1FB7 /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; };
3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01A27D42E141DC056D32C1A3 /* TutorialManager.swift */; };
3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89550F2CD19B950CCC6AD37F /* AuthManager.swift */; };
3C793FD5DA00D3E9C0D51FEC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 72FDF9C8DD37134576356B89 /* Assets.xcassets */; };
3E9DE1CF20BAC479805DF940 /* MyTasksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */; };
42E3B90A981E75FFC10C221F /* AnalyticsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0379128BF8B6C1CA7DF026D0 /* AnalyticsModels.swift */; };
45AA93D76970B39DB8BA6A5B /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */; };
497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */; };
4FB79BBC1EE0F6C7EF844B02 /* RecurrenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */; };
550E9A86BE36228C705E91C1 /* AnalyticsEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DEAB6A9224E02484292362A /* AnalyticsEngineTests.swift */; };
552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */; };
55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429806CE1021C8DE2EB770CE /* WidgetViews.swift */; };
5D0E254AB5D195AFDE09FABF /* StopCountdownIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91A354921717711EB3C0EE8E /* StopCountdownIntent.swift */; };
5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */; };
5F91DDE0B64C8AE1F142F434 /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A22FCC3FA1A10F52415E5AF /* AnalyticsService.swift */; };
67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 106EEF572C6F8990408329F0 /* OnboardingView.swift */; };
6921CB73A3257502FF778381 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */; };
6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */; };
@@ -36,27 +43,35 @@
7CEBC340BFA9238D121946AC /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */; };
8387093D19FB3397CCB8FEF8 /* WenzaWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF528FCC224EF283F95851AD /* WenzaWatchApp.swift */; };
83EA218392952885C97144D1 /* TodayMenuFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */; };
87564C27F644ED9CFF1E517B /* AnalyticsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E8855698633CC9A1172AEF1 /* AnalyticsStoreTests.swift */; };
8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23A4491BFA50721082024756 /* SharedComponents.swift */; };
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3D5289C5F93484E22DEB63 /* TutorialView.swift */; };
8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; };
9070521B1D36A5551976C275 /* CalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF6CCD95A587E26E30F5712 /* CalendarView.swift */; };
9AA3E4808E88CA639BF3F28B /* AnalyticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C6CEBCB6041EA41ED482F67 /* AnalyticsView.swift */; };
9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */; };
A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */; };
A292B0492135BA40F6B6A951 /* CalendarGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FCB2077D9509CB5C0978E58 /* CalendarGrid.swift */; };
A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72308FEE0226F45414C04DDD /* SettingsView.swift */; };
A9FF93259AE8FF0ABF69D71A /* DesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4A35C0E1270E2E15C03F23 /* DesignTokens.swift */; };
AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */; };
B0B83389E72D6DF4563D3DD4 /* AnalyticsAdapterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713DAF7EA146A779E4AC1D1A /* AnalyticsAdapterTests.swift */; };
B39F302F10FA4899AA0A5BAC /* AnalyticsDeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A32192C8620C6AB8499869F /* AnalyticsDeepLink.swift */; };
BD0DB4B0AA8A63D124EDFF2C /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5AFD143B693B77D07FBDA4 /* HealthKitManager.swift */; };
BEAFF968632A34C70B11C5AC /* MatrixView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D44530A77DF12A17E52AAF34 /* MatrixView.swift */; };
C69A21EC244513185A1F59BD /* WorkoutAnalyticsAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E839E4F5E6272515C3EA14D7 /* WorkoutAnalyticsAdapter.swift */; };
C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */; };
C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C786EBC7DF879D64EB28165E /* TodayView.swift */; };
CBE7295BF5ADE08FE93AFAAF /* FloatingTabState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9100804DB1E61EA882CC54DA /* FloatingTabState.swift */; };
CD3B0C436EA2D013FC3A6B5B /* RecurrenceRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A0B871AB37123908CF0CB20 /* RecurrenceRule.swift */; };
D1F2189D03CFF743A534A0C6 /* ActivityHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F0950E6F31016C848B2A63 /* ActivityHistoryView.swift */; };
D591A72235A53D4038FBC2B4 /* KisaniCalApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */; };
E135F5118E1D006F03AE2178 /* StreakLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D230156A72AAFBBAB7626629 /* StreakLogicTests.swift */; };
E6FCFC0D429627201A52C616 /* AnalyticsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10BD058FC3875B6BD6B10C90 /* AnalyticsStore.swift */; };
ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD014B7E3E30A34E18696A0 /* AuthView.swift */; };
EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */; };
EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */; };
FC3D7406706ADC96500AB764 /* AnalyticsCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0E5E96C6FC5DFC4E76452BF /* AnalyticsCoordinatorTests.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -93,19 +108,25 @@
/* Begin PBXFileReference section */
001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalApp.swift; sourceTree = "<group>"; };
01A27D42E141DC056D32C1A3 /* TutorialManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialManager.swift; sourceTree = "<group>"; };
0379128BF8B6C1CA7DF026D0 /* AnalyticsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsModels.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>"; };
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; };
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>"; };
10BD058FC3875B6BD6B10C90 /* AnalyticsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsStore.swift; sourceTree = "<group>"; };
1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; sourceTree = "<group>"; };
1C6CEBCB6041EA41ED482F67 /* AnalyticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsView.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>"; };
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>"; };
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>"; };
3A0B871AB37123908CF0CB20 /* RecurrenceRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurrenceRule.swift; sourceTree = "<group>"; };
3A22FCC3FA1A10F52415E5AF /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; };
3E8855698633CC9A1172AEF1 /* AnalyticsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsStoreTests.swift; sourceTree = "<group>"; };
42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalWidgets.swift; sourceTree = "<group>"; };
429806CE1021C8DE2EB770CE /* WidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetViews.swift; sourceTree = "<group>"; };
449C34805DC6B2CB66886544 /* CloudSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSyncManager.swift; sourceTree = "<group>"; };
@@ -115,20 +136,26 @@
61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetData.swift; sourceTree = "<group>"; };
670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskContextMenu.swift; sourceTree = "<group>"; };
69F0950E6F31016C848B2A63 /* ActivityHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityHistoryView.swift; sourceTree = "<group>"; };
713DAF7EA146A779E4AC1D1A /* AnalyticsAdapterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsAdapterTests.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>"; };
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>"; };
78EEE7568265196447E54D6B /* AnalyticsEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsEngine.swift; sourceTree = "<group>"; };
7A32192C8620C6AB8499869F /* AnalyticsDeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsDeepLink.swift; sourceTree = "<group>"; };
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>"; };
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>"; };
9100804DB1E61EA882CC54DA /* FloatingTabState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingTabState.swift; sourceTree = "<group>"; };
91A354921717711EB3C0EE8E /* StopCountdownIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopCountdownIntent.swift; 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>"; };
9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NLRecurrenceParseTests.swift; sourceTree = "<group>"; };
9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = KisaniCal.app; sourceTree = BUILT_PRODUCTS_DIR; };
9DEAB6A9224E02484292362A /* AnalyticsEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsEngineTests.swift; sourceTree = "<group>"; };
A0E5E96C6FC5DFC4E76452BF /* AnalyticsCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsCoordinatorTests.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>"; };
B4CFFDFE4653A9E901CEF28D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
@@ -139,8 +166,10 @@
C786EBC7DF879D64EB28165E /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = "<group>"; };
D230156A72AAFBBAB7626629 /* StreakLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakLogicTests.swift; sourceTree = "<group>"; };
D44530A77DF12A17E52AAF34 /* MatrixView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixView.swift; sourceTree = "<group>"; };
D4FB8B5BFF3FF62728E9498A /* AnalyticsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsCoordinator.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>"; };
E839E4F5E6272515C3EA14D7 /* WorkoutAnalyticsAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutAnalyticsAdapter.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>"; };
FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventCountdownWidget.swift; sourceTree = "<group>"; };
@@ -153,6 +182,10 @@
068B77B2F01C399C7A430292 /* KisaniCalTests */ = {
isa = PBXGroup;
children = (
713DAF7EA146A779E4AC1D1A /* AnalyticsAdapterTests.swift */,
A0E5E96C6FC5DFC4E76452BF /* AnalyticsCoordinatorTests.swift */,
9DEAB6A9224E02484292362A /* AnalyticsEngineTests.swift */,
3E8855698633CC9A1172AEF1 /* AnalyticsStoreTests.swift */,
74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */,
9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */,
D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */,
@@ -170,6 +203,14 @@
path = Components;
sourceTree = "<group>";
};
2217B7EEC45957B820311EC7 /* Shared */ = {
isa = PBXGroup;
children = (
3A0B871AB37123908CF0CB20 /* RecurrenceRule.swift */,
);
path = Shared;
sourceTree = "<group>";
};
23CBCF100C5EF55E737379CA /* Models */ = {
isa = PBXGroup;
children = (
@@ -196,6 +237,7 @@
F70DA4746C68CD405435DAB6 /* KisaniCal */,
068B77B2F01C399C7A430292 /* KisaniCalTests */,
E8D727EEA0C3A4B8006FB087 /* KisaniCalWidgets */,
2217B7EEC45957B820311EC7 /* Shared */,
48146B56E740528496663D47 /* WenzaWatch */,
51BD1B5DEDE9FAD9CA2FF6DA /* Products */,
);
@@ -217,6 +259,7 @@
children = (
69F0950E6F31016C848B2A63 /* ActivityHistoryView.swift */,
768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */,
1C6CEBCB6041EA41ED482F67 /* AnalyticsView.swift */,
4AD014B7E3E30A34E18696A0 /* AuthView.swift */,
ADF6CCD95A587E26E30F5712 /* CalendarView.swift */,
D44530A77DF12A17E52AAF34 /* MatrixView.swift */,
@@ -241,6 +284,20 @@
path = "Preview Content";
sourceTree = "<group>";
};
C7409CC354029293D424BEDA /* Analytics */ = {
isa = PBXGroup;
children = (
D4FB8B5BFF3FF62728E9498A /* AnalyticsCoordinator.swift */,
7A32192C8620C6AB8499869F /* AnalyticsDeepLink.swift */,
78EEE7568265196447E54D6B /* AnalyticsEngine.swift */,
0379128BF8B6C1CA7DF026D0 /* AnalyticsModels.swift */,
3A22FCC3FA1A10F52415E5AF /* AnalyticsService.swift */,
10BD058FC3875B6BD6B10C90 /* AnalyticsStore.swift */,
E839E4F5E6272515C3EA14D7 /* WorkoutAnalyticsAdapter.swift */,
);
path = Analytics;
sourceTree = "<group>";
};
E0A03E79A679740978E61BF1 /* Theme */ = {
isa = PBXGroup;
children = (
@@ -257,6 +314,7 @@
8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */,
42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */,
208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */,
91A354921717711EB3C0EE8E /* StopCountdownIntent.swift */,
0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */,
61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */,
429806CE1021C8DE2EB770CE /* WidgetViews.swift */,
@@ -273,6 +331,7 @@
1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */,
001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */,
C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */,
C7409CC354029293D424BEDA /* Analytics */,
21B93C269F283F11B415B18C /* Components */,
FB9BF734B9E493EEB09ACE21 /* Managers */,
23CBCF100C5EF55E737379CA /* Models */,
@@ -442,6 +501,10 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B0B83389E72D6DF4563D3DD4 /* AnalyticsAdapterTests.swift in Sources */,
FC3D7406706ADC96500AB764 /* AnalyticsCoordinatorTests.swift in Sources */,
550E9A86BE36228C705E91C1 /* AnalyticsEngineTests.swift in Sources */,
87564C27F644ED9CFF1E517B /* AnalyticsStoreTests.swift in Sources */,
6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */,
16FFC465D8646DED9A5C69D1 /* NLRecurrenceParseTests.swift in Sources */,
4FB79BBC1EE0F6C7EF844B02 /* RecurrenceTests.swift in Sources */,
@@ -463,6 +526,13 @@
files = (
D1F2189D03CFF743A534A0C6 /* ActivityHistoryView.swift in Sources */,
A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */,
023A636FF533304DA5578D1C /* AnalyticsCoordinator.swift in Sources */,
B39F302F10FA4899AA0A5BAC /* AnalyticsDeepLink.swift in Sources */,
152AECD42FCF554C582E2DEA /* AnalyticsEngine.swift in Sources */,
42E3B90A981E75FFC10C221F /* AnalyticsModels.swift in Sources */,
5F91DDE0B64C8AE1F142F434 /* AnalyticsService.swift in Sources */,
E6FCFC0D429627201A52C616 /* AnalyticsStore.swift in Sources */,
9AA3E4808E88CA639BF3F28B /* AnalyticsView.swift in Sources */,
2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */,
3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */,
ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */,
@@ -480,6 +550,7 @@
EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */,
67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */,
5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */,
30D155A1314CBD4BB1744B29 /* RecurrenceRule.swift in Sources */,
A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */,
8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */,
497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */,
@@ -492,6 +563,7 @@
C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */,
3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */,
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */,
C69A21EC244513185A1F59BD /* WorkoutAnalyticsAdapter.swift in Sources */,
9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -504,6 +576,8 @@
552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */,
C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */,
3E9DE1CF20BAC479805DF940 /* MyTasksWidget.swift in Sources */,
CD3B0C436EA2D013FC3A6B5B /* RecurrenceRule.swift in Sources */,
5D0E254AB5D195AFDE09FABF /* StopCountdownIntent.swift in Sources */,
8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */,
205846F9651EDCE0D8207358 /* TaskLiveActivity.swift in Sources */,
AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */,
@@ -615,7 +689,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 10;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = K8BLMMR883;
ENABLE_NS_ASSERTIONS = NO;
@@ -709,7 +783,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 10;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = K8BLMMR883;
ENABLE_STRICT_OBJC_MSGSEND = YES;

View File

@@ -0,0 +1,76 @@
import Foundation
/// Generates any missing immutable weekly/monthly reports for completed periods,
/// then prunes beyond the retention window. Idempotent: already-generated periods
/// are skipped (dedup by store key), so it is safe to run on every launch /
/// foreground without depending on exact background execution times.
///
/// Sample providers are injected so the reconcile logic stays independent of app
/// models / HealthKit and is unit-testable. The app wires the providers to
/// `WorkoutAnalyticsAdapter` + `WorkoutViewModel` + `HealthKitManager`.
final class AnalyticsCoordinator {
struct ReconcileResult: Equatable {
var weekliesGenerated: [String] = []
var monthliesGenerated: [String] = []
var isEmpty: Bool { weekliesGenerated.isEmpty && monthliesGenerated.isEmpty }
}
/// Samples for a completed period plus its comparison baseline.
typealias PeriodSamples = (days: [DaySample], previous: [DaySample])
private let store: AnalyticsStore
private let calendar: Calendar
private let now: () -> Date
private let retentionMonths: Int
private let weekSamples: (_ weekStartKey: String) -> PeriodSamples
private let monthSamples: (_ monthKey: String) -> PeriodSamples
init(store: AnalyticsStore,
calendar: Calendar,
retentionMonths: Int = 12,
now: @escaping () -> Date = Date.init,
weekSamples: @escaping (String) -> PeriodSamples,
monthSamples: @escaping (String) -> PeriodSamples) {
self.store = store
self.calendar = calendar
self.retentionMonths = retentionMonths
self.now = now
self.weekSamples = weekSamples
self.monthSamples = monthSamples
}
/// Backfill every completed week/month within the retention window that has
/// no report yet, then prune expired data. Returns which periods were newly
/// generated (empty on a no-op run drives "should I notify?" decisions).
@discardableResult
func reconcile() -> ReconcileResult {
let ref = now()
var result = ReconcileResult()
let allWeeks = AnalyticsEngine.completedWeekStartKeys(now: ref, months: retentionMonths, calendar: calendar)
for key in AnalyticsEngine.missingKeys(allWeeks, existing: store.existingWeeklyKeys()) {
let s = weekSamples(key)
let report = AnalyticsEngine.weeklyReport(weekStartKey: key, days: s.days,
previousWeekDays: s.previous, generatedAt: ref)
if store.saveWeeklyReport(report) { result.weekliesGenerated.append(key) }
}
let allMonths = AnalyticsEngine.completedMonthKeys(now: ref, months: retentionMonths, calendar: calendar)
for key in AnalyticsEngine.missingKeys(allMonths, existing: store.existingMonthlyKeys()) {
let s = monthSamples(key)
let report = AnalyticsEngine.monthlyReport(monthKey: key, days: s.days,
previousMonthDays: s.previous, generatedAt: ref)
if store.saveMonthlyReport(report) { result.monthliesGenerated.append(key) }
}
store.prune(now: ref, months: retentionMonths, calendar: calendar)
return result
}
/// The most recent completed weekly / monthly report keys (for "notify about
/// the latest report" without duplicating). Deep links for these come from
/// `AnalyticsDeepLink`.
func latestWeeklyKey() -> String? { store.existingWeeklyKeys().max() }
func latestMonthlyKey() -> String? { store.existingMonthlyKeys().max() }
}

View File

@@ -0,0 +1,58 @@
import Foundation
/// Deep links into the Analytics area used by weekly/monthly report
/// notifications to route straight to the relevant report. Pure value type with
/// symmetric URL encode/decode so routing is testable without any UI.
///
/// Format: wenza://analytics overview
/// wenza://analytics/weekly/<yyyy-MM-dd> a weekly report
/// wenza://analytics/monthly/<yyyy-MM> a monthly report
enum AnalyticsDeepLink: Equatable, Identifiable {
case overview
case weekly(weekStartKey: String)
case monthly(monthKey: String)
static let scheme = "wenza"
static let host = "analytics"
var id: String { stringValue }
/// Stable userInfo key carried on notifications.
static let userInfoKey = "kisani.analytics.deeplink"
var url: URL {
var c = URLComponents()
c.scheme = Self.scheme
c.host = Self.host
switch self {
case .overview: c.path = ""
case .weekly(let key): c.path = "/weekly/\(key)"
case .monthly(let key): c.path = "/monthly/\(key)"
}
return c.url!
}
init?(url: URL) {
guard url.scheme == Self.scheme, url.host == Self.host else { return nil }
let parts = url.path.split(separator: "/").map(String.init)
switch parts.first {
case nil:
self = .overview
case "weekly":
guard parts.count >= 2 else { return nil }
self = .weekly(weekStartKey: parts[1])
case "monthly":
guard parts.count >= 2 else { return nil }
self = .monthly(monthKey: parts[1])
default:
return nil
}
}
/// Build/parse via a plain string (for notification userInfo payloads).
var stringValue: String { url.absoluteString }
init?(string: String) {
guard let url = URL(string: string) else { return nil }
self.init(url: url)
}
}

View File

@@ -0,0 +1,330 @@
import Foundation
// Deterministic, side-effect-free analytics calculations. No SwiftUI, no
// persistence, no HealthKit, no LLM pure functions over value types so every
// formula is unit-testable and reproducible. All calendar math takes an injected
// `Calendar` so locale / firstWeekday / timezone / DST behavior is testable.
//
// FORMULAS
// volume = Σ (weight × reps) over completed sets [kg]
// percentChange = (current baseline) / |baseline| × 100 [%]
// consistency = min(1, sessions / scheduled) [01]
// trend = classify(least-squares slope / |mean|) vs threshold
//
// All comparisons degrade honestly: missing baseline no %, tiny/zero baseline
// no % (avoids divide-by-tiny), new exercise / rest day not meaningful.
// Language is factual and supportive; nothing here diagnoses or advises medically.
enum AnalyticsEngine {
/// Informational, non-medical disclaimer surfaced with every report.
static let disclaimer = """
These are informational summaries of your own logged activity — not medical \
advice, diagnosis, or treatment. Talk to a qualified professional for health \
decisions.
"""
// MARK: - Exercise identity
/// Normalized identity so the same exercise matches across days regardless of
/// casing / whitespace. Deterministic and locale-independent.
static func exerciseIdentity(_ name: String) -> String {
name.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
.replacingOccurrences(of: " ", with: " ")
}
// MARK: - Delta
/// Compare `current` against `baseline`, guarding percentage honesty.
/// - `isNew`: no prior baseline exists (e.g. a newly added exercise).
/// - `restDay`: baseline day was a rest day % is not meaningful.
static func delta(current: Double,
baseline: Double?,
minMeaningfulBaseline: Double = 0.0001,
isNew: Bool = false,
restDay: Bool = false) -> MetricDelta {
guard let base = baseline, !isNew, !restDay else {
return MetricDelta(current: current,
baseline: baseline,
absolute: baseline.map { current - $0 },
percent: nil,
direction: current > 0 ? .up : .flat,
meaningful: false)
}
let abs = current - base
let dir: MetricDirection = abs > 0 ? .up : (abs < 0 ? .down : .flat)
// Only compute % when the baseline magnitude is meaningful.
let pct: Double? = Swift.abs(base) >= minMeaningfulBaseline ? (abs / Swift.abs(base)) * 100 : nil
return MetricDelta(current: current,
baseline: base,
absolute: abs,
percent: pct,
direction: dir,
meaningful: pct != nil)
}
/// Classify a single week/month-over-period change from its volume delta.
static func classifyChange(_ d: MetricDelta, plateauPct: Double = 3) -> TrendClassification {
guard d.meaningful, let pct = d.percent else { return .insufficientData }
if pct > plateauPct { return .improving }
if pct < -plateauPct { return .declining }
return .plateau
}
// MARK: - Consistency
static func consistency(sessions: Int, scheduled: Int) -> Double {
guard scheduled > 0 else { return 0 }
return min(1.0, Double(sessions) / Double(scheduled))
}
// MARK: - Volume
static func volume(weight: Double, reps: Int) -> Double { weight * Double(reps) }
// MARK: - Trend (multi-point, for 12-month views)
static func classifyTrend(_ values: [Double],
minPoints: Int = 3,
relativeThreshold: Double = 0.03) -> TrendResult {
let n = values.count
guard n >= minPoints else {
return TrendResult(classification: .insufficientData, slope: 0, points: n)
}
let slope = leastSquaresSlope(values)
let mean = values.reduce(0, +) / Double(n)
guard Swift.abs(mean) > 0 else {
let cls: TrendClassification = slope == 0 ? .plateau : (slope > 0 ? .improving : .declining)
return TrendResult(classification: cls, slope: slope, points: n)
}
let rel = slope / Swift.abs(mean)
let cls: TrendClassification
if rel > relativeThreshold { cls = .improving }
else if rel < -relativeThreshold { cls = .declining }
else { cls = .plateau }
return TrendResult(classification: cls, slope: slope, points: n)
}
static func leastSquaresSlope(_ y: [Double]) -> Double {
let n = Double(y.count)
guard n > 1 else { return 0 }
let xs = (0..<y.count).map(Double.init)
let sumX = xs.reduce(0, +)
let sumY = y.reduce(0, +)
let sumXY = zip(xs, y).map(*).reduce(0, +)
let sumXX = xs.map { $0 * $0 }.reduce(0, +)
let denom = n * sumXX - sumX * sumX
guard denom != 0 else { return 0 }
return (n * sumXY - sumX * sumY) / denom
}
// MARK: - Outliers
/// Winsorize to the [lower, upper] percentile band to tame outliers before
/// trend/mean calculations. No-op for very small samples.
static func winsorize(_ values: [Double], lower: Double = 0.1, upper: Double = 0.9) -> [Double] {
guard values.count >= 5 else { return values }
let sorted = values.sorted()
let lo = percentile(sorted, lower)
let hi = percentile(sorted, upper)
return values.map { min(max($0, lo), hi) }
}
static func percentile(_ sorted: [Double], _ p: Double) -> Double {
guard !sorted.isEmpty else { return 0 }
let idx = Int((Double(sorted.count - 1) * min(max(p, 0), 1)).rounded())
return sorted[min(max(idx, 0), sorted.count - 1)]
}
// MARK: - Exercise comparison
static func compareExercises(current: [ExerciseSample],
baseline: [ExerciseSample],
restDay: Bool = false) -> [ExerciseComparison] {
let baseByIdentity = Dictionary(baseline.map { ($0.identity, $0) }, uniquingKeysWith: { a, _ in a })
return current.map { cur in
let base = baseByIdentity[cur.identity]
let isNew = base == nil
return ExerciseComparison(
identity: cur.identity,
volume: delta(current: cur.volume, baseline: base?.volume, isNew: isNew, restDay: restDay),
sets: delta(current: Double(cur.sets), baseline: base.map { Double($0.sets) }, isNew: isNew, restDay: restDay),
reps: delta(current: Double(cur.reps), baseline: base.map { Double($0.reps) }, isNew: isNew, restDay: restDay),
isNew: isNew
)
}
}
// MARK: - Daily comparison
static func dailyComparison(day: DaySample,
previousDay: DaySample?,
sameWeekdayLastWeek: DaySample?) -> DailyComparison {
DailyComparison(
dateKey: day.dateKey,
restDay: !day.didWorkout,
vsPreviousDay: compareExercises(current: day.exercises,
baseline: previousDay?.exercises ?? [],
restDay: previousDay.map { !$0.didWorkout } ?? true),
vsSameWeekdayLastWeek: compareExercises(current: day.exercises,
baseline: sameWeekdayLastWeek?.exercises ?? [],
restDay: sameWeekdayLastWeek.map { !$0.didWorkout } ?? true)
)
}
// MARK: - Period summaries & reports
static func summarize(_ days: [DaySample]) -> PeriodSummary {
let sessions = days.filter { $0.didWorkout }.count
let scheduled = days.filter { $0.scheduled }.count
let missed = days.filter { $0.scheduled && !$0.didWorkout }.count
let sets = days.reduce(0) { $0 + $1.sets }
let volume = days.reduce(0) { $0 + $1.volume }
let duration = days.reduce(0) { $0 + $1.durationMinutes }
func avg(_ vals: [Int]) -> Int? { vals.isEmpty ? nil : Int((Double(vals.reduce(0, +)) / Double(vals.count)).rounded()) }
return PeriodSummary(sessions: sessions, sets: sets, volume: volume,
durationMinutes: duration, scheduled: scheduled,
missed: missed, consistency: consistency(sessions: sessions, scheduled: scheduled),
avgSteps: avg(days.compactMap { $0.steps }),
avgActiveCalories: avg(days.compactMap { $0.activeCalories }),
avgRestingHR: avg(days.compactMap { $0.restingHeartRate }))
}
private static func periodDeltas(current cur: PeriodSummary, previous prev: PeriodSummary) -> [String: MetricDelta] {
[
"sessions": delta(current: Double(cur.sessions), baseline: Double(prev.sessions)),
"sets": delta(current: Double(cur.sets), baseline: Double(prev.sets)),
"volume": delta(current: cur.volume, baseline: prev.volume),
"duration": delta(current: cur.durationMinutes, baseline: prev.durationMinutes),
"consistency": delta(current: cur.consistency * 100, baseline: prev.consistency * 100),
]
}
static func weeklyReport(weekStartKey: String,
days: [DaySample],
previousWeekDays: [DaySample],
generatedAt: Date) -> WeeklyReport {
let cur = summarize(days)
let prev = summarize(previousWeekDays)
let deltas = periodDeltas(current: cur, previous: prev)
return WeeklyReport(
weekStartKey: weekStartKey,
generatedAt: generatedAt,
summary: cur,
vsPreviousWeek: deltas,
progression: classifyChange(deltas["volume"] ?? delta(current: 0, baseline: nil)),
disclaimer: disclaimer
)
}
static func monthlyReport(monthKey: String,
days: [DaySample],
previousMonthDays: [DaySample],
generatedAt: Date) -> MonthlyReport {
let cur = summarize(days)
let prev = summarize(previousMonthDays)
let deltas = periodDeltas(current: cur, previous: prev)
return MonthlyReport(
monthKey: monthKey,
generatedAt: generatedAt,
summary: cur,
vsPreviousMonth: deltas,
classification: classifyChange(deltas["volume"] ?? delta(current: 0, baseline: nil)),
disclaimer: disclaimer
)
}
// MARK: - Calendar helpers (injected Calendar locale/TZ/DST-safe)
private static func keyFormatter(_ calendar: Calendar) -> DateFormatter {
let f = DateFormatter()
f.calendar = calendar
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = calendar.timeZone
f.dateFormat = "yyyy-MM-dd"
return f
}
static func dateKey(for date: Date, calendar: Calendar) -> String {
keyFormatter(calendar).string(from: calendar.startOfDay(for: date))
}
static func monthKey(for date: Date, calendar: Calendar) -> String {
let f = keyFormatter(calendar); f.dateFormat = "yyyy-MM"
return f.string(from: date)
}
static func weekInterval(containing date: Date, calendar: Calendar) -> DateInterval? {
calendar.dateInterval(of: .weekOfYear, for: date)
}
static func monthInterval(containing date: Date, calendar: Calendar) -> DateInterval? {
calendar.dateInterval(of: .month, for: date)
}
/// The same weekday one week earlier used for the day-vs-last-week compare.
static func sameWeekdayLastWeek(_ date: Date, calendar: Calendar) -> Date? {
calendar.date(byAdding: .weekOfYear, value: -1, to: date)
}
/// Whether `date` falls in a completed period (strictly before the period
/// containing `now`) reports are only generated for completed periods.
static func isCompletedWeek(_ date: Date, now: Date, calendar: Calendar) -> Bool {
guard let w = weekInterval(containing: date, calendar: calendar) else { return false }
return w.end <= (weekInterval(containing: now, calendar: calendar)?.start ?? now)
}
static func isCompletedMonth(_ date: Date, now: Date, calendar: Calendar) -> Bool {
guard let m = monthInterval(containing: date, calendar: calendar) else { return false }
return m.end <= (monthInterval(containing: now, calendar: calendar)?.start ?? now)
}
// MARK: - Retention
/// Keys older than `months` before `now` (for safe pruning of raw data).
static func expiredDayKeys(_ keys: [String], now: Date, months: Int = 12, calendar: Calendar) -> [String] {
guard let cutoff = calendar.date(byAdding: .month, value: -months, to: calendar.startOfDay(for: now)) else { return [] }
let cutoffKey = dateKey(for: cutoff, calendar: calendar)
return keys.filter { $0 < cutoffKey }
}
// MARK: - Reconciliation selection (which completed periods need a report)
/// Week-start keys of every completed week within the last `months`, oldestnewest.
/// Only weeks whose start is on/after the retention cutoff are included, so
/// generation and `prune` agree otherwise a boundary week would be
/// generated then immediately pruned, regenerating on every run.
static func completedWeekStartKeys(now: Date, months: Int = 12, calendar cal: Calendar) -> [String] {
guard let windowStart = cal.date(byAdding: .month, value: -months, to: now),
let thisWeek = weekInterval(containing: now, calendar: cal),
var cursor = weekInterval(containing: windowStart, calendar: cal)?.start else { return [] }
let cutoffKey = dateKey(for: cal.startOfDay(for: windowStart), calendar: cal)
var keys: [String] = []
var guardCount = 0
while cursor < thisWeek.start, guardCount < 70 {
let key = dateKey(for: cursor, calendar: cal)
if key >= cutoffKey { keys.append(key) }
guard let next = cal.date(byAdding: .weekOfYear, value: 1, to: cursor) else { break }
cursor = next; guardCount += 1
}
return keys
}
/// Month keys of every completed month within the last `months`, oldestnewest.
static func completedMonthKeys(now: Date, months: Int = 12, calendar cal: Calendar) -> [String] {
var keys: [String] = []
for m in stride(from: months, through: 1, by: -1) {
guard let d = cal.date(byAdding: .month, value: -m, to: now) else { continue }
let key = monthKey(for: d, calendar: cal)
if !keys.contains(key) { keys.append(key) }
}
return keys
}
/// Keys present in `all` but not in `existing` the periods still needing a
/// report. Idempotent by construction: already-generated periods are skipped.
static func missingKeys(_ all: [String], existing: Set<String>) -> [String] {
all.filter { !existing.contains($0) }
}
}

View File

@@ -0,0 +1,171 @@
import Foundation
// Pure value types for the analytics system. No SwiftUI, no persistence these
// are the inputs and outputs of `AnalyticsEngine`, kept decoupled from app
// models so the engine is deterministic and unit-testable in isolation.
// Adapters from WorkoutDayLog / HealthKit live in the persistence layer.
// MARK: - Direction & trend
/// Semantic direction of a change. Paired in UI with a non-color glyph so status
/// is never conveyed by color alone.
enum MetricDirection: String, Codable, Equatable {
case up, down, flat, none
}
enum TrendClassification: String, Codable, Equatable {
case improving, declining, plateau, insufficientData
}
struct TrendResult: Codable, Equatable {
let classification: TrendClassification
let slope: Double // least-squares slope, units per period
let points: Int
}
// MARK: - Unit compatibility
/// Metrics are only comparable within the same physical dimension. Prevents
/// nonsense like comparing kilograms to minutes.
enum MetricUnit: String, Codable, Equatable {
case kilograms, pounds, count, minutes, kilometers, miles, kilocalories, bpm, percent, none
var dimension: String {
switch self {
case .kilograms, .pounds: return "mass"
case .kilometers, .miles: return "distance"
case .minutes: return "time"
case .kilocalories: return "energy"
case .bpm: return "rate"
case .count: return "count"
case .percent: return "ratio"
case .none: return "none"
}
}
func isComparable(with other: MetricUnit) -> Bool { dimension == other.dimension }
}
// MARK: - Delta
/// One metric compared against a baseline. `percent` is nil when there is no
/// baseline, the baseline is too small to be meaningful, or the comparison is
/// otherwise not honest (new item / rest day). `meaningful` gates UI display.
struct MetricDelta: Codable, Equatable {
let current: Double
let baseline: Double?
let absolute: Double?
let percent: Double?
let direction: MetricDirection
let meaningful: Bool
}
// MARK: - Samples (engine inputs)
/// A single exercise's contribution on a day, keyed by a normalized identity.
struct ExerciseSample: Codable, Equatable {
let identity: String // normalized identity key (see AnalyticsEngine.exerciseIdentity)
let sets: Int
let reps: Int // total reps across completed sets
let volume: Double // Σ weight×reps of completed sets, kg
}
/// A day of activity: app workout record + optional HealthKit reference values.
/// HealthKit fields are optional because authorization/data may be missing.
struct DaySample: Codable, Equatable {
let dateKey: String // "yyyy-MM-dd" in the reporting calendar
let didWorkout: Bool
let scheduled: Bool
let sessions: Int
let sets: Int
let volume: Double
let durationMinutes: Double
let exercises: [ExerciseSample]
// HealthKit reference (source of truth for health measurements) may be nil.
let steps: Int?
let activeCalories: Int?
let restingHeartRate: Int?
init(dateKey: String, didWorkout: Bool = false, scheduled: Bool = false,
sessions: Int = 0, sets: Int = 0, volume: Double = 0, durationMinutes: Double = 0,
exercises: [ExerciseSample] = [], steps: Int? = nil,
activeCalories: Int? = nil, restingHeartRate: Int? = nil) {
self.dateKey = dateKey
self.didWorkout = didWorkout
self.scheduled = scheduled
self.sessions = sessions
self.sets = sets
self.volume = volume
self.durationMinutes = durationMinutes
self.exercises = exercises
self.steps = steps
self.activeCalories = activeCalories
self.restingHeartRate = restingHeartRate
}
}
// MARK: - Comparisons & reports (engine outputs; immutable snapshots)
struct ExerciseComparison: Codable, Equatable {
let identity: String
let volume: MetricDelta
let sets: MetricDelta
let reps: MetricDelta
let isNew: Bool
}
struct DailyComparison: Codable, Equatable {
let dateKey: String
let restDay: Bool
let vsPreviousDay: [ExerciseComparison]
let vsSameWeekdayLastWeek: [ExerciseComparison]
}
struct PeriodSummary: Codable, Equatable {
let sessions: Int
let sets: Int
let volume: Double
let durationMinutes: Double
let scheduled: Int
let missed: Int
let consistency: Double // 0...1
// Available HealthKit trends nil when no reference data in the period.
var avgSteps: Int? = nil
var avgActiveCalories: Int? = nil
var avgRestingHR: Int? = nil
}
/// Immutable weekly report. Once generated with `generatedAt`, later edits to
/// source data never change it (the persistence layer stores it frozen).
struct WeeklyReport: Codable, Equatable, Identifiable {
let weekStartKey: String // "yyyy-MM-dd" of the week's first day
let generatedAt: Date
let summary: PeriodSummary
let vsPreviousWeek: [String: MetricDelta]
let progression: TrendClassification
let disclaimer: String
var id: String { weekStartKey }
}
// Chart-friendly Identifiable points (tuples can't be keyed in SwiftUI Charts).
struct MonthVolumePoint: Identifiable, Equatable {
let monthKey: String
let volume: Double
var id: String { monthKey }
}
struct ExerciseVolumePoint: Identifiable, Equatable {
let dateKey: String
let volume: Double
var id: String { dateKey }
}
struct MonthlyReport: Codable, Equatable, Identifiable {
let monthKey: String // "yyyy-MM"
let generatedAt: Date
let summary: PeriodSummary
let vsPreviousMonth: [String: MetricDelta]
let classification: TrendClassification
let disclaimer: String
var id: String { monthKey }
}

View File

@@ -0,0 +1,190 @@
import Foundation
// App-side glue for the analytics system. Owns the file store, builds the
// reconcile coordinator from a snapshot of live workout data, and exposes read
// APIs for the Analytics UI. Pure engine/store/coordinator/adapter are unit-
// tested; this layer is thin wiring (compiles in-target; not standalone-tested).
//
// Sample building is done by nonisolated static functions over a captured
// `WorkoutSnapshot` value, so the coordinator's closures don't touch main-actor
// state. HealthKit per-day historical reference is optional and currently left
// nil (honest never fabricated); wiring it is a documented follow-up.
@MainActor
final class AnalyticsService: ObservableObject {
static let shared = AnalyticsService()
let store: AnalyticsStore
private let calendar: Calendar
@Published private(set) var lastReconcile: Date?
/// Snapshot from the most recent reconcile, for UI reads (daily comparison,
/// per-exercise history) without passing the view model around.
private var snapshot = WorkoutSnapshot()
init(store: AnalyticsStore = AnalyticsStore(), calendar: Calendar = .current) {
self.store = store
self.calendar = calendar
}
// MARK: - Snapshot of live workout data (a value type)
struct WorkoutSnapshot {
var logByDate: [String: WorkoutDayLog] = [:]
var workoutDates: Set<String> = []
var scheduledWeekdays: Set<Int> = []
var missedDates: Set<String> = []
var healthByDate: [String: RawHealthDay] = [:] // HealthKit reference
init() {}
@MainActor init(vm: WorkoutViewModel) {
self.logByDate = Dictionary(vm.history.map { ($0.date, $0) }, uniquingKeysWith: { a, _ in a })
self.workoutDates = Set(vm.workoutDates)
self.scheduledWeekdays = Set(vm.schedule.keys)
self.missedDates = Set(vm.missedDates)
}
}
/// Cached HealthKit per-day reference (refreshed async). Merged into snapshots.
private var healthByDate: [String: RawHealthDay] = [:]
// MARK: - Reconcile (call on launch + scenePhase active)
/// Snapshot recent days, backfill missing completed weekly/monthly reports,
/// prune. Idempotent safe to call every launch/foreground.
@discardableResult
func reconcile(using vm: WorkoutViewModel, now: Date = Date()) -> AnalyticsCoordinator.ReconcileResult {
var snap = WorkoutSnapshot(vm: vm)
snap.healthByDate = healthByDate
snapshot = snap
freezeRecentSnapshots(snap, now: now)
let result = makeCoordinator(snap, now: now).reconcile()
lastReconcile = now
return result
}
/// Refresh the HealthKit per-day reference cache (~13 months). Call from the
/// app's async Health sync path, then reconcile so new snapshots capture it.
func refreshHealthReference() async {
guard let start = calendar.date(byAdding: .month, value: -13, to: Date()) else { return }
let raw = await HealthKitManager.shared.dailyReference(from: start, to: Date())
healthByDate = raw.mapValues { RawHealthDay(steps: $0.steps, activeCalories: $0.calories, restingHeartRate: $0.restingHR) }
}
private func makeCoordinator(_ snap: WorkoutSnapshot, now: Date) -> AnalyticsCoordinator {
let cal = calendar
return AnalyticsCoordinator(
store: store, calendar: cal, retentionMonths: 12, now: { now },
weekSamples: { key in
let start = Self.date(fromKey: key, calendar: cal) ?? now
let prevStart = cal.date(byAdding: .weekOfYear, value: -1, to: start) ?? start
return (Self.buildSamples(WorkoutAnalyticsAdapter.weekDayKeys(containing: start, calendar: cal), snap, cal),
Self.buildSamples(WorkoutAnalyticsAdapter.weekDayKeys(containing: prevStart, calendar: cal), snap, cal))
},
monthSamples: { key in
guard let start = Self.date(fromMonthKey: key, calendar: cal) else { return ([], []) }
let prevStart = cal.date(byAdding: .month, value: -1, to: start) ?? start
return (Self.buildSamples(WorkoutAnalyticsAdapter.monthDayKeys(containing: start, calendar: cal), snap, cal),
Self.buildSamples(WorkoutAnalyticsAdapter.monthDayKeys(containing: prevStart, calendar: cal), snap, cal))
})
}
/// Freeze immutable snapshots for completed recent days; refresh today's live.
private func freezeRecentSnapshots(_ snap: WorkoutSnapshot, now: Date) {
let today = calendar.startOfDay(for: now)
for back in 0...8 {
guard let d = calendar.date(byAdding: .day, value: -back, to: today) else { continue }
let key = AnalyticsEngine.dateKey(for: d, calendar: calendar)
store.saveSnapshot(Self.buildSamples([key], snap, calendar).first ?? DaySample(dateKey: key), isPast: back > 0)
}
}
// MARK: - Sample building (nonisolated, pure over the snapshot value)
nonisolated static func buildSamples(_ keys: [String], _ snap: WorkoutSnapshot, _ cal: Calendar) -> [DaySample] {
var workouts: [String: RawWorkoutDay] = [:]
var scheduled: Set<String> = []
for key in keys {
if let d = date(fromKey: key, calendar: cal), snap.scheduledWeekdays.contains(cal.component(.weekday, from: d)) {
scheduled.insert(key)
}
if let log = snap.logByDate[key] {
workouts[key] = raw(from: log, done: snap.workoutDates.contains(key))
} else if snap.workoutDates.contains(key) {
workouts[key] = RawWorkoutDay(dateKey: key, didWorkout: true, sessions: 1, durationMinutes: 0, exercises: [])
}
}
return WorkoutAnalyticsAdapter.daySamples(dayKeys: keys, workouts: workouts,
scheduledKeys: scheduled, health: snap.healthByDate)
}
nonisolated private static func raw(from log: WorkoutDayLog, done: Bool) -> RawWorkoutDay {
let samples = log.exercises.map { ex -> ExerciseSample in
let completed = ex.sets.filter { $0.done }.map { (weight: $0.weight, reps: $0.reps) }
return WorkoutAnalyticsAdapter.exerciseSample(name: ex.name, completedSets: completed)
}
return RawWorkoutDay(dateKey: log.date, didWorkout: done || !samples.isEmpty,
sessions: 1, durationMinutes: 0, exercises: samples)
}
// MARK: - Read APIs for the UI
func weeklyReports() -> [WeeklyReport] { store.allWeeklyReports().sorted { $0.weekStartKey > $1.weekStartKey } }
func monthlyReports() -> [MonthlyReport] { store.allMonthlyReports().sorted { $0.monthKey > $1.monthKey } }
func monthlyVolumeTrend() -> [MonthVolumePoint] {
store.allMonthlyReports().sorted { $0.monthKey < $1.monthKey }
.map { MonthVolumePoint(monthKey: $0.monthKey, volume: $0.summary.volume) }
}
func monthlyTrendClassification() -> TrendResult {
AnalyticsEngine.classifyTrend(monthlyVolumeTrend().map(\.volume))
}
/// Daily comparison for `date`: vs previous day and vs same weekday last week.
func dailyComparison(on date: Date) -> DailyComparison {
let dayKey = AnalyticsEngine.dateKey(for: date, calendar: calendar)
let prev = calendar.date(byAdding: .day, value: -1, to: date)
let lastWeek = AnalyticsEngine.sameWeekdayLastWeek(date, calendar: calendar)
return AnalyticsEngine.dailyComparison(
day: daySample(forKey: dayKey),
previousDay: prev.map { daySample(forKey: AnalyticsEngine.dateKey(for: $0, calendar: calendar)) },
sameWeekdayLastWeek: lastWeek.map { daySample(forKey: AnalyticsEngine.dateKey(for: $0, calendar: calendar)) })
}
private func daySample(forKey key: String) -> DaySample {
Self.buildSamples([key], snapshot, calendar).first ?? DaySample(dateKey: key)
}
/// Per-exercise volume history (dateKeyvolume) across stored snapshots.
func exerciseHistory(identity: String) -> [ExerciseVolumePoint] {
store.allSnapshotKeys().sorted().compactMap { key in
guard let sample = store.snapshot(dateKey: key),
let ex = sample.exercises.first(where: { $0.identity == identity }) else { return nil }
return ExerciseVolumePoint(dateKey: key, volume: ex.volume)
}
}
/// Distinct exercise identities across stored snapshots (for the picker).
func knownExerciseIdentities() -> [String] {
var set = Set<String>()
for key in store.allSnapshotKeys() {
store.snapshot(dateKey: key)?.exercises.forEach { set.insert($0.identity) }
}
return set.sorted()
}
// MARK: - Key helpers (nonisolated, pure)
nonisolated private static func date(fromKey key: String, calendar: Calendar) -> Date? {
formatter("yyyy-MM-dd", calendar).date(from: key)
}
nonisolated private static func date(fromMonthKey key: String, calendar: Calendar) -> Date? {
formatter("yyyy-MM", calendar).date(from: key)
}
nonisolated private static func formatter(_ format: String, _ calendar: Calendar) -> DateFormatter {
let f = DateFormatter()
f.calendar = calendar; f.timeZone = calendar.timeZone
f.locale = Locale(identifier: "en_US_POSIX"); f.dateFormat = format
return f
}
}

View File

@@ -0,0 +1,161 @@
import Foundation
// File-based JSON persistence for the analytics system (per KC-51 decision:
// file store over SwiftData/Core Data iOS 16-safe, unbounded local disk,
// lowest migration risk, easily reversible).
//
// Layout (under Application Support/Analytics):
// snapshots/<yyyy-MM-dd>.json immutable frozen DaySample per past day
// reports/weekly/<yyyy-MM-dd>.json
// reports/monthly/<yyyy-MM>.json
//
// Guarantees:
// - Immutability: a past day's snapshot and any report, once written, are never
// overwritten (`writeIfAbsent`). Later edits to source data cannot change a
// generated report.
// - Idempotency + dedup: files are keyed by period; re-saving an existing key is
// a no-op, so generation never duplicates.
// - Retention: `prune` removes snapshots/reports older than the window.
// - Nothing sensitive beyond what's needed is written (no raw HealthKit records,
// only the derived reference values already in DaySample).
final class AnalyticsStore {
private let root: URL
private let fm: FileManager
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private var snapshotsDir: URL { root.appendingPathComponent("snapshots", isDirectory: true) }
private var weeklyDir: URL { root.appendingPathComponent("reports/weekly", isDirectory: true) }
private var monthlyDir: URL { root.appendingPathComponent("reports/monthly", isDirectory: true) }
init(root: URL, fileManager: FileManager = .default) {
self.root = root
self.fm = fileManager
let enc = JSONEncoder(); enc.dateEncodingStrategy = .iso8601; enc.outputFormatting = [.sortedKeys]
self.encoder = enc
let dec = JSONDecoder(); dec.dateDecodingStrategy = .iso8601
self.decoder = dec
ensureDirectories()
}
/// Default location under Application Support.
convenience init() {
let base = (try? FileManager.default.url(for: .applicationSupportDirectory,
in: .userDomainMask, appropriateFor: nil, create: true))
?? URL(fileURLWithPath: NSTemporaryDirectory())
self.init(root: base.appendingPathComponent("Analytics", isDirectory: true))
}
private func ensureDirectories() {
for dir in [snapshotsDir, weeklyDir, monthlyDir] {
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
}
}
// MARK: - Snapshots (immutable once the day is in the past)
/// Persist a day snapshot. A past day (`isPast == true`) is frozen: if a file
/// already exists it is kept unchanged. Today/future may be updated in place.
@discardableResult
func saveSnapshot(_ day: DaySample, isPast: Bool) -> Bool {
let url = snapshotsDir.appendingPathComponent("\(day.dateKey).json")
if isPast && fm.fileExists(atPath: url.path) { return false } // frozen
return write(day, to: url)
}
func snapshot(dateKey: String) -> DaySample? {
read(DaySample.self, from: snapshotsDir.appendingPathComponent("\(dateKey).json"))
}
func allSnapshotKeys() -> [String] {
listKeys(in: snapshotsDir)
}
// MARK: - Weekly / monthly reports (immutable, idempotent)
/// Save a weekly report only if absent (idempotent, never overwritten).
/// Returns true if newly written, false if it already existed.
@discardableResult
func saveWeeklyReport(_ report: WeeklyReport) -> Bool {
writeIfAbsent(report, to: weeklyDir.appendingPathComponent("\(report.weekStartKey).json"))
}
@discardableResult
func saveMonthlyReport(_ report: MonthlyReport) -> Bool {
writeIfAbsent(report, to: monthlyDir.appendingPathComponent("\(report.monthKey).json"))
}
func weeklyReport(weekStartKey: String) -> WeeklyReport? {
read(WeeklyReport.self, from: weeklyDir.appendingPathComponent("\(weekStartKey).json"))
}
func monthlyReport(monthKey: String) -> MonthlyReport? {
read(MonthlyReport.self, from: monthlyDir.appendingPathComponent("\(monthKey).json"))
}
func allWeeklyReports() -> [WeeklyReport] {
listKeys(in: weeklyDir).compactMap { weeklyReport(weekStartKey: $0) }
.sorted { $0.weekStartKey < $1.weekStartKey }
}
func allMonthlyReports() -> [MonthlyReport] {
listKeys(in: monthlyDir).compactMap { monthlyReport(monthKey: $0) }
.sorted { $0.monthKey < $1.monthKey }
}
func existingWeeklyKeys() -> Set<String> { Set(listKeys(in: weeklyDir)) }
func existingMonthlyKeys() -> Set<String> { Set(listKeys(in: monthlyDir)) }
// MARK: - Retention
/// Remove day snapshots (and weekly reports) whose key is older than the
/// retention cutoff. Monthly reports use the "yyyy-MM" cutoff. Returns the
/// number of files removed.
@discardableResult
func prune(now: Date, months: Int = 12, calendar: Calendar) -> Int {
var removed = 0
guard let cutoffDate = calendar.date(byAdding: .month, value: -months, to: calendar.startOfDay(for: now)) else { return 0 }
let dayCutoff = AnalyticsEngine.dateKey(for: cutoffDate, calendar: calendar)
let monthCutoff = AnalyticsEngine.monthKey(for: cutoffDate, calendar: calendar)
for key in listKeys(in: snapshotsDir) where key < dayCutoff {
if remove(snapshotsDir.appendingPathComponent("\(key).json")) { removed += 1 }
}
for key in listKeys(in: weeklyDir) where key < dayCutoff {
if remove(weeklyDir.appendingPathComponent("\(key).json")) { removed += 1 }
}
for key in listKeys(in: monthlyDir) where key < monthCutoff {
if remove(monthlyDir.appendingPathComponent("\(key).json")) { removed += 1 }
}
return removed
}
// MARK: - Low-level IO
private func listKeys(in dir: URL) -> [String] {
guard let items = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else { return [] }
return items.filter { $0.pathExtension == "json" }
.map { $0.deletingPathExtension().lastPathComponent }
.sorted()
}
private func write<T: Encodable>(_ value: T, to url: URL) -> Bool {
guard let data = try? encoder.encode(value) else { return false }
do { try data.write(to: url, options: .atomic); return true } catch { return false }
}
private func writeIfAbsent<T: Encodable>(_ value: T, to url: URL) -> Bool {
if fm.fileExists(atPath: url.path) { return false }
return write(value, to: url)
}
private func read<T: Decodable>(_ type: T.Type, from url: URL) -> T? {
guard let data = try? Data(contentsOf: url) else { return nil }
return try? decoder.decode(type, from: data)
}
private func remove(_ url: URL) -> Bool {
(try? fm.removeItem(at: url)) != nil
}
}

View File

@@ -0,0 +1,93 @@
import Foundation
// Pure bridge from app workout data (+ HealthKit reference) into the engine's
// `DaySample` inputs. Kept free of app-model / HealthKit types so it stays
// deterministic and unit-testable; the app maps `WorkoutDayLog` `RawWorkoutDay`
// and supplies HealthKit reference values at the call site.
/// One day's app-generated workout, already reduced to analysis primitives.
/// The app builds this from `WorkoutDayLog` (volume = Σ weight×reps of done sets).
struct RawWorkoutDay: Equatable {
let dateKey: String
let didWorkout: Bool
let sessions: Int
let durationMinutes: Double
let exercises: [ExerciseSample]
var sets: Int { exercises.reduce(0) { $0 + $1.sets } }
var volume: Double { exercises.reduce(0) { $0 + $1.volume } }
}
/// HealthKit reference values for a day (source of truth for health measurements).
/// All optional authorization or data may be missing.
struct RawHealthDay: Equatable {
let steps: Int?
let activeCalories: Int?
let restingHeartRate: Int?
}
enum WorkoutAnalyticsAdapter {
/// Build ordered `DaySample`s for `dayKeys`. A day is:
/// - `didWorkout` if it has a workout record with `didWorkout`,
/// - `scheduled` if its key is in `scheduledKeys`,
/// - enriched with HealthKit reference values where present.
/// Missing days become rest/empty samples (honest no fabricated data).
static func daySamples(dayKeys: [String],
workouts: [String: RawWorkoutDay],
scheduledKeys: Set<String>,
health: [String: RawHealthDay] = [:]) -> [DaySample] {
dayKeys.map { key in
let w = workouts[key]
let h = health[key]
return DaySample(
dateKey: key,
didWorkout: w?.didWorkout ?? false,
scheduled: scheduledKeys.contains(key),
sessions: w?.sessions ?? 0,
sets: w?.sets ?? 0,
volume: w?.volume ?? 0,
durationMinutes: w?.durationMinutes ?? 0,
exercises: w?.exercises ?? [],
steps: h?.steps,
activeCalories: h?.activeCalories,
restingHeartRate: h?.restingHeartRate
)
}
}
/// Inclusive list of day keys from `start` to `end`, in the given calendar.
static func dayKeys(from start: Date, to end: Date, calendar: Calendar) -> [String] {
var keys: [String] = []
var cursor = calendar.startOfDay(for: start)
let last = calendar.startOfDay(for: end)
var guardCount = 0
while cursor <= last, guardCount < 800 { // ~2yr ceiling
keys.append(AnalyticsEngine.dateKey(for: cursor, calendar: calendar))
guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break }
cursor = next; guardCount += 1
}
return keys
}
/// Day keys for the calendar week/month containing `date`.
static func weekDayKeys(containing date: Date, calendar: Calendar) -> [String] {
guard let w = AnalyticsEngine.weekInterval(containing: date, calendar: calendar) else { return [] }
return dayKeys(from: w.start, to: calendar.date(byAdding: .day, value: -1, to: w.end) ?? w.start, calendar: calendar)
}
static func monthDayKeys(containing date: Date, calendar: Calendar) -> [String] {
guard let m = AnalyticsEngine.monthInterval(containing: date, calendar: calendar) else { return [] }
return dayKeys(from: m.start, to: calendar.date(byAdding: .day, value: -1, to: m.end) ?? m.start, calendar: calendar)
}
/// Reduce an exercise's completed sets to an `ExerciseSample` (app helper
/// mirrors `WorkoutDayLog.volume`). Kept here so the identity + volume rule is
/// defined once and testable.
static func exerciseSample(name: String, completedSets: [(weight: Double, reps: Int)]) -> ExerciseSample {
let reps = completedSets.reduce(0) { $0 + $1.reps }
let volume = completedSets.reduce(0.0) { $0 + AnalyticsEngine.volume(weight: $1.weight, reps: $1.reps) }
return ExerciseSample(identity: AnalyticsEngine.exerciseIdentity(name),
sets: completedSets.count, reps: reps, volume: volume)
}
}

View File

@@ -10,6 +10,25 @@ struct PressButtonStyle: ButtonStyle {
}
}
/// A quiet accent button: outlined/tinted at rest, fills solid only while
/// pressed. Color is a response to touch, not a permanent decoration
/// matches "nothing shouts, color earns its place."
struct QuietAccentButtonStyle: ButtonStyle {
@Environment(\.colorScheme) private var cs
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundColor(configuration.isPressed ? .white : AppColors.accent)
.background(configuration.isPressed ? AppColors.accent : AppColors.accentSoft)
.clipShape(RoundedRectangle(cornerRadius: 11))
.overlay(
RoundedRectangle(cornerRadius: 11)
.stroke(AppColors.accent.opacity(configuration.isPressed ? 0 : 0.35), lineWidth: 1)
)
.scaleEffect(configuration.isPressed ? 0.97 : 1.0)
.animation(KisaniSpring.micro, value: configuration.isPressed)
}
}
struct FabButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label

View File

@@ -18,6 +18,7 @@ struct ContentView: View {
return 0
}()
@State private var showQuickAddTask = false
@State private var analyticsLink: AnalyticsDeepLink?
@StateObject private var tabState = FloatingTabState()
@ObservedObject private var shortcuts = ShortcutHandler.shared
@ObservedObject private var tutorial = TutorialManager.shared
@@ -105,10 +106,16 @@ struct ContentView: View {
HealthKitManager.shared.bootstrap()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
consumeWidgetAddTask()
// Backfill any missing analytics reports (idempotent), then notify.
let generated = AnalyticsService.shared.reconcile(using: workoutVM)
NotificationManager.shared.scheduleAnalyticsReports(generated)
if let link = NotificationManager.shared.consumePendingAnalyticsDeepLink() { analyticsLink = link }
// Pull workouts from Health, then reschedule so a detected workout updates reminders.
Task {
await workoutVM.syncFromHealthKit()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
await AnalyticsService.shared.refreshHealthReference()
AnalyticsService.shared.reconcile(using: workoutVM)
}
}
.onChange(of: scenePhase) { phase in
@@ -120,6 +127,8 @@ struct ContentView: View {
workoutVM.checkPendingMissed()
CloudSyncManager.shared.backupSettings()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
let generated = AnalyticsService.shared.reconcile(using: workoutVM)
NotificationManager.shared.scheduleAnalyticsReports(generated)
Task {
await workoutVM.syncFromHealthKit()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
@@ -161,6 +170,11 @@ struct ContentView: View {
.presentationDetents([.height(150)])
.presentationDragIndicator(.visible)
}
.sheet(item: $analyticsLink) { link in
AnalyticsView(initialLink: link)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
#if DEBUG
.onAppear {
// Test hook: auto-open Quick Add (used to verify the voicefield binding

File diff suppressed because it is too large Load Diff

View File

@@ -84,7 +84,17 @@ final class CloudSyncManager {
else { return }
for key in changedKeys {
if let val = kv.object(forKey: key) {
guard let val = kv.object(forKey: key) else { continue }
if key.hasPrefix("kisani.workout.completedDates."), let incoming = val as? [String] {
// Workout dates are an append-only lifetime tally, and the Watch/
// HealthKit sync writes here independently of this device a
// blind overwrite can race a fresh local write with a stale
// iCloud snapshot and silently drop already-logged days. Union
// instead, so an external change can only ever add days, never
// erase ones this device already knows about.
let existing = Set(UserDefaults.kisani.stringArray(forKey: key) ?? [])
UserDefaults.kisani.set(Array(existing.union(incoming)), forKey: key)
} else {
UserDefaults.kisani.set(val, forKey: key)
}
}

View File

@@ -160,6 +160,51 @@ final class HealthKitManager: ObservableObject {
}
}
// MARK: - Historical daily reference (for analytics)
/// Per-day steps / active calories / resting heart rate over a range, keyed by
/// "yyyy-MM-dd". One bucketed statistics query per metric (efficient over a
/// year). HealthKit is the source of truth; values are read-only reference.
func dailyReference(from start: Date, to end: Date) async -> [String: (steps: Int?, calories: Int?, restingHR: Int?)] {
guard authorized, isAvailable else { return [:] }
let anchor = Calendar.current.startOfDay(for: start)
let interval = DateComponents(day: 1)
async let steps = statsCollection(.stepCount, unit: .count(), options: .cumulativeSum, anchor: anchor, end: end, interval: interval)
async let cals = statsCollection(.activeEnergyBurned, unit: .kilocalorie(), options: .cumulativeSum, anchor: anchor, end: end, interval: interval)
async let hr = statsCollection(.restingHeartRate, unit: HKUnit(from: "count/min"), options: .discreteAverage, anchor: anchor, end: end, interval: interval)
let (s, c, h) = await (steps, cals, hr)
var out: [String: (steps: Int?, calories: Int?, restingHR: Int?)] = [:]
for key in Set(s.keys).union(c.keys).union(h.keys) {
out[key] = (s[key].map(Int.init), c[key].map(Int.init), h[key].map(Int.init))
}
return out
}
private func statsCollection(_ id: HKQuantityTypeIdentifier, unit: HKUnit, options: HKStatisticsOptions,
anchor: Date, end: Date, interval: DateComponents) async -> [String: Double] {
guard let type = HKQuantityType.quantityType(forIdentifier: id) else { return [:] }
return await withCheckedContinuation { cont in
let q = HKStatisticsCollectionQuery(quantityType: type, quantitySamplePredicate: nil,
options: options, anchorDate: anchor, intervalComponents: interval)
q.initialResultsHandler = { _, results, _ in
var dict: [String: Double] = [:]
let fmt = DateFormatter()
fmt.calendar = Calendar.current; fmt.timeZone = Calendar.current.timeZone
fmt.locale = Locale(identifier: "en_US_POSIX"); fmt.dateFormat = "yyyy-MM-dd"
results?.enumerateStatistics(from: anchor, to: end) { stat, _ in
let quantity = options.contains(.cumulativeSum) ? stat.sumQuantity() : stat.averageQuantity()
if let v = quantity?.doubleValue(for: unit), v > 0 {
dict[fmt.string(from: stat.startDate)] = v
}
}
cont.resume(returning: dict)
}
store.execute(q)
}
}
// MARK: - Type sets
private var readTypes: Set<HKObjectType> {

View File

@@ -61,7 +61,14 @@ enum LiveActivityManager {
Task {
for activity in Activity<TaskActivityAttributes>.activities
where activity.attributes.taskID == taskID {
await activity.end(dismissalPolicy: .immediate)
// end(_:dismissalPolicy:) needs iOS 16.2; this file supports
// 16.1 (Live Activities' own minimum), so fall back to the
// deprecated using:dismissalPolicy: overload below that.
if #available(iOS 16.2, *) {
await activity.end(nil, dismissalPolicy: .immediate)
} else {
await activity.end(using: nil, dismissalPolicy: .immediate)
}
}
}
}

View File

@@ -20,6 +20,10 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
private static let logWorkoutActId = "LOG_WORKOUT"
private static let missedWorkoutActId = "MISSED_WORKOUT"
private static let snoozeWorkoutActId = "SNOOZE_WORKOUT"
private static let habitCatId = "HABIT_LOG"
private static let habitDoneActId = "HABIT_DONE"
private static let habitNotTodayActId = "HABIT_NOT_TODAY"
private static let habitStopTrackingActId = "HABIT_STOP_TRACKING"
override private init() {
super.init()
@@ -49,7 +53,15 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
actions: [logAction, missedAction, snoozeAction],
intentIdentifiers: [], options: .customDismissAction)
center.setNotificationCategories([taskCat, confirmCat])
let habitDoneAction = UNNotificationAction(identifier: Self.habitDoneActId, title: "Done", options: [])
let habitNotTodayAction = UNNotificationAction(identifier: Self.habitNotTodayActId, title: "Not Today", options: [])
let habitStopAction = UNNotificationAction(identifier: Self.habitStopTrackingActId, title: "Stop Tracking",
options: .destructive)
let habitCat = UNNotificationCategory(identifier: Self.habitCatId,
actions: [habitDoneAction, habitNotTodayAction, habitStopAction],
intentIdentifiers: [], options: .customDismissAction)
center.setNotificationCategories([taskCat, confirmCat, habitCat])
}
// MARK: - Complete task directly in UserDefaults (called from background)
@@ -138,6 +150,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
self.scheduleTasks(snapshot: tasks, urgentIds: urgentIds)
self.scheduleCalendarEvents(snapshot: calEvents)
self.schedulePeriodMilestones()
self.scheduleHabitReminders()
self.updateBadge(snapshot: tasks)
}
}
@@ -180,6 +193,198 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
add(Self.periodIds[3], "This Year", "One more year passed.", year)
}
// MARK: - Habit reminders (prayer, bed-making, coffee/caffeine)
private static let habitPrefix = "kisani.habit"
/// Named slot definitions shared by Prayer and Coffee (storage key
/// fragment, default hour, default minute, default enabled-when-master-on).
private static let prayerSlots: [(key: String, hour: Int, minute: Int, defaultOn: Bool)] = [
("Morning", 7, 0, true), ("Afternoon", 13, 0, true), ("Evening", 18, 0, true), ("Night", 21, 0, true),
]
private static let coffeeSlots: [(key: String, hour: Int, minute: Int, defaultOn: Bool)] = [
("Morning", 8, 0, true), ("Midday", 12, 0, false), ("Afternoon", 15, 0, false), ("Evening", 18, 0, false),
]
/// Daily (every day, not weekday-scoped) habit nudges. Reads from
/// `UserDefaults.kisani` the App Group suite so it must match wherever
/// the Settings/Onboarding toggles actually write (see the `store:` param
/// on those `@AppStorage` declarations).
private func scheduleHabitReminders() {
let ud = UserDefaults.kisani
var wanted = Set<String>()
// `habitType` drives the "Done" action's log bucket ("bed"/"prayer"/
// "coffee") tapping Done on any slot of a type logs one entry for
// that type today (deduped), feeding a simple lifetime "days done"
// count. Never touches the task list or Activity History.
func addDaily(id: String, enabled: Bool, hour: Int, minute: Int, title: String, body: String, habitType: String) {
guard enabled else { return }
wanted.insert(id)
let content = UNMutableNotificationContent()
content.title = title; content.body = body; content.sound = .default
content.categoryIdentifier = Self.habitCatId
content.userInfo = ["habitType": habitType]
var comps = DateComponents(); comps.hour = hour; comps.minute = minute
center.add(UNNotificationRequest(
identifier: id, content: content,
trigger: UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)
))
}
// Bed-making a single daily nudge, the simplest possible "start the
// day with a win."
addDaily(
id: "\(Self.habitPrefix).bed",
enabled: ud.object(forKey: "bedReminderEnabled") as? Bool ?? false,
hour: ud.object(forKey: "bedReminderHour") as? Int ?? 8,
minute: ud.object(forKey: "bedReminderMinute") as? Int ?? 0,
title: "Rise and shine",
body: "Have you made your bed? Small win, great start to the day.",
habitType: "bed"
)
// Prayer up to 4 independently-toggleable times a day.
let prayerOn = ud.object(forKey: "prayerReminderEnabled") as? Bool ?? false
for slot in Self.prayerSlots {
addDaily(
id: "\(Self.habitPrefix).prayer.\(slot.key)",
enabled: prayerOn && (ud.object(forKey: "prayer\(slot.key)Enabled") as? Bool ?? slot.defaultOn),
hour: ud.object(forKey: "prayer\(slot.key)Hour") as? Int ?? slot.hour,
minute: ud.object(forKey: "prayer\(slot.key)Minute") as? Int ?? slot.minute,
title: "A moment to pause",
body: "Have you had a chance to pray and give thanks?",
habitType: "prayer"
)
}
// Coffee / caffeine up to 4 named times plus one custom reminder.
let coffeeOn = ud.object(forKey: "coffeeReminderEnabled") as? Bool ?? false
for slot in Self.coffeeSlots {
addDaily(
id: "\(Self.habitPrefix).coffee.\(slot.key)",
enabled: coffeeOn && (ud.object(forKey: "coffee\(slot.key)Enabled") as? Bool ?? slot.defaultOn),
hour: ud.object(forKey: "coffee\(slot.key)Hour") as? Int ?? slot.hour,
minute: ud.object(forKey: "coffee\(slot.key)Minute") as? Int ?? slot.minute,
title: "\(slot.key) coffee",
body: "Time for your \(slot.key.lowercased()) coffee?",
habitType: "coffee"
)
}
// Empty until the user names it (the field's placeholder guides that)
// fall back to a sensible default rather than an empty notification title.
let rawCustomLabel = ud.string(forKey: "coffeeCustomLabel") ?? ""
let customLabel = rawCustomLabel.isEmpty ? "Coffee" : rawCustomLabel
addDaily(
id: "\(Self.habitPrefix).coffee.custom",
enabled: coffeeOn && (ud.object(forKey: "coffeeCustomEnabled") as? Bool ?? false),
hour: ud.object(forKey: "coffeeCustomHour") as? Int ?? 16,
minute: ud.object(forKey: "coffeeCustomMinute") as? Int ?? 30,
title: customLabel,
body: "Time for \(customLabel.lowercased())?",
habitType: "coffee"
)
// Cancel anything previously scheduled that isn't wanted this pass
// (toggled off, or a slot that's no longer enabled). `add` with an
// existing identifier already replaces in place for the ones we kept.
let allPossibleIds = Set(
["\(Self.habitPrefix).bed", "\(Self.habitPrefix).coffee.custom"]
+ Self.prayerSlots.map { "\(Self.habitPrefix).prayer.\($0.key)" }
+ Self.coffeeSlots.map { "\(Self.habitPrefix).coffee.\($0.key)" }
)
center.removePendingNotificationRequests(withIdentifiers: Array(allPossibleIds.subtracting(wanted)))
}
// MARK: - Habit "Done" logging (private, lightweight no task-list footprint)
private static let habitLogKeyPrefix = "kisani.habit.doneDates."
/// Log today as done for a habit type ("bed"/"prayer"/"coffee"), once per
/// day (idempotent tapping Done twice doesn't double-count). Mirrors the
/// app's "lifetime tally, never breaks" streak philosophy (see workout
/// streakDays) rather than a fragile consecutive-day streak.
private func logHabitDone(type: String) {
let ud = UserDefaults.kisani
let key = Self.habitLogKeyPrefix + type
let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
let today = fmt.string(from: Date())
var dates = Set(ud.stringArray(forKey: key) ?? [])
guard !dates.contains(today) else { return }
dates.insert(today)
ud.set(Array(dates), forKey: key)
}
/// Turn off future reminders for a habit type (from its notification's
/// "Stop Tracking" action) flips the master toggle so Settings reflects
/// it too, and immediately cancels every pending notification for that
/// type (all its slots), not just the one just tapped.
private func stopTrackingHabit(type: String) {
UserDefaults.kisani.set(false, forKey: "\(type)ReminderEnabled")
let prefix = "\(Self.habitPrefix).\(type)"
center.getPendingNotificationRequests { [weak self] requests in
let ids = requests.map(\.identifier).filter { $0.hasPrefix(prefix) }
self?.center.removePendingNotificationRequests(withIdentifiers: ids)
}
}
/// Total distinct days logged done for a habit type for a small "X days"
/// badge in Settings. Never surfaced in Today/Matrix/Activity History.
func habitDoneCount(type: String) -> Int {
(UserDefaults.kisani.stringArray(forKey: Self.habitLogKeyPrefix + type) ?? []).count
}
// MARK: - Analytics report notifications (deep-linked)
/// Fire one local notification for each newly-generated weekly / monthly
/// report, deep-linking to it. Permission-aware and gated on the same
/// "Period milestones" setting. `result` only lists reports created this run
/// (reconcile is idempotent) and each uses a fixed per-period identifier with
/// remove-then-add, so notifications are never duplicated.
func scheduleAnalyticsReports(_ result: AnalyticsCoordinator.ReconcileResult) {
guard !result.isEmpty,
UserDefaults.kisani.object(forKey: "kisani.periodMilestones") as? Bool ?? true else { return }
center.getNotificationSettings { [weak self] settings in
guard let self,
settings.authorizationStatus == .authorized
|| settings.authorizationStatus == .provisional else { return }
if let week = result.weekliesGenerated.max() {
self.postAnalyticsReport(id: "kisani.analytics.weekly.\(week)",
title: "Your week in review",
body: "Your weekly training summary is ready.",
link: .weekly(weekStartKey: week))
}
if let month = result.monthliesGenerated.max() {
self.postAnalyticsReport(id: "kisani.analytics.monthly.\(month)",
title: "Your month in review",
body: "Your monthly training summary is ready.",
link: .monthly(monthKey: month))
}
}
}
private func postAnalyticsReport(id: String, title: String, body: String, link: AnalyticsDeepLink) {
center.removePendingNotificationRequests(withIdentifiers: [id])
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
content.userInfo = [AnalyticsDeepLink.userInfoKey: link.stringValue]
// Reconcile already runs at an appropriate moment (app opened after the
// period closed) not tied to exact background execution. Deliver shortly.
center.add(UNNotificationRequest(
identifier: id, content: content,
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)))
}
/// The app reads this on active to route to a tapped report (mirrors the
/// existing pending-action pattern used for workout confirm/missed).
func consumePendingAnalyticsDeepLink() -> AnalyticsDeepLink? {
guard let s = UserDefaults.kisani.string(forKey: "kisani.analytics.pendingDeepLink") else { return nil }
UserDefaults.kisani.removeObject(forKey: "kisani.analytics.pendingDeepLink")
return AnalyticsDeepLink(string: s)
}
// MARK: - Recurring task completion confirmation
/// Posts a quiet " Done. Repeats " confirmation when a recurring task's
@@ -534,6 +739,11 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
let deeplink = info["deeplink"] as? String
let taskId = info["taskId"] as? String
// Analytics report tap store the deep link for the app to route on active.
if let s = info[AnalyticsDeepLink.userInfoKey] as? String, AnalyticsDeepLink(string: s) != nil {
UserDefaults.kisani.set(s, forKey: "kisani.analytics.pendingDeepLink")
}
switch response.actionIdentifier {
case Self.actionMarkId:
@@ -565,6 +775,19 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 3600, repeats: false)
))
case Self.habitDoneActId:
if let habitType = info["habitType"] as? String { logHabitDone(type: habitType) }
case Self.habitNotTodayActId:
// Intentionally a no-op beyond dismissing the habit tally only
// ever counts "Done" taps (KC-40's lifetime-tally philosophy), so
// there's nothing to log for a skip. This just gives an explicit,
// discoverable "I saw it, skipping" action instead of only a swipe.
break
case Self.habitStopTrackingActId:
if let habitType = info["habitType"] as? String { stopTrackingHabit(type: habitType) }
default:
if let taskId { markTaskComplete(taskId: taskId, userId: userId) }
Task { @MainActor in

View File

@@ -104,4 +104,24 @@ final class TutorialManager: ObservableObject {
else { withAnimation(KisaniSpring.entrance) { workoutStep += 1 } }
}
func skipWorkout() { withAnimation(KisaniSpring.exit) { workoutDone = true } }
// MARK: - Settings view tutorial
@AppStorage("kisani.tutorial.settings.v1") var settingsDone: Bool = false
@Published var settingsStep: Int = 0
let settingsTips: [PageTip] = [
.init(title: "Habit Reminders",
body: "Prayer, making your bed, coffee — under Habits, set daily nudges for the small routines you don't want to forget. Tap Done on the notification to keep a private streak.",
icon: "hands.sparkles"),
]
var settingsIsActive: Bool { !settingsDone }
var settingsCurrent: PageTip { settingsTips[min(settingsStep, settingsTips.count - 1)] }
var settingsIsLast: Bool { settingsStep == settingsTips.count - 1 }
func advanceSettings() {
if settingsIsLast { withAnimation(KisaniSpring.exit) { settingsDone = true } }
else { withAnimation(KisaniSpring.entrance) { settingsStep += 1 } }
}
func skipSettings() { withAnimation(KisaniSpring.exit) { settingsDone = true } }
}

View File

@@ -233,6 +233,10 @@ class WorkoutViewModel: ObservableObject {
@Published var activeProgramId: UUID
@Published var activeSections: [WorkoutSection]
@Published var workoutDates: [String] = []
/// Subset of `workoutDates` logged via the in-app Finish button rather than
/// detected from Apple Health/Watch only these are eligible for Undo, since
/// Watch/Health is the source of truth once it has recorded a day itself.
@Published var manualWorkoutDates: [String] = []
@Published var missedDates: [String] = [] // days marked "didn't do it"
@Published var compensations: [String: String] = [:] // rest-day "yyyy-MM-dd" programId
@Published var askCompensate: MissedDay? = nil // triggers the compensation sheet
@@ -324,6 +328,7 @@ class WorkoutViewModel: ObservableObject {
private let kSchedule: String
private let kActivePid: String
private let kWorkoutDates: String
private let kManualWorkoutDates: String
private let kLastActiveDay: String
private let kWorkoutHistory: String
private let kMissedDates: String
@@ -335,6 +340,7 @@ class WorkoutViewModel: ObservableObject {
self.kSchedule = "kisani.workout.schedule.\(uid)"
self.kActivePid = "kisani.workout.activePid.\(uid)"
self.kWorkoutDates = "kisani.workout.completedDates.\(uid)"
self.kManualWorkoutDates = "kisani.workout.manualDates.\(uid)"
self.kLastActiveDay = "kisani.workout.lastActiveDay.\(uid)"
self.kWorkoutHistory = "kisani.workout.history.\(uid)"
self.kMissedDates = "kisani.workout.missedDates.\(uid)"
@@ -346,6 +352,7 @@ class WorkoutViewModel: ObservableObject {
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.schedule.\(uid)")
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.activePid.\(uid)")
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.completedDates.\(uid)")
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.manualDates.\(uid)")
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.history.\(uid)")
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.missedDates.\(uid)")
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.compensations.\(uid)")
@@ -412,6 +419,7 @@ class WorkoutViewModel: ObservableObject {
activeSections = blank.sections
}
workoutDates = UserDefaults.kisani.stringArray(forKey: kWorkoutDates) ?? []
manualWorkoutDates = UserDefaults.kisani.stringArray(forKey: kManualWorkoutDates) ?? []
missedDates = UserDefaults.kisani.stringArray(forKey: kMissedDates) ?? []
compensations = (UserDefaults.kisani.dictionary(forKey: kCompensations) as? [String: String]) ?? [:]
if let hData = UserDefaults.kisani.data(forKey: kWorkoutHistory),
@@ -430,6 +438,7 @@ class WorkoutViewModel: ObservableObject {
UserDefaults.kisani.set(schedDict, forKey: kSchedule)
UserDefaults.kisani.set(activeProgramId.uuidString, forKey: kActivePid)
UserDefaults.kisani.set(workoutDates, forKey: kWorkoutDates)
UserDefaults.kisani.set(manualWorkoutDates, forKey: kManualWorkoutDates)
UserDefaults.kisani.set(missedDates, forKey: kMissedDates)
UserDefaults.kisani.set(compensations, forKey: kCompensations)
CloudSyncManager.shared.push(value: missedDates, forKey: kMissedDates)
@@ -441,6 +450,7 @@ class WorkoutViewModel: ObservableObject {
CloudSyncManager.shared.push(value: schedDict, forKey: kSchedule)
CloudSyncManager.shared.push(value: activeProgramId.uuidString, forKey: kActivePid)
CloudSyncManager.shared.push(value: workoutDates, forKey: kWorkoutDates)
CloudSyncManager.shared.push(value: manualWorkoutDates, forKey: kManualWorkoutDates)
}
// MARK: - Daily Reset
@@ -563,6 +573,10 @@ class WorkoutViewModel: ObservableObject {
snapshotDay(key) // record today's session in history
guard !workoutDates.contains(key) else { return }
workoutDates.append(key)
// Only called from the manual Finish path (never from the HealthKit sync
// below), so a day only lands here undo-eligible when the app itself
// is the one claiming it, not Watch/Health.
if !manualWorkoutDates.contains(key) { manualWorkoutDates.append(key) }
save()
let start = sessionStartDate ?? date.addingTimeInterval(-3600)
Task { await HealthKitManager.shared.saveWorkout(start: start, end: date) }
@@ -633,6 +647,13 @@ class WorkoutViewModel: ObservableObject {
workoutDates.contains(iso.string(from: date))
}
/// True only if THIS date's completion came from the in-app Finish button
/// the one case where Undo is offered. A Watch/Health-detected day is never
/// undo-eligible, since that record lives outside the app.
func isManuallyLogged(on date: Date) -> Bool {
manualWorkoutDates.contains(iso.string(from: date))
}
// MARK: - Per-date status (Pending / Done / Missed / Compensation)
enum WorkoutDayStatus { case none, pending, done, missed }
@@ -655,6 +676,16 @@ class WorkoutViewModel: ObservableObject {
logWorkoutCompleted(on: date) // adds to workoutDates, snapshots history, saves
}
/// Undo a manually-logged "done" mark back to pending. Only ever offered
/// for days in `manualWorkoutDates`; a Watch/Health-sourced day has no undo
/// path here (that record isn't this app's to remove).
func unmarkWorkoutDone(on date: Date) {
let key = iso.string(from: date)
workoutDates.removeAll { $0 == key }
manualWorkoutDates.removeAll { $0 == key }
save()
}
/// Mark one specific day's workout missed (undoes a done mark for that day only).
func markWorkoutMissed(on date: Date) {
let key = iso.string(from: date)
@@ -663,6 +694,7 @@ class WorkoutViewModel: ObservableObject {
save()
}
// MARK: - Compensation (recover a missed workout on a rest day)
/// Upcoming rest days (no scheduled program, no compensation yet), starting tomorrow.
@@ -803,7 +835,9 @@ class WorkoutViewModel: ObservableObject {
sessionStartDate = Date()
}
syncBack()
if doneSets == totalSets && totalSets > 0 { logWorkoutCompleted() }
// Ticking the last box no longer silently logs the day as done the
// main Workout card's explicit "Finish" is the single, confirmable way
// to count today (so mis-taps here can't jump the UI into the done state).
if wasMarkedDone && restTimerEnabled { startRestTimer() }
}
@@ -817,11 +851,13 @@ class WorkoutViewModel: ObservableObject {
}
if done, sessionStartDate == nil { sessionStartDate = Date() }
syncBack()
if doneSets == totalSets && totalSets > 0 { logWorkoutCompleted() }
// See toggleSet: no longer auto-logs the day "Finish" is explicit.
}
/// Mark every set of the active workout done/undone the "Select all & mark
/// complete" quick action. Logs completion when everything is checked.
/// Mark every set of the active workout done/undone the "Check/Clear all
/// sets" quick action. Ticking every box no longer auto-logs the day as
/// done on its own; "Finish Workout" on the main card is the single,
/// confirmable action that counts today.
func setAllSetsDone(_ done: Bool) {
for si in activeSections.indices {
for ei in activeSections[si].exercises.indices {
@@ -835,7 +871,6 @@ class WorkoutViewModel: ObservableObject {
UserDefaults.kisani.set(iso.string(from: Date()), forKey: kLastActiveDay)
}
syncBack()
if done, doneSets == totalSets && totalSets > 0 { logWorkoutCompleted() }
}
func addSet(sectionId: UUID, to exerciseId: UUID) {

View File

@@ -5,6 +5,10 @@ import WidgetKit
struct TaskItem: Identifiable, Codable, Equatable {
var id = UUID()
var title: String
// Optional so existing saved tasks decode safely (missing key nil); the
// `Date()` default only fires for genuinely new tasks constructed after
// this field existed it's evaluated fresh at each construction site.
var createdAt: Date? = Date()
var dueDate: Date?
var hasTime: Bool = false
var isComplete: Bool = false
@@ -14,6 +18,9 @@ struct TaskItem: Identifiable, Codable, Equatable {
var taskColor: TaskColor = .orange
var isRecurring: Bool = false
var recurrenceLabel: String? = nil
// "Every N [unit]" optional so existing saved tasks decode safely (missing
// key nil, treated as 1 everywhere). "Every Weekday" ignores this.
var recurrenceInterval: Int? = nil
var isPinned: Bool = false
var endDate: Date? = nil
var isAllDay: Bool = false
@@ -26,6 +33,15 @@ struct TaskItem: Identifiable, Codable, Equatable {
// Matrix: horizontal manual moves override urgency only.
// nil = auto from deadline, true = force urgent, false = force not-urgent.
var urgencyOverride: Bool? = nil
/// User-facing recurrence text "Weekly" stays "Weekly" at interval 1 (no
/// visual change for any existing task), but becomes "Every 2 Weeks" etc.
/// once an interval is set. Use this anywhere recurrence text is shown to
/// the user; `recurrenceLabel` itself stays a plain rule key for matching.
var recurrenceDisplayLabel: String? {
guard let label = recurrenceLabel else { return nil }
return RecurrenceRule.displayLabel(label: label, interval: recurrenceInterval ?? 1)
}
}
enum Priority: String, Codable, CaseIterable, Identifiable {
@@ -104,6 +120,18 @@ enum TaskCategory: String, Codable {
case domain = "Domain"
case annual = "Annual"
case custom = "Custom"
/// Icon-only glyph for task rows `.reminder` is the generic default
/// and stays unmarked so the badge only appears when it adds information.
var glyph: String? {
switch self {
case .reminder: return nil
case .birthday: return "gift.fill"
case .domain: return "briefcase.fill"
case .annual: return "calendar"
case .custom: return "tag.fill"
}
}
}
enum TaskColor: String, Codable {
@@ -253,10 +281,15 @@ class TaskViewModel: ObservableObject {
let cal = Calendar.current
let start = cal.startOfDay(for: now)
let end = cal.date(byAdding: .day, value: 1, to: start)!
// Timed tasks whose time has already passed belong in overdue, not today.
// Timed ONE-OFF tasks whose time has already passed belong in overdue,
// not today. Recurring occurrences always stay in Today regardless of
// time: overdueTasks intentionally excludes recurring tasks (postponing
// one would shift the recurrence rule's anchor date, not just today's
// occurrence), so without this a past-time recurring task would vanish
// from the app entirely instead of showing here like Calendar shows it.
return activeRows().filter {
guard let d = $0.dueDate else { return false }
if $0.hasTime && d < now { return false }
if $0.hasTime && d < now && !$0.isRecurring { return false }
return d >= start && d < end
}
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
@@ -360,24 +393,13 @@ class TaskViewModel: ObservableObject {
}
/// Whether a recurring task has an occurrence on `date` aligned to its rule,
/// on/after its start, and on/before its "repeat until" (if set).
/// on/after its start, and on/before its "repeat until" (if set). Delegates to
/// `RecurrenceRule` (Shared/) so this exact math is also what both widget
/// extensions use for their countdown windows one implementation, not three.
func isOccurrence(_ t: TaskItem, on date: Date) -> Bool {
guard recurs(t), let start = t.dueDate else { return false }
let cal = Calendar.current
let d0 = cal.startOfDay(for: date), s0 = cal.startOfDay(for: start)
guard d0 >= s0 else { return false }
if let end = t.recurrenceEnd, d0 > cal.startOfDay(for: end) { return false }
switch t.recurrenceLabel {
case "Daily": return true
case "Every Weekday":
let wd = cal.component(.weekday, from: d0) // 1=Sun 7=Sat
return wd >= 2 && wd <= 6
case "Weekly": return ((cal.dateComponents([.day], from: s0, to: d0).day ?? 0) % 7) == 0
case "Monthly": return cal.component(.day, from: s0) == cal.component(.day, from: d0)
case "Yearly": return cal.component(.day, from: s0) == cal.component(.day, from: d0)
&& cal.component(.month, from: s0) == cal.component(.month, from: d0)
default: return false
}
return RecurrenceRule.isOccurrence(label: t.recurrenceLabel, interval: t.recurrenceInterval ?? 1,
start: start, date: date, end: t.recurrenceEnd)
}
func occurrenceComplete(_ t: TaskItem, on date: Date) -> Bool {
@@ -400,6 +422,21 @@ class TaskViewModel: ObservableObject {
}
}
/// Complete a recurring task's occurrence AND stop the series distinct
/// from Delete, which erases the task (and every past completion) outright.
/// The task, its title, and its full completedOccurrences history stay
/// intact; only occurrences after `date` stop being generated (via
/// `recurrenceEnd`, which `isOccurrence` already respects).
func completeAndStopSeries(_ t: TaskItem, on date: Date = Date()) {
guard let idx = tasks.firstIndex(where: { $0.id == t.id }) else { return }
let key = occurrenceKey(date)
var set = Set(tasks[idx].completedOccurrences ?? [])
set.insert(key) // ensure done never toggles off
tasks[idx].completedOccurrences = Array(set)
tasks[idx].recurrenceEnd = Calendar.current.startOfDay(for: date)
save()
}
/// A display copy of a recurring task pinned to one occurrence date, with that
/// occurrence's completion baked into `isComplete` (so existing rows just work).
func occurrenceCopy(_ t: TaskItem, on date: Date) -> TaskItem {
@@ -596,7 +633,7 @@ class TaskViewModel: ObservableObject {
func addTask(title: String, dueDate: Date?, hasTime: Bool = false,
quadrant: Quadrant = .urgent, category: TaskCategory = .reminder,
taskColor: TaskColor = .orange, isRecurring: Bool = false,
recurrenceLabel: String? = nil,
recurrenceLabel: String? = nil, recurrenceInterval: Int? = nil,
endDate: Date? = nil, isAllDay: Bool = false,
priority: Priority = .none, reminderDate: Date? = nil,
constantReminder: Bool = false, recurrenceEnd: Date? = nil) {
@@ -609,6 +646,7 @@ class TaskViewModel: ObservableObject {
endDate: endDate, isAllDay: isAllDay,
priority: resolvedPriority, reminderDate: reminderDate,
constantReminder: constantReminder)
item.recurrenceInterval = recurrenceInterval
item.recurrenceEnd = recurrenceEnd
item.quadrant = displayQuadrant(item) // color/widget identity follows placement
tasks.append(item)
@@ -618,13 +656,15 @@ class TaskViewModel: ObservableObject {
func updateTask(_ task: TaskItem, title: String, dueDate: Date?, hasTime: Bool,
isRecurring: Bool, recurrenceLabel: String?,
endDate: Date?, isAllDay: Bool, reminderDate: Date?,
constantReminder: Bool = false, recurrenceEnd: Date? = nil) {
constantReminder: Bool = false, recurrenceEnd: Date? = nil,
recurrenceInterval: Int? = nil) {
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
tasks[idx].title = title
tasks[idx].dueDate = dueDate
tasks[idx].hasTime = hasTime
tasks[idx].isRecurring = isRecurring
tasks[idx].recurrenceLabel = recurrenceLabel
tasks[idx].recurrenceInterval = recurrenceInterval
tasks[idx].endDate = endDate
tasks[idx].isAllDay = isAllDay
tasks[idx].reminderDate = reminderDate

View File

@@ -9,6 +9,7 @@ struct AppColors {
static let blue = Color(r: 77, g: 157, b: 224)
static let yellow = Color(r: 232, g: 184, b: 42)
static let red = Color(r: 232, g: 66, b: 66)
static let coffeeBrown = Color(r: 122, g: 84, b: 52)
static let accentSoft = Color(UIColor { $0.userInterfaceStyle == .dark
? UIColor(r: 52, g: 28, b: 14) // dark: deep warm orange

View File

@@ -0,0 +1,427 @@
import SwiftUI
import Charts
// Analytics area: Overview · Daily · Weekly · Monthly · 12-Month · Exercises.
// Reads from AnalyticsService (which reconciles reports elsewhere). Status is
// conveyed by BOTH color and a glyph (never color alone). Includes empty/partial
// states and an informational, non-medical disclaimer.
//
// NOTE: written without an available build in this environment verify on device.
struct AnalyticsView: View {
@Environment(\.colorScheme) private var cs
@ObservedObject private var service = AnalyticsService.shared
@State private var area: Area = .overview
@State private var selectedExercise: String?
/// Optional deep link (from a report notification) picks the initial area.
init(initialLink: AnalyticsDeepLink? = nil) {
switch initialLink {
case .weekly: _area = State(initialValue: .weekly)
case .monthly: _area = State(initialValue: .monthly)
default: break // keep default .overview
}
}
enum Area: String, CaseIterable, Identifiable {
case overview = "Overview", daily = "Daily", weekly = "Weekly"
case monthly = "Monthly", trends = "12-Month", exercises = "Exercises"
var id: String { rawValue }
}
private var hasAnyReports: Bool {
!service.weeklyReports().isEmpty || !service.monthlyReports().isEmpty
}
var body: some View {
VStack(spacing: 0) {
header
areaSelector
Divider().background(AppColors.border(cs))
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) {
content
disclaimer
}
.padding(16)
.padding(.bottom, 40)
}
}
.background(AppColors.background(cs).ignoresSafeArea())
}
private var header: some View {
Text("Analytics")
.font(AppFonts.sans(20, weight: .bold))
.foregroundColor(AppColors.text(cs))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16).padding(.top, 18).padding(.bottom, 8)
}
private var areaSelector: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Area.allCases) { a in
let on = a == area
Text(a.rawValue)
.font(AppFonts.sans(12, weight: .semibold))
.foregroundColor(on ? .white : AppColors.text2(cs))
.padding(.horizontal, 12).padding(.vertical, 7)
.background(on ? AppColors.accent : AppColors.surface2(cs))
.clipShape(Capsule())
.contentShape(Capsule())
.onTapGesture { withAnimation(KisaniSpring.micro) { area = a } }
.accessibilityLabel(a.rawValue)
.accessibilityAddTraits(on ? [.isButton, .isSelected] : [.isButton])
}
}
.padding(.horizontal, 16).padding(.vertical, 10)
}
}
@ViewBuilder private var content: some View {
if !hasAnyReports && area != .daily {
emptyState
} else {
switch area {
case .overview: overview
case .daily: dailySection
case .weekly: weeklySection
case .monthly: monthlySection
case .trends: trendsSection
case .exercises: exercisesSection
}
}
}
// MARK: - Empty state
private var emptyState: some View {
VStack(spacing: 8) {
Image(systemName: "chart.line.uptrend.xyaxis")
.font(.system(size: 30)).foregroundColor(AppColors.text3(cs))
Text("No reports yet")
.font(AppFonts.sans(15, weight: .semibold)).foregroundColor(AppColors.text2(cs))
Text("As each week and month completes, an immutable summary appears here.")
.font(AppFonts.sans(12)).foregroundColor(AppColors.text3(cs))
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity).padding(.vertical, 60)
}
// MARK: - Overview
private var overview: some View {
VStack(alignment: .leading, spacing: 16) {
if let w = service.weeklyReports().first {
sectionTitle("Latest week")
weeklyCard(w)
}
if let m = service.monthlyReports().first {
sectionTitle("Latest month")
monthlyCard(m)
}
sectionTitle("12-month direction")
HStack {
trendBadge(service.monthlyTrendClassification().classification)
Spacer()
Text("\(service.monthlyReports().count) mo · \(service.weeklyReports().count) wk")
.font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
}
.padding(12).cardBG(cs)
}
}
// MARK: - Daily
private var dailySection: some View {
let cmp = service.dailyComparison(on: Date())
return VStack(alignment: .leading, spacing: 16) {
if cmp.restDay {
infoCard("Rest day", "No workout logged today — nothing to compare.")
}
comparisonGroup("vs. yesterday", cmp.vsPreviousDay)
comparisonGroup("vs. same day last week", cmp.vsSameWeekdayLastWeek)
}
}
private func comparisonGroup(_ title: String, _ comps: [ExerciseComparison]) -> some View {
VStack(alignment: .leading, spacing: 8) {
sectionTitle(title)
if comps.isEmpty {
infoCard("Nothing to compare", "No matching exercises for this comparison.")
} else {
ForEach(comps, id: \.identity) { c in
HStack {
Text(c.identity.capitalized)
.font(AppFonts.sans(13, weight: .medium)).foregroundColor(AppColors.text(cs))
if c.isNew {
Text("NEW").font(AppFonts.mono(8, weight: .bold))
.foregroundColor(AppColors.blue)
.padding(.horizontal, 5).padding(.vertical, 2)
.background(AppColors.blueSoft).clipShape(Capsule())
}
Spacer()
deltaBadge(c.volume, unit: "kg")
}
.padding(12).cardBG(cs)
}
}
}
}
// MARK: - Weekly / Monthly
private var weeklySection: some View {
VStack(alignment: .leading, spacing: 12) {
ForEach(service.weeklyReports()) { weeklyCard($0) }
}
}
private var monthlySection: some View {
VStack(alignment: .leading, spacing: 12) {
ForEach(service.monthlyReports()) { monthlyCard($0) }
}
}
private func weeklyCard(_ r: WeeklyReport) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Week of \(r.weekStartKey)")
.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
Spacer()
trendBadge(r.progression)
}
summaryRow(r.summary)
healthRow(r.summary)
deltaChips(r.vsPreviousWeek)
}
.padding(14).cardBG(cs)
}
private func monthlyCard(_ r: MonthlyReport) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text(r.monthKey)
.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
Spacer()
trendBadge(r.classification)
}
summaryRow(r.summary)
healthRow(r.summary)
deltaChips(r.vsPreviousMonth)
}
.padding(14).cardBG(cs)
}
private func summaryRow(_ s: PeriodSummary) -> some View {
HStack(spacing: 14) {
stat("\(s.sessions)", "sessions")
stat("\(s.sets)", "sets")
stat("\(Int(s.volume))", "kg")
stat("\(Int(s.consistency * 100))%", "consistent")
if s.missed > 0 { stat("\(s.missed)", "missed") }
}
}
/// Available HealthKit trends for the period (shown only when present).
@ViewBuilder private func healthRow(_ s: PeriodSummary) -> some View {
if s.avgSteps != nil || s.avgActiveCalories != nil || s.avgRestingHR != nil {
HStack(spacing: 14) {
if let v = s.avgSteps { stat("\(v)", "avg steps") }
if let v = s.avgActiveCalories { stat("\(v)", "avg kcal") }
if let v = s.avgRestingHR { stat("\(v)", "avg bpm") }
}
}
}
private func deltaChips(_ deltas: [String: MetricDelta]) -> some View {
let order = ["volume", "sessions", "sets", "duration", "consistency"]
return FlowRow(order.compactMap { key -> (String, MetricDelta)? in
guard let d = deltas[key], d.meaningful, d.percent != nil else { return nil }
return (key, d)
}) { pair in
HStack(spacing: 4) {
Text(pair.0).font(AppFonts.mono(9)).foregroundColor(AppColors.text3(cs))
deltaBadge(pair.1, unit: "")
}
.padding(.horizontal, 8).padding(.vertical, 4)
.background(AppColors.surface2(cs)).clipShape(Capsule())
}
}
// MARK: - 12-Month trends
private var trendsSection: some View {
let series = service.monthlyVolumeTrend()
return VStack(alignment: .leading, spacing: 12) {
HStack {
sectionTitle("Monthly volume")
Spacer()
trendBadge(service.monthlyTrendClassification().classification)
}
if series.isEmpty {
infoCard("Not enough data", "A few completed months are needed to chart a trend.")
} else {
Chart(series) { point in
BarMark(
x: .value("Month", point.monthKey),
y: .value("Volume", point.volume)
)
.foregroundStyle(AppColors.accent)
}
.frame(height: 200)
.padding(12).cardBG(cs)
.accessibilityLabel("Monthly training volume for the last year")
}
}
}
// MARK: - Per-exercise
private var exercisesSection: some View {
let names = service.knownExerciseIdentities()
return VStack(alignment: .leading, spacing: 12) {
if names.isEmpty {
infoCard("No exercise history", "Logged exercises will appear here over time.")
} else {
Menu {
ForEach(names, id: \.self) { n in
Button(n.capitalized) { selectedExercise = n }
}
} label: {
HStack {
Text((selectedExercise ?? names.first ?? "").capitalized)
.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
Image(systemName: "chevron.down").font(.system(size: 11)).foregroundColor(AppColors.text3(cs))
Spacer()
}
.padding(12).cardBG(cs)
}
let id = selectedExercise ?? names.first ?? ""
let history = service.exerciseHistory(identity: id)
if history.count < 2 {
infoCard("Not enough history", "Log this exercise on more days to see a trend.")
} else {
Chart(history) { point in
LineMark(x: .value("Date", point.dateKey), y: .value("Volume", point.volume))
.foregroundStyle(AppColors.accent)
PointMark(x: .value("Date", point.dateKey), y: .value("Volume", point.volume))
.foregroundStyle(AppColors.accent)
}
.frame(height: 200)
.padding(12).cardBG(cs)
.accessibilityLabel("Volume history for \(id)")
}
}
}
}
// MARK: - Reusable bits
private func sectionTitle(_ t: String) -> some View {
Text(t.uppercased())
.font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
}
private func stat(_ value: String, _ label: String) -> some View {
VStack(alignment: .leading, spacing: 1) {
Text(value).font(AppFonts.sans(15, weight: .bold)).foregroundColor(AppColors.text(cs))
Text(label).font(AppFonts.mono(8)).foregroundColor(AppColors.text3(cs))
}
}
private func infoCard(_ title: String, _ body: String) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(title).font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text2(cs))
Text(body).font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12).cardBG(cs)
}
/// Direction shown by glyph + sign + color (glyph works without color).
private func deltaBadge(_ d: MetricDelta, unit: String) -> some View {
let (glyph, color): (String, Color) = {
switch d.direction {
case .up: return ("arrow.up.right", AppColors.green)
case .down: return ("arrow.down.right", AppColors.red)
case .flat: return ("equal", AppColors.text3(cs))
case .none: return ("minus", AppColors.text3(cs))
}
}()
let text: String = {
if let p = d.percent { return String(format: "%+.0f%%", p) }
if let a = d.absolute {
let u = unit.isEmpty ? "" : " " + unit
return String(format: "%+.0f", a) + u
}
return ""
}()
return HStack(spacing: 3) {
Image(systemName: glyph).font(.system(size: 9, weight: .bold))
Text(text).font(AppFonts.mono(11, weight: .semibold))
}
.foregroundColor(d.meaningful ? color : AppColors.text3(cs))
.accessibilityElement(children: .combine)
.accessibilityLabel("\(d.direction.rawValue) \(text)")
}
private func trendBadge(_ t: TrendClassification) -> some View {
let (glyph, color, label): (String, Color, String) = {
switch t {
case .improving: return ("arrow.up.right", AppColors.green, "Improving")
case .declining: return ("arrow.down.right", AppColors.red, "Declining")
case .plateau: return ("equal", AppColors.text2(cs), "Plateau")
case .insufficientData: return ("minus", AppColors.text3(cs), "Not enough data")
}
}()
return HStack(spacing: 4) {
Image(systemName: glyph).font(.system(size: 9, weight: .bold))
Text(label).font(AppFonts.mono(9, weight: .bold))
}
.foregroundColor(color)
.padding(.horizontal, 8).padding(.vertical, 4)
.background(color.opacity(0.12)).clipShape(Capsule())
.accessibilityElement(children: .combine)
.accessibilityLabel("Trend: \(label)")
}
private var disclaimer: some View {
Text(AnalyticsEngine.disclaimer)
.font(AppFonts.sans(10)).foregroundColor(AppColors.text3(cs))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 8)
}
}
// A minimal wrapping row (chips flow to new lines).
private struct FlowRow<Data: RandomAccessCollection, Content: View>: View {
let items: Data
let content: (Data.Element) -> Content
init(_ items: Data, @ViewBuilder content: @escaping (Data.Element) -> Content) {
self.items = items; self.content = content
}
var body: some View {
// Simple 2-per-row grid is enough for the small delta-chip set.
let arr = Array(items)
return VStack(alignment: .leading, spacing: 6) {
ForEach(Array(stride(from: 0, to: arr.count, by: 2)), id: \.self) { i in
HStack(spacing: 6) {
content(arr[i])
if i + 1 < arr.count { content(arr[i + 1]) }
Spacer(minLength: 0)
}
}
}
}
}
private extension View {
/// Standard card background used across the analytics screens.
func cardBG(_ cs: ColorScheme) -> some View {
self.frame(maxWidth: .infinity, alignment: .leading)
.background(AppColors.surface(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
}
}

View File

@@ -296,9 +296,6 @@ struct CalendarView: View {
@State private var calTaskListMode: Bool = false
/// Year currently at the top of the continuous year scroll (drives the header in .year mode).
@State private var visibleYear: Int = Calendar.current.component(.year, from: Date())
/// Month view: full grid when true, only the selected day's week when false
/// (TickTick-style collapses to give the day agenda more room).
@State private var monthExpanded: Bool = true
/// Week view: timeline when false, grid of day-cards when true.
@State private var weekGrid: Bool = false
@State private var showAddTask = false
@@ -395,6 +392,9 @@ struct CalendarView: View {
calDayTaskListView
}
}
// Pin to the top so views whose content doesn't fill the height
// (week / 3-day timelines) don't float to the bottom of the ZStack.
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
FAB { showAddTask = true }
.padding(.trailing, 20).padding(.bottom, 10)
@@ -467,25 +467,10 @@ struct CalendarView: View {
CalendarGrid.monthGrid(for: displayMonth, calendar: cal)
}
/// The month grid split into week rows of 7.
private func weekRows() -> [[Date]] {
let days = gridDays()
return stride(from: 0, to: days.count, by: 7).map { Array(days[$0..<min($0 + 7, days.count)]) }
}
/// The single week row that contains the currently selected day.
private func selectedWeekRow() -> [Date] {
let rows = weekRows()
return rows.first { row in row.contains { cal.isDate($0, inSameDayAs: selectedDate) } } ?? (rows.first ?? [])
}
/// Colors of the schedule bars shown under a day in the month grid
/// (workout · calendar events · tasks), capped so cells stay compact.
private func eventDots(for date: Date) -> [Color] {
let maxBars = 4
var dots: [Color] = []
if workoutVM.program(for: date) != nil { dots.append(AppColors.blue) }
if calStore.isAuthorized && dots.count < maxBars {
if calStore.isAuthorized && dots.count < 3 {
let calDay = calStore.events(for: date)
if !calDay.isEmpty {
let calColor = calDay.first.flatMap { Color(cgColor: $0.calendar.cgColor) } ?? AppColors.green
@@ -495,7 +480,7 @@ struct CalendarView: View {
// Includes recurring occurrences that land on this date.
let taskDots = taskVM.occurrences(on: date)
.filter { !$0.isComplete }
.prefix(max(0, maxBars - dots.count))
.prefix(max(0, 3 - dots.count))
.map { $0.quadrant.color }
dots.append(contentsOf: taskDots)
return dots
@@ -584,59 +569,25 @@ struct CalendarView: View {
}
.padding(.horizontal, 16).padding(.top, 14).padding(.bottom, 3)
// Full month (expanded) or just the selected day's week (collapsed).
let rows = monthExpanded ? weekRows() : [selectedWeekRow()]
VStack(spacing: 1) {
ForEach(rows.indices, id: \.self) { ri in
HStack(spacing: 1) {
ForEach(rows[ri], id: \.self) { date in
DayCell(
date: date,
isCurrentMonth: cal.isDate(date, equalTo: displayMonth, toGranularity: .month),
isToday: cal.isDateInToday(date),
isSelected: cal.isDate(date, inSameDayAs: selectedDate),
bars: eventDots(for: date),
highlightColumn: !monthExpanded && cal.isDate(date, inSameDayAs: selectedDate)
)
.onTapGesture {
withAnimation(KisaniSpring.snappy) {
selectedDate = date
monthExpanded = false // collapse to this week, reveal the agenda
}
}
}
}
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 1), count: 7), spacing: 1) {
ForEach(gridDays(), id: \.self) { date in
DayCell(
date: date,
isCurrentMonth: cal.isDate(date, equalTo: displayMonth, toGranularity: .month),
isToday: cal.isDateInToday(date),
isSelected: cal.isDate(date, inSameDayAs: selectedDate),
dots: eventDots(for: date)
)
.onTapGesture { withAnimation(KisaniSpring.micro) { selectedDate = date } }
}
}
.padding(.horizontal, 16)
.gesture(DragGesture(minimumDistance: 30).onEnded { v in
let dx = v.translation.width, dy = v.translation.height
if abs(dx) > abs(dy) {
// Horizontal swipe previous/next month
if dx < -40 { navigateMonth(1) }
else if dx > 40 { navigateMonth(-1) }
} else {
// Vertical drag collapse to week (up) / expand to month (down)
withAnimation(KisaniSpring.snappy) {
if dy < -30 { monthExpanded = false }
else if dy > 30 { monthExpanded = true }
}
}
.gesture(DragGesture(minimumDistance: 40).onEnded { v in
if v.translation.width < -40 { navigateMonth(1) }
else if v.translation.width > 40 { navigateMonth(-1) }
})
// Grabber expand back to the full month, or collapse to the week.
Button {
withAnimation(KisaniSpring.snappy) { monthExpanded.toggle() }
} label: {
Image(systemName: monthExpanded ? "chevron.up" : "chevron.down")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(AppColors.text3(cs))
.frame(maxWidth: .infinity).frame(height: 22)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Divider().background(AppColors.border(cs)).padding(.horizontal, 16)
Divider().background(AppColors.border(cs)).padding(.horizontal, 16).padding(.top, 6)
ScrollView(showsIndicators: false) {
DayTimelineView(
@@ -664,6 +615,7 @@ struct CalendarView: View {
onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
@@ -877,8 +829,10 @@ struct CalendarView: View {
ScrollView(showsIndicators: false) {
cvTimeGrid(days: days).padding(.bottom, 40)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
// Grid layout: the 7 days as cards (2 columns), each listing its schedule.
@@ -958,7 +912,9 @@ struct CalendarView: View {
ScrollView(showsIndicators: false) {
cvTimeGrid(days: days).padding(.bottom, 40)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
// MARK: - Day
@@ -1092,13 +1048,46 @@ struct CalendarView: View {
draggable: item.taskID != nil && !item.recurring,
onReschedule: { newStartMin in
rescheduleTask(id: item.taskID, on: date, toStartMin: newStartMin)
}
},
menu: { cvBlockMenu(for: item) }
)
}
}
.frame(maxWidth: .infinity)
}
/// Long-press menu for a timeline block. Task items get the full shared menu
/// (Complete / Snooze / date / move / edit / delete); events get nothing.
@ViewBuilder
private func cvBlockMenu(for item: CVTimeItem) -> some View {
if let id = item.taskID, let task = taskVM.tasks.first(where: { $0.id == id }) {
Button {
withAnimation(KisaniSpring.snappy) { taskVM.toggle(task) }
} label: {
Label(task.isComplete ? "Mark Incomplete" : "Complete", systemImage: "checkmark.circle")
}
Menu {
Button { taskVM.postpone(task, minutes: 15) } label: { Label("15 minutes", systemImage: "clock") }
Button { taskVM.postpone(task, minutes: 60) } label: { Label("1 hour", systemImage: "clock") }
Button { taskVM.postpone(task, days: 1) } label: { Label("Tomorrow", systemImage: "sun.max") }
} label: {
Label("Snooze", systemImage: "clock.arrow.circlepath")
}
TaskMenuItems(
task: task,
onPin: { taskVM.pin($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }
)
}
}
/// Move a timed task to a new start minute on the same day (drag-to-reschedule).
private func rescheduleTask(id: UUID?, on date: Date, toStartMin newStartMin: Int) {
guard let id, let task = taskVM.tasks.first(where: { $0.id == id }) else { return }
@@ -1210,13 +1199,14 @@ struct CVTimeItem: Identifiable {
// A single timed block on the week/day timeline. Task-backed blocks can be
// dragged vertically to reschedule (snapped to 15-minute steps); events and
// recurring tasks render fixed.
private struct CVTimeBlock: View {
private struct CVTimeBlock<MenuContent: View>: View {
let item: CVTimeItem
let top: CGFloat
let height: CGFloat
let hourH: CGFloat
let draggable: Bool
let onReschedule: (Int) -> Void
@ViewBuilder let menu: () -> MenuContent
@GestureState private var dragY: CGFloat = 0
private var block: some View {
@@ -1239,18 +1229,21 @@ private struct CVTimeBlock: View {
}
var body: some View {
if draggable {
block.gesture(
DragGesture(minimumDistance: 6)
.updating($dragY) { value, state, _ in state = value.translation.height }
.onEnded { value in
let deltaMin = Int((Double(value.translation.height) / Double(hourH) * 60).rounded() / 15) * 15
onReschedule(item.startMin + deltaMin)
}
)
} else {
block
Group {
if draggable {
block.gesture(
DragGesture(minimumDistance: 6)
.updating($dragY) { value, state, _ in state = value.translation.height }
.onEnded { value in
let deltaMin = Int((Double(value.translation.height) / Double(hourH) * 60).rounded() / 15) * 15
onReschedule(item.startMin + deltaMin)
}
)
} else {
block
}
}
.contextMenu { menu() } // empty for events no menu; task actions otherwise
}
}
@@ -1742,8 +1735,7 @@ struct AddCalendarHolidaysView: View {
// MARK: - Day Cell
struct DayCell: View {
@Environment(\.colorScheme) private var cs
let date: Date; let isCurrentMonth: Bool; let isToday: Bool; let isSelected: Bool; let bars: [Color]
var highlightColumn: Bool = false
let date: Date; let isCurrentMonth: Bool; let isToday: Bool; let isSelected: Bool; let dots: [Color]
private let cal = Calendar.current
var dayNum: String { "\(cal.component(.day, from: date))" }
/// Locale week-of-year (NOT ISO), intentionally matching the locale-driven grid
@@ -1768,24 +1760,14 @@ struct DayCell: View {
Text("W\(weekNum)").font(AppFonts.mono(6.5)).foregroundColor(AppColors.text3(cs)).offset(x: -2, y: -10)
}
}
// Schedule bars one per event/task, stacked (TickTick-style).
VStack(spacing: 1.5) {
ForEach(bars.indices, id: \.self) { i in
RoundedRectangle(cornerRadius: 1)
.fill(bars[i].opacity(0.85))
.frame(height: 3)
HStack(spacing: 2) {
ForEach(dots.indices, id: \.self) { i in
Circle().fill(dots[i]).frame(width: 3.5, height: 3.5)
}
}
.frame(maxWidth: .infinity, alignment: .top)
.padding(.horizontal, 3)
Spacer(minLength: 0)
.frame(height: 4)
}
.padding(.top, 4)
.frame(maxWidth: .infinity).frame(height: 54).contentShape(Rectangle())
.background(
RoundedRectangle(cornerRadius: 10)
.fill(highlightColumn ? AppColors.text3(cs).opacity(0.12) : .clear)
)
.frame(maxWidth: .infinity).frame(height: 42).contentShape(Rectangle())
}
}
@@ -1827,6 +1809,7 @@ struct DayTimelineView: View {
var onPostpone: ((TaskItem) -> Void)? = nil
var onEdit: ((TaskItem) -> Void)? = nil
var onDelete: ((TaskItem) -> Void)? = nil
var onCompleteAndStopSeries: ((TaskItem) -> Void)? = nil
var onSetDate: ((TaskItem, Date?) -> Void)? = nil
var onPostponeMinutes: ((TaskItem, Int) -> Void)? = nil
var onMove: ((TaskItem, Quadrant) -> Void)? = nil
@@ -1999,7 +1982,8 @@ struct DayTimelineView: View {
onResetMatrixUrgency: onResetMatrixUrgency,
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
onEdit: onEdit,
onDelete: { onDelete?($0) }
onDelete: { onDelete?($0) },
onCompleteAndStopSeries: { onCompleteAndStopSeries?($0) }
)
}

View File

@@ -72,6 +72,7 @@ struct MatrixView: View {
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onTapHeader: { drillQuadrant = .urgent; showDrill = true }
)
QuadrantCard(
@@ -93,6 +94,7 @@ struct MatrixView: View {
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onTapHeader: { drillQuadrant = .schedule; showDrill = true }
)
}
@@ -118,6 +120,7 @@ struct MatrixView: View {
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onTapHeader: { drillQuadrant = .delegate_; showDrill = true }
)
QuadrantCard(
@@ -139,6 +142,7 @@ struct MatrixView: View {
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onTapHeader: { drillQuadrant = .eliminate; showDrill = true }
)
}
@@ -211,6 +215,7 @@ struct QuadrantCard: View {
var onResetMatrixUrgency: ((TaskItem) -> Void)? = nil
var onEdit: ((TaskItem) -> Void)? = nil
var onDelete: ((TaskItem) -> Void)? = nil
var onCompleteAndStopSeries: ((TaskItem) -> Void)? = nil
var onTapHeader: (() -> Void)? = nil
private let shortFmt: DateFormatter = {
@@ -326,7 +331,8 @@ struct QuadrantCard: View {
onResetMatrixUrgency: { t in withAnimation(KisaniSpring.snappy) { onResetMatrixUrgency?(t) } },
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
onEdit: onEdit.map { cb in { cb($0) } },
onDelete: { t in withAnimation(KisaniSpring.snappy) { onDelete?(t) } }
onDelete: { t in withAnimation(KisaniSpring.snappy) { onDelete?(t) } },
onCompleteAndStopSeries: { t in withAnimation(KisaniSpring.snappy) { onCompleteAndStopSeries?(t) } }
)
}
}
@@ -487,7 +493,8 @@ struct QuadrantDetailView: View {
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) }
onDelete: { taskVM.delete($0) },
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }
)
}
if task.id != overdueTasks.last?.id { Divider().padding(.leading, 46) }
@@ -514,7 +521,8 @@ struct QuadrantDetailView: View {
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) }
onDelete: { taskVM.delete($0) },
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }
)
}
if task.id != laterTasks.last?.id { Divider().padding(.leading, 46) }
@@ -542,7 +550,8 @@ struct QuadrantDetailView: View {
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) }
onDelete: { taskVM.delete($0) },
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }
)
}
if task.id != displayed.last?.id { Divider().padding(.leading, 46) }
@@ -729,6 +738,7 @@ struct TaskEditSheet: View {
@State private var isRecurring: Bool
@State private var recurrenceLabel: String?
@State private var recurrenceEnd: Date?
@State private var recurrenceInterval: Int?
@State private var endDate: Date?
@State private var isAllDay: Bool
@State private var reminderDate: Date?
@@ -743,6 +753,7 @@ struct TaskEditSheet: View {
_isRecurring = State(initialValue: task.isRecurring)
_recurrenceLabel = State(initialValue: task.recurrenceLabel)
_recurrenceEnd = State(initialValue: task.recurrenceEnd)
_recurrenceInterval = State(initialValue: task.recurrenceInterval)
_endDate = State(initialValue: task.endDate)
_isAllDay = State(initialValue: task.isAllDay)
_reminderDate = State(initialValue: task.reminderDate)
@@ -804,7 +815,7 @@ struct TaskEditSheet: View {
.font(.system(size: 12))
.foregroundColor(task.quadrant.color)
if let lbl = recurrenceLabel {
Text(lbl)
Text(RecurrenceRule.displayLabel(label: lbl, interval: recurrenceInterval ?? 1))
.font(AppFonts.sans(12))
.foregroundColor(AppColors.text3(cs))
}
@@ -894,7 +905,8 @@ struct TaskEditSheet: View {
isAllDay: $isAllDay,
reminderDate: $reminderDate,
constantReminder: $constantReminder,
recurrenceEnd: $recurrenceEnd
recurrenceEnd: $recurrenceEnd,
recurrenceInterval: $recurrenceInterval
)
}
}
@@ -905,7 +917,8 @@ struct TaskEditSheet: View {
taskVM.updateTask(task, title: t, dueDate: dueDate, hasTime: hasTime,
isRecurring: isRecurring, recurrenceLabel: recurrenceLabel,
endDate: endDate, isAllDay: isAllDay, reminderDate: reminderDate,
constantReminder: constantReminder, recurrenceEnd: recurrenceEnd)
constantReminder: constantReminder, recurrenceEnd: recurrenceEnd,
recurrenceInterval: recurrenceInterval)
dismiss()
}
}

View File

@@ -16,6 +16,11 @@ struct OnboardingView: View {
@AppStorage("heightCm") private var heightCm: Double = 0
@AppStorage("weightUnit") private var weightUnit: Int = 0
@AppStorage("preferredWorkoutTime") private var preferredWorkoutTime: Int = 0
// Habit reminders same App Group keys HabitRemindersSheet and
// NotificationManager use, so a toggle here is identical to Settings.
@AppStorage("bedReminderEnabled", store: UserDefaults.kisani) private var bedReminderEnabled = false
@AppStorage("prayerReminderEnabled", store: UserDefaults.kisani) private var prayerReminderEnabled = false
@AppStorage("coffeeReminderEnabled", store: UserDefaults.kisani) private var coffeeReminderEnabled = false
// Local (uncommitted) state
@State private var localAppearance: Int = 2 // dark by default
@@ -507,6 +512,41 @@ struct OnboardingView: View {
.padding(.horizontal, 20)
.animation(KisaniSpring.snappy, value: workoutEnabled)
//
// MARK: Habits
//
OnboardingSectionHeader(title: "Habits")
VStack(spacing: 0) {
OnboardingHabitToggle(
icon: "hands.sparkles.fill", iconColor: AppColors.yellow, iconBg: AppColors.yellowSoft,
title: "Prayer reminders",
subtitle: "\"Have you had a chance to pray and give thanks?\"",
isOn: $prayerReminderEnabled)
Divider().background(AppColors.border(cs)).padding(.leading, 53)
OnboardingHabitToggle(
icon: "bed.double.fill", iconColor: AppColors.blue, iconBg: AppColors.blueSoft,
title: "Make your bed",
subtitle: "The simplest task that starts the day with a win",
isOn: $bedReminderEnabled)
Divider().background(AppColors.border(cs)).padding(.leading, 53)
OnboardingHabitToggle(
icon: "cup.and.saucer.fill", iconColor: .white, iconBg: AppColors.coffeeBrown,
title: "Coffee reminders",
subtitle: "Track when you usually reach for a cup",
isOn: $coffeeReminderEnabled)
}
.cardStyle()
.padding(.horizontal, 20)
.padding(.bottom, 8)
Text("You can turn any of these on or off — or fine-tune the exact times, or add a custom coffee reminder — anytime in Settings → Habits.")
.font(AppFonts.sans(11))
.foregroundColor(AppColors.text3(cs))
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
.padding(.bottom, 8)
Text("All settings can be changed later in Settings.")
.font(AppFonts.sans(11))
.foregroundColor(AppColors.text3(cs))
@@ -577,6 +617,37 @@ struct OnboardingView: View {
}
}
// MARK: - Habit Toggle Row
/// A single habit reminder's opt-in row (icon, title, one-line description,
/// toggle) used for Prayer / Bed-making / Coffee in onboarding. Per-slot
/// times and the custom coffee reminder are Settings-only, kept out of
/// onboarding to stay restrained.
private struct OnboardingHabitToggle: View {
@Environment(\.colorScheme) private var cs
let icon: String; let iconColor: Color; let iconBg: Color
let title: String; let subtitle: String
@Binding var isOn: Bool
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 14))
.foregroundColor(iconColor)
.frame(width: 32, height: 32)
.background(iconBg)
.clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 2) {
Text(title).font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
Text(subtitle).font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)).lineLimit(2)
}
Spacer()
Toggle("", isOn: $isOn).labelsHidden().tint(AppColors.accent)
}
.padding(.horizontal, 14).padding(.vertical, 12)
}
}
// MARK: - Section Header
private struct OnboardingSectionHeader: View {

View File

@@ -15,6 +15,7 @@ struct SettingsView: View {
@EnvironmentObject var taskVM: TaskViewModel
@ObservedObject private var auth = AuthManager.shared
@ObservedObject private var calStore = CalendarStore.shared
@ObservedObject private var tm = TutorialManager.shared
// Persisted settings
@AppStorage("appearanceMode") private var appearanceMode = 0
@@ -33,6 +34,9 @@ struct SettingsView: View {
@AppStorage("streakGoal") private var streakGoal = 5
// App-Group store so NotificationManager (which reads UserDefaults.kisani) sees it.
@AppStorage("kisani.periodMilestones", store: UserDefaults.kisani) private var periodMilestones = true
@AppStorage("bedReminderEnabled", store: UserDefaults.kisani) private var bedReminderEnabled = false
@AppStorage("prayerReminderEnabled", store: UserDefaults.kisani) private var prayerReminderEnabled = false
@AppStorage("coffeeReminderEnabled", store: UserDefaults.kisani) private var coffeeReminderEnabled = false
// Sheet presentation
@State private var showProfile = false
@@ -45,6 +49,7 @@ struct SettingsView: View {
@State private var showCalendar = false
@State private var showBackup = false
@State private var showWorkoutSettings = false
@State private var showHabitReminders = false
@State private var showRestTimer = false
@State private var showHelp = false
@State private var showFollow = false
@@ -54,6 +59,10 @@ struct SettingsView: View {
private var restLabel: String { workoutVM.restTimerEnabled ? "\(workoutVM.restTimerSeconds)s" : "Off" }
private var weightLabel: String { weightUnit == 0 ? "kg" : "lbs" }
private var dateLabel: String { ["MMM d", "MM/DD", "DD/MM"][dateFormat] }
private var habitsLabel: String? {
let on = [bedReminderEnabled, prayerReminderEnabled, coffeeReminderEnabled].filter { $0 }.count
return on == 0 ? nil : "\(on) on"
}
var body: some View {
ZStack {
@@ -137,6 +146,13 @@ struct SettingsView: View {
HealthToggleRow(isLast: true)
}
// HABITS
SttSection(label: "Habits") {
SttNavRow(icon: "hands.sparkles", bg: AppColors.yellowSoft, fg: AppColors.yellow,
label: "Habit Reminders", value: habitsLabel,
valueColor: habitsLabel != nil ? AppColors.green : nil, isLast: true) { showHabitReminders = true }
}
// ACCOUNT
SttSection(label: "Account", secondary: true) {
if let user = AuthManager.shared.currentUser {
@@ -174,6 +190,21 @@ struct SettingsView: View {
Spacer().frame(height: 40)
}
}
if tm.settingsIsActive {
VStack {
Spacer()
InViewTutorialCard(
tips: tm.settingsTips,
step: $tm.settingsStep,
onAdvance: { tm.advanceSettings() },
onSkip: { tm.skipSettings() }
)
.padding(.bottom, 84)
}
.transition(.opacity)
.zIndex(10)
}
}
.sheet(isPresented: $showProfile) { ProfileStatsSheet().environmentObject(taskVM).environmentObject(workoutVM).presentationDetents([.large]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showTabBar) { TabBarSheet(showMatrix: $showMatrixTab, showWorkout: $showWorkoutTab).presentationDetents([.height(320)]).presentationDragIndicator(.visible) }
@@ -185,6 +216,7 @@ struct SettingsView: View {
.sheet(isPresented: $showCalendar) { CalendarConnectSheet(calStore: calStore).presentationDetents([.large]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showBackup) { BackupSheet(iCloudSync: $iCloudSync).presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showWorkoutSettings) { WorkoutSettingsSheet(unit: $weightUnit, sets: $defaultSets, reps: $defaultReps).environmentObject(workoutVM).presentationDetents([.large]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showHabitReminders) { HabitRemindersSheet().environmentObject(workoutVM).environmentObject(taskVM).presentationDetents([.large]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showRestTimer) { RestTimerSheet(seconds: $workoutVM.restTimerSeconds, enabled: $workoutVM.restTimerEnabled).presentationDetents([.height(360)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showHelp) { HelpSheet().presentationDetents([.height(300)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showFollow) { FollowSheet().presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
@@ -880,6 +912,261 @@ private struct WorkoutSettingsSheet: View {
}
}
// MARK: - Habit Reminders Sheet
//
// Three independent daily reminders: prayer (up to 4 named times a day),
// bed-making (one nudge research says it's the easiest first win of the
// day), and coffee/caffeine (up to 4 named times plus one custom reminder).
// All keys use the App Group store (`UserDefaults.kisani`) the same store
// NotificationManager reads from so toggling here actually schedules.
private struct HabitRemindersSheet: View {
@Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs
@EnvironmentObject var workoutVM: WorkoutViewModel
@EnvironmentObject var taskVM: TaskViewModel
@AppStorage("bedReminderEnabled", store: UserDefaults.kisani) private var bedReminderEnabled = false
@AppStorage("bedReminderHour", store: UserDefaults.kisani) private var bedReminderHour = 8
@AppStorage("bedReminderMinute", store: UserDefaults.kisani) private var bedReminderMinute = 0
@AppStorage("prayerReminderEnabled", store: UserDefaults.kisani) private var prayerReminderEnabled = false
@AppStorage("coffeeReminderEnabled", store: UserDefaults.kisani) private var coffeeReminderEnabled = false
@AppStorage("coffeeCustomEnabled", store: UserDefaults.kisani) private var coffeeCustomEnabled = false
@AppStorage("coffeeCustomLabel", store: UserDefaults.kisani) private var coffeeCustomLabel = ""
@AppStorage("coffeeCustomHour", store: UserDefaults.kisani) private var coffeeCustomHour = 16
@AppStorage("coffeeCustomMinute", store: UserDefaults.kisani) private var coffeeCustomMinute = 30
private let prayerSlots = ["Morning", "Afternoon", "Evening", "Night"]
private let coffeeSlots = ["Morning", "Midday", "Afternoon", "Evening"]
var body: some View {
VStack(spacing: 0) {
SheetHeader(title: "Habit Reminders") {
dismiss()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
}
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 20) {
// Prayer
VStack(alignment: .leading, spacing: 8) {
Text("PRAYER").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
VStack(spacing: 0) {
HStack(spacing: 11) {
Image(systemName: "hands.sparkles.fill")
.font(.system(size: 13)).foregroundColor(AppColors.yellow)
.frame(width: 28, height: 28).background(AppColors.yellowSoft).cornerRadius(8)
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 6) {
Text("Prayer reminders").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
habitBadge("prayer")
}
Text("\"Have you had a chance to pray and give thanks?\"")
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
}
Spacer()
Toggle("", isOn: $prayerReminderEnabled).labelsHidden().tint(AppColors.accent)
}
.padding(.horizontal, 14).padding(.vertical, 12)
if prayerReminderEnabled {
ForEach(prayerSlots, id: \.self) { slot in
HabitSlotRow(keyPrefix: "prayer\(slot)", label: slot,
defaultHour: Self.defaultHour(for: slot, prayer: true),
defaultMinute: 0, defaultEnabled: true)
}
}
}
.cardStyle()
.animation(KisaniSpring.snappy, value: prayerReminderEnabled)
}
// Bed-making
VStack(alignment: .leading, spacing: 8) {
Text("MORNING").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
VStack(spacing: 0) {
HStack(spacing: 11) {
Image(systemName: "bed.double.fill")
.font(.system(size: 13)).foregroundColor(AppColors.blue)
.frame(width: 28, height: 28).background(AppColors.blueSoft).cornerRadius(8)
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 6) {
Text("Make your bed").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
habitBadge("bed")
}
Text("The simplest task that starts the day with a win.")
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
}
Spacer()
Toggle("", isOn: $bedReminderEnabled).labelsHidden().tint(AppColors.accent)
}
.padding(.horizontal, 14).padding(.vertical, 12)
if bedReminderEnabled {
Divider().background(AppColors.border(cs)).padding(.leading, 53)
HabitTimeRow(label: "Remind me at", hour: $bedReminderHour, minute: $bedReminderMinute)
}
}
.cardStyle()
.animation(KisaniSpring.snappy, value: bedReminderEnabled)
}
// Coffee / Caffeine
VStack(alignment: .leading, spacing: 8) {
Text("CAFFEINE").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
VStack(spacing: 0) {
HStack(spacing: 11) {
Image(systemName: "cup.and.saucer.fill")
.font(.system(size: 13)).foregroundColor(.white)
.frame(width: 28, height: 28).background(AppColors.coffeeBrown).cornerRadius(8)
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 6) {
Text("Coffee reminders").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
habitBadge("coffee")
}
Text("Track when you usually reach for a cup.")
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
}
Spacer()
Toggle("", isOn: $coffeeReminderEnabled).labelsHidden().tint(AppColors.accent)
}
.padding(.horizontal, 14).padding(.vertical, 12)
if coffeeReminderEnabled {
ForEach(coffeeSlots, id: \.self) { slot in
HabitSlotRow(keyPrefix: "coffee\(slot)", label: slot,
defaultHour: Self.defaultHour(for: slot, prayer: false),
defaultMinute: 0, defaultEnabled: slot == "Morning")
}
Divider().background(AppColors.border(cs)).padding(.leading, 14)
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 4) {
Image(systemName: "pencil").font(.system(size: 9))
Text("YOUR OWN REMINDER").font(AppFonts.mono(8, weight: .bold))
}
.foregroundColor(AppColors.text3(cs))
HStack(spacing: 11) {
TextField("Name it, e.g. \"Second coffee\"", text: $coffeeCustomLabel)
.font(AppFonts.sans(12.5)).foregroundColor(AppColors.text(cs))
Spacer()
if coffeeCustomEnabled {
DatePicker("", selection: Binding(
get: { Calendar.current.date(bySettingHour: coffeeCustomHour, minute: coffeeCustomMinute, second: 0, of: Date()) ?? Date() },
set: { v in
let c = Calendar.current.dateComponents([.hour, .minute], from: v)
coffeeCustomHour = c.hour ?? coffeeCustomHour
coffeeCustomMinute = c.minute ?? coffeeCustomMinute
}
), displayedComponents: .hourAndMinute)
.labelsHidden().tint(AppColors.accent)
}
Toggle("", isOn: $coffeeCustomEnabled).labelsHidden().tint(AppColors.accent).scaleEffect(0.85)
}
}
.padding(.horizontal, 14).padding(.vertical, 9)
}
}
.cardStyle()
.animation(KisaniSpring.snappy, value: coffeeReminderEnabled)
}
Text("All reminders are local to this device and never share your responses — they're just gentle nudges.")
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
}
.background(AppColors.background(cs).ignoresSafeArea())
}
private static func defaultHour(for slot: String, prayer: Bool) -> Int {
let table: [String: Int] = prayer
? ["Morning": 7, "Afternoon": 13, "Evening": 18, "Night": 21]
: ["Morning": 8, "Midday": 12, "Afternoon": 15, "Evening": 18]
return table[slot] ?? 9
}
/// A quiet "X days" badge from tapping "Done" on a habit's notifications
/// a private lifetime tally, never shown in Today/Matrix/Activity History.
@ViewBuilder
private func habitBadge(_ type: String) -> some View {
let count = NotificationManager.shared.habitDoneCount(type: type)
if count > 0 {
AppBadge(text: "\(count)d", bg: AppColors.greenSoft, fg: AppColors.green)
}
}
}
/// One named, independently-toggleable daily time slot (e.g. "Morning") used
/// for both Prayer and Coffee's dense multi-slot lists. Reads/writes
/// `UserDefaults.kisani` directly under `<keyPrefix>Enabled/Hour/Minute` so it
/// stays in lockstep with what `NotificationManager` schedules from.
private struct HabitSlotRow: View {
@Environment(\.colorScheme) private var cs
let keyPrefix: String
let label: String
let defaultHour: Int
let defaultMinute: Int
let defaultEnabled: Bool
@AppStorage private var enabled: Bool
@AppStorage private var hour: Int
@AppStorage private var minute: Int
init(keyPrefix: String, label: String, defaultHour: Int, defaultMinute: Int, defaultEnabled: Bool) {
self.keyPrefix = keyPrefix; self.label = label
self.defaultHour = defaultHour; self.defaultMinute = defaultMinute; self.defaultEnabled = defaultEnabled
_enabled = AppStorage(wrappedValue: defaultEnabled, "\(keyPrefix)Enabled", store: UserDefaults.kisani)
_hour = AppStorage(wrappedValue: defaultHour, "\(keyPrefix)Hour", store: UserDefaults.kisani)
_minute = AppStorage(wrappedValue: defaultMinute, "\(keyPrefix)Minute", store: UserDefaults.kisani)
}
var body: some View {
VStack(spacing: 0) {
Divider().background(AppColors.border(cs)).padding(.leading, 14)
HStack(spacing: 11) {
Text(label).font(AppFonts.sans(12.5)).foregroundColor(AppColors.text2(cs))
Spacer()
if enabled {
DatePicker("", selection: Binding(
get: { Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: Date()) ?? Date() },
set: { v in
let c = Calendar.current.dateComponents([.hour, .minute], from: v)
hour = c.hour ?? hour; minute = c.minute ?? minute
}
), displayedComponents: .hourAndMinute)
.labelsHidden().tint(AppColors.accent)
}
Toggle("", isOn: $enabled).labelsHidden().tint(AppColors.accent).scaleEffect(0.85)
}
.padding(.horizontal, 14).padding(.vertical, 9)
}
}
}
/// A plain "label + time picker" row (no toggle) used where the toggle
/// already lives on the parent row (e.g. bed-making's single time).
private struct HabitTimeRow: View {
@Environment(\.colorScheme) private var cs
let label: String
@Binding var hour: Int
@Binding var minute: Int
var body: some View {
HStack {
Text(label).font(AppFonts.sans(12)).foregroundColor(AppColors.text2(cs))
Spacer()
DatePicker("", selection: Binding(
get: { Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: Date()) ?? Date() },
set: { v in
let c = Calendar.current.dateComponents([.hour, .minute], from: v)
hour = c.hour ?? hour; minute = c.minute ?? minute
}
), displayedComponents: .hourAndMinute)
.labelsHidden().tint(AppColors.accent)
}
.padding(.horizontal, 14).padding(.vertical, 8)
}
}
private struct StepperRow: View {
@Environment(\.colorScheme) private var cs
let label: String; @Binding var value: Int; let range: ClosedRange<Int>; var isLast: Bool = false

View File

@@ -40,6 +40,9 @@ struct TaskMenuItems: View {
var onLiveActivity:(TaskItem) -> Void = { LiveActivityManager.toggle(for: $0) }
var onEdit: ((TaskItem) -> Void)? = nil
var onDelete: (TaskItem) -> Void = { _ in }
/// Complete today's occurrence AND stop the series keeps the task and its
/// full completion history (unlike Delete, which erases both).
var onCompleteAndStopSeries: (TaskItem) -> Void = { _ in }
private var cal: Calendar { .current }
@@ -132,6 +135,12 @@ struct TaskMenuItems: View {
Button { onEdit(task) } label: { Label("Edit", systemImage: "pencil") }
}
if task.isRecurring {
Button { onCompleteAndStopSeries(task) } label: {
Label("Complete & Stop Series", systemImage: "checkmark.circle.badge.xmark")
}
}
Divider()
Button(role: .destructive) { onDelete(task) } label: {

View File

@@ -46,6 +46,7 @@ struct TodayView: View {
onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
@@ -159,6 +160,7 @@ struct TodayView: View {
onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
@@ -180,6 +182,7 @@ struct TodayView: View {
onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
@@ -199,6 +202,7 @@ struct TodayView: View {
onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
@@ -218,6 +222,7 @@ struct TodayView: View {
onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
@@ -559,11 +564,25 @@ private struct TodayTLRow: View {
}
}
Spacer(minLength: 0)
TagChip(
text: entry.task.category.rawValue,
color: entry.color,
bg: entry.task.quadrant.softColor
)
// TickTick-style icon only, no text badge; date/time already
// shown in the leading time-track column for this row.
HStack(spacing: 5) {
if let glyph = entry.task.category.glyph {
Image(systemName: glyph)
.font(.system(size: 11))
.foregroundColor(entry.color)
}
if entry.task.reminderDate != nil || entry.task.constantReminder {
Image(systemName: "alarm")
.font(.system(size: 11))
.foregroundColor(AppColors.text3(cs))
}
if entry.task.isRecurring {
Image(systemName: "repeat")
.font(.system(size: 11))
.foregroundColor(AppColors.text3(cs))
}
}
Button(action: onToggle) {
ZStack {
RoundedRectangle(cornerRadius: 5, style: .continuous)
@@ -601,6 +620,7 @@ struct OverdueCard: View {
var onPin: (TaskItem) -> Void = { _ in }
var onEdit: (TaskItem) -> Void = { _ in }
var onDelete: (TaskItem) -> Void = { _ in }
var onCompleteAndStopSeries: (TaskItem) -> Void = { _ in }
var onSetDate: (TaskItem, Date?) -> Void = { _, _ in }
var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in }
var onMove: (TaskItem, Quadrant) -> Void = { _, _ in }
@@ -712,7 +732,8 @@ struct OverdueCard: View {
onResetMatrixUrgency: onResetMatrixUrgency,
onLiveActivity: { LiveActivityManager.toggle(for: $0) },
onEdit: onEdit,
onDelete: onDelete
onDelete: onDelete,
onCompleteAndStopSeries: onCompleteAndStopSeries
)
}
}
@@ -1018,6 +1039,7 @@ struct UpcomingSection: View {
var onPin: (TaskItem) -> Void = { _ in }
var onEdit: (TaskItem) -> Void = { _ in }
let onDelete: (TaskItem) -> Void
var onCompleteAndStopSeries: (TaskItem) -> Void = { _ in }
var onSetDate: (TaskItem, Date?) -> Void = { _, _ in }
var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in }
var onMove: (TaskItem, Quadrant) -> Void = { _, _ in }
@@ -1091,7 +1113,8 @@ struct UpcomingSection: View {
onResetMatrixUrgency: onResetMatrixUrgency,
onLiveActivity: { LiveActivityManager.toggle(for: $0) },
onEdit: onEdit,
onDelete: { task in withAnimation(KisaniSpring.snappy) { onDelete(task) } }
onDelete: { task in withAnimation(KisaniSpring.snappy) { onDelete(task) } },
onCompleteAndStopSeries: { task in withAnimation(KisaniSpring.snappy) { onCompleteAndStopSeries(task) } }
)
}
.padding(.horizontal, 18)
@@ -1327,46 +1350,48 @@ struct TaskRowView: View {
}
.buttonStyle(PressButtonStyle(scale: 0.82))
VStack(alignment: .leading, spacing: 2) {
VStack(alignment: .leading, spacing: 3) {
Text(task.title)
.font(AppFonts.sans(13))
.font(AppFonts.sans(13, weight: .medium))
.foregroundColor(AppColors.text(cs))
if let d = task.dueDate, showDetails {
HStack(spacing: 4) {
Text("\(dueFmt.string(from: d))\(task.isRecurring ? " · \(task.recurrenceLabel ?? "Repeat")" : "")")
.font(AppFonts.mono(9.5))
Text("\(dueFmt.string(from: d))\(task.isRecurring ? " · \(task.recurrenceDisplayLabel ?? "Repeat")" : "")")
.font(AppFonts.mono(9.5))
.tracking(0.2)
.foregroundColor(AppColors.text3(cs))
}
}
Spacer(minLength: 8)
// Right column mirrors the left: countdown metadata on top,
// icon-only category/reminder/series indicators below fills
// what used to be dead space instead of a lone floating icon.
VStack(alignment: .trailing, spacing: 4) {
if let d = task.dueDate, showDetails {
Text(countdownLabel(to: d))
.font(AppFonts.mono(9.5, weight: .semibold))
.tracking(0.2)
.foregroundColor(countdownColor(to: d))
}
HStack(spacing: 5) {
if let glyph = task.category.glyph {
Image(systemName: glyph)
.font(.system(size: 10.5))
.foregroundColor(task.quadrant.color)
}
if task.reminderDate != nil || task.constantReminder {
Image(systemName: "alarm")
.font(.system(size: 10.5))
.foregroundColor(AppColors.text3(cs))
}
if task.isRecurring {
Image(systemName: "repeat")
.font(.system(size: 10.5))
.foregroundColor(AppColors.text3(cs))
Text("·")
.font(AppFonts.mono(9.5))
.foregroundColor(AppColors.text3(cs).opacity(0.4))
Text(countdownLabel(to: d))
.font(AppFonts.mono(9.5, weight: .semibold))
.foregroundColor(countdownColor(to: d))
}
}
}
Spacer()
// reminder · 🔁 recurring indicators
HStack(spacing: 5) {
if task.reminderDate != nil || task.constantReminder {
Image(systemName: "alarm")
.font(.system(size: 11))
.foregroundColor(AppColors.text3(cs))
}
if task.isRecurring {
Image(systemName: "repeat")
.font(.system(size: 11))
.foregroundColor(AppColors.text3(cs))
}
}
TagChip(
text: task.category.rawValue,
color: task.quadrant.color,
bg: task.quadrant.softColor
)
.padding(.trailing, 9)
}
.frame(minHeight: 48)
@@ -1490,6 +1515,7 @@ struct NLParsed {
var tokenRanges: [NSRange] = []
var isRecurring: Bool = false
var recurrenceLabel: String? = nil
var recurrenceInterval: Int? = nil
var recurrenceEnd: Date? = nil
var endDate: Date? = nil
var isAllDay: Bool = false
@@ -1539,9 +1565,10 @@ struct NLTaskParser {
}
// Recurrence
if let (r, label) = parseRecurrence(in: lower) {
if let (r, label, interval) = parseRecurrence(in: lower) {
result.isRecurring = true
result.recurrenceLabel = label
result.recurrenceInterval = interval > 1 ? interval : nil
rawRanges.append(r)
}
@@ -1666,7 +1693,11 @@ struct NLTaskParser {
// MARK: - Recurrence
private static func parseRecurrence(in text: String) -> (NSRange, String)? {
private static func parseRecurrence(in text: String) -> (NSRange, String, Int)? {
// Interval phrases ("every 2 weeks", "biweekly") are checked first since
// they're more specific than the plain unit phrases below.
if let (r, label, n) = parseIntervalRecurrence(in: text) { return (r, label, n) }
// Order matters: more specific phrases ("every weekday") must precede the
// broader ones ("every week" / "every day") so they win.
let pats: [(String, String)] = [
@@ -1686,7 +1717,36 @@ struct NLTaskParser {
("\\bdaily\\b", "Daily"),
]
for (pat, label) in pats {
if let r = match(pat, in: text) { return (r, label) }
if let r = match(pat, in: text) { return (r, label, 1) }
}
return nil
}
/// "every 2 weeks", "every three months", "biweekly", "fortnightly",
/// "bimonthly" anything with an actual N > 1 attached. "every 1 week"
/// falls through to the plain patterns above rather than being handled here.
private static func parseIntervalRecurrence(in text: String) -> (NSRange, String, Int)? {
if let r = match("\\b(biweekly|fortnightly)\\b", in: text) { return (r, "Weekly", 2) }
if let r = match("\\bbimonthly\\b", in: text) { return (r, "Monthly", 2) }
let wordNums = ["a": 1, "an": 1, "one": 1, "two": 2, "three": 3, "four": 4,
"five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10]
let units: [(String, String)] = [
("days?", "Daily"),
("weeks?", "Weekly"),
("months?", "Monthly"),
("years?", "Yearly"),
]
for (unitPat, label) in units {
let pat = "\\bevery\\s+(\\d+|a|an|one|two|three|four|five|six|seven|eight|nine|ten)\\s+\(unitPat)\\b"
guard let rx = try? NSRegularExpression(pattern: pat),
let m = rx.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)),
m.numberOfRanges >= 2,
let nr = Range(m.range(at: 1), in: text) else { continue }
let ns = String(text[nr])
let n = Int(ns) ?? wordNums[ns] ?? 1
guard n > 1 else { continue }
return (m.range, label, n)
}
return nil
}
@@ -1930,7 +1990,9 @@ struct AddTaskSheet: View {
HStack(spacing: 4) {
Image(systemName: "arrow.clockwise")
.font(.system(size: 11, weight: .semibold))
Text(parsed.recurrenceLabel ?? "Recurring")
Text(parsed.recurrenceLabel.map {
RecurrenceRule.displayLabel(label: $0, interval: parsed.recurrenceInterval ?? 1)
} ?? "Recurring")
.font(AppFonts.sans(13, weight: .semibold))
}
.foregroundColor(AppColors.accent)
@@ -2046,7 +2108,8 @@ struct AddTaskSheet: View {
isAllDay: $parsed.isAllDay,
reminderDate: $reminderDate,
constantReminder: $constantReminder,
recurrenceEnd: $parsed.recurrenceEnd
recurrenceEnd: $parsed.recurrenceEnd,
recurrenceInterval: $parsed.recurrenceInterval
)
}
}
@@ -2057,6 +2120,7 @@ struct AddTaskSheet: View {
taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime,
quadrant: prefilledQuadrant, category: selectedCategory,
isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel,
recurrenceInterval: parsed.recurrenceInterval,
endDate: parsed.endDate, isAllDay: parsed.isAllDay,
priority: selectedPriority,
reminderDate: reminderDate, constantReminder: constantReminder,
@@ -2074,6 +2138,7 @@ struct TaskDatePickerSheet: View {
@Binding var isRecurring: Bool
@Binding var recurrenceLabel: String?
@Binding var recurrenceEnd: Date?
@Binding var recurrenceInterval: Int?
@Binding var endDate: Date?
@Binding var isAllDay: Bool
@Binding var reminderDate: Date?
@@ -2095,6 +2160,7 @@ struct TaskDatePickerSheet: View {
@State private var customNumber: Int
@State private var customUnit: Int // 0 = minutes, 1 = hours, 2 = days
@State private var localRepeat: String
@State private var localRecurrenceInterval: Int
@State private var localRecurrenceEnd: Date?
@State private var showRecurrenceEndPicker = false
@State private var localIsAllDay: Bool
@@ -2105,12 +2171,14 @@ struct TaskDatePickerSheet: View {
endDate: Binding<Date?>, isAllDay: Binding<Bool>,
reminderDate: Binding<Date?> = .constant(nil),
constantReminder: Binding<Bool> = .constant(false),
recurrenceEnd: Binding<Date?> = .constant(nil)) {
recurrenceEnd: Binding<Date?> = .constant(nil),
recurrenceInterval: Binding<Int?> = .constant(nil)) {
_selectedDate = selectedDate
_hasTime = hasTime
_isRecurring = isRecurring
_recurrenceLabel = recurrenceLabel
_recurrenceEnd = recurrenceEnd
_recurrenceInterval = recurrenceInterval
_endDate = endDate
_isAllDay = isAllDay
_reminderDate = reminderDate
@@ -2121,6 +2189,7 @@ struct TaskDatePickerSheet: View {
_localEndDate = State(initialValue: endDate.wrappedValue ?? start)
_localTime = State(initialValue: hasTime.wrappedValue ? selectedDate.wrappedValue : nil)
_localRepeat = State(initialValue: recurrenceLabel.wrappedValue ?? "None")
_localRecurrenceInterval = State(initialValue: recurrenceInterval.wrappedValue ?? 1)
_localRecurrenceEnd = State(initialValue: recurrenceEnd.wrappedValue)
_localIsAllDay = State(initialValue: isAllDay.wrappedValue)
_pickerMode = State(initialValue: endDate.wrappedValue != nil ? 1 : 0)
@@ -2267,6 +2336,13 @@ struct TaskDatePickerSheet: View {
}
.buttonStyle(.plain)
// Every N (only for units where an interval means anything
// "Every Weekday" is always MonFri, no interval concept)
if localRepeat != "None" && localRepeat != "Every Weekday" {
Divider().padding(.leading, 54)
intervalRow
}
// Repeat until (only when a recurrence is set)
if localRepeat != "None" {
Divider().padding(.leading, 54)
@@ -2630,6 +2706,37 @@ struct TaskDatePickerSheet: View {
.padding(.horizontal, 16).frame(height: 52)
}
private var intervalUnitLabel: String {
let plural = localRecurrenceInterval != 1
switch localRepeat {
case "Daily": return plural ? "Days" : "Day"
case "Weekly": return plural ? "Weeks" : "Week"
case "Monthly": return plural ? "Months" : "Month"
case "Yearly": return plural ? "Years" : "Year"
default: return ""
}
}
@ViewBuilder
private var intervalRow: some View {
HStack(spacing: 14) {
Image(systemName: "number")
.font(.system(size: 18))
.foregroundColor(localRecurrenceInterval > 1 ? AppColors.accent : .secondary)
.frame(width: 24)
Text("Every")
.font(AppFonts.sans(16)).foregroundColor(.primary)
Spacer()
Stepper(value: $localRecurrenceInterval, in: 1...30) {
Text("\(localRecurrenceInterval) \(intervalUnitLabel)")
.font(AppFonts.sans(15))
.foregroundColor(localRecurrenceInterval > 1 ? AppColors.accent : .secondary)
}
.fixedSize()
}
.padding(.horizontal, 16).frame(height: 52)
}
private struct RepeatOption { let key: String; let display: String }
private var repeatOptions: [RepeatOption] {
@@ -2657,7 +2764,12 @@ struct TaskDatePickerSheet: View {
}
private var repeatDisplayValue: String {
repeatOptions.first(where: { $0.key == localRepeat })?.display ?? localRepeat
// At interval 1 keep the richer "Weekly (Tuesday)" style text; once an
// interval is set, "Every 2 Weeks" is clearer than trying to combine both.
if localRecurrenceInterval > 1 {
return RecurrenceRule.displayLabel(label: localRepeat, interval: localRecurrenceInterval)
}
return repeatOptions.first(where: { $0.key == localRepeat })?.display ?? localRepeat
}
private var timeFmt: DateFormatter {
@@ -2701,6 +2813,11 @@ struct TaskDatePickerSheet: View {
isRecurring = localRepeat != "None"
recurrenceLabel = localRepeat != "None" ? localRepeat : nil
recurrenceEnd = localRepeat != "None" ? localRecurrenceEnd : nil
// Store nil (not 1) at the default interval keeps "no interval set"
// and "explicitly every 1" indistinguishable, matching every other
// task's data shape and RecurrenceRule's own nil-means-1 default.
recurrenceInterval = (localRepeat != "None" && localRepeat != "Every Weekday"
&& localRecurrenceInterval > 1) ? localRecurrenceInterval : nil
// Reminder is a lead-time offset from the task date at the chosen time-of-day.
if let off = localReminderOffset {
reminderDate = computedReminderDate(offset: off)

View File

@@ -64,8 +64,10 @@ struct WorkoutView: View {
@State private var showAddSection = false
@State private var showHistory = false
@State private var showStats = false
@State private var showAnalytics = false
@State private var isReordering = false
@State private var trainAnyway = false // override the rest-day state for today
@State private var showClearAllConfirm = false
@ObservedObject private var tm = TutorialManager.shared
private let restTick = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
@@ -103,13 +105,11 @@ struct WorkoutView: View {
.foregroundColor(AppColors.text3(cs))
}
Spacer()
IButton(icon: "chart.line.uptrend.xyaxis") { showAnalytics = true }
IButton(icon: "chart.bar.xaxis") { showStats = true }
IButton(icon: "clock.arrow.circlepath") { showHistory = true }
Menu {
Button { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(true) } } label: {
Label("Mark All Complete", systemImage: "checkmark.circle")
}
Button { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(false) } } label: {
Button(role: .destructive) { showClearAllConfirm = true } label: {
Label("Clear All Sets", systemImage: "circle")
}
Divider()
@@ -138,8 +138,18 @@ struct WorkoutView: View {
.moveDisabled(true)
} else {
// Dot Grid Progress
DotProgressCard(progress: vm.progress, done: vm.doneSets, total: vm.totalSets)
// Dot Grid Progress + Completion
DotProgressCard(
progress: vm.progress, done: vm.doneSets, total: vm.totalSets,
isDoneToday: vm.isWorkoutComplete(on: Date()),
isMissedToday: vm.isWorkoutMissed(on: Date()),
onFinish: { withAnimation(KisaniSpring.snappy) { vm.markWorkoutDone(on: Date()) } },
onMarkAllSets: { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(true) } },
onClearAllSets: { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(false) } },
onNotCompleted: { withAnimation(KisaniSpring.snappy) { vm.markWorkoutMissed(on: Date()) } },
isManuallyLogged: vm.isManuallyLogged(on: Date()),
onUndo: { withAnimation(KisaniSpring.snappy) { vm.unmarkWorkoutDone(on: Date()) } }
)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 14, bottom: 6, trailing: 14))
@@ -324,11 +334,28 @@ struct WorkoutView: View {
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showAnalytics) {
AnalyticsView()
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showStats) {
WorkoutStatsView().environmentObject(vm)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.confirmationDialog(
"Clear all logged sets?",
isPresented: $showClearAllConfirm,
titleVisibility: .visible
) {
Button("Clear All", role: .destructive) {
withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(false) }
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This un-checks every set in today's workout. It won't undo an already-finished workout.")
}
.sheet(isPresented: $showAddSection) {
AddSectionSheet().environmentObject(vm)
.presentationDetents([.height(240)])
@@ -564,34 +591,223 @@ struct AddSectionSheet: View {
struct DotProgressCard: View {
@Environment(\.colorScheme) private var cs
let progress: Double; let done: Int; let total: Int
let isDoneToday: Bool
let isMissedToday: Bool
/// Capture today as done regardless of how many sets are checked the app's
/// own set-ticking is a nice-to-have log, not the source of truth for whether
/// a workout happened (Health/Watch already knows that independently).
let onFinish: () -> Void
let onMarkAllSets: () -> Void
let onClearAllSets: () -> Void
let onNotCompleted: () -> Void
/// Whether today's "done" mark came from this app's own Finish button rather
/// than Watch/Health only manually-logged days offer an Undo (see body).
let isManuallyLogged: Bool
let onUndo: () -> Void
/// Every quick action that changes today's state is confirmed first a
/// stray tap here shouldn't silently rewrite the day.
private enum PendingAction: Identifiable {
case finish, checkAll, clearAll, notCompleted, undo
var id: Self { self }
var confirmLabel: String {
switch self {
case .finish: return "Finish Anyway"
case .checkAll: return "Check All"
case .clearAll: return "Clear All"
case .notCompleted: return "Mark Not Completed"
case .undo: return "Undo"
}
}
var isDestructive: Bool { self == .clearAll || self == .notCompleted || self == .undo }
}
@State private var pendingAction: PendingAction?
var body: some View {
VStack(spacing: 10) {
VStack(spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("\(done) of \(total) sets done")
Text(statusTitle)
.font(AppFonts.sans(13, weight: .semibold))
.foregroundColor(AppColors.text(cs))
Text(total == 0 ? "Add exercises to get started"
: done == total && total > 0 ? "All sets done"
: "\(total - done) sets remaining")
Text(statusSubtitle)
.font(AppFonts.mono(9))
.foregroundColor(AppColors.text3(cs))
}
Spacer()
Text(total > 0 ? "\(Int(progress * 100))%" : "0%")
.font(AppFonts.mono(13, weight: .bold))
.foregroundColor(progress >= 1 ? AppColors.green : AppColors.text2(cs))
statusBadge
}
// Progress dots stay visible regardless of state finishing the
// day shouldn't replace the one view that shows how it went.
VStack(alignment: .leading, spacing: 6) {
Text("PROGRESS")
.font(AppFonts.mono(8, weight: .bold))
.foregroundColor(AppColors.text3(cs))
DotGridProgress(progress: total > 0 ? progress : 0, cs: cs)
}
// Watch/Health is the source of truth once it has logged a day
// no in-card way to undo that. A manually-logged day (this app's
// own Finish button) is the one case Undo is still offered for,
// confirmed first like every other state change here.
if !(isDoneToday || isMissedToday) {
actionRow
} else if isDoneToday && isManuallyLogged {
manualUndoRow
}
}
.padding(14)
.cardStyle()
.cardStyle(borderColor: isDoneToday ? AppColors.green.opacity(0.3) : nil)
.confirmationDialog(
confirmTitle,
isPresented: Binding(get: { pendingAction != nil }, set: { if !$0 { pendingAction = nil } }),
titleVisibility: .visible,
presenting: pendingAction
) { action in
Button(action.confirmLabel, role: action.isDestructive ? .destructive : nil) {
perform(action)
}
Button("Cancel", role: .cancel) {}
} message: { action in
Text(confirmMessage(action))
}
}
private var confirmTitle: String {
switch pendingAction {
case .finish: return "Finish with \(done) of \(total) sets logged?"
case .checkAll: return "Check every set as done?"
case .clearAll: return "Clear all logged sets?"
case .notCompleted: return "Mark today as not completed?"
case .undo: return "Undo today's logged workout?"
case nil: return ""
}
}
private func confirmMessage(_ action: PendingAction) -> String {
switch action {
case .finish:
return total > 0
? "You've logged \(Int(progress * 100))% of sets. This still counts as today's workout."
: "No sets logged yet. This still counts as today's workout."
case .checkAll:
return "This ticks all \(total) sets as done — useful if you trained but didn't log along the way."
case .clearAll:
return "This un-checks all \(total) sets. It won't undo today's logged workout."
case .notCompleted:
return isDoneToday ? "This replaces today's logged workout with \"not completed.\"" : "You can undo this afterward."
case .undo:
return "This removes today's manually logged workout and goes back to pending. It won't affect anything recorded by Health or your Watch."
}
}
private func perform(_ action: PendingAction) {
switch action {
case .finish: onFinish()
case .checkAll: onMarkAllSets()
case .clearAll: onClearAllSets()
case .notCompleted: onNotCompleted()
case .undo: onUndo()
}
}
private var statusTitle: String {
if isDoneToday { return "Workout logged" }
if isMissedToday { return "Marked as not completed" }
return "\(done) of \(total) sets done"
}
private var statusSubtitle: String {
if isDoneToday { return "Counted toward today · \(done)/\(total) sets logged" }
if isMissedToday { return "Doesn't count toward today" }
if total == 0 { return "Add exercises to get started" }
if done == total { return "All sets done" }
return "\(total - done) sets remaining"
}
@ViewBuilder private var statusBadge: some View {
if isDoneToday {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 18)).foregroundColor(AppColors.green)
} else if isMissedToday {
Image(systemName: "moon.zzz.fill")
.font(.system(size: 16)).foregroundColor(AppColors.text3(cs))
} else {
Text(total > 0 ? "\(Int(progress * 100))%" : "0%")
.font(AppFonts.mono(13, weight: .bold))
.foregroundColor(progress >= 1 ? AppColors.green : AppColors.text2(cs))
}
}
/// Primary: finish the workout as-is (honest capture, not gated on 100% sets).
/// Secondary: the mechanical "tick every box" action (toggles to a clear once
/// all are checked), and the explicit miss. Every action here that changes
/// today's state is confirmed first see `pendingAction`.
private var actionRow: some View {
VStack(spacing: 8) {
Button {
pendingAction = .finish
} label: {
Label(done == total && total > 0 ? "Finish Workout" : "Finish Anyway",
systemImage: "checkmark.circle.fill")
.font(AppFonts.sans(13, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 11)
}
.buttonStyle(QuietAccentButtonStyle())
HStack(spacing: 8) {
if done == total && total > 0 {
Button { pendingAction = .clearAll } label: {
Label("Clear all sets", systemImage: "circle")
.font(AppFonts.sans(11, weight: .medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
.foregroundColor(AppColors.text2(cs))
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: 9))
.buttonStyle(PressButtonStyle(scale: 0.97))
} else {
Button { pendingAction = .checkAll } label: {
Label("Check all sets", systemImage: "checkmark.circle")
.font(AppFonts.sans(11, weight: .medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
.foregroundColor(AppColors.text2(cs))
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: 9))
.buttonStyle(PressButtonStyle(scale: 0.97))
}
Button { pendingAction = .notCompleted } label: {
Label("Didn't train", systemImage: "moon.zzz")
.font(AppFonts.sans(11, weight: .medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
.foregroundColor(AppColors.text3(cs))
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: 9))
.buttonStyle(PressButtonStyle(scale: 0.97))
}
}
}
/// Only shown for a manually-logged day tapping this always confirms first
/// (see `PendingAction.undo`), so a stray tap can't silently un-log a workout.
private var manualUndoRow: some View {
HStack {
Spacer()
Button { pendingAction = .undo } label: {
Label("Undo", systemImage: "arrow.uturn.backward")
.font(AppFonts.sans(11, weight: .semibold))
}
.foregroundColor(AppColors.accent)
}
}
}

View File

@@ -0,0 +1,61 @@
import XCTest
@testable import KisaniCal
/// Adapter tests: exercise reduction (identity + volume), inclusive day-key
/// ranges, and app-data DaySample mapping (rest/missing/scheduled handling).
final class AnalyticsAdapterTests: XCTestCase {
private func cal() -> Calendar {
var c = Calendar(identifier: .gregorian)
c.timeZone = TimeZone(identifier: "UTC")!
c.locale = Locale(identifier: "en_US_POSIX")
return c
}
func testExerciseSampleReduction() {
let es = WorkoutAnalyticsAdapter.exerciseSample(name: " Bench Press ",
completedSets: [(60, 10), (60, 8), (70, 6)])
XCTAssertEqual(es.identity, "bench press")
XCTAssertEqual(es.sets, 3)
XCTAssertEqual(es.reps, 24)
XCTAssertEqual(es.volume, 1500) // 600 + 480 + 420
}
func testInclusiveDayKeys() {
let c = cal()
let start = Date(timeIntervalSince1970: 1_783_500_000) // 2026-07-08
let keys = WorkoutAnalyticsAdapter.dayKeys(from: start, to: c.date(byAdding: .day, value: 6, to: start)!, calendar: c)
XCTAssertEqual(keys.count, 7)
XCTAssertEqual(keys, keys.sorted())
}
func testDaySampleMapping() {
let c = cal()
let start = Date(timeIntervalSince1970: 1_783_500_000)
let keys = WorkoutAnalyticsAdapter.dayKeys(from: start, to: c.date(byAdding: .day, value: 6, to: start)!, calendar: c)
let es = WorkoutAnalyticsAdapter.exerciseSample(name: "Squat", completedSets: [(100, 5), (100, 5)])
let raw = RawWorkoutDay(dateKey: keys[0], didWorkout: true, sessions: 1, durationMinutes: 45, exercises: [es])
let samples = WorkoutAnalyticsAdapter.daySamples(
dayKeys: keys,
workouts: [keys[0]: raw],
scheduledKeys: [keys[0], keys[2]],
health: [keys[0]: RawHealthDay(steps: 8000, activeCalories: 500, restingHeartRate: 55)])
XCTAssertEqual(samples.count, 7)
XCTAssertTrue(samples[0].didWorkout)
XCTAssertEqual(samples[0].volume, 1000)
XCTAssertTrue(samples[0].scheduled)
XCTAssertEqual(samples[0].steps, 8000)
XCTAssertFalse(samples[1].didWorkout) // rest / missing
XCTAssertEqual(samples[1].volume, 0)
XCTAssertNil(samples[1].steps) // honest: no fabricated data
XCTAssertTrue(samples[2].scheduled && !samples[2].didWorkout) // scheduled but missed
}
func testWeekAndMonthDayKeys() {
let c = cal()
let d = Date(timeIntervalSince1970: 1_783_500_000)
XCTAssertEqual(WorkoutAnalyticsAdapter.weekDayKeys(containing: d, calendar: c).count, 7)
XCTAssertEqual(WorkoutAnalyticsAdapter.monthDayKeys(containing: d, calendar: c).count, 31) // July
}
}

View File

@@ -0,0 +1,53 @@
import XCTest
@testable import KisaniCal
/// Deep-link routing + reconcile idempotency (no duplicate reports across runs).
final class AnalyticsCoordinatorTests: XCTestCase {
private func cal() -> Calendar {
var c = Calendar(identifier: .gregorian)
c.timeZone = TimeZone(identifier: "UTC")!
c.locale = Locale(identifier: "en_US_POSIX")
return c
}
private let now = Date(timeIntervalSince1970: 1_783_500_000) // 2026-07-08
// MARK: - Deep links
func testDeepLinkRoundTrips() {
XCTAssertEqual(AnalyticsDeepLink(url: AnalyticsDeepLink.overview.url), .overview)
XCTAssertEqual(AnalyticsDeepLink(url: AnalyticsDeepLink.weekly(weekStartKey: "2026-06-29").url),
.weekly(weekStartKey: "2026-06-29"))
XCTAssertEqual(AnalyticsDeepLink(string: "wenza://analytics/monthly/2026-05"),
.monthly(monthKey: "2026-05"))
XCTAssertEqual(AnalyticsDeepLink.weekly(weekStartKey: "2026-06-29").url.absoluteString,
"wenza://analytics/weekly/2026-06-29")
XCTAssertNil(AnalyticsDeepLink(string: "http://x/weekly/2026-06-29"))
}
// MARK: - Reconcile idempotency
func testReconcileGeneratesThenIsIdempotent() {
let tmp = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("coord_\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: tmp) }
let store = AnalyticsStore(root: tmp)
func sample(_ v: Double) -> [DaySample] {
[DaySample(dateKey: "x", didWorkout: true, scheduled: true, sessions: 1, sets: 10, volume: v)]
}
let coord = AnalyticsCoordinator(
store: store, calendar: cal(), retentionMonths: 12, now: { self.now },
weekSamples: { _ in (sample(1000), sample(800)) },
monthSamples: { _ in (sample(4000), sample(3500)) })
let first = coord.reconcile()
XCTAssertGreaterThanOrEqual(first.weekliesGenerated.count, 50)
XCTAssertEqual(first.monthliesGenerated.count, 12)
let second = coord.reconcile()
XCTAssertTrue(second.isEmpty, "second run must generate nothing (no duplicate reports)")
XCTAssertEqual(store.allWeeklyReports().count, first.weekliesGenerated.count)
XCTAssertEqual(store.allMonthlyReports().count, 12)
XCTAssertEqual(store.allWeeklyReports().first?.progression, .improving) // 1000 vs 800
}
}

View File

@@ -0,0 +1,282 @@
import XCTest
@testable import KisaniCal
/// Deterministic tests for the pure analytics engine formulas, honest-degradation,
/// trend classification, and calendar/timezone/DST/firstWeekday boundary behavior.
final class AnalyticsEngineTests: XCTestCase {
// MARK: - Delta / percentage change
func testDeltaNormal() {
let d = AnalyticsEngine.delta(current: 120, baseline: 100)
XCTAssertEqual(d.absolute, 20)
XCTAssertEqual(d.percent!, 20, accuracy: 0.0001)
XCTAssertEqual(d.direction, .up)
XCTAssertTrue(d.meaningful)
}
func testDeltaDecrease() {
let d = AnalyticsEngine.delta(current: 80, baseline: 100)
XCTAssertEqual(d.percent!, -20, accuracy: 0.0001)
XCTAssertEqual(d.direction, .down)
}
func testDeltaNoBaselineIsNotMeaningful() {
let d = AnalyticsEngine.delta(current: 50, baseline: nil)
XCTAssertNil(d.percent)
XCTAssertNil(d.absolute)
XCTAssertFalse(d.meaningful)
}
func testDeltaZeroBaselineNoPercent() {
// Divide-by-zero guard: zero baseline yields an absolute but no percent.
let d = AnalyticsEngine.delta(current: 30, baseline: 0)
XCTAssertEqual(d.absolute, 30)
XCTAssertNil(d.percent)
XCTAssertFalse(d.meaningful)
}
func testDeltaTinyBaselineNoPercent() {
let d = AnalyticsEngine.delta(current: 10, baseline: 0.00001)
XCTAssertNil(d.percent, "percentage against a negligible baseline is suppressed")
}
func testDeltaNewExercise() {
let d = AnalyticsEngine.delta(current: 500, baseline: 400, isNew: true)
XCTAssertNil(d.percent)
XCTAssertFalse(d.meaningful)
}
func testDeltaRestDayBaseline() {
let d = AnalyticsEngine.delta(current: 500, baseline: 400, restDay: true)
XCTAssertFalse(d.meaningful)
}
// MARK: - Consistency
func testConsistencyNormal() {
XCTAssertEqual(AnalyticsEngine.consistency(sessions: 4, scheduled: 5), 0.8, accuracy: 0.0001)
}
func testConsistencyZeroScheduled() {
XCTAssertEqual(AnalyticsEngine.consistency(sessions: 3, scheduled: 0), 0)
}
func testConsistencyCappedAtOne() {
XCTAssertEqual(AnalyticsEngine.consistency(sessions: 6, scheduled: 5), 1.0)
}
// MARK: - Volume
func testVolume() {
XCTAssertEqual(AnalyticsEngine.volume(weight: 60, reps: 10), 600)
}
// MARK: - Trend classification (multi-point)
func testTrendImproving() {
XCTAssertEqual(AnalyticsEngine.classifyTrend([100, 110, 120, 130]).classification, .improving)
}
func testTrendDeclining() {
XCTAssertEqual(AnalyticsEngine.classifyTrend([130, 120, 110, 100]).classification, .declining)
}
func testTrendPlateau() {
XCTAssertEqual(AnalyticsEngine.classifyTrend([100, 101, 99, 100]).classification, .plateau)
}
func testTrendInsufficientData() {
XCTAssertEqual(AnalyticsEngine.classifyTrend([100, 110]).classification, .insufficientData)
}
func testTrendAllZeroIsPlateau() {
XCTAssertEqual(AnalyticsEngine.classifyTrend([0, 0, 0, 0]).classification, .plateau)
}
// MARK: - classifyChange (week/month over period)
func testClassifyChangeImproving() {
let d = AnalyticsEngine.delta(current: 120, baseline: 100)
XCTAssertEqual(AnalyticsEngine.classifyChange(d), .improving)
}
func testClassifyChangePlateau() {
let d = AnalyticsEngine.delta(current: 101, baseline: 100)
XCTAssertEqual(AnalyticsEngine.classifyChange(d), .plateau)
}
func testClassifyChangeInsufficient() {
let d = AnalyticsEngine.delta(current: 100, baseline: nil)
XCTAssertEqual(AnalyticsEngine.classifyChange(d), .insufficientData)
}
// MARK: - Outliers
func testWinsorizeClampsExtremes() {
let w = AnalyticsEngine.winsorize([1, 2, 3, 4, 5, 6, 7, 8, 9, 1000])
XCTAssertLessThan(w.max()!, 1000, "extreme high value should be clamped")
}
func testWinsorizeSmallSampleNoop() {
let input = [1.0, 999.0]
XCTAssertEqual(AnalyticsEngine.winsorize(input), input)
}
// MARK: - Exercise identity & comparison
func testExerciseIdentityNormalizes() {
XCTAssertEqual(AnalyticsEngine.exerciseIdentity(" Bench Press "),
AnalyticsEngine.exerciseIdentity("bench press"))
}
func testCompareExercisesMarksNew() {
let cur = [ExerciseSample(identity: "squat", sets: 3, reps: 15, volume: 900),
ExerciseSample(identity: "curl", sets: 3, reps: 30, volume: 300)]
let base = [ExerciseSample(identity: "squat", sets: 3, reps: 12, volume: 720)]
let comps = AnalyticsEngine.compareExercises(current: cur, baseline: base)
let squat = comps.first { $0.identity == "squat" }!
let curl = comps.first { $0.identity == "curl" }!
XCTAssertFalse(squat.isNew)
XCTAssertEqual(squat.volume.percent!, 25, accuracy: 0.0001) // 900 vs 720
XCTAssertTrue(curl.isNew)
XCTAssertNil(curl.volume.percent)
}
// MARK: - Unit compatibility
func testUnitComparability() {
XCTAssertTrue(MetricUnit.kilograms.isComparable(with: .pounds))
XCTAssertFalse(MetricUnit.kilograms.isComparable(with: .minutes))
XCTAssertTrue(MetricUnit.kilometers.isComparable(with: .miles))
XCTAssertFalse(MetricUnit.bpm.isComparable(with: .kilocalories))
}
// MARK: - Period summary
func testSummarizeCountsSessionsAndMissed() {
let days = [
DaySample(dateKey: "2026-07-06", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000, durationMinutes: 60),
DaySample(dateKey: "2026-07-07", didWorkout: false, scheduled: true), // missed
DaySample(dateKey: "2026-07-08", didWorkout: true, scheduled: true, sessions: 1, sets: 18, volume: 900, durationMinutes: 55),
DaySample(dateKey: "2026-07-09", didWorkout: false, scheduled: false), // rest
]
let s = AnalyticsEngine.summarize(days)
XCTAssertEqual(s.sessions, 2)
XCTAssertEqual(s.scheduled, 3)
XCTAssertEqual(s.missed, 1)
XCTAssertEqual(s.sets, 38)
XCTAssertEqual(s.volume, 1900)
XCTAssertEqual(s.consistency, 2.0/3.0, accuracy: 0.0001)
}
// MARK: - HealthKit reference aggregation (partial/missing data honest)
func testSummarizeAggregatesHealthKitAveragesWhenPresent() {
let days = [
DaySample(dateKey: "2026-07-06", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000,
steps: 8000, activeCalories: 500, restingHeartRate: 55),
DaySample(dateKey: "2026-07-07", didWorkout: true, scheduled: true, sessions: 1, sets: 18, volume: 900,
steps: 10000, activeCalories: 600, restingHeartRate: 57),
DaySample(dateKey: "2026-07-08", didWorkout: false, scheduled: false) // no HK reference
]
let s = AnalyticsEngine.summarize(days)
XCTAssertEqual(s.avgSteps, 9000)
XCTAssertEqual(s.avgActiveCalories, 550)
XCTAssertEqual(s.avgRestingHR, 56)
}
func testSummarizeHealthKitNilWhenAbsent() {
let s = AnalyticsEngine.summarize([DaySample(dateKey: "2026-07-06", didWorkout: true, scheduled: true, sets: 20, volume: 1000)])
XCTAssertNil(s.avgSteps)
XCTAssertNil(s.avgActiveCalories)
XCTAssertNil(s.avgRestingHR)
XCTAssertEqual(s.volume, 1000) // workout metrics unaffected
}
// MARK: - Reports (idempotency / determinism)
func testWeeklyReportDeterministic() {
let gen = Date(timeIntervalSince1970: 1_780_000_000)
let days = [DaySample(dateKey: "2026-07-06", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000)]
let prev = [DaySample(dateKey: "2026-06-29", didWorkout: true, scheduled: true, sessions: 1, sets: 18, volume: 800)]
let r1 = AnalyticsEngine.weeklyReport(weekStartKey: "2026-07-06", days: days, previousWeekDays: prev, generatedAt: gen)
let r2 = AnalyticsEngine.weeklyReport(weekStartKey: "2026-07-06", days: days, previousWeekDays: prev, generatedAt: gen)
XCTAssertEqual(r1, r2, "same inputs → identical report (idempotent)")
XCTAssertEqual(r1.vsPreviousWeek["volume"]!.percent!, 25, accuracy: 0.0001)
XCTAssertEqual(r1.progression, .improving)
XCTAssertFalse(r1.disclaimer.isEmpty)
}
func testMonthlyReportInsufficientWhenNoPrevious() {
let days = [DaySample(dateKey: "2026-07-06", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000)]
let r = AnalyticsEngine.monthlyReport(monthKey: "2026-07", days: days, previousMonthDays: [], generatedAt: Date())
// previous volume is 0 no meaningful %, classification insufficient.
XCTAssertEqual(r.classification, .insufficientData)
}
// MARK: - Calendar / timezone / firstWeekday / DST boundaries
private func calendar(tz: String, firstWeekday: Int = 1) -> Calendar {
var c = Calendar(identifier: .gregorian)
c.timeZone = TimeZone(identifier: tz)!
c.firstWeekday = firstWeekday
c.locale = Locale(identifier: "en_US_POSIX")
return c
}
func testDateKeyRespectsTimezone() {
// 2026-07-07T02:30Z is still 2026-07-06 (22:30) in New York (UTC-4).
let instant = Date(timeIntervalSince1970: 1_783_391_400) // 2026-07-07T02:30:00Z
let utc = AnalyticsEngine.dateKey(for: instant, calendar: calendar(tz: "UTC"))
let ny = AnalyticsEngine.dateKey(for: instant, calendar: calendar(tz: "America/New_York"))
XCTAssertEqual(utc, "2026-07-07")
XCTAssertEqual(ny, "2026-07-06")
}
func testFirstWeekdayChangesWeekStart() {
let date = Date(timeIntervalSince1970: 1_783_500_000) // a Tuesday
let sundayFirst = calendar(tz: "UTC", firstWeekday: 1)
let mondayFirst = calendar(tz: "UTC", firstWeekday: 2)
let wSun = AnalyticsEngine.weekInterval(containing: date, calendar: sundayFirst)!
let wMon = AnalyticsEngine.weekInterval(containing: date, calendar: mondayFirst)!
XCTAssertNotEqual(wSun.start, wMon.start, "week start differs by firstWeekday")
}
func testDSTSpringForwardDayKeyStable() {
// US DST spring-forward 2026-03-08. The 23-hour day must still key correctly.
let cal = calendar(tz: "America/New_York")
var comps = DateComponents(); comps.year = 2026; comps.month = 3; comps.day = 8; comps.hour = 12
let noon = cal.date(from: comps)!
XCTAssertEqual(AnalyticsEngine.dateKey(for: noon, calendar: cal), "2026-03-08")
// start-of-day is well-defined despite the missing hour
let interval = AnalyticsEngine.weekInterval(containing: noon, calendar: cal)
XCTAssertNotNil(interval)
}
func testMonthKey() {
let cal = calendar(tz: "UTC")
let d = Date(timeIntervalSince1970: 1_783_500_000)
XCTAssertEqual(AnalyticsEngine.monthKey(for: d, calendar: cal), "2026-07")
}
// MARK: - Completed-period gating & retention
func testCompletedWeekGating() {
let cal = calendar(tz: "UTC")
let now = Date(timeIntervalSince1970: 1_783_500_000) // "this week"
let lastWeek = cal.date(byAdding: .weekOfYear, value: -1, to: now)!
XCTAssertTrue(AnalyticsEngine.isCompletedWeek(lastWeek, now: now, calendar: cal))
XCTAssertFalse(AnalyticsEngine.isCompletedWeek(now, now: now, calendar: cal))
}
func testRetentionExpiry() {
let cal = calendar(tz: "UTC")
let now = Date(timeIntervalSince1970: 1_783_500_000) // 2026-07
let old = AnalyticsEngine.dateKey(for: cal.date(byAdding: .month, value: -13, to: now)!, calendar: cal)
let recent = AnalyticsEngine.dateKey(for: cal.date(byAdding: .month, value: -1, to: now)!, calendar: cal)
let expired = AnalyticsEngine.expiredDayKeys([old, recent], now: now, months: 12, calendar: cal)
XCTAssertTrue(expired.contains(old))
XCTAssertFalse(expired.contains(recent))
}
}

View File

@@ -0,0 +1,94 @@
import XCTest
@testable import KisaniCal
/// Persistence tests: snapshot immutability, report idempotency/dedup,
/// reconciliation selection, and retention pruning. Each test runs against a
/// fresh temp directory. (Verified standalone via swiftc; mirrored here for the
/// Xcode test target.)
final class AnalyticsStoreTests: XCTestCase {
private var tmp: URL!
private var store: AnalyticsStore!
private let now = Date(timeIntervalSince1970: 1_783_500_000) // 2026-07-08
private func cal(_ tz: String = "UTC") -> Calendar {
var c = Calendar(identifier: .gregorian)
c.timeZone = TimeZone(identifier: tz)!
c.locale = Locale(identifier: "en_US_POSIX")
return c
}
override func setUp() {
super.setUp()
tmp = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("analytics_test_\(UUID().uuidString)")
store = AnalyticsStore(root: tmp)
}
override func tearDown() {
try? FileManager.default.removeItem(at: tmp)
super.tearDown()
}
func testPastSnapshotIsFrozen() {
let past = DaySample(dateKey: "2026-06-01", didWorkout: true, scheduled: true, sessions: 1, sets: 10, volume: 500)
XCTAssertTrue(store.saveSnapshot(past, isPast: true))
let edited = DaySample(dateKey: "2026-06-01", didWorkout: true, scheduled: true, sessions: 9, sets: 99, volume: 9999)
XCTAssertFalse(store.saveSnapshot(edited, isPast: true), "past snapshot must not be overwritten")
XCTAssertEqual(store.snapshot(dateKey: "2026-06-01")?.sets, 10)
}
func testTodaySnapshotUpdatable() {
_ = store.saveSnapshot(DaySample(dateKey: "2026-07-08", didWorkout: false, scheduled: true), isPast: false)
XCTAssertTrue(store.saveSnapshot(DaySample(dateKey: "2026-07-08", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000), isPast: false))
XCTAssertEqual(store.snapshot(dateKey: "2026-07-08")?.sets, 20)
}
func testWeeklyReportIdempotentAndImmutable() {
let wr = AnalyticsEngine.weeklyReport(
weekStartKey: "2026-06-29",
days: [DaySample(dateKey: "2026-06-29", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000)],
previousWeekDays: [DaySample(dateKey: "2026-06-22", didWorkout: true, scheduled: true, sessions: 1, sets: 18, volume: 800)],
generatedAt: now)
XCTAssertTrue(store.saveWeeklyReport(wr))
XCTAssertFalse(store.saveWeeklyReport(wr), "re-saving must be a no-op (dedup)")
XCTAssertEqual(store.weeklyReport(weekStartKey: "2026-06-29")?.progression, .improving)
}
func testMonthlyReportDedup() {
let mr = AnalyticsEngine.monthlyReport(
monthKey: "2026-06",
days: [DaySample(dateKey: "2026-06-01", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000)],
previousMonthDays: [DaySample(dateKey: "2026-05-01", didWorkout: true, scheduled: true, sessions: 1, sets: 18, volume: 800)],
generatedAt: now)
XCTAssertTrue(store.saveMonthlyReport(mr))
XCTAssertFalse(store.saveMonthlyReport(mr))
XCTAssertNotNil(store.monthlyReport(monthKey: "2026-06"))
}
func testReconciliationSelection() {
let c = cal()
let weekKeys = AnalyticsEngine.completedWeekStartKeys(now: now, months: 12, calendar: c)
XCTAssertTrue((50...53).contains(weekKeys.count))
let currentWeekKey = AnalyticsEngine.dateKey(for: AnalyticsEngine.weekInterval(containing: now, calendar: c)!.start, calendar: c)
XCTAssertFalse(weekKeys.contains(currentWeekKey), "current (incomplete) week excluded")
let monthKeys = AnalyticsEngine.completedMonthKeys(now: now, months: 12, calendar: c)
XCTAssertEqual(monthKeys.count, 12)
XCTAssertFalse(monthKeys.contains("2026-07"), "current month excluded")
// A saved report is not re-selected (idempotent reconciliation).
let wr = AnalyticsEngine.weeklyReport(weekStartKey: weekKeys.last!, days: [], previousWeekDays: [], generatedAt: now)
_ = store.saveWeeklyReport(wr)
XCTAssertFalse(AnalyticsEngine.missingKeys(weekKeys, existing: store.existingWeeklyKeys()).contains(weekKeys.last!))
}
func testRetentionPrune() {
let c = cal()
_ = store.saveSnapshot(DaySample(dateKey: "2025-01-01", didWorkout: true), isPast: true) // >12mo old
_ = store.saveSnapshot(DaySample(dateKey: "2026-07-08", didWorkout: true), isPast: false)
let removed = store.prune(now: now, months: 12, calendar: c)
XCTAssertGreaterThanOrEqual(removed, 1)
XCTAssertNil(store.snapshot(dateKey: "2025-01-01"), "expired snapshot pruned")
XCTAssertNotNil(store.snapshot(dateKey: "2026-07-08"), "recent snapshot survives")
}
}

View File

@@ -1,6 +1,7 @@
import XCTest
@testable import KisaniCal
@MainActor
final class StreakLogicTests: XCTestCase {
private let cal = Calendar.current

View File

@@ -78,6 +78,8 @@ struct EventEntity: AppEntity {
let dueDate: Date?
var isRecurring: Bool = false
var recurrenceLabel: String? = nil
var recurrenceInterval: Int? = nil
var createdAt: Date? = nil
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Event"
static var defaultQuery = EventQuery()
@@ -105,7 +107,9 @@ struct EventQuery: EntityQuery {
.filter { !$0.isComplete && $0.dueDate != nil }
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
.map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate,
isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel) }
isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel,
recurrenceInterval: $0.recurrenceInterval,
createdAt: $0.createdAt) }
}
}
@@ -235,7 +239,9 @@ struct EventProvider: AppIntentTimelineProvider {
.filter { !$0.isComplete && ($0.dueDate ?? .distantPast) > now }
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
.map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate,
isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel) }
isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel,
recurrenceInterval: $0.recurrenceInterval,
createdAt: $0.createdAt) }
}
/// The tracked task ("Track Countdown"), but only while it's still live: not
@@ -267,13 +273,16 @@ struct EventProvider: AppIntentTimelineProvider {
/// Resolve the progress window (start target) for any event. Recurring events
/// (Mode B) span the current occurrence period: previous next occurrence
/// so a birthday 5 days away reads ~98% full. One-off events (Mode A) count
/// from an explicit start, the tracking date, or first-seen, up to the due date.
private func resolveSpan(due: Date, isRecurring: Bool, label: String?,
anchorKey: String, explicitStart: Date?) -> (start: Date, target: Date) {
if isRecurring, let label, let span = Self.recurrenceSpan(label: label, due: due) {
/// from an explicit start, the task's real creation date, the tracking date,
/// or first-seen, up to the due date in that priority order.
private func resolveSpan(due: Date, isRecurring: Bool, label: String?, interval: Int? = nil,
anchorKey: String, explicitStart: Date?,
taskCreatedAt: Date? = nil) -> (start: Date, target: Date) {
if isRecurring, let label, let span = Self.recurrenceSpan(label: label, interval: interval ?? 1, due: due) {
return (span.prev, span.next)
}
if let s = explicitStart, s < due { return (s, due) }
if let created = taskCreatedAt, created < due { return (created, due) }
return (Self.storedAnchor(key: anchorKey, target: due), due)
}
@@ -288,7 +297,9 @@ struct EventProvider: AppIntentTimelineProvider {
let trackedId = task.id.uuidString
let since = UserDefaults.kisani.object(forKey: "kisani.widget.trackedSince") as? Date
let s = resolveSpan(due: task.dueDate ?? now, isRecurring: task.isRecurring,
label: task.recurrenceLabel, anchorKey: trackedId, explicitStart: since)
label: task.recurrenceLabel, interval: task.recurrenceInterval,
anchorKey: trackedId, explicitStart: since,
taskCreatedAt: task.createdAt)
return EventEntry(date: now, eventName: task.title, start: s.start, target: s.target, unitMode: mode)
}
@@ -302,12 +313,15 @@ struct EventProvider: AppIntentTimelineProvider {
}
let ev = upcoming[idx]
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
anchorKey: ev.id, explicitStart: nil)
interval: ev.recurrenceInterval,
anchorKey: ev.id, explicitStart: nil, taskCreatedAt: ev.createdAt)
return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode)
}
if let ev = config.event, let due = ev.dueDate {
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
anchorKey: ev.id, explicitStart: config.startDate)
interval: ev.recurrenceInterval,
anchorKey: ev.id, explicitStart: config.startDate,
taskCreatedAt: ev.createdAt)
return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode)
}
// Fully custom event (no task behind it).
@@ -333,30 +347,11 @@ struct EventProvider: AppIntentTimelineProvider {
/// Mode B window for a recurring event: the occurrence span containing "now"
/// (previous occurrence next occurrence), derived from the rule label.
private static func recurrenceSpan(label: String, due: Date) -> (prev: Date, next: Date)? {
let cal = Calendar.current
let now = Date()
let comp: Calendar.Component
let step: Int
switch label {
case "Daily", "Every Weekday": comp = .day; step = 1
case "Weekly": comp = .day; step = 7
case "Monthly": comp = .month; step = 1
case "Yearly": comp = .year; step = 1
default: return nil
}
var next = due
var guardRail = 0
if next > now {
// Walk back until the previous occurrence is in the past.
while let prev = cal.date(byAdding: comp, value: -step, to: next),
prev > now, guardRail < 4000 { next = prev; guardRail += 1 }
} else {
while next <= now, let n = cal.date(byAdding: comp, value: step, to: next),
guardRail < 4000 { next = n; guardRail += 1 }
}
guard let prev = cal.date(byAdding: comp, value: -step, to: next) else { return nil }
return (prev, next)
/// The actual math lives in RecurrenceRule (Shared/) shared with the main
/// app's occurrence matching and the other countdown widget's copy of this
/// same calculation, so none of the three can independently drift.
private static func recurrenceSpan(label: String, interval: Int = 1, due: Date) -> (prev: Date, next: Date)? {
RecurrenceRule.span(label: label, interval: interval, due: due, now: Date())
}
}

View File

@@ -40,6 +40,7 @@ enum ProgressDotMode: String, AppEnum {
case day
case week
case month
case year
case customEvent
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Progress Mode"
@@ -47,6 +48,7 @@ enum ProgressDotMode: String, AppEnum {
.day: "This Day",
.week: "This Week",
.month: "This Month",
.year: "This Year",
.customEvent: "Custom Event",
]
}
@@ -115,6 +117,13 @@ struct ProgressDotProvider: AppIntentTimelineProvider {
return ProgressDotEntry(date: date, title: Self.monthTitle(for: date),
progress: Self.progress(now: date, start: start, end: end),
totalDots: 100)
case .year:
let interval = Calendar.current.dateInterval(of: .year, for: date)
let start = interval?.start ?? date
let end = interval?.end ?? date
return ProgressDotEntry(date: date, title: Self.yearTitle(for: date),
progress: Self.progress(now: date, start: start, end: end),
totalDots: 100)
case .customEvent:
guard let event = configuration.event,
let task = loadWidgetTasks().first(where: { $0.id.uuidString == event.id }),
@@ -188,45 +197,16 @@ struct ProgressDotProvider: AppIntentTimelineProvider {
return Calendar.current.date(byAdding: .day, value: -totalDays, to: target) ?? now
}
// Recurrence-window math itself lives in RecurrenceRule (Shared/) so this
// widget's countdown can never drift from the main app's occurrence logic
// or from the other countdown widget's copy of the same calculation.
private static func recurrenceSpan(for task: WidgetTask, due: Date, now: Date) -> (prev: Date, next: Date)? {
let category = task.category.lowercased()
if category == "birthday" || category == "annual" {
return recurrenceSpan(label: "Yearly", due: due, now: now)
return RecurrenceRule.span(label: "Yearly", due: due, now: now)
}
guard task.isRecurring, let label = task.recurrenceLabel else { return nil }
return recurrenceSpan(label: label, due: due, now: now)
}
private static func recurrenceSpan(label: String, due: Date, now: Date) -> (prev: Date, next: Date)? {
let cal = Calendar.current
let comp: Calendar.Component
let step: Int
switch label {
case "Daily", "Every Weekday": comp = .day; step = 1
case "Weekly": comp = .day; step = 7
case "Monthly": comp = .month; step = 1
case "Yearly": comp = .year; step = 1
default: return nil
}
var next = due
var guardRail = 0
if next > now {
while let prev = cal.date(byAdding: comp, value: -step, to: next),
prev > now, guardRail < 4000 {
next = prev
guardRail += 1
}
} else {
while next <= now, let candidate = cal.date(byAdding: comp, value: step, to: next),
guardRail < 4000 {
next = candidate
guardRail += 1
}
}
guard let prev = cal.date(byAdding: comp, value: -step, to: next) else { return nil }
return (prev, next)
return RecurrenceRule.span(label: label, interval: task.recurrenceInterval ?? 1, due: due, now: now)
}
private static func startOfISOWeek(containing date: Date) -> Date {
@@ -251,6 +231,12 @@ struct ProgressDotProvider: AppIntentTimelineProvider {
formatter.dateFormat = "MMMM yyyy"
return formatter.string(from: date)
}
private static func yearTitle(for date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy"
return formatter.string(from: date)
}
}
struct DayProgressWidget: Widget {

View File

@@ -0,0 +1,26 @@
import AppIntents
import ActivityKit
/// Ends a running task Live Activity directly from its Lock Screen / Dynamic
/// Island card no need to open the app. Runs in the widget extension
/// process (that's how LiveActivityIntent works), so it talks to ActivityKit
/// directly rather than going through LiveActivityManager (app target only).
@available(iOS 17.0, *)
struct StopCountdownIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Stop Countdown"
static var description = IntentDescription("Ends this task's live countdown.")
@Parameter(title: "Task ID")
var taskID: String
init() {}
init(taskID: String) { self.taskID = taskID }
func perform() async throws -> some IntentResult {
for activity in Activity<TaskActivityAttributes>.activities
where activity.attributes.taskID == taskID {
await activity.end(nil, dismissalPolicy: .immediate)
}
return .result()
}
}

View File

@@ -10,8 +10,8 @@ struct TaskLiveActivity: Widget {
ActivityConfiguration(for: TaskActivityAttributes.self) { context in
// Lock screen / banner
HStack(spacing: 12) {
Image(systemName: context.state.isComplete ? "checkmark.circle.fill" : "circle")
.font(.system(size: 22))
Image(systemName: context.state.isComplete ? "checkmark.circle.fill" : "hourglass")
.font(.system(size: 20))
.foregroundColor(context.state.isComplete ? .green : accent)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
@@ -40,6 +40,18 @@ struct TaskLiveActivity: Widget {
.frame(width: 92, alignment: .trailing)
.fixedSize(horizontal: false, vertical: true)
}
// Explicit stop control a Live Activity can't be prevented
// from being swiped away by the system, so this gives an
// intentional, in-card way to end tracking instead.
if #available(iOS 17.0, *), !context.state.isComplete {
Button(intent: StopCountdownIntent(taskID: context.attributes.taskID)) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 18))
.foregroundColor(.white.opacity(0.5))
}
.buttonStyle(.plain)
}
}
.padding(.leading, 14)
.padding(.trailing, 10)
@@ -69,6 +81,16 @@ struct TaskLiveActivity: Widget {
.font(.system(.callout, design: .monospaced))
.lineLimit(1)
}
DynamicIslandExpandedRegion(.bottom) {
if #available(iOS 17.0, *), !context.state.isComplete {
Button(intent: StopCountdownIntent(taskID: context.attributes.taskID)) {
Label("Stop Countdown", systemImage: "xmark.circle")
.font(.system(.caption, design: .monospaced))
}
.buttonStyle(.plain)
.tint(accent)
}
}
} compactLeading: {
Image(systemName: "checklist").foregroundColor(accent)
} compactTrailing: {

View File

@@ -12,6 +12,7 @@ struct WidgetTask: Codable, Identifiable {
let category: String
var isRecurring: Bool = false
var recurrenceLabel: String? = nil
var recurrenceInterval: Int? = nil
var createdAt: Date? = nil
var reminderDate: Date? = nil
var progress: Double? = nil
@@ -67,6 +68,7 @@ func loadWidgetTasks() -> [WidgetTask] {
WidgetTask(id: $0.id, title: $0.title, dueDate: $0.dueDate,
isComplete: $0.isComplete, quadrant: $0.quadrant, category: $0.category,
isRecurring: $0.isRecurring ?? false, recurrenceLabel: $0.recurrenceLabel,
recurrenceInterval: $0.recurrenceInterval,
createdAt: $0.createdAt, reminderDate: $0.reminderDate,
progress: $0.countdownProgress ?? $0.progress)
}
@@ -82,13 +84,14 @@ private struct RawTask: Codable {
var category: String = "reminder"
var isRecurring: Bool? = nil
var recurrenceLabel: String? = nil
var recurrenceInterval: Int? = nil
var createdAt: Date? = nil
var reminderDate: Date? = nil
var progress: Double? = nil
var countdownProgress: Double? = nil
enum CodingKeys: String, CodingKey {
case id, title, dueDate, isComplete, quadrant, category, isRecurring, recurrenceLabel
case createdAt, reminderDate, progress, countdownProgress
case recurrenceInterval, createdAt, reminderDate, progress, countdownProgress
}
}

109
Shared/RecurrenceRule.swift Normal file
View File

@@ -0,0 +1,109 @@
import Foundation
/// Shared, framework-free recurrence math. Compiled into the main app
/// (`TaskItem.isOccurrence`) AND both widget extensions (their countdown
/// progress-window calculations) from `Shared/`, so all three can never
/// independently drift from each other the way three hand-written copies did.
///
/// `interval` defaults to 1 everywhere every case below is written so that
/// interval == 1 produces results identical to this app's pre-interval
/// behavior (verified in RecurrenceRuleTests.swift).
enum RecurrenceRule {
/// Whether `date` is a valid occurrence of a rule starting at `start`,
/// repeating every `interval` units of `label`, bounded by `end` if set.
static func isOccurrence(label: String?, interval: Int = 1, start: Date, date: Date,
end: Date? = nil, calendar: Calendar = .current) -> Bool {
let cal = calendar
let d0 = cal.startOfDay(for: date), s0 = cal.startOfDay(for: start)
guard d0 >= s0 else { return false }
if let end, d0 > cal.startOfDay(for: end) { return false }
let n = max(1, interval)
switch label {
case "Daily":
let days = cal.dateComponents([.day], from: s0, to: d0).day ?? 0
return days % n == 0
case "Every Weekday":
// No interval concept always MonFri, same as before intervals existed.
let wd = cal.component(.weekday, from: d0) // 1=Sun 7=Sat
return wd >= 2 && wd <= 6
case "Weekly":
let days = cal.dateComponents([.day], from: s0, to: d0).day ?? 0
return days % (7 * n) == 0
case "Monthly":
guard cal.component(.day, from: s0) == cal.component(.day, from: d0) else { return false }
let months = cal.dateComponents([.month], from: s0, to: d0).month ?? 0
return months % n == 0
case "Yearly":
guard cal.component(.day, from: s0) == cal.component(.day, from: d0),
cal.component(.month, from: s0) == cal.component(.month, from: d0)
else { return false }
let years = cal.dateComponents([.year], from: s0, to: d0).year ?? 0
return years % n == 0
default:
return false
}
}
/// The occurrence window (prev, next) containing `now` used by the
/// countdown-dot widgets to size their progress bar. `prev` is the most
/// recent occurrence on/before `now`; `next` is the following one.
/// Returns nil for an unrecognized label.
static func span(label: String, interval: Int = 1, due: Date, now: Date,
calendar: Calendar = .current) -> (prev: Date, next: Date)? {
let cal = calendar
let n = max(1, interval)
let comp: Calendar.Component
let step: Int
switch label {
case "Daily": comp = .day; step = n
case "Every Weekday": comp = .day; step = 1 // no interval concept, see isOccurrence
case "Weekly": comp = .day; step = 7 * n
case "Monthly": comp = .month; step = n
case "Yearly": comp = .year; step = n
default: return nil
}
var next = due
var guardRail = 0
if next > now {
while let prev = cal.date(byAdding: comp, value: -step, to: next),
prev > now, guardRail < 4000 {
next = prev
guardRail += 1
}
} else {
while next <= now,
let candidate = cal.date(byAdding: comp, value: step, to: next),
guardRail < 4000 {
next = candidate
guardRail += 1
}
}
guard let prev = cal.date(byAdding: comp, value: -step, to: next) else { return nil }
return (prev, next)
}
/// User-facing recurrence text "Weekly" at interval 1 (unchanged from
/// before intervals existed), "Every 2 Weeks" once N > 1. One shared
/// formatter so the main app and the create/edit picker UI can't drift.
static func displayLabel(label: String, interval: Int = 1) -> String {
let n = max(1, interval)
guard n > 1, label != "Every Weekday" else { return label }
let unit: String
switch label {
case "Daily": unit = "Days"
case "Weekly": unit = "Weeks"
case "Monthly": unit = "Months"
case "Yearly": unit = "Years"
default: return label
}
return "Every \(n) \(unit)"
}
}

View File

@@ -10,7 +10,7 @@ settings:
base:
DEVELOPMENT_TEAM: K8BLMMR883
MARKETING_VERSION: "2.0"
CURRENT_PROJECT_VERSION: "9"
CURRENT_PROJECT_VERSION: "10"
schemes:
KisaniCal:
@@ -37,6 +37,7 @@ targets:
deploymentTarget: "16.0"
sources:
- path: KisaniCal
- path: Shared
dependencies:
- target: KisaniCalWidgets
embed: true
@@ -90,6 +91,7 @@ targets:
- path: KisaniCalWidgets
- path: KisaniCal/Managers/AppGroup.swift
- path: KisaniCal/Managers/TaskActivityAttributes.swift
- path: Shared
info:
path: KisaniCalWidgets/Info.plist
properties: