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>
117 KiB
KisaniCal — Issue Tracker
Status legend: Open · In Progress · Resolved
KC-1 — Daily workout log does not reset on a new day
Status: In Progress (fix implemented & tested locally, pending release) Reported by: User Area: Workout
Description
When the same workout program is scheduled on back-to-back days (e.g. Shoulders on Monday and Tuesday), the new day does not start fresh. Completed sets from the previous day stay checked, so the workout appears already done.
Root cause
Set completion (ExerciseSet.isDone) was stored permanently inside the
WorkoutProgram and persisted via save(). There was no per-day reset, so a
program reused the next day carried over yesterday's checkmarks.
Fix
Set completion is now a per-day state:
WorkoutViewModel.resetSetsForNewDayIfNeeded()clears every set'sisDoneacross all programs when the calendar day changes (skips first launch, no-ops when the day is unchanged).toggleSet(...)stamps the active day so on-screen state always belongs to today.- Called on cold launch (
init),.onAppear, andscenePhase == .active(handles crossing midnight while the app is alive). - Streak history (
workoutDates) is untouched, so completed days still count.
Files: KisaniCal/Models/ExerciseModels.swift, KisaniCal/ContentView.swift
Verification
Builds cleanly (Xcode 26.2 SDK, simulator). Verified in-process on the simulator
via the real resetSetsForNewDayIfNeeded() against a seeded completed set:
before=[true] after=[false] day=2026-06-06 -> PASS. Streak preserved.
Committed in cdc46e9.
KC-2 — App logo update (new KisaniCal brand mark)
Status: In Progress (assets installed & build-verified, pending release) Area: Branding / App Icon
Description
Replace the app icon with the new KisaniCal logo (orange dot grid on dark) across the App Store marketing icon and all home/lock screen / Spotlight / notification sizes.
Fix
All 12 sizes in AppIcon.appiconset regenerated from the new source. Alpha
flattened onto the brand dark background RGB(26,29,34) so the 1024 App Store icon
is fully opaque (App Store Connect compliant) and device icons are full-bleed.
Files: KisaniCal/Assets.xcassets/AppIcon.appiconset/
Verification
Builds cleanly (app + widget extension); hasAlpha: no on all icons. New logo
confirmed compiled into Assets.car (home, lock-screen notification, Spotlight,
Settings all derive from AppIcon). Committed in cdc46e9.
Note: device still shows old icon until the new build is installed (delete + reinstall).
KC-3 — Today view: section order + RSS feeds leaking into Tasks
Status: In Progress (implemented & build-verified, pending release) Reported by: User Area: Tasks / Today
Description
Two changes to the Today view:
- The Overdue card sat at the very top, above today's active tasks. Active tasks should always be the top section.
- Subscribed/RSS calendar feeds (F1 schedule, TV-show calendars) were appearing in the Today/Tasks timeline. They belong in the Calendar tab only.
Fix
- Moved the
OverdueCardto render directly below today's active tasks (above Next 3 Days), so active tasks stay at the top. todayCalEventsnow excludes events whosecalendar.type == .subscription, so RSS/ICS feeds are filtered out of the Today timeline. The Calendar view readsmonthEventsdirectly and is unaffected.- Birthdays (
.birthday) and personal/CalDAV calendars are intentionally left visible; the existing per-calendar visibility toggle (hiddenCalendarIDs) lets the user hide any of them. User-created tasks are never affected.
Files: KisaniCal/Views/TodayView.swift
Verification
Builds cleanly. Committed in f66f032 (re-authored a92e9d4).
KC-4 — Unified task context menu + Live Activity + calendar event actions
Status: In Progress (implemented & build-verified; Live Activity pending on-device test) Reported by: User Area: Tasks / Calendar / Widgets
Description
Long-pressing a task (in any task list) should show a consistent menu: Pin · Date · Move · Priority · Tags · Add to Live Activity · Delete — and the actions must actually work. Long-pressing a non-subscription calendar event should offer Edit / Delete only.
Fix
- New shared
TaskMenuItems(KisaniCal/Views/TaskContextMenu.swift) used by the Today timeline, Matrix (grid + Overdue/Later/Completed lists), and Calendar day timeline. AddedTaskViewModel.setPriority. - Live Activity (ActivityKit):
TaskActivityAttributes(shared app+widget),LiveActivityManager(start/update/end/toggle, iOS 16.1+),TaskLiveActivitywidget (lock screen + Dynamic Island),NSSupportsLiveActivitiesvia project.yml. - Calendar events (non-subscription, writable only): Edit opens the system
EKEventEditViewController(EventEditView); Delete viaCalendarStore.deleteEvent.
Files: TaskContextMenu.swift, TaskActivityAttributes.swift,
LiveActivityManager.swift, TaskLiveActivity.swift, TaskItem.swift,
MatrixView.swift, CalendarView.swift, TodayView.swift,
KisaniCalWidgets.swift, project.yml. Committed in d0d982f.
How to test
- Menu parity — long-press a task in Today, in a Matrix quadrant, in a Matrix list (Overdue/Later), and in the Calendar day view. All show the same 7 items.
- Each action — Pin (row pins), Date → Today/Tomorrow/Next Week/Remove (due date changes), Move (task changes quadrant), Priority (flag changes), Tags (category changes), Delete (removed).
- Live Activity — tap "Add to Live Activity" → a banner appears on the Lock Screen / Dynamic Island with the task title + countdown; tap again to end. Requires a real iOS 16.1+ device (not visible in the Simulator).
- Calendar events — long-press a personal (writable) event → Edit opens the system editor; Delete removes it. A subscribed/RSS event (F1, TV) shows no menu.
Known limitations
- Live Activity visuals only render on a physical device; the design is a first pass (orange accent + timer) and can be refined.
- Uses the iOS 16.1
Activity.request(...contentState:...)API (deprecation warning on 16.2+, still functional).
KC-5 — Calendar "Connect" toggle unreliable / inconsistent across screens
Status: In Progress (implemented & build-verified; full reliability win is device-only) Reported by: User Area: Calendar / Permissions
Description
The iPhone Calendar "Connect" sometimes needed repeated taps, onboarding sometimes didn't reflect a granted calendar, and there was no place to see/manage connection status from Settings.
Root cause
Three independent calendar-permission states that never synced: TodayView,
CalendarView each had their own CalendarStore + EKEventStore, and
OnboardingView had its own raw EKEventStore. Granting in one left the others
showing stale cached status until they happened to re-read it.
Fix
CalendarStore.sharedsingleton — one source of truth used by Today, Calendar, Onboarding, and Settings. Connect once, every screen reflects it.requestAccess()now debounces in-flight requests and reads the authoritativeEKEventStore.authorizationStatusinstead of trusting the callback bool; already-decided states re-sync viarefreshStatus().- Turning on "Show Calendar Events" while unauthorized now triggers the prompt.
- New Settings → Data → Calendar row showing ● Connected / Connect / Denied, opening the connect sheet.
Files: CalendarView.swift, TodayView.swift, OnboardingView.swift,
SettingsView.swift.
Verification
Builds cleanly. Cross-screen consistency + the Settings status row are testable in the Simulator; the request-reliability fix is best confirmed on a device.
KC-6 — No notifications for calendar events
Status: In Progress (implemented & build-verified; fires on a real device with calendar access) Reported by: User Area: Calendar / Notifications
Description
Task reminders fired, but calendar events never produced a KisaniCal notification — events with no iOS alert (e.g. subscribed F1/TV feeds) notified the user of nothing.
Root cause
NotificationManager only scheduled workout + per-task notifications. There was
no calendar-event path at all (EKEvent/CalendarStore were never involved).
Fix
CalendarStore.upcomingEventNotifs(days:)snapshots visible events for the next 7 days (respects enabled flag + hidden calendars) with start time + alarm offsets.NotificationManager.scheduleCalendarEvents(...)schedules a notification at the event start ("on time") plus at each alarm the user set; all-day events fire at 9am morning-of. Capped at ~20 to stay under iOS's 64 pending-notification limit; recurring instances kept unique via start-time in the id.- Wired into
reschedule(...)(runs on foreground / scene-active / task changes).
Files: CalendarView.swift, NotificationManager.swift.
How to test (device, calendar access granted)
- Create an event a few minutes out with no alert on a visible calendar.
- Background the app (triggers a reschedule).
- Notification fires at the event's start time.
Known limitations / decisions
- "All visible events" was chosen, so personal events that already have an iOS alert may double (system + KisaniCal). Feed events (F1/TV) are the main win.
- Tapping a calendar notification opens the Today view (no calendar deeplink).
- The 7-day window re-scans on each foreground; no background refresh beyond that.
KC-7 — App Store upload: "iPad Multitasking must provide a launch screen"
Status: Resolved (fix in 72f4566; re-archive required to ship)
Reported by: User (App Store / Transporter validation)
Area: Build / App Store
Symptom
Upload fails validation:
Invalid bundle. Apps that support Multitasking on iPad must provide the app's launch screen using an Xcode storyboard, or using UILaunchScreen … Verify that the UILaunchStoryboardName key is included in your com.kutesir.KisaniCal bundle.
Root cause
The app targets iPad (TARGETED_DEVICE_FAMILY = "1,2") with multitasking
(UIApplicationSupportsMultipleScenes = true), but project.yml had
INFOPLIST_KEY_UILaunchStoryboardName: "" (empty) and no UILaunchScreen —
so the bundle shipped no valid launch screen.
Fix
- Added
KisaniCal/LaunchScreen.storyboard(blank,systemBackgroundColor). project.yml:INFOPLIST_KEY_UILaunchStoryboardName: LaunchScreen.xcodegen generate. Verified a clean Release build emitsUILaunchStoryboardName = LaunchScreenand shipsLaunchScreen.storyboardc.
⚡ If it happens again — solve it fast
This error almost always recurs because a stale archive was uploaded, NOT because the code is wrong. Checklist:
- Confirm the fix is present:
grep UILaunchStoryboardName project.yml→ must beLaunchScreen(not"").git log --oneline -- KisaniCal/LaunchScreen.storyboard→ should show72f4566. - If Xcode was open, reload the project (xcodegen rewrites
.xcodeproj). - Product → Clean Build Folder (⇧⌘K) — a cached build folder re-emits the old Info.plist. This is the step people skip.
- Bump
CURRENT_PROJECT_VERSIONinproject.yml+xcodegen generate(a failed upload can "use up" the build number). - Re-Archive → Validate → Distribute.
- Sanity check any archive:
/usr/libexec/PlistBuddy -c "Print :UILaunchStoryboardName" <App>.app/Info.plistandls <App>.app | grep LaunchScreen(expectLaunchScreen.storyboardc).
Committed: fix 72f4566, build-number bump 345415e.
KC-8 — Workout doesn't follow the schedule; rest day shows a stale workout
Status: In Progress (implemented & build-verified, pending device build) Reported by: User Area: Workout
Description
The Workout tab kept showing the last-used program with its old completion, ignoring the weekly schedule. On a rest day (no program assigned) it still displayed a fully-checked workout from a previous day.
Root cause
The daily reset (KC-1) cleared set completion but never changed the active program — it stayed on whatever was last selected, regardless of the schedule.
Fix
applyScheduledProgramForToday()(run fromresetSetsForNewDayIfNeededon a new day) switches the active program to the one assigned to today.isRestDayToday(schedule exists but today has no entry) drives a new Rest Day card inWorkoutViewwith a "Work out anyway" override; header reads "Rest Day".- Daily set reset (KC-1) unchanged.
Files: ExerciseModels.swift, WorkoutView.swift. Committed in f9081cf.
KC-9 — Per-day workout history
Status: In Progress (implemented & build-verified, pending device build) Reported by: User ("store the data for each day") Area: Workout
Description
Set completion resets daily, so there was no way to look back at what was actually logged on a past day.
Fix
- New
WorkoutDayLog/LoggedExercise/LoggedSetmodels; stored askisani.workout.history.<uid>(and iCloud-synced). snapshotDay(_:)captures the active program's completed sets — called fromlogWorkoutCompleted(same-day) and from the daily reset before wiping (captures partial days). Only records days with ≥1 set done.- New
WorkoutHistoryView(clock button in the Workout header) lists past days newest-first with program, sets done, and per-exercise breakdown.
Files: ExerciseModels.swift, WorkoutView.swift.
KC-10 — Calendar marks future workouts complete (per-type, not per-date)
Status: In Progress (implemented & build-verified, pending device build) Reported by: User Area: Calendar / Workout
Description
In the calendar, completing one occurrence of a program (e.g. Tuesday Shoulders) showed every occurrence of that program as completed — including future days (Saturday Shoulders) — with a checkmark + strikethrough.
Root cause
DayTimelineView computed the workout's completion from the program's live
shared set state: w.doneSets == w.totalSets. program(for: date) returns the
same program object for every date it's scheduled, so its in-progress completion
leaked onto all of those dates.
Fix
- Added
WorkoutViewModel.isWorkoutComplete(on:)— checksworkoutDates(the set of dates a workout was actually finished/Health-detected), i.e. per-date. DayTimelineViewnow takes aworkoutDone: Booland uses it instead of the livedoneSets. Calendar passesisWorkoutComplete(on: selectedDate), Today passesisWorkoutComplete(on: today).- Result: a date shows the workout complete only if that specific date was finished. Future occurrences are never auto-completed.
Files: ExerciseModels.swift, CalendarView.swift, TodayView.swift.
KC-11 — Calendar appeared capped at 2026 (no year shown)
Status: Resolved Reported by: User Area: Calendar
Description
Users couldn't navigate/see dates beyond 2026.
Root cause
Navigation was actually unbounded, but every header showed only the month name
("MMMM") — the Year view had no year label at all — so crossing a year boundary
was invisible and future months felt unreachable (compounded by empty future
months from the missing recurrence engine, KC-12).
Fix
Show the year: main header "Month Year", date-picker grid "MMMM yyyy", and a
◀ year ▶ navigator + label in the Year view (navigateYear).
Files: CalendarView.swift, TodayView.swift. Committed in 7b2b61a.
KC-12 — Recurring tasks don't appear on future dates
Status: In Progress (implemented & build-verified, pending device build) Reported by: User Area: Tasks / Calendar
Description
A task set to repeat (Daily/Weekly/Monthly/Yearly/Every-Weekday) only ever showed
on its single original dueDate. No future occurrences, no per-occurrence
completion.
Root cause
There was no recurrence engine — tasks carry isRecurring + recurrenceLabel but
a single dueDate, and every surface matched by isDate(dueDate, inSameDayAs:).
Fix (core)
- Model:
recurrenceEnd+completedOccurrences(optional → safe decode of old data). TaskViewModelengine:isOccurrence(_:on:),occurrenceComplete,toggleOccurrence,occurrenceCopy,nextOccurrence,occurrences(on:).toggle(_:)routes recurring rows to per-occurrence completion (one day never affects another).- Date filters (
today/next3/upcoming/completedToday) exclude recurring masters and re-inject per-date occurrence copies; recurring never goes "overdue". - Calendar dots + day detail use
occurrences(on:). - Works across year boundaries; respects
recurrenceEndwhen set.
Files: TaskItem.swift, CalendarView.swift.
"Repeat until" UI (done)
- Added
recurrenceEndthroughNLParsed,TaskDatePickerSheet, both add/edit sheets, andaddTask/updateTask. - Date picker shows a "Repeat until" row (graphical date picker, default "Forever", with Clear) whenever a recurrence is selected.
Files: TaskItem.swift, CalendarView.swift, TodayView.swift, MatrixView.swift.
KC-13 — Recurrence redesign: single occurrence, new sections, classic Matrix
Status: In Progress (implemented & build-verified, pending device build) Reported by: User Area: Tasks / Calendar / Matrix
Description
The first recurrence pass used calendar-expansion in lists — a daily task showed in Today, Tomorrow, Next 7 Days, and Upcoming simultaneously (planning noise). Should behave like TickTick: one task, one next occurrence, one source of truth.
Fix
- Single active occurrence (P1/P5):
nextActiveOccurrence+activeRows— each recurring task contributes ONE row (its next un-completed occurrence). Completing rolls forward; no future duplicates in lists. Calendar grid still shows per-date. - Sections (P2): replaced Today / Next 3 Days / Upcoming with
Today / Tomorrow / Next 7 Days / Later (
todayTasks/tomorrowTasks/next7DaysTasks/laterTasks;UpcomingSectiongained atitle). - Icons (P3): ⏰ reminder + 🔁 recurring on the right of each row; fixed the hardcoded "Annual" label to show the real frequency.
- Matrix (P4): classic Eisenhower — importance is user-assigned (row), urgency is
derived from the deadline (within
urgentWindowDays= 7).displayQuadrant+matrixTasksgroup by the computed quadrant; recurring rolls forward. Chosen over the fully-auto "far = Q4" model so the user keeps control of importance.
Files: TaskItem.swift, TodayView.swift, MatrixView.swift.
Commits: b4e36db (lists + icons), 2f1bc5d (Matrix).
KC-14 — Workout Stats (daily / weekly / monthly performance)
Status: In Progress (implemented & build-verified, pending device build) Reported by: User Area: Workout
Description
Per-day logs (KC-9) existed but weren't aggregated, so there was no way to compare daily/weekly/monthly performance.
Fix
WorkoutDayLog.volume(Σ weight×reps over done sets);StatPeriod/WorkoutStat/StatBucket.WorkoutViewModel:stat(period, offset:)(current vs previous),statInterval,statBuckets(period, count:)for charting.- New
WorkoutStatsView(chart-bar button in the Workout header): Day/Week/Month selector, summary cards (Workouts / Sets / Volume) with %-delta vs the previous period, a metric chooser, and a Swift Charts bar trend (last 14 days / 8 weeks / 6 months). Empty state when no history.
Files: ExerciseModels.swift, WorkoutView.swift.
Note
Stats are computed from the app's own per-day logs. Health (steps/kcal/resting HR + workout dates) is already read on the Today dashboard and merged into the streak; a Health overlay on this screen could be added later.
KC-15 — Bars countdown widget + unified slate/orange widget theme
Status: In Progress (implemented & build-verified, pending device build) Reported by: User Area: Widgets
Description
Add a bar-meter event-countdown widget (per reference screenshot) and re-theme every widget to one look: dark slate background + brand orange, matching the app.
Fix
WidgetTheme(slate backgroundRGB(0.224,0.255,0.31), brand orange accent).BarGridvertical-bar progress view; newEventBarsView+EventBarsWidget("Event Countdown (Bars)", medium) reusing the existing event config/provider.EventEntry.fineTimeLeft(days → "X hr, Y min left") for the bars label.- Re-themed all home-screen widgets to slate bg + orange: Day Progress, Tasks Done
Today, Event Countdown (dots), My Tasks. Lock-screen accessory widgets stay on
.thinMaterial(system-tinted on the Lock Screen).
Files: WidgetViews.swift, EventCountdownWidget.swift, MyTasksWidget.swift,
KisaniCalWidgets.swift.
KC-16 — Workout check-in, missed state, and rest-day compensation
Status: In Progress (implemented & build-verified; notification actions need a real device) Reported by: User Area: Workout / Notifications
Description
Workout reminders should ask "Have you done this workout?" with Yes / No / Snooze. Saying No marks that day missed and offers to compensate on a rest day. Long-press on workout/exercise cards gives quick Done / Not Done / Compensate actions. Each scheduled day keeps its own completion state.
Fix
- VM:
missedDates+compensations(persisted + iCloud),workoutStatus(on:)(pending/done/missed),markWorkoutDone/markWorkoutMissed(on:)(per-date only),upcomingRestDays,scheduleCompensation,promptCompensation,checkPendingMissed.program(for:)honors compensations; a compensation day is not a rest day. - Notifications: WORKOUT_CONFIRM category now has "Yes, I completed it ✓", "No, I didn't" (→ marks missed + asks compensation on next open), and "Remind me in 1 hour" (snooze re-delivers). Daily reminder + check-in both use it. Compensation days get their own one-off reminder ("Missed workout recovery").
- UI:
CompensationSheet(rest-day picker, "No, keep as missed"); long-press menus on the calendar/Today workout row (Done / Not Done / Compensate) and on exercise cards (Mark as Done / Not Done); workout row subtitle shows Pending / Done / Missed and a "Compensation ·" prefix (missed shows accent color). - Daily per-date rules unchanged (KC-1/KC-10): completing Tuesday never completes Saturday.
Files: ExerciseModels.swift, NotificationManager.swift, ContentView.swift,
WorkoutView.swift, CalendarView.swift, TodayView.swift.
How to test (device)
- Enable workout reminder/check-in in Settings; when it fires, use the actions.
- "No, I didn't" → open app → compensation sheet appears → pick a rest day → that day now shows the program labeled Compensation (and gets a reminder).
- Long-press the workout card in Today/Calendar → Done / Not Done / Compensate.
- Long-press an exercise card in Workout → Mark as Done / Not Done.
KC-17 — Remove Event Countdown (dots) + Tasks Done Today widgets
Status: Resolved (build-verified) Reported by: User Area: Widgets
Description
The dot-grid "Event Countdown" and "Tasks Done Today" widgets were judged not up to standard and removed entirely from the gallery.
Removal
EventCountdownWidgetstruct (kindKisaniEventCountdown) +EventCountdownView.TasksCompleteWidget(kindKisaniTasksComplete) +TasksCompleteView+ its only consumertodayTaskCompletion()in WidgetData.swift.- Both deregistered from
KisaniWidgetBundle. - Kept (shared by the surviving bars widget):
EventConfigIntent,EventEntity,EventQuery,EventProvider,EventEntry,CycleCountdownUnitIntent,BarGrid;DotGridkept (used by Day Progress). - Untouched: Event Countdown (Bars), My Tasks, Day Progress, Lock Screen rect + circular, Task Live Activity.
Files modified: KisaniCalWidgets.swift, EventCountdownWidget.swift,
WidgetViews.swift, WidgetData.swift. No files deleted (shared code remains
in EventCountdownWidget.swift). No app-side settings referenced these widgets.
KC-18 — Bars widget: progress bar never fills; date pickers felt broken
Status: In Progress (implemented & build-verified, pending device build) Reported by: User Area: Widgets
Symptoms
The Event Countdown (Bars) widget showed all bars dim regardless of time left, and the Event date / Counting from rows in the config appeared not to work.
Root causes
- Auto (upcoming-queue) mode anchored progress to
Date()on every refresh, so elapsed time was always ~0 → empty bar forever. - Custom mode defaulted both
Event dateandCounting fromto the same instant (Date.nowat config time) → zero-length span → progress 0. The raw second-precision defaults also made the picker rows look broken. - The timeline only refreshed at midnight, so even a correct bar wouldn't move during an "X hr left" countdown.
Fix
- Persistent per-event anchor (
kisani.widget.anchor.<id>in the App Group): progress fills from when an event was first tracked and resets naturally when the tracked event changes. An explicit "Counting from" before the event wins. targetDate/startDateare now optional — rows read "Choose" until set, and a missing custom date falls back to a 2-day stub window.- Timeline pre-renders an entry every 30 minutes (12 h horizon) so the bar and time-left label keep moving.
Files: KisaniCalWidgets/EventCountdownWidget.swift.
Note: existing widget instances re-read their config; dates must be re-picked
once (params changed to optional).
KC-19 — Widgets: exact logo palette everywhere
Status: In Progress (implemented & build-verified, pending device build) Reported by: User Area: Widgets
Description
All widgets should match the brand orange theme of the logo exactly.
Fix
WidgetThemenow uses the precise brand palette: accent RGB(232,98,42) (appAppColors.accent) on the logo's dark RGB(26,29,34) background — previously a near-miss orange on a slate background.- Every themed widget (Event Countdown Bars, My Tasks, Day Progress) inherits it.
- Task Live Activity: hardcoded accent →
WidgetTheme.accent; background tint → logo dark. - Lock-screen ring tints → brand accent (renders in color on StandBy/home).
Files: WidgetViews.swift, TaskLiveActivity.swift.
KC-20 — "Track Countdown": zero-config bars widget driven from the app
Status: In Progress (implemented & build-verified, pending device build) Reported by: User Area: Widgets / Tasks
Description
The bars widget should not need manual event entry: track any KisaniCal event from the app and the widget displays it automatically (title, date, time left, progress bars).
Fix
- App: new
CountdownTracker+ a "Track Countdown" / "Stop Tracking Countdown" item in the shared task long-press menu. Storeskisani.widget.trackedEvent(+trackedSince) in the App Group and reloads widget timelines. - Widget: the tracked event bypasses the widget configuration entirely.
- Mode A (one-off): progress = elapsed/(event − tracking date).
- Mode B (recurring birthdays/anniversaries): progress across the
occurrence span containing now (previous → next occurrence), derived from
the task's recurrence rule (
recurrenceSpan).
WidgetTasknow decodesisRecurring/recurrenceLabelfrom the shared JSON.- No tracked event → existing configuration (upcoming queue / picked / custom) remains the fallback, exactly as before.
- Bar rendering already maps progress → filled (accent) vs remaining (dimmed) bars; the screenshot showing static dim bars was the pre-KC-18 build.
Files: TaskContextMenu.swift, WidgetData.swift, EventCountdownWidget.swift.
How to test (device)
- Long-press any task with a due date → "Track Countdown".
- The bars widget switches to that event without touching its config.
- A yearly birthday shows Mode B: bar spans last year's → this year's date.
- Long-press the task again → "Stop Tracking Countdown" → widget falls back to its configured mode.
KC-21 — Eisenhower Matrix as a TickTick-style view (Priority × deadline)
Status: In Progress (implemented & build-verified, pending device QA) Reported by: User Area: Tasks / Matrix
Problem
The Matrix treated importance as the task's stored quadrant — you placed a task in a row and only a manual "Move" changed it. TickTick instead treats the Matrix as a view: it auto-places every task from its Priority and due date, while still allowing quick drag/menu moves.
Fix (model — TaskItem.swift)
- Replaced whole-quadrant override with
urgencyOverride: Bool?.nil= urgency is computed from due date.true= force urgent column.false= force not-urgent column.
- Importance is now
priority == .high(top row), not the stored quadrant. Medium/Low/None stay in the bottom row. - Urgency is deadline-driven:
- default urgent window = 3 days
- birthday/domain/annual/renewal/subscription/exam/deadline = 14 days Auto-placement: Q1 = High+urgent, Q2 = High+later, Q3 = not-High+urgent, Q4 = the rest.
moveToQuadrantupdates real attributes:- vertical movement sets Priority (top → High; moving down demotes High → Medium)
- horizontal movement sets
urgencyOverrideonly if the target column disagrees with date-derived urgency.
- Editing due date (
setDate) or saving the editor (updateTask) clearsurgencyOverrideso the task re-places live. - Category changes, priority changes, and date changes refresh the legacy
quadrantcolor/widget fallback to match computed placement. addTaskapplies KisaniCal defaults when no priority is picked: birthday / domain / annual (+ title keywords exam, subscription, renew, expir) → High; workout / gym / exercise → Medium.
Fix (create UI — TodayView.swift)
AddTaskSheetno longer has a manual quadrant selector. It now exposes a Priority menu (flag icon). User picks Priority + Date only; placement is computed. Adding from a Matrix quadrant seeds High for the top row.- Fixed no-date creation: a task without an explicit parsed/picked date stays "No Date" instead of silently becoming today/urgent.
Fix (Matrix UI — MatrixView.swift, TaskContextMenu.swift)
- Completed tasks are hidden from the 2×2 Matrix grid by default, but remain in the per-quadrant detail Completed section.
- Rows with an urgency override show a small Manual badge.
- Context menu includes Reset Matrix Urgency when an urgency override exists.
- Move menu shows all four quadrants, with a checkmark on the current quadrant.
How to test
- New task, no priority, far date → lands in Q4; raise to High → jumps to Q2.
- New task with no date → stays in right column until given a due date/reminder.
- Drag Q2 → Q1 when due date is far out → Manual badge appears.
- Reset Matrix Urgency → task returns to its date-derived column.
- Drag Q1 → Q4 → High demotes to Medium and urgency is overridden if needed.
- Open the task, change its due date → urgency override clears, re-places by rule.
- Add a "Mum's birthday" with no priority → auto-High and uses 14-day urgency window.
KC-22 — Quick postpone / snooze from task menus and lock screen
Status: In Progress (implemented & build-verified, pending device QA) Reported by: User Area: Tasks / Calendar / Matrix / Notifications
Problem
Long-pressing a task/event in Today, Matrix, or Calendar required going into the date editor or using only coarse "+1 day" actions. Lock-screen task reminders also only supported "Mark Complete". The user wanted standard task-manager snooze choices directly from the pressed item and notification actions.
Fix (app task menus)
- Added a shared Postpone submenu to
TaskMenuItems:- Snooze 15 Minutes
- Snooze 30 Minutes
- Snooze 1 Hour
- Snooze 2 Hours
- Tomorrow
- Wired the submenu through:
- Today timeline + overdue/upcoming sections
- Matrix grid + quadrant detail sections
- Calendar day timeline
TaskViewModel.postpone(_:minutes:)applies the nudge:- existing reminder → reminder moves forward
- due date with no reminder → due date/time moves forward and becomes timed
- no date/reminder → creates a reminder
Fix (lock screen notifications)
- Task reminder notifications now include actions:
- Snooze 15 min
- Snooze 30 min
- Snooze 1 hour
- Snooze 2 hours
- Notification response handler updates the stored task reminder and schedules a one-off snoozed notification for the selected delay.
Files: TaskContextMenu.swift, TaskItem.swift, TodayView.swift,
MatrixView.swift, CalendarView.swift, NotificationManager.swift.
How to test
- Long-press a task in Today → Postpone → 15 Minutes; reminder/due time moves.
- Repeat from Matrix grid, Matrix detail, and Calendar day timeline.
- Open Move from the same menu → only the other three quadrants are listed (see KC-25).
- Trigger a task reminder notification on device → verify 15/30/60/120 minute lock-screen actions appear.
- Tap a lock-screen snooze action → task reminder updates and notification re-delivers after the selected interval.
KC-23 — "Pick Date" everywhere + sensible time-based reminders
Status: In Progress (implemented & build-verified, pending device QA) Reported by: User Area: Tasks / Matrix / Calendar / Reminders
Problem
The Date submenu offered "Pick Date" only in the Today view — the Matrix grid cards and the Calendar reschedule/move menu were missing it. Separately, the reminder lead-time options were day/week based ("2 days early", "1 week early"), which is unrealistic for timed events; the user wanted the standard time-based set.
Fix
- "Pick Date" (and Edit) now appear in the Matrix grid cards and the Calendar day
timeline menu — both open the shared
TaskEditSheetvia a neweditingTasksheet (QuadrantCardgained anonEdit; Calendar passesonEditinto the task menu). - Reworked the reminder picker (
TaskDatePickerSheet, shared by every entry point) from day-offsets to minutes before the event time: None, On time, 5 min, 30 min, 1 hour, 1 day, plus a Minutes/Hours/Days custom wheel. Existing absolutereminderDates migrate to the nearest minute offset on open.
Files: MatrixView.swift, CalendarView.swift, TodayView.swift.
How to test
- Long-press a task in Matrix and in Calendar → Date → "Pick Date" is present.
- Create/edit a task → reminder options read On time / 5 min / 30 min / 1 hour / 1 day / Custom, relative to the event time.
- Custom → Minutes/Hours/Days toggle + count wheel commits the right offset.
KC-24 — App rebrand to "Wenza"
Status: In Progress (display name + copy done; App Store name set in ASC) Reported by: User Area: Branding
Problem
The app is being rebranded from "Kisani Cal" to Wenza.
Fix
CFBundleDisplayName→ "Wenza" (app) and "Wenza Widgets" (widget) inproject.yml; permission prompts and in-app copy (sidebar, Auth, Onboarding, Settings, tutorial, widget description) updated to Wenza.- Deliberately unchanged to preserve App Store identity and user data: bundle
ID
com.kutesir.KisaniCal, App Groupgroup.com.kutesir.KisaniCal,kisani.tasks.v2.*storage keys, team ID, and internal Xcode target/project names. - App Store listing name set separately in App Store Connect (ships with the next version).
Files: project.yml, ContentView.swift, TutorialManager.swift, AuthView.swift,
OnboardingView.swift, SettingsView.swift, KisaniCalWidgets.swift.
How to test
- Build & run → home-screen label and all in-app titles read "Wenza".
- Confirm tasks/widget data survive (App Group + keys unchanged).
KC-25 — Matrix "Move" lists only the other quadrants
Status: In Progress (implemented & build-verified, pending device QA) Reported by: User Area: Tasks / Matrix
Problem
The Move submenu listed all four quadrants with a checkmark on the current one, so the quadrant a task already sat in appeared as a no-op option.
Fix
TaskMenuItemsMove submenu now excludes the task's current quadrant and lists only the other three, using the clear matrix labels (e.g. a task in Urgent & Important shows Not Urgent & Important, Urgent & Unimportant, Not Urgent & Unimportant). Shared across Today, Matrix, and Calendar.
Files: TaskContextMenu.swift.
How to test
- Long-press a task in any quadrant → Move shows only the three other quadrants.
- Pick one → task moves (priority/urgency updated per the TickTick model).
KC-26 — Bars widget: auto-select advances to the next event
Status: In Progress (implemented & build-verified, pending device QA) Reported by: User Area: Widgets
Problem
With "Upcoming queue" on + "Nearest to finish", the Event Countdown (Bars) widget stayed stuck on an event after it expired (e.g. a passed deadline) instead of rolling to the next-nearest. The selection was frozen: the timeline computed the chosen event once and reused it for all 24 pre-rendered entries, only refreshing every 12 hours.
Fix
- Each timeline entry now re-resolves the nearest-to-finish event as of that
entry's own time (
upcomingEvents(asOf:)filtersdueDate > entryDate), so the moment the current event expires the next entry rolls to the next one. - The timeline refreshes the instant the current selection expires
(
currentSelectionExpiry), not 12 hours later, giving the next event a fresh full pre-render window. - Completing/closing a task already reloads widget timelines, and the recompute drops it and picks the next.
Follow-up — tracked events now advance too
The first pass left the tracked ("Track Countdown") path pinned: a completed
or expired tracked event stayed on screen. Fixed with trackedTask(asOf:), which
returns the tracked event only while it's live (not complete, and recurring or
still future); once done/past (non-recurring) the widget falls through to the
next-nearest. Recurring tracked events still roll forward, and the refresh
boundary now includes the tracked event's expiry.
Files: EventCountdownWidget.swift.
How to test
- Queue on + Nearest to finish; let the nearest event's due time pass → widget rolls to the next-nearest without manual action.
- Complete the nearest event → widget advances to the next.
- Track an event, then complete it → widget advances to the next-nearest.
KC-27 — Today ⋯ menu: Background, Group & Sort, Select
Status: In Progress (implemented & build-verified, pending device QA) Reported by: User Area: Tasks / Today
Problem
Three items in the Today ⋯ menu were empty placeholders: Background, Group & Sort, and Select.
Fix
- Background: sheet with 5 themes (Default/Warm/Cool/Mono/Paper) applied as a
subtle gradient to the Today background; persisted via
@AppStorage. - Group & Sort: group by Date (default)/Priority/Category/Quadrant and sort by
Smart/Due/Priority/Title. Non-date groupings render as titled sections reusing
UpcomingSection. - Select: multi-select sheet with Select All / Deselect All and a bulk action bar (Complete / Priority / Move / Delete).
- New file
TodayMenuFeatures.swift; addedTaskViewModel.allActiveRowsso grouping/selection use the same single-occurrence task set (incl. undated).
Files: TodayMenuFeatures.swift, TodayView.swift, TaskItem.swift.
How to test
- ⋯ → Background → pick a theme; Today background updates and persists.
- ⋯ → Group & Sort → Priority/Category/Quadrant regroups the list.
- ⋯ → Select → Select All → Complete marks all done.
KC-28 — Clearer Snooze labels on notifications and task menus
Status: In Progress (implemented & build-verified, pending device QA) Reported by: User Area: Notifications / Tasks
Problem
The lock-screen reminder actions and the in-app Postpone submenu showed bare durations ("15 min", "30 min", …) that didn't say what they do. iOS notification buttons are a flat list and can't nest, so a single expanding "Snooze" isn't possible — the labels must carry the meaning.
Fix
- Lock-screen actions: "Snooze 15 min / 30 min / 1 hour / 2 hours" (+ Mark Complete).
- In-app Postpone submenu: "Snooze 15 Minutes / 30 Minutes / 1 Hour / 2 Hours".
Files: NotificationManager.swift, TaskContextMenu.swift.
KC-29 — Lock-screen Progress Dots widget legibility
Status: In Progress (implemented & build-verified, pending device QA) Reported by: User Area: Widgets
Problem
The Progress Dots widget in the lock-screen accessoryRectangular family packed 100 tiny dots into a small pill over a custom dark background, which the lock screen's vibrant rendering washed out — it looked faint / not loaded.
Fix
- accessoryRectangular: 28 larger dots (vs 100),
.primarycolor for the vibrant tint, andAccessoryWidgetBackground()for the standard frosted pill. Home Screen small/medium/large keep the 100-dot orange-on-dark grid.
Files: WidgetViews.swift.
KC-30 — Period milestone notifications (day / week / month / year)
Status: In Progress (implemented & build-verified, pending device QA) Reported by: User Area: Notifications
Problem
The user wanted recurring "one more period passed" nudges (Pretty Progress style): day, week, month, and year, all at midnight.
Fix
schedulePeriodMilestones()schedules recurringUNCalendarNotificationTriggers, all firing at midnight (00:00):- Today — "One more day passed." — every day
- This Week — "One more week passed." — Monday 00:00 (weekly)
- This Month — "One more month passed." — 1st at 00:00 (monthly)
- This Year — "One more year passed." — Jan 1 at 00:00 (yearly)
- Fixed identifiers + remove-then-add keep them de-duplicated; runs via the
existing
reschedule(...)path when authorized; gated by thekisani.periodMilestonesflag (default on). - Pending: no in-app Settings toggle yet (see KC-31 #2).
Files: NotificationManager.swift.
How to test
- Authorize notifications; at midnight a "One more day passed." notification fires (and the week/month/year equivalents on their boundaries).
KC-31 — Audit fixes: lock-screen sync, live urgency, batch postpone
Status: Fixed items build-verified; pending items remain open (see below) Reported by: User (code review) Area: Notifications / Tasks / Matrix
Reviewed items & outcomes
Fixed
- Lock-screen Mark Complete / Snooze now sync + reload.
markTaskCompleteandsnoozeTaskpreviously wrote only toUserDefaults.kisani. They now alsoCloudSyncManager.shared.push(...)(→ iCloud / Apple Watch) andWidgetCenter.shared.reloadAllTimelines(). - Notification copy uses live Matrix urgency, not stored quadrant.
reschedule(...)snapshots urgent task IDs from the computeddisplayQuadrantand threads them intoscheduleTasks/notifBody, so "Urgent — tap to mark complete" and the evening check-in reflect current urgency rather than a stale stored fallback. - Batch
postponeAllOverduematches single postpone. It now clearsurgencyOverrideand re-syncs the stored quadrant per task.
Files: NotificationManager.swift, TaskItem.swift.
Pending (not closed)
- #2 — Period-milestone Settings toggle. Currently only the
kisani.periodMilestonesUserDefaults flag (default on); no in-app UI to turn the day/week/month/year notifications on/off. Pending. - #6 — Configurable Matrix thresholds & snooze durations.
urgentWindowDays(3),categoryUrgentWindowDays(14), and the 15/30/60/120-min snooze actions are hardcoded constants — fine for now, but no settings for TickTick-style customization. Pending. - Note (#1) — Matrix "Move" lists only the other 3 quadrants. This is the intended KC-25 behavior, not a defect; left as-is unless reversed.
KC-32 — Calendar Day / 3-Day / Week / List views not showing tasks & events
Status: Fixed (build + unit-test verified) Reported by: User Area: Calendar
Description
Tasks/events only appeared in the Month view. Day and 3-Day showed an empty timeline; Week showed abstract colored bars with no titles; the List/task-list views missed recurring tasks.
Root cause
cvTimeItems(date)iterated rawtaskVM.tasks(no recurrence) and dropped any untimed task viaguard h != 0 || m != 0(midnight = "no time" → discarded).- Week rendered
eventDots(colors only); List and the task-list toggle used rawtasksinstead ofoccurrences(on:).
Fix
cvTimeItemsnow usesoccurrences(on:)+ the realhasTimeflag; added an all-day band (cvAllDayBand/cvAllDayItems) for untimed tasks, all-day events, and the day's workout.- Week lists real titles (up to 4/day + "N more") via shared
cvAgendaItems. - List view and
calDayTaskListViewswitched tooccurrences(on:).
Files: CalendarView.swift.
KC-33 — Calendar month grid: weekday header & week numbers misaligned to firstWeekday
Status: Fixed (17 unit tests, UI-verified) Reported by: User (release-blocking, data integrity) Area: Calendar
Description
Dates appeared under the wrong weekday labels (e.g. June 1 2026 — a Monday — shown under "S"). A data-integrity bug, not visual.
Root cause
The grid is built from .weekOfMonth (respects Calendar.current.firstWeekday),
but the weekday header was hardcoded Sunday-first (["S","M","T","W","T","F","S"])
and the week-number label was gated on isSunday (weekday == 1). On any
non-Sunday-first locale / "Start Week On = Monday", every column was mislabeled by
one and the W-number landed on the wrong column.
Fix
- Extracted pure, testable date math into
CalendarGrid(monthGrid+weekdaySymbols);gridDays()and the year-view mini-month dedupe to it. - Header now derived from
firstWeekday, so it always matches the grid. - Week-number label gated on
isWeekStart(weekday == firstWeekday). Documented that week numbers are localeweekOfYear(consistent with layout), not ISO.
Files: CalendarGrid.swift (new), CalendarView.swift, ContentView.swift
(DEBUG-only KISANI_INITIAL_TAB launch hook, compiled out of Release).
Verification
New KisaniCalTests target — 17 tests passing on simulator: exact Sunday-first
June 2026 grid, header↔grid alignment for firstWeekday 1–7, cell identity, month
navigation (May/Jul/Feb 2026 + Feb 2028 leap + Dec 2026→Jan 2027), event-day
bucketing (all-day / late-night / midnight-crossing) across 4 timezones, and
recurring-task occurrence expansion. Verified in the running app.
KC-34 — Confirm before completing a future-dated task
Status: Fixed (build-verified) Reported by: User Area: Tasks
Description
Tapping the circle on a task in "Next 7 Days" / "Later" silently completed it — easy to do by accident.
Fix
TaskRowView now shows a confirmation dialog before completing a task whose due
date is after today (isFutureTask). Today/overdue tasks still toggle instantly,
and un-completing never prompts. Applies everywhere TaskRowView is used.
Files: TodayView.swift.
KC-35 — Visual cleanup: slimmer checkbox + remove decorative emoji
Status: Fixed (build-verified) Area: Today / Widget / Settings / Notifications
Fix
- Task checkbox: 22pt filled donut → 18pt open ring (1.5pt), no fill — quieter, matches the restrained brand. Tap target kept at 36×44.
- Removed decorative emoji: 🎉 (My Tasks widget empty state),
𝕏 👾 📸(Settings → Follow Us),✓(notification action title + "Done" body).
Files: TodayView.swift, MyTasksWidget.swift, SettingsView.swift,
NotificationManager.swift.
KC-36 — Version display reads from the bundle (no hardcoded "v1.0.0")
Status: Fixed (build-verified) Area: Settings / Build
Description
About row and About sheet hardcoded "v1.0.0" while the app was 2.0 — drift.
Fix
Added a Bundle extension (marketingVersion / buildNumber / versionDisplay);
both spots now read from the bundle (driven by project.yml). Build set to
2.0 (9).
Files: SettingsView.swift, project.yml.
KC-37 — Quick-add recurrence: detect "everyday" and "every weekday"
Status: Fixed (6 parser tests) Reported by: User Area: Tasks / Natural-language parsing
Description
Typing "remind me to pray everyday" didn't set a Daily recurrence.
Root cause
parseRecurrence required a space (\bevery\s+day\b), so "everyday" (one word)
was missed; "every weekday" wasn't handled.
Fix
\bevery\s*day\b now matches both "every day" and "everyday"; added "each day",
and "Every Weekday" detection ("every weekday" / "weekdays") ahead of the broader
weekly/daily patterns. Sub-day cadence ("every hour" / "hourly") is intentionally
not matched — the occurrence engine is day-granular, so it stays a one-off
rather than being mislabeled.
Files: TodayView.swift (NLTaskParser). 6 parser tests added.
Pending (follow-up)
- Hourly / sub-day recurrence is unsupported by the day-granular occurrence engine. Would need an engine + repeating-notification extension. Pending.
KC-38 — Voice input: add tasks by speaking
Status: Implemented (committed eb7d563)
Area: Tasks / Quick-add
Description
The quick-add mic button is now wired to live, on-device speech recognition so a task can be dictated instead of typed.
Implementation
- New
SpeechRecognizer(Speech+AVFoundation):SFSpeechRecognizerdriven byAVAudioEngine, publishingtranscript/isRecording/error. Requests speech + microphone authorization, then streams partial results. TodayViewquick-add: mic button callsspeech.toggle()(mic → mic.fill, accent + pulse while recording);onChange(of: speech.transcript)binds the text into the task field, so dictation flows through the sameNLTaskParser— spoken dates/recurrence (e.g. "remind me to pray everyday") parse exactly like typed input (see KC-37). Permission errors surface inline.- Usage strings added:
NSMicrophoneUsageDescription,NSSpeechRecognitionUsageDescription.
Files: SpeechRecognizer.swift (new), TodayView.swift, project.yml.
Notes / follow-up
- Recognition locale is
.current; no manual language picker. - Contains
print("[VOICE] …")debug logging — harmless, could be removed before release. Minor.
Follow-up fix — transcript never appeared in the field
Status: Fixed (simulator-verified)
The mic captured speech and the transcript reached rawText, but the text never
showed in the Quick Add field. Root cause: NLTaskField.updateUIView
(UIViewRepresentable over UITextView) had guard coordinator.isEditing else { …return } — so when the field wasn't focused (the normal case when tapping the
mic), externally-set text (voice) was dropped and the placeholder stayed. Fixed
to render non-empty text even when not editing (caret moved to end). Verified on
simulator by pushing text through the real speech.transcript path: "Buy milk
tomorrow" → field shows it, date chip = Tomorrow; "Call Romeo at 11 AM" → field
shows it, chip = Today 11:00.
Not exercised on the sim (no microphone / no UI-tap automation): live mic capture
and the literal Save tap. The save path is the same submit()/addTask used by
all typed tasks, so it's covered by normal task creation.
Added DEBUG-only test hooks (KISANI_AUTOADD, KISANI_VOICE_SIM) to inject a
transcript on the simulator; #if DEBUG, compiled out of Release.
Files: TodayView.swift (NLTaskField.updateUIView), ContentView.swift.
KC-39 — Workout state lost on reinstall/update (incomplete iCloud restore)
Status: Fixed (build-verified) Reported by: User Area: Workout / iCloud sync
Description
Workout state was lost after app updates / fresh installs.
Root cause
save() pushes 7 workout keys to iCloud KVS (programs, schedule, activePid,
completedDates, history, missedDates, compensations), but WorkoutViewModel.init
only restoreIfMissing(...) for the first four. So history, missed dates, and
rest-day compensations were uploaded to iCloud but never restored on a fresh
launch — silently lost on reinstall/update.
Fix
Added the three missing restoreIfMissing calls (history, missedDates,
compensations) so restore is symmetric with save. (Per-day lastActiveDay is
transient set-reset state and intentionally not synced.)
Files: ExerciseModels.swift.
Note
Cross-device live refresh while the app is open isn't wired (WorkoutViewModel
doesn't observe kisaniCloudDataRefreshed) — out of scope; the restore-on-launch
path is what preserves state across updates.
KC-40 — Workout streak resets on a missed day (should be a lifetime tally)
Status: Implemented (not build-verified — sim runtime unavailable) Reported by: User Area: Workout
Description
The profile showed a streak of 1 even after 5 workouts that week — a single
skipped/rest day reset it. User wants the count to only ever grow: every
workout achieved counts, a 4/5 week and a 5/5 week both count fully, misses
never subtract.
Change
streakDays is now a lifetime tally of distinct workout days
(Set(workoutDates).count, totalAchievedWorkoutDays). Removed the
consecutive/weekly-goal chain. Settings copy updated (goal no longer "maintains"
a breakable streak). Unit tests in StreakLogicTests (4/5+5/5=9, blank-week,
duplicates, skipped-day, empty) — algorithm also verified via standalone harness.
Files: ExerciseModels.swift, SettingsView.swift, KisaniCalTests/StreakLogicTests.swift.
KC-41 — 7-day bars empty & "this week" = 0 despite a nonzero done rate
Status: Implemented (not build-verified) Reported by: User Area: Tasks / Profile stats
Description
The profile's "COMPLETED LAST 7 DAYS" bars stayed flat and "this week" showed 0
even with completed tasks, because only one-off completedAt tasks were counted
— recurring occurrences were ignored.
Change
Added TaskViewModel.completionCount(on:) / completions(on:) that count
recurring occurrences (completedOccurrences) plus one-offs. Profile bars,
"this week", and a new "day streak" cell now use them.
Files: TaskItem.swift, SettingsView.swift.
KC-42 — No way to browse historical task/workout activity
Status: Implemented (not build-verified) Reported by: User ("we need to be able to dig through the data") Area: Tasks / Workout / History
Change
New ActivityHistoryView: a 90-day, day-by-day archive of completed tasks (with
times, recurring markers), workout logs (sets + volume), and missed / made-up
days. Opened via a "History" button on the profile Tasks card.
Files: ActivityHistoryView.swift (new), SettingsView.swift.
KC-43 — Today-list checkbox color duplicates the row accent
Status: Implemented (not build-verified) Reported by: User Area: Tasks / Today
Change
TaskRowView checkbox stroke changed from task.quadrant.color to neutral
text3 — the row already carries its color on the left accent bar and the
category chip. Calendar day-list checkbox left colored (no accent bar there).
Files: TodayView.swift.
KC-44 — Workout streak banner should show week, then year, then comparison
Status: Implemented (not build-verified) Reported by: User Area: Workout
Change
StreakBannerView is now a swipeable 3-page TabView: (1) this week + 7 day
ticks, (2) this year + 12 month ticks + all-time total, (3) this-week-vs-last-week
delta. Added VM breakdowns: workoutsThisWeek/LastWeek/ThisYear,
currentWeekDays, currentYearMonths.
Files: WorkoutView.swift, ExerciseModels.swift.
KC-45 — Calendar year view should scroll continuously (TickTick-style)
Status: Implemented (not build-verified) Reported by: User Area: Calendar
Change
Replaced the single paged year (◀ 2026 ▶, dead space below December) with an
infinite LazyVStack of years, anchored on the current year (accent-colored).
The top header now follows the scrolled-to year via a YearTopKey preference
(iOS 16-safe). Removed navigateYear + the year nav header.
Files: CalendarView.swift.
KC-46 — Calendar month view: TickTick bars + collapse (REVERTED)
Status: Reverted (user preferred the original) Reported by: User Area: Calendar
Notes
Implemented TickTick-style day bars, collapse-to-week on selection (tap/drag),
and a full-column selection highlight (commits 2ec3278, bd43eec). User
preferred the original month view, so both were reverted (commits 0a87769,
e8d74d4). Month view is back to dots + full grid + agenda below.
Files: CalendarView.swift.
KC-47 — Calendar week view should be a TickTick-style timeline
Status: Implemented (not build-verified) Reported by: User (screen recording) Area: Calendar
Change
Replaced the mini-month + agenda-list split with a 7-day timeline (hours down
the left, one column per weekday, events as positioned blocks) reusing
cvTimeGrid/cvDayColumnHeader. Added:
- Drag-to-reschedule: task blocks drag vertically to change time, snapped to
15 min (
CVTimeBlock,rescheduleTask; events + recurring tasks stay fixed). - Layout toggle: timeline ⇄ grid of day-cards (
weekGrid,weekGridLayout). - Week-swipe navigation (
navigateWeek).
Known gaps: drag moves time only (not across days); calendar events not draggable.
Files: CalendarView.swift.
KC-48 — Week / 3-day views float to the bottom (dead space on top)
Status: Fixed (not build-verified) Reported by: User Area: Calendar / Layout
Root cause
The timeline views are a thin day-header + a greedy ScrollView (≈0 ideal
height), so the top-level VStack shrank and the ZStack(alignment: .bottomTrailing) floated it to the bottom, leaving a large empty gap above the
header. Month view has a tall fixed grid, so it never surfaced.
Fix
Pinned the top-level content VStack with
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top).
Files: CalendarView.swift.
KC-49 — Week/3-day dead space persisted after KC-48
Status: Fixed (not build-verified) — supersedes KC-48 Reported by: User ("DEAD SPACE STILL EXISTS") Area: Calendar / Layout
Root cause (refined)
Pinning only the outer container (.frame(maxHeight: .infinity), KC-48) merely
allows fill — it doesn't force it. The timeline ScrollView returned its
content size instead of expanding, so the week/3-day root stayed short and the
.bottomTrailing ZStack floated it down, leaving the day header stranded mid-
screen. Day view happened to fill, so it never showed.
Fix
Force the timeline ScrollView to be greedy with
.frame(maxWidth: .infinity, maxHeight: .infinity), and pin each timeline
view's root VStack to fill + top-align. Applied to weekView and
threeDayView.
Files: CalendarView.swift.
KC-50 — Long-press on a timeline event has no actions
Status: Implemented (not build-verified) Reported by: User Area: Calendar / Tasks
Description
Long-pressing a task block on the day/week/3-day timeline did nothing — it should offer the same actions as elsewhere (Complete, Snooze, reschedule, etc.).
Change
CVTimeBlock now takes @ViewBuilder menu content and shows it via
.contextMenu. cvBlockMenu(for:) builds it for task-backed items: a Complete
toggle, a Snooze submenu (15 min / 1 hr / tomorrow), then the shared
TaskMenuItems (pin, reschedule, move, priority, category, edit, delete).
Events (no taskID) get no menu.
Files: CalendarView.swift.
KC-51 — Health & Workout Analytics system (12-month, immutable reports) — PLAN
Status: Planning (blocked on 2 decisions + build-verification environment) Reported by: User (/goal) Area: Analytics / Persistence / HealthKit / Notifications
Reusable existing functionality (survey)
WorkoutViewModel:history: [WorkoutDayLog],workoutDates,schedule,missedDates,compensations;StatPeriod/WorkoutStat/StatBucketaggregation already exists (stat(period:offset:),statBuckets).WorkoutDayLog→LoggedExercise→LoggedSet(weight, reps, done)with computedvolume(Σ weight×reps for done sets),totalSets,doneSets.HealthKitManager: steps, active calories, resting HR, workoutsThisWeek,fetchWorkoutDates(from:to:),saveWorkout,sumQuery/latestQuery.NotificationManager: categories/actions, foregroundreschedule(...), andschedulePeriodMilestones()(week/month/year-close nudges) — extend this.- Lifecycle reconcile pattern already used:
checkPendingMissed/checkPendingHealthConfirmononAppear+scenePhase == .active. - Persistence:
UserDefaultsmirrored to iCloud KVS (CloudSyncManager).
Persistence evaluation (SwiftData / Core Data / current)
- Current (JSON blobs in UserDefaults + iCloud KVS): iCloud KVS has a hard 1 MB total ceiling (see KC-39 context). 12 months of daily snapshots + HealthKit reference cache will exceed it → silent sync failure. Not viable as the analytics store.
- SwiftData: cleanest modelling/migration, but requires iOS 17+. App deploymentTarget is iOS 16.0 → SwiftData is UNAVAILABLE unless the min target is raised (drops iOS 16 devices — a product call).
- Core Data: works at iOS 16, unbounded local storage, supports lightweight migration; more boilerplate. Local-only (analytics need not sync via KVS; reports are device-derived and reconstructable).
Proposed architecture (pending the decision below)
- Stores:
WorkoutRecordStore(app-generated, mirrorsWorkoutDayLog) +HealthReferenceStore(HealthKit cache; HealthKit = source of truth) +ReportStore(immutable daily snapshots, weekly, monthly). Keep app records and HealthKit reference separate; analysis reads across both. - Immutability: reports store a frozen snapshot at generation; later edits to source data never mutate a generated report. Retention: prune raw beyond 12 months; keep report summaries.
- Analysis engine (
AnalyticsEngine): pure, no SwiftUI/persistence deps, deterministic. Formulas documented: volume = Σ(weight×reps) of completed sets; %Δ = (cur−base)/|base| guarded for base≈0/new/rest; consistency = sessions ÷ scheduled over window; trend = classify(slope, threshold) → improve / decline / plateau / insufficient-data. Handles missing/partial HK, deloads, outliers (winsorize/IQR), unit compatibility, divide-by-zero. Non-medical language + disclaimer; never diagnoses. - Comparisons: daily vs previous comparable day + same weekday −1 week; weekly vs previous completed week; monthly vs previous month.
- Reconciliation: idempotent generators keyed by period id; on launch/active, backfill any missing completed week/month; never duplicate reports or notifications. Do not depend on exact BG execution.
- Notifications: extend
schedulePeriodMilestones(); one permission-aware, user-scheduled local notification per weekly/monthly report; deep-link viauserInfo→ route to the specific report (new deep-link cases). - UI (
Analyticsarea): Overview · Daily comparison · Weekly reports · Monthly reports · 12-month trends · Per-exercise history. Full loading / empty / denied-permission / partial-data / error states; Dynamic Type, VoiceOver, light/dark, compact/regular; Wenza design tokens; charts only where they aid understanding; non-color status indicators. - Tests: date boundaries (daily/weekly/monthly), calendar/locale/firstWeekday/ timezone/DST, volume/%/consistency/trend math, missing/partial HK, migration/ dedup/reconcile/retention, exercise identity + unit compat, report+notification idempotency, deep-link routing.
BLOCKERS (must resolve before build-verified completion)
- Build/test verification is impossible in this environment. The iOS 26.5 simulator runtime was removed this session and CLI builds are broken, so completion conditions #10 (tests pass) and #11 (Xcode build succeeds) CANNOT be verified here. Per "never claim verification not performed", any code produced now is inspection-only until a runtime is restored / built on device.
- Persistence + min-iOS decision (irreversible): SwiftData needs iOS 17 (app is iOS 16). Migrating live workout history to a new store is a one-way data migration. Needs the founder's call before implementation.
Next action
Awaiting decision on persistence backend / deployment target, then implement the
pure AnalyticsEngine + models + tests first (most inspection-verifiable), UI
last. Device/build QA tracked here.
KC-51 progress — Phase 1: analysis engine (DONE, engine-verified)
- Added pure, deterministic
AnalyticsEngine+ value-type models (AnalyticsModels.swift) — no SwiftUI/persistence/HealthKit deps. - Formulas documented in-source: volume, %change (guarded), consistency, trend.
- Honest degradation: nil/zero/tiny baseline → no %; new exercise / rest day → not meaningful; multi-point trend vs least-squares slope; winsorized outliers; unit-dimension compatibility; divide-by-zero guarded.
- Calendar helpers take an injected
Calendar→ locale/firstWeekday/TZ/DST-safe. AnalyticsEngineTests.swift(35 cases). Verified independently: compiled withswiftcand ran a 42-assertion harness → all pass (Xcode test target not runnable here — no sim runtime; same assertions live in the XCTest file).- Next: Phase 2 file-based persistence (report/record stores), adapters from
WorkoutDayLog+ HealthKit, reconciliation, retention, dedup — with tests.
KC-51 progress — Phase 2: persistence + reconciliation (DONE, store-verified)
AnalyticsStore(file-based JSON under Application Support): immutable frozen daily snapshots + immutable weekly/monthly reports (writeIfAbsent → idempotent, deduped by period key), retention prune (>12mo), no raw HealthKit persisted (only derived reference values in DaySample).- Engine reconciliation selectors:
completedWeekStartKeys,completedMonthKeys,missingKeys(idempotent — already-generated periods skipped). AnalyticsStoreTests(6 cases). Verified independently: compiled store + engine with swiftc and ran a 23-assertion temp-dir harness → all pass.- Next: Phase 3 app integration — adapter (
WorkoutDayLog+ HealthKit → DaySample), reconcile coordinator on launch/active, notifications + deep links. Phase 4: Analytics UI (6 areas + states + accessibility). Both require an Xcode build / device to verify (no sim runtime here).
KC-51 progress — Phase 3: adapter + reconcile coordinator + deep links (verified)
WorkoutAnalyticsAdapter(pure):WorkoutDayLog/HealthKit primitives →DaySample; exercise reduction (identity + volume), inclusive day-key ranges, week/month key sets. Rest/missing days become honest empty samples.AnalyticsCoordinator(injectable sample providers → testable): backfills every missing completed week/month, saves immutably (dedup), prunes retention. Idempotent — a second run generates nothing.AnalyticsDeepLink:wenza://analytics[/weekly/<key>|/monthly/<key>]with symmetric URL/string encode-decode for report notifications.- Bug caught + fixed by tests:
completedWeekStartKeysincluded a boundary week thatprunethen deleted → the report regenerated (and would re-notify) on every launch. Aligned generation to the retention cutoff → idempotent. - Tests:
AnalyticsAdapterTests,AnalyticsCoordinatorTests. Verified standalone via swiftc (adapter 17 + coordinator/deeplink 10 assertions, all pass). Total analytics assertions verified standalone: ~90.
KC-51 — remaining (device/Xcode-build required; NOT verified here)
- App wiring: inject real providers (adapter + WorkoutViewModel + HealthKit) into
the coordinator; call
reconcile()on launch +scenePhase == .active(reuse existing checkPending* pattern). App-coupled — needs Xcode build. - Notifications: extend
NotificationManager.schedulePeriodMilestones()to fire one permission-aware weekly + monthly local notification carrying anAnalyticsDeepLinkinuserInfo; route it on tap. - Analytics UI (6 areas: Overview / Daily / Weekly / Monthly / 12-month trends / Per-exercise) + loading/empty/denied/partial/error states + Dynamic Type, VoiceOver, light/dark, size classes. SwiftUI — not build-verifiable here.
- BLOCKER unchanged: no iOS runtime in this env → cannot run the Xcode test target or build (goal conditions #10/#11, and thus UI #8/#9). Restore a runtime, then wire + build + run the suite incrementally.
KC-51 progress — Phase 4a: app integration wiring (NOT build-verified)
AnalyticsService(@MainActor): owns the store, builds the coordinator from aWorkoutViewModelsnapshot (history/workoutDates/schedule/missedDates → RawWorkoutDay → DaySample), freezes recent daily snapshots, exposes UI reads (weekly/monthly reports, 12-month volume trend, daily comparison, per-exercise history). HealthKit per-day historical reference left nil (honest) — follow-up.- Lifecycle:
ContentViewcallsAnalyticsService.shared.reconcile(using:)+NotificationManager.scheduleAnalyticsReports(...)on launch and scenePhase.active(reuses existing pending-action pattern). - Notifications:
scheduleAnalyticsReportsfires one permission-aware, setting-gated weekly + monthly notification with anAnalyticsDeepLinkin userInfo, deduped by fixed per-period id (remove-then-add). Tap captured in the UNUserNotificationCenter delegate →consumePendingAnalyticsDeepLink(). - ⚠️ NOT compiled/build-verified (no iOS runtime + app-module/SwiftUI deps). Time-of-day "user-scheduled" report notification (vs deliver-on-detect) and historical HealthKit reference are documented follow-ups.
KC-51 progress — Phase 4b: Analytics UI (NOT build-verified)
AnalyticsView(self-contained): 6 areas — Overview / Daily / Weekly / Monthly / 12-Month / Exercises — via a chip selector. ReadsAnalyticsService.- Status by BOTH color and glyph (arrow.up.right / arrow.down.right / equal / minus) — never color alone. Empty + partial ("not enough data") states.
- Swift Charts for 12-month volume (bar) + per-exercise volume (line).
- Informational non-medical disclaimer (
AnalyticsEngine.disclaimer) footer. - VoiceOver labels on badges/chips/charts; Wenza tokens (auto light/dark).
- Entry point: an Analytics
IButton(chart.line.uptrend) in the Workout header → presentsAnalyticsViewas a sheet (mirrors the existing history/stats). - Added Chart-friendly
MonthVolumePoint/ExerciseVolumePoint(tuples can't be keyed in Charts). - Self-review caught + fixed pre-emptively (no build here): tuple key-paths in
Chart(id:), accessibility-traits array literal,%@format string. - ⚠️ NOT compiled — SwiftUI + app-module deps; needs an Xcode build. Likely still has issues only a compiler will surface. Remaining UI polish: deep-link routing into the presented view, Dynamic-Type audit, denied-permission state for future HealthKit trends.
KC-51 progress — Phase 4c: deep-link routing end-to-end (core-verified)
AnalyticsDeepLinknowIdentifiable(id = url string) → usable with.sheet(item:). Round-trip re-verified standalone.- Notification tap → stores pending link (delegate) →
ContentViewconsumes it on launch +.active→ presentsAnalyticsView(initialLink:)opened to the weekly/monthly area. Completes goal condition #7 (report notifications deep-link to their report). Wiring not build-verified (SwiftUI/app deps).
KC-51 progress — Phase 4d: concurrency hardening (compile-risk reduction)
- Refactored
AnalyticsService: coordinator sample-building moved to nonisolated static functions over a capturedWorkoutSnapshotvalue, so the coordinator's escaping closures no longer call@MainActormethods (avoids a main-actor isolation error under strict concurrency). Verified@MainActor+static let sharedis the codebase's existing pattern (HealthKitManager). - Self-review confirmed the singleton/actor pattern matches the app; remaining
first-build risk is concentrated in
AnalyticsView.swift(SwiftUI Charts / FlowRow generics) — surface-level, not logic.
KC-51 progress — Phase 4e: HealthKit historical reference wired (core-verified)
HealthKitManager.dailyReference(from:to:): per-day steps/active-calories/ resting-HR over a range via 3 parallelHKStatisticsCollectionQuery(one bucketed query per metric — efficient over a year, not per-day fetches). Matches the file's existing@MainActor+ continuation query pattern.AnalyticsService.refreshHealthReference(): fetches ~13mo of reference, caches it, merged into everyDaySamplebuilt afterward. Called from the existing Health-syncTaskinContentView(aftersyncFromHealthKit), then reconciles so new/updated reports capture it.PeriodSummarygainedavgSteps/avgActiveCalories/avgRestingHR(nil when no reference data in the period — honest, never fabricated). Weekly/monthly cards inAnalyticsViewshow them when present.- Verified standalone (swiftc, 8/8): HK averages aggregate correctly when
present, stay nil when absent, workout metrics unaffected either way, reports
remain deterministic. Added
testSummarizeAggregatesHealthKitAveragesWhenPresenttestSummarizeHealthKitNilWhenAbsenttoAnalyticsEngineTests.
- This completes "available HealthKit trends" for weekly/monthly reports (goal requirement). HealthKit remains the sole source for these values — nothing is derived or estimated by the app.
KC-51 — status: all logic complete; awaiting first Xcode build (device, user-run)
Every core + integration piece is now written: engine, store, adapter, coordinator, deep links, HealthKit reference, notifications, and a 6-area UI. Pure-Swift pieces (~110 assertions total) are compiler-verified standalone; SwiftUI/app-module pieces are self-reviewed but never compiled (no iOS runtime in this environment). User will build on device next. Awaiting: first-build error list (expected — SwiftUI Charts/generics are the likely spots), then XCTest run, then close out goal conditions #10/#11 and any remaining polish.
KC-52 — Bring workout completion onto the main page (not buried in "…" menu)
Status: Implemented (not build-verified) Reported by: User Area: Workout
Description
"Mark All Complete" / "Clear All Sets" were hidden in the header's "…" menu. User wants completion controls front-and-center on the main Workout page, and a way to finish a workout even if not every set was ticked — since Health/Watch already independently confirms a workout happened, the app shouldn't gate "did you train today" on checking every box.
Change
Redesigned DotProgressCard (the "X of Y sets done" card, already at the top of
the main page) into a 3-state completion card:
- Pending: progress dots + a primary Finish Workout / Finish Anyway
button (captures today via
markWorkoutDone, regardless of set completion — honest capture, not gated on 100%), plus two secondary actions: Check all sets (mechanical, ticks every box) and Didn't train (marks missed). - Done today: green "Workout logged" state + Undo.
- Missed today: muted "Marked as not completed" state + Undo.
- Added
unmarkWorkoutDone(on:)/unmarkWorkoutMissed(on:)toWorkoutViewModel— clean "back to pending" without conflating done/missed. - Removed "Mark All Complete" from the header's "…" menu (now the card's primary action); kept "Clear All Sets" there as a distinct reset utility.
Files: WorkoutView.swift, ExerciseModels.swift.
KC-52 — follow-up: restraint, confirmation, clear (user feedback)
User pushed back on the first pass: the primary button was a permanently-filled accent pill ("giving AI," violates PRODUCT.md "nothing shouts / color earns its place"), "Finish Anyway" skipped confirming the % logged before overriding progress, and there was no quick way to undo an accidental "Check all sets."
- New
QuietAccentButtonStyle(SharedComponents.swift): outlined/tinted at rest, fills solid accent only while pressed — color as a response to touch, not a static decoration. Applied to the Finish button. confirmationDialogbefore finishing with partial sets: "Finish with X of Y sets logged?" + the logged % in the message. Skipped when already 100% (no need to ask about something already true).- "Check all sets" now flips to "Clear all sets" once every set is checked — the undo is the button itself, always one tap away, no separate menu needed.
Files: WorkoutView.swift, Components/SharedComponents.swift.
KC-52 — follow-up 2: drop the "seal" icon, confirm every destructive action
User: "whats with the ai tick also add clear all sets with a prompt to confirm on all these to avoid accidentals."
checkmark.seal.fill(a generic "verified badge" glyph, unused anywhere else in the app) replaced withcheckmark.circle.fill— the codebase's actual convention (TodayView, TaskContextMenu, etc.).- All four state-changing quick actions on the card now confirm first via a
single
PendingActionenum + oneconfirmationDialog(Finish Anyway, Check All Sets, Clear All Sets, Didn't Train) — each with its own message; Clear All / Didn't Train use.destructiverole. "Finish Workout" at 100% still skips the dialog (nothing to override). - The header's "…" menu Clear All Sets (works at any completion level, not just 100%) now also confirms before executing — previously instant/silent.
Files: WorkoutView.swift.
KC-52 — follow-up 3: Check/Clear all no longer auto-jumps to "done" (real bug)
User: "when you check all or clear all it shouldnt load the screen shot [the green 'Workout logged' card]. just show the workout widget."
Root cause
Not a display bug — setAllSetsDone(true) ("Check all sets"), toggleSet, and
setExerciseDone all still auto-called logWorkoutCompleted() the instant
every set hit done (doneSets == totalSets). That's pre-redesign behavior that
now directly fights KC-52: the whole point of the new card was to make "Finish"
a single, deliberate, confirmable action — but ticking the last checkbox (or
tapping "Check all sets") silently counted the day anyway, snapping the card
straight to the green done takeover the user never asked for.
Fix
Removed the auto-logWorkoutCompleted() call from all three sites
(toggleSet, setExerciseDone, setAllSetsDone). Checking sets — by any
path — now only ever updates progress; only the card's explicit "Finish
Workout"/"Finish Anyway" (→ markWorkoutDone) counts the day as done. "Check
all sets" now does exactly what it says: checks the boxes, nothing more.
Files: ExerciseModels.swift.
KC-53 — Add "This Year" to the Progress Dots lock-screen widget
Status: Implemented (not build-verified) Reported by: User Area: Widgets (KisaniCalWidgets)
Context
The "Progress Dots" widget (DayProgressWidget, kind KisaniDayProgress) is a
single configurable widget — long-press → Edit Widget lets you pick a mode.
It already supports Day, Week, and Month (the one the user said is
"already good") on the lock screen (.accessoryRectangular) and home screen.
Day and Week needed no code change — just add another widget instance and pick
that mode. Year was the only mode that didn't exist.
Change
Added .year to ProgressDotMode ("This Year"), a matching branch in
ProgressDotProvider.entry(for:at:) using Calendar.dateInterval(of: .year, for:) (same pattern as month), and a yearTitle(for:) formatter ("yyyy") —
mirrors day/week/month exactly.
Files: KisaniCalWidgets/KisaniCalWidgets.swift.
Note
Not build-verified (no iOS runtime here). To use: on the Lock Screen, tap the widget area → Add Widgets → Wenza → Progress Dots → long-press → Edit Widget → set Mode to This Day / This Week / This Year (This Month already configured).
KC-54 — Timed recurring tasks vanish from Today once their time passes
Status: Fixed (not build-verified) Reported by: User (screenshots: Calendar day view showed "Look into DPO certificate" 11 AM and "Read Azure 104" 5 PM; neither appeared anywhere in the Today tab — not in Today, not in Overdue, not in Completed)
Root cause
Two rules interacted badly for recurring tasks:
todayTasksdeliberately excludes timed tasks whose time has passed today ("belongs in overdue, not today").overdueTasksdeliberately excludes ALL recurring tasks (!recurs($0)), because it feedspostpone/postponeAllOverdue, which mutate a task'sdueDatedirectly by id — for a recurring task that field is the recurrence rule's anchor date, not just "today's occurrence." Including recurring occurrences there would let "Postpone All" shift/corrupt the whole series.
Net effect: once a recurring task's today-occurrence time passed, it was
excluded from Today (rule 1) but never picked up by Overdue (rule 2) — it fell
into a gap and disappeared from the Today tab entirely, while Calendar's day
view (occurrences(on:), which has no time-of-day gating) kept showing it
normally. Not a display bug — real data was correctly stored, just not
surfaced.
Fix
todayTasks' "past time → excluded" rule now only applies to non-recurring
tasks (!$0.isRecurring). A recurring task's today-occurrence always stays in
Today regardless of time — matching what Calendar already shows, and avoiding
the postpone/recurrence-anchor risk entirely (recurring rows are never routed
through overdueTasks). One-line, additive filter change; activeRows() /
occurrenceCopy already preserve isRecurring on the occurrence copy.
Files: TaskItem.swift.
KC-55 — Habit reminders: Prayer, Bed-making, Coffee/Caffeine
Status: Implemented (not build-verified) Reported by: User Area: Settings / Onboarding / Notifications
Description
Add three new opt-in daily local-notification habits, each toggleable in both Settings and Onboarding:
- Prayer — up to 4 independently-toggleable times a day (Morning 7:00, Afternoon 13:00, Evening 18:00, Night 21:00 by default). Message: "Have you had a chance to pray and give thanks?"
- Bed-making — a single daily nudge, default 8:00 AM (per the user's note that research shows it's an effective first win of the day).
- Coffee / Caffeine — up to 4 named times (Morning 8:00 on by default, Midday/Afternoon/Evening off by default) plus one custom-labeled reminder.
Design decisions
- Onboarding stays restrained: 3 simple master toggles (Prayer/Bed/Coffee)
with sensible pre-filled defaults — no per-slot time editors there, per the
app's "nothing shouts" principle and to avoid a long onboarding form. Full
per-slot control (and the custom coffee reminder) lives in Settings →
Habit Reminders, which onboarding points to via existing "change later
in Settings" copy. The SAME
UserDefaults.kisanikeys back both surfaces, so a toggle in onboarding is identical to the one in Settings. - Store correctness: discovered the existing
workoutCheckInEnabled/workoutConfirmEnabled/ etc.@AppStoragedeclarations have no explicitstore:, so they write toUserDefaults.standard— butNotificationManagerreads them viaUserDefaults.kisani(the App Group suite). That's a pre-existing mismatch (not fixed here — out of scope, flagging for a future look). All NEW keys for this feature explicitly usestore: UserDefaults.kisanieverywhere (Settings, Onboarding, and the reusableHabitSlotRow), matching the one place in the codebase that already does this correctly (kisani.periodMilestones) — so these reminders are guaranteed to actually fire. - All daily, not weekday-scoped: unlike workout notifications (tied to a
weekly schedule), these use a plain
UNCalendarNotificationTriggerwith only hour/minute set (no weekday) — fires every day, same pattern asschedulePeriodMilestones's "Every midnight" trigger.
Implementation
NotificationManager.scheduleHabitReminders(): reads all keys fromUserDefaults.kisani, schedules/cancels 9 possible daily notifications (bed ×1, prayer ×4, coffee ×4 + custom ×1) with fixed identifiers so re-scheduling is idempotent (no duplicates); explicitly cancels any previously-scheduled slot that's no longer wanted. Wired into the existingreschedule(workoutVM:taskVM:)orchestration (same place asschedulePeriodMilestones()), so it refreshes on every app foreground.- Settings: new "Habits" section → Habit Reminders sheet
(
HabitRemindersSheet), one card per habit, following the exact visual pattern of the existing Workout Settings sheet.HabitSlotRow(reusable, used 9×) is a named toggle + time-picker row that owns its own@AppStoragebacking via a manualinit(dynamic key per slot). - Onboarding: a new "Habits" card with 3
OnboardingHabitTogglerows (reusable, used 3×) right after the Workout section.
Files: NotificationManager.swift, SettingsView.swift, OnboardingView.swift.
Note
Not build-verified — no iOS runtime in this environment. Self-reviewed for
compile correctness (balanced braces/parens checked programmatically; the
manual @AppStorage backing-storage init in HabitSlotRow is the highest-risk
spot syntactically — standard, documented pattern, but worth a close look on
first build).
KC-56 — Habit reminders: "Done" notification action + private day count
Status: Implemented (not build-verified) Reported by: User (follow-up to KC-55: "should they appear in tasks, or stay reminders only?")
Decision
Discussed with the user: keep habit reminders (Prayer/Bed/Coffee, KC-55) OUT of Today/Matrix/Activity History — turning them into recurring TaskItems would add 3–9 extra daily checkboxes forever, against PRODUCT.md's "nothing shouts / data is primary" principle, and a prayer nudge isn't really a "deliverable." Middle ground: add a "Done" action directly on the notification (like the workout confirm flow's "Yes, log it") that logs a private timestamp for a lightweight day count — no task, no Activity History entry, nothing shown outside Settings.
Implementation
- New
HABIT_LOGnotification category +HABIT_DONEaction ("Done"), registered alongside the existing task/workout categories. - Every habit notification (bed, all 4 prayer slots, all 4 coffee slots + the
custom one) carries
userInfo["habitType"]= "bed"/"prayer"/"coffee" (slot- level detail collapses to the 3 habit TYPES — tapping Done on any prayer slot counts as "prayed today," not per-slot). logHabitDone(type:): appends today's date toUserDefaults.kisani["kisani. habit.doneDates.<type>"], deduped (idempotent — tapping Done twice same day is a no-op). Mirrors the "lifetime tally, never breaks" philosophy already established for the workout streak (KC-40), not a fragile consecutive streak.habitDoneCount(type:): reads the count back.- Settings → Habit Reminders now shows a quiet "Xd" badge next to each habit's title (Prayer/Bed/Coffee) once count > 0 — the only place this number appears.
Files: NotificationManager.swift, SettingsView.swift.
Note
Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/
parens checked programmatically; logHabitDone/habitDoneCount intentionally
left without @MainActor to match the existing nonisolated handler functions
(markTaskComplete, snoozeTask) they run alongside in the notification
delegate callback.
KC-57 — Build error: Color(r:g:b:) used outside its file (coffee brown)
Status: Fixed (not build-verified) Reported by: User (Xcode build errors: "Cannot convert value of type 'Int' to expected argument type 'Color'", "Extra argument 'b' in call")
Root cause
Color(r:g:b:) (Double) is declared as private extension Color inside
DesignTokens.swift — invisible outside that file by design (only UIColor's
init(r:g:b:) sibling is private too, both file-scoped). KC-55/56 used
Color(r: 122, g: 84, b: 52) directly in SettingsView.swift and
OnboardingView.swift for the coffee icon's brown background — neither file
can see the private initializer, so the compiler fell back to unrelated
overloads and produced the confusing "Cannot convert Int to Color" errors.
Fix
Added AppColors.coffeeBrown (defined inside DesignTokens.swift, where the
private init IS visible) alongside the other fixed accent colors
(accent/green/blue/yellow/red). Both call sites now reference
AppColors.coffeeBrown instead of constructing the color inline — matches
how every other color in the app is centralized, and is accessible from any
file.
Files: DesignTokens.swift, SettingsView.swift, OnboardingView.swift.
KC-58 — Custom coffee reminder row was unlabeled/confusing
Status: Implemented (not build-verified) Reported by: User (screenshot: last row in "Coffee reminders" just said "Coffee" — indistinguishable from the card's own title, no hint it's an editable custom slot)
Fix
- The custom-reminder row now has a small "✎ YOUR OWN REMINDER" label above it, so it reads as a distinct, nameable 5th slot rather than a redundant duplicate of the "Coffee reminders" card title.
- Default label changed from the confusing pre-filled "Coffee" to empty, so the field's placeholder ("Name it, e.g. "Second coffee"") actually guides the user instead of looking like a static, already-filled-in option.
NotificationManagerfalls back to "Coffee" for the notification title only if the user never actually typed a name (empty string), so an un-customized custom reminder still reads sensibly if enabled.
Files: SettingsView.swift, NotificationManager.swift.
KC-59 — "Complete & Stop Series" for recurring tasks (distinct from Delete)
Status: Implemented (not build-verified) Reported by: User: "for any reoccurring tasks in calendar and tasks view can we put a complete and also stop series and not delete — for example if i want to complete and stop a series from running"
Description
Recurring tasks previously had only two menu actions relevant here: toggle
today's occurrence (series continues forever), or Delete (erases the task
and its entire completedOccurrences history — no way to "wind down" a
series while keeping its record).
Change
TaskViewModel.completeAndStopSeries(_:on:): marks the given occurrence (default: today) complete, then setsrecurrenceEndto that day — whichisOccurrencealready respects (a field that existed in the model but had no UI to set it). The task itself, its title, and its fullcompletedOccurrenceshistory are all preserved — only future occurrences stop generating. Genuinely different fromdelete(_:).- New "Complete & Stop Series" item in the shared
TaskMenuItems(used by Today, Matrix, and Calendar's day/week/3-day timelines) — shown only whentask.isRecurring. - Wired through all 8
TaskMenuItemscall sites acrossTodayView(OverdueCard,UpcomingSection),MatrixView(QuadrantCard,QuadrantDetailView×3), andCalendarView(main view,DayTimelineView, the timeline-block context menu) — some needed a new optional closure threaded through an intermediate wrapper view, others hadtaskVMdirectly.
Files: TaskItem.swift, TaskContextMenu.swift, TodayView.swift,
MatrixView.swift, CalendarView.swift.
Note
Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/
parens confirmed per-file via git diff (one file had a pre-existing +1 paren
offset from an unrelated comment, verified via stash — not introduced by this
change). OverdueCard's wiring is technically unreachable today since
overdueTasks already excludes recurring tasks by design — wired anyway for
consistency in case that changes.
KC-60 — Habit reminders also introduced in the in-app tutorial, not just onboarding
Status: Implemented (not build-verified) Reported by: User: "do we have the behavior reminders as part of onboarding and also in the tutorial"
Description
Habit reminders (KC-55) were added to Onboarding, but not to the app's
separate tutorial system (TutorialManager — the spotlight/coachmark walkthrough
shown per-tab: intro tour, Today tips, Workout tips). Someone who skips or
doesn't linger on onboarding's toggles had no second chance to discover the
feature exists.
Change
- New fourth tutorial track in
TutorialManager:settingsTips/settingsStep/settingsDone(persisted keykisani.tutorial.settings.v1), following the exact same pattern as the existing Today/Workout tip tracks (advanceSettings()/skipSettings()). - One tip: "Habit Reminders" — introduces Prayer/Bed/Coffee and the private streak via the notification's "Done" action.
- Wired into
SettingsViewvia the sameInViewTutorialCardcomponent Today/Workout already use — shown once, the first time Settings is opened.
Files: TutorialManager.swift, SettingsView.swift.
Note
Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/
parens confirmed; wiring mirrors TodayView/WorkoutView's existing
tm.xIsActive + InViewTutorialCard pattern exactly, reusing the same shared
component rather than introducing a new one.
KC-61 — Three fixes: onboarding note, Live Activity circle/stop control, habit notification actions
Status: Implemented (not build-verified) Reported by: User (screenshot: a real device Live Activity + delivered "A moment to pause" prayer notification — confirms KC-55/56 are actually firing correctly on-device)
1. Onboarding: clearer "change in Settings" note
The Habits card's caption only mentioned times/custom-reminder, not the toggles themselves. Reworded: "You can turn any of these on or off — or fine-tune the exact times, or add a custom coffee reminder — anytime in Settings → Habits."
2. Live Activity: removed the checkbox-look circle, added a Stop control
TaskLiveActivity's lock-screen card used circle/checkmark.circle.fill —
read as an unchecked checkbox on a pure countdown display. Two honest
technical notes acted on:
- iOS gives apps no API to block the system's own dismiss gesture on a Live Activity — not something any app can control, confirmed against ActivityKit's public surface. Not attempted.
- True "long-press reveals a menu" isn't how Live Activities work (that's
specific to
UNNotificationCategoryactions). The correct iOS 17+ equivalent is a button embedded directly in the card.
Changes:
- Circle →
hourglass(informational, not checkbox-shaped) when incomplete. - New
StopCountdownIntent(LiveActivityIntent, widget-extension target) ends the activity viaActivity<TaskActivityAttributes>...end()directly from the card — no app launch needed. - Wired as an "×" button on the lock-screen card and a labeled "Stop
Countdown" button in the Dynamic Island's expanded region, both iOS 17+
gated (graceful no-op below that, matching the file's existing
@available(iOS 16.1, *)pattern).
3. Habit notifications: two more actions beyond "Done"
Consolidated the requested "not done / not today / don't track for today / mute for today / stop tracking" into two clear, non-redundant actions:
- "Not Today" — dismisses only; intentionally a no-op beyond that, since the habit tally only ever counts "Done" taps (KC-40's lifetime-tally philosophy has no concept of a logged miss). Gives an explicit, discoverable "I saw it, skipping" action instead of relying on a swipe.
- "Stop Tracking" (
.destructivestyle) — flips that habit's master toggle off (so Settings reflects it) and immediately cancels every pending notification for that type across all its slots, not just the one tapped.
Files: OnboardingView.swift, TaskLiveActivity.swift,
StopCountdownIntent.swift (new), NotificationManager.swift.
Note
Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/
parens confirmed per-file. StopCountdownIntent needs to land in the
KisaniCalWidgets target (already covered by project.yml's sources: -path: KisaniCalWidgets glob — no project.yml change needed).
KC-62 — Task row category pill replaced with TickTick-style icon glyphs
Status: Implemented (not build-verified — no iOS runtime here) Reported by: User, from device screenshots ("reminder labeling a bit too much, let's do it just like TickTick... show countdown info too") Area: Today (task rows, both the top mini-timeline and the Tomorrow/Next 7 Days/Later list)
Description
Every task row rendered a TagChip text badge showing task.category .rawValue — for the vast majority of tasks (the default .reminder
category) this just says "Reminder" on nearly every row, adding no real
information. Confirmed only 2 call sites exist app-wide (grep -rn "TagChip(" KisaniCal/Views/*.swift), both in TodayView.swift — Matrix and
Calendar don't render this pill at all, so "everywhere" (user's chosen
scope) resolves to these two.
Change
- Added
TaskCategory.glyph: String?(TaskItem.swift) — maps each category to a small SF Symbol,nilfor.reminder(the generic default stays unmarked, matching TickTick's "no badge unless it says something" density):.birthday → gift.fill,.domain → briefcase.fill,.annual → calendar,.custom → tag.fill. TaskRowView(Tomorrow/Next 7 Days/Later rows — matches the user's screenshot exactly): removed theTagChipcall outright and folded the category glyph into the row's existing ⏰/↻ iconHStack. This row already carried a countdown (countdownLabel(to:)) next to the due date, so "show countdown info too" was already satisfied — no countdown change needed here.TodayTLRow(top mini-timeline — Overdue/Today/Next 3 Days/Upcoming): same glyph swap, and while in there also added the ⏰/↻ indicators this row was missing (it only had the category pill before). No standalone countdown text added here — the leading time-track column already shows either the time-of-day or the short date (MMM d) for non-today entries, so a duplicate "in X days" string would be redundant clutter in an already dense timeline row.
Files: KisaniCal/Models/TaskItem.swift, KisaniCal/Views/TodayView.swift.
Note
Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/
parens confirmed per-file (TaskItem.swift has one pre-existing stray (
imbalance confirmed via git stash to predate this change — a comment
artifact, not introduced here). TagChip itself is left intact in
SharedComponents.swift since it's a generic shared component, just no
longer called from these two sites.
Follow-up — right side was dead space
Device screenshot of the "Later" list showed the icon column sitting empty
for most rows (default .reminder tasks have no glyph, no alarm, no
series), leaving a lone floating repeat icon on recurring rows and a big
gap everywhere else. Rebalanced TaskRowView: the countdown (in 1 wk
etc., previously crammed under the title on the left) moved to a trailing
VStack on the right, with the icon row underneath it — so the row now
reads title/date on the left, countdown/indicators on the right, instead of
all metadata piled on the left and the right column empty. TodayTLRow was
left as-is (its leading time-track column already fills that role there).
Follow-up 2 — typography polish
User asked for the row text to be "easy on the eye" without increasing any
font sizes. Impeccable was considered but is a no-op here (its detect
engine only parses HTML, not Swift/SwiftUI — confirmed limitation from the
F1 Pitt project setup). Applied by hand instead, sizes unchanged: title
bumped from .regular to .medium weight for stronger hierarchy against
the dimmer date/countdown line; both mono metadata lines (date and
countdown) got .tracking(0.2) for legibility at 9.5pt; column spacing
loosened slightly (2→3 left, 3→4 right) for breathing room between the two
lines.
KC-63 — Workout card: keep the progress dots visible after finishing; confirm every Finish tap
Status: Implemented (not build-verified — no iOS runtime here)
Reported by: User, from device screenshots
Area: Workout (DotProgressCard)
Description
Two regressions from KC-52's completion-flow redesign, per the user directly:
- Tapping "Finish Workout" replaced the progress-dots view (33/33 sets, 100%) with a green "Workout logged / Undo" takeover — and there was no way back to the dots short of Undo. User: "it gets stuck here and i cant go back to 100% progress view... please only maintain this view of progress."
- "Finish Workout" skipped the confirmation dialog whenever all sets were already checked (KC-52 follow-up 2's stated behavior: "nothing to override"). User now wants every Finish tap confirmed, to avoid accidental toggles — same bar as Clear All / Didn't Train already have.
Change
DotProgressCard.body: thePROGRESSlabel +DotGridProgressnow render unconditionally, outside theisDoneToday/isMissedTodaybranch. Only the row below it switches betweenactionRow(pending) andundoRow(done/missed) — the dots themselves never disappear.- Finish button now always sets
pendingAction = .finishinstead of short-circuiting straight toonFinish()at 100% sets — every Finish tap goes through the existingconfirmationDialog. Clear All Sets already confirmed (KC-52 follow-up 2); no change needed there.
Files: KisaniCal/Views/WorkoutView.swift.
Note
Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/ parens confirmed for the file.
Follow-up — Finish-at-100% confirmation reverted
User pushed back on the "confirm every Finish tap" part: at 100% sets,
tapping "Finish Workout" going through a confirmation dialog was
unwanted friction, not a fix — restored the KC-52 behavior where Finish
skips the dialog only when done == total (nothing to override at that
point). Partial-completion "Finish Anyway" still confirms. The
always-visible progress dots from this issue's first change are untouched.
KC-64 — Xcode warning: deprecated Activity.end(using:dismissalPolicy:)
Status: Implemented (not build-verified — no iOS runtime here) Reported by: User, Xcode warning screenshot ("'end(using:dismissalPolicy:)' was deprecated in iOS 16.2: Use end(content:dismissalPolicy:)") Area: Live Activities
Description
Both Live Activity "stop" call sites called activity.end(dismissalPolicy: .immediate) with no content argument. ActivityKit has two overloads —
end(_:dismissalPolicy:) (current) and the deprecated end(using: dismissalPolicy:) — and omitting the first argument entirely resolved to
the deprecated one.
Change
Pass nil explicitly as the unlabeled first argument so it binds to the
non-deprecated overload: activity.end(nil, dismissalPolicy: .immediate).
Files: KisaniCal/Managers/LiveActivityManager.swift,
KisaniCalWidgets/StopCountdownIntent.swift.
KC-65 — TaskItem never actually stored a creation date
Status: Implemented (not build-verified — no iOS runtime here) Reported by: User, from a Track Countdown lock-screen widget stuck at 0% Area: Model / widgets (Track Countdown progress dots)
Description
The countdown-dots widget (KisaniCalWidgets.swift's eventStartDate) was
already written to prefer task.createdAt as the progress bar's start
date — but TaskItem never had a createdAt property at all, so it always
decoded to nil and every task fell through to a fallback chain: a
per-task "first render" anchor persisted in UserDefaults.kisani
(kisani.widget.progressDots.anchor.<id>), or failing that, an inferred
legacy start (remainingDays * 2, capped 30–365 days). That anchor can
reset to "now" under several conditions (widget re-added, storage cleared,
the looksLikeUpgradeAnchor heuristic firing) — which is exactly the
"starting from scratch on every update" behavior the user was seeing.
Change
Added var createdAt: Date? = Date() to TaskItem (Models/TaskItem.swift)
— optional so existing saved tasks decode safely (missing key → nil,
same pattern as recurrenceEnd), but the Date() default is evaluated
fresh at each construction site, so every newly created task now genuinely
records its creation moment. No call sites needed updating: addTask uses
the compiler-synthesized memberwise init and doesn't pass createdAt
explicitly, so it gets the live default; occurrenceCopy copies the
struct (var c = t), so recurring-task instances correctly inherit the
series' original createdAt rather than getting a fresh one.
The widget-side plumbing (WidgetData.swift's RawTask/WidgetTask
decode, eventStartDate's preference order) already existed and needed no
change — it was just never being fed a real value.
Files: KisaniCal/Models/TaskItem.swift.
Note
Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/
parens (one pre-existing stray ( predates this change, confirmed earlier
in KC-62). Existing tasks (created before this fix) still have no real
creation date and will keep using the anchor/inferred fallback — there's
no way to retroactively know when they were actually created.
KC-66 — Live Activity build error + second countdown widget missing createdAt
Status: Implemented (not build-verified — no iOS runtime here) Reported by: User, Xcode error screenshot + follow-up request to audit all widgets for creation-date usage Area: Live Activities, Widgets
1. Build error: end(_:dismissalPolicy:) needs iOS 16.2, file targets 16.1
KC-64's fix picked the non-deprecated ActivityKit overload, but
LiveActivityManager.end(taskID:) is only gated @available(iOS 16.1, *)
— matching Live Activities' own minimum — so the call didn't compile for
16.1.x. Branched on #available(iOS 16.2, *): the new overload when
available, falling back to the deprecated end(using:dismissalPolicy:)
below that. StopCountdownIntent.swift needed no change — it's already
gated @available(iOS 17.0, *), well above 16.2.
Files: KisaniCal/Managers/LiveActivityManager.swift.
2. Audit: does every countdown widget use the task's real creation date?
KC-65 fixed KisaniCalWidgets.swift's ProgressDotProvider (the "Track
Countdown" dot-grid widget, driven by App Group task data) to prefer
task.createdAt. Auditing the rest turned up a second, separate countdown
widget — EventCountdownWidget.swift's EventBarsWidget/EventProvider
(the "Event Countdown (Bars)" widget family, config-driven via
EventConfigIntent/EventEntity) — whose resolveSpan had its own
independent start-date resolution that never looked at createdAt at all:
explicit start (tracked-since date, or the widget's configured "Counting
from") → storedAnchor (first-seen fallback) only. Confirmed via grep createdAt EventCountdownWidget.swift returning nothing before this fix.
WidgetViews.swift's progress-rendering views (ProgressDotView,
BarGrid, etc.) only consume an already-computed progress value, so
they needed no change — the two TimelineProviders are the only places
that resolve a start date, and both are now fixed.
Change
- Added
createdAt: Date? = niltoEventEntity(the App-Group-backed event picker's entity type), populated in both places that build it:EventQuery.allEvents()andEventProvider.upcomingEvents(asOf:). resolveSpan(...)gained ataskCreatedAt: Date? = nilparameter, checked afterexplicitStart(an explicit user choice, e.g. the widget's "Counting from" field, still wins) and beforestoredAnchor(the last-resort fallback for tasks created before KC-65, or fully custom events with no task behind them at all).- All three call sites that resolve a real task's span (tracked event,
upcoming-queue auto-select, manually configured event) now pass
taskCreatedAt:. The fully-custom-event branch (no task, just a typed name + date) is unaffected — there's no task to have a creation date.
Files: KisaniCalWidgets/EventCountdownWidget.swift.
Note
Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/
parens confirmed per-file. Same caveat as KC-65: tasks created before that
fix still have no real createdAt and fall through to the anchor/inferred
heuristics in both widgets.
KC-67 — Watch/Health workout streak got stomped by a stale iCloud sync
Status: Implemented (not build-verified — no iOS runtime here)
Reported by: User — streak reset to 3 after a workout was already logged
by the Watch, plus a request for Finish/Clear-All confirmations and to
remove Undo
Area: Workout (DotProgressCard, WorkoutViewModel, CloudSyncManager)
Root cause
workoutDates is a lifetime tally (KC-40 — Set(workoutDates).count,
append-only by design) and every local write path already respects that:
logWorkoutCompleted dedups by key before appending, syncFromHealthKit
only unions HealthKit's dates in. But CloudSyncManager.kvStoreChanged —
the handler for "iCloud KV store changed externally" — did a blind
overwrite: UserDefaults.kisani.set(val, forKey: key) for every changed
key, no merge. Since save() pushes workoutDates to iCloud on every
write (including the Watch/HealthKit sync path), a stale iCloud snapshot
arriving after a fresh local write (races are inherent to NSUbiquitous KeyValueStore — it's eventually consistent) would silently replace the
correct, larger local array with an older, smaller one — exactly matching
"the workout was already logged by the Watch, then tapping Finish reset
the streak."
Change
CloudSyncManager.kvStoreChanged: for keys matchingkisani.workout.completedDates.*, union the incoming iCloud value with whatever's already local instead of overwriting — an external change can now only ever add days, never erase ones this device already knows about. Every other synced key keeps the existing overwrite behavior (unaffected — this tally is the only append-only-by-design value among them).DotProgressCard: removed theonUndoaction, its row, and theisDoneToday/isMissedTodaybranch that showed it — the done/missed state now shows the status header + progress dots with no action row underneath at all (per "remove the Undo action from the logged workout state"). Watch/Health is the source of truth once a day is logged; there's no in-card way to contest that.- Removed
onUndo:at theDotProgressCard(...)call site inWorkoutView.swift, and deletedWorkoutViewModel.unmarkWorkoutDone/unmarkWorkoutMissed(now dead — Undo was their only caller). - Re-added the confirmation dialog on "Finish Workout" (previously reverted in KC-63's follow-up at the user's request; re-requested here alongside the streak fix) — every state-changing action on the card (Finish, Check All, Clear All, Didn't Train) now confirms first.
Files: KisaniCal/Managers/CloudSyncManager.swift,
KisaniCal/Views/WorkoutView.swift, KisaniCal/Models/ExerciseModels.swift.
Note
Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/
parens confirmed per-file. Structurally, the action row (with the Finish
button) is only ever rendered when isDoneToday/isMissedToday are both
false, so a watch-logged day already hides Finish entirely rather than
needing a runtime guard against double-logging — the real gap was the
CloudSync race above, not the button itself.
KC-68 — Bring back Undo, but only for manually-logged days
Status: Implemented (not build-verified — no iOS runtime here)
Reported by: User — tested Finish Workout to confirm KC-67 and got stuck
with no way back since KC-67 removed Undo entirely
Area: Workout (DotProgressCard, WorkoutViewModel)
Description
KC-67 removed Undo outright on the reasoning "Watch/Health is the source of truth once a day is logged." That's correct for a Watch/Health-detected day, but it also blocked undoing a day the user logged manually through the in-app Finish button (e.g. while testing) — there was no way back short of waiting for a new calendar day. User wants Undo back, but only when the day wasn't Watch/Health-sourced, and confirmed first.
Change
workoutDates(the lifetime tally) didn't previously track how a day was logged. AddedmanualWorkoutDates: [String](own storage keykisani.workout.manualDates.<uid>, loaded/saved/pushed to iCloud alongside the other workout keys) — the subset ofworkoutDateslogged via the in-app Finish button.logWorkoutCompleted(only ever called from the manualmarkWorkoutDonepath, never fromsyncFromHealthKit) now also records the date intomanualWorkoutDates. If the day is already inworkoutDates(e.g. Watch/Health got there first), the existing dedup guard makes this whole function a no-op — so a Watch-logged day can never retroactively become "manual."- Added
WorkoutViewModel.isManuallyLogged(on:)and restoredunmarkWorkoutDone(on:)(removes from bothworkoutDatesandmanualWorkoutDates). DotProgressCardgainedisManuallyLogged/onUndoparams back. Undo is now shown only whenisDoneToday && isManuallyLogged(a Watch/Health day still shows nothing extra below the dots, per KC-67), and tapping it goes through a new.undocase in the existingconfirmationDialog("Undo today's logged workout? ... won't affect anything recorded by Health or your Watch") rather than acting immediately — the bare Undo button KC-67 removed had no confirmation at all.
Files: KisaniCal/Models/ExerciseModels.swift,
KisaniCal/Views/WorkoutView.swift.
Note
Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/
parens confirmed per-file. Deliberately did NOT extend the KC-67 iCloud
union-merge fix to the new manualDates key — unlike the lifetime tally,
this set is meant to shrink when the user undoes, and merging it
across-devices-only-grows would make an undo silently un-do itself on
another device. Worst case for this key without that protection is a rare
multi-device timing quirk in whether Undo is offered, not a data-integrity
bug like KC-67's.
KC-69 — Consolidate 3 duplicate recurrence implementations before adding "every N"
Status: Implemented and verified via standalone swiftc test run (see below)
— the pure-logic part of this session's only change that's actually been
executed, not just read
Reported by: User — asked for confidence to be improved before building a
requested "every 2 weeks / every N months" recurrence feature
Area: Recurrence engine (main app + both widget extensions)
Description
Before adding interval support, audited every place recurrence-window math lives and found THREE independent hand-written copies that would all need identical interval logic added, with no shared code to guarantee they stayed in sync:
TaskItem.isOccurrence(main app — occurrence matching for Calendar, Matrix, Today's list; everything else in the model funnels through this one function, so it was already well-centralized on the app side)KisaniCalWidgets.swift'srecurrenceSpan(two overloads — the "Track Countdown" dot-grid widget's progress window)EventCountdownWidget.swift'srecurrenceSpan— a near-identical copy of #2, for the separate "Event Countdown (Bars)" widget family
Change
- New
Shared/RecurrenceRule.swift— pure Foundation, zero SwiftUI/ WidgetKit/UIKit dependencies.isOccurrence(label:interval:start:date: end:calendar:)andspan(label:interval:due:now:calendar:), both defaultingintervalto 1. Added to BOTH targets'sourcesinproject.yml(a new top-levelShared/path, alongside the existing precedent of individual files shared intoKisaniCalWidgetsfromKisaniCal/Managers/) — compiled into the app and the widget extension as the literal same code, not copy-pasted. - All three call sites above now delegate to
RecurrenceRuleinstead of each having their own switch/date-walk.EventCountdownWidget.swift'srecurrenceSpanshrank from a ~25-line implementation to a 1-line delegation. - Verified every case is mathematically identical to the pre-existing
behavior at
interval == 1(the implicit default before this change) — e.g. Weekly'sdays % 7 == 0becomesdays % (7×N) == 0, identical at N=1.KisaniCalTests/RecurrenceTests.swift(an existing XCTest suite coveringTaskItem.isOccurrence) exercises exactly this path and should still pass unchanged once run on a simulator. - Wrote a standalone test driver (not committed — scratch file) and
compiled+ran it directly against the real
Shared/RecurrenceRule.swiftviaswiftc(no Xcode/simulator needed, since this file has no framework dependencies) — 38/38 tests passing, including the edge cases that mattered most for confidence: a task due Jan 31 repeating monthly (Feb has no 31st), a yearly task anchored on a Feb 29 leap day, interval backward-compat at N=1 for every label, andrecurrenceEndbounding. This is the one piece of SwiftUI-adjacent work this session that's been actually executed and verified rather than only self-reviewed by reading.
Files: Shared/RecurrenceRule.swift (new), project.yml,
KisaniCal/Models/TaskItem.swift, KisaniCalWidgets/KisaniCalWidgets.swift,
KisaniCalWidgets/EventCountdownWidget.swift.
Note
This is groundwork only — TaskItem still has no recurrenceInterval
field and nothing calls RecurrenceRule with interval != 1 yet. The
actual "every N weeks/months" feature (model field, picker UI, quick-add
NLP) is still pending, to be built on top of this now-consolidated,
now-tested engine. xcodegen generate re-run cleanly after the
project.yml change; not build-verified in Xcode itself (no iOS runtime
here), though the standalone test above is real, executed verification of
the actual shipped file.