Files
KisaniCal/KisaniCal/ISSUES.md
kutesir f6a9358a86 recurrence: consolidate 3 duplicate implementations into one shared, tested engine (KC-69)
Before adding "every N weeks/months" support, found the recurrence
window math was independently hand-written in three places (main app's
TaskItem.isOccurrence, and two separate widget extensions' countdown
progress calculations) with no shared code to keep them in sync.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:57:13 +00:00

117 KiB
Raw Blame History

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's isDone across 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, and scenePhase == .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:

  1. The Overdue card sat at the very top, above today's active tasks. Active tasks should always be the top section.
  2. 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 OverdueCard to render directly below today's active tasks (above Next 3 Days), so active tasks stay at the top.
  • todayCalEvents now excludes events whose calendar.type == .subscription, so RSS/ICS feeds are filtered out of the Today timeline. The Calendar view reads monthEvents directly 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. Added TaskViewModel.setPriority.
  • Live Activity (ActivityKit): TaskActivityAttributes (shared app+widget), LiveActivityManager (start/update/end/toggle, iOS 16.1+), TaskLiveActivity widget (lock screen + Dynamic Island), NSSupportsLiveActivities via project.yml.
  • Calendar events (non-subscription, writable only): Edit opens the system EKEventEditViewController (EventEditView); Delete via CalendarStore.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

  1. 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.
  2. 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).
  3. 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).
  4. 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.shared singleton — 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 authoritative EKEventStore.authorizationStatus instead of trusting the callback bool; already-decided states re-sync via refreshStatus().
  • 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)

  1. Create an event a few minutes out with no alert on a visible calendar.
  2. Background the app (triggers a reschedule).
  3. 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 emits UILaunchStoryboardName = LaunchScreen and ships LaunchScreen.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:

  1. Confirm the fix is present: grep UILaunchStoryboardName project.yml → must be LaunchScreen (not ""). git log --oneline -- KisaniCal/LaunchScreen.storyboard → should show 72f4566.
  2. If Xcode was open, reload the project (xcodegen rewrites .xcodeproj).
  3. Product → Clean Build Folder (⇧⌘K) — a cached build folder re-emits the old Info.plist. This is the step people skip.
  4. Bump CURRENT_PROJECT_VERSION in project.yml + xcodegen generate (a failed upload can "use up" the build number).
  5. Re-Archive → Validate → Distribute.
  6. Sanity check any archive: /usr/libexec/PlistBuddy -c "Print :UILaunchStoryboardName" <App>.app/Info.plist and ls <App>.app | grep LaunchScreen (expect LaunchScreen.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 from resetSetsForNewDayIfNeeded on 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 in WorkoutView with 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 / LoggedSet models; stored as kisani.workout.history.<uid> (and iCloud-synced).
  • snapshotDay(_:) captures the active program's completed sets — called from logWorkoutCompleted (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:) — checks workoutDates (the set of dates a workout was actually finished/Health-detected), i.e. per-date.
  • DayTimelineView now takes a workoutDone: Bool and uses it instead of the live doneSets. Calendar passes isWorkoutComplete(on: selectedDate), Today passes isWorkoutComplete(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).
  • TaskViewModel engine: 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 recurrenceEnd when set.

Files: TaskItem.swift, CalendarView.swift.

"Repeat until" UI (done)

  • Added recurrenceEnd through NLParsed, TaskDatePickerSheet, both add/edit sheets, and addTask/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; UpcomingSection gained a title).
  • 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 + matrixTasks group 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 background RGB(0.224,0.255,0.31), brand orange accent).
  • BarGrid vertical-bar progress view; new EventBarsView + 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)

  1. Enable workout reminder/check-in in Settings; when it fires, use the actions.
  2. "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).
  3. Long-press the workout card in Today/Calendar → Done / Not Done / Compensate.
  4. 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

  • EventCountdownWidget struct (kind KisaniEventCountdown) + EventCountdownView.
  • TasksCompleteWidget (kind KisaniTasksComplete) + TasksCompleteView + its only consumer todayTaskCompletion() in WidgetData.swift.
  • Both deregistered from KisaniWidgetBundle.
  • Kept (shared by the surviving bars widget): EventConfigIntent, EventEntity, EventQuery, EventProvider, EventEntry, CycleCountdownUnitIntent, BarGrid; DotGrid kept (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

  1. Auto (upcoming-queue) mode anchored progress to Date() on every refresh, so elapsed time was always ~0 → empty bar forever.
  2. Custom mode defaulted both Event date and Counting from to the same instant (Date.now at config time) → zero-length span → progress 0. The raw second-precision defaults also made the picker rows look broken.
  3. 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/startDate are 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

  • WidgetTheme now uses the precise brand palette: accent RGB(232,98,42) (app AppColors.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. Stores kisani.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).
  • WidgetTask now decodes isRecurring/recurrenceLabel from 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)

  1. Long-press any task with a due date → "Track Countdown".
  2. The bars widget switches to that event without touching its config.
  3. A yearly birthday shows Mode B: bar spans last year's → this year's date.
  4. 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.
  • moveToQuadrant updates real attributes:
    • vertical movement sets Priority (top → High; moving down demotes High → Medium)
    • horizontal movement sets urgencyOverride only if the target column disagrees with date-derived urgency.
  • Editing due date (setDate) or saving the editor (updateTask) clears urgencyOverride so the task re-places live.
  • Category changes, priority changes, and date changes refresh the legacy quadrant color/widget fallback to match computed placement.
  • addTask applies 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)

  • AddTaskSheet no 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

  1. New task, no priority, far date → lands in Q4; raise to High → jumps to Q2.
  2. New task with no date → stays in right column until given a due date/reminder.
  3. Drag Q2 → Q1 when due date is far out → Manual badge appears.
  4. Reset Matrix Urgency → task returns to its date-derived column.
  5. Drag Q1 → Q4 → High demotes to Medium and urgency is overridden if needed.
  6. Open the task, change its due date → urgency override clears, re-places by rule.
  7. 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

  1. Long-press a task in Today → Postpone → 15 Minutes; reminder/due time moves.
  2. Repeat from Matrix grid, Matrix detail, and Calendar day timeline.
  3. Open Move from the same menu → only the other three quadrants are listed (see KC-25).
  4. Trigger a task reminder notification on device → verify 15/30/60/120 minute lock-screen actions appear.
  5. 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 TaskEditSheet via a new editingTask sheet (QuadrantCard gained an onEdit; Calendar passes onEdit into the task menu).
  • Reworked the reminder picker (TaskDatePickerSheet, shared by every entry point) from day-offsets to minutes before the event time: None, On time, 5 min, 30 min, 1 hour, 1 day, plus a Minutes/Hours/Days custom wheel. Existing absolute reminderDates migrate to the nearest minute offset on open.

