using System.Collections.ObjectModel; using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services.Interfaces; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace ClaudeDo.Ui.ViewModels.Islands; /// Date separator between the note groups. Rows carry these inline (like the task list's /// HeaderRow) so one ItemsControl renders today and the history without a nested control per day. public sealed class NoteDayHeaderViewModel { public DateOnly Day { get; } public string Label { get; } public bool IsToday { get; } public NoteDayHeaderViewModel(DateOnly day, DateOnly today) { Day = day; IsToday = day == today; Label = day == today ? Loc.T("notes.today") : day == today.AddDays(-1) ? Loc.T("notes.yesterday") : day.ToString("dddd, dd.MM."); } } public sealed partial class NoteBulletViewModel : ViewModelBase { /// Empty until the note is persisted — a row the user started but hasn't typed into /// yet exists only in the list, so an abandoned Enter leaves nothing behind in the database. public string Id { get; set; } public DateOnly Day { get; } public bool FromAgent { get; } public bool IsNew => Id.Length == 0; [ObservableProperty] private string _text; public NoteBulletViewModel(string id, DateOnly day, string text, bool fromAgent = false) { Id = id; Day = day; _text = text; FromAgent = fromAgent; } } public sealed partial class NotesEditorViewModel : ViewModelBase { /// How far the history scrolls back. Older notes stay in the database and in the week /// report; they are simply not worth the round trip in a day log. public const int HistoryDays = 14; private readonly INotesApi _api; public NotesEditorViewModel(INotesApi api) => _api = api; // Raised when a worker call fails so the host VM can surface it (e.g. via ShowErrorAsync). public event Action? ErrorReported; /// Raised after a row is inserted so the view can move the caret into it. public event Action? FocusRequested; /// NoteDayHeaderViewModel | NoteBulletViewModel, newest day first. public ObservableCollection Rows { get; } = new(); [ObservableProperty] private string _newBulletText = ""; [ObservableProperty] private bool _isEmpty = true; public DateOnly Today { get; private set; } = DateOnly.FromDateTime(DateTime.Today); /// Loads today plus days of history. public async Task LoadAsync() { Today = DateOnly.FromDateTime(DateTime.Today); List notes; try { notes = await _api.ListBetweenAsync(Today.AddDays(-HistoryDays), Today); } catch (Exception ex) { ErrorReported?.Invoke(ex.Message); return; } Rows.Clear(); // Today always gets a header, even with nothing under it — it is where typing lands. Rows.Add(new NoteDayHeaderViewModel(Today, Today)); foreach (var n in notes.Where(n => Day(n) == Today).OrderBy(n => n.SortOrder)) Rows.Add(Bullet(n)); foreach (var group in notes.Where(n => Day(n) != Today) .GroupBy(Day).OrderByDescending(g => g.Key)) { Rows.Add(new NoteDayHeaderViewModel(group.Key, Today)); foreach (var n in group.OrderBy(n => n.SortOrder)) Rows.Add(Bullet(n)); } RefreshEmpty(); } private static DateOnly Day(DailyNoteDto n) => DateOnly.Parse(n.Date); private static NoteBulletViewModel Bullet(DailyNoteDto n) => new(n.Id, Day(n), n.Text, n.FromAgent); private void RefreshEmpty() => IsEmpty = !Rows.OfType().Any(); /// The box above the list: Enter files the note under today and keeps the box focused, /// so a burst of notes is one uninterrupted stream of typing. [RelayCommand] private async Task AddBullet() { var text = NewBulletText.Trim(); if (text.Length == 0) return; try { var dto = await _api.AddAsync(Today, text); if (dto is not null) Rows.Insert(1, Bullet(dto)); NewBulletText = ""; RefreshEmpty(); } catch (Exception ex) { ErrorReported?.Invoke(ex.Message); } } /// Enter inside a note: save it, then open an empty row underneath and focus it. [RelayCommand] private async Task SplitBullet(NoteBulletViewModel? b) { if (b is null) return; await CommitBullet(b); if (!Rows.Contains(b)) return; // committing an emptied note removed it var fresh = new NoteBulletViewModel("", b.Day, ""); Rows.Insert(Rows.IndexOf(b) + 1, fresh); FocusRequested?.Invoke(fresh); } /// Backspace in an already-empty note: drop it and put the caret in the one above. [RelayCommand] private async Task DeleteBullet(NoteBulletViewModel? b) { if (b is null) return; var index = Rows.IndexOf(b); await RemoveAsync(b); for (var i = index - 1; i >= 0; i--) if (Rows[i] is NoteBulletViewModel previous) { FocusRequested?.Invoke(previous); return; } } [RelayCommand] private async Task CommitBullet(NoteBulletViewModel? b) { if (b is null) return; var text = b.Text?.Trim() ?? ""; try { if (text.Length == 0) { await RemoveAsync(b); return; } if (b.IsNew) { var dto = await _api.AddAsync(b.Day, text); if (dto is not null) b.Id = dto.Id; } else { await _api.UpdateAsync(b.Id, text); } } catch (Exception ex) { ErrorReported?.Invoke(ex.Message); } } private async Task RemoveAsync(NoteBulletViewModel b) { if (!b.IsNew) await _api.DeleteAsync(b.Id); Rows.Remove(b); RefreshEmpty(); } }