Compare commits

...

61 Commits

Author SHA1 Message Date
kutesir
36986ebe00 docs: log KC-72 (StreakLogicTests fix + runner recovery)
Some checks failed
Release / archive-and-export (push) Failing after 1m9s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:57:13 +00:00
kutesir
44b4d20b3c test: mark StreakLogicTests @MainActor to fix Swift 6 compile error (KC-72)
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-12 22:57:13 +00:00
kutesir
725c4a0324 ci: finish release.yml's TestFlight upload, rule out Xcode Cloud for this repo (KC-71)
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-12 22:57:13 +00:00
kutesir
b7af2dd76b tasks: add "every N weeks/months/days/years" recurrence (KC-70)
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 22:57:13 +00:00
kutesir
f6a9358a86 recurrence: consolidate 3 duplicate implementations into one shared, tested engine (KC-69)
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-12 22:57:13 +00:00
kutesir
ee4c33a3c3 workout: restore confirmed Undo for manually-logged days only (KC-68)
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-12 22:57:13 +00:00
kutesir
3a35783331 workout: fix iCloud sync stomping the Watch-logged streak, remove Undo (KC-67)
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-12 22:57:13 +00:00
kutesir
239eb4845f liveactivity+widgets: fix iOS 16.2 build error, extend createdAt to Bars widget (KC-66)
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-12 22:57:13 +00:00
kutesir
a73f313fde model: give TaskItem a real createdAt so countdown widgets stop resetting (KC-65)
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-12 22:57:13 +00:00
kutesir
d5a8faf83e liveactivity: fix deprecated end(using:dismissalPolicy:) warning (KC-64)
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-12 22:57:13 +00:00
kutesir
d429dd04dc workout: revert Finish-at-100% confirmation, keep dots-always-visible fix (KC-63 follow-up)
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-12 22:57:13 +00:00
kutesir
4b006cfd39 workout: keep progress dots visible after finish, confirm every Finish tap (KC-63)
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-12 22:57:13 +00:00
kutesir
172bb011ff tasks: typography polish on task rows, no size changes (KC-62 follow-up 2)
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-12 22:57:13 +00:00
kutesir
92a533a930 tasks: rebalance row layout, countdown+icons move to right column (KC-62 follow-up)
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-12 22:57:13 +00:00
kutesir
80fbbd7895 tasks: replace category text pill with TickTick-style icon glyphs (KC-62)
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-12 22:57:13 +00:00
kutesir
cddbefd314 onboarding+liveactivity+habits: 3 fixes from device feedback (KC-61)
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-12 22:57:13 +00:00
kutesir
fc6ecc4979 tutorial: introduce habit reminders in the Settings tutorial too (KC-60)
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-12 22:57:13 +00:00
kutesir
d3e781c8e7 tasks: add 'Complete & Stop Series' for recurring tasks (KC-59)
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-12 22:57:13 +00:00
kutesir
e0bc5538c1 Bump build to 2.0 (10)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:57:13 +00:00
kutesir
ec58032246 habits: clarify the custom coffee reminder row (KC-58)
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-12 22:57:13 +00:00
kutesir
a6e21e3b52 fix(theme): add AppColors.coffeeBrown -- Color(r:g:b:) is file-private (KC-57)
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-12 22:57:13 +00:00
kutesir
6fe896083f habits: add a 'Done' notification action + private day-count badge (KC-56)
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-12 22:57:13 +00:00
kutesir
39c95784db habits: add Prayer, Bed-making, and Coffee/Caffeine reminders (KC-55)
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-12 22:57:13 +00:00
kutesir
dc5c403a36 tasks: fix timed recurring tasks vanishing from Today after their time passes (KC-54)
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-12 22:57:13 +00:00
kutesir
7bb37d1995 widgets: add 'This Year' mode to Progress Dots (KC-53)
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-12 22:57:13 +00:00
kutesir
709eee2579 workout: stop auto-completing the day when sets are ticked (KC-52 follow-up 3)
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-12 22:57:13 +00:00
kutesir
65c93769c5 workout: drop seal icon, confirm every destructive quick action (KC-52 follow-up 2)
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-12 22:57:13 +00:00
kutesir
76366a0065 workout: restrained button, finish confirmation, clear toggle (KC-52 follow-up)
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-12 22:57:13 +00:00
kutesir
4b2463b017 workout: bring completion controls onto the main page (KC-52)
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-12 22:57:13 +00:00
kutesir
c75a067993 fix(healthkit): use let for interval captured across async let tasks
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-12 22:57:13 +00:00
kutesir
764e1c2f3d fix(analytics): mark WorkoutSnapshot.init(vm:) @MainActor
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-12 22:57:13 +00:00
kutesir
b4813272f6 analytics(healthkit): wire historical HealthKit reference into reports (KC-51 ph4e)
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-12 22:57:13 +00:00
kutesir
2930557142 analytics(service): capture snapshot value in coordinator closures (concurrency hardening)
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-12 22:57:13 +00:00
kutesir
bcbcda5ba5 analytics(deeplink): route report notification tap to its report (KC-51 ph4c)
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-12 22:57:13 +00:00
kutesir
f8a1f90f8c analytics(ui): AnalyticsView (6 areas + states + charts) + Workout entry (KC-51 ph4b)
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-12 22:57:13 +00:00
kutesir
3f46369078 analytics(app): AnalyticsService + lifecycle reconcile + deep-linked notifications (KC-51 ph4a)
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-12 22:57:13 +00:00
kutesir
f3213c080e analytics(integration): adapter + reconcile coordinator + deep links (KC-51 ph3)
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-12 22:57:13 +00:00
kutesir
8e73615e8b analytics(persistence): file-based store + reconciliation selection (KC-51 ph2)
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-12 22:57:13 +00:00
kutesir
31cf38e911 analytics(engine): pure deterministic AnalyticsEngine + models + tests (KC-51 ph1)
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-12 22:57:13 +00:00
kutesir
a4abbb0d4d docs(issues): KC-51 — analytics system plan, persistence eval, blockers
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-12 22:57:13 +00:00
kutesir
508d321619 Calendar: force timeline fill (dead space) + long-press menu on blocks
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-12 22:57:13 +00:00
kutesir
e8065820b4 docs(issues): log KC-40–48 (streak tally, stats, history, banner, calendar rework)
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-12 22:57:13 +00:00
kutesir
51c716ae97 Calendar: fix week/3-day dead space (pin content to top)
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-12 22:57:13 +00:00
kutesir
a34afabb2f Revert "Calendar month view: TickTick-style bars + collapse-to-week"
This reverts commit 2ec3278632.
2026-07-12 22:57:13 +00:00
kutesir
d32e55fd44 Revert "Calendar month: drag-to-collapse + full-column selection highlight"
This reverts commit bd43eecfbe.
2026-07-12 22:57:13 +00:00
kutesir
6e20d665fd Calendar week view: timeline ⇄ grid layout toggle
Completes the TickTick weekly reference — a toggle flips the week between the
timeline and a grid of day-cards (2 columns), each card listing that day's
schedule as colored bars. Tapping a card opens that day. Both layouts swipe
by week.

- weekGrid state + toggle button (timeline/grid icons)
- weekGridLayout + weekDayCard

