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
24 changes: 24 additions & 0 deletions macOS/Synapse.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

142 changes: 141 additions & 1 deletion macOS/Synapse/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,12 @@ struct TemplateRenameRequest: Identifiable {
}

// MARK: - Tab Item
/// Represents an item that can be displayed in a tab - either a file or a tag
/// Represents an item that can be displayed in a tab - either a file, tag, graph, or date view
enum TabItem: Hashable {
case file(URL)
case tag(String)
case graph
case date(Date)

var displayName: String {
switch self {
Expand All @@ -76,6 +77,10 @@ enum TabItem: Hashable {
return "#\(tagName)"
case .graph:
return "Graph"
case .date(let date):
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
}

Expand All @@ -94,6 +99,11 @@ enum TabItem: Hashable {
return false
}

var isDate: Bool {
if case .date = self { return true }
return false
}

var fileURL: URL? {
if case .file(let url) = self { return url }
return nil
Expand All @@ -103,6 +113,11 @@ enum TabItem: Hashable {
if case .tag(let name) = self { return name }
return nil
}

var dateValue: Date? {
if case .date(let date) = self { return date }
return nil
}
}

// MARK: - Split Pane
Expand Down Expand Up @@ -701,6 +716,15 @@ class AppState: ObservableObject {
pendingCursorRange = nil
pendingScrollOffsetY = nil
pendingCursorTargetPaneIndex = nil
case .date:
// Date tab - clear file state (date view shows note lists, not a single file)
selectedFile = nil
fileContent = ""
isDirty = false
stopWatching()
pendingCursorRange = nil
pendingScrollOffsetY = nil
pendingCursorTargetPaneIndex = nil
}

// Write runtime state file
Expand Down Expand Up @@ -1230,6 +1254,58 @@ class AppState: ObservableObject {
}
}

/// Returns all notes created on a specific date.
/// Results are sorted descending by creation date (newest first).
func notesCreatedOnDate(_ date: Date) -> [URL] {
let calendar = Calendar.current
let targetDay = calendar.startOfDay(for: date)

let matchingFiles = allFiles.filter { url in
guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
let creationDate = attributes[.creationDate] as? Date else {
return false
}
let fileDay = calendar.startOfDay(for: creationDate)
return fileDay == targetDay
}

// Sort by creation date descending (newest first)
return matchingFiles.sorted { url1, url2 in
let date1 = (try? FileManager.default.attributesOfItem(atPath: url1.path)[.creationDate] as? Date) ?? Date.distantPast
let date2 = (try? FileManager.default.attributesOfItem(atPath: url2.path)[.creationDate] as? Date) ?? Date.distantPast
return date1 > date2
}
}

/// Returns all notes modified on a specific date (including notes modified after creation
/// on the same day they were created).
/// Results are sorted descending by modification date (newest first).
func notesModifiedOnDate(_ date: Date) -> [URL] {
let calendar = Calendar.current
let targetDay = calendar.startOfDay(for: date)

let matchingFiles = allFiles.filter { url in
guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
let creationDate = attributes[.creationDate] as? Date,
let modificationDate = attributes[.modificationDate] as? Date else {
return false
}

let modificationDay = calendar.startOfDay(for: modificationDate)

// Only count if modified on target day AND modification time is strictly after creation
// This includes notes modified later on the same day they were created
return modificationDay == targetDay && modificationDate > creationDate
}

// Sort by modification date descending (newest first)
return matchingFiles.sorted { url1, url2 in
let date1 = (try? FileManager.default.attributesOfItem(atPath: url1.path)[.modificationDate] as? Date) ?? Date.distantPast
let date2 = (try? FileManager.default.attributesOfItem(atPath: url2.path)[.modificationDate] as? Date) ?? Date.distantPast
return date1 > date2
}
}

/// Returns the cached note title → URL index.
/// Falls back to building from allFiles if the cache hasn't been populated yet
/// (e.g. during the first launch before the scan completes).
Expand Down Expand Up @@ -2576,6 +2652,70 @@ class AppState: ObservableObject {
recordTabRecency(for: .tag(tag))
}

func openDate(_ date: Date) {
captureCurrentTabEditorState(in: activePaneIndex)

// Normalize date to start of day for consistent comparison
let calendar = Calendar.current
let normalizedDate = calendar.startOfDay(for: date)

// If this date is already open in a tab, just switch to it
if let existingIndex = tabs.firstIndex(of: .date(normalizedDate)) {
switchTab(to: existingIndex)
return
}

// Replace current tab or add new tab if none exists
if let activeTabIndex = activeTabIndex, tabs.indices.contains(activeTabIndex) {
tabs[activeTabIndex] = .date(normalizedDate)
} else {
tabs.append(.date(normalizedDate))
self.activeTabIndex = tabs.count - 1
}

// Clear file-related state since we're viewing a date
selectedFile = nil
fileContent = ""
isDirty = false
stopWatching()
pendingCursorRange = nil
pendingScrollOffsetY = nil
pendingCursorTargetPaneIndex = nil

// Update recency
recordTabRecency(for: .date(normalizedDate))
}

func openDateInNewTab(_ date: Date) {
captureCurrentTabEditorState(in: activePaneIndex)

// Normalize date to start of day for consistent comparison
let calendar = Calendar.current
let normalizedDate = calendar.startOfDay(for: date)

// If date already open in a tab, just switch to it
if let existingIndex = tabs.firstIndex(of: .date(normalizedDate)) {
switchTab(to: existingIndex)
return
}

// Add new date tab
tabs.append(.date(normalizedDate))
activeTabIndex = tabs.count - 1

// Clear file-related state since we're viewing a date
selectedFile = nil
fileContent = ""
isDirty = false
stopWatching()
pendingCursorRange = nil
pendingScrollOffsetY = nil
pendingCursorTargetPaneIndex = nil

// Update recency
recordTabRecency(for: .date(normalizedDate))
}

func closeTab(at index: Int) {
guard index >= 0 && index < tabs.count else { return }

Expand Down
121 changes: 121 additions & 0 deletions macOS/Synapse/CalendarDayActivityCalculator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import Foundation

/// Calculates note activity levels for calendar days and computes badge sizes.
/// Badge sizes use logarithmic scaling to prevent outliers from skewing visibility.
struct CalendarDayActivityCalculator {
let calendar: Calendar

init(calendar: Calendar = .current) {
self.calendar = calendar
}

/// Returns the activity count (number of notes) for a specific date.
/// The date is normalized to the start of the day for consistent lookup.
func activityCount(for date: Date, in activityMap: [Date: Int]) -> Int {
let normalizedDate = calendar.startOfDay(for: date)
return activityMap[normalizedDate] ?? 0
}

/// Calculates the badge size for a specific date using logarithmic scaling.
/// This prevents one high-activity day from making all other badges tiny.
/// - Parameters:
/// - date: The date to calculate the badge size for
/// - activityMap: Map of normalized dates to activity counts
/// - maxSize: The maximum badge size (cap)
/// - minSize: The minimum badge size for any activity
/// - Returns: The calculated badge size
func badgeSize(for date: Date, in activityMap: [Date: Int], maxSize: CGFloat, minSize: CGFloat = 8) -> CGFloat {
guard !activityMap.isEmpty else { return 0 }

let count = activityCount(for: date, in: activityMap)
guard count > 0 else { return 0 }

// Use 95th percentile as the reference max to avoid outlier skew
let referenceMax = percentileActivity(in: activityMap, percentile: 0.95)
guard referenceMax > 0 else { return minSize }

// Logarithmic scaling: log(count + 1) / log(referenceMax + 1)
// This gives better visual differentiation for low-to-moderate activity
let logCount = log(Double(count) + 1)
let logMax = log(Double(referenceMax) + 1)
let ratio = logCount / logMax

// Scale between minSize and maxSize
let scaledSize = minSize + (maxSize - minSize) * CGFloat(ratio)
return min(scaledSize, maxSize)
}

/// Returns the maximum activity count in the activity map.
func maxActivity(in activityMap: [Date: Int]) -> Int {
activityMap.values.max() ?? 0
}

/// Returns the activity count at a given percentile (0.0 to 1.0).
/// Used to find a reference max that isn't skewed by outliers.
func percentileActivity(in activityMap: [Date: Int], percentile: Double) -> Int {
let values = activityMap.values.sorted()
guard !values.isEmpty else { return 0 }

let index = Int(Double(values.count - 1) * percentile)
return values[index]
}

/// Filters the activity map to only include dates within the same month as the reference date.
/// All dates in the returned map are normalized to the start of their respective days.
func monthActivityMap(for referenceDate: Date, from activityMap: [Date: Int]) -> [Date: Int] {
let components = calendar.dateComponents([.year, .month], from: referenceDate)

var result: [Date: Int] = [:]
for (date, count) in activityMap {
let dateComponents = calendar.dateComponents([.year, .month], from: date)
if dateComponents.year == components.year && dateComponents.month == components.month {
let normalizedDate = calendar.startOfDay(for: date)
result[normalizedDate] = count
}
}

return result
}

/// Builds an activity map from a collection of notes.
/// A note contributes to the activity count of:
/// - Its creation date day
/// - Its modification date day (if different from creation)
///
/// The resulting map uses normalized dates (start of day) as keys.
func buildActivityMap<T: NoteActivityProviding>(from notes: [T]) -> [Date: Int] {
var activityMap: [Date: Int] = [:]

for note in notes {
let createdDay = calendar.startOfDay(for: note.created)
let modifiedDay = calendar.startOfDay(for: note.modified)

// Always count the creation day
activityMap[createdDay, default: 0] += 1

// Count the modification day if different from creation
if modifiedDay != createdDay {
activityMap[modifiedDay, default: 0] += 1
}
}

return activityMap
}
}

/// Protocol for objects that provide note activity information.
/// Used by CalendarDayActivityCalculator to build activity maps.
protocol NoteActivityProviding {
var url: URL { get }
var created: Date { get }
var modified: Date { get }
}

// MARK: - URL Helpers

extension URL {
/// Helper for creating file URLs with a more explicit label.
static func file(urlPath: String) -> URL {
URL(fileURLWithPath: urlPath)
}
}
Loading
Loading