feat(app): ask 'how did it do?' when a harvest is noted (schema v13)

The note growers most wish they had two seasons later — did it do well,
was it worth keeping — never had a home. New append-only GardenOutcomes
table (lotId, season year, three-face rating, free note); the question
appears ONCE, inline, right after recording a harvest in the batch story,
and is skippable without a trace. The answer shows as one more story
line. Rides backups/sync like every mutable table (JSON codec, LWW
import, HLC clock absorb). Migration v12→v13 guarded + verified from
every historical version.
This commit is contained in:
vjrj 2026-07-18 12:18:25 +02:00
parent 92fd84590b
commit bb5b3d4a43
20 changed files with 6030 additions and 15 deletions

View file

@ -327,7 +327,13 @@ class ConditionEntry extends Equatable {
}
/// What kind of event a [LotHistoryEntry] narrates.
enum LotHistoryKind { created, movement, germinationTest, conditionCheck }
enum LotHistoryKind {
created,
movement,
germinationTest,
conditionCheck,
gardenOutcome,
}
/// One line of a lot's composite history — the batch's own story, merged from
/// what is already recorded (the lot's creation with its origin, movements,
@ -346,6 +352,8 @@ class LotHistoryEntry extends Equatable {
this.germinationRate,
this.containerCount,
this.desiccantState,
this.rating,
this.year,
this.hasParentLink = false,
});
@ -374,6 +382,10 @@ class LotHistoryEntry extends Equatable {
final int? containerCount;
final DesiccantState? desiccantState;
/// Set for [LotHistoryKind.gardenOutcome] entries: how that season went.
final GardenOutcomeRating? rating;
final int? year;
/// True when the movement links back to a source batch (provenance DAG).
final bool hasParentLink;
@ -390,6 +402,8 @@ class LotHistoryEntry extends Equatable {
germinationRate,
containerCount,
desiccantState,
rating,
year,
hasParentLink,
];
}
@ -1973,6 +1987,33 @@ class VarietyRepository {
return id;
}
/// Records how a season went for a lot the optional, skippable answer to
/// "how did it do?" asked when a harvest is noted. Returns the new row id.
Future<String> addGardenOutcome({
required String lotId,
int? year,
GardenOutcomeRating? rating,
String? notes,
}) async {
final (created, updated) = await _stamp();
final id = idGen.newId();
await _db
.into(_db.gardenOutcomes)
.insert(
GardenOutcomesCompanion.insert(
id: id,
lotId: lotId,
createdAt: created,
updatedAt: updated,
lastAuthor: nodeId,
year: Value(year),
rating: Value(rating),
notes: Value(_hasText(notes) ? notes!.trim() : null),
),
);
return id;
}
/// The lot's composite history, most-recent first: every movement,
/// germination test and condition check already recorded, closed by the
/// lot's own creation entry (carrying its origin, so "received from…" shows
@ -1993,6 +2034,9 @@ class VarietyRepository {
final checks = await (_db.select(
_db.conditionChecks,
)..where((c) => c.lotId.equals(lotId) & c.isDeleted.equals(false))).get();
final outcomes = await (_db.select(
_db.gardenOutcomes,
)..where((o) => o.lotId.equals(lotId) & o.isDeleted.equals(false))).get();
// Resolve counterparty display names in one go (usually zero or one).
final partyIds = movements
@ -2047,6 +2091,14 @@ class VarietyRepository {
containerCount: c.containerCount,
desiccantState: c.desiccantState,
),
for (final o in outcomes)
LotHistoryEntry(
kind: LotHistoryKind.gardenOutcome,
when: o.createdAt,
notes: o.notes,
rating: o.rating,
year: o.year,
),
]..sort((a, b) => (b.when ?? 0).compareTo(a.when ?? 0));
return [
@ -2451,6 +2503,9 @@ class VarietyRepository {
conditionChecks: await (_db.select(
_db.conditionChecks,
)..where((c) => c.isDeleted.equals(false))).get(),
gardenOutcomes: await (_db.select(
_db.gardenOutcomes,
)..where((o) => o.isDeleted.equals(false))).get(),
movements: await _db.select(_db.movements).get(),
parties: await (_db.select(
_db.parties,
@ -2484,6 +2539,7 @@ class VarietyRepository {
externalLinks: await _db.select(_db.externalLinks).get(),
germinationTests: await _db.select(_db.germinationTests).get(),
conditionChecks: await _db.select(_db.conditionChecks).get(),
gardenOutcomes: await _db.select(_db.gardenOutcomes).get(),
movements: await _db.select(_db.movements).get(),
parties: await _db.select(_db.parties).get(),
plantares: await _db.select(_db.plantares).get(),
@ -2564,6 +2620,14 @@ class VarietyRepository {
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMutableRows(
table: _db.gardenOutcomes,
idColumn: _db.gardenOutcomes.id,
rows: snapshot.gardenOutcomes,
idOf: (r) => r.id,
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMutableRows(
table: _db.parties,
idColumn: _db.parties.id,
@ -2606,6 +2670,7 @@ class VarietyRepository {
for (final e in snapshot.externalLinks) e.updatedAt,
for (final g in snapshot.germinationTests) g.updatedAt,
for (final c in snapshot.conditionChecks) c.updatedAt,
for (final o in snapshot.gardenOutcomes) o.updatedAt,
for (final p in snapshot.parties) p.updatedAt,
for (final a in snapshot.attachments) a.updatedAt,
for (final pl in snapshot.plantares) pl.updatedAt,