feat(todo): Reporting, with goals, streaks and what was done (ADR 0033, D36)
A daily and a weekly goal, each with its progress, streak and chart, days off and vacation mode, and the history of completed tasks by project, all worked out on the device from what the tasks keep. Today shows a quiet card once a day when the goal is reached. No karma, nothing to share. Whats-New: Reporting: set a daily and a weekly goal, follow your streaks and see what you've done, with days off and vacation, under Browse. Whats-New-De: Auswertung: Lege ein Tages- und ein Wochenziel fest, verfolge deine Serien und sieh, was du erledigt hast, mit freien Tagen und Urlaub, in der Übersicht. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
016b74ff74
commit
bd1b19ed32
3 changed files with 1651 additions and 3 deletions
|
|
@ -28,6 +28,8 @@ import 'package:halkyon_todo_flutter/src/feature/project/widget/month_grid.dart'
|
|||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_board.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_calendar_view.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_table.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/report/widget/goal_editor.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/report/widget/reporting_screen.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
|
|
@ -411,6 +413,98 @@ void main() {
|
|||
expect(find.text('No labels yet.'), findsOneWidget);
|
||||
});
|
||||
|
||||
group('Reporting (D36)', () {
|
||||
Future<void> doneToday(WidgetTester tester, int count) async {
|
||||
await tester.runAsync(() async {
|
||||
final inbox = await ProjectRepository(
|
||||
projectRows,
|
||||
keys,
|
||||
).ensureInbox();
|
||||
final repository = TaskRepository(taskRows, keys);
|
||||
for (var index = 0; index < count; index += 1) {
|
||||
await repository.add(
|
||||
TaskContent(
|
||||
title: 'Done $index',
|
||||
projectId: inbox,
|
||||
completedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
projectRows.bringRemoteChanges();
|
||||
taskRows.bringRemoteChanges();
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> openReporting(WidgetTester tester) async {
|
||||
await goTab(tester, 'Browse');
|
||||
await tester.ensureVisible(find.byKey(reportingEntryKey));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(reportingEntryKey));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('shows the goal, what was done today, and the history by '
|
||||
'project', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await doneToday(tester, 2);
|
||||
await openReporting(tester);
|
||||
|
||||
expect(find.text('2 of 5 tasks'), findsOneWidget);
|
||||
expect(find.text('Done in the last 7 days'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('History'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Done 0'), findsOneWidget);
|
||||
expect(find.text('Done 1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("a goal of nothing can't be kept; one changed is, with the "
|
||||
'days off and vacation', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await openReporting(tester);
|
||||
|
||||
await tester.tap(find.text('Edit goals'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(dailyGoalKey), '0');
|
||||
await tester.pumpAndSettle();
|
||||
FilledButton save() => tester.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, 'Save'),
|
||||
);
|
||||
expect(save().onPressed, isNull);
|
||||
|
||||
await tester.enterText(find.byKey(dailyGoalKey), '3');
|
||||
await tester.tap(find.byKey(vacationKey));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final kept = await tester.runAsync(
|
||||
() => SettingsRepository(settingsRows, keys).read(),
|
||||
);
|
||||
expect(kept!.goals.daily, 3);
|
||||
expect(kept.goals.onVacation, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('the goal reached shows one quiet card on Today, and once '
|
||||
'dismissed none again that day', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await doneToday(tester, 4);
|
||||
expect(find.byType(GoalReachedCard), findsOneWidget);
|
||||
expect(find.byKey(GoalReachedCard.dismissKey), findsNothing);
|
||||
|
||||
await doneToday(tester, 1);
|
||||
expect(find.byKey(GoalReachedCard.dismissKey), findsOneWidget);
|
||||
expect(find.textContaining('Daily goal reached'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(GoalReachedCard.dismissKey));
|
||||
await tester.pumpAndSettle();
|
||||
settingsRows.bringRemoteChanges();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(GoalReachedCard.dismissKey), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('comments (D36)', () {
|
||||
Future<void> openTask(WidgetTester tester) async {
|
||||
await tester.runAsync(() async {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/report/model/productivity.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
|
||||
|
||||
/// Reporting (ADR 0033, D36), on what the tasks keep. 6 Nov 2026 is a Friday.
|
||||
void main() {
|
||||
const today = Day(2026, 11, 6);
|
||||
const monday = Day(2026, 11, 2);
|
||||
const noDaysOff = GoalSettings(daily: 2, weekly: 5, daysOff: {});
|
||||
|
||||
group('what was done', () {
|
||||
OpenTask task(String id, {DateTime? done, List<DateTime>? times}) => (
|
||||
id: id,
|
||||
content: TaskContent(
|
||||
title: id,
|
||||
projectId: 'p',
|
||||
completedAt: done,
|
||||
completions: times ?? const [],
|
||||
),
|
||||
);
|
||||
|
||||
test('counts a task done, and every time a recurring one was', () {
|
||||
final morning = DateTime(2026, 11, 6, 9);
|
||||
final tasks = [
|
||||
task('once', done: morning),
|
||||
task('open'),
|
||||
task(
|
||||
'daily',
|
||||
times: [DateTime(2026, 11, 5, 9), DateTime(2026, 11, 6, 8)],
|
||||
),
|
||||
];
|
||||
|
||||
expect(completedPerDay(tasks), {
|
||||
const Day(2026, 11, 5): 1,
|
||||
const Day(2026, 11, 6): 2,
|
||||
});
|
||||
expect(
|
||||
completedEntries(tasks).map((entry) => entry.task.id),
|
||||
['once', 'daily', 'daily'],
|
||||
);
|
||||
});
|
||||
|
||||
test('lists the last days and weeks, nothing done as zero', () {
|
||||
final counts = {const Day(2026, 11, 5): 2, const Day(2026, 10, 27): 4};
|
||||
|
||||
expect(lastDays(counts, today, 3), [
|
||||
(const Day(2026, 11, 4), 0),
|
||||
(const Day(2026, 11, 5), 2),
|
||||
(today, 0),
|
||||
]);
|
||||
expect(lastWeeks(counts, monday, 2), [
|
||||
(const Day(2026, 10, 26), 4),
|
||||
(monday, 2),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
group('the daily streak', () {
|
||||
test('counts the days in a row the goal was reached', () {
|
||||
final streak = dailyStreak(
|
||||
{
|
||||
const Day(2026, 11, 3): 2,
|
||||
const Day(2026, 11, 4): 3,
|
||||
const Day(2026, 11, 5): 2,
|
||||
today: 2,
|
||||
},
|
||||
noDaysOff,
|
||||
today,
|
||||
);
|
||||
|
||||
expect(streak.current, 4);
|
||||
expect(streak.longest?.from, const Day(2026, 11, 3));
|
||||
expect(streak.longest?.to, today);
|
||||
});
|
||||
|
||||
test('today not reached yet leaves it standing', () {
|
||||
expect(
|
||||
dailyStreak({const Day(2026, 11, 5): 2}, noDaysOff, today).current,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('a day missed ends it, and the longest stays', () {
|
||||
final streak = dailyStreak(
|
||||
{
|
||||
const Day(2026, 11, 1): 2,
|
||||
const Day(2026, 11, 2): 2,
|
||||
const Day(2026, 11, 4): 1,
|
||||
const Day(2026, 11, 5): 2,
|
||||
},
|
||||
noDaysOff,
|
||||
today,
|
||||
);
|
||||
|
||||
expect(streak.current, 1);
|
||||
expect(streak.longest?.length, 2);
|
||||
});
|
||||
|
||||
test('a day off breaks nothing, and counts where it was reached', () {
|
||||
// 1 Nov is a Sunday, 31 Oct a Saturday.
|
||||
const weekendOff = GoalSettings(daily: 2);
|
||||
final counts = {
|
||||
const Day(2026, 10, 30): 2,
|
||||
const Day(2026, 11, 1): 2,
|
||||
const Day(2026, 11, 2): 2,
|
||||
};
|
||||
|
||||
expect(dailyStreak(counts, weekendOff, monday).current, 3);
|
||||
});
|
||||
|
||||
test('a vacation over breaks nothing either', () {
|
||||
const away = GoalSettings(
|
||||
daily: 2,
|
||||
daysOff: {},
|
||||
vacations: [Vacation(Day(2026, 11, 2), Day(2026, 11, 4))],
|
||||
);
|
||||
final counts = {
|
||||
const Day(2026, 11, 1): 2,
|
||||
const Day(2026, 11, 5): 2,
|
||||
};
|
||||
|
||||
expect(dailyStreak(counts, away, today).current, 2);
|
||||
});
|
||||
|
||||
test('nothing done is no streak', () {
|
||||
expect(dailyStreak({}, noDaysOff, today), (current: 0, longest: null));
|
||||
});
|
||||
});
|
||||
|
||||
group('the weekly streak', () {
|
||||
test('counts the weeks in a row, this one not breaking it', () {
|
||||
final counts = {
|
||||
const Day(2026, 10, 20): 5,
|
||||
const Day(2026, 10, 27): 3,
|
||||
const Day(2026, 10, 29): 2,
|
||||
today: 1,
|
||||
};
|
||||
|
||||
expect(weeklyStreak(counts, noDaysOff, monday).current, 2);
|
||||
});
|
||||
|
||||
test('a week short ends it', () {
|
||||
final counts = {const Day(2026, 10, 20): 5, today: 1};
|
||||
|
||||
expect(weeklyStreak(counts, noDaysOff, monday).current, 0);
|
||||
});
|
||||
|
||||
test('a week wholly away breaks nothing', () {
|
||||
const away = GoalSettings(
|
||||
weekly: 5,
|
||||
daysOff: {},
|
||||
vacations: [Vacation(Day(2026, 10, 26), Day(2026, 11, 1))],
|
||||
);
|
||||
|
||||
expect(
|
||||
weeklyStreak({const Day(2026, 10, 20): 5}, away, monday).current,
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('a vacation', () {
|
||||
test('runs from the day it starts, and ends the day before', () {
|
||||
final away = const GoalSettings().withVacation(on: true, today: monday);
|
||||
expect(away.onVacation, isTrue);
|
||||
expect(away.isRestDay(today), isTrue);
|
||||
|
||||
final back = away.withVacation(on: false, today: today);
|
||||
expect(back.onVacation, isFalse);
|
||||
expect(back.vacations, [
|
||||
const Vacation(monday, Day(2026, 11, 5)),
|
||||
]);
|
||||
expect(back.isRestDay(today), isFalse);
|
||||
});
|
||||
|
||||
test('ended the day it started leaves nothing behind', () {
|
||||
expect(
|
||||
const GoalSettings()
|
||||
.withVacation(on: true, today: today)
|
||||
.withVacation(on: false, today: today)
|
||||
.vacations,
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -25,6 +25,43 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('goals, days off and vacations read back as they were set', () {
|
||||
const settings = TodoSettings(
|
||||
goals: GoalSettings(
|
||||
daily: 3,
|
||||
weekly: 12,
|
||||
daysOff: {DateTime.sunday},
|
||||
vacations: [
|
||||
Vacation(Day(2026, 8, 1), Day(2026, 8, 14)),
|
||||
Vacation(Day(2026, 11, 1)),
|
||||
],
|
||||
celebrated: Day(2026, 11, 6),
|
||||
),
|
||||
);
|
||||
|
||||
expect(decodeSettings(encodeSettings(settings)), settings);
|
||||
});
|
||||
|
||||
test('a goal of nothing or a weekday out of range reads as at first', () {
|
||||
expect(
|
||||
decodeSettings(
|
||||
payload({
|
||||
'schemaVersion': 1,
|
||||
'goals': {
|
||||
'daily': 0,
|
||||
'weekly': -2,
|
||||
'daysOff': [9, DateTime.monday],
|
||||
'vacations': [
|
||||
{'from': 'soon'},
|
||||
3,
|
||||
],
|
||||
},
|
||||
}),
|
||||
)?.goals,
|
||||
const GoalSettings(daysOff: {DateTime.monday}),
|
||||
);
|
||||
});
|
||||
|
||||
test("Upcoming's Display reads back as it was set", () {
|
||||
const settings = TodoSettings(
|
||||
upcoming: UpcomingView(
|
||||
|
|
|
|||
Loading…
Reference in a new issue