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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user