Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions NOTES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Notes

## 2026-05-14 (issue #289)
- `_needsReviewWhere`'s actionless branch now ANDs on `NOT EXISTS person tag`. Delegated tasks (any person-typed `todo_tags` row) are excluded from the daily re-clarification surface — waiting-for cadence belongs to the weekly review, not the daily card. The stale branch is untouched, so a delegated task you nudged today still surfaces.
- `readsFrom` for the three `_needsReviewWhere` callers (`watchNeedsReview`, `getNeedsReview`/`getNeedsReviewCount`/`isNeedsReview`) widened from `{todos}` to `{todos, todoTags, tags}`. Without the widening, Drift's stream invalidation doesn't fire when a person tag is attached/detached, and the live daily card would not drop a newly-tagged task until the next cold reload.
- `FocusSessionPlanningState.reviewPersonTags` is loaded alongside the review snapshot in `advanceStep` (one batched `getPersonTagsForTodos` query) so the new `staleWaitingFor` card variant can name the delegate without a per-frame DAO call.

## 2026-05-14 (issue #290)
- Weekly Review's third step renamed from Projects → Next Actions and broadened to iterate **all** active next actions excluding person-tagged ones (which Waiting For already covers). The disjointness invariant — each task surfaces in at most one wizard step — is now enforced at the SQL layer via `TodoDao.getNextActionsExcludingPersonTagged()`. The old `getNextActionsWithProjectTags()` and `Projects*` symbols are gone. A future dedicated Projects step is deferred until full Projects support lands; resurrecting it will require re-establishing the disjointness matrix (project-tagged ⊂ next-actions).

