todo-app/lib/data/note_query.dart
Krzysztof kuhy Rudnicki 57019eec30
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Track who filed a note (created_by)
New optional note field created_by (kuhy|claude), stored like by/repo and
omitted when unset so older builds stay wire-compatible. App UI stamps kuhy;
MCP add_task and the CLI stamp claude. list/get/search output includes it.
Human notes sort above Claude's; Claude notes are hidden behind a per-device
eye toggle. CLI --backfill-created-by [--dry-run] classifies old notes by the
node that wrote created_at, writing only the created_by field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:18:19 +01:00

206 lines
6.6 KiB
Dart

/// How notes are selected and ordered: the sort modes, the filter, and the
/// pure predicates behind them.
///
/// Split out of `note_repository.dart` for file size; that file re-exports
/// this one, so callers import it as before. Everything here is pure -- it
/// takes notes and a filter and returns a decision -- which is what made it
/// the natural seam.
library;
import 'package:todo/data/note.dart';
/// How the history list should be ordered.
enum NoteSort {
/// Newest created first.
createdDesc,
/// Most recently modified first.
modifiedDesc,
/// A-Z by note text.
alphabetical,
/// Highest [Priority] first.
priorityDesc,
}
/// An immutable set of constraints for querying notes.
///
/// All fields combine with logical AND. Empty/null fields impose no
/// constraint. Lives in the data layer so the filtering it drives never leaks
/// into the UI. Construct copies with [copyWith] when toggling one facet.
class NoteFilter {
/// Creates a [NoteFilter] from its constraint fields.
const NoteFilter({
this.query = '',
this.priorities = const {},
this.statuses = const {},
this.repo,
this.repoTasksOnly = false,
this.hideClaude = false,
this.createdFrom,
this.createdTo,
this.updatedFrom,
this.updatedTo,
});
/// Case-insensitive substring matched against the note body.
final String query;
/// Notes must have one of these priorities. Empty means "any priority".
final Set<Priority> priorities;
/// Notes must have one of these statuses. Empty means "any status".
final Set<Status> statuses;
/// Only notes whose repo equals this. Null means any repo (or none).
final String? repo;
/// Only notes that have a repo (repo tasks). Ideas are excluded.
final bool repoTasksOnly;
/// Leave out notes Claude filed. A view preference rather than a user
/// facet, so it never counts toward [isEmpty] or [activeCount].
final bool hideClaude;
/// Inclusive lower bound (by calendar day) on the creation date.
final DateTime? createdFrom;
/// Inclusive upper bound (by calendar day) on the creation date.
final DateTime? createdTo;
/// Inclusive lower bound (by calendar day) on the last-updated date.
final DateTime? updatedFrom;
/// Inclusive upper bound (by calendar day) on the last-updated date.
final DateTime? updatedTo;
/// True when no constraint is active (the unfiltered, full list).
bool get isEmpty =>
query.trim().isEmpty &&
priorities.isEmpty &&
statuses.isEmpty &&
repo == null &&
!repoTasksOnly &&
createdFrom == null &&
createdTo == null &&
updatedFrom == null &&
updatedTo == null;
/// Number of distinct active facets, for an "N filters" badge in the UI.
int get activeCount {
var n = 0;
if (query.trim().isNotEmpty) n++;
if (priorities.isNotEmpty) n++;
if (statuses.isNotEmpty) n++;
if (repo != null) n++;
if (repoTasksOnly) n++;
if (createdFrom != null || createdTo != null) n++;
if (updatedFrom != null || updatedTo != null) n++;
return n;
}
/// Returns a copy with selected facets replaced. A `null` argument keeps
/// the current value; clearing a date is done via the dedicated clear
/// flags so `null` can mean "unchanged".
NoteFilter copyWith({
String? query,
Set<Priority>? priorities,
Set<Status>? statuses,
String? repo,
bool? repoTasksOnly,
bool? hideClaude,
DateTime? createdFrom,
DateTime? createdTo,
DateTime? updatedFrom,
DateTime? updatedTo,
bool clearCreated = false,
bool clearUpdated = false,
}) {
return NoteFilter(
query: query ?? this.query,
priorities: priorities ?? this.priorities,
statuses: statuses ?? this.statuses,
repo: repo ?? this.repo,
repoTasksOnly: repoTasksOnly ?? this.repoTasksOnly,
hideClaude: hideClaude ?? this.hideClaude,
createdFrom: clearCreated ? null : (createdFrom ?? this.createdFrom),
createdTo: clearCreated ? null : (createdTo ?? this.createdTo),
updatedFrom: clearUpdated ? null : (updatedFrom ?? this.updatedFrom),
updatedTo: clearUpdated ? null : (updatedTo ?? this.updatedTo),
);
}
}
/// Whether [note] passes every clause of [filter].
///
/// Clauses are AND-combined, and an empty clause matches everything -- an
/// empty priority set means "any priority", not "no priorities".
bool matchesFilter(Note note, NoteFilter filter) {
final query = filter.query.trim().toLowerCase();
if (query.isNotEmpty &&
!note.text.toLowerCase().contains(query) &&
!(note.repo?.toLowerCase().contains(query) ?? false)) {
return false;
}
if (filter.repo != null && note.repo != filter.repo) return false;
if (filter.repoTasksOnly && note.repo == null) return false;
if (filter.hideClaude && note.isClaudeMade) return false;
if (filter.priorities.isNotEmpty &&
!filter.priorities.contains(note.priority)) {
return false;
}
if (filter.statuses.isNotEmpty && !filter.statuses.contains(note.status)) {
return false;
}
if (!_withinDays(note.createdAt, filter.createdFrom, filter.createdTo)) {
return false;
}
if (!_withinDays(note.updatedAt, filter.updatedFrom, filter.updatedTo)) {
return false;
}
return true;
}
/// Inclusive day-granularity range check, matching the old SQL bounds:
/// `from` includes its whole day; `to` includes its whole day.
bool _withinDays(DateTime value, DateTime? from, DateTime? to) {
if (from != null && value.isBefore(_startOfDay(from))) return false;
if (to != null &&
!value.isBefore(_startOfDay(to).add(const Duration(days: 1)))) {
return false;
}
return true;
}
/// The comparator implementing [sort]: human-made notes first, Claude-made
/// ones after, and [sort] orders each group.
Comparator<Note> comparatorFor(NoteSort sort) {
final within = _within(sort);
return (a, b) {
if (a.isClaudeMade != b.isClaudeMade) return a.isClaudeMade ? 1 : -1;
return within(a, b);
};
}
Comparator<Note> _within(NoteSort sort) {
switch (sort) {
case NoteSort.createdDesc:
return (a, b) => b.createdAt.compareTo(a.createdAt);
case NoteSort.modifiedDesc:
return (a, b) => b.updatedAt.compareTo(a.updatedAt);
case NoteSort.alphabetical:
return (a, b) => a.text.toLowerCase().compareTo(b.text.toLowerCase());
case NoteSort.priorityDesc:
return (a, b) {
final byPriority = b.priority.value.compareTo(a.priority.value);
return byPriority != 0
? byPriority
: b.updatedAt.compareTo(a.updatedAt);
};
}
}
/// Midnight (local) of [t]'s calendar day.
DateTime _startOfDay(DateTime t) => DateTime(t.year, t.month, t.day);