The editor was reachable from one button that only appeared in My Day, which is how it got forgotten. It is now a permanent sidebar entry next to My Day and Planned, and it fills the tasks island rather than the details pane, because a note list is content. Inside, the date picker is gone: today sits at the top with the capture box, older days follow as dated groups. Enter in a note saves it and opens the next one, Shift+Enter breaks the line so notes can be several, Backspace on an empty note deletes it and steps back up. Deleting also has a visible hover button instead of only "clear the text and hope". Agent-written notes carry a marker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
197 lines
6.3 KiB
C#
197 lines
6.3 KiB
C#
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;
|
|
|
|
/// <summary>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.</summary>
|
|
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
|
|
{
|
|
/// <summary>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.</summary>
|
|
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
|
|
{
|
|
/// <summary>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.</summary>
|
|
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<string>? ErrorReported;
|
|
|
|
/// <summary>Raised after a row is inserted so the view can move the caret into it.</summary>
|
|
public event Action<NoteBulletViewModel>? FocusRequested;
|
|
|
|
/// <summary>NoteDayHeaderViewModel | NoteBulletViewModel, newest day first.</summary>
|
|
public ObservableCollection<object> Rows { get; } = new();
|
|
|
|
[ObservableProperty] private string _newBulletText = "";
|
|
[ObservableProperty] private bool _isEmpty = true;
|
|
|
|
public DateOnly Today { get; private set; } = DateOnly.FromDateTime(DateTime.Today);
|
|
|
|
/// <summary>Loads today plus <see cref="HistoryDays"/> days of history.</summary>
|
|
public async Task LoadAsync()
|
|
{
|
|
Today = DateOnly.FromDateTime(DateTime.Today);
|
|
List<DailyNoteDto> 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<NoteBulletViewModel>().Any();
|
|
|
|
/// <summary>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.</summary>
|
|
[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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Enter inside a note: save it, then open an empty row underneath and focus it.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Backspace in an already-empty note: drop it and put the caret in the one above.</summary>
|
|
[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();
|
|
}
|
|
}
|