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

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

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

View File

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