Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ on:

jobs:
test:
# macos-14 valida que el proyecto compila y el dominio corre en Sonoma
# (el target mínimo declarado); macos-15 es el entorno principal.
# macos-14 verifies the project builds and the domain runs on Sonoma
# (the declared minimum target); macos-15 is the main environment.
strategy:
fail-fast: false
matrix:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Release

# Al pushear un tag vX.Y.Z: compila, empaqueta y publica el release.
# Los releases dejan de depender de la máquina de nadie.
# On pushing a vX.Y.Z tag: build, package and publish the release.
# Releases stop depending on anyone's machine.
on:
push:
tags: ["v*"]
Expand Down
48 changes: 24 additions & 24 deletions Corner/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import AppKit
import SwiftData
import Carbon.HIToolbox

/// Composition root: único lugar donde se cablean las piezas (ARCHITECTURE §2).
/// Composition root: the only place where the pieces are wired together (ARCHITECTURE §2).
@main
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
Expand All @@ -29,14 +29,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}

func applicationDidFinishLaunching(_ notification: Notification) {
// Como test host no se cablea nada: los tests instancian sus propias
// piezas, y el guard de instancia única mataría al test runner si la
// app real está corriendo a la vez.
// As a test host nothing is wired: tests build their own pieces, and
// the single-instance guard would kill the test runner while the real
// app is running.
guard ProcessInfo.processInfo.environment["XCTestSessionIdentifier"] == nil else { return }
terminateIfAlreadyRunning()

// Tooltips (.help) más ágiles: el default del sistema (~2 s) hace que
// el texto completo de una tarea truncada tarde en aparecer (SPEC §6.8).
// Snappier tooltips (.help): the system default (~2 s) makes the full
// text of a truncated task take too long to appear (SPEC §6.8).
UserDefaults.standard.register(defaults: ["NSInitialToolTipDelay": 1000])

let clock = SystemDayClock()
Expand Down Expand Up @@ -66,15 +66,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
onToggleSync: { [weak self] in self?.toggleReminderSync() },
isSyncEnabled: { [weak self] in self?.reminderSync.isEnabled ?? false }
)
// Alarmas por tarea (SPEC §4.1.6): Corner solo notifica cuando el sync
// está apagado; con sync activo las entrega Recordatorios.
// Per-task alarms (SPEC §4.1.6): Corner only notifies when sync is off;
// with sync on, Reminders delivers them.
notifications = NotificationScheduler(store: store)
notifications.isDeliveryDelegated = { [weak self] in
self?.reminderSync.isEnabled ?? false
}
notifications.onOpenPanel = { [weak self] in self?.togglePanelAtCursor() }