Files: MatrixView.swift, CalendarView.swift, TodayView.swift.

How to test

  1. Long-press a task in Matrix and in Calendar → Date → "Pick Date" is present.
  2. Create/edit a task → reminder options read On time / 5 min / 30 min / 1 hour / 1 day / Custom, relative to the event time.
  3. Custom → Minutes/Hours/Days toggle + count wheel commits the right offset.

KC-24 — App rebrand to "Wenza"

Status: In Progress (display name + copy done; App Store name set in ASC) Reported by: User Area: Branding

Problem

The app is being rebranded from "Kisani Cal" to Wenza.

Fix

  • CFBundleDisplayName → "Wenza" (app) and "Wenza Widgets" (widget) in project.yml; permission prompts and in-app copy (sidebar, Auth, Onboarding, Settings, tutorial, widget description) updated to Wenza.
  • Deliberately unchanged to preserve App Store identity and user data: bundle ID com.kutesir.KisaniCal, App Group group.com.kutesir.KisaniCal, kisani.tasks.v2.* storage keys, team ID, and internal Xcode target/project names.
  • App Store listing name set separately in App Store Connect (ships with the next version).

Files: project.yml, ContentView.swift, TutorialManager.swift, AuthView.swift, OnboardingView.swift, SettingsView.swift, KisaniCalWidgets.swift.

