diff --git a/JournalApp.Tests/PinnedNotesTests.razor b/JournalApp.Tests/PinnedNotesTests.razor new file mode 100644 index 00000000..2336e602 --- /dev/null +++ b/JournalApp.Tests/PinnedNotesTests.razor @@ -0,0 +1,99 @@ +@namespace JournalApp.Tests +@inherits JaTestContext + +@code { + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + + AddDbContext(); + Services.GetService().SeedCategories(); + } + + [Fact] + public void CanPinAndUnpinNote() + { + // Arrange + var category = new DataPointCategory + { + Guid = Guid.NewGuid(), + Type = PointType.Note, + Group = "Notes", + }; + + var day = Day.Create(new(2024, 01, 01)); + var point = DataPoint.Create(day, category); + point.Text = "Test note"; + + var layout = Render( + @ + + + + + ); + + // Assert - Note should not be pinned initially + point.IsPinned.Should().BeFalse(); + + // Act - Pin the note + var pinButton = layout.Find(".note-pin-button"); + pinButton.Click(); + + // Assert - Note should be pinned + point.IsPinned.Should().BeTrue(); + + // Act - Unpin the note + pinButton.Click(); + + // Assert - Note should be unpinned + point.IsPinned.Should().BeFalse(); + } + + [Fact] + public async Task PinnedNotesPageShowsPinnedNotes() + { + // Arrange + using var db = Services.GetService>().CreateDbContext(); + var today = DateOnly.FromDateTime(DateTime.Now); + var day = await db.GetOrCreateDayAndAddPoints(today); + + // Create and pin a note + var note = db.CreateNote(day); + note.Text = "Pinned test note"; + note.IsPinned = true; + note.Category.Points.Add(note); + await db.SaveChangesAsync(); + + // Act + var layout = Render( + @ + + + + + ); + + // Assert - Wait for the component to render and show pinned notes + layout.WaitForState(() => layout.FindAll(".pinned-note-card").Count > 0, timeout: TimeSpan.FromSeconds(5)); + layout.Markup.Should().Contain("Pinned test note"); + } + + [Fact] + public void PinnedNotesPageShowsEmptyMessageWhenNoPinnedNotes() + { + // Arrange - No pinned notes + + // Act + var layout = Render( + @ + + + + + ); + + // Assert + layout.Markup.Should().Contain("No pinned notes yet"); + } +} diff --git a/JournalApp/Components/DataPointView.razor b/JournalApp/Components/DataPointView.razor index 5b8225df..851af64c 100644 --- a/JournalApp/Components/DataPointView.razor +++ b/JournalApp/Components/DataPointView.razor @@ -67,6 +67,13 @@ else if (Point.Type == PointType.Note) AutoGrow Lines="1" MaxLines="10" /> } + + public bool Deleted { get; set; } + /// + /// Indicates whether the note is pinned. + /// + public bool IsPinned { get; set; } + /// /// The mood value of the data point, if applicable. /// diff --git a/JournalApp/Migrations/20251102061630_AddIsPinnedToDataPoint.Designer.cs b/JournalApp/Migrations/20251102061630_AddIsPinnedToDataPoint.Designer.cs new file mode 100644 index 00000000..3ee36bbb --- /dev/null +++ b/JournalApp/Migrations/20251102061630_AddIsPinnedToDataPoint.Designer.cs @@ -0,0 +1,162 @@ +// +using System; +using JournalApp; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace JournalApp.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20251102061630_AddIsPinnedToDataPoint")] + partial class AddIsPinnedToDataPoint + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.1"); + + modelBuilder.Entity("JournalApp.DataPoint", b => + { + b.Property("Guid") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Bool") + .HasColumnType("INTEGER"); + + b.Property("CategoryGuid") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DayGuid") + .HasColumnType("TEXT"); + + b.Property("Deleted") + .HasColumnType("INTEGER"); + + b.Property("IsPinned") + .HasColumnType("INTEGER"); + + b.Property("MedicationDose") + .HasColumnType("TEXT"); + + b.Property("Mood") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("REAL"); + + b.Property("ScaleIndex") + .HasColumnType("INTEGER"); + + b.Property("SleepHours") + .HasColumnType("TEXT"); + + b.Property("Text") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Guid"); + + b.HasIndex("CategoryGuid"); + + b.HasIndex("DayGuid"); + + b.ToTable("Points"); + }); + + modelBuilder.Entity("JournalApp.DataPointCategory", b => + { + b.Property("Guid") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Deleted") + .HasColumnType("INTEGER"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Group") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("MedicationDose") + .HasColumnType("TEXT"); + + b.Property("MedicationEveryDaySince") + .HasColumnType("TEXT"); + + b.Property("MedicationUnit") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Guid"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("JournalApp.Day", b => + { + b.Property("Guid") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.HasKey("Guid"); + + b.ToTable("Days"); + }); + + modelBuilder.Entity("JournalApp.DataPoint", b => + { + b.HasOne("JournalApp.DataPointCategory", "Category") + .WithMany("Points") + .HasForeignKey("CategoryGuid"); + + b.HasOne("JournalApp.Day", "Day") + .WithMany("Points") + .HasForeignKey("DayGuid"); + + b.Navigation("Category"); + + b.Navigation("Day"); + }); + + modelBuilder.Entity("JournalApp.DataPointCategory", b => + { + b.Navigation("Points"); + }); + + modelBuilder.Entity("JournalApp.Day", b => + { + b.Navigation("Points"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/JournalApp/Migrations/20251102061630_AddIsPinnedToDataPoint.cs b/JournalApp/Migrations/20251102061630_AddIsPinnedToDataPoint.cs new file mode 100644 index 00000000..b125bbf8 --- /dev/null +++ b/JournalApp/Migrations/20251102061630_AddIsPinnedToDataPoint.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JournalApp.Migrations +{ + /// + public partial class AddIsPinnedToDataPoint : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsPinned", + table: "Points", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsPinned", + table: "Points"); + } + } +} diff --git a/JournalApp/Migrations/AppDbContextModelSnapshot.cs b/JournalApp/Migrations/AppDbContextModelSnapshot.cs index e78386e0..53b18f15 100644 --- a/JournalApp/Migrations/AppDbContextModelSnapshot.cs +++ b/JournalApp/Migrations/AppDbContextModelSnapshot.cs @@ -38,6 +38,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Deleted") .HasColumnType("INTEGER"); + b.Property("IsPinned") + .HasColumnType("INTEGER"); + b.Property("MedicationDose") .HasColumnType("TEXT"); diff --git a/JournalApp/Pages/Index.razor b/JournalApp/Pages/Index.razor index e37d5611..61e11401 100644 --- a/JournalApp/Pages/Index.razor +++ b/JournalApp/Pages/Index.razor @@ -18,6 +18,7 @@ + Pinned notes Elements Medications Trends @@ -126,6 +127,9 @@ [Parameter] public string OpenToDateString { get; set; } + [SupplyParameterFromQuery(Name = "scrollToNote")] + public string ScrollToNoteGuid { get; set; } + protected override async Task OnInitializedAsync() { logger.LogDebug("Initializing asynchronously"); @@ -149,6 +153,12 @@ logger.LogInformation($"Opening to {date}"); await GoToDay(date); + + // Scroll to the specific note if requested + if (!string.IsNullOrEmpty(ScrollToNoteGuid) && Guid.TryParse(ScrollToNoteGuid, out var noteGuid)) + { + await ScrollToNote(noteGuid); + } } protected override void OnWindowDeactivatedOrDestroying(object sender, EventArgs e) @@ -229,6 +239,12 @@ NavigationManager.NavigateTo($"/calendar/{_day.Date:yyyyMMdd}", false, true); } + void OpenPinnedNotes() + { + logger.LogInformation("Opening Pinned notes"); + NavigationManager.NavigateTo($"/pinned-notes", false, true); + } + void ManageCategories() { logger.LogInformation("Opening category manager"); @@ -292,6 +308,21 @@ await db.SaveChangesAsync(); } + async Task ScrollToNote(Guid noteGuid) + { + logger.LogInformation($"Scrolling to note {noteGuid}"); + + // Wait for the page to render + await Task.Delay(100); + + // Try to scroll to the note's container + var categoryGuid = _day.Points.FirstOrDefault(p => p.Guid == noteGuid)?.Category?.Guid; + if (categoryGuid.HasValue) + { + await JSRuntime.InvokeVoidAsync("scrollToTopOfNestedElement", "main", $"[data-category-guid='{categoryGuid.Value}']", "smooth"); + } + } + protected override void SaveState() { base.SaveState(); diff --git a/JournalApp/Pages/PinnedNotesPage.razor b/JournalApp/Pages/PinnedNotesPage.razor new file mode 100644 index 00000000..db57f8f2 --- /dev/null +++ b/JournalApp/Pages/PinnedNotesPage.razor @@ -0,0 +1,97 @@ +@namespace JournalApp +@page "/pinned-notes" +@inherits JaPage +@implements IDisposable +@inject ILogger logger +@inject IDbContextFactory DbFactory + + + +
+
+ @if (!_pinnedNotes.Any()) + { + + + No pinned notes yet. Pin a note to see it here. + + + } + else + { + @foreach (var note in _pinnedNotes) + { + + + @note.Day.Date.ToString("ddd, MMM d, yyyy") - @note.CreatedAt.ToLocalTime().ToString("h:mm tt") + @if (!string.IsNullOrWhiteSpace(note.Text)) + { + @note.Text + } + + + } + } +
+
+ +@code { + AppDbContext db; + List _pinnedNotes = new(); + + protected override async Task OnInitializedAsync() + { + logger.LogDebug("Initializing pinned notes page"); + db = await DbFactory.CreateDbContextAsync(); + await base.OnInitializedAsync(); + + await LoadPinnedNotes(); + } + + async Task LoadPinnedNotes() + { + logger.LogInformation("Loading pinned notes"); + _pinnedNotes = await db.Points + .Include(p => p.Day) + .Include(p => p.Category) + .Where(p => p.IsPinned && !p.Deleted && p.Type == PointType.Note) + .ToListAsync(); + + // Order on the client side to avoid SQLite DateTimeOffset ordering issues + _pinnedNotes = _pinnedNotes + .OrderBy(p => p.Day.Date) + .ThenBy(p => p.CreatedAt) + .ToList(); + + logger.LogInformation($"Loaded {_pinnedNotes.Count} pinned notes"); + StateHasChanged(); + } + + void NavigateToNote(DataPoint note) + { + logger.LogInformation($"Navigating to note on {note.Day.Date}"); + + // Navigate to the day and use a query parameter to indicate which note to scroll to + var dateString = note.Day.Date.ToString("yyyyMMdd"); + NavigationManager.NavigateTo($"/{dateString}?scrollToNote={note.Guid}"); + } + + void Close() + { + logger.LogDebug("Closing pinned notes page"); + NavigationManager.NavigateTo("/"); + } + + protected override void Dispose(bool disposing) + { + logger.LogDebug("Disposing"); + base.Dispose(disposing); + + db?.Dispose(); + } +} diff --git a/JournalApp/Pages/PinnedNotesPage.razor.css b/JournalApp/Pages/PinnedNotesPage.razor.css new file mode 100644 index 00000000..b227b03a --- /dev/null +++ b/JournalApp/Pages/PinnedNotesPage.razor.css @@ -0,0 +1,22 @@ +.pinned-notes-list { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem; +} + +.pinned-note-card { + cursor: pointer; + transition: transform 0.2s ease-in-out; +} + +.pinned-note-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.pinned-note-card .mud-card-content { + display: flex; + flex-direction: column; + gap: 0.5rem; +}