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

@ -153,6 +153,23 @@ class InventoryJsonCodec {
'notes': c.notes,
},
],
'gardenOutcomes': [
for (final o in snapshot.gardenOutcomes)
{
..._syncMeta(
id: o.id,
createdAt: o.createdAt,
updatedAt: o.updatedAt,
lastAuthor: o.lastAuthor,
schemaRowVersion: o.schemaRowVersion,
isDeleted: o.isDeleted,
),
'lotId': o.lotId,
'year': o.year,
'rating': o.rating?.name,
'notes': o.notes,
},
],
'movements': [
for (final m in snapshot.movements)
{
@ -417,6 +434,20 @@ class InventoryJsonCodec {
notes: m['notes'] as String?,
);
}),
gardenOutcomes: _rows(root, 'gardenOutcomes', (m) {
return GardenOutcome(
id: _string(m, 'id'),
createdAt: _int(m, 'createdAt'),
updatedAt: _string(m, 'updatedAt'),
lastAuthor: _string(m, 'lastAuthor'),
isDeleted: _bool(m, 'isDeleted'),
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
lotId: _string(m, 'lotId'),
year: m['year'] as int?,
rating: _enumOrNull(GardenOutcomeRating.values, m['rating']),
notes: m['notes'] as String?,
);
}),
movements: _rows(root, 'movements', (m) {
final type = _enumOrNull(MovementType.values, m['type']);
if (type == null) return null; // no safe default drop row

View file

@ -27,6 +27,7 @@ class InventorySnapshot {
this.externalLinks = const [],
this.germinationTests = const [],
this.conditionChecks = const [],
this.gardenOutcomes = const [],
this.movements = const [],
this.parties = const [],
this.attachments = const [],
@ -41,6 +42,7 @@ class InventorySnapshot {
final List<ExternalLink> externalLinks;
final List<GerminationTest> germinationTests;
final List<ConditionCheck> conditionChecks;
final List<GardenOutcome> gardenOutcomes;
final List<Movement> movements;
final List<Party> parties;
final List<Attachment> attachments;

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,