How to test

  1. Build & run → home-screen label and all in-app titles read "Wenza".
  2. Confirm tasks/widget data survive (App Group + keys unchanged).

KC-25 — Matrix "Move" lists only the other quadrants

Status: In Progress (implemented & build-verified, pending device QA) Reported by: User Area: Tasks / Matrix

Problem

The Move submenu listed all four quadrants with a checkmark on the current one, so the quadrant a task already sat in appeared as a no-op option.

Fix

  • TaskMenuItems Move submenu now excludes the task's current quadrant and lists only the other three, using the clear matrix labels (e.g. a task in Urgent & Important shows Not Urgent & Important, Urgent & Unimportant, Not Urgent & Unimportant). Shared across Today, Matrix, and Calendar.

Files: TaskContextMenu.swift.

How to test

  1. Long-press a task in any quadrant → Move shows only the three other quadrants.
  2. Pick one → task moves (priority/urgency updated per the TickTick model).

KC-26 — Bars widget: auto-select advances to the next event

Status: In Progress (implemented & build-verified, pending device QA) Reported by: User Area: Widgets

Problem

With "Upcoming queue" on + "Nearest to finish", the Event Countdown (Bars) widget stayed stuck on an event after it expired (e.g. a passed deadline) instead of rolling to the next-nearest. The selection was frozen: the timeline computed the chosen event once and reused it for all 24 pre-rendered entries, only refreshing every 12 hours.

Fix

  • Each timeline entry now re-resolves the nearest-to-finish event as of that entry's own time (upcomingEvents(asOf:) filters dueDate > entryDate), so the moment the current event expires the next entry rolls to the next one.
  • The timeline refreshes the instant the current selection expires (currentSelectionExpiry), not 12 hours later, giving the next event a fresh full pre-render window.
  • Completing/closing a task already reloads widget timelines, and the recompute drops it and picks the next.

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

  1. Queue on + Nearest to finish; let the nearest event's due time pass → widget rolls to the next-nearest without manual action.
  2. Complete the nearest event → widget advances to the next.
  3. 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; added TaskViewModel.allActiveRows so grouping/selection use the same single-occurrence task set (incl. undated).

Files: TodayMenuFeatures.swift, TodayView.swift, TaskItem.swift.

