look ahead

This commit is contained in:
davmar
2026-09-10 00:35:54 +02:00
parent 91d6dc9e54
commit 5b22fc8694
20 changed files with 2942 additions and 799 deletions
+157 -5
View File
@@ -52,6 +52,13 @@ TxMetaStoreData mergeTxMetaStores(TxMetaStoreData a, TxMetaStoreData b) {
existing == null ? e.value : _mergeExact(existing, e.value);
}
final recurringTemplates = <String, RecurringTemplate>{
...a.recurringTemplates,
};
for (final e in b.recurringTemplates.entries) {
recurringTemplates.putIfAbsent(e.key, () => e.value);
}
final labelsByClientId = <String, TxLabel>{
for (final label in a.customLabels) label.id: label,
};
@@ -66,6 +73,7 @@ TxMetaStoreData mergeTxMetaStores(TxMetaStoreData a, TxMetaStoreData b) {
labelsByMerchant: labelsByMerchant,
notesByMerchant: notesByMerchant,
exactTemplates: exactTemplates,
recurringTemplates: recurringTemplates,
customLabels: labelsByClientId.values.toList(growable: false),
labelParents: labelParents,
);
@@ -170,6 +178,7 @@ class PbSyncService {
}) async {
final out = <RecordModel>[];
var page = 1;
var lastReported = -1;
while (true) {
final result = await _withTimeout(
_pb.collection(collection).getList(
@@ -181,8 +190,13 @@ class PbSyncService {
label: '$collection list page $page',
);
out.addAll(result.items);
onPage?.call(out.length);
if (result.items.length < _listPageSize) break;
final finished = result.items.length < _listPageSize;
if (onPage != null &&
(finished || out.length - lastReported >= 400)) {
lastReported = out.length;
onPage(out.length);
}
if (finished) break;
page++;
}
return out;
@@ -213,7 +227,17 @@ class PbSyncService {
report(0.36);
onStatus?.call('Pulling templates…');
final exactRecords = await _fullList('exact_templates', filter);
report(0.45);
report(0.42);
onStatus?.call('Pulling recurring templates…');
List<RecordModel> recurringRecords = const [];
try {
recurringRecords = await _fullList('recurring_templates', filter);
} catch (error) {
debugPrint(
'PbSyncService pull: recurring_templates unavailable ($error)',
);
}
report(0.48);
final customLabels = <TxLabel>[];
final labelParents = <String, String>{};
@@ -269,10 +293,35 @@ class PbSyncService {
);
}
final recurringTemplates = <String, RecurringTemplate>{};
for (final r in recurringRecords) {
final clientId = r.getStringValue('client_id');
if (clientId.isEmpty) continue;
final sourceTx = r.getStringValue('source_tx_id');
final labelId = r.getStringValue('label_id');
recurringTemplates[clientId] = RecurringTemplate(
id: clientId,
name: r.getStringValue('name').isEmpty
? 'Recurring'
: r.getStringValue('name'),
dayOfMonth: r.getIntValue('day_of_month', 1).clamp(1, 28),
currency: r.getStringValue('currency').isEmpty
? 'EUR'
: r.getStringValue('currency'),
source: _parseTemplateSource(r.getStringValue('source')),
amountRule: _parseAmountRule(r.getStringValue('amount_rule')),
merchantKey: r.getStringValue('merchant_key'),
sourceTxId: sourceTx.isEmpty ? null : sourceTx,
labelId: labelId.isEmpty ? null : labelId,
avgCount: r.getIntValue('avg_count', 5).clamp(1, 36),
autoFromRecurring: r.getBoolValue('auto_from_recurring'),
);
}
debugPrint(
'PbSyncService pull: labels=${labelRecords.length} '
'tx_meta=${metaRecords.length} merchants=${merchantRecords.length} '
'exact=${exactRecords.length}',
'exact=${exactRecords.length} recurring=${recurringRecords.length}',
);
return TxMetaStoreData(
@@ -280,6 +329,7 @@ class PbSyncService {
labelsByMerchant: labelsByMerchant,
notesByMerchant: notesByMerchant,
exactTemplates: exactTemplates,
recurringTemplates: recurringTemplates,
customLabels: customLabels,
labelParents: labelParents,
);
@@ -347,6 +397,15 @@ class PbSyncService {
report(0.96);
onStatus?.call('Syncing templates…');
await _syncExactTemplates(userId, filter, store);
report(0.98);
onStatus?.call('Syncing recurring templates…');
try {
await _syncRecurringTemplates(userId, filter, store);
} catch (error) {
debugPrint(
'PbSyncService push: recurring_templates unavailable ($error)',
);
}
report(1.0);
return txSummary;
}
@@ -563,7 +622,11 @@ class PbSyncService {
]);
done = end;
onProgress?.call(done / total);
onStatus?.call('Syncing $collection ($done/$total)…');
// Status less often than progress — SyncController already throttles,
// but fewer string allocs/churn still help.
if (done == total || done == end && (done ~/ _writeConcurrency) % 4 == 0) {
onStatus?.call('Syncing $collection ($done/$total)…');
}
}
if (errors.isNotEmpty) {
@@ -603,6 +666,12 @@ class PbSyncService {
filter =
'user="$userId" && exact_key="${key.replaceAll('"', '\\"')}"';
}
case 'recurring_templates':
final key = body['client_id'];
if (userId != null && key is String) {
filter =
'user="$userId" && client_id="${key.replaceAll('"', '\\"')}"';
}
case 'transactions':
final key = body['tx_id'];
if (userId != null && key is String) {
@@ -853,6 +922,61 @@ class PbSyncService {
await _runWriteOps('exact_templates', ops);
}
Future<void> _syncRecurringTemplates(
String userId,
String filter,
TxMetaStoreData store,
) async {
final existing = {
for (final r in await _fullList('recurring_templates', filter))
r.getStringValue('client_id'): r,
};
final keep = <String>{};
final ops = <_BatchOp>[];
for (final e in store.recurringTemplates.entries) {
final t = e.value;
final clientId = _clip(t.id.isNotEmpty ? t.id : e.key, 120);
if (clientId.isEmpty) continue;
keep.add(clientId);
final body = <String, dynamic>{
'user': userId,
'client_id': clientId,
'name': _clip(t.name.isEmpty ? 'Recurring' : t.name, 400),
'day_of_month': t.dayOfMonth.clamp(1, 28),
'currency': _clip(
t.currency.isEmpty ? 'EUR' : t.currency.toUpperCase(),
8,
),
'source': t.source.name,
'amount_rule': t.amountRule.name,
'merchant_key': _clip(t.merchantKey, 500),
'source_tx_id': _clip(t.sourceTxId ?? '', 2000),
'label_id': _clip(t.labelId ?? '', 80),
'avg_count': t.avgCount.clamp(1, 36),
'auto_from_recurring': t.autoFromRecurring,
};
final remote = existing[clientId];
if (remote == null) {
ops.add(_BatchOp.create(body));
} else if (!_recurringUnchanged(remote, t)) {
ops.add(_BatchOp.update(remote.id, body));
}
}
for (final e in existing.entries) {
if (!keep.contains(e.key)) {
ops.add(_BatchOp.delete(e.value.id));
}
}
debugPrint(
'PbSyncService recurring_templates: remote=${existing.length} '
'ops=${ops.length}',
);
await _runWriteOps('recurring_templates', ops);
}
bool _labelUnchanged(RecordModel remote, Map<String, dynamic> body) {
return remote.getStringValue('name') == body['name'] &&
remote.getIntValue('color') == body['color'] &&
@@ -882,6 +1006,34 @@ class PbSyncService {
remote.getBoolValue('recurring') == t.recurring &&
remote.getBoolValue('internal_transfer') == t.internalTransfer;
}
bool _recurringUnchanged(RecordModel remote, RecurringTemplate t) {
return remote.getStringValue('name') == t.name &&
remote.getIntValue('day_of_month') == t.dayOfMonth &&
remote.getStringValue('currency') == t.currency &&
remote.getStringValue('source') == t.source.name &&
remote.getStringValue('amount_rule') == t.amountRule.name &&
remote.getStringValue('merchant_key') == t.merchantKey &&
remote.getStringValue('source_tx_id') == (t.sourceTxId ?? '') &&
remote.getStringValue('label_id') == (t.labelId ?? '') &&
remote.getIntValue('avg_count', 5) == t.avgCount &&
remote.getBoolValue('auto_from_recurring') == t.autoFromRecurring;
}
}
RecurringTemplateSource _parseTemplateSource(String raw) {
return switch (raw) {
'label' => RecurringTemplateSource.label,
_ => RecurringTemplateSource.transaction,
};
}
RecurringAmountRule _parseAmountRule(String raw) {
return switch (raw) {
'avgLastTxs' => RecurringAmountRule.avgLastTxs,
'labelAvgMonths' => RecurringAmountRule.labelAvgMonths,
_ => RecurringAmountRule.lastValue,
};
}
List<String> _jsonStringList(dynamic raw) {