diff --git a/macOS/Synapse.xcodeproj/project.pbxproj b/macOS/Synapse.xcodeproj/project.pbxproj index 3120907..7c4808d 100644 --- a/macOS/Synapse.xcodeproj/project.pbxproj +++ b/macOS/Synapse.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 149B715AD9DF9829286F386F /* GistPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88EA4153C25D088562FAC1C /* GistPublisher.swift */; }; 1626A8B12BFDAD5B528B6ECA /* SidebarDragDropTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D6B6923B1814AF9178F7B51 /* SidebarDragDropTests.swift */; }; 168BD2D7D140EB2F1BC65CBC /* VaultIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49A381F0D18E754A88B51A80 /* VaultIndex.swift */; }; + 1A911A8A04B7386F14EFC000 /* DatePageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B15898506DA642E05E2E0704 /* DatePageView.swift */; }; 1B8470063FF3F149A02D581B /* AppConstantsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD119E93048F3C571E60BD8A /* AppConstantsTests.swift */; }; 1BC0B2F0938220AACB87EEE5 /* AppStateTagTabsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A798505F66B8166624E3427B /* AppStateTagTabsTests.swift */; }; 1DE02F37A58DEFD8A7517790 /* AppStateCloseTabAutoSaveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5B2A3616AB5C3D1E0A2F97F /* AppStateCloseTabAutoSaveTests.swift */; }; @@ -48,6 +49,7 @@ 374896C6C09F48147147D1BD /* AppStateSplitPaneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D4F955DD37FCF31EA4E20E7 /* AppStateSplitPaneTests.swift */; }; 37B5E15E3F64988C8315C4B2 /* ContentCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2521831A835D4452BEFF98A /* ContentCacheTests.swift */; }; 37D9958A42C0B10CA58FF96E /* AppStateTagsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1AEA1E774C98F1243030F5 /* AppStateTagsTests.swift */; }; + 37DD887F373E78B7A956950E /* AppStateDateTabTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279485A9034A0683F711F6B3 /* AppStateDateTabTests.swift */; }; 3922732A4CB7398AD1BB100F /* MarkdownEditorInlineSemanticStylesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BACA7AE5D4E066C647E7063E /* MarkdownEditorInlineSemanticStylesTests.swift */; }; 399BAD4F8D874258D0F43EBB /* GlobalGraphView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3CE451A43640282F817405D /* GlobalGraphView.swift */; }; 3A7ED42BFA879ABEA38CD06D /* Grape in Frameworks */ = {isa = PBXBuildFile; productRef = E3D1D630282C1FE7995BB335 /* Grape */; }; @@ -63,6 +65,7 @@ 452CC50BA74921E6D69CE307 /* CommandPaletteScoringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FAFBB2B334B2C5C148C42E6 /* CommandPaletteScoringTests.swift */; }; 45EBBE248217B2FE2005E428 /* AppStateDailyNotesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7186E8801E8BC8BAA0678D /* AppStateDailyNotesTests.swift */; }; 49CFC13D35F5F772C572BA0F /* AppStateTemplateRenameTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3EB97BB362AAC57D3AF9F8E /* AppStateTemplateRenameTests.swift */; }; + 4A963D450EF3EA0CC87ECA01 /* CalendarDayActivityCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A15F4D02024CEC0C6D118D0D /* CalendarDayActivityCalculatorTests.swift */; }; 4DCEA0C615CA64683100C661 /* TemplatesDirectoryUIBehaviorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47250559CE52FF1D2C46C549 /* TemplatesDirectoryUIBehaviorTests.swift */; }; 5027113BDFD3F4EFCBB359A4 /* FileTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9573DDE37ACEA626605DC7AF /* FileTreeView.swift */; }; 52E378E429A5FE561E26291E /* SearchIndexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE3C045946343A8A05384584 /* SearchIndexTests.swift */; }; @@ -114,6 +117,7 @@ 897A06A0A6C817BE9AAAAA03 /* AppStateCommandPaletteGuardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 147AFC9C373247489F744397 /* AppStateCommandPaletteGuardTests.swift */; }; 899061DD86BA4E4A6719C95B /* AppStateNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6076EE60ED15C4BA9011BE7 /* AppStateNavigationTests.swift */; }; 89E3EA85538AA25FAAB34501 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = C4C7BBA308D786C6CE2F1328 /* SwiftTerm */; }; + 8AC37ADAD3179D24D35DBAB1 /* CalendarPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89BAED87C130E2C30AE200FD /* CalendarPaneView.swift */; }; 8B53A015F2ED2112F0131B2B /* GitErrorHostnameExtractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C248544EA6817276B7203C84 /* GitErrorHostnameExtractionTests.swift */; }; 8CBDA9B8ADE2DF2DA5C2C075 /* MarkdownPreviewRendererTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 533FB35FAD975DD196500CCA /* MarkdownPreviewRendererTests.swift */; }; 8DF6C8091C8EF8192BDDCDD1 /* CollapsibleSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2378A288D2FE5AEAA19A8EA /* CollapsibleSection.swift */; }; @@ -138,6 +142,7 @@ 9C491E4C25AF389520BC1BD1 /* NoteGraphModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D419E073C2917DEE1A85E20 /* NoteGraphModelTests.swift */; }; 9E080881DF7B54EB62019205 /* KeyCodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 716125BBC502DD8F555057F7 /* KeyCodeTests.swift */; }; 9E9FB2AC8B6C764F7F4DD366 /* AppStatePinnedFolderFocusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE443AECAB749CEBD9058E7C /* AppStatePinnedFolderFocusTests.swift */; }; + 9EC02229A8FFE70905BE9465 /* CalendarDayActivityCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 556021F9F9711240A933E0D0 /* CalendarDayActivityCalculator.swift */; }; A033139B390220EE576027A0 /* AppStateCurrentDirectoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A59692ED432DF0BD26FADE0 /* AppStateCurrentDirectoryTests.swift */; }; A0988EBDF1218D4D58E2B842 /* FontEnumeratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A37B5687F020E8B15BBA70 /* FontEnumeratorTests.swift */; }; A3005E593A077EC5A3F1A612 /* MarkdownTaskCheckboxInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6300BFA84F1EDAD32147A4E2 /* MarkdownTaskCheckboxInteraction.swift */; }; @@ -183,6 +188,7 @@ DB7BB560268ADEDB34E2A56B /* AppStateRelatedLinksTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 272B2D760A5DF243524A2DF1 /* AppStateRelatedLinksTests.swift */; }; DBA2C58B64AA4C690BD3A4F3 /* SyntaxHighlightingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 318FE7CEE90A23A1237EF0F1 /* SyntaxHighlightingTests.swift */; }; DC6C566BCC20D6F619A926CB /* FileTreeSortingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BD983D6D3F278E0AD47E0D1 /* FileTreeSortingTests.swift */; }; + E02E616863B84EEFECF5DE5E /* AppStateDateFilteringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9C59A9091EA8E6FE3683544 /* AppStateDateFilteringTests.swift */; }; E0730BEEF94695E2F06AFE72 /* GraphNodeColorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C55EAAB85AA800B037CDF588 /* GraphNodeColorTests.swift */; }; E08D238D2FCB17F5749FD3C1 /* AppStateCloneRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E6A63BB6A6141AA83800A2E /* AppStateCloneRepositoryTests.swift */; }; E1BF2B6AC2C37076F6B646F1 /* GitignoreFileScanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD8C8C252137B5144D88F2C /* GitignoreFileScanTests.swift */; }; @@ -247,6 +253,7 @@ 26693477A455D0812BEF659A /* SettingsManagerBrowserStartupURLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerBrowserStartupURLTests.swift; sourceTree = ""; }; 26C7B58403081D448E5999D8 /* AppStateTemplatesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTemplatesTests.swift; sourceTree = ""; }; 272B2D760A5DF243524A2DF1 /* AppStateRelatedLinksTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateRelatedLinksTests.swift; sourceTree = ""; }; + 279485A9034A0683F711F6B3 /* AppStateDateTabTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateDateTabTests.swift; sourceTree = ""; }; 29CA9E8A87139F33100BCC83 /* CommandPaletteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteView.swift; sourceTree = ""; }; 2A6300042BEBE1EFB7DCA293 /* SettingsManagerThemeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerThemeTests.swift; sourceTree = ""; }; 2ADBC72A6A85DB161568424D /* GraphPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphPaneView.swift; sourceTree = ""; }; @@ -282,6 +289,7 @@ 4E033CC3C89CE74D228468EF /* FileTreeDragDropTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTreeDragDropTests.swift; sourceTree = ""; }; 533FB35FAD975DD196500CCA /* MarkdownPreviewRendererTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownPreviewRendererTests.swift; sourceTree = ""; }; 53D9D2ED28ABE373443038FE /* PullAndRefreshWIPAutoSaveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullAndRefreshWIPAutoSaveTests.swift; sourceTree = ""; }; + 556021F9F9711240A933E0D0 /* CalendarDayActivityCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayActivityCalculator.swift; sourceTree = ""; }; 56691DF2CDDFA2DF5F263CF3 /* AppStateCoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateCoreTests.swift; sourceTree = ""; }; 575EBDEB0FCDE2BC05FFF26C /* MarkdownPreviewCSS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownPreviewCSS.swift; sourceTree = ""; }; 581F07CA2599C2ED63315D92 /* AppStateFolderOperationsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateFolderOperationsTests.swift; sourceTree = ""; }; @@ -329,6 +337,7 @@ 879B52DC9366FD1D0BA57D20 /* AppStateTemplateVariablesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTemplateVariablesTests.swift; sourceTree = ""; }; 880F3BB5427731925CF20907 /* MarkdownEditorInlineSemanticStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownEditorInlineSemanticStyles.swift; sourceTree = ""; }; 89A37B5687F020E8B15BBA70 /* FontEnumeratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontEnumeratorTests.swift; sourceTree = ""; }; + 89BAED87C130E2C30AE200FD /* CalendarPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarPaneView.swift; sourceTree = ""; }; 8A03956EC9D388F74A50F6F3 /* ImageSidebarEmbedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSidebarEmbedTests.swift; sourceTree = ""; }; 8B1F0DD61D59D79D8119FB6A /* InlineTagClickTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineTagClickTests.swift; sourceTree = ""; }; 8DC2A24B73EE5F6505170332 /* AppStateSettingsPropagationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateSettingsPropagationTests.swift; sourceTree = ""; }; @@ -349,6 +358,7 @@ 9D6B6923B1814AF9178F7B51 /* SidebarDragDropTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarDragDropTests.swift; sourceTree = ""; }; 9D9C3A2B05AAC380C1B9F486 /* GistPublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GistPublisherTests.swift; sourceTree = ""; }; A0F7229E12831164A430591D /* SettingsManagerFontSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerFontSettingsTests.swift; sourceTree = ""; }; + A15F4D02024CEC0C6D118D0D /* CalendarDayActivityCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayActivityCalculatorTests.swift; sourceTree = ""; }; A2378A288D2FE5AEAA19A8EA /* CollapsibleSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleSection.swift; sourceTree = ""; }; A307C880E354FA57E8BFEB62 /* CollapsibleSectionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleSectionsTests.swift; sourceTree = ""; }; A4CC0773A494F79664F79AD7 /* SettingsManagerFileTreeModeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerFileTreeModeTests.swift; sourceTree = ""; }; @@ -359,6 +369,7 @@ AC8CA87990A19BCF411A9424 /* ImagePasteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePasteTests.swift; sourceTree = ""; }; ACDCB904B2D9DC31F370D424 /* SynapseThemeLayoutConstantsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynapseThemeLayoutConstantsTests.swift; sourceTree = ""; }; AF32B4A42DB4CCC2F89A19C2 /* GitAutoSaveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitAutoSaveTests.swift; sourceTree = ""; }; + B15898506DA642E05E2E0704 /* DatePageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatePageView.swift; sourceTree = ""; }; B27EA6038CE434E63D723B28 /* AppStateInactivePaneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateInactivePaneTests.swift; sourceTree = ""; }; B2933BCDF0B2B56488D2D9F4 /* TerminalPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPaneView.swift; sourceTree = ""; }; B36D775B1AD89841EFA0C7C7 /* MarkdownTablePrettifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownTablePrettifier.swift; sourceTree = ""; }; @@ -378,6 +389,7 @@ C55EAAB85AA800B037CDF588 /* GraphNodeColorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphNodeColorTests.swift; sourceTree = ""; }; C5928863F052D71F95DEA4C4 /* TaskListCheckboxInteractionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListCheckboxInteractionTests.swift; sourceTree = ""; }; C5F2CB84A6974094C752A300 /* MarkdownTaskCheckboxMatchesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownTaskCheckboxMatchesTests.swift; sourceTree = ""; }; + C9C59A9091EA8E6FE3683544 /* AppStateDateFilteringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateDateFilteringTests.swift; sourceTree = ""; }; CB3B1F42FA2D44B23BE869BF /* SidebarPaneItemCodableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarPaneItemCodableTests.swift; sourceTree = ""; }; CB8BA24E7E55C8EF0A3530F0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; CC1886EC1E29B04F46A977BF /* AppStateTemplatesDirNormalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTemplatesDirNormalizationTests.swift; sourceTree = ""; }; @@ -442,10 +454,13 @@ 2137851A65FC9A0A2599C8BE /* AppTheme.swift */, CB8BA24E7E55C8EF0A3530F0 /* Assets.xcassets */, 077732396E925C86B668C094 /* AutoUpdater.swift */, + 556021F9F9711240A933E0D0 /* CalendarDayActivityCalculator.swift */, + 89BAED87C130E2C30AE200FD /* CalendarPaneView.swift */, A2378A288D2FE5AEAA19A8EA /* CollapsibleSection.swift */, 29CA9E8A87139F33100BCC83 /* CommandPaletteView.swift */, 4A1C79B3232B98A6F756FE47 /* Constants.swift */, 45E413806B3F56C9791BDD79 /* ContentView.swift */, + B15898506DA642E05E2E0704 /* DatePageView.swift */, B916ADB0587845108BD3EF62 /* EditorModeToggle.swift */, CD24A6CF1D5D237DEE3390E6 /* EditorState.swift */, 6DF7018244BB6E635327C670 /* EditorView.swift */, @@ -513,6 +528,8 @@ 56691DF2CDDFA2DF5F263CF3 /* AppStateCoreTests.swift */, 7A59692ED432DF0BD26FADE0 /* AppStateCurrentDirectoryTests.swift */, 7F7186E8801E8BC8BAA0678D /* AppStateDailyNotesTests.swift */, + C9C59A9091EA8E6FE3683544 /* AppStateDateFilteringTests.swift */, + 279485A9034A0683F711F6B3 /* AppStateDateTabTests.swift */, F0B00871B184157E21F3C346 /* AppStateEditModeTests.swift */, 490C9CA7FD044A526559CA53 /* AppStateExitVaultFullTests.swift */, F2819956191CAC51CF76DAFA /* AppStateFileOperationsTests.swift */, @@ -549,6 +566,7 @@ BBB767AA001E28102BB91278 /* AsyncFileScanTests.swift */, 1F6B970F5933269DFCBB921C /* AutoUpdaterFetchTests.swift */, 0DE78783DAFB7D972767FA82 /* AutoUpdaterTests.swift */, + A15F4D02024CEC0C6D118D0D /* CalendarDayActivityCalculatorTests.swift */, 0A550D18D4F2CA38FCA1D0B8 /* CodeBlockCopyButtonTests.swift */, 71DDA03EE1F69F7782A6C13A /* CodeBlockLayoutTests.swift */, A307C880E354FA57E8BFEB62 /* CollapsibleSectionsTests.swift */, @@ -768,6 +786,8 @@ 716F196A70B019DB1125E38F /* AppStateCoreTests.swift in Sources */, A033139B390220EE576027A0 /* AppStateCurrentDirectoryTests.swift in Sources */, 45EBBE248217B2FE2005E428 /* AppStateDailyNotesTests.swift in Sources */, + E02E616863B84EEFECF5DE5E /* AppStateDateFilteringTests.swift in Sources */, + 37DD887F373E78B7A956950E /* AppStateDateTabTests.swift in Sources */, 3DF79EAA61F88614FB9F2F89 /* AppStateEditModeTests.swift in Sources */, 726A539D32A11574BB72A17D /* AppStateExitVaultFullTests.swift in Sources */, 61F38C954D4BD659595F04D8 /* AppStateFileOperationsTests.swift in Sources */, @@ -804,6 +824,7 @@ 895F0B858ADA0B50766DCA00 /* AsyncFileScanTests.swift in Sources */, C2CD6237CEEEBDC1FFBAB0BA /* AutoUpdaterFetchTests.swift in Sources */, AE9908CACA946E363AF49D5A /* AutoUpdaterTests.swift in Sources */, + 4A963D450EF3EA0CC87ECA01 /* CalendarDayActivityCalculatorTests.swift in Sources */, B87F1BB42DB175A44CDDAE72 /* CodeBlockCopyButtonTests.swift in Sources */, 6460A69C604E78BA3A371AE9 /* CodeBlockLayoutTests.swift in Sources */, E84D8DF773FF8BED5152D2BC /* CollapsibleSectionsTests.swift in Sources */, @@ -917,10 +938,13 @@ 879C70A3EEC9637B2E355681 /* AppState.swift in Sources */, 2251992111A29E18A3DBA6EC /* AppTheme.swift in Sources */, C1CD8FA28738B344240C177B /* AutoUpdater.swift in Sources */, + 9EC02229A8FFE70905BE9465 /* CalendarDayActivityCalculator.swift in Sources */, + 8AC37ADAD3179D24D35DBAB1 /* CalendarPaneView.swift in Sources */, 8DF6C8091C8EF8192BDDCDD1 /* CollapsibleSection.swift in Sources */, 59A6F7BB258D1FF977C53B25 /* CommandPaletteView.swift in Sources */, 95878F8A850A56ED9838178A /* Constants.swift in Sources */, 5705294841067700B4957F48 /* ContentView.swift in Sources */, + 1A911A8A04B7386F14EFC000 /* DatePageView.swift in Sources */, 0FC57BB32EFF6F4BE66FFA95 /* EditorModeToggle.swift in Sources */, E48F37F260D24AE089E48440 /* EditorState.swift in Sources */, 589CC9B2EF571DD4C8E39414 /* EditorView.swift in Sources */, diff --git a/macOS/Synapse/AppState.swift b/macOS/Synapse/AppState.swift index 5ea6f1f..9c83c08 100644 --- a/macOS/Synapse/AppState.swift +++ b/macOS/Synapse/AppState.swift @@ -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 { @@ -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) } } @@ -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 @@ -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 @@ -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 @@ -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). @@ -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 } diff --git a/macOS/Synapse/CalendarDayActivityCalculator.swift b/macOS/Synapse/CalendarDayActivityCalculator.swift new file mode 100644 index 0000000..668ff5a --- /dev/null +++ b/macOS/Synapse/CalendarDayActivityCalculator.swift @@ -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(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) + } +} diff --git a/macOS/Synapse/CalendarPaneView.swift b/macOS/Synapse/CalendarPaneView.swift new file mode 100644 index 0000000..06f60b0 --- /dev/null +++ b/macOS/Synapse/CalendarPaneView.swift @@ -0,0 +1,251 @@ +import SwiftUI + +/// A calendar widget for the sidebar that shows note activity by day. +/// - Days with note activity show a badge behind the date number +/// - Badge size scales with activity level (number of notes created/modified) +/// - Today's date is highlighted with the accent color +/// - Clicking a day opens a tab showing notes from that day +struct CalendarPaneView: View { + @EnvironmentObject var appState: AppState + @EnvironmentObject var themeEnv: ThemeEnvironment + + @State private var currentMonth: Date = Date() + @State private var activityCalculator = CalendarDayActivityCalculator() + + private let calendar = Calendar.current + private let maxBadgeSize: CGFloat = 18 + private let minBadgeSize: CGFloat = 4 + + var body: some View { + VStack(spacing: 0) { + // Month navigation header + HStack { + Button(action: previousMonth) { + Image(systemName: "chevron.left") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(SynapseTheme.textMuted) + } + .buttonStyle(.plain) + + Spacer() + + Text(monthYearString(from: currentMonth)) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(SynapseTheme.textPrimary) + + Spacer() + + Button(action: nextMonth) { + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(SynapseTheme.textMuted) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + + Divider() + .background(SynapseTheme.divider) + + // Day headers (Sun, Mon, Tue, etc.) + HStack(spacing: 0) { + ForEach(dayHeaders, id: \.self) { day in + Text(day) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(SynapseTheme.textMuted) + .frame(maxWidth: .infinity) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + + // Calendar grid + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 7), spacing: 2) { + ForEach(daysInMonth, id: \.self) { date in + if let date = date { + DayCell( + date: date, + isToday: calendar.isDateInToday(date), + badgeSize: badgeSize(for: date), + isInCurrentMonth: calendar.isDate(date, equalTo: currentMonth, toGranularity: .month), + onTap: { selectDate(date) } + ) + } else { + // Empty cell for days outside current month + Color.clear + .frame(height: 28) + } + } + } + .padding(.horizontal, 8) + .padding(.bottom, 8) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .onAppear { + updateActivityMap() + } + .onChange(of: appState.allFiles) { _, _ in + updateActivityMap() + } + .onReceive(NotificationCenter.default.publisher(for: .filesDidChange)) { _ in + updateActivityMap() + } + } + + // MARK: - Activity Badge + + @State private var activityMap: [Date: Int] = [:] + + private func updateActivityMap() { + // Build activity map from all files + let notes = appState.allFiles.map { url in + NoteActivityInfo( + url: url, + created: (try? FileManager.default.attributesOfItem(atPath: url.path)[.creationDate] as? Date) ?? Date.distantPast, + modified: (try? FileManager.default.attributesOfItem(atPath: url.path)[.modificationDate] as? Date) ?? Date.distantPast + ) + } + activityMap = activityCalculator.buildActivityMap(from: notes) + } + + private func badgeSize(for date: Date) -> CGFloat { + // Use logarithmic scaling with minSize of 10 and maxSize of 22 + activityCalculator.badgeSize(for: date, in: activityMap, maxSize: 22, minSize: 10) + } + + // MARK: - Navigation + + private func previousMonth() { + currentMonth = calendar.date(byAdding: .month, value: -1, to: currentMonth) ?? currentMonth + } + + private func nextMonth() { + currentMonth = calendar.date(byAdding: .month, value: 1, to: currentMonth) ?? currentMonth + } + + private func selectDate(_ date: Date) { + appState.openDate(date) + } + + // MARK: - Date Helpers + + private var dayHeaders: [String] { + let formatter = DateFormatter() + formatter.locale = Locale.current + return formatter.veryShortWeekdaySymbols + } + + private func monthYearString(from date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "MMMM yyyy" + return formatter.string(from: date) + } + + private var daysInMonth: [Date?] { + let interval = calendar.dateInterval(of: .month, for: currentMonth)! + let firstDayOfMonth = interval.start + + // Find the first Sunday on or before the first day of the month + let weekdayOffset = calendar.component(.weekday, from: firstDayOfMonth) - 1 + let firstVisibleDay = calendar.date(byAdding: .day, value: -weekdayOffset, to: firstDayOfMonth)! + + // Generate 42 days (6 weeks) + var days: [Date?] = [] + for i in 0..<42 { + let date = calendar.date(byAdding: .day, value: i, to: firstVisibleDay)! + // Only include dates that are in the current month or adjacent weeks + days.append(date) + } + + return days + } +} + +// MARK: - Day Cell + +private struct DayCell: View { + let date: Date + let isToday: Bool + let badgeSize: CGFloat + let isInCurrentMonth: Bool + let onTap: () -> Void + + @State private var isHovered = false + + private var dayNumber: Int { + Calendar.current.component(.day, from: date) + } + + private var hasActivity: Bool { + badgeSize > 0 + } + + var body: some View { + ZStack { + // Activity badge (behind the number) + if hasActivity { + ZStack { + // Outer glow + Circle() + .fill(SynapseTheme.accent.opacity(0.25)) + .frame(width: badgeSize + 4, height: badgeSize + 4) + + // Main badge + Circle() + .fill(SynapseTheme.accent.opacity(0.7)) + .frame(width: badgeSize, height: badgeSize) + } + } + + // Date number + Text("\(dayNumber)") + .font(.system(size: 12, weight: isToday ? .bold : .medium)) + .foregroundStyle(textColor) + } + .frame(height: 28) + .frame(maxWidth: .infinity) + .background(backgroundColor) + .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) + .contentShape(Rectangle()) + .onHover { hovering in + isHovered = hovering + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + .onTapGesture { + onTap() + } + } + + private var textColor: Color { + if isToday { + return SynapseTheme.accent + } + if !isInCurrentMonth { + return SynapseTheme.textMuted.opacity(0.5) + } + return SynapseTheme.textPrimary + } + + private var backgroundColor: Color { + if isToday { + return SynapseTheme.accent.opacity(0.1) + } + if isHovered { + return SynapseTheme.row + } + return Color.clear + } +} + +// MARK: - Note Activity Info + +private struct NoteActivityInfo: NoteActivityProviding { + let url: URL + let created: Date + let modified: Date +} diff --git a/macOS/Synapse/ContentView.swift b/macOS/Synapse/ContentView.swift index 11536f3..f8bac98 100644 --- a/macOS/Synapse/ContentView.swift +++ b/macOS/Synapse/ContentView.swift @@ -1375,6 +1375,7 @@ struct SidebarPaneInContainer: View { case .builtIn(let builtInPane): switch builtInPane { case .files: FileTreeView(settings: settings) + case .calendar: CalendarPaneView() case .tags: TagsPaneView() case .links: RelatedLinksPaneView() case .terminal: TerminalPaneView() diff --git a/macOS/Synapse/DatePageView.swift b/macOS/Synapse/DatePageView.swift new file mode 100644 index 0000000..0e71058 --- /dev/null +++ b/macOS/Synapse/DatePageView.swift @@ -0,0 +1,222 @@ +import SwiftUI + +/// Displays notes created or modified on a specific date. +/// Shows two sections: Created and Modified, each sorted by date descending. +struct DatePageView: View { + @EnvironmentObject var appState: AppState + let date: Date + + private let calendar = Calendar.current + + private var createdNotes: [URL] { + appState.notesCreatedOnDate(date) + } + + private var modifiedNotes: [URL] { + appState.notesModifiedOnDate(date) + } + + private var dateTitle: String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } + + private var dateSubtitle: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + return formatter.string(from: date) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: "calendar") + .foregroundStyle(SynapseTheme.accent) + Text(dateTitle) + .font(.system(size: 22, weight: .bold, design: .rounded)) + .foregroundStyle(SynapseTheme.textPrimary) + } + + Text(dateSubtitle) + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundStyle(SynapseTheme.textMuted) + } + + Spacer() + + Button(action: { + if let index = appState.activeTabIndex { + appState.closeTab(at: index) + } + }) { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(SynapseTheme.textSecondary) + .frame(width: 28, height: 28) + .background(SynapseTheme.panel) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(SynapseTheme.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + .help("Close date view") + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(SynapseTheme.panel) + + Rectangle() + .fill(SynapseTheme.border) + .frame(height: 1) + + // Content + if createdNotes.isEmpty && modifiedNotes.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("No notes") + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundStyle(SynapseTheme.textPrimary) + Text("No notes were created or modified on this date.") + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundStyle(SynapseTheme.textMuted) + } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Created section + if !createdNotes.isEmpty { + SectionHeader( + title: "Created", + count: createdNotes.count, + icon: "plus.circle.fill" + ) + + VStack(alignment: .leading, spacing: 8) { + ForEach(createdNotes, id: \.self) { url in + DatePageNoteRow(url: url, appState: appState) + } + } + } + + // Modified section + if !modifiedNotes.isEmpty { + SectionHeader( + title: "Modified", + count: modifiedNotes.count, + icon: "pencil.circle.fill" + ) + + VStack(alignment: .leading, spacing: 8) { + ForEach(modifiedNotes, id: \.self) { url in + DatePageNoteRow(url: url, appState: appState) + } + } + } + } + .padding(16) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(SynapseTheme.editorShell) + } +} + +// MARK: - Section Header + +private struct SectionHeader: View { + let title: String + let count: Int + let icon: String + + var body: some View { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 14)) + .foregroundStyle(SynapseTheme.accent) + + Text(title) + .font(.system(size: 14, weight: .bold, design: .rounded)) + .foregroundStyle(SynapseTheme.textPrimary) + + Text("(\(count))") + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundStyle(SynapseTheme.textMuted) + + Spacer() + } + } +} + +// MARK: - Note Row + +struct DatePageNoteRow: View { + let url: URL + @ObservedObject var appState: AppState + @State private var isHovered = false + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "doc.text") + .foregroundStyle(SynapseTheme.accent) + + VStack(alignment: .leading, spacing: 2) { + Text(url.deletingPathExtension().lastPathComponent) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundStyle(SynapseTheme.textPrimary) + .lineLimit(1) + Text(appState.relativePath(for: url)) + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundStyle(SynapseTheme.textMuted) + .lineLimit(1) + } + + Spacer() + + if isHovered { + Text("⌘+Click for new tab") + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundStyle(SynapseTheme.textMuted) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(SynapseTheme.row) + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(SynapseTheme.rowBorder, lineWidth: 1) + } + } + .contentShape(Rectangle()) + .onHover { hovering in + isHovered = hovering + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + .gesture( + DragGesture(minimumDistance: 0) + .onEnded { _ in + let isCommandPressed = NSEvent.modifierFlags.contains(.command) + if isCommandPressed { + appState.openFileInNewTab(url) + } else { + appState.openFile(url) + } + } + ) + } +} diff --git a/macOS/Synapse/SettingsManager.swift b/macOS/Synapse/SettingsManager.swift index 231ebf5..dab276d 100644 --- a/macOS/Synapse/SettingsManager.swift +++ b/macOS/Synapse/SettingsManager.swift @@ -4,6 +4,7 @@ import Yams enum SidebarPane: String, Codable, CaseIterable, Identifiable { case files = "files" + case calendar = "calendar" case tags = "tags" case links = "links" case terminal = "terminal" @@ -15,6 +16,7 @@ enum SidebarPane: String, Codable, CaseIterable, Identifiable { var title: String { switch self { case .files: return "Files" + case .calendar: return "Calendar" case .tags: return "Tags" case .links: return "Related" case .terminal: return "Terminal" @@ -199,7 +201,7 @@ enum FixedSidebar { static let right2ID = UUID(uuidString: "00000000-0000-0000-0000-000000000003")! static let all: [Sidebar] = [ - Sidebar(id: leftID, position: .left, panes: [.builtIn(.files), .builtIn(.links)]), + Sidebar(id: leftID, position: .left, panes: [.builtIn(.calendar), .builtIn(.files), .builtIn(.links)]), Sidebar(id: right1ID, position: .right, panes: [.builtIn(.terminal), .builtIn(.tags)]), Sidebar(id: right2ID, position: .right, panes: [.builtIn(.browser)]), ] diff --git a/macOS/Synapse/SplitPaneEditorView.swift b/macOS/Synapse/SplitPaneEditorView.swift index 5070289..ebd099e 100644 --- a/macOS/Synapse/SplitPaneEditorView.swift +++ b/macOS/Synapse/SplitPaneEditorView.swift @@ -167,6 +167,9 @@ func editorContent(for tab: TabItem?, paneIndex: Int) -> some View { } else if let tab, let tagName = tab.tagName { TagPageView(tag: tagName) .background(SynapseTheme.editorShell) + } else if let tab, let date = tab.dateValue { + DatePageView(date: date) + .background(SynapseTheme.editorShell) } else { EditorView(paneIndex: paneIndex) .background(SynapseTheme.editorShell) diff --git a/macOS/SynapseTests/AppStateDateFilteringTests.swift b/macOS/SynapseTests/AppStateDateFilteringTests.swift new file mode 100644 index 0000000..c0b90a3 --- /dev/null +++ b/macOS/SynapseTests/AppStateDateFilteringTests.swift @@ -0,0 +1,254 @@ +import XCTest +@testable import Synapse + +/// Tests for date-based note filtering: notesCreatedOnDate and notesModifiedOnDate +/// These functions are used by the calendar sidebar to show notes for a specific day. +final class AppStateDateFilteringTests: XCTestCase { + + var sut: AppState! + var tempDir: URL! + var calendar: Calendar! + + override func setUp() { + super.setUp() + sut = AppState() + calendar = Calendar.current + + // Create temp directory for test files + tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try! FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + + sut.rootURL = tempDir + } + + override func tearDown() { + try? FileManager.default.removeItem(at: tempDir) + sut = nil + super.tearDown() + } + + // MARK: - Helper Methods + + private func createNote(named name: String, created: Date, modified: Date? = nil) -> URL { + let fileURL = tempDir.appendingPathComponent("\(name).md") + let content = "Content for \(name)" + + // Write content + try! content.write(to: fileURL, atomically: true, encoding: .utf8) + + // Set creation date + let attributes: [FileAttributeKey: Any] = [ + .creationDate: created, + .modificationDate: modified ?? created + ] + try! FileManager.default.setAttributes(attributes, ofItemAtPath: fileURL.path) + + return fileURL + } + + private func date(year: Int, month: Int, day: Int, hour: Int = 12) -> Date { + var components = DateComponents() + components.year = year + components.month = month + components.day = day + components.hour = hour + components.minute = 0 + components.second = 0 + return calendar.date(from: components)! + } + + // MARK: - notesCreatedOnDate + + func test_notesCreatedOnDate_emptyVault_returnsEmpty() { + let targetDate = date(year: 2024, month: 1, day: 15) + + let notes = sut.notesCreatedOnDate(targetDate) + + XCTAssertTrue(notes.isEmpty) + } + + func test_notesCreatedOnDate_returnsNotesCreatedOnDate() { + let day1 = date(year: 2024, month: 1, day: 15) + let day2 = date(year: 2024, month: 1, day: 16) + + let note1 = createNote(named: "Note1", created: day1) + let note2 = createNote(named: "Note2", created: day2) + let note3 = createNote(named: "Note3", created: day1) + + // Refresh the file list so AppState knows about these files + sut.refreshAllFiles() + + let notes = sut.notesCreatedOnDate(day1) + + XCTAssertEqual(notes.count, 2) + XCTAssertTrue(notes.contains(note1)) + XCTAssertTrue(notes.contains(note3)) + XCTAssertFalse(notes.contains(note2)) + } + + func test_notesCreatedOnDate_differentTimesSameDay() { + let baseDate = date(year: 2024, month: 1, day: 15) + let morning = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: baseDate)! + let evening = calendar.date(bySettingHour: 20, minute: 30, second: 0, of: baseDate)! + + let note1 = createNote(named: "MorningNote", created: morning) + let note2 = createNote(named: "EveningNote", created: evening) + + sut.refreshAllFiles() + + let notes = sut.notesCreatedOnDate(baseDate) + + XCTAssertEqual(notes.count, 2) + XCTAssertTrue(notes.contains(note1)) + XCTAssertTrue(notes.contains(note2)) + } + + func test_notesCreatedOnDate_noMatchingNotes() { + let day1 = date(year: 2024, month: 1, day: 15) + let day2 = date(year: 2024, month: 1, day: 16) + + createNote(named: "Note1", created: day1) + createNote(named: "Note2", created: day1) + + sut.refreshAllFiles() + + let notes = sut.notesCreatedOnDate(day2) + + XCTAssertTrue(notes.isEmpty) + } + + // MARK: - notesModifiedOnDate + + func test_notesModifiedOnDate_emptyVault_returnsEmpty() { + let targetDate = date(year: 2024, month: 1, day: 15) + + let notes = sut.notesModifiedOnDate(targetDate) + + XCTAssertTrue(notes.isEmpty) + } + + func test_notesModifiedOnDate_returnsNotesModifiedOnDate() { + let createdDate = date(year: 2024, month: 1, day: 10) + let modifiedDay1 = date(year: 2024, month: 1, day: 15) + let modifiedDay2 = date(year: 2024, month: 1, day: 16) + + let note1 = createNote(named: "Note1", created: createdDate, modified: modifiedDay1) + let note2 = createNote(named: "Note2", created: createdDate, modified: modifiedDay2) + let note3 = createNote(named: "Note3", created: createdDate, modified: modifiedDay1) + + sut.refreshAllFiles() + + let notes = sut.notesModifiedOnDate(modifiedDay1) + + XCTAssertEqual(notes.count, 2) + XCTAssertTrue(notes.contains(note1)) + XCTAssertTrue(notes.contains(note3)) + XCTAssertFalse(notes.contains(note2)) + } + + func test_notesModifiedOnDate_differentTimesSameDay() { + let createdDate = date(year: 2024, month: 1, day: 10) + let baseDate = date(year: 2024, month: 1, day: 15) + let morning = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: baseDate)! + let evening = calendar.date(bySettingHour: 20, minute: 30, second: 0, of: baseDate)! + + let note1 = createNote(named: "MorningNote", created: createdDate, modified: morning) + let note2 = createNote(named: "EveningNote", created: createdDate, modified: evening) + + sut.refreshAllFiles() + + let notes = sut.notesModifiedOnDate(baseDate) + + XCTAssertEqual(notes.count, 2) + XCTAssertTrue(notes.contains(note1)) + XCTAssertTrue(notes.contains(note2)) + } + + func test_notesModifiedOnDate_notesNeverModified_excluded() { + let createdDate = date(year: 2024, month: 1, day: 10) + let modifiedDate = date(year: 2024, month: 1, day: 15) + + // Note1: created and modified on same day (never subsequently modified) + let note1 = createNote(named: "Note1", created: createdDate, modified: createdDate) + // Note2: created on day1, modified on day2 + let note2 = createNote(named: "Note2", created: createdDate, modified: modifiedDate) + + sut.refreshAllFiles() + + let notes = sut.notesModifiedOnDate(modifiedDate) + + // Only Note2 should appear (it was modified on modifiedDate) + XCTAssertEqual(notes.count, 1) + XCTAssertTrue(notes.contains(note2)) + XCTAssertFalse(notes.contains(note1)) + } + + func test_notesModifiedOnDate_createdAndModifiedSameDay_includesIfActuallyModified() { + let day1 = date(year: 2024, month: 1, day: 15) + + // Note created on day1 and modified later on day1 (different timestamp) + let morning = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: day1)! + let afternoon = calendar.date(bySettingHour: 14, minute: 0, second: 0, of: day1)! + + let note1 = createNote(named: "Note1", created: morning, modified: afternoon) + + sut.refreshAllFiles() + + let notesDay1 = sut.notesModifiedOnDate(day1) + + // Should show in Modified because it was modified after creation (different timestamps) + XCTAssertEqual(notesDay1.count, 1) + XCTAssertTrue(notesDay1.contains(note1)) + } + + func test_notesModifiedOnDate_sameTimestampAsCreation_excluded() { + let day1 = date(year: 2024, month: 1, day: 15) + + // Note created and modified at exact same timestamp (never actually edited) + let note1 = createNote(named: "Note1", created: day1, modified: day1) + + sut.refreshAllFiles() + + let notesDay1 = sut.notesModifiedOnDate(day1) + + // Should NOT show in Modified because modification == creation + XCTAssertTrue(notesDay1.isEmpty) + } + + // MARK: - Combined Results + + func test_notesCreatedAndModifiedOnDate_sameNoteCanAppearInBoth() { + let createdDate = date(year: 2024, month: 1, day: 10) + let modifiedDate = date(year: 2024, month: 1, day: 15) + + let note = createNote(named: "Note1", created: createdDate, modified: modifiedDate) + + sut.refreshAllFiles() + + let createdNotes = sut.notesCreatedOnDate(createdDate) + let modifiedNotes = sut.notesModifiedOnDate(modifiedDate) + + // Same note can appear in both lists + XCTAssertTrue(createdNotes.contains(note)) + XCTAssertTrue(modifiedNotes.contains(note)) + } + + func test_resultsSortedDescendingByDate() { + let day1 = date(year: 2024, month: 1, day: 15, hour: 9) + let day1Later = date(year: 2024, month: 1, day: 15, hour: 14) + let day1EvenLater = date(year: 2024, month: 1, day: 15, hour: 20) + + let note1 = createNote(named: "Note1", created: day1) + let note2 = createNote(named: "Note2", created: day1Later) + let note3 = createNote(named: "Note3", created: day1EvenLater) + + sut.refreshAllFiles() + + let notes = sut.notesCreatedOnDate(day1) + + // Should be sorted descending by time (newest first) + XCTAssertEqual(notes.first, note3) + XCTAssertEqual(notes.last, note1) + } +} diff --git a/macOS/SynapseTests/AppStateDateTabTests.swift b/macOS/SynapseTests/AppStateDateTabTests.swift new file mode 100644 index 0000000..896a4e6 --- /dev/null +++ b/macOS/SynapseTests/AppStateDateTabTests.swift @@ -0,0 +1,249 @@ +import XCTest +@testable import Synapse + +/// Tests for date-based tab functionality: opening dates in new tabs. +/// Mirrors the pattern used for tags (openTagInNewTab). +final class AppStateDateTabTests: XCTestCase { + + var sut: AppState! + var tempDir: URL! + var calendar: Calendar! + var testDate: Date! + + override func setUp() { + super.setUp() + sut = AppState() + calendar = Calendar.current + + // Create a fixed test date: 2024-01-15 + var components = DateComponents() + components.year = 2024 + components.month = 1 + components.day = 15 + testDate = calendar.date(from: components) + + // Create temp directory + tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try! FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + + sut.rootURL = tempDir + } + + override func tearDown() { + try? FileManager.default.removeItem(at: tempDir) + sut = nil + super.tearDown() + } + + // MARK: - Opening Date Tabs + + func test_openDateInNewTab_withNoTabs_addsDateTab() { + sut.openDateInNewTab(testDate) + + XCTAssertEqual(sut.tabs.count, 1) + XCTAssertEqual(sut.tabs[0], .date(testDate)) + } + + func test_openDateInNewTab_setsActiveTabIndex() { + sut.openDateInNewTab(testDate) + + XCTAssertEqual(sut.activeTabIndex, 0) + } + + func test_openDateInNewTab_activeTabIsDate() { + sut.openDateInNewTab(testDate) + + XCTAssertTrue(sut.activeTab?.isDate == true) + XCTAssertEqual(sut.activeTab?.dateValue, testDate) + } + + func test_openDateInNewTab_clearsSelectedFile() { + // Create a file to open first + let fileURL = tempDir.appendingPathComponent("TestNote.md") + try! "Content".write(to: fileURL, atomically: true, encoding: .utf8) + sut.refreshAllFiles() + + sut.openFile(fileURL) + XCTAssertNotNil(sut.selectedFile) + + sut.openDateInNewTab(testDate) + + XCTAssertNil(sut.selectedFile) + } + + func test_openDateInNewTab_clearsFileContent() { + let fileURL = tempDir.appendingPathComponent("TestNote.md") + try! "Content".write(to: fileURL, atomically: true, encoding: .utf8) + sut.refreshAllFiles() + + sut.openFile(fileURL) + XCTAssertFalse(sut.fileContent.isEmpty) + + sut.openDateInNewTab(testDate) + + XCTAssertEqual(sut.fileContent, "") + } + + func test_openDateInNewTab_clearsDirtyFlag() { + let fileURL = tempDir.appendingPathComponent("TestNote.md") + try! "Content".write(to: fileURL, atomically: true, encoding: .utf8) + sut.refreshAllFiles() + + sut.openFile(fileURL) + sut.fileContent = "Unsaved edit" + sut.isDirty = true + + sut.openDateInNewTab(testDate) + + XCTAssertFalse(sut.isDirty) + } + + // MARK: - Deduplication + + func test_openDateInNewTab_whenAlreadyOpen_switchesToExistingTab() { + // Create a file first + let fileURL = tempDir.appendingPathComponent("Test.md") + try! "Content".write(to: fileURL, atomically: true, encoding: .utf8) + sut.refreshAllFiles() + + sut.openDateInNewTab(testDate) // tab 0 + sut.openFileInNewTab(fileURL) // tab 1 + + sut.switchTab(to: 0) // Go back to date tab + + sut.openDateInNewTab(testDate) // Should reuse, not create new + + XCTAssertEqual(sut.tabs.count, 2, "Should not add a second date tab") + XCTAssertEqual(sut.activeTabIndex, 0, "Should switch to the existing date tab") + XCTAssertEqual(sut.activeTab, .date(testDate)) + } + + func test_openDateInNewTab_differentDates_createSeparateTabs() { + let date1 = testDate! + let date2 = calendar.date(byAdding: .day, value: 1, to: date1)! + + sut.openDateInNewTab(date1) + sut.openDateInNewTab(date2) + + XCTAssertEqual(sut.tabs.count, 2) + XCTAssertEqual(sut.tabs[0], .date(date1)) + XCTAssertEqual(sut.tabs[1], .date(date2)) + } + + func test_openDateInNewTab_calledTwice_sameDate_onlyOneTabExists() { + sut.openDateInNewTab(testDate) + sut.openDateInNewTab(testDate) + + let dateTabCount = sut.tabs.filter { $0 == .date(testDate) }.count + XCTAssertEqual(dateTabCount, 1, "Only one date tab should ever exist for the same date") + } + + // MARK: - Date Tab with Other Tabs + + func test_openDateInNewTab_appendsAfterFileTabs() { + let fileURL = tempDir.appendingPathComponent("TestNote.md") + try! "Content".write(to: fileURL, atomically: true, encoding: .utf8) + sut.refreshAllFiles() + + sut.openFile(fileURL) + sut.openFileInNewTab(tempDir.appendingPathComponent("TestNote2.md")) + + sut.openDateInNewTab(testDate) + + XCTAssertEqual(sut.tabs.count, 3) + XCTAssertEqual(sut.tabs[2], .date(testDate)) + XCTAssertEqual(sut.activeTabIndex, 2) + } + + func test_openDateInNewTab_afterTagTab_appendsCorrectly() { + sut.openTagInNewTab("work") + + sut.openDateInNewTab(testDate) + + XCTAssertEqual(sut.tabs.count, 2) + XCTAssertEqual(sut.tabs[0], .tag("work")) + XCTAssertEqual(sut.tabs[1], .date(testDate)) + XCTAssertEqual(sut.activeTabIndex, 1) + } + + // MARK: - Closing and Reopening + + func test_closeDateTab_removesItFromTabs() { + let fileURL = tempDir.appendingPathComponent("TestNote.md") + try! "Content".write(to: fileURL, atomically: true, encoding: .utf8) + sut.refreshAllFiles() + + sut.openFile(fileURL) + sut.openDateInNewTab(testDate) // tab 1 + + sut.closeTab(at: 1) + + XCTAssertFalse(sut.tabs.contains(.date(testDate))) + XCTAssertEqual(sut.tabs.count, 1) + XCTAssertEqual(sut.activeTabIndex, 0) + } + + func test_openDateInNewTab_afterClosingPrevious_createsNewDateTab() { + sut.openDateInNewTab(testDate) + sut.closeTab(at: 0) + + sut.openDateInNewTab(testDate) + + XCTAssertTrue(sut.tabs.contains(.date(testDate))) + XCTAssertEqual(sut.activeTab, .date(testDate)) + } + + // MARK: - Tab Type Helpers + + func test_dateTabItem_isDate_returnsTrue() { + sut.openDateInNewTab(testDate) + XCTAssertTrue(sut.activeTab?.isDate == true) + } + + func test_dateTabItem_isFile_returnsFalse() { + sut.openDateInNewTab(testDate) + XCTAssertFalse(sut.activeTab?.isFile == true) + } + + func test_dateTabItem_isTag_returnsFalse() { + sut.openDateInNewTab(testDate) + XCTAssertFalse(sut.activeTab?.isTag == true) + } + + func test_dateTabItem_isGraph_returnsFalse() { + sut.openDateInNewTab(testDate) + XCTAssertFalse(sut.activeTab?.isGraph == true) + } + + func test_dateTabItem_displayName_isISOFormat() { + sut.openDateInNewTab(testDate) + XCTAssertEqual(sut.activeTab?.displayName, "2024-01-15") + } + + // MARK: - TabItem Equality + + func test_dateTabItem_equality_sameDate() { + let tab1 = TabItem.date(testDate) + let tab2 = TabItem.date(testDate) + + XCTAssertEqual(tab1, tab2) + } + + func test_dateTabItem_equality_differentDates() { + let date1 = testDate! + let date2 = calendar.date(byAdding: .day, value: 1, to: date1)! + + let tab1 = TabItem.date(date1) + let tab2 = TabItem.date(date2) + + XCTAssertNotEqual(tab1, tab2) + } + + func test_dateTabItem_hashValue_sameForSameDate() { + let tab1 = TabItem.date(testDate) + let tab2 = TabItem.date(testDate) + + XCTAssertEqual(tab1.hashValue, tab2.hashValue) + } +} diff --git a/macOS/SynapseTests/CalendarDayActivityCalculatorTests.swift b/macOS/SynapseTests/CalendarDayActivityCalculatorTests.swift new file mode 100644 index 0000000..f3afd6f --- /dev/null +++ b/macOS/SynapseTests/CalendarDayActivityCalculatorTests.swift @@ -0,0 +1,340 @@ +import XCTest +@testable import Synapse + +/// Tests for the CalendarDayActivityCalculator which computes note activity counts per day +/// and calculates badge sizes for the calendar view. +/// +/// Badge sizing is based on the relative activity level (note count) for each day, +/// with a maximum cap to keep the calendar layout stable. +final class CalendarDayActivityCalculatorTests: XCTestCase { + + var sut: CalendarDayActivityCalculator! + var calendar: Calendar! + + override func setUp() { + super.setUp() + calendar = Calendar.current + sut = CalendarDayActivityCalculator(calendar: calendar) + } + + override func tearDown() { + sut = nil + calendar = nil + super.tearDown() + } + + // MARK: - Empty State + + func test_emptyActivityMap_returnsZeroForAllDays() { + let date = Date() + let count = sut.activityCount(for: date, in: [:]) + XCTAssertEqual(count, 0) + } + + func test_emptyActivityMap_badgeSizeIsZero() { + let date = Date() + let size = sut.badgeSize(for: date, in: [:], maxSize: 20) + XCTAssertEqual(size, 0) + } + + // MARK: - Activity Count + + func test_activityCount_returnsCorrectCountForDate() { + let date = Date(timeIntervalSince1970: 1_700_000_000) // Fixed date + let activityMap: [Date: Int] = [ + calendar.startOfDay(for: date): 5 + ] + + let count = sut.activityCount(for: date, in: activityMap) + XCTAssertEqual(count, 5) + } + + func test_activityCount_differentTimesSameDay_returnSameCount() { + let baseDate = Date(timeIntervalSince1970: 1_700_000_000) + let morning = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: baseDate)! + let evening = calendar.date(bySettingHour: 20, minute: 30, second: 0, of: baseDate)! + + let activityMap: [Date: Int] = [ + calendar.startOfDay(for: baseDate): 3 + ] + + XCTAssertEqual(sut.activityCount(for: morning, in: activityMap), 3) + XCTAssertEqual(sut.activityCount(for: evening, in: activityMap), 3) + } + + func test_activityCount_differentDays_returnDifferentCounts() { + let day1 = Date(timeIntervalSince1970: 1_700_000_000) + let day2 = Date(timeIntervalSince1970: 1_700_086_400) // Next day + + let activityMap: [Date: Int] = [ + calendar.startOfDay(for: day1): 3, + calendar.startOfDay(for: day2): 7 + ] + + XCTAssertEqual(sut.activityCount(for: day1, in: activityMap), 3) + XCTAssertEqual(sut.activityCount(for: day2, in: activityMap), 7) + } + + // MARK: - Badge Size Calculation + + func test_badgeSize_zeroActivity_returnsZero() { + let date = Date() + let activityMap: [Date: Int] = [ + calendar.startOfDay(for: date): 0 + ] + + let size = sut.badgeSize(for: date, in: activityMap, maxSize: 20) + XCTAssertEqual(size, 0) + } + + func test_badgeSize_singleDay_returnsMaxSize() { + let date = Date() + let activityMap: [Date: Int] = [ + calendar.startOfDay(for: date): 5 + ] + + let size = sut.badgeSize(for: date, in: activityMap, maxSize: 20) + XCTAssertEqual(size, 20) + } + + func test_badgeSize_proportionalToMaxActivity() { + let day1 = Date(timeIntervalSince1970: 1_700_000_000) + let day2 = Date(timeIntervalSince1970: 1_700_086_400) + let day3 = Date(timeIntervalSince1970: 1_700_172_800) + let day4 = Date(timeIntervalSince1970: 1_700_259_200) + let day5 = Date(timeIntervalSince1970: 1_700_345_600) + + // More data points so 95th percentile isn't the absolute max + // 95th percentile of [1, 2, 5, 10, 50] will be around 10 (index 3) + // With referenceMax=10: day5(50) and day4(10) get maxSize, day3(5) gets mid + let activityMap: [Date: Int] = [ + calendar.startOfDay(for: day1): 1, + calendar.startOfDay(for: day2): 2, + calendar.startOfDay(for: day3): 5, + calendar.startOfDay(for: day4): 10, + calendar.startOfDay(for: day5): 50 + ] + + let maxSize: CGFloat = 20.0 + let minSize: CGFloat = 8.0 + + let day5Size = sut.badgeSize(for: day5, in: activityMap, maxSize: maxSize, minSize: minSize) + let day4Size = sut.badgeSize(for: day4, in: activityMap, maxSize: maxSize, minSize: minSize) + let day3Size = sut.badgeSize(for: day3, in: activityMap, maxSize: maxSize, minSize: minSize) + let day2Size = sut.badgeSize(for: day2, in: activityMap, maxSize: maxSize, minSize: minSize) + let day1Size = sut.badgeSize(for: day1, in: activityMap, maxSize: maxSize, minSize: minSize) + + // Day 5 (outlier 50) and Day 4 (at 95th percentile) should be at max + XCTAssertEqual(day5Size, maxSize, accuracy: 0.01) + XCTAssertEqual(day4Size, maxSize, accuracy: 0.01) + // Day 3 (5 notes) should be mid-range: smaller than max, larger than day2 + XCTAssertLessThan(day3Size, maxSize) + XCTAssertGreaterThan(day3Size, day2Size) + // Day 2 should be larger than day1 (minimum) + XCTAssertGreaterThan(day2Size, minSize) + XCTAssertGreaterThanOrEqual(day1Size, minSize) + } + + func test_badgeSize_respectsMaxSizeCap() { + let day1 = Date(timeIntervalSince1970: 1_700_000_000) + let day2 = Date(timeIntervalSince1970: 1_700_086_400) + + // Day 1 has 100 notes, Day 2 has 1 note + // With 95th percentile, the outlier shouldn't skew the small counts too much + let activityMap: [Date: Int] = [ + calendar.startOfDay(for: day1): 100, + calendar.startOfDay(for: day2): 1 + ] + + let maxSize: CGFloat = 20.0 + let minSize: CGFloat = 8.0 + + let day1Size = sut.badgeSize(for: day1, in: activityMap, maxSize: maxSize, minSize: minSize) + let day2Size = sut.badgeSize(for: day2, in: activityMap, maxSize: maxSize, minSize: minSize) + + // Day 1 should be capped at maxSize + XCTAssertEqual(day1Size, maxSize, accuracy: 0.01) + // Day 2 should be at least minSize (outlier doesn't crush it) + XCTAssertGreaterThanOrEqual(day2Size, minSize) + } + + func test_badgeSize_logarithmicScalingCompressesOutliers() { + let day1 = Date(timeIntervalSince1970: 1_700_000_000) + let day2 = Date(timeIntervalSince1970: 1_700_086_400) + let day3 = Date(timeIntervalSince1970: 1_700_172_800) + + // One extreme outlier (100) and two normal days (3 and 5) + let activityMap: [Date: Int] = [ + calendar.startOfDay(for: day1): 3, + calendar.startOfDay(for: day2): 5, + calendar.startOfDay(for: day3): 100 + ] + + let maxSize: CGFloat = 20.0 + let minSize: CGFloat = 8.0 + + let day1Size = sut.badgeSize(for: day1, in: activityMap, maxSize: maxSize, minSize: minSize) + let day2Size = sut.badgeSize(for: day2, in: activityMap, maxSize: maxSize, minSize: minSize) + + // With linear scaling, day1 would be 3/100 * 20 = 0.6 (invisible) + // With log scaling and 95th percentile, it should be much larger + XCTAssertGreaterThanOrEqual(day1Size, minSize) + XCTAssertGreaterThan(day2Size, day1Size) + } + + func test_percentileActivity_findsCorrectPercentile() { + let day1 = calendar.date(from: DateComponents(year: 2024, month: 1, day: 1))! + let day2 = calendar.date(from: DateComponents(year: 2024, month: 1, day: 2))! + let day3 = calendar.date(from: DateComponents(year: 2024, month: 1, day: 3))! + let day4 = calendar.date(from: DateComponents(year: 2024, month: 1, day: 4))! + let day5 = calendar.date(from: DateComponents(year: 2024, month: 1, day: 5))! + + let activityMap: [Date: Int] = [ + calendar.startOfDay(for: day1): 1, + calendar.startOfDay(for: day2): 2, + calendar.startOfDay(for: day3): 3, + calendar.startOfDay(for: day4): 4, + calendar.startOfDay(for: day5): 100 // Outlier + ] + + // 95th percentile should ignore the outlier and return 4 + let p95 = sut.percentileActivity(in: activityMap, percentile: 0.95) + XCTAssertEqual(p95, 4) + + // 100th percentile should return the max (100) + let p100 = sut.percentileActivity(in: activityMap, percentile: 1.0) + XCTAssertEqual(p100, 100) + } + + // MARK: - Month Activity Map + + func test_monthActivityMap_filtersToSpecificMonth() { + // January 2024 + let january2024 = DateComponents(year: 2024, month: 1, day: 15) + let januaryDate = calendar.date(from: january2024)! + + let activityMap: [Date: Int] = [ + calendar.date(from: DateComponents(year: 2024, month: 1, day: 5))!: 3, + calendar.date(from: DateComponents(year: 2024, month: 1, day: 15))!: 7, + calendar.date(from: DateComponents(year: 2024, month: 1, day: 28))!: 2, + calendar.date(from: DateComponents(year: 2024, month: 2, day: 1))!: 5, // February + calendar.date(from: DateComponents(year: 2023, month: 12, day: 31))!: 4 // December 2023 + ] + + let monthMap = sut.monthActivityMap(for: januaryDate, from: activityMap) + + XCTAssertEqual(monthMap.count, 3) + XCTAssertEqual(monthMap[calendar.date(from: DateComponents(year: 2024, month: 1, day: 5))!], 3) + XCTAssertEqual(monthMap[calendar.date(from: DateComponents(year: 2024, month: 1, day: 15))!], 7) + XCTAssertEqual(monthMap[calendar.date(from: DateComponents(year: 2024, month: 1, day: 28))!], 2) + } + + func test_monthActivityMap_normalizesDatesToStartOfDay() { + let january2024 = DateComponents(year: 2024, month: 1, day: 15) + let januaryDate = calendar.date(from: january2024)! + + // Activity map with non-normalized dates (has time components) + let morning = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: januaryDate)! + let activityMap: [Date: Int] = [ + morning: 5 + ] + + let monthMap = sut.monthActivityMap(for: januaryDate, from: activityMap) + + // Should be accessible via startOfDay key + XCTAssertEqual(monthMap[calendar.startOfDay(for: januaryDate)], 5) + } + + // MARK: - Maximum Activity Calculation + + func test_maxActivityInMonth_returnsHighestCount() { + let baseDate = Date(timeIntervalSince1970: 1_700_000_000) + + let activityMap: [Date: Int] = [ + calendar.startOfDay(for: baseDate): 3, + calendar.date(byAdding: .day, value: 1, to: baseDate)!: 8, + calendar.date(byAdding: .day, value: 2, to: baseDate)!: 2 + ] + + let maxActivity = sut.maxActivity(in: activityMap) + XCTAssertEqual(maxActivity, 8) + } + + func test_maxActivity_emptyMap_returnsZero() { + let maxActivity = sut.maxActivity(in: [:]) + XCTAssertEqual(maxActivity, 0) + } + + // MARK: - Building Activity Map from Notes + + func test_buildActivityMapFromNotes_countsCreatedAndModified() { + let day1 = calendar.date(from: DateComponents(year: 2024, month: 1, day: 15))! + let day2 = calendar.date(from: DateComponents(year: 2024, month: 1, day: 16))! + + let note1 = NoteActivity( + url: URL(fileURLWithPath: "/test/note1.md"), + created: day1, + modified: day1 + ) + let note2 = NoteActivity( + url: URL(fileURLWithPath: "/test/note2.md"), + created: day1, + modified: day2 + ) + let note3 = NoteActivity( + url: URL(fileURLWithPath: "/test/note3.md"), + created: day2, + modified: day2 + ) + + let notes = [note1, note2, note3] + let activityMap = sut.buildActivityMap(from: notes) + + // Day 1: note1 (created + modified), note2 (created) = 2 notes + XCTAssertEqual(activityMap[calendar.startOfDay(for: day1)], 2) + // Day 2: note2 (modified), note3 (created + modified) = 2 notes + XCTAssertEqual(activityMap[calendar.startOfDay(for: day2)], 2) + } + + func test_buildActivityMapFromNotes_sameNoteMultipleDays() { + let day1 = calendar.date(from: DateComponents(year: 2024, month: 1, day: 15))! + let day2 = calendar.date(from: DateComponents(year: 2024, month: 1, day: 16))! + + // Same note created on day1 and modified on day2 + let note = NoteActivity( + url: URL(fileURLWithPath: "/test/note.md"), + created: day1, + modified: day2 + ) + + let activityMap = sut.buildActivityMap(from: [note]) + + // Should count on both days + XCTAssertEqual(activityMap[calendar.startOfDay(for: day1)], 1) + XCTAssertEqual(activityMap[calendar.startOfDay(for: day2)], 1) + } + + func test_buildActivityMapFromNotes_sameDayCreatedModified_countsOnce() { + let day1 = calendar.date(from: DateComponents(year: 2024, month: 1, day: 15))! + + let note = NoteActivity( + url: URL(fileURLWithPath: "/test/note.md"), + created: day1, + modified: day1 + ) + + let activityMap = sut.buildActivityMap(from: [note]) + + // Created and modified same day = count as 1 note + XCTAssertEqual(activityMap[calendar.startOfDay(for: day1)], 1) + } +} + +// MARK: - Test Helpers + +/// Represents a note's activity information for testing +struct NoteActivity: NoteActivityProviding { + let url: URL + let created: Date + let modified: Date +} diff --git a/macOS/SynapseTests/SettingsManagerMovePaneItemTests.swift b/macOS/SynapseTests/SettingsManagerMovePaneItemTests.swift index bbf0d15..f9aaa35 100644 --- a/macOS/SynapseTests/SettingsManagerMovePaneItemTests.swift +++ b/macOS/SynapseTests/SettingsManagerMovePaneItemTests.swift @@ -73,30 +73,30 @@ final class SettingsManagerMovePaneItemTests: XCTestCase { // MARK: - Same-sidebar moves (reordering) func test_movePaneItem_withinSameSidebar_movingDown_adjustsIndex() { - // left sidebar starts as [files, links]. - // Moving 'files' (index 0) to index 2 (after 'links') should produce [links, files]. - sut.movePaneItem(.builtIn(.files), toSidebar: FixedSidebar.leftID, at: 2) + // left sidebar starts as [calendar, files, links]. + // Moving 'files' (index 1) to index 3 (after 'links') should produce [calendar, links, files]. + sut.movePaneItem(.builtIn(.files), toSidebar: FixedSidebar.leftID, at: 3) let left = sut.sidebars.first { $0.id == FixedSidebar.leftID }! - XCTAssertEqual(left.panes.compactMap(\.builtInPane), [.links, .files], - "Moving down within same sidebar should correctly reorder to [links, files]") + XCTAssertEqual(left.panes.compactMap(\.builtInPane), [.calendar, .links, .files], + "Moving down within same sidebar should correctly reorder to [calendar, links, files]") } func test_movePaneItem_withinSameSidebar_movingUp_correctOrder() { - // left sidebar: [files, links]. - // Moving 'links' (index 1) to index 0 should produce [links, files]. + // left sidebar: [calendar, files, links]. + // Moving 'links' (index 2) to index 0 should produce [links, calendar, files]. sut.movePaneItem(.builtIn(.links), toSidebar: FixedSidebar.leftID, at: 0) let left = sut.sidebars.first { $0.id == FixedSidebar.leftID }! - XCTAssertEqual(left.panes.compactMap(\.builtInPane), [.links, .files], - "Moving up within same sidebar should produce [links, files]") + XCTAssertEqual(left.panes.compactMap(\.builtInPane), [.links, .calendar, .files], + "Moving up within same sidebar should produce [links, calendar, files]") } func test_movePaneItem_withinSameSidebar_sameSamePosition_noChange() { - // Moving 'files' to index 0 when it's already at index 0 should be a no-op. + // Moving 'calendar' to index 0 when it's already at index 0 should be a no-op. let before = sut.sidebars.first { $0.id == FixedSidebar.leftID }!.panes - sut.movePaneItem(.builtIn(.files), toSidebar: FixedSidebar.leftID, at: 0) + sut.movePaneItem(.builtIn(.calendar), toSidebar: FixedSidebar.leftID, at: 0) let after = sut.sidebars.first { $0.id == FixedSidebar.leftID }!.panes XCTAssertEqual(before, after, "Moving an item to its current position should be a no-op") diff --git a/macOS/SynapseTests/SettingsManagerTests.swift b/macOS/SynapseTests/SettingsManagerTests.swift index b298b1a..daaeca2 100644 --- a/macOS/SynapseTests/SettingsManagerTests.swift +++ b/macOS/SynapseTests/SettingsManagerTests.swift @@ -366,7 +366,9 @@ final class SettingsManagerTests: XCTestCase { func test_removePane_removesPaneFromSidebar() { sut.removePane(.links, fromSidebar: FixedSidebar.leftID) let left = sut.sidebars.first { $0.id == FixedSidebar.leftID } - XCTAssertEqual(left?.panes, [.builtIn(.files)]) + // Left sidebar now has [calendar, files, links] by default + // After removing links, should be [calendar, files] + XCTAssertEqual(left?.panes, [.builtIn(.calendar), .builtIn(.files)]) } func test_assignPane_movesPaneToAnotherSidebar() { diff --git a/macOS/SynapseTests/SidebarPaneTitleTests.swift b/macOS/SynapseTests/SidebarPaneTitleTests.swift index 006dee5..d838554 100644 --- a/macOS/SynapseTests/SidebarPaneTitleTests.swift +++ b/macOS/SynapseTests/SidebarPaneTitleTests.swift @@ -68,14 +68,14 @@ final class SidebarPaneTitleTests: XCTestCase { } } - // MARK: - CaseIterable — exactly 6 known cases + // MARK: - CaseIterable — exactly 7 known cases - func test_allCases_containsExactlySixCases() { - XCTAssertEqual(SidebarPane.allCases.count, 6) + func test_allCases_containsExactlySevenCases() { + XCTAssertEqual(SidebarPane.allCases.count, 7) } func test_allCases_containsAllExpectedValues() { - let expected: [SidebarPane] = [.files, .tags, .links, .terminal, .browser, .graph] + let expected: [SidebarPane] = [.files, .calendar, .tags, .links, .terminal, .browser, .graph] for pane in expected { XCTAssertTrue(SidebarPane.allCases.contains(pane), "allCases should contain .\(pane.rawValue)") diff --git a/marketing-site/docs/images/calendar.png b/marketing-site/docs/images/calendar.png new file mode 100644 index 0000000..1424035 Binary files /dev/null and b/marketing-site/docs/images/calendar.png differ diff --git a/marketing-site/docs/index.md b/marketing-site/docs/index.md index 3d852a1..8b723c5 100644 --- a/marketing-site/docs/index.md +++ b/marketing-site/docs/index.md @@ -26,6 +26,8 @@ Once your vault is opened, consider configuring the following: ## Features +![](images/calendar.png) + Synapse packs a robust set of features to boost your productivity. ### Markdown Editor