Powerful analytics for your ffastdb database — group, rank, pivot, and compute statistics directly inside your Dart or Flutter app, with no server, no network, and no SQL required.
| I want to… | Use |
|---|---|
| Sum / average / count records by category | groupBy |
| Keep, sort and limit groups (top 5 categories with ≥ 10 orders) | groupBy(having:, orderBy:, limit:) |
| Group by several fields, by date buckets or by a computed value | groupByKeys |
| Chart totals per day / week / month / quarter / year | timeSeries |
| Get count, mean, stddev, min/max and quartiles in one call | describe |
| Find the top-N items by a value | topN |
| Rank items with tie support | rank |
| Find the few items that make most of the total (80/20, ABC) | pareto |
| Compute moving averages, sums, min/max, stddev or medians | rolling |
| Build a running total | cumulativeSum |
| Measure growth period over period | change |
| Generate a spreadsheet-style pivot table with totals | pivot / pivotBy |
| Calculate a percentile (e.g. p95 latency) | percentile |
| Measure spread with standard deviation | stddev |
| See how values are distributed | histogram / valueCounts |
| Check whether two fields move together / fit a trend line | correlation / regression |
| Spot unusual values | outliers |
| Keep a dashboard live as data changes | watch |
| Narrow down results before analysing | where |
| Read nested fields | 'customer.country', 'items.0.price' |
| Average a price weighted by quantity | aggWeightedAvg |
| Add margins, ratios or each group's share of the total | groupBy(derived:) |
| Aggregate "the last 7 days" instead of "the last 7 rows" | rolling(period:) |
| Keep the UI smooth with big collections | Automatic — nothing to do (details) |
Contents: Installation · Quick start · Aggregations · Time series · Ranking · Window functions · Pivot tables · Statistics · Live dashboards · Performance · Full example · Behavior and edge cases · How it works · Roadmap
Add to your pubspec.yaml:
dependencies:
ffastdb_analytics: ^0.2.0Then run:
dart pub getimport 'package:ffastdb/ffastdb.dart';
import 'package:ffastdb_analytics/ffastdb_analytics.dart';
final db = await FfastDb.init(MemoryStorageStrategy());
// Insert some documents
await db.insertMany([
{'category': 'Food', 'amount': 120.0, 'status': 'active'},
{'category': 'Food', 'amount': 80.0, 'status': 'active'},
{'category': 'Tech', 'amount': 500.0, 'status': 'inactive'},
]);
// Analyse the whole collection
final summary = await db.analytics.all.groupBy('category', {
'revenue': aggSum('amount'),
'orders': aggCount(),
'avg_ticket': aggAvg('amount'),
});
// → [{key: Food, revenue: 200.0, orders: 2, avg_ticket: 100.0}, ...]
// Or filter first, then analyse
final activeOnly = await db.analytics
.where((q) => q.where('status').equals('active').findIds())
.groupBy('category', {'total': aggSum('amount')});Tip: Every analytical method works the same way whether you use
.allor.where(...)— just pick the scope you need.
Use groupBy to split documents into groups and compute values for each group. Pass any combination of aggregation expressions:
| Expression | Description |
|---|---|
aggSum('field') |
Total of a numeric field |
aggAvg('field') |
Average of a numeric field |
aggCount() |
Number of documents in the group |
aggMin('field') |
Smallest value |
aggMax('field') |
Largest value |
aggVariance('field') |
Population variance |
aggStdDev('field') |
Population standard deviation |
aggMedian('field') |
Median value |
aggPercentile('field', 0.9) |
Value at a given rank |
aggCountDistinct('field') |
Number of distinct values |
aggFirst('field') / aggLast('field') |
First / last value in storage order |
aggMode('field') |
Most frequent value |
aggWeightedAvg('price', weight: 'qty') |
sum(price × qty) / sum(qty) |
aggCustom((docs) => ...) |
Your own function over the group's documents |
aggVariance and aggStdDev accept sample: true for the sample statistic (divides by n − 1).
Every field parameter — in aggregations, keys, windows, pivots and statistics — accepts a dotted path into nested maps or lists: 'customer.country', 'items.0.price'.
aggCount() counts documents, aggSum returns 0 when a group has no numeric values, and the other aggregations return null in that case. Mixed value types never throw.
final result = await db.analytics.all.groupBy('department', {
'headcount': aggCount(),
'total_salary': aggSum('salary'),
'avg_salary': aggAvg('salary'),
'min_salary': aggMin('salary'),
'max_salary': aggMax('salary'),
});Restrict any aggregation to some documents with .filter(...) — like SQL FILTER (WHERE ...). One pass, several slices:
final perMonth = await db.analytics.all.groupBy('month', {
'income': aggSum('amount').filter((d) => d['type'] == 'INCOME'),
'expense': aggSum('amount').filter((d) => d['type'] == 'EXPENSE'),
'refunds': aggCount().filter((d) => d['refunded'] == true),
});// Top 5 categories by revenue, only those with at least 10 orders
final top = await db.analytics.all.groupBy(
'category',
{'revenue': aggSum('amount'), 'orders': aggCount()},
having: (g) => g.count >= 10,
orderBy: 'revenue', // an aggregation name, 'key' or 'count'
descending: true,
limit: 5,
);Compute metrics from each group's aggregations — margins, ratios, shares of the total. Each derived metric receives the group and the same aggregations over every document in scope, and can be used by having, orderBy and limit:
final categories = await db.analytics.all.groupBy(
'category',
{'revenue': aggSum('amount'), 'profit': aggSum('profit')},
derived: {
'margin': metricRatio('profit', 'revenue'), // profit / revenue
'share': metricShare('revenue'), // revenue / total revenue
'vsAvg': (g, total) => g['revenue'] - total['revenue'] / 12,
},
orderBy: 'margin',
descending: true,
);
print(categories.first['margin']); // g['name'] reads any aggregationgroupByKeys takes a list of GroupKeys. g.key is the value for a single key or a list for several; g.keys maps each key name to its value.
final groups = await db.analytics.all.groupByKeys(
[
GroupKey.date('date', DateBucket.month), // hour | day | week | month | quarter | year
GroupKey.field('category'),
GroupKey.computed('size', (d) => d['amount'] > 100 ? 'big' : 'small'),
],
{'total': aggSum('amount')},
orderBy: 'key',
);
for (final g in groups) {
print('${DateBucket.month.label(g.keys['date'])} ' // 2026-03
'${g.keys['category']} ${g.keys['size']}: ${g.aggregations['total']}');
}Date fields may hold a DateTime, an ISO-8601 string or milliseconds since the epoch. Weeks start on Monday (ISO 8601).
timeSeries returns one row per bucket in chronological order and fills the gaps, so charts never skip a month:
final monthly = await db.analytics.all.timeSeries(
'date',
DateBucket.month,
{'revenue': aggSum('amount'), 'orders': aggCount()},
from: DateTime(2026, 1, 1), // optional
to: DateTime(2026, 12, 31), // optional
);
// Empty months → count 0, sum 0, other aggregations null.Get the highest (or lowest) N documents by any field:
// Top 5 products by revenue
final top5 = await db.analytics.all.topN('revenue', n: 5);
// Bottom 3 (ascending)
final bottom3 = await db.analytics.all.topN('revenue', n: 3, ascending: true);Assign a rank to every document (standard competition ranking). Ties share the same rank, and results come back in storage order:
final ranked = await db.analytics.all.rank('score');
// scores [100, 100, 80] → ranks [1, 1, 3]
for (final r in ranked) {
print('#${r.rank} ${r.document['name']} score=${r.value}');
}Which products make 80% of revenue?
final abc = await db.analytics.all.pareto('product', 'revenue');
for (final p in abc) {
print('${p.key}: ${(p.share * 100).toStringAsFixed(1)}% '
'(cumulative ${(p.cumulativeShare * 100).toStringAsFixed(1)}%) → ${p.abcClass}');
}Window functions compute a value for each document based on a sliding window over an ordered sequence — like Excel's moving-average formula or SQL's OVER (...).
// 7-day moving average of daily revenue
final rolling = await db.analytics.all
.rollingAvg('amount', window: 7, orderBy: 'date');
for (final p in rolling) {
print('day ${p.index}: value=${p.value} 7-day avg=${p.rollingValue}');
}// Year-to-date running total for income entries
final ytd = await db.analytics
.where((q) => q.where('type').equals('INCOME').findIds())
.cumulativeSum('amount', orderBy: 'date');
print('YTD total: \$${ytd.last.cumSum}');rolling computes any RollingAgg — sum, avg, min, max, variance, stddev, median — and there is a shorthand for each (rollingSum, rollingMin, rollingMax, rollingVariance, rollingStdDev, rollingMedian):
// 30-day volatility of daily prices
final vol = await db.analytics.all
.rollingStdDev('price', window: 30, orderBy: 'date');window: 7 means "the last 7 documents". When data is irregular — weekends, missing days, several sales per day — use period for "the last 7 days" instead. orderBy must then be a date field (DateTime, ISO-8601 string or epoch milliseconds):
final weekly = await db.analytics.all.rollingSum(
'amount',
period: const Duration(days: 7), // documents dated within (date − 7 days, date]
orderBy: 'date',
);Every rolling method accepts either window or period.
Every window function accepts partitionBy to run an independent window per group — like SQL OVER (PARTITION BY ...). Each point carries its partition and orderValue:
// Running total per account
final balances = await db.analytics.all
.cumulativeSum('amount', orderBy: 'date', partitionBy: 'account');final growth = await db.analytics.all.change('total', orderBy: 'month');
for (final p in growth) {
print('${p.orderValue}: ${p.delta} (${p.percentChange})'); // 0.12 = +12%
}
// offset: 12 compares with the same month last year.Turn rows into a spreadsheet-style grid — perfect for comparing values across two dimensions:
final table = await db.analytics.all.pivot(
rowField: 'department',
colField: 'quarter',
valueField: 'budget',
aggregation: PivotAgg.sum, // sum | avg | count | min | max | variance | stddev | median
);
// Read a single cell
final q1Eng = table.rows['Engineering']?['Q1']; // num?
// Print the whole grid
print(['', ...table.columnKeys].join('\t'));
for (final entry in table.rows.entries) {
final cells = table.columnKeys
.map((c) => table.rows[entry.key]?[c]?.toString() ?? '-')
.join('\t');
print('${entry.key}\t$cells');
}
// Totals are computed from the documents, so they are right for averages too
print(table.rowTotals['Engineering']);
print(table.columnTotals['Q1']);
print(table.grandTotal);Pass sortKeys: true to sort rows and columns. For date columns, computed keys or any aggregation, use pivotBy:
final table = await db.analytics.all.pivotBy(
rows: GroupKey.field('category'),
columns: GroupKey.date('date', DateBucket.month),
value: aggSum('amount'),
sortKeys: true,
);All the usual descriptive statistics in one call:
final s = await db.analytics.all.describe('amount');
print('n=${s.count} mean=${s.mean} sd=${s.stddev} '
'min=${s.min} p25=${s.p25} median=${s.median} p75=${s.p75} max=${s.max}');Find the value below which a given percentage of data falls — great for SLA thresholds:
final p95latency = await db.analytics.all.percentile('latency_ms', 0.95);
print('95th percentile latency: ${p95latency}ms');
// Linear interpolation between ranks (Excel PERCENTILE.INC / NumPy default)
final median = await db.analytics.all
.percentile('latency_ms', 0.5, interpolate: true);By default the nearest-rank value is returned, which is always an actual value of the field.
Measure how spread out values are — useful for detecting outliers:
final sigma = await db.analytics.all.stddev('amount'); // population
final s = await db.analytics.all.stddev('amount', sample: true); // sample (n − 1)Understand the shape of your data by splitting it into equal-width buckets:
final hist = await db.analytics.all.histogram('price', bins: 10);
for (final bin in hist) {
print('[${bin.low.toStringAsFixed(2)} – ${bin.high.toStringAsFixed(2)}): '
'${bin.count} items');
}final countries = await db.analytics.all.valueCounts('country', limit: 5);
// → [ValueCount(MX: 120), ValueCount(AR: 80), ...] each with a sharefinal r = await db.analytics.all.correlation('ad_spend', 'revenue'); // -1..1
final fit = await db.analytics.all.regression('ad_spend', 'revenue');
print('revenue ≈ ${fit?.slope} × ad_spend + ${fit?.intercept} (r² ${fit?.r2})');
print(fit?.predict(5000));final unusual = await db.analytics.all.outliers('amount'); // Tukey's fences (IQR, k = 1.5)
final extreme = await db.analytics.all
.outliers('amount', method: OutlierMethod.zScore, k: 3); // > 3σ from the meanwatch re-runs any analysis whenever a document containing the watched field changes:
db.analytics.all
.watch('amount', (q) => q.timeSeries('date', DateBucket.month, {
'total': aggSum('amount'),
}))
.listen((series) => setState(() => _series = series));watchGroupBy and watchHistogram remain available as shortcuts.
You don't need to do anything to keep your UI smooth. Every analysis runs cooperatively:
- Batched reads. Documents are read from the database in small batches instead of all at once.
- Time slices. Work runs in slices of at most ~8 ms. Between slices the event loop gets a turn, so Flutter keeps painting frames and handling taps while the analysis runs.
- Cached date bucketing. Local date strings are bucketed without parsing, and each distinct bucket is built once.
- Heavy final steps off the UI thread. Sorting, windows and medians over large inputs run in an isolate on native platforms. Only compact values are copied there, never documents.
Results are identical either way. Here is what that means for the longest UI freeze, measured on 50,000 documents (Dart VM, desktop):
| Analysis | Longest UI freeze before | Longest UI freeze now | Total time now |
|---|---|---|---|
timeSeries per month |
2179 ms | 8 ms | 135 ms |
groupBy with 8 aggregations |
285 ms | 15 ms | 263 ms |
describe |
266 ms | 26 ms | 223 ms |
rollingMedian (window 30) |
675 ms | 26 ms | 674 ms |
pivotBy category × month |
1997 ms | 33 ms | 242 ms |
At 60 fps a frame lasts 16 ms, so the remaining pauses cost one or two frames at most.
Because documents are read across several event-loop turns, a write made while an analysis runs may or may not be included. Run the analysis again (or use watch) to see it.
.inBackground() moves the whole computation to another thread: a Dart isolate on mobile, desktop and server, or a Web Worker on the web.
final summary = await db.analytics.all.inBackground().describe('amount');You rarely need it. Doing this means copying every document to the other thread, and that copy happens on the UI thread. On 50,000 documents the copy alone freezes the UI for 120–220 ms, which is worse than the default above. It pays off only when the per-group work itself is heavy, for example an aggCustom that runs an expensive function over each group's documents.
| Platform | Runs in | Dart functions (aggCustom, .filter, GroupKey.computed) |
|---|---|---|
| Android, iOS, desktop, server | A new Isolate (Isolate.run) |
Supported |
| Web | A dedicated Web Worker, talking JSON | Can't be sent to a worker: those computations run on the main thread |
derived, having, orderBy and limit always run on the calling isolate after the heavy work, so they can use any Dart code.
Web Workers run a separate script, so compile one next to your app (once, and again when you upgrade the package):
-
Create
web/ffastdb_analytics_worker.dart:import 'package:ffastdb_analytics/worker.dart'; void main() => runAnalyticsWorker();
-
Compile it to JavaScript in the same folder:
dart compile js -O2 -o web/ffastdb_analytics_worker.js web/ffastdb_analytics_worker.dart
Flutter web serves everything in web/ next to the app, so the default URL ffastdb_analytics_worker.js just works. If you put the script elsewhere, tell the executor:
AnalyticsExecutor.shared = AnalyticsExecutor.background(
webWorkerUrl: 'workers/ffastdb_analytics_worker.js',
);If the script is missing, the computation fails with a StateError explaining how to compile it.
final executor = AnalyticsExecutor.background(
minDocuments: 5000, // smaller scopes stay on the calling isolate (default 1000)
);
final q = db.analytics.all.inBackground(executor);
await executor.close(); // terminates the web worker; it restarts on demand- Lazy streams stay inline.
rollingStreamandcumulativeSumStreamkeep emitting as documents are read. - Testing worker compatibility on the VM.
AnalyticsExecutor.simulatedWorker()runs the exact JSON round trip of the web worker on the current isolate.
// 1. Balance grouped by account type
final balance = await db.analytics.all.groupBy('type', {
'total': aggSum('amount'),
'entries': aggCount(),
'avg_amount': aggAvg('amount'),
});
// 2. Revenue vs expenses per quarter
final revenue = await db.analytics
.where((q) => q.where('type').equals('INCOME').findIds())
.groupBy('quarter', {'total': aggSum('amount')});
final expenses = await db.analytics
.where((q) => q.where('type').equals('EXPENSE').findIds())
.groupBy('quarter', {'total': aggSum('amount')});
// 3. Expense breakdown as a pivot (category × quarter)
final breakdown = await db.analytics
.where((q) => q.where('type').equals('EXPENSE').findIds())
.pivot(
rowField: 'category',
colField: 'quarter',
valueField: 'amount',
aggregation: PivotAgg.sum,
);
// 4. Monthly income vs expenses in a single pass, gaps filled
final monthly = await db.analytics.all.timeSeries('date', DateBucket.month, {
'income': aggSum('amount').filter((d) => d['type'] == 'INCOME'),
'expense': aggSum('amount').filter((d) => d['type'] == 'EXPENSE'),
});
// 5. Month-over-month expense growth
final growth = await db.analytics
.where((q) => q.where('type').equals('EXPENSE').findIds())
.change('amount', orderBy: 'date');
// 6. Flag unusually large transactions
final unusual = await db.analytics.all.outliers('amount');Real-world documents are messy. The rules are the same across every method:
| Situation | What happens |
|---|---|
| A group has no numeric values | aggCount counts documents, aggSum returns 0, every other aggregation returns null |
| A document lacks the group field | It is grouped under the null key (filter it out with having: (g) => g.key != null) |
| A pivot cell has no documents | The cell is null |
A field mixes types (int and String) |
Nothing throws: values are ordered by kind, num < String < DateTime |
A document lacks the orderBy field |
It goes last, keeping its original order (sorting is always stable) |
| A date field can't be parsed | GroupKey.date gives null; timeSeries skips the document |
Invalid arguments (p outside 0–1, bins <= 0, window <= 0, negative n or limit) |
ArgumentError, in release builds too |
| A dotted path hits a missing key, a non-map or an out-of-range index | The value is null (a key that literally contains the dots wins) |
A time window's orderBy date can't be read |
The document is skipped |
Invalid window options (both or neither of window / period, period without orderBy) |
ArgumentError |
| A write happens while an analysis runs | It may or may not be included (documents are read across several event-loop turns) |
Returned documents (topN, rank, outliers) |
They are copies: modifying them never touches the database |
Every analytics operation follows the same two-step pattern:
- Scope — decide which documents to include using
.allor.where(filter). - Analyse — call any analytical method on that scope.
db.analytics
.where(...) ← optional filter (any ffastdb query)
.inBackground() ← optional, rarely needed: compute in an isolate / web worker
.groupBy(...) ← or any other analytical method
Documents are read from storage on each call, so results are always fresh. Every analysis is a single-pass computation. It consumes documents in batches and time slices as they are read, and aggregations (groupBy, timeSeries, pivot, stddev, topN…) keep only accumulators in memory, not the documents. See Performance.
Ideas under consideration. Feedback and pull requests are welcome.
Analysis
- Exponential moving average (
RollingAgg.ema). - Period comparison for KPI cards: this month vs the previous one or the same month last year.
- Subtotals (rollup) across grouping levels.
- Equal-frequency bins (quantiles /
ntile). - Cohort retention and funnel analysis.
- Simple forecasting: linear trend and Holt-Winters over a
timeSeries. - Concentration metrics (Gini coefficient, entropy).
Performance and integration
- Letting a background thread read the documents itself, so
inBackgroundnever copies them through the main thread. - Incrementally maintained aggregates for
watch, instead of recomputing on every write. - Approximate distinct counts and percentiles (HyperLogLog, t-digest) for very large collections.
- Export helpers: CSV / JSON, and chart-ready series for packages such as
fl_chart.