How to test

  1. ⋯ → Background → pick a theme; Today background updates and persists.
  2. ⋯ → Group & Sort → Priority/Category/Quadrant regroups the list.
  3. ⋯ → 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), .primary color for the vibrant tint, and AccessoryWidgetBackground() 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 recurring UNCalendarNotificationTriggers, 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 the kisani.periodMilestones flag (default on).
  • Pending: no in-app Settings toggle yet (see KC-31 #2).

Files: NotificationManager.swift.

How to test

  1. 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. markTaskComplete and snoozeTask previously wrote only to UserDefaults.kisani. They now also CloudSyncManager.shared.push(...) (→ iCloud / Apple Watch) and WidgetCenter.shared.reloadAllTimelines().
  • Notification copy uses live Matrix urgency, not stored quadrant. reschedule(...) snapshots urgent task IDs from the computed displayQuadrant and threads them into scheduleTasks / notifBody, so "Urgent — tap to mark complete" and the evening check-in reflect current urgency rather than a stale stored fallback.
  • Batch postponeAllOverdue matches single postpone. It now clears urgencyOverride and re-syncs the stored quadrant per task.

Files: NotificationManager.swift, TaskItem.swift.

Pending (not closed)

  • #2 — Period-milestone Settings toggle. Currently only the kisani.periodMilestones UserDefaults 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 raw taskVM.tasks (no recurrence) and dropped any untimed task via guard h != 0 || m != 0 (midnight = "no time" → discarded).
  • Week rendered eventDots (colors only); List and the task-list toggle used raw tasks instead of occurrences(on:).

Fix

  • cvTimeItems now uses occurrences(on:) + the real hasTime flag; 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 calDayTaskListView switched to occurrences(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 locale weekOfYear (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 17, 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): SFSpeechRecognizer driven by AVAudioEngine, publishing transcript / isRecording / error. Requests speech + microphone authorization, then streams partial results.
  • TodayView quick-add: mic button calls speech.toggle() (mic → mic.fill, accent + pulse while recording); onChange(of: speech.transcript) binds the text into the task field, so dictation flows through the same NLTaskParser — 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/StatBucket aggregation already exists (stat(period:offset:), statBuckets).
  • WorkoutDayLogLoggedExerciseLoggedSet(weight, reps, done) with computed volume (Σ weight×reps for done sets), totalSets, doneSets.
  • HealthKitManager: steps, active calories, resting HR, workoutsThisWeek, fetchWorkoutDates(from:to:), saveWorkout, sumQuery/latestQuery.
  • NotificationManager: categories/actions, foreground reschedule(...), and schedulePeriodMilestones() (week/month/year-close nudges) — extend this.
  • Lifecycle reconcile pattern already used: checkPendingMissed / checkPendingHealthConfirm on onAppear + scenePhase == .active.
  • Persistence: UserDefaults mirrored 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, mirrors WorkoutDayLog) + 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; %Δ = (curbase)/|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 via userInfo → route to the specific report (new deep-link cases).
  • UI (Analytics area): 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)

  1. 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.
  2. 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 with swiftc and 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).
  • 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: completedWeekStartKeys included a boundary week that prune then 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 an AnalyticsDeepLink in userInfo; 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 a WorkoutViewModel snapshot (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: ContentView calls AnalyticsService.shared.reconcile(using:) + NotificationManager.scheduleAnalyticsReports(...) on launch and scenePhase .active (reuses existing pending-action pattern).
  • Notifications: scheduleAnalyticsReports fires one permission-aware, setting-gated weekly + monthly notification with an AnalyticsDeepLink in 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. Reads AnalyticsService.
    • 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 → presents AnalyticsView as 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.
  • AnalyticsDeepLink now Identifiable (id = url string) → usable with .sheet(item:). Round-trip re-verified standalone.
  • Notification tap → stores pending link (delegate) → ContentView consumes it on launch + .active → presents AnalyticsView(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 captured WorkoutSnapshot value, so the coordinator's escaping closures no longer call @MainActor methods (avoids a main-actor isolation error under strict concurrency). Verified @MainActor + static let shared is 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 parallel HKStatisticsCollectionQuery (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 every DaySample built afterward. Called from the existing Health-sync Task in ContentView (after syncFromHealthKit), then reconciles so new/updated reports capture it.
  • PeriodSummary gained avgSteps/avgActiveCalories/avgRestingHR (nil when no reference data in the period — honest, never fabricated). Weekly/monthly cards in AnalyticsView show 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 testSummarizeAggregatesHealthKitAveragesWhenPresent
    • testSummarizeHealthKitNilWhenAbsent to AnalyticsEngineTests.
  • 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:) to WorkoutViewModel — 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.
  • confirmationDialog before 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 with checkmark.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 PendingAction enum + one confirmationDialog (Finish Anyway, Check All Sets, Clear All Sets, Didn't Train) — each with its own message; Clear All / Didn't Train use .destructive role. "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:

  • todayTasks deliberately excludes timed tasks whose time has passed today ("belongs in overdue, not today").
  • overdueTasks deliberately excludes ALL recurring tasks (!recurs($0)), because it feeds postpone/postponeAllOverdue, which mutate a task's dueDate directly 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:

  1. 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?"
  2. 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).
  3. 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.kisani keys back both surfaces, so a toggle in onboarding is identical to the one in Settings.
  • Store correctness: discovered the existing workoutCheckInEnabled / workoutConfirmEnabled / etc. @AppStorage declarations have no explicit store:, so they write to UserDefaults.standard — but NotificationManager reads them via UserDefaults.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 use store: UserDefaults.kisani everywhere (Settings, Onboarding, and the reusable HabitSlotRow), 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 UNCalendarNotificationTrigger with only hour/minute set (no weekday) — fires every day, same pattern as schedulePeriodMilestones's "Every midnight" trigger.

Implementation

  • NotificationManager.scheduleHabitReminders(): reads all keys from UserDefaults.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 existing reschedule(workoutVM:taskVM:) orchestration (same place as schedulePeriodMilestones()), 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 @AppStorage backing via a manual init (dynamic key per slot).
  • Onboarding: a new "Habits" card with 3 OnboardingHabitToggle rows (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 39 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_LOG notification category + HABIT_DONE action ("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 to UserDefaults.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.
  • NotificationManager falls 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 sets recurrenceEnd to that day — which isOccurrence already respects (a field that existed in the model but had no UI to set it). The task itself, its title, and its full completedOccurrences history are all preserved — only future occurrences stop generating. Genuinely different from delete(_:).
  • New "Complete & Stop Series" item in the shared TaskMenuItems (used by Today, Matrix, and Calendar's day/week/3-day timelines) — shown only when task.isRecurring.
  • Wired through all 8 TaskMenuItems call sites across TodayView (OverdueCard, UpcomingSection), MatrixView (QuadrantCard, QuadrantDetailView ×3), and CalendarView (main view, DayTimelineView, the timeline-block context menu) — some needed a new optional closure threaded through an intermediate wrapper view, others had taskVM directly.

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 key kisani.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 SettingsView via the same InViewTutorialCard component 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 UNNotificationCategory actions). 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 via Activity<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" (.destructive style) — 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, nil for .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 the TagChip call outright and folded the category glyph into the row's existing /↻ icon HStack. 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:

  1. 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."
  2. "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: the PROGRESS label + DotGridProgress now render unconditionally, outside the isDoneToday/isMissedToday branch. Only the row below it switches between actionRow (pending) and undoRow (done/missed) — the dots themselves never disappear.
  • Finish button now always sets pendingAction = .finish instead of short-circuiting straight to onFinish() at 100% sets — every Finish tap goes through the existing confirmationDialog. 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 30365 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? = nil to EventEntity (the App-Group-backed event picker's entity type), populated in both places that build it: EventQuery.allEvents() and EventProvider.upcomingEvents(asOf:).
  • resolveSpan(...) gained a taskCreatedAt: Date? = nil parameter, checked after explicitStart (an explicit user choice, e.g. the widget's "Counting from" field, still wins) and before storedAnchor (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 matching kisani.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 the onUndo action, its row, and the isDoneToday/isMissedToday branch 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 the DotProgressCard(...) call site in WorkoutView.swift, and deleted WorkoutViewModel.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. Added manualWorkoutDates: [String] (own storage key kisani.workout.manualDates.<uid>, loaded/saved/pushed to iCloud alongside the other workout keys) — the subset of workoutDates logged via the in-app Finish button.
  • logWorkoutCompleted (only ever called from the manual markWorkoutDone path, never from syncFromHealthKit) now also records the date into manualWorkoutDates. If the day is already in workoutDates (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 restored unmarkWorkoutDone(on:) (removes from both workoutDates and manualWorkoutDates).
  • DotProgressCard gained isManuallyLogged/onUndo params back. Undo is now shown only when isDoneToday && isManuallyLogged (a Watch/Health day still shows nothing extra below the dots, per KC-67), and tapping it goes through a new .undo case in the existing confirmationDialog ("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:

  1. 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)
  2. KisaniCalWidgets.swift's recurrenceSpan (two overloads — the "Track Countdown" dot-grid widget's progress window)
  3. EventCountdownWidget.swift's recurrenceSpan — 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:) and span(label:interval:due:now:calendar:), both defaulting interval to 1. Added to BOTH targets' sources in project.yml (a new top-level Shared/ path, alongside the existing precedent of individual files shared into KisaniCalWidgets from KisaniCal/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 RecurrenceRule instead of each having their own switch/date-walk. EventCountdownWidget.swift's recurrenceSpan shrank 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's days % 7 == 0 becomes days % (7×N) == 0, identical at N=1. KisaniCalTests/RecurrenceTests.swift (an existing XCTest suite covering TaskItem.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.swift via swiftc (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, and recurrenceEnd bounding. 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.