// Fan-out de los guardados locales a los dos interesados.
// Fan-out of local saves to the two interested parties.
store.onDidSave = { [weak self] in
self?.reminderSync.scheduleSync()
self?.notifications.scheduleRefresh()
Expand All @@ -83,7 +83,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
reminderSync.start()
notifications.scheduleRefresh()

// Cableado hot corner <-> panel (no se conocen entre sí, ARCHITECTURE §3.4).
// Hot corner <-> panel wiring (they don't know each other, ARCHITECTURE §3.4).
hotCorner.onTrigger = { [weak self] screen in
self?.panelController.show(on: screen)
self?.hotCorner.suspend()
Expand All @@ -92,7 +92,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self?.hotCorner.resumeAfterCooldown()
}

// Atajos globales (SPEC §2): ⌥Espacio panel, ⇧⌥Espacio captura rápida.
// Global shortcuts (SPEC §2): ⌥Space panel, ⇧⌥Space quick capture.
hotKeys = HotKeyCenter()
hotKeys.onHotKey = { [weak self] id in
switch id {
Expand All @@ -105,7 +105,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
hotKeys.register(id: 1, keyCode: UInt32(kVK_Space), modifiers: UInt32(optionKey))
hotKeys.register(id: 2, keyCode: UInt32(kVK_Space), modifiers: UInt32(optionKey | shiftKey))

// Triggers del rollover (SPEC §4): arranque, medianoche, despertar de reposo.
// Rollover triggers (SPEC §4): launch, midnight, wake from sleep.
rollover.performRolloverIfNeeded()
dayChangeObserver = NotificationCenter.default.addObserver(
forName: .NSCalendarDayChanged, object: nil, queue: .main
Expand All @@ -130,8 +130,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {

if usedFallback { showStorageAlert() }

// Primera ejecución (SPEC §2): abrir el panel una única vez para que
// el usuario descubra qué instaló; el tip interno hace el resto.
// First run (SPEC §2): open the panel once so the user discovers what
// they installed; the in-panel tip does the rest.
if !UserDefaults.standard.bool(forKey: "didLaunchBefore") {
UserDefaults.standard.set(true, forKey: "didLaunchBefore")
if let screen = cursorScreen {
Expand All @@ -147,8 +147,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}) ?? NSScreen.main
}

/// Toggle del panel por menú o atajo: abrir suspende el hot corner
/// igual que abrir por esquina (SPEC §2).
/// Panel toggle from the menu or a shortcut: opening suspends the hot
/// corner just like opening from the corner itself (SPEC §2).
private func togglePanelAtCursor() {
guard let screen = cursorScreen else { return }
let wasVisible = panelController.isVisible
Expand All @@ -161,7 +161,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
quickCapture.toggle(on: screen)
}

/// Alterna el sync con Recordatorios (SPEC §8); permiso denegado → alerta.
/// Toggles Reminders sync (SPEC §8); permission denied → alert.
private func toggleReminderSync() {
Task { @MainActor [weak self] in
guard let self else { return }
Expand All @@ -176,9 +176,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}

// MARK: - Persistencia (ARCHITECTURE §4)
// MARK: - Persistence (ARCHITECTURE §4)

/// Store en Application Support; si falla, arranca en memoria con alerta única (SPEC §6.10).
/// Store in Application Support; on failure it starts in memory with a single alert (SPEC §6.10).
private static func makeContainer() -> (ModelContainer, usedFallback: Bool) {
let schema = Schema([TaskItem.self, TaskCategory.self])
do {
Expand All @@ -192,7 +192,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
} catch {
NSLog("Corner: no se pudo abrir el store en disco, usando memoria: \(error)")
let config = ModelConfiguration(isStoredInMemoryOnly: true)
// Si ni el store en memoria puede crearse, no hay app posible.
// If not even the in-memory store can be created, there is no app to run.
let container = try! ModelContainer(for: schema, configurations: config)
return (container, true)
}
Expand All @@ -206,8 +206,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
alert.runModal()
}

/// Un guardado o consulta falló en caliente (SPEC §6.10): avisar una sola
/// vez por sesión, nunca fallar en silencio.
/// A save or fetch failed at runtime (SPEC §6.10): warn once per session,
/// never fail silently.
private var didShowPersistenceAlert = false

private func showPersistenceAlertOnce(_ error: Error) {
Expand All @@ -229,8 +229,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}

/// Referencia débil para que los closures de notificaciones (no aislados)
/// reentren al MainActor sin capturar self.
/// Weak reference so the notification closures (non-isolated) can re-enter
/// the MainActor without capturing self.
@MainActor
private enum AppAccess {
static weak var shared: AppDelegate?
Expand Down
20 changes: 10 additions & 10 deletions Corner/Domain/DayClock.swift
Original file line number Diff line number Diff line change
@@ -1,30 +1,30 @@
import Foundation

/// Única fuente de "¿qué día es hoy?". Nadie llama Date() para lógica de fechas
/// fuera de este archivo (ARCHITECTURE §3.3); eso hace testeable el rollover
/// y la vista semanal con fechas fijas.
/// Single source of "what day is today?". Nobody calls Date() for date logic
/// outside this file (ARCHITECTURE §3.3); that is what makes the rollover
/// and the week view testable with fixed dates.
protocol DayClock {
var now: Date { get }
var calendar: Calendar { get }
}

extension DayClock {
/// Hoy, normalizado a startOfDay.
/// Today, normalized to startOfDay.
var today: Date { calendar.startOfDay(for: now) }

func startOfDay(_ date: Date) -> Date { calendar.startOfDay(for: date) }

/// Los 7 días (startOfDay) de la semana actual, lunes a domingo.
/// The 7 days (startOfDay) of the current week, Monday to Sunday.
func currentWeek() -> [Date] {
var cal = calendar
cal.firstWeekday = 2 // lunes
cal.firstWeekday = 2 // Monday
guard let interval = cal.dateInterval(of: .weekOfYear, for: now) else { return [] }
return (0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: interval.start) }
}

/// Destinos de "Mover a…" (SPEC §4.1.5): mañana + días restantes de la
/// semana + lunes próximo en fin de semana. Ordenados; el primero es
/// siempre mañana.
/// "Move to…" destinations (SPEC §4.1.5): tomorrow + the remaining days of
/// the week + next Monday on weekends. Sorted; the first one is always
/// tomorrow.
func moveDestinations() -> [Date] {
let today = self.today
var days = Set(currentWeek().filter { $0 > today })
Expand All @@ -45,7 +45,7 @@ struct SystemDayClock: DayClock {
var calendar: Calendar { Calendar.current }
}

/// Reloj fijo para tests.
/// Fixed clock for tests.
struct FixedDayClock: DayClock {
var now: Date
var calendar: Calendar = Calendar(identifier: .gregorian)
Expand Down
16 changes: 8 additions & 8 deletions Corner/Domain/HotCornerLogic.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import Foundation
import CoreGraphics

/// Máquina de estados pura del hot corner (SPEC §2): permanencia, cooldown y
/// suspensión, sin timers ni AppKit — el crash de reordenar demostró que la
/// capa sin tests es donde viven los bugs, así que la decisión de "cuándo
/// disparar" se saca del monitor y se testea aquí.
/// Pure state machine for the hot corner (SPEC §2): dwell, cooldown and
/// suspension, with no timers or AppKit — the reorder crash proved that the
/// untested layer is where bugs live, so the "when to fire" decision was
/// pulled out of the monitor and is tested here.
struct HotCornerLogic {
var zoneSize: CGFloat = 12
var dwell: TimeInterval = 0.25
Expand All @@ -19,8 +19,8 @@ struct HotCornerLogic {
case trigger
}

/// Un tick de polling: posición del cursor, frame de la pantalla que lo
/// contiene (nil si ninguna) y el instante actual.
/// One polling tick: cursor position, frame of the screen containing it
/// (nil if none) and the current instant.
mutating func poll(location: CGPoint, screenFrame: CGRect?, at now: Date) -> Verdict {
guard !suspended, let frame = screenFrame else {
dwellStart = nil
Expand All @@ -45,13 +45,13 @@ struct HotCornerLogic {
return .none
}

/// El panel está visible: no re-disparar.
/// The panel is visible: do not fire again.
mutating func suspend() {
suspended = true
dwellStart = nil
}

/// El panel se cerró: reanudar con cooldown (SPEC §2).
/// The panel closed: resume with a cooldown (SPEC §2).
mutating func resumeAfterCooldown(at now: Date) {
suspended = false
dwellStart = nil
Expand Down
36 changes: 18 additions & 18 deletions Corner/Domain/ReminderSchedule.swift
Original file line number Diff line number Diff line change
@@ -1,36 +1,36 @@
import Foundation

/// Alarma pendiente de programar (SPEC §4.1.6). Struct neutral: el dominio
/// no conoce UserNotifications ni EventKit.
/// An alarm pending scheduling (SPEC §4.1.6). Neutral struct: the domain
/// knows nothing about UserNotifications or EventKit.
struct PendingReminder: Equatable, Sendable {
var taskID: UUID
var title: String
var fireDate: Date
}

/// Qué alarmas deben estar programadas y a qué hora exacta. Función pura:
/// toda la política (pendientes, futuras, solo nivel superior) se testea aquí
/// en vez de dentro del scheduler del sistema.
/// Which alarms must be scheduled and at what exact time. Pure function:
/// the whole policy (pending, future, top-level only) is tested here
/// instead of inside the system scheduler.
enum ReminderSchedule {
/// Vista mínima de una tarea para decidir su alarma.
/// Minimal view of a task for deciding its alarm.
struct Candidate: Equatable, Sendable {
var id: UUID
var title: String
/// startOfDay del día asignado.
/// startOfDay of the assigned day.
var day: Date
var isDone: Bool
var remindMinutes: Int?
var isTopLevel: Bool
}

/// Instante exacto del aviso: día asignado + minutos.
/// Exact instant of the alert: assigned day + minutes.
static func fireDate(day: Date, minutes: Int, calendar: Calendar) -> Date {
calendar.date(byAdding: .minute, value: minutes,
to: calendar.startOfDay(for: day)) ?? day
}

/// Alarmas a programar: nivel superior, pendientes, con hora, y cuyo
/// instante aún no pasó. Ordenadas por cercanía.
/// Alarms to schedule: top-level, pending, with a time, and whose instant
/// has not passed yet. Sorted by proximity.
static func pending(from candidates: [Candidate], now: Date,
calendar: Calendar = .current) -> [PendingReminder] {
candidates.compactMap { candidate -> PendingReminder? in
Expand All @@ -44,15 +44,15 @@ enum ReminderSchedule {
.sorted { $0.fireDate < $1.fireDate }
}

// MARK: - Opciones del menú "Recordarme" (SPEC §4.1.6)
// MARK: - "Remind me" menu options (SPEC §4.1.6)

/// Horas absolutas ofrecidas, en minutos desde medianoche.
/// Absolute times offered, in minutes from midnight.
static let presetMinutes = [9 * 60, 12 * 60, 15 * 60, 18 * 60, 21 * 60]
/// Desplazamientos relativos ofrecidos, en minutos (solo para hoy).
/// Relative offsets offered, in minutes (today only).
static let relativeOffsets = [5, 30, 60, 180]

/// Presets válidos para un día: en el día de hoy solo las horas que aún
/// no pasaron; en días futuros, todas.
/// Valid presets for a day: on today, only the times that have not passed
/// yet; on future days, all of them.
static func availablePresets(for day: Date, now: Date,
calendar: Calendar = .current) -> [Int] {
let isToday = calendar.isDate(day, inSameDayAs: now)
Expand All @@ -62,8 +62,8 @@ enum ReminderSchedule {
return presetMinutes.filter { $0 > currentMinutes }
}

/// Minutos del día correspondientes a "dentro de N minutos"; nil si eso
/// cruzaría a mañana (la alarma vive dentro de su día).
/// Minutes of the day for "in N minutes"; nil if that would cross into
/// tomorrow (an alarm lives within its own day).
static func minutesFromNow(_ offset: Int, now: Date,
calendar: Calendar = .current) -> Int? {
let target = now.addingTimeInterval(TimeInterval(offset * 60))
Expand All @@ -72,7 +72,7 @@ enum ReminderSchedule {
+ calendar.component(.minute, from: target)
}

/// "14:30" a partir de los minutos del día.
/// "14:30" from the minutes of the day.
static func label(forMinutes minutes: Int) -> String {
String(format: "%02d:%02d", minutes / 60, minutes % 60)
}
Expand Down
Loading
Loading