(Not build-verified — sim runtime removed to reclaim disk.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:57:13 +00:00
kutesir
39f4c0fcfa Calendar timeline: drag a task block to reschedule its time
The headline of the TickTick weekly timeline — task blocks are now draggable
vertically to change their time, snapped to 15-minute steps. Applies on the
week, 3-day, and day timelines.

- CVTimeItem carries taskID + recurring; task items are tagged, events are not
- CVTimeBlock: a draggable block (GestureState offset while dragging, commits
  on release); events and recurring tasks render fixed (non-draggable)
- rescheduleTask: clamps to the visible hour range and writes the new time via
  taskVM.setDate on the same day

(Not build-verified — sim runtime removed to reclaim disk.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:57:13 +00:00
kutesir
7fdf50c898 Calendar week view: 7-day timeline (TickTick-style)
Replaces the mini-month + agenda-list split with a proper weekly timeline —
hours down the left axis, one column per weekday, events as time-positioned
blocks — reusing the same cvTimeGrid/cvDayColumnHeader the day and 3-day
views already use. Horizontal swipe navigates by week (navigateWeek).

Removes the now-unused cvAgendaItems helper.

(Not build-verified — sim runtime removed to reclaim disk; reuses existing
timeline components.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:57:13 +00:00
kutesir
2441efe304 Calendar month: drag-to-collapse + full-column selection highlight
Two refinements to get closer to the TickTick reference:
- Drag up on the calendar collapses to the selected week; drag down expands
  to the full month (horizontal swipe still changes month). One direction-
  aware DragGesture handles both axes.
- The selected day now gets a rounded full-column highlight behind its
  number + bars when collapsed (as in the tapped-day screenshot), instead
  of only the day circle.

(Not build-verified — sim runtime removed to reclaim disk.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:57:13 +00:00
kutesir
8f89d3c73b Calendar month view: TickTick-style bars + collapse-to-week
Matches the referenced TickTick monthly view:
- Day cells now show stacked colored schedule BARS (workout/events/tasks,
  up to 4) instead of dots — a glanceable overview of the month.
- Selecting a day COLLAPSES the grid to just that week, giving the day's
  agenda room below (as in the tapped-day screenshot). A chevron grabber
  expands back to the full month.

- DayCell: bars param + stacked RoundedRectangles, top-aligned, height 54
- monthView: renders week rows (all when expanded, only the selected week
  when collapsed) via weekRows()/selectedWeekRow(); grabber toggles
- eventDots cap raised to 4 for the bar stack

(Not build-verified — sim runtime removed to reclaim disk.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:57:13 +00:00
kutesir
23fac88b1d Calendar: header follows the scrolled-to year in continuous year view
The top title now tracks whichever year is at the top of the year scroll
(e.g. scroll into 2027 and the header reads '2027'), instead of staying on
the displayed month. In month/week/day modes it still shows month + year.

- visibleYear state driven by a YearTopKey preference reporting each year
  block's top offset in the 'yearScroll' coordinate space
- headerTitle switches on viewMode
- iOS 16-safe (GeometryReader + PreferenceKey, no scrollPosition API)

(Not build-verified — sim runtime removed to reclaim disk.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:57:13 +00:00
kutesir
83d1f1d542 Calendar year view: continuous TickTick-style multi-year scroll
Replaces the single paged year (< 2026 >, with dead space below December)
with an infinite vertical scroll where years flow one after another. Opens
anchored on the current year (accent-colored); scroll up for past years,
down for future. Bottom padding clears the floating tab bar / + button.

- yearView: ScrollViewReader + LazyVStack over a year range, scrollTo(current)
- yearBlock(_:): bold year label + 12 mini-months (reuses cvMiniMonth)
- Removed navigateYear + the ◀ year ▶ header (superseded by inline labels)

(Not build-verified — sim runtime removed to reclaim disk; standard SwiftUI
+ existing cvMiniMonth helper.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:57:13 +00:00
kutesir
a69b54ff19 Workout streak banner: swipeable week / year / vs-last-week pages
Replaces the single lifetime-tally banner (which used a meaningless streak%7
tick bar) with a 3-page paged TabView:
  1. This week — count + 7 per-day ticks for the current week
  2. This year — count + 12 month ticks, with all-time total underneath
  3. vs last week — this week's count and the delta (▲ more / ▼ fewer / same)

Adds WorkoutViewModel breakdowns: workoutsThisWeek/LastWeek/ThisYear,
currentWeekDays (7 flags), currentYearMonths (12 flags).

(Not build-verified — sim runtime removed to reclaim disk; uses only APIs
already present in the original banner.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:57:13 +00:00
kutesir
97cc5589cc Today list: neutral checkbox stroke (color already on accent bar + chip)
The Tomorrow/Later rows carry their quadrant color on the left accent bar
and the category chip, so tinting the checkbox too was redundant. Match the
neutral text3 stroke the timeline rows already use.

(Not build-verified — local sim runtime removed to reclaim disk; trivial
one-line swap to an API already used elsewhere in the same file.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:57:13 +00:00
kutesir
e79cced64a docs: add CICD.md with pipeline flow diagram and gate rules
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:57:13 +00:00
kutesir
b9d2ca104e docs: add STRUCTURE.md describing targets, layout, and architecture
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:57:13 +00:00
cd2dc1cee6 ci: lowercase runner label (runner registered as self-hosted,macos) 2026-07-12 22:57:13 +00:00
f7bc0b9b3c ci: lowercase runner label (runner registered as self-hosted,macos) 2026-07-12 22:57:13 +00:00
kutesir
cf5dc8943a ci: add Gitea Actions CI/CD, branch workflow, and contributor docs
- .gitea/workflows/ci.yml: build + test KisaniCal scheme on a self-hosted
  macOS runner for every PR into main/develop (and pushes to develop).
- .gitea/workflows/release.yml: archive + export IPA on push to main, with
  a commented TestFlight upload placeholder (App Store Connect API key).
- ExportOptions.plist: export template (Team ID K8BLMMR883, app-store method).
- scripts/gitea-setup.sh: idempotent Gitea API setup for develop default
  branch + main branch protection.
- CONTRIBUTING.md: feature/* -> develop -> main workflow and PR gate rules.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:57:13 +00:00
kutesir
c0dd298e5b Workout streak: lifetime tally that only grows (never resets)
Per user model: the workout count should reflect every workout achieved and
never break. Replaces the consecutive/weekly-goal streak with a simple count
of distinct workout days (totalAchievedWorkoutDays). A below-goal week (4/5)
and a full week (5/5) both count fully (=9); a missed day or blank week never
reduces it; duplicate HealthKit days count once.

- ExerciseModels: streakDays = Set(workoutDates).count
- StreakLogicTests: retargeted to tally semantics (4/5+5/5=9, blank-week,
  duplicates, skipped-day, empty)
- SettingsView: goal caption no longer claims the streak can be lost

NOTE: not built/tested locally — CLI xcodebuild wedges on actool via the
broken CoreSimulator daemon (missing sim runtimes, machine-wide). Verify in
Xcode GUI (prefs now fixed) or on device.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:51:46 +03:00
kutesir
6b5f6bd3ea Stats: schedule-aware weekly streaks, recurring-aware counts, activity history
- Workout streak now honors the weekly goal ('reach your goal every week'):
  counts workout days across consecutive kept weeks, so rest days and a
  skipped day within goal no longer reset it to 1 (KC: profile showed
  streak 1 with 5 workouts that week).
- Task 7-day bars / 'this week' now count recurring occurrences
  (completedOccurrences), fixing permanently-empty bars alongside a
  nonzero done rate.
- New task day-streak stat on the profile Tasks card.
- New ActivityHistoryView: 90-day day-by-day archive of completed tasks
  (times, repeat markers), workout logs (sets/volume), missed/made-up days.
- StreakLogicTests: 8 unit tests; algorithm additionally verified via
  standalone harness (7/7 scenarios incl. the skipped-Thursday case).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:05:14 +03:00
47 changed files with 6286 additions and 337 deletions

86
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,86 @@
name: CI
# Build + test on every PR into main or develop (and on direct pushes to develop).
on:
pull_request:
branches: [main, develop]
push:
branches: [develop]
# ⚠️ RUNNER LABELS — must match how your Mac runner was registered with act_runner.
# Check your runner's labels in Gitea: Settings → Actions → Runners (click the runner).
# If it registered as e.g. `macos` or `self-hosted`, change `runs-on` below to match.
# `[self-hosted, macOS]` means "a runner that has BOTH labels".
jobs:
build-and-test:
runs-on: [self-hosted, macos]
env:
SCHEME: KisaniCal
PROJECT: KisaniCal.xcodeproj
steps:
- name: Checkout
uses: actions/checkout@v4
# The .xcodeproj is generated from project.yml via XcodeGen.
# Regenerate it so CI never builds a stale project. No-op if xcodegen isn't installed.
- name: Regenerate Xcode project (XcodeGen)
run: |
if command -v xcodegen >/dev/null 2>&1; then
xcodegen generate
else
echo "xcodegen not found on runner — using committed ${PROJECT}."
echo "Install with: brew install xcodegen"
fi
- name: Show toolchain
run: |
xcodebuild -version
xcrun simctl list runtimes | grep -i ios || true
# Pick a concrete, bootable iOS Simulator (needed for `xcodebuild test`).
- name: Select iOS Simulator destination
id: sim
run: |
# First available iPhone simulator on any installed iOS runtime.
UDID=$(xcrun simctl list devices available -j | python3 -c "import json,sys,re; d=json.load(sys.stdin)['devices']; devs=[x for k,v in d.items() if re.search('iOS',k) for x in v if x.get('isAvailable') and 'iPhone' in x['name']]; print(devs[0]['udid'] if devs else '')")
if [ -z "$UDID" ]; then
echo "No available iPhone simulator found on the runner." >&2
echo "Install an iOS runtime via Xcode → Settings → Components." >&2
exit 1
fi
echo "udid=$UDID" >> "$GITHUB_OUTPUT"
echo "Using simulator UDID: $UDID"
- name: Build
run: |
set -o pipefail
xcodebuild build \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration Debug \
-destination "id=${{ steps.sim.outputs.udid }}" \
CODE_SIGNING_ALLOWED=NO \
| xcbeautify || xcodebuild build \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration Debug \
-destination "id=${{ steps.sim.outputs.udid }}" \
CODE_SIGNING_ALLOWED=NO
- name: Test
run: |
set -o pipefail
xcodebuild test \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration Debug \
-destination "id=${{ steps.sim.outputs.udid }}" \
CODE_SIGNING_ALLOWED=NO \
| xcbeautify || xcodebuild test \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration Debug \
-destination "id=${{ steps.sim.outputs.udid }}" \
CODE_SIGNING_ALLOWED=NO

View File

@@ -0,0 +1,66 @@
name: Release
# Runs only when code lands on main (i.e. after a PR is merged).
on:
push:
branches: [main]
# ⚠️ Same runner-label caveat as ci.yml — adjust `runs-on` to match your registered runner.
jobs:
archive-and-export:
runs-on: [self-hosted, macos]
env:
SCHEME: KisaniCal
PROJECT: KisaniCal.xcodeproj
# A real distribution build MUST be signed. This requires an Apple
# Distribution certificate + provisioning profile installed in the
# runner's login keychain (see notes at the bottom of this file).
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Regenerate Xcode project (XcodeGen)
run: |
if command -v xcodegen >/dev/null 2>&1; then xcodegen generate; fi
- name: Archive
run: |
set -o pipefail
xcodebuild archive \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath "$RUNNER_TEMP/KisaniCal.xcarchive" \
-allowProvisioningUpdates
# Write the App Store Connect API key to disk. xcodebuild's auto-discovery
# looks for exactly this filename pattern under ~/private_keys.
- name: Write App Store Connect API key
run: |
mkdir -p ~/private_keys
echo "${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }}" > ~/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}.p8
# Export AND upload in one step: passing the API key credentials to
# -exportArchive makes xcodebuild upload directly to App Store Connect
# (method "app-store" in ExportOptions.plist) — no separate altool/
# Transporter step needed. (altool's own upload path was retired by
# Apple in 2023; this is the current supported mechanism.)
- name: Export IPA and upload to TestFlight
run: |
set -o pipefail
xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/KisaniCal.xcarchive" \
-exportOptionsPlist ExportOptions.plist \
-exportPath "$RUNNER_TEMP/export" \
-allowProvisioningUpdates \
-authenticationKeyPath ~/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}.p8 \
-authenticationKeyID ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} \
-authenticationKeyIssuerID ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
ls -la "$RUNNER_TEMP/export"
- name: Clean up API key
if: always()
run: rm -rf ~/private_keys

61
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,61 @@
# Contributing to KisaniCal
## Branch workflow
```
feature/* ─PR→ develop ─PR→ main
```
- **`main`** — protected, release-ready. Never commit or push directly. Every
change arrives via a reviewed pull request. Pushes to `main` trigger the
release workflow (archive → IPA → TestFlight).
- **`develop`** — the default working branch and integration target. CI runs on
every PR into it.
- **`feature/*`** — short-lived branches for a single change, e.g.
`feature/voice-quick-add`, `fix/icloud-restore`. Branch off `develop`.
## Day-to-day
```bash
git checkout develop
git pull
git checkout -b feature/my-change
# ...work, commit...
git push -u origin feature/my-change
# then open a PR in Gitea: feature/my-change → develop
```
When `develop` is ready to ship, open a PR **`develop``main`**.
## Pull request requirements
A PR into `main` (and `develop`) cannot be merged until:
1. **CI passes** — the `build-and-test` job in `.gitea/workflows/ci.yml` builds
the app and runs `KisaniCalTests` on an iOS Simulator.
2. **At least 1 approval** — reviewed and approved.
3. **No unresolved change requests** — a rejected review blocks the merge.
4. Your branch is **up to date** with the base branch.
Direct pushes, force-pushes, and deletion of `main` are blocked server-side by
Gitea branch protection, and a local `pre-push` hook blocks accidental direct
pushes to `main` from this clone.
## CI / CD
- **CI** (`.gitea/workflows/ci.yml`) — runs on every PR into `main`/`develop`
and on pushes to `develop`. Builds + tests. A red check blocks the merge.
- **Release** (`.gitea/workflows/release.yml`) — runs only on push to `main`
(i.e. after a PR merges). Archives, exports an IPA via `ExportOptions.plist`,
and (once configured) uploads to TestFlight.
Both workflows run on a **self-hosted macOS runner** with Xcode — there is no
Gitea-hosted Mac runner.
## Versioning
`MARKETING_VERSION` (e.g. `2.0`) and `CURRENT_PROJECT_VERSION` (build number)
live in `project.yml`. The build number must **increase** for every TestFlight
upload — App Store Connect rejects a build number it has already seen.

45
ExportOptions.plist Normal file
View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
ExportOptions template for `xcodebuild -exportArchive`.
FILL IN:
• teamID — your Apple Developer Team ID. Detected from project.yml
(DEVELOPMENT_TEAM): K8BLMMR883 ← pre-filled below, verify it.
• method — choose ONE:
app-store → TestFlight / App Store submission
ad-hoc → distribute to registered UDIDs
development → internal dev devices
enterprise → in-house (Apple Enterprise Program only)
Notes:
• signingStyle "automatic" lets Xcode manage certs/profiles (pairs with
-allowProvisioningUpdates in release.yml). Switch to "manual" if you
install a specific distribution profile on the runner.
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store</string>
<key>teamID</key>
<string>K8BLMMR883</string>
<key>signingStyle</key>
<string>automatic</string>
<key>uploadSymbols</key>
<true/>
<key>uploadBitcode</key>
<false/>
<!-- Optional: pin the app to a specific provisioning profile (manual signing).
<key>provisioningProfiles</key>
<dict>
<key>com.kutesir.KisaniCal</key>
<string>YOUR_PROFILE_NAME</string>
</dict>
-->
</dict>
</plist>

View File

@@ -7,26 +7,33 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* 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 */; }; 06CA0F336E64D9F6D56F7472 /* CloudSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449C34805DC6B2CB66886544 /* CloudSyncManager.swift */; };
096804560A2F0A7D74E64780 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC2AFB4FA765383740767CB /* TaskItem.swift */; }; 096804560A2F0A7D74E64780 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC2AFB4FA765383740767CB /* TaskItem.swift */; };
12E42CE8B8E535FAE6268A0C /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF905C574F34B4EE51A8D21E /* AppGroup.swift */; }; 12E42CE8B8E535FAE6268A0C /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF905C574F34B4EE51A8D21E /* AppGroup.swift */; };
13E4B9854F595394FC9D5912 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC5E179A9189B0A8C3F856F6 /* ContentView.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 */; }; 16FFC465D8646DED9A5C69D1 /* NLRecurrenceParseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */; };
1A22EE21460821170E44B1DF /* ExerciseModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5722CC4B59E3939724142710 /* ExerciseModels.swift */; }; 1A22EE21460821170E44B1DF /* ExerciseModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5722CC4B59E3939724142710 /* ExerciseModels.swift */; };
205846F9651EDCE0D8207358 /* TaskLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */; }; 205846F9651EDCE0D8207358 /* TaskLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */; };
2885D000426D063F2125804C /* SpeechRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86906905A4FA7A2384B3E24E /* SpeechRecognizer.swift */; }; 2885D000426D063F2125804C /* SpeechRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86906905A4FA7A2384B3E24E /* SpeechRecognizer.swift */; };
2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF905C574F34B4EE51A8D21E /* AppGroup.swift */; }; 2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF905C574F34B4EE51A8D21E /* AppGroup.swift */; };
30D155A1314CBD4BB1744B29 /* RecurrenceRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A0B871AB37123908CF0CB20 /* RecurrenceRule.swift */; };
32C63D81925FBFE51CAE1FB7 /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; }; 32C63D81925FBFE51CAE1FB7 /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; };
3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01A27D42E141DC056D32C1A3 /* TutorialManager.swift */; }; 3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01A27D42E141DC056D32C1A3 /* TutorialManager.swift */; };
3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89550F2CD19B950CCC6AD37F /* AuthManager.swift */; }; 3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89550F2CD19B950CCC6AD37F /* AuthManager.swift */; };
3C793FD5DA00D3E9C0D51FEC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 72FDF9C8DD37134576356B89 /* Assets.xcassets */; }; 3C793FD5DA00D3E9C0D51FEC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 72FDF9C8DD37134576356B89 /* Assets.xcassets */; };
3E9DE1CF20BAC479805DF940 /* MyTasksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */; }; 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 */; }; 45AA93D76970B39DB8BA6A5B /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */; };
497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */; }; 497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */; };
4FB79BBC1EE0F6C7EF844B02 /* RecurrenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */; }; 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 */; }; 552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */; };
55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429806CE1021C8DE2EB770CE /* WidgetViews.swift */; }; 55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429806CE1021C8DE2EB770CE /* WidgetViews.swift */; };
5D0E254AB5D195AFDE09FABF /* StopCountdownIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91A354921717711EB3C0EE8E /* StopCountdownIntent.swift */; };
5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.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 */; }; 67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 106EEF572C6F8990408329F0 /* OnboardingView.swift */; };
6921CB73A3257502FF778381 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */; }; 6921CB73A3257502FF778381 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */; };
6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */; }; 6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */; };
@@ -36,25 +43,35 @@
7CEBC340BFA9238D121946AC /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */; }; 7CEBC340BFA9238D121946AC /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */; };
8387093D19FB3397CCB8FEF8 /* WenzaWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF528FCC224EF283F95851AD /* WenzaWatchApp.swift */; }; 8387093D19FB3397CCB8FEF8 /* WenzaWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF528FCC224EF283F95851AD /* WenzaWatchApp.swift */; };
83EA218392952885C97144D1 /* TodayMenuFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.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 */; }; 8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23A4491BFA50721082024756 /* SharedComponents.swift */; };
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3D5289C5F93484E22DEB63 /* TutorialView.swift */; }; 8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3D5289C5F93484E22DEB63 /* TutorialView.swift */; };
8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; }; 8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; };
9070521B1D36A5551976C275 /* CalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF6CCD95A587E26E30F5712 /* CalendarView.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 */; }; 9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */; };
A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */; }; A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */; };
A292B0492135BA40F6B6A951 /* CalendarGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FCB2077D9509CB5C0978E58 /* CalendarGrid.swift */; }; A292B0492135BA40F6B6A951 /* CalendarGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FCB2077D9509CB5C0978E58 /* CalendarGrid.swift */; };
A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72308FEE0226F45414C04DDD /* SettingsView.swift */; }; A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72308FEE0226F45414C04DDD /* SettingsView.swift */; };
A9FF93259AE8FF0ABF69D71A /* DesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4A35C0E1270E2E15C03F23 /* DesignTokens.swift */; }; A9FF93259AE8FF0ABF69D71A /* DesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4A35C0E1270E2E15C03F23 /* DesignTokens.swift */; };
AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */; }; AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */; };
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 */; }; BD0DB4B0AA8A63D124EDFF2C /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5AFD143B693B77D07FBDA4 /* HealthKitManager.swift */; };
BEAFF968632A34C70B11C5AC /* MatrixView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D44530A77DF12A17E52AAF34 /* MatrixView.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 */; }; C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */; };
C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C786EBC7DF879D64EB28165E /* TodayView.swift */; }; C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C786EBC7DF879D64EB28165E /* TodayView.swift */; };
CBE7295BF5ADE08FE93AFAAF /* FloatingTabState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9100804DB1E61EA882CC54DA /* FloatingTabState.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 */; }; 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 */; }; ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD014B7E3E30A34E18696A0 /* AuthView.swift */; };
EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */; }; EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */; };
EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93D045FE3DEB1D22D908A29F /* NotificationManager.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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -91,19 +108,25 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalApp.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 0506183945D16EC443A69651 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskLiveActivity.swift; sourceTree = "<group>"; }; 0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskLiveActivity.swift; sourceTree = "<group>"; };
0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = "<group>"; }; 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = "<group>"; };
0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = KisaniCalWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = KisaniCalWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
0FCB2077D9509CB5C0978E58 /* CalendarGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarGrid.swift; sourceTree = "<group>"; }; 0FCB2077D9509CB5C0978E58 /* CalendarGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarGrid.swift; sourceTree = "<group>"; };
106EEF572C6F8990408329F0 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; }; 106EEF572C6F8990408329F0 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
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>"; }; 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>"; }; 208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyTasksWidget.swift; sourceTree = "<group>"; };
20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutView.swift; sourceTree = "<group>"; }; 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutView.swift; sourceTree = "<group>"; };
20E29A9264577A89891DAEC1 /* KisaniCalTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = KisaniCalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 20E29A9264577A89891DAEC1 /* KisaniCalTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = KisaniCalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
23A4491BFA50721082024756 /* SharedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedComponents.swift; sourceTree = "<group>"; }; 23A4491BFA50721082024756 /* SharedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedComponents.swift; sourceTree = "<group>"; };
326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutHandler.swift; sourceTree = "<group>"; }; 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutHandler.swift; sourceTree = "<group>"; };
35FF274EFF2361B7F3D2D227 /* WenzaWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WenzaWatch.entitlements; sourceTree = "<group>"; }; 35FF274EFF2361B7F3D2D227 /* WenzaWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WenzaWatch.entitlements; sourceTree = "<group>"; };
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>"; }; 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>"; }; 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>"; }; 449C34805DC6B2CB66886544 /* CloudSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSyncManager.swift; sourceTree = "<group>"; };
@@ -112,20 +135,27 @@
57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; }; 57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetData.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 72308FEE0226F45414C04DDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
72FDF9C8DD37134576356B89 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 72FDF9C8DD37134576356B89 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarGridTests.swift; sourceTree = "<group>"; }; 74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarGridTests.swift; sourceTree = "<group>"; };
768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExerciseSheet.swift; sourceTree = "<group>"; }; 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExerciseSheet.swift; sourceTree = "<group>"; };
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; }; 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>"; }; 86906905A4FA7A2384B3E24E /* SpeechRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechRecognizer.swift; sourceTree = "<group>"; };
89550F2CD19B950CCC6AD37F /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; }; 89550F2CD19B950CCC6AD37F /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; };
8DC8687EA2FBA9FB2EEE51C6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 8DC8687EA2FBA9FB2EEE51C6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCalWidgets.entitlements; sourceTree = "<group>"; }; 8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCalWidgets.entitlements; sourceTree = "<group>"; };
9100804DB1E61EA882CC54DA /* FloatingTabState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingTabState.swift; sourceTree = "<group>"; }; 9100804DB1E61EA882CC54DA /* FloatingTabState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingTabState.swift; sourceTree = "<group>"; };
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>"; }; 92824ED40ECD41EFD4F78BEC /* ISSUES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ISSUES.md; sourceTree = "<group>"; };
93D045FE3DEB1D22D908A29F /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = "<group>"; }; 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = "<group>"; };
9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NLRecurrenceParseTests.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; }; 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>"; }; ADF6CCD95A587E26E30F5712 /* CalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarView.swift; sourceTree = "<group>"; };
AF905C574F34B4EE51A8D21E /* AppGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroup.swift; sourceTree = "<group>"; }; AF905C574F34B4EE51A8D21E /* AppGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroup.swift; sourceTree = "<group>"; };
B4CFFDFE4653A9E901CEF28D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; B4CFFDFE4653A9E901CEF28D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
@@ -134,9 +164,12 @@
BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = "<group>"; }; BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = "<group>"; };
C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; }; C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
C786EBC7DF879D64EB28165E /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = "<group>"; }; C786EBC7DF879D64EB28165E /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = "<group>"; };
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>"; }; 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>"; }; D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurrenceTests.swift; sourceTree = "<group>"; };
DCC2AFB4FA765383740767CB /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = "<group>"; }; DCC2AFB4FA765383740767CB /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = "<group>"; };
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>"; }; ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayMenuFeatures.swift; sourceTree = "<group>"; };
FA3D5289C5F93484E22DEB63 /* TutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialView.swift; sourceTree = "<group>"; }; FA3D5289C5F93484E22DEB63 /* TutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialView.swift; sourceTree = "<group>"; };
FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventCountdownWidget.swift; sourceTree = "<group>"; }; FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventCountdownWidget.swift; sourceTree = "<group>"; };
@@ -149,9 +182,14 @@
068B77B2F01C399C7A430292 /* KisaniCalTests */ = { 068B77B2F01C399C7A430292 /* KisaniCalTests */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
713DAF7EA146A779E4AC1D1A /* AnalyticsAdapterTests.swift */,
A0E5E96C6FC5DFC4E76452BF /* AnalyticsCoordinatorTests.swift */,
9DEAB6A9224E02484292362A /* AnalyticsEngineTests.swift */,
3E8855698633CC9A1172AEF1 /* AnalyticsStoreTests.swift */,
74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */, 74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */,
9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */, 9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */,
D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */, D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */,
D230156A72AAFBBAB7626629 /* StreakLogicTests.swift */,
); );
path = KisaniCalTests; path = KisaniCalTests;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -165,6 +203,14 @@
path = Components; path = Components;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
2217B7EEC45957B820311EC7 /* Shared */ = {
isa = PBXGroup;
children = (
3A0B871AB37123908CF0CB20 /* RecurrenceRule.swift */,
);
path = Shared;
sourceTree = "<group>";
};
23CBCF100C5EF55E737379CA /* Models */ = { 23CBCF100C5EF55E737379CA /* Models */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -191,6 +237,7 @@
F70DA4746C68CD405435DAB6 /* KisaniCal */, F70DA4746C68CD405435DAB6 /* KisaniCal */,
068B77B2F01C399C7A430292 /* KisaniCalTests */, 068B77B2F01C399C7A430292 /* KisaniCalTests */,
E8D727EEA0C3A4B8006FB087 /* KisaniCalWidgets */, E8D727EEA0C3A4B8006FB087 /* KisaniCalWidgets */,
2217B7EEC45957B820311EC7 /* Shared */,
48146B56E740528496663D47 /* WenzaWatch */, 48146B56E740528496663D47 /* WenzaWatch */,
51BD1B5DEDE9FAD9CA2FF6DA /* Products */, 51BD1B5DEDE9FAD9CA2FF6DA /* Products */,
); );
@@ -210,7 +257,9 @@
5E9A0E064E153429180400E6 /* Views */ = { 5E9A0E064E153429180400E6 /* Views */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
69F0950E6F31016C848B2A63 /* ActivityHistoryView.swift */,
768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */, 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */,
1C6CEBCB6041EA41ED482F67 /* AnalyticsView.swift */,
4AD014B7E3E30A34E18696A0 /* AuthView.swift */, 4AD014B7E3E30A34E18696A0 /* AuthView.swift */,
ADF6CCD95A587E26E30F5712 /* CalendarView.swift */, ADF6CCD95A587E26E30F5712 /* CalendarView.swift */,
D44530A77DF12A17E52AAF34 /* MatrixView.swift */, D44530A77DF12A17E52AAF34 /* MatrixView.swift */,
@@ -235,6 +284,20 @@
path = "Preview Content"; path = "Preview Content";
sourceTree = "<group>"; 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 */ = { E0A03E79A679740978E61BF1 /* Theme */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -251,6 +314,7 @@
8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */, 8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */,
42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */, 42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */,
208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */, 208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */,
91A354921717711EB3C0EE8E /* StopCountdownIntent.swift */,
0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */, 0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */,
61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */, 61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */,
429806CE1021C8DE2EB770CE /* WidgetViews.swift */, 429806CE1021C8DE2EB770CE /* WidgetViews.swift */,
@@ -267,6 +331,7 @@
1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */, 1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */,
001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */, 001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */,
C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */, C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */,
C7409CC354029293D424BEDA /* Analytics */,
21B93C269F283F11B415B18C /* Components */, 21B93C269F283F11B415B18C /* Components */,
FB9BF734B9E493EEB09ACE21 /* Managers */, FB9BF734B9E493EEB09ACE21 /* Managers */,
23CBCF100C5EF55E737379CA /* Models */, 23CBCF100C5EF55E737379CA /* Models */,
@@ -436,9 +501,14 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( 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 */, 6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */,
16FFC465D8646DED9A5C69D1 /* NLRecurrenceParseTests.swift in Sources */, 16FFC465D8646DED9A5C69D1 /* NLRecurrenceParseTests.swift in Sources */,
4FB79BBC1EE0F6C7EF844B02 /* RecurrenceTests.swift in Sources */, 4FB79BBC1EE0F6C7EF844B02 /* RecurrenceTests.swift in Sources */,
E135F5118E1D006F03AE2178 /* StreakLogicTests.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -454,7 +524,15 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
D1F2189D03CFF743A534A0C6 /* ActivityHistoryView.swift in Sources */,
A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.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 */, 2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */,
3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */, 3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */,
ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */, ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */,
@@ -472,6 +550,7 @@
EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */, EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */,
67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */, 67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */,
5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */, 5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */,
30D155A1314CBD4BB1744B29 /* RecurrenceRule.swift in Sources */,
A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */, A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */,
8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */, 8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */,
497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */, 497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */,
@@ -484,6 +563,7 @@
C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */, C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */,
3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */, 3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */,
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */, 8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */,
C69A21EC244513185A1F59BD /* WorkoutAnalyticsAdapter.swift in Sources */,
9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */, 9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -496,6 +576,8 @@
552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */, 552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */,
C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */, C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */,
3E9DE1CF20BAC479805DF940 /* MyTasksWidget.swift in Sources */, 3E9DE1CF20BAC479805DF940 /* MyTasksWidget.swift in Sources */,
CD3B0C436EA2D013FC3A6B5B /* RecurrenceRule.swift in Sources */,
5D0E254AB5D195AFDE09FABF /* StopCountdownIntent.swift in Sources */,
8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */, 8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */,
205846F9651EDCE0D8207358 /* TaskLiveActivity.swift in Sources */, 205846F9651EDCE0D8207358 /* TaskLiveActivity.swift in Sources */,
AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */, AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */,
@@ -607,7 +689,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 9; CURRENT_PROJECT_VERSION = 10;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = K8BLMMR883; DEVELOPMENT_TEAM = K8BLMMR883;
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
@@ -701,7 +783,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 9; CURRENT_PROJECT_VERSION = 10;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = K8BLMMR883; DEVELOPMENT_TEAM = K8BLMMR883;
ENABLE_STRICT_OBJC_MSGSEND = YES; 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 { struct FabButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
configuration.label configuration.label

View File

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

View File

@@ -61,7 +61,14 @@ enum LiveActivityManager {
Task { Task {
for activity in Activity<TaskActivityAttributes>.activities for activity in Activity<TaskActivityAttributes>.activities
where activity.attributes.taskID == taskID { 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 logWorkoutActId = "LOG_WORKOUT"
private static let missedWorkoutActId = "MISSED_WORKOUT" private static let missedWorkoutActId = "MISSED_WORKOUT"
private static let snoozeWorkoutActId = "SNOOZE_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() { override private init() {
super.init() super.init()
@@ -49,7 +53,15 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
actions: [logAction, missedAction, snoozeAction], actions: [logAction, missedAction, snoozeAction],
intentIdentifiers: [], options: .customDismissAction) 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) // 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.scheduleTasks(snapshot: tasks, urgentIds: urgentIds)
self.scheduleCalendarEvents(snapshot: calEvents) self.scheduleCalendarEvents(snapshot: calEvents)
self.schedulePeriodMilestones() self.schedulePeriodMilestones()
self.scheduleHabitReminders()
self.updateBadge(snapshot: tasks) 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) 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 // MARK: - Recurring task completion confirmation
/// Posts a quiet " Done. Repeats " confirmation when a recurring task's /// 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 deeplink = info["deeplink"] as? String
let taskId = info["taskId"] 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 { switch response.actionIdentifier {
case Self.actionMarkId: case Self.actionMarkId:
@@ -565,6 +775,19 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 3600, repeats: false) 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: default:
if let taskId { markTaskComplete(taskId: taskId, userId: userId) } if let taskId { markTaskComplete(taskId: taskId, userId: userId) }
Task { @MainActor in Task { @MainActor in

View File

@@ -104,4 +104,24 @@ final class TutorialManager: ObservableObject {
else { withAnimation(KisaniSpring.entrance) { workoutStep += 1 } } else { withAnimation(KisaniSpring.entrance) { workoutStep += 1 } }
} }
func skipWorkout() { withAnimation(KisaniSpring.exit) { workoutDone = true } } 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 activeProgramId: UUID
@Published var activeSections: [WorkoutSection] @Published var activeSections: [WorkoutSection]
@Published var workoutDates: [String] = [] @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 missedDates: [String] = [] // days marked "didn't do it"
@Published var compensations: [String: String] = [:] // rest-day "yyyy-MM-dd" programId @Published var compensations: [String: String] = [:] // rest-day "yyyy-MM-dd" programId
@Published var askCompensate: MissedDay? = nil // triggers the compensation sheet @Published var askCompensate: MissedDay? = nil // triggers the compensation sheet
@@ -249,19 +253,69 @@ class WorkoutViewModel: ObservableObject {
@Published var restTimerTotal: Int = 0 @Published var restTimerTotal: Int = 0
@Published var sessionStartDate: Date? = nil @Published var sessionStartDate: Date? = nil
/// Lifetime tally of workout days achieved. Per the user's model this only ever
/// grows a missed day, an off week, even a fully-blank week never reduce it.
/// A 4/5 week still contributes its 4; there is no "break." Not a consecutive
/// streak: it's the sum of everything you've actually done.
var streakDays: Int { var streakDays: Int {
Self.totalAchievedWorkoutDays(workoutDates)
}
/// Count of distinct workout days ever logged (unit-tested in StreakLogicTests).
static func totalAchievedWorkoutDays(_ workoutDates: [String]) -> Int {
Set(workoutDates).count
}
// MARK: - Streak-banner breakdowns (this week / this year / comparison)
/// Workout days in the week `offset` weeks from now (0 = current, -1 = last).
private func workoutCount(inWeekOffset offset: Int) -> Int {
let cal = Calendar.current let cal = Calendar.current
let fmt = iso; let dateSet = Set(workoutDates) guard let ref = cal.date(byAdding: .weekOfYear, value: offset, to: Date()),
var streak = 0 let wk = cal.dateInterval(of: .weekOfYear, for: ref) else { return 0 }
var check = cal.startOfDay(for: Date()) let set = Set(workoutDates)
if !dateSet.contains(fmt.string(from: check)) { return (0..<7).reduce(0) { acc, i in
check = cal.date(byAdding: .day, value: -1, to: check)! guard let d = cal.date(byAdding: .day, value: i, to: wk.start) else { return acc }
return acc + (set.contains(iso.string(from: d)) ? 1 : 0)
} }
while dateSet.contains(fmt.string(from: check)) { }
streak += 1
check = cal.date(byAdding: .day, value: -1, to: check)! var workoutsThisWeek: Int { workoutCount(inWeekOffset: 0) }
var workoutsLastWeek: Int { workoutCount(inWeekOffset: -1) }
var workoutsThisYear: Int {
let cal = Calendar.current
let year = cal.component(.year, from: Date())
return Set(workoutDates).filter {
guard let d = iso.date(from: $0) else { return false }
return cal.component(.year, from: d) == year
}.count
}
/// 7 flags for the current week (calendar week order) true if worked out that day.
var currentWeekDays: [Bool] {
let cal = Calendar.current
guard let wk = cal.dateInterval(of: .weekOfYear, for: Date()) else {
return Array(repeating: false, count: 7)
} }
return streak let set = Set(workoutDates)
return (0..<7).map { i in
guard let d = cal.date(byAdding: .day, value: i, to: wk.start) else { return false }
return set.contains(iso.string(from: d))
}
}
/// 12 flags for the current year true if 1 workout that month.
var currentYearMonths: [Bool] {
let cal = Calendar.current
let year = cal.component(.year, from: Date())
var months = Array(repeating: false, count: 12)
for s in workoutDates {
guard let d = iso.date(from: s), cal.component(.year, from: d) == year else { continue }
let m = cal.component(.month, from: d) - 1
if (0..<12).contains(m) { months[m] = true }
}
return months
} }
private let iso: DateFormatter = { private let iso: DateFormatter = {
@@ -274,6 +328,7 @@ class WorkoutViewModel: ObservableObject {
private let kSchedule: String private let kSchedule: String
private let kActivePid: String private let kActivePid: String
private let kWorkoutDates: String private let kWorkoutDates: String
private let kManualWorkoutDates: String
private let kLastActiveDay: String private let kLastActiveDay: String
private let kWorkoutHistory: String private let kWorkoutHistory: String
private let kMissedDates: String private let kMissedDates: String
@@ -285,6 +340,7 @@ class WorkoutViewModel: ObservableObject {
self.kSchedule = "kisani.workout.schedule.\(uid)" self.kSchedule = "kisani.workout.schedule.\(uid)"
self.kActivePid = "kisani.workout.activePid.\(uid)" self.kActivePid = "kisani.workout.activePid.\(uid)"
self.kWorkoutDates = "kisani.workout.completedDates.\(uid)" self.kWorkoutDates = "kisani.workout.completedDates.\(uid)"
self.kManualWorkoutDates = "kisani.workout.manualDates.\(uid)"
self.kLastActiveDay = "kisani.workout.lastActiveDay.\(uid)" self.kLastActiveDay = "kisani.workout.lastActiveDay.\(uid)"
self.kWorkoutHistory = "kisani.workout.history.\(uid)" self.kWorkoutHistory = "kisani.workout.history.\(uid)"
self.kMissedDates = "kisani.workout.missedDates.\(uid)" self.kMissedDates = "kisani.workout.missedDates.\(uid)"
@@ -296,6 +352,7 @@ class WorkoutViewModel: ObservableObject {
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.schedule.\(uid)") CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.schedule.\(uid)")
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.activePid.\(uid)") CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.activePid.\(uid)")
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.completedDates.\(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.history.\(uid)")
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.missedDates.\(uid)") CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.missedDates.\(uid)")
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.compensations.\(uid)") CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.compensations.\(uid)")
@@ -362,6 +419,7 @@ class WorkoutViewModel: ObservableObject {
activeSections = blank.sections activeSections = blank.sections
} }
workoutDates = UserDefaults.kisani.stringArray(forKey: kWorkoutDates) ?? [] workoutDates = UserDefaults.kisani.stringArray(forKey: kWorkoutDates) ?? []
manualWorkoutDates = UserDefaults.kisani.stringArray(forKey: kManualWorkoutDates) ?? []
missedDates = UserDefaults.kisani.stringArray(forKey: kMissedDates) ?? [] missedDates = UserDefaults.kisani.stringArray(forKey: kMissedDates) ?? []
compensations = (UserDefaults.kisani.dictionary(forKey: kCompensations) as? [String: String]) ?? [:] compensations = (UserDefaults.kisani.dictionary(forKey: kCompensations) as? [String: String]) ?? [:]
if let hData = UserDefaults.kisani.data(forKey: kWorkoutHistory), if let hData = UserDefaults.kisani.data(forKey: kWorkoutHistory),
@@ -380,6 +438,7 @@ class WorkoutViewModel: ObservableObject {
UserDefaults.kisani.set(schedDict, forKey: kSchedule) UserDefaults.kisani.set(schedDict, forKey: kSchedule)
UserDefaults.kisani.set(activeProgramId.uuidString, forKey: kActivePid) UserDefaults.kisani.set(activeProgramId.uuidString, forKey: kActivePid)
UserDefaults.kisani.set(workoutDates, forKey: kWorkoutDates) UserDefaults.kisani.set(workoutDates, forKey: kWorkoutDates)
UserDefaults.kisani.set(manualWorkoutDates, forKey: kManualWorkoutDates)
UserDefaults.kisani.set(missedDates, forKey: kMissedDates) UserDefaults.kisani.set(missedDates, forKey: kMissedDates)
UserDefaults.kisani.set(compensations, forKey: kCompensations) UserDefaults.kisani.set(compensations, forKey: kCompensations)
CloudSyncManager.shared.push(value: missedDates, forKey: kMissedDates) CloudSyncManager.shared.push(value: missedDates, forKey: kMissedDates)
@@ -391,6 +450,7 @@ class WorkoutViewModel: ObservableObject {
CloudSyncManager.shared.push(value: schedDict, forKey: kSchedule) CloudSyncManager.shared.push(value: schedDict, forKey: kSchedule)
CloudSyncManager.shared.push(value: activeProgramId.uuidString, forKey: kActivePid) CloudSyncManager.shared.push(value: activeProgramId.uuidString, forKey: kActivePid)
CloudSyncManager.shared.push(value: workoutDates, forKey: kWorkoutDates) CloudSyncManager.shared.push(value: workoutDates, forKey: kWorkoutDates)
CloudSyncManager.shared.push(value: manualWorkoutDates, forKey: kManualWorkoutDates)
} }
// MARK: - Daily Reset // MARK: - Daily Reset
@@ -513,6 +573,10 @@ class WorkoutViewModel: ObservableObject {
snapshotDay(key) // record today's session in history snapshotDay(key) // record today's session in history
guard !workoutDates.contains(key) else { return } guard !workoutDates.contains(key) else { return }
workoutDates.append(key) 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() save()
let start = sessionStartDate ?? date.addingTimeInterval(-3600) let start = sessionStartDate ?? date.addingTimeInterval(-3600)
Task { await HealthKitManager.shared.saveWorkout(start: start, end: date) } Task { await HealthKitManager.shared.saveWorkout(start: start, end: date) }
@@ -583,6 +647,13 @@ class WorkoutViewModel: ObservableObject {
workoutDates.contains(iso.string(from: date)) 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) // MARK: - Per-date status (Pending / Done / Missed / Compensation)
enum WorkoutDayStatus { case none, pending, done, missed } enum WorkoutDayStatus { case none, pending, done, missed }
@@ -605,6 +676,16 @@ class WorkoutViewModel: ObservableObject {
logWorkoutCompleted(on: date) // adds to workoutDates, snapshots history, saves 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). /// Mark one specific day's workout missed (undoes a done mark for that day only).
func markWorkoutMissed(on date: Date) { func markWorkoutMissed(on date: Date) {
let key = iso.string(from: date) let key = iso.string(from: date)
@@ -613,6 +694,7 @@ class WorkoutViewModel: ObservableObject {
save() save()
} }
// MARK: - Compensation (recover a missed workout on a rest day) // MARK: - Compensation (recover a missed workout on a rest day)
/// Upcoming rest days (no scheduled program, no compensation yet), starting tomorrow. /// Upcoming rest days (no scheduled program, no compensation yet), starting tomorrow.
@@ -753,7 +835,9 @@ class WorkoutViewModel: ObservableObject {
sessionStartDate = Date() sessionStartDate = Date()
} }
syncBack() 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() } if wasMarkedDone && restTimerEnabled { startRestTimer() }
} }
@@ -767,11 +851,13 @@ class WorkoutViewModel: ObservableObject {
} }
if done, sessionStartDate == nil { sessionStartDate = Date() } if done, sessionStartDate == nil { sessionStartDate = Date() }
syncBack() 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 /// Mark every set of the active workout done/undone the "Check/Clear all
/// complete" quick action. Logs completion when everything is checked. /// 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) { func setAllSetsDone(_ done: Bool) {
for si in activeSections.indices { for si in activeSections.indices {
for ei in activeSections[si].exercises.indices { for ei in activeSections[si].exercises.indices {
@@ -785,7 +871,6 @@ class WorkoutViewModel: ObservableObject {
UserDefaults.kisani.set(iso.string(from: Date()), forKey: kLastActiveDay) UserDefaults.kisani.set(iso.string(from: Date()), forKey: kLastActiveDay)
} }
syncBack() syncBack()
if done, doneSets == totalSets && totalSets > 0 { logWorkoutCompleted() }
} }
func addSet(sectionId: UUID, to exerciseId: UUID) { func addSet(sectionId: UUID, to exerciseId: UUID) {

View File

@@ -5,6 +5,10 @@ import WidgetKit
struct TaskItem: Identifiable, Codable, Equatable { struct TaskItem: Identifiable, Codable, Equatable {
var id = UUID() var id = UUID()
var title: String 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 dueDate: Date?
var hasTime: Bool = false var hasTime: Bool = false
var isComplete: Bool = false var isComplete: Bool = false
@@ -14,6 +18,9 @@ struct TaskItem: Identifiable, Codable, Equatable {
var taskColor: TaskColor = .orange var taskColor: TaskColor = .orange
var isRecurring: Bool = false var isRecurring: Bool = false
var recurrenceLabel: String? = nil 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 isPinned: Bool = false
var endDate: Date? = nil var endDate: Date? = nil
var isAllDay: Bool = false var isAllDay: Bool = false
@@ -26,6 +33,15 @@ struct TaskItem: Identifiable, Codable, Equatable {
// Matrix: horizontal manual moves override urgency only. // Matrix: horizontal manual moves override urgency only.
// nil = auto from deadline, true = force urgent, false = force not-urgent. // nil = auto from deadline, true = force urgent, false = force not-urgent.
var urgencyOverride: Bool? = nil 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 { enum Priority: String, Codable, CaseIterable, Identifiable {
@@ -104,6 +120,18 @@ enum TaskCategory: String, Codable {
case domain = "Domain" case domain = "Domain"
case annual = "Annual" case annual = "Annual"
case custom = "Custom" 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 { enum TaskColor: String, Codable {
@@ -253,10 +281,15 @@ class TaskViewModel: ObservableObject {
let cal = Calendar.current let cal = Calendar.current
let start = cal.startOfDay(for: now) let start = cal.startOfDay(for: now)
let end = cal.date(byAdding: .day, value: 1, to: start)! 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 { return activeRows().filter {
guard let d = $0.dueDate else { return false } 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 return d >= start && d < end
} }
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
@@ -280,6 +313,56 @@ class TaskViewModel: ObservableObject {
.sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) } .sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
} }
// MARK: - Completion stats (counts recurring occurrences, not just one-offs)
/// Completions on a given day: one-off tasks by `completedAt`, recurring
/// tasks by their `completedOccurrences` entry for that day.
func completionCount(on day: Date) -> Int {
Self.completionCount(in: tasks, on: day, calendar: Calendar.current)
}
/// Pure counting (unit-tested in StreakLogicTests).
static func completionCount(in tasks: [TaskItem], on day: Date, calendar cal: Calendar) -> Int {
let d0 = cal.startOfDay(for: day)
let next = cal.date(byAdding: .day, value: 1, to: d0)!
let key = occFmt.string(from: d0)
return tasks.reduce(0) { n, t in
let isRecurring = t.isRecurring && (t.recurrenceLabel ?? "None") != "None" && t.dueDate != nil
if isRecurring {
return n + ((t.completedOccurrences?.contains(key) ?? false) ? 1 : 0)
}
if t.isComplete, let at = t.completedAt, at >= d0, at < next { return n + 1 }
return n
}
}
/// Completed tasks on a day, recurring occurrences included (for history views).
func completions(on day: Date) -> [TaskItem] {
let cal = Calendar.current
let d0 = cal.startOfDay(for: day)
let next = cal.date(byAdding: .day, value: 1, to: d0)!
let nonRec = tasks.filter { !recurs($0) && $0.isComplete && (($0.completedAt ?? .distantPast) >= d0) && (($0.completedAt ?? .distantPast) < next) }
let rec = tasks.filter { recurs($0) && occurrenceComplete($0, on: d0) }
.map { occurrenceCopy($0, on: d0) }
return (nonRec + rec).sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
}
/// Consecutive days with 1 completion, walking back from today.
/// A quiet day today doesn't break the streak it just isn't counted yet.
var taskStreakDays: Int {
let cal = Calendar.current
var check = cal.startOfDay(for: Date())
var streak = 0
if completionCount(on: check) == 0 {
check = cal.date(byAdding: .day, value: -1, to: check)!
}
while completionCount(on: check) > 0 {
streak += 1
check = cal.date(byAdding: .day, value: -1, to: check)!
}
return streak
}
func toggle(_ task: TaskItem) { func toggle(_ task: TaskItem) {
// A recurring row carries its occurrence date in dueDate toggle just that day. // A recurring row carries its occurrence date in dueDate toggle just that day.
if recurs(task), let d = task.dueDate { if recurs(task), let d = task.dueDate {
@@ -310,24 +393,13 @@ class TaskViewModel: ObservableObject {
} }
/// Whether a recurring task has an occurrence on `date` aligned to its rule, /// 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 { func isOccurrence(_ t: TaskItem, on date: Date) -> Bool {
guard recurs(t), let start = t.dueDate else { return false } guard recurs(t), let start = t.dueDate else { return false }
let cal = Calendar.current return RecurrenceRule.isOccurrence(label: t.recurrenceLabel, interval: t.recurrenceInterval ?? 1,
let d0 = cal.startOfDay(for: date), s0 = cal.startOfDay(for: start) start: start, date: date, end: t.recurrenceEnd)
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
}
} }
func occurrenceComplete(_ t: TaskItem, on date: Date) -> Bool { func occurrenceComplete(_ t: TaskItem, on date: Date) -> Bool {
@@ -350,6 +422,21 @@ class TaskViewModel: ObservableObject {
} }
} }
/// Complete a recurring task's occurrence AND stop the series distinct
/// from Delete, which erases the task (and every past completion) outright.
/// The task, its title, and its full completedOccurrences history stay
/// intact; only occurrences after `date` stop being generated (via
/// `recurrenceEnd`, which `isOccurrence` already respects).
func completeAndStopSeries(_ t: TaskItem, on date: Date = Date()) {
guard let idx = tasks.firstIndex(where: { $0.id == t.id }) else { return }
let key = occurrenceKey(date)
var set = Set(tasks[idx].completedOccurrences ?? [])
set.insert(key) // ensure done never toggles off
tasks[idx].completedOccurrences = Array(set)
tasks[idx].recurrenceEnd = Calendar.current.startOfDay(for: date)
save()
}
/// A display copy of a recurring task pinned to one occurrence date, with that /// 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). /// occurrence's completion baked into `isComplete` (so existing rows just work).
func occurrenceCopy(_ t: TaskItem, on date: Date) -> TaskItem { func occurrenceCopy(_ t: TaskItem, on date: Date) -> TaskItem {
@@ -546,7 +633,7 @@ class TaskViewModel: ObservableObject {
func addTask(title: String, dueDate: Date?, hasTime: Bool = false, func addTask(title: String, dueDate: Date?, hasTime: Bool = false,
quadrant: Quadrant = .urgent, category: TaskCategory = .reminder, quadrant: Quadrant = .urgent, category: TaskCategory = .reminder,
taskColor: TaskColor = .orange, isRecurring: Bool = false, taskColor: TaskColor = .orange, isRecurring: Bool = false,
recurrenceLabel: String? = nil, recurrenceLabel: String? = nil, recurrenceInterval: Int? = nil,
endDate: Date? = nil, isAllDay: Bool = false, endDate: Date? = nil, isAllDay: Bool = false,
priority: Priority = .none, reminderDate: Date? = nil, priority: Priority = .none, reminderDate: Date? = nil,
constantReminder: Bool = false, recurrenceEnd: Date? = nil) { constantReminder: Bool = false, recurrenceEnd: Date? = nil) {
@@ -559,6 +646,7 @@ class TaskViewModel: ObservableObject {
endDate: endDate, isAllDay: isAllDay, endDate: endDate, isAllDay: isAllDay,
priority: resolvedPriority, reminderDate: reminderDate, priority: resolvedPriority, reminderDate: reminderDate,
constantReminder: constantReminder) constantReminder: constantReminder)
item.recurrenceInterval = recurrenceInterval
item.recurrenceEnd = recurrenceEnd item.recurrenceEnd = recurrenceEnd
item.quadrant = displayQuadrant(item) // color/widget identity follows placement item.quadrant = displayQuadrant(item) // color/widget identity follows placement
tasks.append(item) tasks.append(item)
@@ -568,13 +656,15 @@ class TaskViewModel: ObservableObject {
func updateTask(_ task: TaskItem, title: String, dueDate: Date?, hasTime: Bool, func updateTask(_ task: TaskItem, title: String, dueDate: Date?, hasTime: Bool,
isRecurring: Bool, recurrenceLabel: String?, isRecurring: Bool, recurrenceLabel: String?,
endDate: Date?, isAllDay: Bool, reminderDate: Date?, 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 } guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
tasks[idx].title = title tasks[idx].title = title
tasks[idx].dueDate = dueDate tasks[idx].dueDate = dueDate
tasks[idx].hasTime = hasTime tasks[idx].hasTime = hasTime
tasks[idx].isRecurring = isRecurring tasks[idx].isRecurring = isRecurring
tasks[idx].recurrenceLabel = recurrenceLabel tasks[idx].recurrenceLabel = recurrenceLabel
tasks[idx].recurrenceInterval = recurrenceInterval
tasks[idx].endDate = endDate tasks[idx].endDate = endDate
tasks[idx].isAllDay = isAllDay tasks[idx].isAllDay = isAllDay
tasks[idx].reminderDate = reminderDate tasks[idx].reminderDate = reminderDate

View File

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

View File

@@ -0,0 +1,161 @@
import SwiftUI
// MARK: - Activity History
// Day-by-day archive of everything logged: tasks completed (recurring
// occurrences included), workouts done/missed/compensated. Only days with
// activity are shown quiet days stay quiet.
struct ActivityHistoryView: View {
@EnvironmentObject var taskVM: TaskViewModel
@EnvironmentObject var workoutVM: WorkoutViewModel
@Environment(\.colorScheme) var cs
@Environment(\.dismiss) var dismiss
/// How far back the archive reaches.
private let daysBack = 90
private static let keyFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f
}()
private static let pretty: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "EEE, MMM d"; return f
}()
private static let timeFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "HH:mm"; return f
}()
private struct DayEntry: Identifiable {
let id: String // "yyyy-MM-dd"
let date: Date
let tasks: [TaskItem]
let workout: WorkoutDayLog? // full log if one was saved
let workoutDone: Bool // in workoutDates (streak record)
let missed: Bool // marked "didn't do it"
let compensated: Bool // rest-day makeup scheduled
}
private var entries: [DayEntry] {
let cal = Calendar.current
let today = cal.startOfDay(for: Date())
let logByDate = Dictionary(uniqueKeysWithValues: workoutVM.history.map { ($0.date, $0) })
let doneSet = Set(workoutVM.workoutDates)
let missedSet = Set(workoutVM.missedDates)
return (0..<daysBack).compactMap { ago -> DayEntry? in
guard let day = cal.date(byAdding: .day, value: -ago, to: today) else { return nil }
let key = Self.keyFmt.string(from: day)
let tasks = taskVM.completions(on: day)
let log = logByDate[key]
let done = doneSet.contains(key)
let missed = missedSet.contains(key)
let comp = workoutVM.compensations[key] != nil
guard !tasks.isEmpty || log != nil || done || missed || comp else { return nil }
return DayEntry(id: key, date: day, tasks: tasks, workout: log,
workoutDone: done, missed: missed, compensated: comp)
}
}
private func label(_ date: Date) -> String {
if Calendar.current.isDateInToday(date) { return "Today" }
if Calendar.current.isDateInYesterday(date) { return "Yesterday" }
return Self.pretty.string(from: date)
}
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Activity").font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs))
Spacer()
Button("Done") { dismiss() }
.font(AppFonts.sans(13, weight: .semibold))
.foregroundColor(AppColors.accent).buttonStyle(.plain)
}
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14)
Divider().background(AppColors.border(cs))
if entries.isEmpty {
VStack(spacing: 8) {
Image(systemName: "clock.arrow.circlepath").font(.system(size: 28)).foregroundColor(AppColors.text3(cs))
Text("Nothing here yet").font(AppFonts.sans(14, weight: .semibold)).foregroundColor(AppColors.text2(cs))
Text("Completed tasks and workouts show up here, day by day.")
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)).multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity).padding()
} else {
ScrollView(showsIndicators: false) {
VStack(spacing: 12) {
ForEach(entries) { day in
dayCard(day)
}
}
.padding(.horizontal, 16).padding(.top, 14).padding(.bottom, 30)
}
}
}
.background(AppColors.background(cs).ignoresSafeArea())
}
@ViewBuilder
private func dayCard(_ day: DayEntry) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text(label(day.date))
.font(AppFonts.sans(13, weight: .bold)).foregroundColor(AppColors.text(cs))
Spacer()
if !day.tasks.isEmpty {
Text("\(day.tasks.count) task\(day.tasks.count == 1 ? "" : "s")")
.font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.green)
}
}
// Workout line
if let log = day.workout {
HStack(spacing: 8) {
Image(systemName: "dumbbell.fill").font(.system(size: 10)).foregroundColor(AppColors.green).frame(width: 18)
Text(log.programName).font(AppFonts.sans(12, weight: .semibold)).foregroundColor(AppColors.text2(cs))
Spacer()
Text("\(log.doneSets)/\(log.totalSets) sets\(log.volume > 0 ? " · \(Int(log.volume)) kg" : "")")
.font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
}
} else if day.workoutDone {
HStack(spacing: 8) {
Image(systemName: "dumbbell.fill").font(.system(size: 10)).foregroundColor(AppColors.green).frame(width: 18)
Text("Workout done").font(AppFonts.sans(12)).foregroundColor(AppColors.text2(cs))
Spacer()
}
}
if day.missed {
HStack(spacing: 8) {
Image(systemName: "minus.circle").font(.system(size: 10)).foregroundColor(AppColors.accent).frame(width: 18)
Text("Workout missed\(day.compensated ? " · made up on a rest day" : "")")
.font(AppFonts.sans(12)).foregroundColor(AppColors.text3(cs))
Spacer()
}
} else if day.compensated && !day.workoutDone {
HStack(spacing: 8) {
Image(systemName: "arrow.uturn.forward.circle").font(.system(size: 10)).foregroundColor(AppColors.blue).frame(width: 18)
Text("Rest-day makeup scheduled").font(AppFonts.sans(12)).foregroundColor(AppColors.text3(cs))
Spacer()
}
}
// Task lines
ForEach(day.tasks) { t in
HStack(spacing: 8) {
Image(systemName: "checkmark.square").font(.system(size: 10)).foregroundColor(AppColors.green).frame(width: 18)
Text(t.title).font(AppFonts.sans(12)).foregroundColor(AppColors.text2(cs)).lineLimit(1)
if t.isRecurring {
Image(systemName: "repeat").font(.system(size: 8)).foregroundColor(AppColors.text3(cs))
}
Spacer()
if t.hasTime, let at = t.completedAt {
Text(Self.timeFmt.string(from: at)).font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
}
}
}
}
.padding(14)
.background(AppColors.surface(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
}
}

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

@@ -294,6 +294,10 @@ struct CalendarView: View {
}() }()
@State private var viewMode: CalendarViewMode = .month @State private var viewMode: CalendarViewMode = .month
@State private var calTaskListMode: Bool = false @State private var calTaskListMode: Bool = false
/// Year currently at the top of the continuous year scroll (drives the header in .year mode).
@State private var visibleYear: Int = Calendar.current.component(.year, from: Date())
/// Week view: timeline when false, grid of day-cards when true.
@State private var weekGrid: Bool = false
@State private var showAddTask = false @State private var showAddTask = false
@State private var showCalendarConnect = false @State private var showCalendarConnect = false
@State private var showWorkoutSession = false @State private var showWorkoutSession = false
@@ -333,7 +337,7 @@ struct CalendarView: View {
// Center: month + year // Center: month + year
Spacer() Spacer()
Text(monthTitle) Text(headerTitle)
.font(AppFonts.sans(17, weight: .semibold)) .font(AppFonts.sans(17, weight: .semibold))
.foregroundColor(AppColors.text(cs)) .foregroundColor(AppColors.text(cs))
Spacer() Spacer()
@@ -388,6 +392,9 @@ struct CalendarView: View {
calDayTaskListView 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 } FAB { showAddTask = true }
.padding(.trailing, 20).padding(.bottom, 10) .padding(.trailing, 20).padding(.bottom, 10)
@@ -434,6 +441,12 @@ struct CalendarView: View {
} }
} }
/// In the continuous year view the header follows the scrolled-to year;
/// otherwise it's the month + year of the displayed month.
private var headerTitle: String {
viewMode == .year ? String(visibleYear) : monthTitle
}
private var monthTitle: String { private var monthTitle: String {
let f = DateFormatter(); f.dateFormat = "MMMM yyyy" let f = DateFormatter(); f.dateFormat = "MMMM yyyy"
return f.string(from: displayMonth) return f.string(from: displayMonth)
@@ -602,6 +615,7 @@ struct CalendarView: View {
onPin: { taskVM.pin($0) }, onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 }, onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) }, onDelete: { taskVM.delete($0) },
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onSetDate: { taskVM.setDate($0, date: $1) }, onSetDate: { taskVM.setDate($0, date: $1) },
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
@@ -673,42 +687,77 @@ struct CalendarView: View {
// MARK: - Year // MARK: - Year
private var yearView: some View { // Reports each year block's top offset within the year scroll, so the
let year = cal.component(.year, from: displayMonth) // header can follow whichever year is currently at the top.
let months: [Date] = (1...12).compactMap { cal.date(from: DateComponents(year: year, month: $0, day: 1)) } private struct YearTopKey: PreferenceKey {
return VStack(spacing: 0) { static var defaultValue: [Int: CGFloat] = [:]
// Year header with navigation (unbounded go as far forward/back as you like). static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) {
HStack { value.merge(nextValue()) { _, new in new }
Button { navigateYear(-1) } label: { }
Image(systemName: "chevron.left").font(.system(size: 14, weight: .semibold)) }
.foregroundColor(AppColors.text(cs)).frame(width: 40, height: 40)
}
Spacer()
Text(String(year))
.font(AppFonts.sans(20, weight: .bold)).foregroundColor(AppColors.text(cs))
Spacer()
Button { navigateYear(1) } label: {
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold))
.foregroundColor(AppColors.text(cs)).frame(width: 40, height: 40)
}
}
.padding(.horizontal, 16).padding(.top, 4)
// Continuous, TickTick-style: years flow one after another in an infinite
// scroll. Opens anchored on the current year; scroll up for past years,
// down for future ones no paging, no dead space below December.
private var yearRange: [Int] {
let now = cal.component(.year, from: Date())
return Array((now - 8)...(now + 20))
}
private var yearView: some View {
let currentYear = cal.component(.year, from: Date())
return ScrollViewReader { proxy in
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 24) { LazyVStack(alignment: .leading, spacing: 28) {
ForEach(months, id: \.self) { m in ForEach(yearRange, id: \.self) { yr in
cvMiniMonth(m) yearBlock(yr).id(yr)
} }
} }
.padding(16) .padding(.top, 8)
.padding(.bottom, 96) // clear the floating tab bar / + button
}
.coordinateSpace(name: "yearScroll")
.onPreferenceChange(YearTopKey.self) { tops in
// The header year is the topmost block that has reached the top edge:
// among blocks at/above a small threshold, the one nearest the top.
let threshold: CGFloat = 60
let reached = tops.filter { $0.value <= threshold }
if let y = reached.max(by: { $0.value < $1.value })?.key
?? tops.min(by: { $0.value < $1.value })?.key,
y != visibleYear {
visibleYear = y
}
}
.onAppear {
visibleYear = currentYear
proxy.scrollTo(currentYear, anchor: .top)
} }
} }
} }
private func navigateYear(_ offset: Int) { private func yearBlock(_ year: Int) -> some View {
withAnimation(KisaniSpring.snappy) { let months: [Date] = (1...12).compactMap { cal.date(from: DateComponents(year: year, month: $0, day: 1)) }
displayMonth = cal.date(byAdding: .year, value: offset, to: displayMonth) ?? displayMonth let isCurrent = year == cal.component(.year, from: Date())
return VStack(alignment: .leading, spacing: 14) {
Text(String(year))
.font(AppFonts.sans(26, weight: .bold))
.foregroundColor(isCurrent ? AppColors.accent : AppColors.text(cs))
.padding(.horizontal, 16)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 24) {
ForEach(months, id: \.self) { m in
cvMiniMonth(m)
}
}
.padding(.horizontal, 16)
} }
.background(
GeometryReader { geo in
Color.clear.preference(
key: YearTopKey.self,
value: [year: geo.frame(in: .named("yearScroll")).minY]
)
}
)
} }
private func cvMiniMonth(_ month: Date) -> some View { private func cvMiniMonth(_ month: Date) -> some View {
@@ -745,84 +794,111 @@ struct CalendarView: View {
// MARK: - Week // MARK: - Week
// Week: a 7-day timeline (TickTick-style) hours down the left, one column
// per weekday, events as positioned blocks. Toggle to a grid of day-cards.
private var weekView: some View { private var weekView: some View {
HStack(alignment: .top, spacing: 0) { let days = cvWeekDays()
VStack(spacing: 0) { return VStack(spacing: 0) {
HStack(spacing: 0) { // Layout toggle: timeline grid
ForEach(Array(weekdays.enumerated()), id: \.offset) { _, d in HStack {
Text(d).font(AppFonts.mono(7, weight: .semibold))
.foregroundColor(AppColors.text3(cs)).frame(maxWidth: .infinity)
}
}
.padding(.horizontal, 6).padding(.top, 10)
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 0), count: 7), spacing: 2) {
ForEach(Array(gridDays().enumerated()), id: \.offset) { _, date in
let isTod = cal.isDateInToday(date)
let isSel = cal.isDate(date, inSameDayAs: selectedDate)
let inMon = cal.isDate(date, equalTo: displayMonth, toGranularity: .month)
ZStack {
if isTod { Circle().fill(AppColors.accent).frame(width: 20, height: 20) }
else if isSel { Circle().fill(AppColors.accentSoft).frame(width: 20, height: 20) }
Text("\(cal.component(.day, from: date))")
.font(AppFonts.sans(9, weight: isTod ? .bold : .regular))
.foregroundColor(isTod ? .white : inMon ? AppColors.text(cs) : AppColors.text3(cs))
}
.frame(height: 22)
.onTapGesture { withAnimation(KisaniSpring.micro) { selectedDate = date } }
}
}
.padding(.horizontal, 4)
.gesture(DragGesture(minimumDistance: 40).onEnded { v in
if v.translation.width < -40 { navigateMonth(1) }
else if v.translation.width > 40 { navigateMonth(-1) }
})
Spacer() Spacer()
Button {
withAnimation(KisaniSpring.snappy) { weekGrid.toggle() }
} label: {
Image(systemName: weekGrid ? "calendar.day.timeline.left" : "square.grid.2x2")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(AppColors.text2(cs))
.padding(.horizontal, 10).padding(.vertical, 5)
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
} }
.frame(maxWidth: .infinity) .padding(.horizontal, 14).padding(.top, 6).padding(.bottom, 2)
Rectangle().fill(AppColors.border(cs)).frame(width: 1) if weekGrid {
weekGridLayout(days)
} else {
cvDayColumnHeader(days)
.contentShape(Rectangle())
.gesture(DragGesture(minimumDistance: 40).onEnded { v in
if v.translation.width < -40 { navigateWeek(1) }
else if v.translation.width > 40 { navigateWeek(-1) }
})
Divider().background(AppColors.border(cs))
ScrollView(showsIndicators: false) {
cvTimeGrid(days: days).padding(.bottom, 40)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
ScrollView(showsIndicators: false) { // Grid layout: the 7 days as cards (2 columns), each listing its schedule.
VStack(spacing: 0) { private func weekGridLayout(_ days: [Date]) -> some View {
ForEach(cvWeekDays(), id: \.self) { date in ScrollView(showsIndicators: false) {
let isTod = cal.isDateInToday(date) LazyVGrid(columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)], spacing: 10) {
let isSel = cal.isDate(date, inSameDayAs: selectedDate) ForEach(days, id: \.self) { date in
let items = cvAgendaItems(date) weekDayCard(date)
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 4) {
Text(cvDayFmt.string(from: date))
.font(AppFonts.mono(9, weight: .semibold))
.foregroundColor(isTod ? AppColors.accent : AppColors.text2(cs))
Text("\(cal.component(.day, from: date))")
.font(AppFonts.sans(12, weight: isTod ? .bold : .medium))
.foregroundColor(isTod ? AppColors.accent : AppColors.text(cs))
}
if items.isEmpty {
Text("Free").font(AppFonts.sans(9)).foregroundColor(AppColors.text3(cs))
} else {
ForEach(Array(items.prefix(4).enumerated()), id: \.offset) { _, item in
HStack(spacing: 5) {
RoundedRectangle(cornerRadius: 1.5).fill(item.color)
.frame(width: 2.5, height: 11)
Text(item.title).font(AppFonts.sans(9))
.foregroundColor(AppColors.text2(cs)).lineLimit(1)
}
}
if items.count > 4 {
Text("+\(items.count - 4) more").font(AppFonts.sans(8))
.foregroundColor(AppColors.text3(cs))
}
}
}
.padding(10)
.background(isSel ? AppColors.accentSoft : Color.clear)
.contentShape(Rectangle())
.onTapGesture { withAnimation(KisaniSpring.micro) { selectedDate = date } }
Divider().background(AppColors.border(cs))
}
} }
} }
.frame(maxWidth: .infinity) .padding(16)
}
.gesture(DragGesture(minimumDistance: 40).onEnded { v in
if v.translation.width < -40 { navigateWeek(1) }
else if v.translation.width > 40 { navigateWeek(-1) }
})
}
private func weekDayCard(_ date: Date) -> some View {
let items = (cvAllDayItems(date) + cvTimeItems(date)).sorted { $0.startMin < $1.startMin }
let isTod = cal.isDateInToday(date)
let isSel = cal.isDate(date, inSameDayAs: selectedDate)
return VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 5) {
Text(cvDayFmt.string(from: date))
.font(AppFonts.mono(9, weight: .semibold))
.foregroundColor(isTod ? AppColors.accent : AppColors.text3(cs))
Text("\(cal.component(.day, from: date))")
.font(AppFonts.sans(14, weight: isTod ? .bold : .semibold))
.foregroundColor(isTod ? AppColors.accent : AppColors.text(cs))
Spacer(minLength: 0)
}
if items.isEmpty {
Text("Free").font(AppFonts.sans(9)).foregroundColor(AppColors.text3(cs))
} else {
ForEach(Array(items.prefix(5).enumerated()), id: \.offset) { _, item in
HStack(spacing: 4) {
RoundedRectangle(cornerRadius: 2).fill(item.color).frame(width: 3, height: 12)
Text(item.title).font(AppFonts.sans(9))
.foregroundColor(AppColors.text2(cs)).lineLimit(1)
Spacer(minLength: 0)
}
}
if items.count > 5 {
Text("+\(items.count - 5) more").font(AppFonts.sans(8)).foregroundColor(AppColors.text3(cs))
}
}
}
.padding(10)
.frame(maxWidth: .infinity, minHeight: 92, alignment: .topLeading)
.background(AppColors.surface(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
.overlay(
RoundedRectangle(cornerRadius: AppRadius.large)
.stroke(isSel ? AppColors.accent : AppColors.border(cs), lineWidth: isSel ? 1.5 : 1)
)
.contentShape(Rectangle())
.onTapGesture {
withAnimation(KisaniSpring.micro) { selectedDate = date; viewMode = .day }
}
}
private func navigateWeek(_ offset: Int) {
withAnimation(KisaniSpring.snappy) {
selectedDate = cal.date(byAdding: .day, value: offset * 7, to: selectedDate) ?? selectedDate
displayMonth = cal.date(from: cal.dateComponents([.year, .month], from: selectedDate)) ?? displayMonth
} }
} }
@@ -836,7 +912,9 @@ struct CalendarView: View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
cvTimeGrid(days: days).padding(.bottom, 40) cvTimeGrid(days: days).padding(.bottom, 40)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
} }
// MARK: - Day // MARK: - Day
@@ -902,11 +980,6 @@ struct CalendarView: View {
return items return items
} }
/// Everything happening on a date all-day items first, then timed for compact
/// agenda listings (e.g. the Week column). Recurrence-aware.
private func cvAgendaItems(_ date: Date) -> [CVTimeItem] {
(cvAllDayItems(date) + cvTimeItems(date)).sorted { $0.startMin < $1.startMin }
}
@ViewBuilder @ViewBuilder
private func cvAllDayBand(days: [Date]) -> some View { private func cvAllDayBand(days: [Date]) -> some View {
@@ -970,27 +1043,60 @@ struct CalendarView: View {
ForEach(cvTimeItems(date)) { item in ForEach(cvTimeItems(date)) { item in
let top = max(0, CGFloat(item.startMin - cvStartH * 60) / 60.0 * cvHourH) let top = max(0, CGFloat(item.startMin - cvStartH * 60) / 60.0 * cvHourH)
let dur = max(CGFloat(item.endMin - item.startMin) / 60.0 * cvHourH, 28) let dur = max(CGFloat(item.endMin - item.startMin) / 60.0 * cvHourH, 28)
RoundedRectangle(cornerRadius: 4) CVTimeBlock(
.fill(item.color.opacity(0.28)) item: item, top: top, height: dur, hourH: cvHourH,
.overlay(alignment: .leading) { draggable: item.taskID != nil && !item.recurring,
Rectangle().fill(item.color).frame(width: 3) onReschedule: { newStartMin in
.clipShape(RoundedRectangle(cornerRadius: 2)) rescheduleTask(id: item.taskID, on: date, toStartMin: newStartMin)
} },
.overlay(alignment: .topLeading) { menu: { cvBlockMenu(for: item) }
Text(item.title) )
.font(AppFonts.sans(9, weight: .medium))
.foregroundColor(item.color)
.padding(.horizontal, 6).padding(.top, 4)
.lineLimit(2)
}
.frame(maxWidth: .infinity).frame(height: dur)
.padding(.trailing, 2)
.offset(y: top)
} }
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
/// Long-press menu for a timeline block. Task items get the full shared menu
/// (Complete / Snooze / date / move / edit / delete); events get nothing.
@ViewBuilder
private func cvBlockMenu(for item: CVTimeItem) -> some View {
if let id = item.taskID, let task = taskVM.tasks.first(where: { $0.id == id }) {
Button {
withAnimation(KisaniSpring.snappy) { taskVM.toggle(task) }
} label: {
Label(task.isComplete ? "Mark Incomplete" : "Complete", systemImage: "checkmark.circle")
}
Menu {
Button { taskVM.postpone(task, minutes: 15) } label: { Label("15 minutes", systemImage: "clock") }
Button { taskVM.postpone(task, minutes: 60) } label: { Label("1 hour", systemImage: "clock") }
Button { taskVM.postpone(task, days: 1) } label: { Label("Tomorrow", systemImage: "sun.max") }
} label: {
Label("Snooze", systemImage: "clock.arrow.circlepath")
}
TaskMenuItems(
task: task,
onPin: { taskVM.pin($0) },
onSetDate: { taskVM.setDate($0, date: $1) },
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) },
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }
)
}
}
/// Move a timed task to a new start minute on the same day (drag-to-reschedule).
private func rescheduleTask(id: UUID?, on date: Date, toStartMin newStartMin: Int) {
guard let id, let task = taskVM.tasks.first(where: { $0.id == id }) else { return }
let clamped = min(max(newStartMin, cvStartH * 60), cvEndH * 60 - 15)
let h = clamped / 60, m = clamped % 60
guard let newDate = cal.date(bySettingHour: h, minute: m, second: 0, of: date) else { return }
withAnimation(KisaniSpring.snappy) { taskVM.setDate(task, date: newDate) }
}
// MARK: - Shared helpers // MARK: - Shared helpers
private var cvWeekStrip: some View { private var cvWeekStrip: some View {
@@ -1050,7 +1156,9 @@ struct CalendarView: View {
for task in taskVM.occurrences(on: date) where !task.isComplete && task.hasTime { for task in taskVM.occurrences(on: date) where !task.isComplete && task.hasTime {
guard let d = task.dueDate else { continue } guard let d = task.dueDate else { continue }
let h = cal.component(.hour, from: d), m = cal.component(.minute, from: d) let h = cal.component(.hour, from: d), m = cal.component(.minute, from: d)
items.append(CVTimeItem(title: task.title, color: task.quadrant.color, startMin: h * 60 + m, endMin: h * 60 + m + 30)) items.append(CVTimeItem(title: task.title, color: task.quadrant.color,
startMin: h * 60 + m, endMin: h * 60 + m + 30,
taskID: task.id, recurring: taskVM.recurs(task)))
} }
for ev in calStore.isAuthorized ? calStore.events(for: date) : [] { for ev in calStore.isAuthorized ? calStore.events(for: date) : [] {
guard let s = ev.startDate, let e = ev.endDate, !ev.isAllDay else { continue } guard let s = ev.startDate, let e = ev.endDate, !ev.isAllDay else { continue }
@@ -1073,13 +1181,69 @@ struct CVTimeItem: Identifiable {
let color: Color let color: Color
let startMin: Int let startMin: Int
let endMin: Int let endMin: Int
let taskID: UUID? // non-nil for task-backed items (draggable)
let recurring: Bool // recurring tasks aren't drag-rescheduled
init(title: String, color: Color, startMin: Int, endMin: Int) { init(title: String, color: Color, startMin: Int, endMin: Int,
self.id = "\(title)-\(startMin)" taskID: UUID? = nil, recurring: Bool = false) {
self.title = title self.id = taskID?.uuidString ?? "\(title)-\(startMin)"
self.color = color self.title = title
self.startMin = startMin self.color = color
self.endMin = endMin self.startMin = startMin
self.endMin = endMin
self.taskID = taskID
self.recurring = recurring
}
}
// A single timed block on the week/day timeline. Task-backed blocks can be
// dragged vertically to reschedule (snapped to 15-minute steps); events and
// recurring tasks render fixed.
private struct CVTimeBlock<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 {
RoundedRectangle(cornerRadius: 4)
.fill(item.color.opacity(dragY == 0 ? 0.28 : 0.45))
.overlay(alignment: .leading) {
Rectangle().fill(item.color).frame(width: 3)
.clipShape(RoundedRectangle(cornerRadius: 2))
}
.overlay(alignment: .topLeading) {
Text(item.title)
.font(AppFonts.sans(9, weight: .medium))
.foregroundColor(item.color)
.padding(.horizontal, 6).padding(.top, 4)
.lineLimit(2)
}
.frame(maxWidth: .infinity).frame(height: height)
.padding(.trailing, 2)
.offset(y: top + dragY)
}
var body: some View {
Group {
if draggable {
block.gesture(
DragGesture(minimumDistance: 6)
.updating($dragY) { value, state, _ in state = value.translation.height }
.onEnded { value in
let deltaMin = Int((Double(value.translation.height) / Double(hourH) * 60).rounded() / 15) * 15
onReschedule(item.startMin + deltaMin)
}
)
} else {
block
}
}
.contextMenu { menu() } // empty for events no menu; task actions otherwise
} }
} }
@@ -1645,6 +1809,7 @@ struct DayTimelineView: View {
var onPostpone: ((TaskItem) -> Void)? = nil var onPostpone: ((TaskItem) -> Void)? = nil
var onEdit: ((TaskItem) -> Void)? = nil var onEdit: ((TaskItem) -> Void)? = nil
var onDelete: ((TaskItem) -> Void)? = nil var onDelete: ((TaskItem) -> Void)? = nil
var onCompleteAndStopSeries: ((TaskItem) -> Void)? = nil
var onSetDate: ((TaskItem, Date?) -> Void)? = nil var onSetDate: ((TaskItem, Date?) -> Void)? = nil
var onPostponeMinutes: ((TaskItem, Int) -> Void)? = nil var onPostponeMinutes: ((TaskItem, Int) -> Void)? = nil
var onMove: ((TaskItem, Quadrant) -> Void)? = nil var onMove: ((TaskItem, Quadrant) -> Void)? = nil
@@ -1817,7 +1982,8 @@ struct DayTimelineView: View {
onResetMatrixUrgency: onResetMatrixUrgency, onResetMatrixUrgency: onResetMatrixUrgency,
onLiveActivity:{ LiveActivityManager.toggle(for: $0) }, onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
onEdit: onEdit, onEdit: onEdit,
onDelete: { onDelete?($0) } onDelete: { onDelete?($0) },
onCompleteAndStopSeries: { onCompleteAndStopSeries?($0) }
) )
} }

View File

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

View File

@@ -16,6 +16,11 @@ struct OnboardingView: View {
@AppStorage("heightCm") private var heightCm: Double = 0 @AppStorage("heightCm") private var heightCm: Double = 0
@AppStorage("weightUnit") private var weightUnit: Int = 0 @AppStorage("weightUnit") private var weightUnit: Int = 0
@AppStorage("preferredWorkoutTime") private var preferredWorkoutTime: 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 // Local (uncommitted) state
@State private var localAppearance: Int = 2 // dark by default @State private var localAppearance: Int = 2 // dark by default
@@ -507,6 +512,41 @@ struct OnboardingView: View {
.padding(.horizontal, 20) .padding(.horizontal, 20)
.animation(KisaniSpring.snappy, value: workoutEnabled) .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.") Text("All settings can be changed later in Settings.")
.font(AppFonts.sans(11)) .font(AppFonts.sans(11))
.foregroundColor(AppColors.text3(cs)) .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 // MARK: - Section Header
private struct OnboardingSectionHeader: View { private struct OnboardingSectionHeader: View {

View File

@@ -15,6 +15,7 @@ struct SettingsView: View {
@EnvironmentObject var taskVM: TaskViewModel @EnvironmentObject var taskVM: TaskViewModel
@ObservedObject private var auth = AuthManager.shared @ObservedObject private var auth = AuthManager.shared
@ObservedObject private var calStore = CalendarStore.shared @ObservedObject private var calStore = CalendarStore.shared
@ObservedObject private var tm = TutorialManager.shared
// Persisted settings // Persisted settings
@AppStorage("appearanceMode") private var appearanceMode = 0 @AppStorage("appearanceMode") private var appearanceMode = 0
@@ -33,6 +34,9 @@ struct SettingsView: View {
@AppStorage("streakGoal") private var streakGoal = 5 @AppStorage("streakGoal") private var streakGoal = 5
// App-Group store so NotificationManager (which reads UserDefaults.kisani) sees it. // App-Group store so NotificationManager (which reads UserDefaults.kisani) sees it.
@AppStorage("kisani.periodMilestones", store: UserDefaults.kisani) private var periodMilestones = true @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 // Sheet presentation
@State private var showProfile = false @State private var showProfile = false
@@ -45,6 +49,7 @@ struct SettingsView: View {
@State private var showCalendar = false @State private var showCalendar = false
@State private var showBackup = false @State private var showBackup = false
@State private var showWorkoutSettings = false @State private var showWorkoutSettings = false
@State private var showHabitReminders = false
@State private var showRestTimer = false @State private var showRestTimer = false
@State private var showHelp = false @State private var showHelp = false
@State private var showFollow = 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 restLabel: String { workoutVM.restTimerEnabled ? "\(workoutVM.restTimerSeconds)s" : "Off" }
private var weightLabel: String { weightUnit == 0 ? "kg" : "lbs" } private var weightLabel: String { weightUnit == 0 ? "kg" : "lbs" }
private var dateLabel: String { ["MMM d", "MM/DD", "DD/MM"][dateFormat] } 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 { var body: some View {
ZStack { ZStack {
@@ -137,6 +146,13 @@ struct SettingsView: View {
HealthToggleRow(isLast: true) 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 // ACCOUNT
SttSection(label: "Account", secondary: true) { SttSection(label: "Account", secondary: true) {
if let user = AuthManager.shared.currentUser { if let user = AuthManager.shared.currentUser {
@@ -174,6 +190,21 @@ struct SettingsView: View {
Spacer().frame(height: 40) 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: $showProfile) { ProfileStatsSheet().environmentObject(taskVM).environmentObject(workoutVM).presentationDetents([.large]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showTabBar) { TabBarSheet(showMatrix: $showMatrixTab, showWorkout: $showWorkoutTab).presentationDetents([.height(320)]).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: $showCalendar) { CalendarConnectSheet(calStore: calStore).presentationDetents([.large]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showBackup) { BackupSheet(iCloudSync: $iCloudSync).presentationDetents([.height(280)]).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: $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: $showRestTimer) { RestTimerSheet(seconds: $workoutVM.restTimerSeconds, enabled: $workoutVM.restTimerEnabled).presentationDetents([.height(360)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showHelp) { HelpSheet().presentationDetents([.height(300)]).presentationDragIndicator(.visible) } .sheet(isPresented: $showHelp) { HelpSheet().presentationDetents([.height(300)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showFollow) { FollowSheet().presentationDetents([.height(280)]).presentationDragIndicator(.visible) } .sheet(isPresented: $showFollow) { FollowSheet().presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
@@ -781,7 +813,7 @@ private struct WorkoutSettingsSheet: View {
}.buttonStyle(.plain) }.buttonStyle(.plain)
} }
} }
Text("Reach your goal every week to maintain your streak.") Text("Your weekly workout target. Every workout you log adds to your total — a missed day never takes it away.")
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)) .font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
} }
@@ -880,6 +912,261 @@ private struct WorkoutSettingsSheet: View {
} }
} }
// MARK: - Habit Reminders Sheet
//
// Three independent daily reminders: prayer (up to 4 named times a day),
// bed-making (one nudge research says it's the easiest first win of the
// day), and coffee/caffeine (up to 4 named times plus one custom reminder).
// All keys use the App Group store (`UserDefaults.kisani`) the same store
// NotificationManager reads from so toggling here actually schedules.
private struct HabitRemindersSheet: View {
@Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs
@EnvironmentObject var workoutVM: WorkoutViewModel
@EnvironmentObject var taskVM: TaskViewModel
@AppStorage("bedReminderEnabled", store: UserDefaults.kisani) private var bedReminderEnabled = false
@AppStorage("bedReminderHour", store: UserDefaults.kisani) private var bedReminderHour = 8
@AppStorage("bedReminderMinute", store: UserDefaults.kisani) private var bedReminderMinute = 0
@AppStorage("prayerReminderEnabled", store: UserDefaults.kisani) private var prayerReminderEnabled = false
@AppStorage("coffeeReminderEnabled", store: UserDefaults.kisani) private var coffeeReminderEnabled = false
@AppStorage("coffeeCustomEnabled", store: UserDefaults.kisani) private var coffeeCustomEnabled = false
@AppStorage("coffeeCustomLabel", store: UserDefaults.kisani) private var coffeeCustomLabel = ""
@AppStorage("coffeeCustomHour", store: UserDefaults.kisani) private var coffeeCustomHour = 16
@AppStorage("coffeeCustomMinute", store: UserDefaults.kisani) private var coffeeCustomMinute = 30
private let prayerSlots = ["Morning", "Afternoon", "Evening", "Night"]
private let coffeeSlots = ["Morning", "Midday", "Afternoon", "Evening"]
var body: some View {
VStack(spacing: 0) {
SheetHeader(title: "Habit Reminders") {
dismiss()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
}
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 20) {
// Prayer
VStack(alignment: .leading, spacing: 8) {
Text("PRAYER").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
VStack(spacing: 0) {
HStack(spacing: 11) {
Image(systemName: "hands.sparkles.fill")
.font(.system(size: 13)).foregroundColor(AppColors.yellow)
.frame(width: 28, height: 28).background(AppColors.yellowSoft).cornerRadius(8)
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 6) {
Text("Prayer reminders").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
habitBadge("prayer")
}
Text("\"Have you had a chance to pray and give thanks?\"")
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
}
Spacer()
Toggle("", isOn: $prayerReminderEnabled).labelsHidden().tint(AppColors.accent)
}
.padding(.horizontal, 14).padding(.vertical, 12)
if prayerReminderEnabled {
ForEach(prayerSlots, id: \.self) { slot in
HabitSlotRow(keyPrefix: "prayer\(slot)", label: slot,
defaultHour: Self.defaultHour(for: slot, prayer: true),
defaultMinute: 0, defaultEnabled: true)
}
}
}
.cardStyle()
.animation(KisaniSpring.snappy, value: prayerReminderEnabled)
}
// Bed-making
VStack(alignment: .leading, spacing: 8) {
Text("MORNING").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
VStack(spacing: 0) {
HStack(spacing: 11) {
Image(systemName: "bed.double.fill")
.font(.system(size: 13)).foregroundColor(AppColors.blue)
.frame(width: 28, height: 28).background(AppColors.blueSoft).cornerRadius(8)
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 6) {
Text("Make your bed").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
habitBadge("bed")
}
Text("The simplest task that starts the day with a win.")
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
}
Spacer()
Toggle("", isOn: $bedReminderEnabled).labelsHidden().tint(AppColors.accent)
}
.padding(.horizontal, 14).padding(.vertical, 12)
if bedReminderEnabled {
Divider().background(AppColors.border(cs)).padding(.leading, 53)
HabitTimeRow(label: "Remind me at", hour: $bedReminderHour, minute: $bedReminderMinute)
}
}
.cardStyle()
.animation(KisaniSpring.snappy, value: bedReminderEnabled)
}
// Coffee / Caffeine
VStack(alignment: .leading, spacing: 8) {
Text("CAFFEINE").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
VStack(spacing: 0) {
HStack(spacing: 11) {
Image(systemName: "cup.and.saucer.fill")
.font(.system(size: 13)).foregroundColor(.white)
.frame(width: 28, height: 28).background(AppColors.coffeeBrown).cornerRadius(8)
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 6) {
Text("Coffee reminders").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
habitBadge("coffee")
}
Text("Track when you usually reach for a cup.")
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
}
Spacer()
Toggle("", isOn: $coffeeReminderEnabled).labelsHidden().tint(AppColors.accent)
}
.padding(.horizontal, 14).padding(.vertical, 12)
if coffeeReminderEnabled {
ForEach(coffeeSlots, id: \.self) { slot in
HabitSlotRow(keyPrefix: "coffee\(slot)", label: slot,
defaultHour: Self.defaultHour(for: slot, prayer: false),
defaultMinute: 0, defaultEnabled: slot == "Morning")
}
Divider().background(AppColors.border(cs)).padding(.leading, 14)
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 4) {
Image(systemName: "pencil").font(.system(size: 9))
Text("YOUR OWN REMINDER").font(AppFonts.mono(8, weight: .bold))
}
.foregroundColor(AppColors.text3(cs))
HStack(spacing: 11) {
TextField("Name it, e.g. \"Second coffee\"", text: $coffeeCustomLabel)
.font(AppFonts.sans(12.5)).foregroundColor(AppColors.text(cs))
Spacer()
if coffeeCustomEnabled {
DatePicker("", selection: Binding(
get: { Calendar.current.date(bySettingHour: coffeeCustomHour, minute: coffeeCustomMinute, second: 0, of: Date()) ?? Date() },
set: { v in
let c = Calendar.current.dateComponents([.hour, .minute], from: v)
coffeeCustomHour = c.hour ?? coffeeCustomHour
coffeeCustomMinute = c.minute ?? coffeeCustomMinute
}
), displayedComponents: .hourAndMinute)
.labelsHidden().tint(AppColors.accent)
}
Toggle("", isOn: $coffeeCustomEnabled).labelsHidden().tint(AppColors.accent).scaleEffect(0.85)
}
}
.padding(.horizontal, 14).padding(.vertical, 9)
}
}
.cardStyle()
.animation(KisaniSpring.snappy, value: coffeeReminderEnabled)
}
Text("All reminders are local to this device and never share your responses — they're just gentle nudges.")
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
}
.background(AppColors.background(cs).ignoresSafeArea())
}
private static func defaultHour(for slot: String, prayer: Bool) -> Int {
let table: [String: Int] = prayer
? ["Morning": 7, "Afternoon": 13, "Evening": 18, "Night": 21]
: ["Morning": 8, "Midday": 12, "Afternoon": 15, "Evening": 18]
return table[slot] ?? 9
}
/// A quiet "X days" badge from tapping "Done" on a habit's notifications
/// a private lifetime tally, never shown in Today/Matrix/Activity History.
@ViewBuilder
private func habitBadge(_ type: String) -> some View {
let count = NotificationManager.shared.habitDoneCount(type: type)
if count > 0 {
AppBadge(text: "\(count)d", bg: AppColors.greenSoft, fg: AppColors.green)
}
}
}
/// One named, independently-toggleable daily time slot (e.g. "Morning") used
/// for both Prayer and Coffee's dense multi-slot lists. Reads/writes
/// `UserDefaults.kisani` directly under `<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 { private struct StepperRow: View {
@Environment(\.colorScheme) private var cs @Environment(\.colorScheme) private var cs
let label: String; @Binding var value: Int; let range: ClosedRange<Int>; var isLast: Bool = false let label: String; @Binding var value: Int; let range: ClosedRange<Int>; var isLast: Bool = false
@@ -1053,6 +1340,7 @@ private struct ProfileStatsSheet: View {
@AppStorage("streakGoal") private var streakGoal = 5 @AppStorage("streakGoal") private var streakGoal = 5
@ObservedObject private var hk = HealthKitManager.shared @ObservedObject private var hk = HealthKitManager.shared
@ObservedObject private var auth = AuthManager.shared @ObservedObject private var auth = AuthManager.shared
@State private var showActivityHistory = false
// Task metrics // Task metrics
private var totalTasks: Int { taskVM.tasks.count } private var totalTasks: Int { taskVM.tasks.count }
@@ -1063,24 +1351,19 @@ private struct ProfileStatsSheet: View {
return Int(Double(doneTasks) / Double(totalTasks) * 100) return Int(Double(doneTasks) / Double(totalTasks) * 100)
} }
private var completedThisWeek: Int { private var completedThisWeek: Int {
let start = Calendar.current.date(byAdding: .day, value: -6, to: Calendar.current.startOfDay(for: Date()))! // Sum of per-day counts includes recurring occurrences.
return taskVM.tasks.filter { $0.isComplete && ($0.completedAt ?? .distantPast) >= start }.count last7.reduce(0) { $0 + $1.1 }
} }
private var urgentOpen: Int { private var urgentOpen: Int {
taskVM.tasks.filter { !$0.isComplete && taskVM.displayQuadrant($0) == .urgent }.count taskVM.tasks.filter { !$0.isComplete && taskVM.displayQuadrant($0) == .urgent }.count
} }
// 7-day task activity // 7-day task activity (recurring occurrences included)
private var last7: [(Date, Int)] { private var last7: [(Date, Int)] {
let cal = Calendar.current let cal = Calendar.current
return (0..<7).reversed().map { ago -> (Date, Int) in return (0..<7).reversed().map { ago -> (Date, Int) in
let day = cal.date(byAdding: .day, value: -ago, to: cal.startOfDay(for: Date()))! let day = cal.date(byAdding: .day, value: -ago, to: cal.startOfDay(for: Date()))!
let next = cal.date(byAdding: .day, value: 1, to: day)! return (day, taskVM.completionCount(on: day))
let n = taskVM.tasks.filter { t in
guard t.isComplete, let at = t.completedAt else { return false }
return at >= day && at < next
}.count
return (day, n)
} }
} }
private var maxDay: Int { max(1, last7.map { $0.1 }.max() ?? 1) } private var maxDay: Int { max(1, last7.map { $0.1 }.max() ?? 1) }
@@ -1121,9 +1404,19 @@ private struct ProfileStatsSheet: View {
// Tasks card // Tasks card
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("TASKS").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs)) HStack {
Text("TASKS").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
Spacer()
Button { showActivityHistory = true } label: {
Text("History")
.font(AppFonts.mono(9, weight: .bold))
.foregroundColor(AppColors.text2(cs))
}
.buttonStyle(.plain)
}
HStack(spacing: 8) { HStack(spacing: 8) {
PStatCell(value: "\(taskVM.taskStreakDays)", label: "day streak", color: taskVM.taskStreakDays > 0 ? AppColors.green : AppColors.text3(cs))
PStatCell(value: "\(completedThisWeek)", label: "this week", color: AppColors.green) PStatCell(value: "\(completedThisWeek)", label: "this week", color: AppColors.green)
PStatCell(value: "\(overdueCount)", label: "overdue", color: overdueCount > 0 ? AppColors.accent : AppColors.text3(cs)) PStatCell(value: "\(overdueCount)", label: "overdue", color: overdueCount > 0 ? AppColors.accent : AppColors.text3(cs))
PStatCell(value: "\(completionRate)%", label: "done rate", color: AppColors.blue) PStatCell(value: "\(completionRate)%", label: "done rate", color: AppColors.blue)
@@ -1296,6 +1589,11 @@ private struct ProfileStatsSheet: View {
} }
} }
.background(AppColors.background(cs).ignoresSafeArea()) .background(AppColors.background(cs).ignoresSafeArea())
.sheet(isPresented: $showActivityHistory) {
ActivityHistoryView()
.environmentObject(taskVM)
.environmentObject(workoutVM)
}
} }
private func dayLetter(_ date: Date) -> String { private func dayLetter(_ date: Date) -> String {

View File

@@ -40,6 +40,9 @@ struct TaskMenuItems: View {
var onLiveActivity:(TaskItem) -> Void = { LiveActivityManager.toggle(for: $0) } var onLiveActivity:(TaskItem) -> Void = { LiveActivityManager.toggle(for: $0) }
var onEdit: ((TaskItem) -> Void)? = nil var onEdit: ((TaskItem) -> Void)? = nil
var onDelete: (TaskItem) -> Void = { _ in } 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 } private var cal: Calendar { .current }
@@ -132,6 +135,12 @@ struct TaskMenuItems: View {
Button { onEdit(task) } label: { Label("Edit", systemImage: "pencil") } 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() Divider()
Button(role: .destructive) { onDelete(task) } label: { Button(role: .destructive) { onDelete(task) } label: {

View File

@@ -46,6 +46,7 @@ struct TodayView: View {
onPin: { taskVM.pin($0) }, onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 }, onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) }, onDelete: { taskVM.delete($0) },
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onSetDate: { taskVM.setDate($0, date: $1) }, onSetDate: { taskVM.setDate($0, date: $1) },
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
@@ -159,6 +160,7 @@ struct TodayView: View {
onPin: { taskVM.pin($0) }, onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 }, onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) }, onDelete: { taskVM.delete($0) },
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onSetDate: { taskVM.setDate($0, date: $1) }, onSetDate: { taskVM.setDate($0, date: $1) },
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
@@ -180,6 +182,7 @@ struct TodayView: View {
onPin: { taskVM.pin($0) }, onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 }, onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) }, onDelete: { taskVM.delete($0) },
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onSetDate: { taskVM.setDate($0, date: $1) }, onSetDate: { taskVM.setDate($0, date: $1) },
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
@@ -199,6 +202,7 @@ struct TodayView: View {
onPin: { taskVM.pin($0) }, onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 }, onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) }, onDelete: { taskVM.delete($0) },
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onSetDate: { taskVM.setDate($0, date: $1) }, onSetDate: { taskVM.setDate($0, date: $1) },
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
@@ -218,6 +222,7 @@ struct TodayView: View {
onPin: { taskVM.pin($0) }, onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 }, onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) }, onDelete: { taskVM.delete($0) },
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
onSetDate: { taskVM.setDate($0, date: $1) }, onSetDate: { taskVM.setDate($0, date: $1) },
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
@@ -559,11 +564,25 @@ private struct TodayTLRow: View {
} }
} }
Spacer(minLength: 0) Spacer(minLength: 0)
TagChip( // TickTick-style icon only, no text badge; date/time already
text: entry.task.category.rawValue, // shown in the leading time-track column for this row.
color: entry.color, HStack(spacing: 5) {
bg: entry.task.quadrant.softColor 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) { Button(action: onToggle) {
ZStack { ZStack {
RoundedRectangle(cornerRadius: 5, style: .continuous) RoundedRectangle(cornerRadius: 5, style: .continuous)
@@ -601,6 +620,7 @@ struct OverdueCard: View {
var onPin: (TaskItem) -> Void = { _ in } var onPin: (TaskItem) -> Void = { _ in }
var onEdit: (TaskItem) -> Void = { _ in } var onEdit: (TaskItem) -> Void = { _ in }
var onDelete: (TaskItem) -> Void = { _ in } var onDelete: (TaskItem) -> Void = { _ in }
var onCompleteAndStopSeries: (TaskItem) -> Void = { _ in }
var onSetDate: (TaskItem, Date?) -> Void = { _, _ in } var onSetDate: (TaskItem, Date?) -> Void = { _, _ in }
var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in } var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in }
var onMove: (TaskItem, Quadrant) -> Void = { _, _ in } var onMove: (TaskItem, Quadrant) -> Void = { _, _ in }
@@ -712,7 +732,8 @@ struct OverdueCard: View {
onResetMatrixUrgency: onResetMatrixUrgency, onResetMatrixUrgency: onResetMatrixUrgency,
onLiveActivity: { LiveActivityManager.toggle(for: $0) }, onLiveActivity: { LiveActivityManager.toggle(for: $0) },
onEdit: onEdit, onEdit: onEdit,
onDelete: onDelete onDelete: onDelete,
onCompleteAndStopSeries: onCompleteAndStopSeries
) )
} }
} }
@@ -1018,6 +1039,7 @@ struct UpcomingSection: View {
var onPin: (TaskItem) -> Void = { _ in } var onPin: (TaskItem) -> Void = { _ in }
var onEdit: (TaskItem) -> Void = { _ in } var onEdit: (TaskItem) -> Void = { _ in }
let onDelete: (TaskItem) -> Void let onDelete: (TaskItem) -> Void
var onCompleteAndStopSeries: (TaskItem) -> Void = { _ in }
var onSetDate: (TaskItem, Date?) -> Void = { _, _ in } var onSetDate: (TaskItem, Date?) -> Void = { _, _ in }
var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in } var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in }
var onMove: (TaskItem, Quadrant) -> Void = { _, _ in } var onMove: (TaskItem, Quadrant) -> Void = { _, _ in }
@@ -1091,7 +1113,8 @@ struct UpcomingSection: View {
onResetMatrixUrgency: onResetMatrixUrgency, onResetMatrixUrgency: onResetMatrixUrgency,
onLiveActivity: { LiveActivityManager.toggle(for: $0) }, onLiveActivity: { LiveActivityManager.toggle(for: $0) },
onEdit: onEdit, 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) .padding(.horizontal, 18)
@@ -1316,7 +1339,10 @@ struct TaskRowView: View {
} }
} label: { } label: {
RoundedRectangle(cornerRadius: 5, style: .continuous) RoundedRectangle(cornerRadius: 5, style: .continuous)
.strokeBorder(task.quadrant.color, lineWidth: 1.5) // Neutral stroke the row already carries its quadrant color
// on the left accent bar and the category chip, so a tinted
// checkbox is redundant.
.strokeBorder(AppColors.text3(cs), lineWidth: 1.5)
.frame(width: 16, height: 16) .frame(width: 16, height: 16)
.frame(width: 36, height: 44) .frame(width: 36, height: 44)
.padding(.leading, 4) .padding(.leading, 4)
@@ -1324,46 +1350,48 @@ struct TaskRowView: View {
} }
.buttonStyle(PressButtonStyle(scale: 0.82)) .buttonStyle(PressButtonStyle(scale: 0.82))
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 3) {
Text(task.title) Text(task.title)
.font(AppFonts.sans(13)) .font(AppFonts.sans(13, weight: .medium))
.foregroundColor(AppColors.text(cs)) .foregroundColor(AppColors.text(cs))
if let d = task.dueDate, showDetails { if let d = task.dueDate, showDetails {
HStack(spacing: 4) { Text("\(dueFmt.string(from: d))\(task.isRecurring ? " · \(task.recurrenceDisplayLabel ?? "Repeat")" : "")")
Text("\(dueFmt.string(from: d))\(task.isRecurring ? " · \(task.recurrenceLabel ?? "Repeat")" : "")") .font(AppFonts.mono(9.5))
.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)) .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) .padding(.trailing, 9)
} }
.frame(minHeight: 48) .frame(minHeight: 48)
@@ -1487,6 +1515,7 @@ struct NLParsed {
var tokenRanges: [NSRange] = [] var tokenRanges: [NSRange] = []
var isRecurring: Bool = false var isRecurring: Bool = false
var recurrenceLabel: String? = nil var recurrenceLabel: String? = nil
var recurrenceInterval: Int? = nil
var recurrenceEnd: Date? = nil var recurrenceEnd: Date? = nil
var endDate: Date? = nil var endDate: Date? = nil
var isAllDay: Bool = false var isAllDay: Bool = false
@@ -1536,9 +1565,10 @@ struct NLTaskParser {
} }
// Recurrence // Recurrence
if let (r, label) = parseRecurrence(in: lower) { if let (r, label, interval) = parseRecurrence(in: lower) {
result.isRecurring = true result.isRecurring = true
result.recurrenceLabel = label result.recurrenceLabel = label
result.recurrenceInterval = interval > 1 ? interval : nil
rawRanges.append(r) rawRanges.append(r)
} }
@@ -1663,7 +1693,11 @@ struct NLTaskParser {
// MARK: - Recurrence // 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 // Order matters: more specific phrases ("every weekday") must precede the
// broader ones ("every week" / "every day") so they win. // broader ones ("every week" / "every day") so they win.
let pats: [(String, String)] = [ let pats: [(String, String)] = [
@@ -1683,7 +1717,36 @@ struct NLTaskParser {
("\\bdaily\\b", "Daily"), ("\\bdaily\\b", "Daily"),
] ]
for (pat, label) in pats { 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 return nil
} }
@@ -1927,7 +1990,9 @@ struct AddTaskSheet: View {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "arrow.clockwise") Image(systemName: "arrow.clockwise")
.font(.system(size: 11, weight: .semibold)) .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)) .font(AppFonts.sans(13, weight: .semibold))
} }
.foregroundColor(AppColors.accent) .foregroundColor(AppColors.accent)
@@ -2043,7 +2108,8 @@ struct AddTaskSheet: View {
isAllDay: $parsed.isAllDay, isAllDay: $parsed.isAllDay,
reminderDate: $reminderDate, reminderDate: $reminderDate,
constantReminder: $constantReminder, constantReminder: $constantReminder,
recurrenceEnd: $parsed.recurrenceEnd recurrenceEnd: $parsed.recurrenceEnd,
recurrenceInterval: $parsed.recurrenceInterval
) )
} }
} }
@@ -2054,6 +2120,7 @@ struct AddTaskSheet: View {
taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime, taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime,
quadrant: prefilledQuadrant, category: selectedCategory, quadrant: prefilledQuadrant, category: selectedCategory,
isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel, isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel,
recurrenceInterval: parsed.recurrenceInterval,
endDate: parsed.endDate, isAllDay: parsed.isAllDay, endDate: parsed.endDate, isAllDay: parsed.isAllDay,
priority: selectedPriority, priority: selectedPriority,
reminderDate: reminderDate, constantReminder: constantReminder, reminderDate: reminderDate, constantReminder: constantReminder,
@@ -2071,6 +2138,7 @@ struct TaskDatePickerSheet: View {
@Binding var isRecurring: Bool @Binding var isRecurring: Bool
@Binding var recurrenceLabel: String? @Binding var recurrenceLabel: String?
@Binding var recurrenceEnd: Date? @Binding var recurrenceEnd: Date?
@Binding var recurrenceInterval: Int?
@Binding var endDate: Date? @Binding var endDate: Date?
@Binding var isAllDay: Bool @Binding var isAllDay: Bool
@Binding var reminderDate: Date? @Binding var reminderDate: Date?
@@ -2092,6 +2160,7 @@ struct TaskDatePickerSheet: View {
@State private var customNumber: Int @State private var customNumber: Int
@State private var customUnit: Int // 0 = minutes, 1 = hours, 2 = days @State private var customUnit: Int // 0 = minutes, 1 = hours, 2 = days
@State private var localRepeat: String @State private var localRepeat: String
@State private var localRecurrenceInterval: Int
@State private var localRecurrenceEnd: Date? @State private var localRecurrenceEnd: Date?
@State private var showRecurrenceEndPicker = false @State private var showRecurrenceEndPicker = false
@State private var localIsAllDay: Bool @State private var localIsAllDay: Bool
@@ -2102,12 +2171,14 @@ struct TaskDatePickerSheet: View {
endDate: Binding<Date?>, isAllDay: Binding<Bool>, endDate: Binding<Date?>, isAllDay: Binding<Bool>,
reminderDate: Binding<Date?> = .constant(nil), reminderDate: Binding<Date?> = .constant(nil),
constantReminder: Binding<Bool> = .constant(false), constantReminder: Binding<Bool> = .constant(false),
recurrenceEnd: Binding<Date?> = .constant(nil)) { recurrenceEnd: Binding<Date?> = .constant(nil),
recurrenceInterval: Binding<Int?> = .constant(nil)) {
_selectedDate = selectedDate _selectedDate = selectedDate
_hasTime = hasTime _hasTime = hasTime
_isRecurring = isRecurring _isRecurring = isRecurring
_recurrenceLabel = recurrenceLabel _recurrenceLabel = recurrenceLabel
_recurrenceEnd = recurrenceEnd _recurrenceEnd = recurrenceEnd
_recurrenceInterval = recurrenceInterval
_endDate = endDate _endDate = endDate
_isAllDay = isAllDay _isAllDay = isAllDay
_reminderDate = reminderDate _reminderDate = reminderDate
@@ -2118,6 +2189,7 @@ struct TaskDatePickerSheet: View {
_localEndDate = State(initialValue: endDate.wrappedValue ?? start) _localEndDate = State(initialValue: endDate.wrappedValue ?? start)
_localTime = State(initialValue: hasTime.wrappedValue ? selectedDate.wrappedValue : nil) _localTime = State(initialValue: hasTime.wrappedValue ? selectedDate.wrappedValue : nil)
_localRepeat = State(initialValue: recurrenceLabel.wrappedValue ?? "None") _localRepeat = State(initialValue: recurrenceLabel.wrappedValue ?? "None")
_localRecurrenceInterval = State(initialValue: recurrenceInterval.wrappedValue ?? 1)
_localRecurrenceEnd = State(initialValue: recurrenceEnd.wrappedValue) _localRecurrenceEnd = State(initialValue: recurrenceEnd.wrappedValue)
_localIsAllDay = State(initialValue: isAllDay.wrappedValue) _localIsAllDay = State(initialValue: isAllDay.wrappedValue)
_pickerMode = State(initialValue: endDate.wrappedValue != nil ? 1 : 0) _pickerMode = State(initialValue: endDate.wrappedValue != nil ? 1 : 0)
@@ -2264,6 +2336,13 @@ struct TaskDatePickerSheet: View {
} }
.buttonStyle(.plain) .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) // Repeat until (only when a recurrence is set)
if localRepeat != "None" { if localRepeat != "None" {
Divider().padding(.leading, 54) Divider().padding(.leading, 54)
@@ -2627,6 +2706,37 @@ struct TaskDatePickerSheet: View {
.padding(.horizontal, 16).frame(height: 52) .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 struct RepeatOption { let key: String; let display: String }
private var repeatOptions: [RepeatOption] { private var repeatOptions: [RepeatOption] {
@@ -2654,7 +2764,12 @@ struct TaskDatePickerSheet: View {
} }
private var repeatDisplayValue: String { 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 { private var timeFmt: DateFormatter {
@@ -2698,6 +2813,11 @@ struct TaskDatePickerSheet: View {
isRecurring = localRepeat != "None" isRecurring = localRepeat != "None"
recurrenceLabel = localRepeat != "None" ? localRepeat : nil recurrenceLabel = localRepeat != "None" ? localRepeat : nil
recurrenceEnd = localRepeat != "None" ? localRecurrenceEnd : 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. // Reminder is a lead-time offset from the task date at the chosen time-of-day.
if let off = localReminderOffset { if let off = localReminderOffset {
reminderDate = computedReminderDate(offset: off) reminderDate = computedReminderDate(offset: off)

View File

@@ -64,8 +64,10 @@ struct WorkoutView: View {
@State private var showAddSection = false @State private var showAddSection = false
@State private var showHistory = false @State private var showHistory = false
@State private var showStats = false @State private var showStats = false
@State private var showAnalytics = false
@State private var isReordering = false @State private var isReordering = false
@State private var trainAnyway = false // override the rest-day state for today @State private var trainAnyway = false // override the rest-day state for today
@State private var showClearAllConfirm = false
@ObservedObject private var tm = TutorialManager.shared @ObservedObject private var tm = TutorialManager.shared
private let restTick = Timer.publish(every: 1, on: .main, in: .common).autoconnect() private let restTick = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
@@ -103,13 +105,11 @@ struct WorkoutView: View {
.foregroundColor(AppColors.text3(cs)) .foregroundColor(AppColors.text3(cs))
} }
Spacer() Spacer()
IButton(icon: "chart.line.uptrend.xyaxis") { showAnalytics = true }
IButton(icon: "chart.bar.xaxis") { showStats = true } IButton(icon: "chart.bar.xaxis") { showStats = true }
IButton(icon: "clock.arrow.circlepath") { showHistory = true } IButton(icon: "clock.arrow.circlepath") { showHistory = true }
Menu { Menu {
Button { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(true) } } label: { Button(role: .destructive) { showClearAllConfirm = true } label: {
Label("Mark All Complete", systemImage: "checkmark.circle")
}
Button { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(false) } } label: {
Label("Clear All Sets", systemImage: "circle") Label("Clear All Sets", systemImage: "circle")
} }
Divider() Divider()
@@ -138,15 +138,25 @@ struct WorkoutView: View {
.moveDisabled(true) .moveDisabled(true)
} else { } else {
// Dot Grid Progress // Dot Grid Progress + Completion
DotProgressCard(progress: vm.progress, done: vm.doneSets, total: vm.totalSets) 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) .listRowBackground(Color.clear)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 14, bottom: 6, trailing: 14)) .listRowInsets(EdgeInsets(top: 0, leading: 14, bottom: 6, trailing: 14))
.moveDisabled(true) .moveDisabled(true)
// Streak // Streak
StreakBannerView(streak: vm.streakDays) StreakBannerView(vm: vm)
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 14, bottom: 10, trailing: 14)) .listRowInsets(EdgeInsets(top: 0, leading: 14, bottom: 10, trailing: 14))
@@ -324,11 +334,28 @@ struct WorkoutView: View {
.presentationDetents([.large]) .presentationDetents([.large])
.presentationDragIndicator(.visible) .presentationDragIndicator(.visible)
} }
.sheet(isPresented: $showAnalytics) {
AnalyticsView()
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showStats) { .sheet(isPresented: $showStats) {
WorkoutStatsView().environmentObject(vm) WorkoutStatsView().environmentObject(vm)
.presentationDetents([.large]) .presentationDetents([.large])
.presentationDragIndicator(.visible) .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) { .sheet(isPresented: $showAddSection) {
AddSectionSheet().environmentObject(vm) AddSectionSheet().environmentObject(vm)
.presentationDetents([.height(240)]) .presentationDetents([.height(240)])
@@ -564,34 +591,223 @@ struct AddSectionSheet: View {
struct DotProgressCard: View { struct DotProgressCard: View {
@Environment(\.colorScheme) private var cs @Environment(\.colorScheme) private var cs
let progress: Double; let done: Int; let total: Int 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 { var body: some View {
VStack(spacing: 10) { VStack(spacing: 12) {
HStack { HStack {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("\(done) of \(total) sets done") Text(statusTitle)
.font(AppFonts.sans(13, weight: .semibold)) .font(AppFonts.sans(13, weight: .semibold))
.foregroundColor(AppColors.text(cs)) .foregroundColor(AppColors.text(cs))
Text(total == 0 ? "Add exercises to get started" Text(statusSubtitle)
: done == total && total > 0 ? "All sets done"
: "\(total - done) sets remaining")
.font(AppFonts.mono(9)) .font(AppFonts.mono(9))
.foregroundColor(AppColors.text3(cs)) .foregroundColor(AppColors.text3(cs))
} }
Spacer() Spacer()
Text(total > 0 ? "\(Int(progress * 100))%" : "0%") statusBadge
.font(AppFonts.mono(13, weight: .bold))
.foregroundColor(progress >= 1 ? AppColors.green : AppColors.text2(cs))
} }
// 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) { VStack(alignment: .leading, spacing: 6) {
Text("PROGRESS") Text("PROGRESS")
.font(AppFonts.mono(8, weight: .bold)) .font(AppFonts.mono(8, weight: .bold))
.foregroundColor(AppColors.text3(cs)) .foregroundColor(AppColors.text3(cs))
DotGridProgress(progress: total > 0 ? progress : 0, cs: 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) .padding(14)
.cardStyle() .cardStyle(borderColor: isDoneToday ? AppColors.green.opacity(0.3) : nil)
.confirmationDialog(
confirmTitle,
isPresented: Binding(get: { pendingAction != nil }, set: { if !$0 { pendingAction = nil } }),
titleVisibility: .visible,
presenting: pendingAction
) { action in
Button(action.confirmLabel, role: action.isDestructive ? .destructive : nil) {
perform(action)
}
Button("Cancel", role: .cancel) {}
} message: { action in
Text(confirmMessage(action))
}
}
private var confirmTitle: String {
switch pendingAction {
case .finish: return "Finish with \(done) of \(total) sets logged?"
case .checkAll: return "Check every set as done?"
case .clearAll: return "Clear all logged sets?"
case .notCompleted: return "Mark today as not completed?"
case .undo: return "Undo today's logged workout?"
case nil: return ""
}
}
private func confirmMessage(_ action: PendingAction) -> String {
switch action {
case .finish:
return total > 0
? "You've logged \(Int(progress * 100))% of sets. This still counts as today's workout."
: "No sets logged yet. This still counts as today's workout."
case .checkAll:
return "This ticks all \(total) sets as done — useful if you trained but didn't log along the way."
case .clearAll:
return "This un-checks all \(total) sets. It won't undo today's logged workout."
case .notCompleted:
return isDoneToday ? "This replaces today's logged workout with \"not completed.\"" : "You can undo this afterward."
case .undo:
return "This removes today's manually logged workout and goes back to pending. It won't affect anything recorded by Health or your Watch."
}
}
private func perform(_ action: PendingAction) {
switch action {
case .finish: onFinish()
case .checkAll: onMarkAllSets()
case .clearAll: onClearAllSets()
case .notCompleted: onNotCompleted()
case .undo: onUndo()
}
}
private var statusTitle: String {
if isDoneToday { return "Workout logged" }
if isMissedToday { return "Marked as not completed" }
return "\(done) of \(total) sets done"
}
private var statusSubtitle: String {
if isDoneToday { return "Counted toward today · \(done)/\(total) sets logged" }
if isMissedToday { return "Doesn't count toward today" }
if total == 0 { return "Add exercises to get started" }
if done == total { return "All sets done" }
return "\(total - done) sets remaining"
}
@ViewBuilder private var statusBadge: some View {
if isDoneToday {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 18)).foregroundColor(AppColors.green)
} else if isMissedToday {
Image(systemName: "moon.zzz.fill")
.font(.system(size: 16)).foregroundColor(AppColors.text3(cs))
} else {
Text(total > 0 ? "\(Int(progress * 100))%" : "0%")
.font(AppFonts.mono(13, weight: .bold))
.foregroundColor(progress >= 1 ? AppColors.green : AppColors.text2(cs))
}
}
/// Primary: finish the workout as-is (honest capture, not gated on 100% sets).
/// Secondary: the mechanical "tick every box" action (toggles to a clear once
/// all are checked), and the explicit miss. Every action here that changes
/// today's state is confirmed first see `pendingAction`.
private var actionRow: some View {
VStack(spacing: 8) {
Button {
pendingAction = .finish
} label: {
Label(done == total && total > 0 ? "Finish Workout" : "Finish Anyway",
systemImage: "checkmark.circle.fill")
.font(AppFonts.sans(13, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 11)
}
.buttonStyle(QuietAccentButtonStyle())
HStack(spacing: 8) {
if done == total && total > 0 {
Button { pendingAction = .clearAll } label: {
Label("Clear all sets", systemImage: "circle")
.font(AppFonts.sans(11, weight: .medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
.foregroundColor(AppColors.text2(cs))
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: 9))
.buttonStyle(PressButtonStyle(scale: 0.97))
} else {
Button { pendingAction = .checkAll } label: {
Label("Check all sets", systemImage: "checkmark.circle")
.font(AppFonts.sans(11, weight: .medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
.foregroundColor(AppColors.text2(cs))
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: 9))
.buttonStyle(PressButtonStyle(scale: 0.97))
}
Button { pendingAction = .notCompleted } label: {
Label("Didn't train", systemImage: "moon.zzz")
.font(AppFonts.sans(11, weight: .medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
.foregroundColor(AppColors.text3(cs))
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: 9))
.buttonStyle(PressButtonStyle(scale: 0.97))
}
}
}
/// Only shown for a manually-logged day tapping this always confirms first
/// (see `PendingAction.undo`), so a stray tap can't silently un-log a workout.
private var manualUndoRow: some View {
HStack {
Spacer()
Button { pendingAction = .undo } label: {
Label("Undo", systemImage: "arrow.uturn.backward")
.font(AppFonts.sans(11, weight: .semibold))
}
.foregroundColor(AppColors.accent)
}
} }
} }
@@ -908,28 +1124,28 @@ struct RestDayCard: View {
} }
// MARK: - Streak Banner // MARK: - Streak Banner
// Swipeable: this week this year this-week-vs-last-week.
struct StreakBannerView: View { struct StreakBannerView: View {
@Environment(\.colorScheme) private var cs @Environment(\.colorScheme) private var cs
let streak: Int @ObservedObject var vm: WorkoutViewModel
@State private var page = 0
var body: some View { var body: some View {
HStack(spacing: 0) { VStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) { TabView(selection: $page) {
Text("\(streak)") weekPage.tag(0)
.font(AppFonts.mono(28, weight: .bold)) yearPage.tag(1)
.foregroundColor(AppColors.text(cs)) comparePage.tag(2)
Text("day streak")
.font(AppFonts.sans(11))
.foregroundColor(AppColors.text3(cs))
} }
Spacer() .tabViewStyle(.page(indexDisplayMode: .never))
// 7-day tick bar current week progress .frame(height: 50)
HStack(spacing: 3) {
ForEach(0..<7, id: \.self) { i in // Page dots
let filled = streak > 0 && i < (streak % 7 == 0 ? 7 : streak % 7) HStack(spacing: 5) {
RoundedRectangle(cornerRadius: 1.5) ForEach(0..<3, id: \.self) { i in
.fill(filled ? AppColors.accent : AppColors.borderHi(cs)) Circle()
.frame(width: 18, height: 3) .fill(i == page ? AppColors.accent : AppColors.borderHi(cs))
.frame(width: 5, height: 5)
} }
} }
} }
@@ -937,6 +1153,76 @@ struct StreakBannerView: View {
.padding(.vertical, 13) .padding(.vertical, 13)
.cardStyle() .cardStyle()
} }
// Page 1 this week (per-day ticks)
private var weekPage: some View {
HStack(spacing: 0) {
statBlock(value: "\(vm.workoutsThisWeek)", label: "this week")
Spacer()
HStack(spacing: 3) {
ForEach(Array(vm.currentWeekDays.enumerated()), id: \.offset) { _, done in
RoundedRectangle(cornerRadius: 1.5)
.fill(done ? AppColors.accent : AppColors.borderHi(cs))
.frame(width: 18, height: 3)
}
}
}
}
// Page 2 this year (month ticks) + all-time total
private var yearPage: some View {
HStack(spacing: 0) {
statBlock(value: "\(vm.workoutsThisYear)", label: "this year")
Spacer()
VStack(alignment: .trailing, spacing: 5) {
HStack(spacing: 2) {
ForEach(Array(vm.currentYearMonths.enumerated()), id: \.offset) { _, active in
RoundedRectangle(cornerRadius: 1)
.fill(active ? AppColors.accent : AppColors.borderHi(cs))
.frame(width: 9, height: 3)
}
}
Text("\(vm.streakDays) all-time")
.font(AppFonts.mono(9))
.foregroundColor(AppColors.text3(cs))
}
}
}
// Page 3 this week vs last week
private var comparePage: some View {
let delta = vm.workoutsThisWeek - vm.workoutsLastWeek
let deltaColor = delta > 0 ? AppColors.green : (delta < 0 ? AppColors.accent : AppColors.text3(cs))
return HStack(spacing: 0) {
statBlock(value: "\(vm.workoutsThisWeek)", label: "this week")
Spacer()
VStack(alignment: .trailing, spacing: 3) {
HStack(spacing: 4) {
Image(systemName: delta > 0 ? "arrow.up.right" : (delta < 0 ? "arrow.down.right" : "equal"))
.font(.system(size: 10, weight: .bold))
Text(delta == 0
? "same as last week"
: "\(abs(delta)) \(delta > 0 ? "more" : "fewer") than last week")
.font(AppFonts.mono(10, weight: .semibold))
}
.foregroundColor(deltaColor)
Text("last week: \(vm.workoutsLastWeek)")
.font(AppFonts.mono(9))
.foregroundColor(AppColors.text3(cs))
}
}
}
private func statBlock(value: String, label: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(value)
.font(AppFonts.mono(28, weight: .bold))
.foregroundColor(AppColors.text(cs))
Text(label)
.font(AppFonts.sans(11))
.foregroundColor(AppColors.text3(cs))
}
}
} }
// MARK: - Exercise Card // MARK: - Exercise Card

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

@@ -0,0 +1,122 @@
import XCTest
@testable import KisaniCal
@MainActor
final class StreakLogicTests: XCTestCase {
private let cal = Calendar.current
private let fmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f
}()
private func day(_ offset: Int, from ref: Date) -> String {
fmt.string(from: cal.date(byAdding: .day, value: offset, to: ref)!)
}
// MARK: - Workout tally (lifetime; only ever grows, never resets)
/// The field scenario: 6-day week, Thursday skipped, 5 workouts done
/// the count is 5, not 1. A missed day never reduces it.
func testSkippedDayStillCountsAchieved() {
let today = cal.startOfDay(for: Date())
var dates: [String] = []
guard let week = cal.dateInterval(of: .weekOfYear, for: today) else { return XCTFail() }
var d = week.start, added = 0
while d <= today {
let key = fmt.string(from: d)
if key != day(-2, from: today) { dates.append(key); added += 1 } // skip one day
d = cal.date(byAdding: .day, value: 1, to: d)!
}
XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays(dates), added,
"every logged day counts; the skipped day is simply absent")
}
/// A below-goal week (4/5) and a full week (5/5) both contribute all their
/// achieved days 9 total, nothing lost to the 4/5 week "failing."
func testBelowGoalAndFullWeekBothCountFully() {
let today = cal.startOfDay(for: Date())
guard let prevRef = cal.date(byAdding: .weekOfYear, value: -1, to: today),
let prevWeek = cal.dateInterval(of: .weekOfYear, for: prevRef),
let thisWeek = cal.dateInterval(of: .weekOfYear, for: today)
else { return XCTFail() }
var dates: [String] = []
var d = prevWeek.start
for i in 0..<7 { if i < 4 { dates.append(fmt.string(from: d)) }; d = cal.date(byAdding: .day, value: 1, to: d)! } // 4/5
d = thisWeek.start
for i in 0..<7 { if i < 5 { dates.append(fmt.string(from: d)) }; d = cal.date(byAdding: .day, value: 1, to: d)! } // 5/5
XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays(dates), 9,
"4/5 + 5/5 = 9; a below-goal week is never discarded")
}
/// A completely blank week between two active stretches does not reset the
/// tally earlier achievements are preserved.
func testBlankWeekDoesNotReset() {
let today = cal.startOfDay(for: Date())
guard let twoBack = cal.date(byAdding: .weekOfYear, value: -2, to: today),
let oldWeek = cal.dateInterval(of: .weekOfYear, for: twoBack),
let thisWeek = cal.dateInterval(of: .weekOfYear, for: today)
else { return XCTFail() }
var dates: [String] = []
var d = oldWeek.start
for i in 0..<7 { if i < 3 { dates.append(fmt.string(from: d)) }; d = cal.date(byAdding: .day, value: 1, to: d)! }
// (previous week: nothing a blank week)
dates.append(fmt.string(from: thisWeek.start))
XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays(dates), 4,
"3 old + 1 now; the empty week in between changes nothing")
}
/// Duplicate day entries (e.g. HealthKit merge) never double-count.
func testDuplicatesCountOnce() {
let key = fmt.string(from: cal.startOfDay(for: Date()))
XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays([key, key, key]), 1)
}
func testEmptyDataIsZero() {
XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays([]), 0)
}
// MARK: - Task completion counting (recurring occurrences)
private func makeRecurring(title: String, completedOn keys: [String]) -> TaskItem {
var t = TaskItem(title: title, dueDate: cal.date(byAdding: .day, value: -30, to: Date()))
t.isRecurring = true
t.recurrenceLabel = "Daily"
t.completedOccurrences = keys
return t
}
/// Recurring completions must count toward per-day stats even though the
/// stored task's isComplete/completedAt stay untouched.
func testRecurringOccurrencesCountedPerDay() {
let today = cal.startOfDay(for: Date())
let todayKey = fmt.string(from: today)
let recurring = makeRecurring(title: "Morning routine", completedOn: [todayKey])
var oneOff = TaskItem(title: "Buy milk")
oneOff.isComplete = true
oneOff.completedAt = today.addingTimeInterval(3600)
let n = TaskViewModel.completionCount(in: [recurring, oneOff], on: today, calendar: cal)
XCTAssertEqual(n, 2, "one recurring occurrence + one one-off completion")
}
func testRecurringNotCountedOnOtherDays() {
let today = cal.startOfDay(for: Date())
let yesterdayKey = day(-1, from: today)
let recurring = makeRecurring(title: "Morning routine", completedOn: [yesterdayKey])
XCTAssertEqual(TaskViewModel.completionCount(in: [recurring], on: today, calendar: cal), 0)
let yesterday = cal.date(byAdding: .day, value: -1, to: today)!
XCTAssertEqual(TaskViewModel.completionCount(in: [recurring], on: yesterday, calendar: cal), 1)
}
func testIncompleteOneOffNotCounted() {
let today = cal.startOfDay(for: Date())
let t = TaskItem(title: "Open task")
XCTAssertEqual(TaskViewModel.completionCount(in: [t], on: today, calendar: cal), 0)
}
}

View File

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

View File

@@ -40,6 +40,7 @@ enum ProgressDotMode: String, AppEnum {
case day case day
case week case week
case month case month
case year
case customEvent case customEvent
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Progress Mode" static var typeDisplayRepresentation: TypeDisplayRepresentation = "Progress Mode"
@@ -47,6 +48,7 @@ enum ProgressDotMode: String, AppEnum {
.day: "This Day", .day: "This Day",
.week: "This Week", .week: "This Week",
.month: "This Month", .month: "This Month",
.year: "This Year",
.customEvent: "Custom Event", .customEvent: "Custom Event",
] ]
} }
@@ -115,6 +117,13 @@ struct ProgressDotProvider: AppIntentTimelineProvider {
return ProgressDotEntry(date: date, title: Self.monthTitle(for: date), return ProgressDotEntry(date: date, title: Self.monthTitle(for: date),
progress: Self.progress(now: date, start: start, end: end), progress: Self.progress(now: date, start: start, end: end),
totalDots: 100) 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: case .customEvent:
guard let event = configuration.event, guard let event = configuration.event,
let task = loadWidgetTasks().first(where: { $0.id.uuidString == event.id }), 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 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)? { private static func recurrenceSpan(for task: WidgetTask, due: Date, now: Date) -> (prev: Date, next: Date)? {
let category = task.category.lowercased() let category = task.category.lowercased()
if category == "birthday" || category == "annual" { 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 } guard task.isRecurring, let label = task.recurrenceLabel else { return nil }
return recurrenceSpan(label: label, due: due, now: now) return RecurrenceRule.span(label: label, interval: task.recurrenceInterval ?? 1, 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)
} }
private static func startOfISOWeek(containing date: Date) -> Date { private static func startOfISOWeek(containing date: Date) -> Date {
@@ -251,6 +231,12 @@ struct ProgressDotProvider: AppIntentTimelineProvider {
formatter.dateFormat = "MMMM yyyy" formatter.dateFormat = "MMMM yyyy"
return formatter.string(from: date) 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 { 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 ActivityConfiguration(for: TaskActivityAttributes.self) { context in
// Lock screen / banner // Lock screen / banner
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: context.state.isComplete ? "checkmark.circle.fill" : "circle") Image(systemName: context.state.isComplete ? "checkmark.circle.fill" : "hourglass")
.font(.system(size: 22)) .font(.system(size: 20))
.foregroundColor(context.state.isComplete ? .green : accent) .foregroundColor(context.state.isComplete ? .green : accent)
.frame(width: 24) .frame(width: 24)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
@@ -40,6 +40,18 @@ struct TaskLiveActivity: Widget {
.frame(width: 92, alignment: .trailing) .frame(width: 92, alignment: .trailing)
.fixedSize(horizontal: false, vertical: true) .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(.leading, 14)
.padding(.trailing, 10) .padding(.trailing, 10)
@@ -69,6 +81,16 @@ struct TaskLiveActivity: Widget {
.font(.system(.callout, design: .monospaced)) .font(.system(.callout, design: .monospaced))
.lineLimit(1) .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: { } compactLeading: {
Image(systemName: "checklist").foregroundColor(accent) Image(systemName: "checklist").foregroundColor(accent)
} compactTrailing: { } compactTrailing: {

View File

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

108
STRUCTURE.md Normal file
View File

@@ -0,0 +1,108 @@
# Project Structure
KisaniCal (display name **Wenza**) is a personal productivity + fitness iOS app,
built with SwiftUI and generated from `project.yml` via [XcodeGen]. It ships an
iOS app, a WidgetKit extension, and an optional watchOS companion.
## Build targets
Defined in [`project.yml`](project.yml):
| Target | Type | Platform | Bundle ID |
|---|---|---|---|
| `KisaniCal` | application | iOS 16+ | `com.kutesir.KisaniCal` |
| `KisaniCalWidgets` | app-extension | iOS 18+ | `com.kutesir.KisaniCal.KisaniCalWidgets` |
| `WenzaWatch` | application | watchOS 10+ | `com.kutesir.KisaniCal.watchkitapp` |
| `KisaniCalTests` | unit-test | iOS 16+ | — |
`WenzaWatch` is built/embedded only when the watchOS platform is installed
(see the commented `embed` note in `project.yml`).
## Directory layout
```
KisaniCal/ repo root (Gitea: kutesir/KisaniCal)
├── project.yml XcodeGen spec — source of truth for the .xcodeproj
├── KisaniCal.xcodeproj generated (do not hand-edit)
├── KisaniCal/ 📱 main iOS app target
│ ├── KisaniCalApp.swift @main entry point
│ ├── ContentView.swift root view / tab shell
│ ├── KisaniCal.entitlements
│ ├── LaunchScreen.storyboard
│ ├── Views/ SwiftUI screens (13)
│ │ ├── TodayView, CalendarView, MatrixView tasks
│ │ ├── WorkoutView, AddExerciseSheet fitness
│ │ ├── AuthView, OnboardingView, ProfileSetupView, SplashView
│ │ ├── SettingsView, TutorialView
│ │ └── TaskContextMenu, TodayMenuFeatures
│ ├── Managers/ stateful service singletons (10)
│ │ ├── AuthManager, CloudSyncManager auth + iCloud KVS
│ │ ├── HealthKitManager, SpeechRecognizer system frameworks
│ │ ├── NotificationManager, LiveActivityManager, ShortcutHandler
│ │ ├── TutorialManager
│ │ └── AppGroup, TaskActivityAttributes shared with widgets
│ ├── Models/ TaskItem, ExerciseModels, CalendarGrid
│ ├── Components/ SharedComponents, FloatingTabState
│ └── Theme/ DesignTokens
├── KisaniCalWidgets/ 🧩 WidgetKit extension (iOS 18+)
│ ├── KisaniCalWidgets.swift, MyTasksWidget, EventCountdownWidget
│ ├── TaskLiveActivity, WidgetViews, WidgetData
│ └── Info.plist, .entitlements
├── WenzaWatch/ ⌚️ watchOS companion (build-optional)
│ └── WenzaWatchApp.swift, Info.plist, .entitlements
├── KisaniCalTests/ 🧪 unit tests (the CI test target)
│ └── CalendarGridTests, RecurrenceTests, NLRecurrenceParseTests
├── .gitea/workflows/ 🔧 CI/CD (Gitea Actions)
│ ├── ci.yml build + test on PRs into main/develop
│ └── release.yml archive + export IPA on push to main
├── scripts/gitea-setup.sh Gitea branch-protection / default-branch setup
├── ExportOptions.plist IPA export template
├── CONTRIBUTING.md branch workflow + PR gate rules
├── PRODUCT.md product brief, users, design principles
├── SERVICES.md backing services / infra notes
├── ISSUES.md running issue log (KC-## ids)
└── Sanctum-Auto-FailOver-Uptime-Kuma.md
```
## Architecture
A flat SwiftUI **MVVM-lite** layout, organized by role rather than by feature:
- **Views** — SwiftUI screens, one file per screen.
- **Managers** — `ObservableObject` service singletons holding cross-cutting
state (auth, iCloud sync, HealthKit, notifications, speech, Live Activities).
- **Models** — plain data types (`TaskItem`, exercise/workout models, calendar
grid math).
- **Components / Theme** — shared UI and design tokens.
Three product domains coexist in one app:
1. **Tasks** — Today list, Calendar, Eisenhower Matrix; natural-language quick
add (with voice via `SpeechRecognizer`).
2. **Fitness** — workout logging with weighted/bodyweight sets; reads/writes
Health via `HealthKitManager`.
3. **Calendar / events** — surfaced on the dashboard, in **widgets**, and as
**Live Activities**.
### Cross-target code sharing
`KisaniCal/Managers/AppGroup.swift` and `TaskActivityAttributes.swift` are
compiled into **both** the app and the widget extension (see the widget target's
`sources` in `project.yml`), so task/activity data is shared through the App
Group container.
### Persistence & sync
State is persisted locally and mirrored to **iCloud key-value store** via
`CloudSyncManager`. Restore-on-launch rehydrates data after reinstall/update
(see ISSUES.md KC-39 for the save/restore symmetry the sync layer must keep).
[XcodeGen]: https://github.com/yonaskolb/XcodeGen

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)"
}
}

75
docs/CICD.md Normal file
View File

@@ -0,0 +1,75 @@
# CI/CD Flow
How a change travels from a local branch to TestFlight, and where the gates are.
```mermaid
flowchart TD
A["feature/* branch · local<br/><i>pre-push hook blocks main</i>"]
B["Pull request → develop<br/><i>develop is the default branch</i>"]
C["CI · ci.yml (macOS runner)<br/><i>xcodebuild build + test</i>"]
D{"Merge gate<br/>CI green + 1 approval"}
E["Pull request develop → main<br/><i>same gate</i>"]
F["Release · release.yml<br/><i>archive → IPA → TestFlight</i>"]
A -->|git push · open PR| B
B -->|PR triggers CI| C
C -->|status check: build-and-test| D
D -->|promote when ready| E
E -->|on merge to main| F
```
## Steps
1. **`feature/*` branch (local).** Branch off `develop`. A local
`.git/hooks/pre-push` hook rejects any direct push to `main`, so nothing
reaches the protected branch outside a PR.
2. **PR → `develop`.** `develop` is the default branch and the integration
target for all feature work.
3. **CI runs** — [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml) on the
self-hosted **macOS runner**. It runs `xcodebuild build` then
`xcodebuild test` (the `KisaniCalTests` target on an iOS simulator). The
result is published as a commit status named **`build-and-test`**.
4. **Merge gate.** Gitea branch protection blocks the merge until:
- the `build-and-test` status check is **green**,
- there is **≥ 1 approval**,
- there are no rejected reviews, and
- the branch is up to date with its base.
The same gate guards both `develop` and `main`.
5. **Promote `develop` → `main`** via a second PR when a release is ready. It
passes through the identical gate.
6. **Release runs** — [`.gitea/workflows/release.yml`](../.gitea/workflows/release.yml)
fires **only** on push to `main` (i.e. after a PR merges). It archives,
exports an IPA via [`ExportOptions.plist`](../ExportOptions.plist), and
uploads to **TestFlight** (currently a commented-out placeholder awaiting the
App Store Connect API-key secrets).
## Two automation halves
- **CI** (steps 34) gates **every** PR into `main`/`develop`.
- **Release** (step 6) runs **only** on `main`.
Blue/manual steps are actions you take; CI and Release run unattended on the
runner.
## Requirements & gotchas
- **Runner labels.** Both workflows use `runs-on: [self-hosted, macOS]`. The
registered runner must carry **both** labels or jobs queue forever. Check at
Settings → Actions → Runners.
- **Status-check name.** Branch protection requires a check literally named
`build-and-test` (the CI job name). If the rendered context differs, update
the rule (`scripts/gitea-setup.sh`, `STATUS_CONTEXT`).
- **Simulator.** The runner needs an installed iOS runtime (Xcode → Settings →
Components) — CI auto-selects the first available iPhone simulator.
- **Signing.** CI builds with `CODE_SIGNING_ALLOWED=NO`. The Release job needs a
distribution cert + profile in the runner's login keychain.
- **Build number.** `CURRENT_PROJECT_VERSION` in `project.yml` must increase for
every TestFlight upload — App Store Connect rejects a repeated build number.
See also [CONTRIBUTING.md](../CONTRIBUTING.md) for the branch workflow.

View File

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

82
scripts/gitea-setup.sh Executable file
View File

@@ -0,0 +1,82 @@
#!/usr/bin/env bash
#
# One-shot Gitea setup for KisaniCal:
# 1. Creates the `develop` branch on the server (from main) if missing.
# 2. Sets `develop` as the repo's default branch.
# 3. Adds branch protection on `main` (require PR + 1 approval + status check,
# block direct pushes / force pushes / deletion).
#
# USAGE:
# export GITEA_TOKEN=xxxxxxxxxxxxxxxxxxxx # a Gitea access token with repo scope
# ./scripts/gitea-setup.sh
#
# Create the token in Gitea: click your avatar → Settings → Applications →
# "Generate New Token" (scopes: at least write:repository ).
#
set -euo pipefail
GITEA_URL="http://10.10.1.21:3002"
OWNER="kutesir"
REPO="KisaniCal"
# The commit-status context that must pass before merge into main.
# For Gitea Actions this is the JOB name from ci.yml ("build-and-test").
# After your first CI run, confirm the exact context string at:
# ${GITEA_URL}/${OWNER}/${REPO}/commits (hover the check) and adjust if needed.
STATUS_CONTEXT="build-and-test"
: "${GITEA_TOKEN:?Set GITEA_TOKEN env var first (see header).}"
API="${GITEA_URL}/api/v1"
AUTH=(-H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json")
echo "==> 1/3 Ensuring 'develop' branch exists on server..."
if curl -fsS "${AUTH[@]}" "${API}/repos/${OWNER}/${REPO}/branches/develop" >/dev/null 2>&1; then
echo " develop already exists."
else
curl -fsS -X POST "${AUTH[@]}" \
"${API}/repos/${OWNER}/${REPO}/branches" \
-d '{"new_branch_name":"develop","old_branch_name":"main"}' >/dev/null
echo " created develop from main."
fi
echo "==> 2/3 Setting default branch to 'develop'..."
curl -fsS -X PATCH "${AUTH[@]}" \
"${API}/repos/${OWNER}/${REPO}" \
-d '{"default_branch":"develop"}' >/dev/null
echo " default branch = develop."
echo "==> 3/3 Applying branch protection on 'main'..."
# If a rule already exists this POST returns 409; we then PUT-update it.
PROTECT_PAYLOAD=$(cat <<JSON
{
"branch_name": "main",
"rule_name": "main",
"enable_push": false,
"enable_push_whitelist": false,
"required_approvals": 1,
"enable_approvals_whitelist": false,
"block_on_rejected_reviews": true,
"dismiss_stale_approvals": true,
"enable_status_check": true,
"status_check_contexts": ["${STATUS_CONTEXT}"],
"block_on_outdated_branch": true
}
JSON
)
if curl -fsS -X POST "${AUTH[@]}" \
"${API}/repos/${OWNER}/${REPO}/branch_protections" \
-d "${PROTECT_PAYLOAD}" >/dev/null 2>&1; then
echo " created protection rule for main."
else
echo " rule may already exist — updating it..."
curl -fsS -X PATCH "${AUTH[@]}" \
"${API}/repos/${OWNER}/${REPO}/branch_protections/main" \
-d "${PROTECT_PAYLOAD}" >/dev/null
echo " updated protection rule for main."
fi
echo ""
echo "Done. Note: Gitea automatically blocks force-pushes and deletion of a"
echo "protected branch, so 'main' is now safe from both."