Expand Down
23 changes: 16 additions & 7 deletions app/lib/database/daos/todo_dao.dart
Original file line number Diff line number Diff line change
Expand Up @@ -388,19 +388,28 @@ AND (
(last_next_action_completion_at IS NOT NULL
AND (last_clarified_at IS NULL
OR last_clarified_at < last_next_action_completion_at))
OR next_action_text IS NULL
OR TRIM(next_action_text) = ''
OR (
(next_action_text IS NULL OR TRIM(next_action_text) = '')
AND NOT EXISTS (
SELECT 1 FROM todo_tags tt
JOIN tags tg ON tg.id = tt.tag_id
WHERE tt.todo_id = todos.id AND tg.type = 'person'
)
)
)''';

/// Stream of tasks needing re-clarification: Stale or Actionless per spec.
///
/// Stale: worked on in a session more recently than last clarified.
/// Actionless: no next action defined (regardless of session history).
/// Actionless: no next action defined AND not delegated. Delegated tasks
/// (carrying any person-typed tag) are excluded from the actionless branch —
/// their cadence belongs to the weekly Waiting For review, not the daily
/// re-clarification surface.
Stream<List<Todo>> watchNeedsReview() {
return customSelect(
'SELECT * FROM todos WHERE $_needsReviewWhere ORDER BY created_at',
variables: [],
readsFrom: {todos},
readsFrom: {todos, todoTags, tags},
).watch().map((rows) => rows.map((r) => todos.map(r.data)).toList());
}

Expand Down Expand Up @@ -430,7 +439,7 @@ AND (
return customSelect(
'SELECT * FROM todos WHERE $_needsReviewWhere ORDER BY created_at',
variables: [],
readsFrom: {todos},
readsFrom: {todos, todoTags, tags},
).get().then((rows) => rows.map((r) => todos.map(r.data)).toList());
}

Expand All @@ -440,7 +449,7 @@ AND (
final rows = await customSelect(
'SELECT COUNT(*) AS cnt FROM todos WHERE $_needsReviewWhere',
variables: [],
readsFrom: {todos},
readsFrom: {todos, todoTags, tags},
).get();
return rows.first.read<int>('cnt');
}
Expand Down Expand Up @@ -523,7 +532,7 @@ AND (
final rows = await customSelect(
'SELECT 1 FROM todos WHERE id = ? AND $_needsReviewWhere',
variables: [Variable(todoId)],
readsFrom: {todos},
readsFrom: {todos, todoTags, tags},
).get();
return rows.isNotEmpty;
}
Expand Down
18 changes: 17 additions & 1 deletion app/lib/providers/focus_session_planning_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import 'database_provider.dart';
import 'synced_preferences_provider.dart';
import 'tag_filter_provider.dart';

export '../database/gtd_database.dart' show Todo, FocusSession;
export '../database/gtd_database.dart' show Todo, FocusSession, Tag;

// ---------------------------------------------------------------------------
// Date helpers
Expand Down Expand Up @@ -272,6 +272,7 @@ class FocusSessionPlanningState {
this.reviewedTaskIds = const [],
this.reviewNav = const SnapshotNav<Todo>(),
this.reviewActions = const {},
this.reviewPersonTags = const {},
});

final int currentStep;
Expand Down Expand Up @@ -316,6 +317,12 @@ class FocusSessionPlanningState {
/// when the user navigates back within the review step.
final Map<int, ReviewActionRecord> reviewActions;

/// Person-typed tags for each task in [reviewNav.items], keyed by task id.
/// Loaded alongside the review snapshot so the card can render the delegate
/// name(s) for the stale waiting-for variant without a per-frame DAO call.
/// Tasks with no person tag are absent from the map.
final Map<String, List<Tag>> reviewPersonTags;

FocusSessionPlanningState copyWith({
int? currentStep,
int? availableMinutes,
Expand All @@ -328,6 +335,7 @@ class FocusSessionPlanningState {
List<String>? reviewedTaskIds,
SnapshotNav<Todo>? reviewNav,
Map<int, ReviewActionRecord>? reviewActions,
Map<String, List<Tag>>? reviewPersonTags,
}) =>
FocusSessionPlanningState(
currentStep: currentStep ?? this.currentStep,
Expand All @@ -341,6 +349,7 @@ class FocusSessionPlanningState {
reviewedTaskIds: reviewedTaskIds ?? this.reviewedTaskIds,
reviewNav: reviewNav ?? this.reviewNav,
reviewActions: reviewActions ?? this.reviewActions,
reviewPersonTags: reviewPersonTags ?? this.reviewPersonTags,
);
}

Expand Down Expand Up @@ -407,9 +416,16 @@ class FocusSessionPlanningNotifier extends Notifier<FocusSessionPlanningState> {
if (items.isEmpty) {
next = 2;
} else {
// Batched person-tag lookup so the stale waiting-for card variant
// can render delegate names without a per-frame DAO call.
final personTags = await _db.todoDao.getPersonTagsForTodos(
items.map((t) => t.id).toSet(),
);
if (!ref.mounted) return;
state = state.copyWith(
reviewNav: SnapshotNav<Todo>(items: items),
reviewActions: {},
reviewPersonTags: personTags,
);
}
}
Expand Down
121 changes: 84 additions & 37 deletions app/lib/screens/planning/steps/task_review_step.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
///
/// Surfaces tasks needing re-clarification one at a time:
/// - Stale tasks: worked on in a session more recently than last clarified.
/// - Actionless tasks: no next action defined.
/// - Actionless tasks: no next action defined (excluding delegated tasks).
///
/// Context-aware action menu:
/// - Stale tasks show "Still relevant" (stamps last_clarified_at, clears stale).
/// - Stale tasks without a delegate show "Still relevant" (stamps
/// last_clarified_at, clears stale).
/// - Stale delegated tasks show "Still waiting" (same write, waiting-for copy).
/// - Actionless tasks omit "Still relevant" — defining an action is required.
library;

Expand All @@ -20,15 +22,39 @@ import '../../../widgets/process_to_handlers.dart';
// Hint enum — derived from task state
// ---------------------------------------------------------------------------

enum ReclarifyHint { noNextAction, updatedSinceClarified }
enum ReclarifyHint {
noNextAction,
updatedSinceClarified,
staleWaitingFor,
}

/// Returns true when [t]'s session timestamps mark it as stale per the
/// `_needsReviewWhere` stale branch: a session completion happened after the
/// last clarification stamp.
bool isStaleReclarification(Todo t) =>
t.lastNextActionCompletionAt != null &&
(t.lastClarifiedAt == null ||
t.lastClarifiedAt!.isBefore(t.lastNextActionCompletionAt!));

/// Mirror of [TodoDao] `_needsReviewWhere`'s actionless predicate:
/// `next_action_text IS NULL OR TRIM(next_action_text) = ''`. Whitespace-only
/// values land rows in the queue, so the hint must agree (#278).
ReclarifyHint hintFor(Todo t) =>
(t.nextActionText == null || t.nextActionText!.trim().isEmpty)
? ReclarifyHint.noNextAction
: ReclarifyHint.updatedSinceClarified;
///
/// [hasPersonTag] and [isStale] are computed by the caller (the widget reads
/// person tags from [FocusSessionPlanningState.reviewPersonTags] and stale
/// from [isStaleReclarification]). Keeping the helper pure makes it
/// trivially unit-testable.
ReclarifyHint hintFor(
Todo t, {
required bool hasPersonTag,
required bool isStale,
}) {
if (isStale && hasPersonTag) return ReclarifyHint.staleWaitingFor;
if (t.nextActionText == null || t.nextActionText!.trim().isEmpty) {
return ReclarifyHint.noNextAction;
}
return ReclarifyHint.updatedSinceClarified;
}

// ---------------------------------------------------------------------------
// Step widget
Expand All @@ -47,10 +73,16 @@ class TaskReviewStep extends ConsumerWidget {
}

final task = nav.current!;
final personTags = state.reviewPersonTags[task.id] ?? const <Tag>[];
return _ReviewCard(
key: ValueKey(task.id),
task: task,
hint: hintFor(task),
hint: hintFor(
task,
hasPersonTag: personTags.isNotEmpty,
isStale: isStaleReclarification(task),
),
personTags: personTags,
previousAction: state.reviewActions[nav.index],
);
}
Expand All @@ -65,19 +97,50 @@ class _ReviewCard extends ConsumerWidget {
super.key,
required this.task,
required this.hint,
this.personTags = const [],
this.previousAction,
});

final Todo task;
final ReclarifyHint hint;

/// Person-typed tags assigned to [task]. Drives the delegate name(s) in
/// the [ReclarifyHint.staleWaitingFor] badge.
final List<Tag> personTags;

/// Action recorded for this index on a previous pass — drives selection
/// affordances and pre-fills dialogs when the user navigates back.
final ReviewActionRecord? previousAction;

@override
Widget build(BuildContext context, WidgetRef ref) {
final isActionless = hint == ReclarifyHint.noNextAction;
final isWaitingFor = hint == ReclarifyHint.staleWaitingFor;
final showKeep = !isActionless;

final badgeBg = isActionless
? const Color(0xFFFEF3C7)
: const Color(0xFFEFF6FF);
final badgeBorder = isActionless
? const Color(0xFFFDE68A)
: const Color(0xFFDBEAFE);
final badgeIconColor = isActionless
? const Color(0xFFD97706)
: const Color(0xFF2563EB);
final badgeTextColor = isActionless
? const Color(0xFF92400E)
: const Color(0xFF1D4ED8);
final badgeIcon = switch (hint) {
ReclarifyHint.noNextAction => Icons.warning_amber_outlined,
ReclarifyHint.updatedSinceClarified => Icons.update_outlined,
ReclarifyHint.staleWaitingFor => Icons.person_outline,
};
final badgeText = switch (hint) {
ReclarifyHint.noNextAction => 'No next action defined',
ReclarifyHint.updatedSinceClarified => 'Updated since last clarified',
ReclarifyHint.staleWaitingFor =>
'Waiting for ${personTags.map((t) => t.name).join(', ')} — still waiting?',
};

return ListView(
physics: const ClampingScrollPhysics(),
Expand All @@ -87,39 +150,21 @@ class _ReviewCard extends ConsumerWidget {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isActionless
? const Color(0xFFFEF3C7)
: const Color(0xFFEFF6FF),
color: badgeBg,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isActionless
? const Color(0xFFFDE68A)
: const Color(0xFFDBEAFE),
),
border: Border.all(color: badgeBorder),
),
child: Row(
children: [
Icon(
isActionless
? Icons.warning_amber_outlined
: Icons.update_outlined,
size: 18,
color: isActionless
? const Color(0xFFD97706)
: const Color(0xFF2563EB),
),
Icon(badgeIcon, size: 18, color: badgeIconColor),
const SizedBox(width: 8),
Expanded(
child: Text(
isActionless
? 'No next action defined'
: 'Updated since last clarified',
badgeText,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isActionless
? const Color(0xFF92400E)
: const Color(0xFF1D4ED8),
color: badgeTextColor,
),
),
),
Expand Down Expand Up @@ -147,7 +192,7 @@ class _ReviewCard extends ConsumerWidget {
),
],

// Current next action (if stale)
// Current next action (if present)
if (!isActionless && task.nextActionText != null) ...[
const SizedBox(height: 8),
Row(
Expand All @@ -172,19 +217,21 @@ class _ReviewCard extends ConsumerWidget {
const SizedBox(height: 28),

// Action buttons — defaults plus the dialog modifier on Next.
// Stale tasks add "Still relevant" (keep with a custom label);
// Actionless tasks omit it because defining an action is required.
// Stale tasks add a "keep" variant (label depends on whether the task
// is delegated); Actionless tasks omit it because defining an action
// is required.
const _FieldLabel('WHAT DO YOU WANT TO DO?'),
const SizedBox(height: 12),

ProcessToHandlers(
todo: task,
include: {
ProcessAction.nextActionDialog,
if (!isActionless) ProcessAction.keep,
if (showKeep) ProcessAction.keep,
},
labels: const {
ProcessAction.keep: 'Still relevant',
labels: {
ProcessAction.keep:
isWaitingFor ? 'Still waiting' : 'Still relevant',
ProcessAction.next: 'Update next action…',
},
lastAction: _toProcessAction(previousAction?.kind),
Expand Down
Loading
Loading