about summary refs log tree commit diff
diff options
context:
space:
mode:
authorWilliam Carroll <wpcarro@gmail.com>2023-01-23T05·14-0800
committerclbot <clbot@tvl.fyi>2023-01-23T15·59+0000
commitb3a91ce57b2b55fe0ad246425f6528caef11a1e7 (patch)
tree043cfef25f04731e777c0531b07bd66186fb4bb5
parent9f75973e4a93b0625f100e9d01f049dac4ac79e4 (diff)
feat(wpcarro/ynabsql): Proof-of-concept demo r/5741
Hacked this together during my week-off while I was in Telluride, CO. The git
history is quite sloppy; so is some of the code. But it (mostly) works as a
demo, and that was the point.

Change-Id: Icfbc277090b69a802c00becdbd162652e4e8e156
Reviewed-on: https://cl.tvl.fyi/c/depot/+/7904
Reviewed-by: wpcarro <wpcarro@gmail.com>
Tested-by: BuildkiteCI
Autosubmit: wpcarro <wpcarro@gmail.com>
-rw-r--r--users/wpcarro/ynabsql/dataviz/.gitignore5
-rw-r--r--users/wpcarro/ynabsql/dataviz/.parcelrc3
l---------users/wpcarro/ynabsql/dataviz/cdn1
-rw-r--r--users/wpcarro/ynabsql/dataviz/chart.js66
-rw-r--r--users/wpcarro/ynabsql/dataviz/components.js1002
-rw-r--r--users/wpcarro/ynabsql/dataviz/components.jsx1248
-rw-r--r--users/wpcarro/ynabsql/dataviz/index.html28
-rw-r--r--users/wpcarro/ynabsql/dataviz/index.js72
-rw-r--r--users/wpcarro/ynabsql/dataviz/package.json15
-rw-r--r--users/wpcarro/ynabsql/dataviz/yarn.lock1540
10 files changed, 3832 insertions, 148 deletions
diff --git a/users/wpcarro/ynabsql/dataviz/.gitignore b/users/wpcarro/ynabsql/dataviz/.gitignore
new file mode 100644
index 0000000000..efb13a1549
--- /dev/null
+++ b/users/wpcarro/ynabsql/dataviz/.gitignore
@@ -0,0 +1,5 @@
+/dist
+/node_modules
+/.parcel-cache
+/cdn
+/data.js
\ No newline at end of file
diff --git a/users/wpcarro/ynabsql/dataviz/.parcelrc b/users/wpcarro/ynabsql/dataviz/.parcelrc
new file mode 100644
index 0000000000..5dacc3dd88
--- /dev/null
+++ b/users/wpcarro/ynabsql/dataviz/.parcelrc
@@ -0,0 +1,3 @@
+{
+    "extends": "@parcel/config-default"
+}
\ No newline at end of file
diff --git a/users/wpcarro/ynabsql/dataviz/cdn b/users/wpcarro/ynabsql/dataviz/cdn
new file mode 120000
index 0000000000..9c83dcee43
--- /dev/null
+++ b/users/wpcarro/ynabsql/dataviz/cdn
@@ -0,0 +1 @@
+/tmp/cdn
\ No newline at end of file
diff --git a/users/wpcarro/ynabsql/dataviz/chart.js b/users/wpcarro/ynabsql/dataviz/chart.js
new file mode 100644
index 0000000000..7ec8f00aae
--- /dev/null
+++ b/users/wpcarro/ynabsql/dataviz/chart.js
@@ -0,0 +1,66 @@
+const colors = {
+  red: 'rgb(255, 45, 70)',
+  green: 'rgb(75, 192, 35)',
+};
+
+////////////////////////////////////////////////////////////////////////////////
+// Main
+////////////////////////////////////////////////////////////////////////////////
+
+const mount = document.getElementById('mount');
+
+const chart = new Chart(mount, {
+  type: 'scatter',
+  data: {
+    datasets: [
+      {
+        label: 'Revenue',
+        data: data.data.transactions.filter(x => x.Inflow > 0).map(x => ({
+          x: x.Date,
+          y: x.Inflow,
+          metadata: x,
+        })),
+        backgroundColor: colors.green,
+      },
+      {
+        label: 'Expenses',
+        data: data.data.transactions.filter(x => x.Outflow).map(x => ({
+          x: x.Date,
+          y: x.Outflow,
+          metadata: x,
+        })),
+        backgroundColor: colors.red,
+      },
+    ],
+  },
+  options: {
+    scales: {
+      x: {
+        type: 'time',
+        title: {
+          display: true,
+          text: 'Date',
+        },
+      },
+      y: {
+        title: {
+          display: true,
+          text: 'Amount ($USD)'
+        },
+      },
+    },
+    plugins: {
+      tooltip: {
+        callbacks: {
+          title: function(x) {
+            return `$${x[0].raw.y} (${x[0].raw.metadata.Date.toLocaleDateString()})`;
+          },
+          label: function(x) {
+            const { Category, Payee, Memo } = x.raw.metadata;
+            return `${Payee} - ${Category} (${Memo})`;
+          },
+        },
+      },
+    },
+  },
+});
diff --git a/users/wpcarro/ynabsql/dataviz/components.js b/users/wpcarro/ynabsql/dataviz/components.js
index 4065318978..c385e84e63 100644
--- a/users/wpcarro/ynabsql/dataviz/components.js
+++ b/users/wpcarro/ynabsql/dataviz/components.js
@@ -1,10 +1,131 @@
+const colors = { 
+    red: 'rgb(255, 45, 70)',
+    green: 'rgb(75, 192, 35)',
+    white: 'rgb(249, 246, 238)',
+    blue: 'rgb(137, 207, 240)',
+    fadedBlue: 'rgb(137, 207, 240, 0.25)',
+    purple: 'rgb(203, 195, 227)',
+    brown: 'rgb(205, 127, 50)',
+    black: 'rgb(53, 57, 53)',
+};
+  
+const months = [
+    'January',
+    'February',
+    'March',
+    'April',
+    'May',
+    'June',
+    'July',
+    'August',
+    'September',
+    'October',
+    'November',
+    'December',
+];
+  
+function getWeek(x) {
+    const dowOffset = 0;
+    var newYear = new Date(x.getFullYear(), 0, 1);
+    var day = newYear.getDay() - dowOffset; //the day of week the year begins on
+    day = (day >= 0 ? day : day + 7);
+    var daynum = Math.floor((x.getTime() - newYear.getTime() -
+        (x.getTimezoneOffset() - newYear.getTimezoneOffset()) * 60000) / 86400000) + 1;
+    var weeknum;
+    //if the year starts before the middle of a week
+    if (day < 4) {
+        weeknum = Math.floor((daynum + day - 1) / 7) + 1;
+        if (weeknum > 52) {
+            nYear = new Date(x.getFullYear() + 1, 0, 1);
+            nday = nYear.getDay() - dowOffset;
+            nday = nday >= 0 ? nday : nday + 7;
+            /*if the next year starts before the middle of
+              the week, it is week #1 of that year*/
+            weeknum = nday < 4 ? 1 : 53;
+        }
+    }
+    else {
+        weeknum = Math.floor((daynum + day - 1) / 7);
+    }
+    return weeknum;
+}
+  
+function dollars(n, sensitive) {
+    if (sensitive) {
+        const order = magnitude(n);
+        // Shortcut to avoid writing comma-insertion logic v0v.
+        if (n === 0) {
+            return '$X.XX';
+        }
+        if (order <= 0) {
+            return '$X.XX';
+        }
+        if (order === 1) {
+            return '$XX.XX';
+        }
+        if (order === 2) {
+            return '$XXX.XX';
+        }
+        if (order === 3) {
+            return '$X,XXX.XX';
+        }
+        if (order === 4) {
+            return '$XX,XXX.XX';
+        }
+        if (order === 4) {
+            return '$XX,XXX.XX';
+        }
+        if (order === 5) {
+            return '$XXX,XXX.XX';
+        }
+        // Coming soon! :P
+        if (order === 6) {
+            return '$X,XXX,XXX.XX';
+        }
+        if (order === 7) {
+            return '$XX,XXX,XXX.XX';
+        }
+        if (order === 8) {
+            return '$XXX,XXX,XXX.XX';
+        }
+        // Unsupported
+        else {
+            return '$???.??';
+        }
+    }
+    return usd.format(n);
+}
+  
 const usd = new Intl.NumberFormat('en-US', {
   style: 'currency',
   currency: 'USD',
 });
-
-const categories = data.data.transactions.reduce((xs, x) => { xs[x.Category] = null; return xs; }, {});
-
+  
+const categories = data.data.transactions.reduce((xs, x) => { 
+    if (!(x.Category in xs)) {
+        xs[x.Category] = [];
+    }
+    xs[x.Category].push(x);
+    return xs;
+}, {});
+  
+const queries = {
+    housing: 'Category:/(rent|electric)/',
+    food: 'Category:/(eating|alcohol|grocer)/',
+    commute: 'Category:/(vacation|gasoline|parking|car maintenance)/',
+};
+  
+/**
+ * Return the Order of Magnitude of some value, x.
+ */
+function magnitude(x) {
+    return Math.floor(Math.log(x) / Math.LN10 + 0.000000001);
+}
+  
+function getSum(transactions) {
+    return transactions.reduce((acc, x) => acc + x.Outflow, 0);
+}
+  
 function sortTransactions(transactions) {
     return [...transactions].sort((x, y) => {
         if (x.Outflow < y.Outflow) {
@@ -16,7 +137,7 @@ function sortTransactions(transactions) {
         }
     });
 }
-
+  
 function transactionKey(x) {
     const keys = [
         'Account',
@@ -31,23 +152,461 @@ function transactionKey(x) {
     ];
     return keys.map(k => x[k]).join('|');
 }
+  
+class ScatterChart extends React.Component {
+    constructor(props) {
+        super(props);
+        this.chart = null;
+        // Generate a 1/1M random ID.
+        this.id = btoa(Math.floor(Math.random() * 1e9));
+    }
+    componentDidUpdate(prevProps) {
+        if (this.props.transactions !== prevProps.transactions) {
+            this.chart.data.datasets[0].data = this.props.transactions.filter(x => x.Inflow > 0).map(x => ({
+                x: x.Date,
+                y: x.Inflow,
+                metadata: x,
+            }));
+            this.chart.data.datasets[1].data = this.props.transactions.filter(x => x.Outflow > 0).map(x => ({
+                x: x.Date,
+                y: x.Outflow,
+                metadata: x,
+            }));
+            this.chart.update();
+        }
+    }
+    componentDidMount() {
+        const mount = document.getElementById(this.id);
+        this.chart = new Chart(mount, {
+          type: 'scatter',
+          data: {
+            datasets: [
+              {
+                label: 'Revenue',
+                data: this.props.transactions.filter(x => x.Inflow > 0).map(x => ({
+                  x: x.Date,
+                  y: x.Inflow,
+                  metadata: x,
+                })),
+                backgroundColor: colors.green,
+              },
+              {
+                label: 'Expenses',
+                data: this.props.transactions.filter(x => x.Outflow).map(x => ({
+                  x: x.Date,
+                  y: x.Outflow,
+                  metadata: x,
+                })),
+                backgroundColor: colors.red,
+              },
+            ],
+          },
+          options: {
+            scales: {
+              x: {
+                type: 'time',
+                title: {
+                  display: true,
+                  text: 'Date',
+                },
+              },
+              y: {
+                title: {
+                  display: true,
+                  text: 'Amount ($USD)'
+                },
+              },
+            },
+            plugins: {
+              tooltip: {
+                callbacks: {
+                  title: function(x) {
+                    return `$${x[0].raw.y} (${x[0].raw.metadata.Date.toLocaleDateString()})`;
+                  },
+                  label: function(x) {
+                    const { Category, Payee, Memo } = x.raw.metadata;
+                    return `${Payee} - ${Category} (${Memo})`;
+                  },
+                },
+              },
+            },
+          },
+        });
+    }
+    render() {
+        return <canvas id={this.id}></canvas>;
+    }
+}
+  
+/**
+ * Generic line chart parameterized by:
+ * - datasets: forwarded to chart.js library
+ * - x: string label for x-axis
+ * - y: string label for y-axis
+ */
+class GenLineChart extends React.Component {
+    constructor(props) {
+        super(props);
+        this.chart = null;
+        // Generate a 1/1M random ID.
+        this.id = btoa(Math.floor(Math.random() * 1e9));
+    }
+
+    componentDidUpdate(prevProps, prevState) {
+        if (this.props.datasets != prevProps.datasets) {
+            this.chart.data.datasets = this.props.datasets;
+            this.chart.update();
+        }
+    }
+
+    componentDidMount() {
+        const mount = document.getElementById(this.id);
+        this.chart = new Chart(mount, {
+            type: 'line',
+            data: {
+                datasets: this.props.datasets,
+            },
+            options: {
+                scales: {
+                    x: {
+                        type: 'time',
+                        title: {
+                            display: true,
+                            text: this.props.x,
+                        },
+                    },
+                    y: {
+                        title: {
+                            display: true,
+                            text: this.props.y
+                        },
+                    },
+                },
+            },
+        });
+    }
+
+    render() {
+        return <canvas id={this.id}></canvas>;
+    }
+}
+
+class DonutChart extends React.Component {
+    constructor(props) {
+        super(props);
+        this.chart = null;
+        // Generate a 1/1M random ID.
+        this.id = btoa(Math.floor(Math.random() * 1e9));
+    }
+
+    componentDidUpdate(prevProps, prevState) {
+        if (this.props.datasets != prevProps.datasets) {
+            this.chart.data.datasets = this.props.datasets;
+            this.chart.update();
+        }
+    }
+
+    componentDidMount() {
+        const mount = document.getElementById(this.id);
+        this.chart = new Chart(mount, {
+            type: 'doughnut',
+            data: {
+                labels: this.props.labels,
+                datasets: this.props.datasets,
+            },
+            options: {
+                resonsive: true,
+            },
+        });
+    }
+
+    render() {
+        return <canvas id={this.id}></canvas>;
+    }
+}
+
+class StackedHistogram extends React.Component {
+    constructor(props) {
+        super(props);
+        this.chart = null;
+        // Generate a 1/1M random ID.
+        this.id = btoa(Math.floor(Math.random() * 1e9));
+    }
+
+    componentDidUpdate(prevProps, prevState) {
+        if (this.props.datasets != prevProps.datasets) {
+            this.chart.data.datasets = this.props.datasets;
+            this.chart.update();
+        }
+    }
+
+    componentDidMount() {
+        const mount = document.getElementById(this.id);
+        this.chart = new Chart(mount, {
+            type: 'bar',
+            data: {
+                labels: this.props.labels,
+                datasets: this.props.datasets,
+            },
+            options: {
+                scales: {
+                    x: {
+                        stacked: true,
+                    },
+                    y: {
+                        stacked: true,
+                    },
+                },
+            },
+        });
+    }
+
+    render() {
+        return <canvas id={this.id}></canvas>;
+    }
+}
+
+/**
+ * Display the "Actual Savings Rate" (bucketed by month) as a line chart with
+ * the "Expected Savings Rate" overlay.
+ */
+class SavingsRateLineChart extends React.Component {
+    constructor(props) {
+        super(props);
+        this.chart = null;
+        // Generate a 1/1M random ID.
+        this.id = btoa(Math.floor(Math.random() * 1e9));
+    }
+
+    static getRevenue(transactions) {
+        // Bucket revenues into months.
+        return transactions.reduce((acc, x) => {
+            const month = x.Date.getMonth();
+            acc[month] += x.Inflow;
+            return acc;
+        }, new Array(12).fill(0));
+    }
+
+    static getExpenses(transactions) {
+        // Bucket revenues into months.
+        return transactions.reduce((acc, x) => {
+            const month = x.Date.getMonth();
+            acc[month] += x.Outflow;
+            return acc;
+        }, new Array(12).fill(0));
+    }
+
+    componentDidMount() {
+        const mount = document.getElementById(this.id);
+        const revenue = SavingsRateLineChart.getRevenue(this.props.transactions);
+        const expenses = SavingsRateLineChart.getExpenses(this.props.transactions);
+
+        this.chart = new Chart(mount, {
+            type: 'line',
+            data: {
+                datasets: [
+                    {
+                        label: 'actual savings (by month)',
+                        data: new Array(12).fill(null).map((_, i) => ({
+                            x: i,
+                            y: (revenue[i] - expenses[i]) / revenue[i],
+                        })),
+                        cubicInterpolationMode: 'monotone',
+                        tension: 0.4,
+                        borderColor: colors.fadedBlue,
+                        backgroundColor: colors.fadedBlue,
+                    },
+                    {
+                        label: 'actual savings (overall)',
+                        data: new Array(12).fill(null).map((_, i) => ({
+                            x: i,
+                            y: this.props.rate,
+                        })),
+                        cubicInterpolationMode: 'monotone',
+                        tension: 0.4,
+                        borderColor: colors.blue,
+                        backgroundColor: colors.blue,
+                    },
+                    // 0% marker (out of debt)
+                    {
+                        label: 'beginner (0%)',
+                        data: new Array(12).fill(null).map((x, i) => ({
+                            x: i,
+                            y: 0.00,
+                        })),
+                        cubicInterpolationMode: 'monotone',
+                        tension: 0.4,
+                        borderColor: colors.white,
+                        backgroundColor: colors.white,
+                    },
+                    // 25% marker (quarter "Washington" club)
+                    {
+                        label: 'healthy (25%)',
+                        data: new Array(12).fill(null).map((x, i) => ({
+                            x: i,
+                            y: 0.25,
+                        })),
+                        cubicInterpolationMode: 'monotone',
+                        tension: 0.4,
+                        borderColor: colors.purple,
+                        backgroundColor: colors.purple,
+                    },
+                    // 50% marker (1/2-dollar "Kennedy" club)
+                    {
+                        label: 'rich (50%)',
+                        data: new Array(12).fill(null).map((x, i) => ({
+                            x: i,
+                            y: 0.50,
+                        })),
+                        cubicInterpolationMode: 'monotone',
+                        tension: 0.4,
+                        borderColor: colors.brown,
+                        backgroundColor: colors.brown,
+                    },
+                    // 75% marker
+                    {
+                        label: 'wealthy (75%)',
+                        data: new Array(12).fill(null).map((x, i) => ({
+                            x: i,
+                            y: 0.75,
+                        })),
+                        cubicInterpolationMode: 'monotone',
+                        tension: 0.4,
+                        borderColor: colors.black,
+                        backgroundColor: colors.black,
+                    },
+                ],
+                labels: months,
+            },
+            options: {
+                scales: {
+                    y: {
+                        max: 1.0,
+                        min: -1.0,
+                        title: {
+                            display: true,
+                            text: 'Savings Rate (%)'
+                        },
+                    },
+                },
+            },
+        });
+    }
+
+    componentDidUpdate(prevProps, prevState) {
+        // Bucket revenues into months.
+        const revenue = SavingsRateLineChart.getRevenue(this.props.transactions);
+        const expenses = SavingsRateLineChart.getExpenses(this.props.transactions);
+
+        this.chart.data.datasets[0].data = new Array(12).fill(null).map((_, i) => ({
+            x: i,
+            y: (revenue[i] - expenses[i]) / revenue[i],
+        }));
+        this.chart.update();
+    }
+
+    render() {
+        return <canvas id={this.id}></canvas>;
+    }
+}
 
 class App extends React.Component {
     constructor(props) {
         super(props);
         const query = 'Account:/checking/ after:"01/01/2022" before:"01/01/2023"';
+        const savingsView = 'after:"01/01/2022" before:"01/01/2023"';
+        const inflowQuery = 'Account:/checking/';
+        const outflowQuery = 'Account:/checking/ -Category:/(stocks|crypto)/';
 
         this.state = {
             query,
+            sensitive: false,
             transactions: select(query, data.data.transactions),
             saved: {},
             focus: {
+                sum: false,
                 1000: false,
                 100: false,
                 10: false,
                 1: false,
                 0.1: false,
             },
+            budget: [
+                { 
+                    label: 'Flexible', 
+                    children: [
+                        { label: 'groceries', savings: false, monthly: 400.00 },
+                        { label: 'eating out', savings: false, monthly: 200.00 },
+                        { label: 'alcohol', savings: false, monthly: 200.00 },
+                        { label: 'household items', savings: false, monthly: 50.00 },
+                        { label: 'toiletries', savings: false, monthly: 200.00 / 12 },
+                        { label: 'haircuts', savings: false, monthly: 400.00 / 12 },
+                        { label: 'gasoline', savings: false, monthly: 100.00 },
+                        { label: 'parking', savings: false, monthly: 10.00 },
+                        { label: 'ride services', savings: false, monthly: 50.00 },
+                        { label: 'LMNT', savings: false, monthly: 45.00 },
+                        { label: 'books', savings: false, monthly: 25.00 },
+                        { label: 'vacation', savings: false, monthly: 4000.00 / 12 },
+                        { label: 'reimbursement', savings: false, monthly: 0.00 },
+                    ],
+                },
+                { 
+                    label: 'Fixed', 
+                    children: [
+                        { label: 'rent', savings: false, monthly: 3100.00 },
+                        { label: 'electric', savings: false, monthly: 50.00 },
+                        { label: 'gas', savings: false, monthly: 30.00 },
+                        { label: 'YNAB', savings: false, monthly: 100.00 / 12 },
+                        { label: 'Robinhood Gold', savings: false, monthly: 5.00 },
+                        { label: 'Spotify', savings: false, monthly: 10.00 },
+                        { label: 'Surfline', savings: false, monthly: 100.00 / 12 },
+                        { label: 'HBO Max', savings: false, monthly: 170.00 },
+                        { label: 'Clear', savings: false, monthly: 179.00 },
+                        { label: 'car insurance', savings: false, monthly: 100.00 },
+                        { label: 'Making Sense', savings: false, monthly: 50.00 / 12 },
+                        { label: 'internet', savings: false, monthly: 100.00 },
+                        { label: 'tax return', savings: false, monthly: 200.00 / 12 },
+                    ],
+                },
+                {
+                    label: 'Rainy Day (dont touch)',
+                    children: [
+                        { label: 'emergency fund', savings: false, monthly: 0.00 },
+                        { label: 'check-ups', savings: false, monthly: 7.50 },
+                        { label: 'car maintenance', savings: false, monthly: 98.33 },
+                    ],
+                },
+                {
+                    label: 'Savings (dont touch)',
+                    children: [
+                        { label: 'stocks', savings: true, monthly: 4000.00 },
+                        { label: 'crypto', savings: true, monthly: 736.00 },
+                    ],
+                },
+                {
+                    label: 'Gifts (dont touch)',
+                    children: [
+                        { label: 'birthdays', savings: false, monthly: 250.00 / 12 },
+                        { label: 'Valentines Day', savings: false, monthly: 100.00 / 12 },
+                        { label: 'Mothers Day', savings: false, monthly: 25.00 / 12 },
+                        { label: 'Fathers Day', savings: false, monthly: 25.00 / 12 },
+                        { label: 'Christmas', savings: false, monthly: 500.00 / 12 },
+                    ],
+                },
+                {
+                    label: 'Error Budget',
+                    children: [
+                        { label: 'stuff I forgot to budget for', savings: false, monthly: 0.00 },
+                    ],
+                },
+            ],
+            paycheck: 6000.00,
+            view: 'budget',
+            savingsView,
+            inflowQuery,
+            outflowQuery,
+            inflowTransactions: select(inflowQuery, select(savingsView, data.data.transactions)),
+            outflowTransactions: select(outflowQuery, select(savingsView, data.data.transactions)),
         };
     }
 
@@ -55,61 +614,372 @@ class App extends React.Component {
         const sum = this.state.transactions.reduce((acc, { Outflow }) => acc + Outflow, 0);
         const savedSum = Object.values(this.state.saved).reduce((acc, sum) => acc + sum, 0);
 
-        return (
-            <div className="container">
-                <select>
-                    {Object.keys(categories).map(x => (
-                        <option value={x} key={x}>{x}</option>
-                    ))}
-                </select>
-                <Input 
-                    query={this.state.query} 
-                    onChange={query => this.setState({ 
-                        query,
+        let view = null;
+        if (this.state.view === 'query') {
+            view = (
+                <QueryView
+                    sensitive={this.state.sensitive}
+                    query={this.state.query}
+                    focus={this.state.focus}
+                    transactions={this.state.transactions}
+                    saved={this.state.saved}
+                    setState={this.setState.bind(this)}
+                />
+            );
+        } else if (this.state.view === 'savings') {
+            view = (
+                <SavingsView
+                    sensitive={this.state.sensitive}
+                    savingsView={this.state.savingsView}
+                    inflowQuery={this.state.inflowQuery}
+                    outflowQuery={this.state.outflowQuery}
+                    inflowTransactions={this.state.inflowTransactions}
+                    outflowTransactions={this.state.outflowTransactions}
+                    onFilterInflow={() => this.setState({
+                        inflowTransactions: select(this.state.inflowQuery, select(this.state.savingsView, data.data.transactions)),
                     })}
-                    onFilter={() => this.setState({
-                        transactions: select(this.state.query, data.data.transactions),
-                    })} 
-                    onSave={() => this.setState({
-                        saved: { ...this.state.saved, [this.state.query]: sum }
+                    onFilterOutflow={() => this.setState({
+                        outflowTransactions: select(this.state.outflowQuery, select(this.state.savingsView, data.data.transactions)),
                     })}
-                />
-                <AggregateTable 
-                    focus={this.state.focus} 
-                    onFocus={(n) => this.setState({
-                        focus: { ...this.state.focus, [n]: !this.state.focus[n] },
+                    onFilterSavingsView={() => this.setState({
+                        inflowTransactions: select(this.state.inflowQuery, select(this.state.savingsView, data.data.transactions)),
+                        outflowTransactions: select(this.state.outflowQuery, select(this.state.savingsView, data.data.transactions)),
                     })}
-                    transactions={this.state.transactions} 
+                    onSavingsViewChange={x => this.setState({ savingsView: x })}
+                    onInflowQueryChange={x => this.setState({ inflowQuery: x })}
+                    onOutflowQueryChange={x => this.setState({ outflowQuery: x })}
                 />
-                <hr />
+            );
+        } else if (this.state.view === 'budget') {
+            // Planned expenses:
+            // - minus planned assignment to emergency fund (not an expense)
+            // - minus planned spend to investments (e.g. stocks, crypto)
+            const budgetedSpend = this.state.budget.reduce((acc, x) => acc + x.children.filter(x => x.savings).reduce((acc, x) => acc + x.monthly, 0), 0);
+
+            view = (
                 <div>
+                    <fieldset>
+                        <legend>Details</legend>
+                        <div className="form-group">
+                            <label htmlFor="paycheck">Paycheck</label>
+                            <input name="paycheck" type="text" />
+                        </div>
+                        <div className="form-group">
+                            <label htmlFor="savings-rate">Savings Rate</label>
+                            <input name="savings-rate" type="text" />
+                        </div>
+                    </fieldset>
+                    <ul>
+                        <li>Available Spend: {dollars(this.state.paycheck * 2, this.state.sensitive)}</li>
+                        <li>Target Spend: {dollars(this.state.paycheck, this.state.sensitive)}</li>
+                        <li>Budgeted Spend (minus savings): {dollars(budgetedSpend, this.state.sensitive)}</li>
+                        <li>Emergency Fund Size (recommended): {dollars(budgetedSpend * 3, this.state.sensitive)}</li>
+                    </ul>
+                    <div style={{ width: '30em' }}>
+                        <DonutChart labels={this.state.budget.map(x => x.label)} datasets={[
+                            {
+                                label: 'Categories',
+                                data: this.state.budget.map(x => x.children.reduce((acc, y) => acc + y.monthly, 0)),
+                            }
+                        ]} />
+                    </div>
                     <ul>
-                        {Object.keys(this.state.saved).map(k => (
-                            <li key={k}>
-                                {usd.format(this.state.saved[k])} {k}
+                        {this.state.budget.map(x => (
+                            <li>
+                                <div>{x.label} - {dollars(x.children.reduce((acc, x) => acc + x.monthly, 0), this.state.sensitive)}</div>
+                                <ul>{x.children.map(y => <li>{y.label} - {dollars(y.monthly, this.state.sensitive)}</li>)}</ul>
                             </li>
                         ))}
                     </ul>
-                    <p>{usd.format(savedSum)}</p>
-                    <button className="btn btn-default" onClick={() => this.setState({ saved: {} })}>clear</button>
                 </div>
-                <hr />
-                <Table 
-                    transactions={sortTransactions(this.state.transactions)} 
-                    onClick={x => this.setState({
-                        saved: { ...this.state.saved, [transactionKey(x)]: x.Outflow }
-                    })}
-                />
+            );
+        }
+
+        return (
+            <div className="container">
+                <nav className="terminal-menu">
+                    <ul>
+                        <li><a href="#" onClick={() => this.setState({ view: 'query' })}>query</a></li>
+                        <li><a href="#" onClick={() => this.setState({ view: 'savings' })}>savings</a></li>
+                        <li><a href="#" onClick={() => this.setState({ view: 'budget' })}>budget</a></li>
+                        <li><a href="#" onClick={() => this.setState({ sensitive: !this.state.sensitive })}>sensitive</a></li>
+                    </ul>
+                </nav>
+                {view}
             </div>
         );
     }
 }
 
+const QueryView = ({ sensitive, query, focus, transactions, saved, setState }) => (
+    <div>
+        <Query
+            query={query}
+            onChange={query => setState({
+                query,
+            })}
+            onFilter={() => setState({
+                transactions: select(query, data.data.transactions),
+            })}
+        />
+        <hr />
+        <ScatterChart transactions={transactions} />
+        <hr />
+        <Transactions
+            sensitive={sensitive}
+            transactions={sortTransactions(transactions)}
+            onClick={x => setState({
+                saved: { ...saved, [transactionKey(x)]: x.Outflow }
+            })}
+        />
+    </div>
+);
+
+function classifyRate(x) {
+    if (x < 0.25) {
+        return 'needs improvement';
+    }
+    if (x < 0.50) {
+        return 'healthy';
+    }
+    if (x < 0.75) {
+        return 'rich';
+    }
+    if (x < 1.00) {
+        return 'wealthy';
+    }
+}
+
+const SavingsView = ({
+    sensitive,
+    savingsView,
+    inflowQuery,
+    outflowQuery,
+    inflowTransactions,
+    outflowTransactions,
+    onSavingsViewChange,
+    onInflowQueryChange,
+    onOutflowQueryChange,
+    onFilterInflow,
+    onFilterOutflow,
+    onFilterSavingsView,
+}) => {
+    const revenue = inflowTransactions.reduce((acc, x) => {
+        acc[x.Date.getMonth()] += x.Inflow;
+        return acc;
+    }, new Array(12).fill(0));
+
+    const inflow = inflowTransactions.reduce((acc, x) => acc + x.Inflow, 0);
+    const outflow = outflowTransactions.reduce((acc, x) => acc + x.Outflow, 0);
+
+    const delta25Sum = new Array(12).fill(0);
+    for (let i = 1; i < 12; i += 1) {
+        delta25Sum[i] = delta25Sum[i - 1] + revenue[i] * 0.25;
+    }
+
+    const delta50Sum = new Array(12).fill(0);
+    for (let i = 1; i < 12; i += 1) {
+        delta50Sum[i] = delta50Sum[i - 1] + revenue[i] * 0.5;
+    }
+
+    const delta75Sum = new Array(12).fill(0);
+    for (let i = 1; i < 12; i += 1) {
+        delta75Sum[i] = delta75Sum[i - 1] + revenue[i] * 0.75;
+    }
+
+    return (
+        <section>
+            <fieldset>
+                <legend>Filtering</legend>
+                <div className="form-group">
+                    <label htmlFor="savings-view">Savings View</label>
+                    <input name="savings-view" type="text" placeholder="Savings View..." value={savingsView}
+                        onChange={e => onSavingsViewChange(e.target.value)} />
+                    <button className="btn btn-default" onClick={() => onFilterSavingsView()}>Apply</button>
+                </div>
+                <div className="form-group">
+                    <label htmlFor="inflow-query">Inflow Query</label>
+                    <input name="inflow-query" type="text" placeholder="Inflow query..." value={inflowQuery}
+                        onChange={e => onInflowQueryChange(e.target.value)} />
+                    <button className="btn btn-default" onClick={() => onFilterInflow()}>Filter</button>
+                </div>
+                <div className="form-group">
+                    <label htmlFor="outflow-query">Outflow Query</label>
+                    <input name="outflow-query" type="text" placeholder="Outflow query..." value={outflowQuery}
+                        onChange={e => onOutflowQueryChange(e.target.value)} />
+                    <button className="btn btn-default" onClick={() => onFilterOutflow()}>Filter</button>
+                </div>
+            </fieldset>
+            <ul>
+                <li>inflow: {dollars(inflow, sensitive)}</li>
+                <li>outflow: {dollars(outflow)}</li>
+                <li>savings: {dollars(inflow - outflow)}</li>
+                <li>rate: {parseFloat((inflow - outflow) / inflow * 100).toFixed(2)+"%"} ({classifyRate((inflow - outflow) / inflow)})</li>
+            </ul>
+            <SavingsRateLineChart rate={(inflow - outflow) / inflow} transactions={outflowTransactions} />
+            {/* O($1,000) */}
+            <StackedHistogram labels={months} datasets={Object.keys(categories).map(k => ({
+                label: k,
+                data: categories[k].reduce((acc, x) => {
+                    acc[x.Date.getMonth()] += x.Outflow;
+                    return acc;
+                }, new Array(12).fill(0)).map((x, i) => ({
+                    x: i,
+                    y: x,
+                }))
+            }))} />
+            {/* O($100) */}
+            <StackedHistogram labels={months} datasets={Object.keys(categories).map(k => ({
+                label: k,
+                data: categories[k].reduce((acc, x) => {
+                    acc[x.Date.getMonth()] += x.Outflow;
+                    return acc;
+                }, new Array(12).fill(0)).map((x, i) => ({
+                    x: i,
+                    y: x,
+                }))
+            }))} />
+            {/* O($10) */}
+            <StackedHistogram labels={months} datasets={Object.keys(categories).map(k => ({
+                label: k,
+                data: categories[k].reduce((acc, x) => {
+                    acc[x.Date.getMonth()] += x.Outflow;
+                    return acc;
+                }, new Array(12).fill(0)).map((x, i) => ({
+                    x: i,
+                    y: x,
+                }))
+            }))} />
+            {/* <GenLineChart x="X" y="Y" datasets={[
+                {
+                    label: '25%',
+                    data: delta25Sum.map((x, i) => ({
+                        x: i,
+                        y: x,
+                    })),
+                },
+                {
+                    label: '50%',
+                    data: delta50Sum.map((x, i) => ({
+                        x: i,
+                        y: x,
+                    })),
+                },
+                {
+                    label: '75%',
+                    data: delta75Sum.map((x, i) => ({
+                        x: i,
+                        y: x,
+                    })),
+                },
+            ]} /> */}
+            <hr />
+            <table>
+                <thead>
+                    <tr>
+                        <th>Month</th>
+                        <th>Delta (25%)</th>
+                        <th>Delta (50%)</th>
+                        <th>Delta (75%)</th>
+                    </tr>
+                </thead>
+                <tbody>
+                {months.map((x, i) => (
+                    <tr key={i}>
+                        <td>{x}</td>
+                        <td>{dollars(delta25Sum[i], sensitive)}</td>
+                        <td>{dollars(delta50Sum[i], sensitive)}</td>
+                        <td>{dollars(delta75Sum[i], sensitive)}</td>
+                    </tr>
+                ))}
+                </tbody>
+            </table>
+        </section>
+    );
+};
+
+const Calculator = ({ sensitive, saved, onClear }) => (
+    <div>
+        <ul>
+            {Object.keys(saved).map(k => (
+                <li key={k}>
+                    {dollars(saved[k], sensitive)} {k}
+                </li>
+            ))}
+        </ul>
+        <p>{dollars(savedSum, sensitive)}</p>
+        <button className="btn btn-default" onClick={() => onClear()}>clear</button>
+    </div>
+);
+
+const Forecast = ({ sensitive, paycheck, onPaycheck }) => {
+    const getModel = k => {
+        const max = paycheck / 3;
+        const lastYear = getSum(select(`Account:/checking/ after:"01/01/2022" before:"01/01/2023" ${queries[k]}`, data.data.transactions)) / 12;
+        return {
+            max,
+            lastYear,
+            surplus: max - lastYear,
+        };
+    };
+
+    const housing = getModel('housing');
+    const food = getModel('food');
+    const commute = getModel('commute');
+
+    return (
+        <fieldset>
+            <legend>Forecasting</legend>
+            <label htmlFor="paycheck">Paycheck</label>
+            <input
+                name="paycheck" type="text" placeholder="Last paycheck ($USD)"
+                value={paycheck}
+                onChange={e => onPaycheck(parseFloat(e.target.value))} />
+            <table>
+                <thead>
+                    <tr>
+                        <th>category</th>
+                        <th>max</th>
+                        <th>last year</th>
+                        <th>surplus</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    <tr>
+                        <td>Housing</td>
+                        <td>{dollars(housing.max, sensitive)}</td>
+                        <td>{dollars(housing.lastYear, sensitive)}</td>
+                        <td>{dollars(housing.surplus, sensitive)}</td>
+                    </tr>
+                    <tr>
+                        <td>Food</td>
+                        <td>{dollars(food.max, sensitive)}</td>
+                        <td>{dollars(food.lastYear, sensitive)}</td>
+                        <td>{dollars(food.surplus, sensitive)}</td>
+                    </tr>
+                    <tr>
+                        <td>Commute</td>
+                        <td>{dollars(commute.max, sensitive)}</td>
+                        <td>{dollars(commute.lastYear, sensitive)}</td>
+                        <td>{dollars(commute.surplus, sensitive)}</td>
+                    </tr>
+                    <tr>
+                        <td>Sum</td>
+                        <td>-</td>
+                        <td>-</td>
+                        <td>{dollars(housing.surplus + food.surplus + commute.surplus, sensitive)}</td>
+                    </tr>
+                </tbody>
+            </table>
+        </fieldset>
+    );
+};
+
 /**
  * Table rendering information about transactions bucketed by its order of
  * magnitude.
  */
-const Magnitable = ({ label, transactions }) => {
+const Magnitable = ({ sensitive, label, transactions }) => {
     const categories = transactions.reduce((acc, x) => {
         if (x.Category === '') {
             return acc;
@@ -136,7 +1006,7 @@ const Magnitable = ({ label, transactions }) => {
         <React.Fragment>
             {keys.map(k => (
                 <tr style={{backgroundColor: '#F0F8FF'}}>
-                    <td>{k}</td><td>{usd.format(categories[k])}</td>
+                    <td>{k}</td><td>{dollars(categories[k], sensitive)}</td>
                 </tr>
             ))}
         </React.Fragment>
@@ -146,10 +1016,11 @@ const Magnitable = ({ label, transactions }) => {
 /**
  * Calculates and renders various aggregates over an input list of transactions.
  */
-const AggregateTable = ({ focus, onFocus, transactions }) => {
+const AggregateTable = ({ sensitive, focus, onFocus, transactions }) => {
+    const net = transactions.reduce((acc, x) => acc + x.Inflow - x.Outflow, 0);
     const sum = transactions.reduce((acc, x) => acc + x.Outflow, 0);
     const buckets = transactions.reduce((acc, x) => {
-        const order = Math.floor(Math.log(x.Outflow) / Math.LN10 + 0.000000001);
+        const order = magnitude(x.Outflow);
         const bucket = Math.pow(10, order);
         acc[bucket].push(x);
         return acc;
@@ -166,21 +1037,23 @@ const AggregateTable = ({ focus, onFocus, transactions }) => {
                     </tr>
                 </thead>
                 <tbody>
-                    <tr><td>sum</td><td>{usd.format(sum)}</td></tr>
-                    <tr><td>per day</td><td>{usd.format(sum / 365)}</td></tr>
-                    <tr><td>per week</td><td>{usd.format(sum / 52)}</td></tr>
-                    <tr><td>per month</td><td>{usd.format(sum / 12)}</td></tr>
-                    <tr onClick={() => onFocus(1000)}><td>Σ Θ($1,000)</td><td>{usd.format(buckets[1000].reduce((acc, x) => acc + x.Outflow, 0))}</td></tr>
-                    {(focus[1000]) && <Magnitable label="$1,000" transactions={buckets[1000]} />}
-                    <tr onClick={() => onFocus(100)}><td>Σ Θ($100)</td><td>{usd.format(buckets[100].reduce((acc, x) => acc + x.Outflow, 0))}</td></tr>
-                    {(focus[100]) && <Magnitable label="$100" transactions={buckets[100]} />}
-                    <tr onClick={() => onFocus(10)}><td>Σ Θ($10)</td><td>{usd.format(buckets[10].reduce((acc, x) => acc + x.Outflow, 0))}</td></tr>
-                    {(focus[10]) && <Magnitable label="$10" transactions={buckets[10]} />}
-                    <tr onClick={() => onFocus(1)}><td>Σ Θ($1)</td><td>{usd.format(buckets[1].reduce((acc, x) => acc + x.Outflow, 0))}</td></tr>
-                    {(focus[1]) && <Magnitable label="$1.00" transactions={buckets[1]} />}
-                    <tr onClick={() => onFocus(0.1)}><td>Σ Θ($0.10)</td><td>{usd.format(buckets[0.1].reduce((acc, x) => acc + x.Outflow, 0))}</td></tr>
-                    {(focus[0.1]) && <Magnitable label="$0.10" transactions={buckets[0.1]} />}
-                    <tr><td>average</td><td>{usd.format(sum / transactions.length)}</td></tr>
+                    <tr><td>net</td><td>{dollars(net, sensitive)}</td></tr>
+                    <tr onClick={() => onFocus('sum')}><td>sum</td><td>{dollars(sum, sensitive)}</td></tr>
+                    {focus.sum && <Magnitable sensitive={sensitive} label="sum" transactions={transactions} />}
+                    <tr><td>per day</td><td>{dollars(sum / 365, sensitive)}</td></tr>
+                    <tr><td>per week</td><td>{dollars(sum / 52, sensitive)}</td></tr>
+                    <tr><td>per month</td><td>{dollars(sum / 12, sensitive)}</td></tr>
+                    <tr onClick={() => onFocus(1000)}><td>Σ Θ($1,000)</td><td>{dollars(buckets[1000].reduce((acc, x) => acc + x.Outflow, 0), sensitive)}</td></tr>
+                    {(focus[1000]) && <Magnitable sensitive={sensitive} label="$1,000" transactions={buckets[1000]} />}
+                    <tr onClick={() => onFocus(100)}><td>Σ Θ($100)</td><td>{dollars(buckets[100].reduce((acc, x, sensitive) => acc + x.Outflow, 0), sensitive)}</td></tr>
+                    {(focus[100]) && <Magnitable sensitive={sensitive} label="$100" transactions={buckets[100]} />}
+                    <tr onClick={() => onFocus(10)}><td>Σ Θ($10)</td><td>{dollars(buckets[10].reduce((acc, x) => acc + x.Outflow, 0), sensitive)}</td></tr>
+                    {(focus[10]) && <Magnitable sensitive={sensitive} label="$10" transactions={buckets[10]} />}
+                    <tr onClick={() => onFocus(1)}><td>Σ Θ($1)</td><td>{dollars(buckets[1].reduce((acc, x) => acc + x.Outflow, 0), sensitive)}</td></tr>
+                    {(focus[1]) && <Magnitable sensitive={sensitive} label="$1.00" transactions={buckets[1]} />}
+                    <tr onClick={() => onFocus(0.1)}><td>Σ Θ($0.10)</td><td>{dollars(buckets[0.1].reduce((acc, x) => acc + x.Outflow, 0), sensitive)}</td></tr>
+                    {(focus[0.1]) && <Magnitable sensitive={sensitive} label="$0.10" transactions={buckets[0.1]} />}
+                    <tr><td>average</td><td>{dollars(sum / transactions.length, sensitive)}</td></tr>
                     <tr><td>count</td><td>{transactions.length}</td></tr>
                 </tbody>
             </table>
@@ -188,20 +1061,19 @@ const AggregateTable = ({ focus, onFocus, transactions }) => {
     );
 };
 
-const Input = ({ query, onChange, onFilter, onSave }) => (
+const Query = ({ query, onChange, onFilter }) => (
     <fieldset>
         <legend>Query</legend>
         <div className="form-group">
             <input name="query" type="text" value={query} onChange={e => onChange(e.target.value)} />
             <div className="btn-group">
                 <button className="btn btn-default" onClick={() => onFilter()}>Filter</button>
-                <button className="btn btn-default" onClick={() => onSave()}>Save</button>
             </div>
         </div>
     </fieldset>
 );
 
-const Table = ({ transactions, onClick }) => (
+const Transactions = ({ sensitive, transactions, onClick }) => (
     <table>
         <caption>Transactions</caption>
         <thead>
@@ -209,6 +1081,7 @@ const Table = ({ transactions, onClick }) => (
                 <th>Account</th>
                 <th>Category</th>
                 <th>Date</th>
+                <th>Inflow</th>
                 <th>Outflow</th>
                 <th>Payee</th>
                 <th>Memo</th>
@@ -220,7 +1093,8 @@ const Table = ({ transactions, onClick }) => (
                     <td>{x.Account}</td>
                     <td>{x.Category}</td>
                     <td>{x.Date.toLocaleDateString()}</td>
-                    <td>{usd.format(x.Outflow)}</td>
+                    <td>{dollars(x.Inflow, sensitive)}</td>
+                    <td>{dollars(x.Outflow, sensitive)}</td>
                     <td>{x.Payee}</td>
                     <td>{x.Memo}</td>
                 </tr>
@@ -229,7 +1103,7 @@ const Table = ({ transactions, onClick }) => (
     </table>
 );
 
-const domContainer = document.querySelector('#react-mount');
+const domContainer = document.querySelector('#mount');
 const root = ReactDOM.createRoot(domContainer);
 
-root.render(<App />);
\ No newline at end of file
+root.render(<App />);
diff --git a/users/wpcarro/ynabsql/dataviz/components.jsx b/users/wpcarro/ynabsql/dataviz/components.jsx
new file mode 100644
index 0000000000..7a9b7ae958
--- /dev/null
+++ b/users/wpcarro/ynabsql/dataviz/components.jsx
@@ -0,0 +1,1248 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import Chart from 'chart.js/auto';
+import 'chartjs-adapter-date-fns';
+
+const colors = {
+    red: 'rgb(255, 45, 70)',
+    green: 'rgb(75, 192, 35)',
+    white: 'rgb(249, 246, 238)',
+    blue: 'rgb(137, 207, 240)',
+    fadedBlue: 'rgb(137, 207, 240, 0.25)',
+    purple: 'rgb(203, 195, 227)',
+    brown: 'rgb(205, 127, 50)',
+    black: 'rgb(53, 57, 53)',
+};
+
+const months = [
+    'January',
+    'February',
+    'March',
+    'April',
+    'May',
+    'June',
+    'July',
+    'August',
+    'September',
+    'October',
+    'November',
+    'December',
+];
+
+function getWeek(x) {
+    const dowOffset = 0;
+    const newYear = new Dte(x.getFullYear(), 0, 1);
+    let day = newYear.getDay() - dowOffset; //the day of week the year begins on
+    day = (day >= 0 ? day : day + 7);
+    const daynum = Math.floor((x.getTime() - newYear.getTime() -
+        (x.getTimezoneOffset() - newYear.getTimezoneOffset()) * 60000) / 86400000) + 1;
+    let weeknum;
+
+    //if the year starts before the middle of a week
+    if (day < 4) {
+        weeknum = Math.floor((daynum + day - 1) / 7) + 1;
+        if (weeknum > 52) {
+            const nYear = new Date(x.getFullYear() + 1, 0, 1);
+            let nday = nYear.getDay() - dowOffset;
+            nday = nday >= 0 ? nday : nday + 7;
+            /*if the next year starts before the middle of
+              the week, it is week #1 of that year*/
+            weeknum = nday < 4 ? 1 : 53;
+        }
+    }
+    else {
+        weeknum = Math.floor((daynum + day - 1) / 7);
+    }
+    return weeknum;
+}
+
+// Convert a sorting expressions (e.g. "Outflow DESC; Date ASC; Category ASC")
+// into a function that can be passed to Array.prototype.sort.
+function compileSort(expr) {
+    return expr.split(/\s*;\s*/).reverse().reduce((acc, x) => {
+        const [k, dir] = x.split(/\s+/);
+        if (dir === 'ASC') {
+            return function(x, y) {
+                if (x[k] > y[k]) { return 1; }
+                if (x[k] < y[k]) { return -1; }
+                else { return acc(x, y); }
+            };
+        }
+        if (dir === 'DESC') {
+            return function(x, y) {
+                if (x[k] > y[k]) { return -1; }
+                if (x[k] < y[k]) { return 1; }
+                else { return acc(x, y); }
+            };
+        }
+        else {
+            throw new Error(`Sort direction not supported, ${dir}, must be either "ASC" or "DESC"`);
+        }
+    }, function(x, y) { return 0; })
+}
+
+function dollars(n, sensitive) {
+    if (sensitive) {
+        const order = magnitude(n);
+        // Shortcut to avoid writing comma-insertion logic v0v.
+        if (n === 0) {
+            return '$X.XX';
+        }
+        if (order <= 0) {
+            return '$X.XX';
+        }
+        if (order === 1) {
+            return '$XX.XX';
+        }
+        if (order === 2) {
+            return '$XXX.XX';
+        }
+        if (order === 3) {
+            return '$X,XXX.XX';
+        }
+        if (order === 4) {
+            return '$XX,XXX.XX';
+        }
+        if (order === 4) {
+            return '$XX,XXX.XX';
+        }
+        if (order === 5) {
+            return '$XXX,XXX.XX';
+        }
+        // Coming soon! :P
+        if (order === 6) {
+            return '$X,XXX,XXX.XX';
+        }
+        if (order === 7) {
+            return '$XX,XXX,XXX.XX';
+        }
+        if (order === 8) {
+            return '$XXX,XXX,XXX.XX';
+        }
+        // Unsupported
+        else {
+            return '$???.??';
+        }
+    }
+    return usd.format(n);
+}
+
+const usd = new Intl.NumberFormat('en-US', {
+    style: 'currency',
+    currency: 'USD',
+});
+
+const categories = data.data.transactions.reduce((xs, x) => {
+    if (!(x.Category in xs)) {
+        xs[x.Category] = [];
+    }
+    xs[x.Category].push(x);
+    return xs;
+}, {});
+
+const queries = {
+    housing: 'Category:/(rent|electric)/',
+    food: 'Category:/(eating|alcohol|grocer)/',
+    commute: 'Category:/(vacation|gasoline|parking|car maintenance)/',
+};
+
+/**
+ * Return the Order of Magnitude of some value, x.
+ */
+function magnitude(x) {
+    return Math.floor(Math.log(x) / Math.LN10 + 0.000000001);
+}
+
+function getSum(transactions) {
+    return transactions.reduce((acc, x) => acc + x.Outflow, 0);
+}
+
+function transactionKey(x) {
+    const keys = [
+        'Account',
+        'Flag',
+        'Date',
+        'Payee',
+        'Category',
+        'Memo',
+        'Outflow',
+        'Inflow',
+        'Cleared',
+    ];
+    return keys.map(k => x[k]).join('|');
+}
+
+function parseCSV(csv) {
+  var lines=csv.split("\n");
+  var result = [];
+
+  // Strip the surrounding 2x-quotes from the header.
+  //
+  // NOTE: If your columns contain commas in their values, you'll need
+  // to deal with those before doing the next step 
+  var headers = lines[0].split(",").map(x => x.slice(1, -1));
+
+  for(var i = 1; i < lines.length; i += 1) {
+      var obj = {};
+      var currentline=lines[i].split(",");
+
+      for(var j = 0; j <  headers.length; j += 1) { 
+        obj[headers[j]] = currentline[j].slice(1, -1);
+      }
+
+      result.push(obj);
+  }
+
+  return result.map(x => ({
+    ...x,
+    Date: new Date(x.Date),
+    Inflow: parseFloat(x.Inflow),
+    Outflow: parseFloat(x.Outflow),
+  }));
+}
+
+
+class UploadJSON extends React.Component {
+    handleUpload(e) {
+        let files = e.target.files;
+        if (!files.length) {
+            alert('No file selected!');
+            return;
+        }
+        let file = files[0];
+        let reader = new FileReader();
+        reader.onload = (event) => {
+            this.props.onUpload(parseCSV(event.target.result));
+        };
+        reader.readAsText(file);
+    }
+    render() {
+        return <input onChange={e => this.handleUpload(e)} id="json-upload" type="file" accept="application/csv" />;
+    }
+}
+
+class ScatterChart extends React.Component {
+    constructor(props) {
+        super(props);
+        this.chart = null;
+        // Generate a 1/1M random ID.
+        this.id = btoa(Math.floor(Math.random() * 1e9));
+    }
+    componentDidUpdate(prevProps) {
+        if (this.props.transactions !== prevProps.transactions) {
+            this.chart.data.datasets[0].data = this.props.transactions.filter(x => x.Inflow > 0).map(x => ({
+                x: x.Date,
+                y: x.Inflow,
+                metadata: x,
+            }));
+            this.chart.data.datasets[1].data = this.props.transactions.filter(x => x.Outflow > 0).map(x => ({
+                x: x.Date,
+                y: x.Outflow,
+                metadata: x,
+            }));
+            this.chart.update();
+        }
+    }
+    componentDidMount() {
+        const mount = document.getElementById(this.id);
+        this.chart = new Chart(mount, {
+            type: 'scatter',
+            data: {
+                datasets: [
+                    {
+                        label: 'Revenue',
+                        data: this.props.transactions.filter(x => x.Inflow > 0).map(x => ({
+                            x: x.Date,
+                            y: x.Inflow,
+                            metadata: x,
+                        })),
+                        backgroundColor: colors.green,
+                    },
+                    {
+                        label: 'Expenses',
+                        data: this.props.transactions.filter(x => x.Outflow).map(x => ({
+                            x: x.Date,
+                            y: x.Outflow,
+                            metadata: x,
+                        })),
+                        backgroundColor: colors.red,
+                    },
+                ],
+            },
+            options: {
+                scales: {
+                    x: {
+                        type: 'time',
+                        title: {
+                            display: true,
+                            text: 'Date',
+                        },
+                    },
+                    y: {
+                        title: {
+                            display: true,
+                            text: 'Amount ($USD)'
+                        },
+                    },
+                },
+                plugins: {
+                    tooltip: {
+                        callbacks: {
+                            title: function (x) {
+                                return `$${x[0].raw.y} (${x[0].raw.metadata.Date.toLocaleDateString()})`;
+                            },
+                            label: function (x) {
+                                const { Category, Payee, Memo } = x.raw.metadata;
+                                return `${Payee} - ${Category} (${Memo})`;
+                            },
+                        },
+                    },
+                },
+            },
+        });
+    }
+    render() {
+        return <canvas id={this.id}></canvas>;
+    }
+}
+
+/**
+ * Generic line chart parameterized by:
+ * - datasets: forwarded to chart.js library
+ * - x: string label for x-axis
+ * - y: string label for y-axis
+ */
+class GenLineChart extends React.Component {
+    constructor(props) {
+        super(props);
+        this.chart = null;
+        // Generate a 1/1M random ID.
+        this.id = btoa(Math.floor(Math.random() * 1e9));
+    }
+
+    componentDidUpdate(prevProps, prevState) {
+        if (this.props.datasets != prevProps.datasets) {
+            this.chart.data.datasets = this.props.datasets;
+            this.chart.update();
+        }
+    }
+
+    componentDidMount() {
+        const mount = document.getElementById(this.id);
+        this.chart = new Chart(mount, {
+            type: 'line',
+            data: {
+                datasets: this.props.datasets,
+            },
+            options: {
+                scales: {
+                    x: {
+                        type: 'time',
+                        title: {
+                            display: true,
+                            text: this.props.x,
+                        },
+                    },
+                    y: {
+                        title: {
+                            display: true,
+                            text: this.props.y
+                        },
+                    },
+                },
+            },
+        });
+    }
+
+    render() {
+        return <canvas id={this.id}></canvas>;
+    }
+}
+
+class DonutChart extends React.Component {
+    constructor(props) {
+        super(props);
+        this.chart = null;
+        // Generate a 1/1M random ID.
+        this.id = btoa(Math.floor(Math.random() * 1e9));
+    }
+
+    componentDidUpdate(prevProps, prevState) {
+        if (this.props.datasets != prevProps.datasets) {
+            this.chart.data.datasets = this.props.datasets;
+            this.chart.update();
+        }
+    }
+
+    componentDidMount() {
+        const mount = document.getElementById(this.id);
+        this.chart = new Chart(mount, {
+            type: 'doughnut',
+            data: {
+                labels: this.props.labels,
+                datasets: this.props.datasets,
+            },
+            options: {
+                resonsive: true,
+            },
+        });
+    }
+
+    render() {
+        return <canvas id={this.id}></canvas>;
+    }
+}
+
+class StackedHistogram extends React.Component {
+    constructor(props) {
+        super(props);
+        this.chart = null;
+        // Generate a 1/1M random ID.
+        this.id = btoa(Math.floor(Math.random() * 1e9));
+    }
+
+    componentDidUpdate(prevProps, prevState) {
+        if (this.props.datasets != prevProps.datasets) {
+            this.chart.data.datasets = this.props.datasets;
+            this.chart.update();
+        }
+    }
+
+    componentDidMount() {
+        const mount = document.getElementById(this.id);
+        this.chart = new Chart(mount, {
+            type: 'bar',
+            data: {
+                labels: this.props.labels,
+                datasets: this.props.datasets,
+            },
+            options: {
+                scales: {
+                    x: {
+                        stacked: true,
+                    },
+                    y: {
+                        stacked: true,
+                    },
+                },
+            },
+        });
+    }
+
+    render() {
+        return <canvas id={this.id}></canvas>;
+    }
+}
+
+/**
+ * Display the "Actual Savings Rate" (bucketed by month) as a line chart with
+ * the "Expected Savings Rate" overlay.
+ */
+class SavingsRateLineChart extends React.Component {
+    constructor(props) {
+        super(props);
+        this.chart = null;
+        // Generate a 1/1M random ID.
+        this.id = btoa(Math.floor(Math.random() * 1e9));
+    }
+
+    static getRevenue(transactions) {
+        // Bucket revenues into months.
+        return transactions.reduce((acc, x) => {
+            const month = x.Date.getMonth();
+            acc[month] += x.Inflow;
+            return acc;
+        }, new Array(12).fill(0));
+    }
+
+    static getExpenses(transactions) {
+        // Bucket revenues into months.
+        return transactions.reduce((acc, x) => {
+            const month = x.Date.getMonth();
+            acc[month] += x.Outflow;
+            return acc;
+        }, new Array(12).fill(0));
+    }
+
+    componentDidMount() {
+        const mount = document.getElementById(this.id);
+        const revenue = SavingsRateLineChart.getRevenue(this.props.transactions);
+        const expenses = SavingsRateLineChart.getExpenses(this.props.transactions);
+
+        this.chart = new Chart(mount, {
+            type: 'line',
+            data: {
+                datasets: [
+                    {
+                        label: 'actual savings (by month)',
+                        data: new Array(12).fill(null).map((_, i) => ({
+                            x: i,
+                            y: (revenue[i] - expenses[i]) / revenue[i],
+                        })),
+                        cubicInterpolationMode: 'monotone',
+                        tension: 0.4,
+                        borderColor: colors.fadedBlue,
+                        backgroundColor: colors.fadedBlue,
+                    },
+                    {
+                        label: 'actual savings (overall)',
+                        data: new Array(12).fill(null).map((_, i) => ({
+                            x: i,
+                            y: this.props.rate,
+                        })),
+                        cubicInterpolationMode: 'monotone',
+                        tension: 0.4,
+                        borderColor: colors.blue,
+                        backgroundColor: colors.blue,
+                    },
+                    // 0% marker (out of debt)
+                    {
+                        label: 'beginner (0%)',
+                        data: new Array(12).fill(null).map((x, i) => ({
+                            x: i,
+                            y: 0.00,
+                        })),
+                        cubicInterpolationMode: 'monotone',
+                        tension: 0.4,
+                        borderColor: colors.white,
+                        backgroundColor: colors.white,
+                    },
+                    // 25% marker (quarter "Washington" club)
+                    {
+                        label: 'healthy (25%)',
+                        data: new Array(12).fill(null).map((x, i) => ({
+                            x: i,
+                            y: 0.25,
+                        })),
+                        cubicInterpolationMode: 'monotone',
+                        tension: 0.4,
+                        borderColor: colors.purple,
+                        backgroundColor: colors.purple,
+                    },
+                    // 50% marker (1/2-dollar "Kennedy" club)
+                    {
+                        label: 'rich (50%)',
+                        data: new Array(12).fill(null).map((x, i) => ({
+                            x: i,
+                            y: 0.50,
+                        })),
+                        cubicInterpolationMode: 'monotone',
+                        tension: 0.4,
+                        borderColor: colors.brown,
+                        backgroundColor: colors.brown,
+                    },
+                    // 75% marker
+                    {
+                        label: 'wealthy (75%)',
+                        data: new Array(12).fill(null).map((x, i) => ({
+                            x: i,
+                            y: 0.75,
+                        })),
+                        cubicInterpolationMode: 'monotone',
+                        tension: 0.4,
+                        borderColor: colors.black,
+                        backgroundColor: colors.black,
+                    },
+                ],
+                labels: months,
+            },
+            options: {
+                scales: {
+                    y: {
+                        max: 1.0,
+                        min: -1.0,
+                        title: {
+                            display: true,
+                            text: 'Savings Rate (%)'
+                        },
+                    },
+                },
+            },
+        });
+    }
+
+    componentDidUpdate(prevProps, prevState) {
+        // Bucket revenues into months.
+        const revenue = SavingsRateLineChart.getRevenue(this.props.transactions);
+        const expenses = SavingsRateLineChart.getExpenses(this.props.transactions);
+
+        this.chart.data.datasets[0].data = new Array(12).fill(null).map((_, i) => ({
+            x: i,
+            y: (revenue[i] - expenses[i]) / revenue[i],
+        }));
+        this.chart.update();
+    }
+
+    render() {
+        return <canvas id={this.id}></canvas>;
+    }
+}
+
+class App extends React.Component {
+    constructor(props) {
+        super(props);
+        const query = 'Account:/checking/ (Inflow>1000 OR Outflow>1000)';
+        const allTransactions = data.data.transactions;
+        const savingsView = 'after:"01/01/2022"';
+        const inflowQuery = 'Account:/checking/';
+        const outflowQuery = 'Account:/checking/ -Category:/(stocks|crypto)/';
+
+        // slx configuration
+        const slxCaseSensitive = false;
+        const slxPreferRegex = true;
+        const slxDateKey = 'Date';
+
+        this.state = {
+            query,
+            transactionsView: 'table',
+            sensitive: false,
+            allTransactions,
+            slxCaseSensitive,
+            slxPreferRegex,
+            slxDateKey,
+            filteredTransactions: select(query, allTransactions, {
+                caseSensitive: slxCaseSensitive,
+                preferRegex: slxPreferRegex,
+                dateKey: slxDateKey,
+            }),
+            saved: {},
+            focus: {
+                sum: false,
+                1000: false,
+                100: false,
+                10: false,
+                1: false,
+                0.1: false,
+            },
+            budget: [
+                {
+                    label: 'Flexible',
+                    children: [
+                        { label: 'groceries - $200/mo', savings: false, monthly: 400.00 },
+                        { label: 'eating out - $150/mo', savings: false, monthly: 200.00 },
+                        { label: 'alcohol - $200/mo', savings: false, monthly: 200.00 },
+                        { label: 'household items - $50/mo', savings: false, monthly: 50.00 },
+                        { label: 'toiletries - $200/yr', savings: false, monthly: 200.00 / 12 },
+                        { label: 'haircuts - $400/yr', savings: false, monthly: 400.00 / 12 },
+                        { label: 'gasoline - $100/mo', savings: false, monthly: 100.00 },
+                        { label: 'parking - $10/mo', savings: false, monthly: 10.00 },
+                        { label: 'ride services - $25/mo', savings: false, monthly: 50.00 },
+                        { label: 'LMNT - $45/mo', savings: false, monthly: 45.00 },
+                        { label: 'books - $25/mo', savings: false, monthly: 25.00 },
+                        { label: 'vacation - $4,000/yr', savings: false, monthly: 4000.00 / 12 },
+                        { label: 'reimbursements - $5,000 balance', savings: false, monthly: 0.00 },
+                    ],
+                },
+                {
+                    label: 'Fixed',
+                    children: [
+                        { label: 'rent - $3,100/mo', savings: false, monthly: 3100.00 },
+                        { label: 'electric - $50/mo', savings: false, monthly: 50.00 },
+                        { label: 'SoCalGas - $30/mo', savings: false, monthly: 30.00 },
+                        { label: 'YNAB', savings: false, monthly: 100.00 / 12 },
+                        { label: 'Robinhood Gold', savings: false, monthly: 5.00 },
+                        { label: 'Spotify', savings: false, monthly: 10.00 },
+                        { label: 'Surfline', savings: false, monthly: 100.00 / 12 },
+                        { label: 'HBO Max', savings: false, monthly: 170.00 },
+                        { label: 'Clear', savings: false, monthly: 179.00 },
+                        { label: 'car insurance', savings: false, monthly: 100.00 },
+                        { label: 'Making Sense', savings: false, monthly: 50.00 / 12 },
+                        { label: 'internet', savings: false, monthly: 100.00 },
+                        { label: 'tax return', savings: false, monthly: 200.00 / 12 },
+                    ],
+                },
+                {
+                    label: 'Rainy Day (dont touch)',
+                    children: [
+                        { label: 'emergency fund', savings: false, monthly: 0.00 },
+                        { label: 'check-ups', savings: false, monthly: 7.50 },
+                        { label: 'car maintenance', savings: false, monthly: 98.33 },
+                    ],
+                },
+                {
+                    label: 'Savings (dont touch)',
+                    children: [
+                        { label: 'stocks', savings: true, monthly: 4000.00 },
+                        { label: 'crypto', savings: true, monthly: 736.00 },
+                    ],
+                },
+                {
+                    label: 'Gifts (dont touch)',
+                    children: [
+                        { label: 'birthdays', savings: false, monthly: 250.00 / 12 },
+                        { label: 'Valentines Day', savings: false, monthly: 100.00 / 12 },
+                        { label: 'Mothers Day', savings: false, monthly: 25.00 / 12 },
+                        { label: 'Fathers Day', savings: false, monthly: 25.00 / 12 },
+                        { label: 'Christmas', savings: false, monthly: 500.00 / 12 },
+                    ],
+                },
+                {
+                    label: 'Error Budget',
+                    children: [
+                        { label: 'stuff I forgot to budget for - $2,254/mo', savings: false, monthly: 0.00 },
+                    ],
+                },
+            ],
+            paycheck: 6000.00,
+            view: 'query',
+            savingsView,
+            inflowQuery,
+            outflowQuery,
+            inflowTransactions: select(inflowQuery, select(savingsView, allTransactions, {
+                caseSensitive: slxCaseSensitive,
+                preferRegex: slxPreferRegex,
+                dateKey: slxDateKey,
+            }), {
+                caseSensitive: slxCaseSensitive,
+                preferRegex: slxPreferRegex,
+                dateKey: slxDateKey,
+            }),
+            outflowTransactions: select(outflowQuery, select(savingsView, allTransactions, {
+                caseSensitive: slxCaseSensitive,
+                preferRegex: slxPreferRegex,
+                dateKey: slxDateKey,
+            }), {
+                caseSensitive: slxCaseSensitive,
+                preferRegex: slxPreferRegex,
+                dateKey: slxDateKey,
+            }),
+            sortExpr: 'Date DESC; Outflow DESC',
+        };
+    }
+
+    render() {
+        const sum = this.state.filteredTransactions.reduce((acc, { Outflow }) => acc + Outflow, 0);
+        const savedSum = Object.values(this.state.saved).reduce((acc, sum) => acc + sum, 0);
+
+        let view = null;
+        if (this.state.view === 'query') {
+            view = (
+                <QueryView
+                    transactionsView={this.state.transactionsView}
+                    sortExpr={this.state.sortExpr}
+                    onSortExprChange={sortExpr => this.setState({ sortExpr })}
+                    sensitive={this.state.sensitive}
+                    query={this.state.query}
+                    focus={this.state.focus}
+                    allTransactions={this.state.allTransactions}
+                    transactions={this.state.filteredTransactions}
+                    saved={this.state.saved}
+                    setState={this.setState.bind(this)}
+                    slxConfig={{
+                        caseSensitive: this.state.slxCaseSensitive,
+                        preferRegex: this.state.slxPreferRegex,
+                        dateKey: this.state.slxDateKey,
+                    }}
+                />
+            );
+        } else if (this.state.view === 'savings') {
+            view = (
+                <SavingsView
+                    sensitive={this.state.sensitive}
+                    savingsView={this.state.savingsView}
+                    inflowQuery={this.state.inflowQuery}
+                    outflowQuery={this.state.outflowQuery}
+                    inflowTransactions={this.state.inflowTransactions}
+                    outflowTransactions={this.state.outflowTransactions}
+                    onFilterInflow={() => this.setState({
+                        inflowTransactions: select(this.state.inflowQuery, select(this.state.savingsView, this.state.allTransactions, {
+                            caseSensitive: this.state.slxCaseSensitive,
+                            preferRegex: this.state.slxPreferRegex,
+                            dateKey: this.state.slxDateKey,
+                        }), {
+                            caseSensitive: this.state.slxCaseSensitive,
+                            preferRegex: this.state.slxPreferRegex,
+                            dateKey: this.state.slxDateKey,
+                        }),
+                    })}
+                    onFilterOutflow={() => this.setState({
+                        outflowTransactions: select(this.state.outflowQuery, select(this.state.savingsView, this.state.allTransactions, {
+                            caseSensitive: this.state.slxCaseSensitive,
+                            preferRegex: this.state.slxPreferRegex,
+                            dateKey: this.state.slxDateKey,
+                        }), {
+                            caseSensitive: this.state.slxCaseSensitive,
+                            preferRegex: this.state.slxPreferRegex,
+                            dateKey: this.state.slxDateKey,
+                        }),
+                    })}
+                    onFilterSavingsView={() => this.setState({
+                        inflowTransactions: select(this.state.inflowQuery, select(this.state.savingsView, this.state.allTransactions, {
+                            caseSensitive: this.state.slxCaseSensitive,
+                            preferRegex: this.state.slxPreferRegex,
+                            dateKey: this.state.slxDateKey,
+                        }), {
+                            caseSensitive: this.state.slxCaseSensitive,
+                            preferRegex: this.state.slxPreferRegex,
+                            dateKey: this.state.slxDateKey,
+                        }),
+                        outflowTransactions: select(this.state.outflowQuery, select(this.state.savingsView, this.state.allTransactions, {
+                            caseSensitive: this.state.slxCaseSensitive,
+                            preferRegex: this.state.slxPreferRegex,
+                            dateKey: this.state.slxDateKey,
+                        }), {
+                            caseSensitive: this.state.slxCaseSensitive,
+                            preferRegex: this.state.slxPreferRegex,
+                            dateKey: this.state.slxDateKey,
+                        }),
+                    })}
+                    onSavingsViewChange={x => this.setState({ savingsView: x })}
+                    onInflowQueryChange={x => this.setState({ inflowQuery: x })}
+                    onOutflowQueryChange={x => this.setState({ outflowQuery: x })}
+                />
+            );
+        } else if (this.state.view === 'budget') {
+            // Planned expenses:
+            // - minus planned assignment to emergency fund (not an expense)
+            // - minus planned spend to investments (e.g. stocks, crypto)
+            const budgetedSpend = this.state.budget.reduce((acc, x) => acc + x.children.filter(x => x.savings).reduce((acc, x) => acc + x.monthly, 0), 0);
+
+            view = (
+                <div>
+                    <fieldset>
+                        <legend>Details</legend>
+                        <div className="form-group">
+                            <label htmlFor="paycheck">Paycheck</label>
+                            <input name="paycheck" type="text" />
+                        </div>
+                        <div className="form-group">
+                            <label htmlFor="savings-rate">Savings Rate</label>
+                            <input name="savings-rate" type="text" />
+                        </div>
+                    </fieldset>
+                    <ul>
+                        <li>Available Spend: {dollars(this.state.paycheck * 2, this.state.sensitive)}</li>
+                        <li>Target Spend: {dollars(this.state.paycheck, this.state.sensitive)}</li>
+                        <li>Budgeted Spend (minus savings): {dollars(budgetedSpend, this.state.sensitive)}</li>
+                        <li>Emergency Fund Size (recommended): {dollars(budgetedSpend * 3, this.state.sensitive)}</li>
+                    </ul>
+                    <div style={{ width: '30em' }}>
+                        <DonutChart labels={this.state.budget.map(x => x.label)} datasets={[
+                            {
+                                label: 'Categories',
+                                data: this.state.budget.map(x => x.children.reduce((acc, y) => acc + y.monthly, 0)),
+                            }
+                        ]} />
+                    </div>
+                    <ul>
+                        {this.state.budget.map(x => (
+                            <li>
+                                <div>{x.label} - {dollars(x.children.reduce((acc, x) => acc + x.monthly, 0), this.state.sensitive)}</div>
+                                <table>
+                                    <thead>
+                                        <tr>
+                                            <th>category</th>
+                                            <th>assigned (target)</th>
+                                            <th>last year (actual)</th>
+                                        </tr>
+                                    </thead>
+                                    <tbody>
+                                        {x.children.map(y => (
+                                            <tr>
+                                                <td>{y.label}</td>
+                                                <td>{dollars(y.monthly, this.state.sensitive)}</td>
+                                                <td>{dollars((categories[y.label] || []).reduce((acc, x) => acc + x.Outflow, 0) / 12, this.state.sensitive)}</td>
+                                            </tr>
+                                        ))}
+                                    </tbody>
+                                </table>
+                            </li>
+                        ))}
+                    </ul>
+                </div>
+            );
+        }
+
+        return (
+            <div className="container">
+                <nav className="terminal-menu">
+                    <ul>
+                        <li><a href="#" onClick={() => this.setState({ view: 'query' })}>query</a></li>
+                        <li><a href="#" onClick={() => this.setState({ view: 'savings' })}>savings</a></li>
+                        <li><a href="#" onClick={() => this.setState({ view: 'budget' })}>budget</a></li>
+                        <li><a href="#" onClick={() => this.setState({ sensitive: !this.state.sensitive })}>sensitive</a></li>
+                    </ul>
+                </nav>
+                <UploadJSON onUpload={xs => this.setState({ allTnransactions: xs })} />
+                {view}
+            </div>
+        );
+    }
+}
+
+const QueryView = ({ sensitive, query, focus, allTransactions, transactions, saved, setState, slxConfig, sortExpr, transactionsView }) => (
+    <div>
+        <Query
+            query={query}
+            onChange={query => setState({
+                query,
+            })}
+            onFilter={() => setState({
+                filteredTransactions: select(query, allTransactions, slxConfig),
+            })}
+        />
+        <fieldset>
+            <legend>Sort</legend>
+            <div className="form-group">
+                <input type="text" value={sortExpr} onChange={e => setState({ sortExpr: e.target.value, })} />
+            </div>
+            <div className="form-group">
+                <button className="btn btn-default" onClick={() => setState({
+                    filteredTransactions: transactions.slice().sort(compileSort(sortExpr)),
+                })}>Sort</button>
+            </div>
+        </fieldset>
+        <hr />
+        <ScatterChart transactions={transactions} />
+        <hr />
+        <Transactions
+            transactionsView={transactionsView}
+            sensitive={sensitive}
+            transactions={transactions}
+            onClick={x => setState({
+                saved: { ...saved, [transactionKey(x)]: x.Outflow }
+            })}
+            onViewChange={transactionsView => setState({ transactionsView })}
+        />
+    </div>
+);
+
+function classifyRate(x) {
+    if (x < 0.25) {
+        return 'needs improvement';
+    }
+    if (x < 0.50) {
+        return 'healthy';
+    }
+    if (x < 0.75) {
+        return 'rich';
+    }
+    if (x < 1.00) {
+        return 'wealthy';
+    }
+}
+
+const SavingsView = ({
+    sensitive,
+    savingsView,
+    inflowQuery,
+    outflowQuery,
+    inflowTransactions,
+    outflowTransactions,
+    onSavingsViewChange,
+    onInflowQueryChange,
+    onOutflowQueryChange,
+    onFilterInflow,
+    onFilterOutflow,
+    onFilterSavingsView,
+}) => {
+    const revenue = inflowTransactions.reduce((acc, x) => {
+        acc[x.Date.getMonth()] += x.Inflow;
+        return acc;
+    }, new Array(12).fill(0));
+
+    const inflow = inflowTransactions.reduce((acc, x) => acc + x.Inflow, 0);
+    const outflow = outflowTransactions.reduce((acc, x) => acc + x.Outflow, 0);
+
+    const delta25Sum = new Array(12).fill(0);
+    for (let i = 1; i < 12; i += 1) {
+        delta25Sum[i] = delta25Sum[i - 1] + revenue[i] * 0.25;
+    }
+
+    const delta50Sum = new Array(12).fill(0);
+    for (let i = 1; i < 12; i += 1) {
+        delta50Sum[i] = delta50Sum[i - 1] + revenue[i] * 0.5;
+    }
+
+    const delta75Sum = new Array(12).fill(0);
+    for (let i = 1; i < 12; i += 1) {
+        delta75Sum[i] = delta75Sum[i - 1] + revenue[i] * 0.75;
+    }
+
+    return (
+        <section>
+            <fieldset>
+                <legend>Filtering</legend>
+                <div className="form-group">
+                    <label htmlFor="savings-view">Savings View</label>
+                    <input name="savings-view" type="text" placeholder="Savings View..." value={savingsView}
+                        onChange={e => onSavingsViewChange(e.target.value)} />
+                    <button className="btn btn-default" onClick={() => onFilterSavingsView()}>Apply</button>
+                </div>
+                <div className="form-group">
+                    <label htmlFor="inflow-query">Inflow Query</label>
+                    <input name="inflow-query" type="text" placeholder="Inflow query..." value={inflowQuery}
+                        onChange={e => onInflowQueryChange(e.target.value)} />
+                    <button className="btn btn-default" onClick={() => onFilterInflow()}>Filter</button>
+                </div>
+                <div className="form-group">
+                    <label htmlFor="outflow-query">Outflow Query</label>
+                    <input name="outflow-query" type="text" placeholder="Outflow query..." value={outflowQuery}
+                        onChange={e => onOutflowQueryChange(e.target.value)} />
+                    <button className="btn btn-default" onClick={() => onFilterOutflow()}>Filter</button>
+                </div>
+            </fieldset>
+            <ul>
+                <li>inflow: {dollars(inflow, sensitive)}</li>
+                <li>outflow: {dollars(outflow)}</li>
+                <li>savings: {dollars(inflow - outflow)}</li>
+                <li>rate: {parseFloat((inflow - outflow) / inflow * 100).toFixed(2) + "%"} ({classifyRate((inflow - outflow) / inflow)})</li>
+            </ul>
+            <SavingsRateLineChart rate={(inflow - outflow) / inflow} transactions={outflowTransactions} />
+            {/* O($1,000) */}
+            <StackedHistogram labels={months} datasets={Object.keys(categories).map(k => ({
+                label: k,
+                data: categories[k].reduce((acc, x) => {
+                    acc[x.Date.getMonth()] += x.Outflow;
+                    return acc;
+                }, new Array(12).fill(0)).map((x, i) => ({
+                    x: i,
+                    y: x,
+                }))
+            }))} />
+            {/* O($100) */}
+            <StackedHistogram labels={months} datasets={Object.keys(categories).map(k => ({
+                label: k,
+                data: categories[k].reduce((acc, x) => {
+                    acc[x.Date.getMonth()] += x.Outflow;
+                    return acc;
+                }, new Array(12).fill(0)).map((x, i) => ({
+                    x: i,
+                    y: x,
+                }))
+            }))} />
+            {/* O($10) */}
+            <StackedHistogram labels={months} datasets={Object.keys(categories).map(k => ({
+                label: k,
+                data: categories[k].reduce((acc, x) => {
+                    acc[x.Date.getMonth()] += x.Outflow;
+                    return acc;
+                }, new Array(12).fill(0)).map((x, i) => ({
+                    x: i,
+                    y: x,
+                }))
+            }))} />
+            {/* <GenLineChart x="X" y="Y" datasets={[
+                {
+                    label: '25%',
+                    data: delta25Sum.map((x, i) => ({
+                        x: i,
+                        y: x,
+                    })),
+                },
+                {
+                    label: '50%',
+                    data: delta50Sum.map((x, i) => ({
+                        x: i,
+                        y: x,
+                    })),
+                },
+                {
+                    label: '75%',
+                    data: delta75Sum.map((x, i) => ({
+                        x: i,
+                        y: x,
+                    })),
+                },
+            ]} /> */}
+            <hr />
+            <table>
+                <thead>
+                    <tr>
+                        <th>Month</th>
+                        <th>Delta (25%)</th>
+                        <th>Delta (50%)</th>
+                        <th>Delta (75%)</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    {months.map((x, i) => (
+                        <tr key={i}>
+                            <td>{x}</td>
+                            <td>{dollars(delta25Sum[i], sensitive)}</td>
+                            <td>{dollars(delta50Sum[i], sensitive)}</td>
+                            <td>{dollars(delta75Sum[i], sensitive)}</td>
+                        </tr>
+                    ))}
+                </tbody>
+            </table>
+        </section>
+    );
+};
+
+const Calculator = ({ sensitive, saved, onClear }) => (
+    <div>
+        <ul>
+            {Object.keys(saved).map(k => (
+                <li key={k}>
+                    {dollars(saved[k], sensitive)} {k}
+                </li>
+            ))}
+        </ul>
+        <p>{dollars(savedSum, sensitive)}</p>
+        <button className="btn btn-default" onClick={() => onClear()}>clear</button>
+    </div>
+);
+
+/**
+ * Table rendering information about transactions bucketed by its order of
+ * magnitude.
+ */
+const Magnitable = ({ sensitive, label, transactions }) => {
+    const categories = transactions.reduce((acc, x) => {
+        if (x.Category === '') {
+            return acc;
+        }
+        if (!(x.Category in acc)) {
+            acc[x.Category] = 0;
+        }
+        acc[x.Category] += x.Outflow;
+        return acc;
+    }, {});
+
+    // Sort category keys by sum decreasing.
+    const keys = [...Object.keys(categories)].sort((x, y) => {
+        if (categories[x] < categories[y]) {
+            return 1;
+        } else if (categories[x] > categories[y]) {
+            return -1;
+        } else {
+            return 0;
+        }
+    });
+
+    return (
+        <React.Fragment>
+            {keys.map(k => (
+                <tr style={{ backgroundColor: '#F0F8FF' }}>
+                    <td>{k}</td><td>{dollars(categories[k], sensitive)}</td>
+                </tr>
+            ))}
+        </React.Fragment>
+    );
+};
+
+/**
+ * Calculates and renders various aggregates over an input list of transactions.
+ */
+const AggregateTable = ({ sensitive, focus, onFocus, transactions }) => {
+    const net = transactions.reduce((acc, x) => acc + x.Inflow - x.Outflow, 0);
+    const sum = transactions.reduce((acc, x) => acc + x.Outflow, 0);
+    const buckets = transactions.reduce((acc, x) => {
+        const order = magnitude(x.Outflow);
+        const bucket = Math.pow(10, order);
+        acc[bucket].push(x);
+        return acc;
+    }, { 0.1: [], 0: [], 1: [], 10: [], 100: [], 1000: [] });
+
+    return (
+        <div>
+            <table>
+                <caption>Aggregations</caption>
+                <thead>
+                    <tr>
+                        <th>function</th>
+                        <th>value</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    <tr><td>net</td><td>{dollars(net, sensitive)}</td></tr>
+                    <tr onClick={() => onFocus('sum')}><td>sum</td><td>{dollars(sum, sensitive)}</td></tr>
+                    {focus.sum && <Magnitable sensitive={sensitive} label="sum" transactions={transactions} />}
+                    <tr><td>per day</td><td>{dollars(sum / 365, sensitive)}</td></tr>
+                    <tr><td>per week</td><td>{dollars(sum / 52, sensitive)}</td></tr>
+                    <tr><td>per month</td><td>{dollars(sum / 12, sensitive)}</td></tr>
+                    <tr onClick={() => onFocus(1000)}><td>Σ Θ($1,000)</td><td>{dollars(buckets[1000].reduce((acc, x) => acc + x.Outflow, 0), sensitive)}</td></tr>
+                    {(focus[1000]) && <Magnitable sensitive={sensitive} label="$1,000" transactions={buckets[1000]} />}
+                    <tr onClick={() => onFocus(100)}><td>Σ Θ($100)</td><td>{dollars(buckets[100].reduce((acc, x, sensitive) => acc + x.Outflow, 0), sensitive)}</td></tr>
+                    {(focus[100]) && <Magnitable sensitive={sensitive} label="$100" transactions={buckets[100]} />}
+                    <tr onClick={() => onFocus(10)}><td>Σ Θ($10)</td><td>{dollars(buckets[10].reduce((acc, x) => acc + x.Outflow, 0), sensitive)}</td></tr>
+                    {(focus[10]) && <Magnitable sensitive={sensitive} label="$10" transactions={buckets[10]} />}
+                    <tr onClick={() => onFocus(1)}><td>Σ Θ($1)</td><td>{dollars(buckets[1].reduce((acc, x) => acc + x.Outflow, 0), sensitive)}</td></tr>
+                    {(focus[1]) && <Magnitable sensitive={sensitive} label="$1.00" transactions={buckets[1]} />}
+                    <tr onClick={() => onFocus(0.1)}><td>Σ Θ($0.10)</td><td>{dollars(buckets[0.1].reduce((acc, x) => acc + x.Outflow, 0), sensitive)}</td></tr>
+                    {(focus[0.1]) && <Magnitable sensitive={sensitive} label="$0.10" transactions={buckets[0.1]} />}
+                    <tr><td>average</td><td>{dollars(sum / transactions.length, sensitive)}</td></tr>
+                    <tr><td>count</td><td>{transactions.length}</td></tr>
+                </tbody>
+            </table>
+        </div>
+    );
+};
+
+const Query = ({ query, onChange, onFilter }) => (
+    <fieldset>
+        <legend>Query</legend>
+        <div className="form-group">
+            <input name="query" type="text" value={query} onChange={e => onChange(e.target.value)} />
+        </div>
+        <div className="form-group">
+            <button className="btn btn-default" onClick={() => onFilter()}>Filter</button>
+        </div>
+    </fieldset>
+);
+
+const tableHeaders = [
+    'Account',
+    'Category',
+    'Date',
+    'Inflow',
+    'Outflow',
+    'Payee',
+    'Memo',
+];
+
+const Transactions = ({ sensitive, transactions, onSort, onClick, onViewChange, transactionsView }) => {
+    let view = null;
+    if (transactionsView === 'table') {
+        view = (
+            <table>
+                <thead>
+                    <tr>
+                        {tableHeaders.map(x => <th>{x}</th>)}
+                    </tr>
+                </thead>
+                <tbody>
+                    {transactions.map(x => (
+                        <tr onClick={() => onClick(x)}>
+                            <td>{x.Account}</td>
+                            <td>{x.Category}</td>
+                            <td>{x.Date.toLocaleDateString()}</td>
+                            <td>{dollars(x.Inflow, sensitive)}</td>
+                            <td>{dollars(x.Outflow, sensitive)}</td>
+                            <td>{x.Payee}</td>
+                            <td>{x.Memo}</td>
+                        </tr>
+                    ))}
+                </tbody>
+            </table>
+        );
+    } 
+    else if (transactionsView === 'csv') {
+        view = (
+            <code>{tableHeaders.join(',') + '\n' + transactions.map(x => tableHeaders.map(k => x[k]).join(',')).join("\n")}</code>
+        );
+    }
+    else if (transactionsView === 'json') {
+        view = (
+            <code>{JSON.stringify(transactions)}</code>
+        );
+    }
+
+    return (
+        <div>
+            <caption>Transactions</caption>
+            <div className="btn-group">
+                <button onClick={() => onViewChange('table')} className="btn btn-default btn-ghost">Table</button>
+                <button onClick={() => onViewChange('csv')} className="btn btn-default btn-ghost">CSV</button>
+                <button onClick={() => onViewChange('json')} className="btn btn-default btn-ghost">JSON</button>
+            </div>
+            {view}
+        </div>
+    );
+}
+
+const domContainer = document.querySelector('#mount');
+const root = ReactDOM.createRoot(domContainer);
+
+root.render(<App />);
diff --git a/users/wpcarro/ynabsql/dataviz/index.html b/users/wpcarro/ynabsql/dataviz/index.html
index 823ffdc58b..2fdf8866cf 100644
--- a/users/wpcarro/ynabsql/dataviz/index.html
+++ b/users/wpcarro/ynabsql/dataviz/index.html
@@ -2,24 +2,28 @@
 <html lang="en">
   <head>
     <meta charset="UTF-8" />
-    <link rel="stylesheet" href="https://unpkg.com/terminal.css@0.7.2/dist/terminal.min.css" />
-    <link rel="stylesheet" href="https://unpkg.com/terminal.css@0.7.1/dist/terminal.min.css" />
-    <link rel="stylesheet" href="https://unpkg.com/terminal.css@0.7.1/dist/terminal.min.css" />
+    <!-- TODO(wpcarro): Cache these locally -->
+    <link rel="stylesheet" href="./cdn/terminal.min.css" />
+    <style>
+      :root {
+        --page-width: 100em;
+      }
+    </style>
   </head>
-  <body>
-    <div id="react-mount"></div>
-    <!-- <canvas id="mount"></canvas> -->
+  <body class="container">
+    <div id="mount"></div>
 
     <!-- chart.js -->
-    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
+    <script src="./cdn/chart.js"></script>
+    <script src="./cdn/date_fns.js"></script>
+    <script src="./cdn/chartjs-adapter-date-fns.bundle.min.js"></script>
     <!-- react.js -->
-    <script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
-    <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
-    <script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>
+    <script src="./cdn/react.development.js" crossorigin></script>
+    <script src="./cdn/react-dom.development.js" crossorigin></script>
+    <script src="./cdn/babel.min.js"></script>
     <!-- depot JS -->
-    <script src="http://localhost:8002/index.js"></script>
+    <script src="./cdn/slx.js"></script>
     <script src="./data.js"></script>
-    <script src="./index.js"></script>
     <script src="./components.js" type="text/babel"></script>
   </body>
 </html>
diff --git a/users/wpcarro/ynabsql/dataviz/index.js b/users/wpcarro/ynabsql/dataviz/index.js
deleted file mode 100644
index 4a6aaa5d1b..0000000000
--- a/users/wpcarro/ynabsql/dataviz/index.js
+++ /dev/null
@@ -1,72 +0,0 @@
-const colors = {
-  red: 'rgb(255, 99, 132)',
-  orange: 'rgb(255, 159, 64)',
-  yellow: 'rgb(255, 205, 86)',
-  green: 'rgb(75, 192, 192)',
-  blue: 'rgb(54, 162, 235)',
-  purple: 'rgb(153, 102, 255)',
-  grey: 'rgb(201, 203, 207)'
-};
-
-function randomExpense() {
-  // 10/1000     expenses are O(1,000)
-  // 100/2000    expenses are O(100)
-  // 1,000/2000  expenses are O(10)
-  // 10,000/2000 expenses are O(1)
-  const r = Math.random();
-
-  if (r <= 0.02) {
-    return Math.floor(Math.random() * 5000);
-  } else if (r <= 0.1) {
-    return Math.floor(Math.random() * 1000);
-  } else if (r <= 0.5) {
-    return Math.floor(Math.random() * 100);
-  } else {
-    return Math.floor(Math.random() * 10);
-  }
-}
-
-// Browser starts to choke around 10,000 data points.
-function generateData() {
-  return Array(2000).fill(0).map(x => ({
-    // select a random day [0, 365]
-    x: Math.floor(Math.random() * 365),
-    // select a random USD amount in the range [1, 5,000]
-    y: randomExpense(),
-    // TODO(wpcarro): Attach transaction to `metadata` key.
-    metadata: { foo: 'bar' },
-  }));
-}
-
-////////////////////////////////////////////////////////////////////////////////
-// Main
-////////////////////////////////////////////////////////////////////////////////
-
-const mount = document.getElementById('mount');
-
-new Chart(mount, {
-  type: 'scatter',
-  data: {
-    datasets: [
-      {
-        label: 'Expenses',
-        data: generateData(),
-        backgroundColor: colors.red,
-      }
-    ],
-  },
-  options: {
-    plugins: {
-      tooltip: {
-        callbacks: {
-          title: function(x) {
-            return `$${x[0].raw.y}`;
-          },
-          label: function(x) {
-            return JSON.stringify(x.raw.metadata);
-          },
-        },
-      },
-    },
-  },
-})
diff --git a/users/wpcarro/ynabsql/dataviz/package.json b/users/wpcarro/ynabsql/dataviz/package.json
new file mode 100644
index 0000000000..03f795c0bf
--- /dev/null
+++ b/users/wpcarro/ynabsql/dataviz/package.json
@@ -0,0 +1,15 @@
+{
+  "devDependencies": {
+    "@parcel/validator-typescript": "^2.8.3",
+    "parcel": "^2.8.3",
+    "process": "^0.11.10",
+    "typescript": ">=3.0.0"
+  },
+  "dependencies": {
+    "chart.js": "^4.2.0",
+    "chartjs-adapter-date-fns": "^3.0.0",
+    "date-fns": "^2.29.3",
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
+  }
+}
diff --git a/users/wpcarro/ynabsql/dataviz/yarn.lock b/users/wpcarro/ynabsql/dataviz/yarn.lock
new file mode 100644
index 0000000000..70c52a3a9f
--- /dev/null
+++ b/users/wpcarro/ynabsql/dataviz/yarn.lock
@@ -0,0 +1,1540 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+"@babel/code-frame@^7.0.0":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a"
+  integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==
+  dependencies:
+    "@babel/highlight" "^7.18.6"
+
+"@babel/helper-validator-identifier@^7.18.6":
+  version "7.19.1"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz#7eea834cf32901ffdc1a7ee555e2f9c27e249ca2"
+  integrity sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==
+
+"@babel/highlight@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.18.6.tgz#81158601e93e2563795adcbfbdf5d64be3f2ecdf"
+  integrity sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==
+  dependencies:
+    "@babel/helper-validator-identifier" "^7.18.6"
+    chalk "^2.0.0"
+    js-tokens "^4.0.0"
+
+"@jridgewell/gen-mapping@^0.3.0":
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz#c1aedc61e853f2bb9f5dfe6d4442d3b565b253b9"
+  integrity sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==
+  dependencies:
+    "@jridgewell/set-array" "^1.0.1"
+    "@jridgewell/sourcemap-codec" "^1.4.10"
+    "@jridgewell/trace-mapping" "^0.3.9"
+
+"@jridgewell/resolve-uri@3.1.0":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78"
+  integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==
+
+"@jridgewell/set-array@^1.0.1":
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72"
+  integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==
+
+"@jridgewell/source-map@^0.3.2":
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.2.tgz#f45351aaed4527a298512ec72f81040c998580fb"
+  integrity sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==
+  dependencies:
+    "@jridgewell/gen-mapping" "^0.3.0"
+    "@jridgewell/trace-mapping" "^0.3.9"
+
+"@jridgewell/sourcemap-codec@1.4.14", "@jridgewell/sourcemap-codec@^1.4.10":
+  version "1.4.14"
+  resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24"
+  integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==
+
+"@jridgewell/trace-mapping@^0.3.9":
+  version "0.3.17"
+  resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz#793041277af9073b0951a7fe0f0d8c4c98c36985"
+  integrity sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==
+  dependencies:
+    "@jridgewell/resolve-uri" "3.1.0"
+    "@jridgewell/sourcemap-codec" "1.4.14"
+
+"@kurkle/color@^0.3.0":
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/@kurkle/color/-/color-0.3.2.tgz#5acd38242e8bde4f9986e7913c8fdf49d3aa199f"
+  integrity sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==
+
+"@lezer/common@^0.15.0", "@lezer/common@^0.15.7":
+  version "0.15.12"
+  resolved "https://registry.yarnpkg.com/@lezer/common/-/common-0.15.12.tgz#2f21aec551dd5fd7d24eb069f90f54d5bc6ee5e9"
+  integrity sha512-edfwCxNLnzq5pBA/yaIhwJ3U3Kz8VAUOTRg0hhxaizaI1N+qxV7EXDv/kLCkLeq2RzSFvxexlaj5Mzfn2kY0Ig==
+
+"@lezer/lr@^0.15.4":
+  version "0.15.8"
+  resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-0.15.8.tgz#1564a911e62b0a0f75ca63794a6aa8c5dc63db21"
+  integrity sha512-bM6oE6VQZ6hIFxDNKk8bKPa14hqFrV07J/vHGOeiAbJReIaQXmkVb6xQu4MR+JBTLa5arGRyAAjJe1qaQt3Uvg==
+  dependencies:
+    "@lezer/common" "^0.15.0"
+
+"@lmdb/lmdb-darwin-arm64@2.5.2":
+  version "2.5.2"
+  resolved "https://registry.yarnpkg.com/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-2.5.2.tgz#bc66fa43286b5c082e8fee0eacc17995806b6fbe"
+  integrity sha512-+F8ioQIUN68B4UFiIBYu0QQvgb9FmlKw2ctQMSBfW2QBrZIxz9vD9jCGqTCPqZBRbPHAS/vG1zSXnKqnS2ch/A==
+
+"@lmdb/lmdb-darwin-x64@2.5.2":
+  version "2.5.2"
+  resolved "https://registry.yarnpkg.com/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-2.5.2.tgz#89d8390041bce6bab24a82a20392be22faf54ffc"
+  integrity sha512-KvPH56KRLLx4KSfKBx0m1r7GGGUMXm0jrKmNE7plbHlesZMuPJICtn07HYgQhj1LNsK7Yqwuvnqh1QxhJnF1EA==
+
+"@lmdb/lmdb-linux-arm64@2.5.2":
+  version "2.5.2"
+  resolved "https://registry.yarnpkg.com/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-2.5.2.tgz#14fe4c96c2bb1285f93797f45915fa35ee047268"
+  integrity sha512-aLl89VHL/wjhievEOlPocoefUyWdvzVrcQ/MHQYZm2JfV1jUsrbr/ZfkPPUFvZBf+VSE+Q0clWs9l29PCX1hTQ==
+
+"@lmdb/lmdb-linux-arm@2.5.2":
+  version "2.5.2"
+  resolved "https://registry.yarnpkg.com/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-2.5.2.tgz#05bde4573ab10cf21827339fe687148f2590cfa1"
+  integrity sha512-5kQAP21hAkfW5Bl+e0P57dV4dGYnkNIpR7f/GAh6QHlgXx+vp/teVj4PGRZaKAvt0GX6++N6hF8NnGElLDuIDw==
+
+"@lmdb/lmdb-linux-x64@2.5.2":
+  version "2.5.2"
+  resolved "https://registry.yarnpkg.com/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-2.5.2.tgz#d2f85afd857d2c33d2caa5b057944574edafcfee"
+  integrity sha512-xUdUfwDJLGjOUPH3BuPBt0NlIrR7f/QHKgu3GZIXswMMIihAekj2i97oI0iWG5Bok/b+OBjHPfa8IU9velnP/Q==
+
+"@lmdb/lmdb-win32-x64@2.5.2":
+  version "2.5.2"
+  resolved "https://registry.yarnpkg.com/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-2.5.2.tgz#28f643fbc0bec30b07fbe95b137879b6b4d1c9c5"
+  integrity sha512-zrBczSbXKxEyK2ijtbRdICDygRqWSRPpZMN5dD1T8VMEW5RIhIbwFWw2phDRXuBQdVDpSjalCIUMWMV2h3JaZA==
+
+"@mischnic/json-sourcemap@^0.1.0":
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/@mischnic/json-sourcemap/-/json-sourcemap-0.1.0.tgz#38af657be4108140a548638267d02a2ea3336507"
+  integrity sha512-dQb3QnfNqmQNYA4nFSN/uLaByIic58gOXq4Y4XqLOWmOrw73KmJPt/HLyG0wvn1bnR6mBKs/Uwvkh+Hns1T0XA==
+  dependencies:
+    "@lezer/common" "^0.15.7"
+    "@lezer/lr" "^0.15.4"
+    json5 "^2.2.1"
+
+"@msgpackr-extract/msgpackr-extract-darwin-arm64@2.2.0":
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-2.2.0.tgz#901c5937e1441572ea23e631fe6deca68482fe76"
+  integrity sha512-Z9LFPzfoJi4mflGWV+rv7o7ZbMU5oAU9VmzCgL240KnqDW65Y2HFCT3MW06/ITJSnbVLacmcEJA8phywK7JinQ==
+
+"@msgpackr-extract/msgpackr-extract-darwin-x64@2.2.0":
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-2.2.0.tgz#fb877fe6bae3c4d3cea29786737840e2ae689066"
+  integrity sha512-vq0tT8sjZsy4JdSqmadWVw6f66UXqUCabLmUVHZwUFzMgtgoIIQjT4VVRHKvlof3P/dMCkbMJ5hB1oJ9OWHaaw==
+
+"@msgpackr-extract/msgpackr-extract-linux-arm64@2.2.0":
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-2.2.0.tgz#986179c38b10ac41fbdaf7d036c825cbc72855d9"
+  integrity sha512-hlxxLdRmPyq16QCutUtP8Tm6RDWcyaLsRssaHROatgnkOxdleMTgetf9JsdncL8vLh7FVy/RN9i3XR5dnb9cRA==
+
+"@msgpackr-extract/msgpackr-extract-linux-arm@2.2.0":
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-2.2.0.tgz#15f2c6fe9e0adc06c21af7e95f484ff4880d79ce"
+  integrity sha512-SaJ3Qq4lX9Syd2xEo9u3qPxi/OB+5JO/ngJKK97XDpa1C587H9EWYO6KD8995DAjSinWvdHKRrCOXVUC5fvGOg==
+
+"@msgpackr-extract/msgpackr-extract-linux-x64@2.2.0":
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-2.2.0.tgz#30cae5c9a202f3e1fa1deb3191b18ffcb2f239a2"
+  integrity sha512-94y5PJrSOqUNcFKmOl7z319FelCLAE0rz/jPCWS+UtdMZvpa4jrQd+cJPQCLp2Fes1yAW/YUQj/Di6YVT3c3Iw==
+
+"@msgpackr-extract/msgpackr-extract-win32-x64@2.2.0":
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-2.2.0.tgz#016d855b6bc459fd908095811f6826e45dd4ba64"
+  integrity sha512-XrC0JzsqQSvOyM3t04FMLO6z5gCuhPE6k4FXuLK5xf52ZbdvcFe1yBmo7meCew9B8G2f0T9iu9t3kfTYRYROgA==
+
+"@parcel/bundler-default@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/bundler-default/-/bundler-default-2.8.3.tgz#d64739dbc2dbd59d6629861bf77a8083aced5229"
+  integrity sha512-yJvRsNWWu5fVydsWk3O2L4yIy3UZiKWO2cPDukGOIWMgp/Vbpp+2Ct5IygVRtE22bnseW/E/oe0PV3d2IkEJGg==
+  dependencies:
+    "@parcel/diagnostic" "2.8.3"
+    "@parcel/graph" "2.8.3"
+    "@parcel/hash" "2.8.3"
+    "@parcel/plugin" "2.8.3"
+    "@parcel/utils" "2.8.3"
+    nullthrows "^1.1.1"
+
+"@parcel/cache@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/cache/-/cache-2.8.3.tgz#169e130cf59913c0ed9fadce1a450e68f710e16f"
+  integrity sha512-k7xv5vSQrJLdXuglo+Hv3yF4BCSs1tQ/8Vbd6CHTkOhf7LcGg6CPtLw053R/KdMpd/4GPn0QrAsOLdATm1ELtQ==
+  dependencies:
+    "@parcel/fs" "2.8.3"
+    "@parcel/logger" "2.8.3"
+    "@parcel/utils" "2.8.3"
+    lmdb "2.5.2"
+
+"@parcel/codeframe@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/codeframe/-/codeframe-2.8.3.tgz#84fb529ef70def7f5bc64f6c59b18d24826f5fcc"
+  integrity sha512-FE7sY53D6n/+2Pgg6M9iuEC6F5fvmyBkRE4d9VdnOoxhTXtkEqpqYgX7RJ12FAQwNlxKq4suBJQMgQHMF2Kjeg==
+  dependencies:
+    chalk "^4.1.0"
+
+"@parcel/compressor-raw@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/compressor-raw/-/compressor-raw-2.8.3.tgz#301753df8c6de967553149639e8a4179b88f0c95"
+  integrity sha512-bVDsqleBUxRdKMakWSlWC9ZjOcqDKE60BE+Gh3JSN6WJrycJ02P5wxjTVF4CStNP/G7X17U+nkENxSlMG77ySg==
+  dependencies:
+    "@parcel/plugin" "2.8.3"
+
+"@parcel/config-default@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/config-default/-/config-default-2.8.3.tgz#9a43486e7c702e96c68052c37b79098d7240e35b"
+  integrity sha512-o/A/mbrO6X/BfGS65Sib8d6SSG45NYrNooNBkH/o7zbOBSRQxwyTlysleK1/3Wa35YpvFyLOwgfakqCtbGy4fw==
+  dependencies:
+    "@parcel/bundler-default" "2.8.3"
+    "@parcel/compressor-raw" "2.8.3"
+    "@parcel/namer-default" "2.8.3"
+    "@parcel/optimizer-css" "2.8.3"
+    "@parcel/optimizer-htmlnano" "2.8.3"
+    "@parcel/optimizer-image" "2.8.3"
+    "@parcel/optimizer-svgo" "2.8.3"
+    "@parcel/optimizer-terser" "2.8.3"
+    "@parcel/packager-css" "2.8.3"
+    "@parcel/packager-html" "2.8.3"
+    "@parcel/packager-js" "2.8.3"
+    "@parcel/packager-raw" "2.8.3"
+    "@parcel/packager-svg" "2.8.3"
+    "@parcel/reporter-dev-server" "2.8.3"
+    "@parcel/resolver-default" "2.8.3"
+    "@parcel/runtime-browser-hmr" "2.8.3"
+    "@parcel/runtime-js" "2.8.3"
+    "@parcel/runtime-react-refresh" "2.8.3"
+    "@parcel/runtime-service-worker" "2.8.3"
+    "@parcel/transformer-babel" "2.8.3"
+    "@parcel/transformer-css" "2.8.3"
+    "@parcel/transformer-html" "2.8.3"
+    "@parcel/transformer-image" "2.8.3"
+    "@parcel/transformer-js" "2.8.3"
+    "@parcel/transformer-json" "2.8.3"
+    "@parcel/transformer-postcss" "2.8.3"
+    "@parcel/transformer-posthtml" "2.8.3"
+    "@parcel/transformer-raw" "2.8.3"
+    "@parcel/transformer-react-refresh-wrap" "2.8.3"
+    "@parcel/transformer-svg" "2.8.3"
+
+"@parcel/core@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/core/-/core-2.8.3.tgz#22a69f36095d53736ab10bf42697d9aa5f4e382b"
+  integrity sha512-Euf/un4ZAiClnlUXqPB9phQlKbveU+2CotZv7m7i+qkgvFn5nAGnrV4h1OzQU42j9dpgOxWi7AttUDMrvkbhCQ==
+  dependencies:
+    "@mischnic/json-sourcemap" "^0.1.0"
+    "@parcel/cache" "2.8.3"
+    "@parcel/diagnostic" "2.8.3"
+    "@parcel/events" "2.8.3"
+    "@parcel/fs" "2.8.3"
+    "@parcel/graph" "2.8.3"
+    "@parcel/hash" "2.8.3"
+    "@parcel/logger" "2.8.3"
+    "@parcel/package-manager" "2.8.3"
+    "@parcel/plugin" "2.8.3"
+    "@parcel/source-map" "^2.1.1"
+    "@parcel/types" "2.8.3"
+    "@parcel/utils" "2.8.3"
+    "@parcel/workers" "2.8.3"
+    abortcontroller-polyfill "^1.1.9"
+    base-x "^3.0.8"
+    browserslist "^4.6.6"
+    clone "^2.1.1"
+    dotenv "^7.0.0"
+    dotenv-expand "^5.1.0"
+    json5 "^2.2.0"
+    msgpackr "^1.5.4"
+    nullthrows "^1.1.1"
+    semver "^5.7.1"
+
+"@parcel/diagnostic@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/diagnostic/-/diagnostic-2.8.3.tgz#d560276d5d2804b48beafa1feaf3fc6b2ac5e39d"
+  integrity sha512-u7wSzuMhLGWZjVNYJZq/SOViS3uFG0xwIcqXw12w54Uozd6BH8JlhVtVyAsq9kqnn7YFkw6pXHqAo5Tzh4FqsQ==
+  dependencies:
+    "@mischnic/json-sourcemap" "^0.1.0"
+    nullthrows "^1.1.1"
+
+"@parcel/events@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/events/-/events-2.8.3.tgz#205f8d874e6ecc2cbdb941bf8d54bae669e571af"
+  integrity sha512-hoIS4tAxWp8FJk3628bsgKxEvR7bq2scCVYHSqZ4fTi/s0+VymEATrRCUqf+12e5H47uw1/ZjoqrGtBI02pz4w==
+
+"@parcel/fs-search@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/fs-search/-/fs-search-2.8.3.tgz#1c7d812c110b808758f44c56e61dfffdb09e9451"
+  integrity sha512-DJBT2N8knfN7Na6PP2mett3spQLTqxFrvl0gv+TJRp61T8Ljc4VuUTb0hqBj+belaASIp3Q+e8+SgaFQu7wLiQ==
+  dependencies:
+    detect-libc "^1.0.3"
+
+"@parcel/fs@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/fs/-/fs-2.8.3.tgz#80536afe877fc8a2bd26be5576b9ba27bb4c5754"
+  integrity sha512-y+i+oXbT7lP0e0pJZi/YSm1vg0LDsbycFuHZIL80pNwdEppUAtibfJZCp606B7HOjMAlNZOBo48e3hPG3d8jgQ==
+  dependencies:
+    "@parcel/fs-search" "2.8.3"
+    "@parcel/types" "2.8.3"
+    "@parcel/utils" "2.8.3"
+    "@parcel/watcher" "^2.0.7"
+    "@parcel/workers" "2.8.3"
+
+"@parcel/graph@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/graph/-/graph-2.8.3.tgz#00ffe8ec032e74fee57199e54529f1da7322571d"
+  integrity sha512-26GL8fYZPdsRhSXCZ0ZWliloK6DHlMJPWh6Z+3VVZ5mnDSbYg/rRKWmrkhnr99ZWmL9rJsv4G74ZwvDEXTMPBg==
+  dependencies:
+    nullthrows "^1.1.1"
+
+"@parcel/hash@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/hash/-/hash-2.8.3.tgz#bc2499a27395169616cad2a99e19e69b9098f6e9"
+  integrity sha512-FVItqzjWmnyP4ZsVgX+G00+6U2IzOvqDtdwQIWisCcVoXJFCqZJDy6oa2qDDFz96xCCCynjRjPdQx2jYBCpfYw==
+  dependencies:
+    detect-libc "^1.0.3"
+    xxhash-wasm "^0.4.2"
+
+"@parcel/logger@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/logger/-/logger-2.8.3.tgz#e14e4debafb3ca9e87c07c06780f9afc38b2712c"
+  integrity sha512-Kpxd3O/Vs7nYJIzkdmB6Bvp3l/85ydIxaZaPfGSGTYOfaffSOTkhcW9l6WemsxUrlts4za6CaEWcc4DOvaMOPA==
+  dependencies:
+    "@parcel/diagnostic" "2.8.3"
+    "@parcel/events" "2.8.3"
+
+"@parcel/markdown-ansi@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/markdown-ansi/-/markdown-ansi-2.8.3.tgz#1337d421bb1133ad178f386a8e1b746631bba4a1"
+  integrity sha512-4v+pjyoh9f5zuU/gJlNvNFGEAb6J90sOBwpKJYJhdWXLZMNFCVzSigxrYO+vCsi8G4rl6/B2c0LcwIMjGPHmFQ==
+  dependencies:
+    chalk "^4.1.0"
+
+"@parcel/namer-default@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/namer-default/-/namer-default-2.8.3.tgz#5304bee74beb4b9c1880781bdbe35be0656372f4"
+  integrity sha512-tJ7JehZviS5QwnxbARd8Uh63rkikZdZs1QOyivUhEvhN+DddSAVEdQLHGPzkl3YRk0tjFhbqo+Jci7TpezuAMw==
+  dependencies:
+    "@parcel/diagnostic" "2.8.3"
+    "@parcel/plugin" "2.8.3"
+    nullthrows "^1.1.1"
+
+"@parcel/node-resolver-core@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/node-resolver-core/-/node-resolver-core-2.8.3.tgz#581df074a27646400b3fed9da95297b616a7db8f"
+  integrity sha512-12YryWcA5Iw2WNoEVr/t2HDjYR1iEzbjEcxfh1vaVDdZ020PiGw67g5hyIE/tsnG7SRJ0xdRx1fQ2hDgED+0Ww==
+  dependencies:
+    "@parcel/diagnostic" "2.8.3"
+    "@parcel/utils" "2.8.3"
+    nullthrows "^1.1.1"
+    semver "^5.7.1"
+
+"@parcel/optimizer-css@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/optimizer-css/-/optimizer-css-2.8.3.tgz#420a333f4b78f7ff15e69217dfed34421b1143ee"
+  integrity sha512-JotGAWo8JhuXsQDK0UkzeQB0UR5hDAKvAviXrjqB4KM9wZNLhLleeEAW4Hk8R9smCeQFP6Xg/N/NkLDpqMwT3g==
+  dependencies:
+    "@parcel/diagnostic" "2.8.3"
+    "@parcel/plugin" "2.8.3"
+    "@parcel/source-map" "^2.1.1"
+    "@parcel/utils" "2.8.3"
+    browserslist "^4.6.6"
+    lightningcss "^1.16.1"
+    nullthrows "^1.1.1"
+
+"@parcel/optimizer-htmlnano@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/optimizer-htmlnano/-/optimizer-htmlnano-2.8.3.tgz#a71ab6f0f24160ef9f573266064438eff65e96d0"
+  integrity sha512-L8/fHbEy8Id2a2E0fwR5eKGlv9VYDjrH9PwdJE9Za9v1O/vEsfl/0T/79/x129l5O0yB6EFQkFa20MiK3b+vOg==
+  dependencies:
+    "@parcel/plugin" "2.8.3"
+    htmlnano "^2.0.0"
+    nullthrows "^1.1.1"
+    posthtml "^0.16.5"
+    svgo "^2.4.0"
+
+"@parcel/optimizer-image@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/optimizer-image/-/optimizer-image-2.8.3.tgz#ea49b4245b4f7d60b38c7585c6311fb21d341baa"
+  integrity sha512-SD71sSH27SkCDNUNx9A3jizqB/WIJr3dsfp+JZGZC42tpD/Siim6Rqy9M4To/BpMMQIIiEXa5ofwS+DgTEiEHQ==
+  dependencies:
+    "@parcel/diagnostic" "2.8.3"
+    "@parcel/plugin" "2.8.3"
+    "@parcel/utils" "2.8.3"
+    "@parcel/workers" "2.8.3"
+    detect-libc "^1.0.3"
+
+"@parcel/optimizer-svgo@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/optimizer-svgo/-/optimizer-svgo-2.8.3.tgz#04da4efec6b623679539a84961bff6998034ba8a"
+  integrity sha512-9KQed99NZnQw3/W4qBYVQ7212rzA9EqrQG019TIWJzkA9tjGBMIm2c/nXpK1tc3hQ3e7KkXkFCQ3C+ibVUnHNA==
+  dependencies:
+    "@parcel/diagnostic" "2.8.3"
+    "@parcel/plugin" "2.8.3"
+    "@parcel/utils" "2.8.3"
+    svgo "^2.4.0"
+
+"@parcel/optimizer-terser@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/optimizer-terser/-/optimizer-terser-2.8.3.tgz#3a06d98d09386a1a0ae1be85376a8739bfba9618"
+  integrity sha512-9EeQlN6zIeUWwzrzu6Q2pQSaYsYGah8MtiQ/hog9KEPlYTP60hBv/+utDyYEHSQhL7y5ym08tPX5GzBvwAD/dA==
+  dependencies:
+    "@parcel/diagnostic" "2.8.3"
+    "@parcel/plugin" "2.8.3"
+    "@parcel/source-map" "^2.1.1"
+    "@parcel/utils" "2.8.3"
+    nullthrows "^1.1.1"
+    terser "^5.2.0"
+
+"@parcel/package-manager@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/package-manager/-/package-manager-2.8.3.tgz#ddd0d62feae3cf0fb6cc0537791b3a16296ad458"
+  integrity sha512-tIpY5pD2lH53p9hpi++GsODy6V3khSTX4pLEGuMpeSYbHthnOViobqIlFLsjni+QA1pfc8NNNIQwSNdGjYflVA==
+  dependencies:
+    "@parcel/diagnostic" "2.8.3"
+    "@parcel/fs" "2.8.3"
+    "@parcel/logger" "2.8.3"
+    "@parcel/types" "2.8.3"
+    "@parcel/utils" "2.8.3"
+    "@parcel/workers" "2.8.3"
+    semver "^5.7.1"
+
+"@parcel/packager-css@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/packager-css/-/packager-css-2.8.3.tgz#0eff34268cb4f5dfb53c1bbca85f5567aeb1835a"
+  integrity sha512-WyvkMmsurlHG8d8oUVm7S+D+cC/T3qGeqogb7sTI52gB6uiywU7lRCizLNqGFyFGIxcVTVHWnSHqItBcLN76lA==
+  dependencies:
+    "@parcel/plugin" "2.8.3"
+    "@parcel/source-map" "^2.1.1"
+    "@parcel/utils" "2.8.3"
+    nullthrows "^1.1.1"
+
+"@parcel/packager-html@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/packager-html/-/packager-html-2.8.3.tgz#f9263b891aa4dd46c6e2fa2b07025a482132fff1"
+  integrity sha512-OhPu1Hx1RRKJodpiu86ZqL8el2Aa4uhBHF6RAL1Pcrh2EhRRlPf70Sk0tC22zUpYL7es+iNKZ/n0Rl+OWSHWEw==
+  dependencies:
+    "@parcel/plugin" "2.8.3"
+    "@parcel/types" "2.8.3"
+    "@parcel/utils" "2.8.3"
+    nullthrows "^1.1.1"
+    posthtml "^0.16.5"
+
+"@parcel/packager-js@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/packager-js/-/packager-js-2.8.3.tgz#3ed11565915d73d12192b6901c75a6b820e4a83a"
+  integrity sha512-0pGKC3Ax5vFuxuZCRB+nBucRfFRz4ioie19BbDxYnvBxrd4M3FIu45njf6zbBYsI9eXqaDnL1b3DcZJfYqtIzw==
+  dependencies:
+    "@parcel/diagnostic" "2.8.3"
+    "@parcel/hash" "2.8.3"
+    "@parcel/plugin" "2.8.3"
+    "@parcel/source-map" "^2.1.1"
+    "@parcel/utils" "2.8.3"
+    globals "^13.2.0"
+    nullthrows "^1.1.1"
+
+"@parcel/packager-raw@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/packager-raw/-/packager-raw-2.8.3.tgz#bdec826df991e186cb58691cc45d12ad5c06676e"
+  integrity sha512-BA6enNQo1RCnco9MhkxGrjOk59O71IZ9DPKu3lCtqqYEVd823tXff2clDKHK25i6cChmeHu6oB1Rb73hlPqhUA==
+  dependencies:
+    "@parcel/plugin" "2.8.3"
+
+"@parcel/packager-svg@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/packager-svg/-/packager-svg-2.8.3.tgz#7233315296001c531cb55ca96b5f2ef672343630"
+  integrity sha512-mvIoHpmv5yzl36OjrklTDFShLUfPFTwrmp1eIwiszGdEBuQaX7JVI3Oo2jbVQgcN4W7J6SENzGQ3Q5hPTW3pMw==
+  dependencies:
+    "@parcel/plugin" "2.8.3"
+    "@parcel/types" "2.8.3"
+    "@parcel/utils" "2.8.3"
+    posthtml "^0.16.4"
+
+"@parcel/plugin@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/plugin/-/plugin-2.8.3.tgz#7bb30a5775eaa6473c27f002a0a3ee7308d6d669"
+  integrity sha512-jZ6mnsS4D9X9GaNnvrixDQwlUQJCohDX2hGyM0U0bY2NWU8Km97SjtoCpWjq+XBCx/gpC4g58+fk9VQeZq2vlw==
+  dependencies:
+    "@parcel/types" "2.8.3"
+
+"@parcel/reporter-cli@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/reporter-cli/-/reporter-cli-2.8.3.tgz#12a4743b51b8fe6837f53c20e01bbf1f7336e8e4"
+  integrity sha512-3sJkS6tFFzgIOz3u3IpD/RsmRxvOKKiQHOTkiiqRt1l44mMDGKS7zANRnJYsQzdCsgwc9SOP30XFgJwtoVlMbw==
+  dependencies:
+    "@parcel/plugin" "2.8.3"
+    "@parcel/types" "2.8.3"
+    "@parcel/utils" "2.8.3"
+    chalk "^4.1.0"
+    term-size "^2.2.1"
+
+"@parcel/reporter-dev-server@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/reporter-dev-server/-/reporter-dev-server-2.8.3.tgz#a0daa5cc015642684cea561f4e0e7116bbffdc1c"
+  integrity sha512-Y8C8hzgzTd13IoWTj+COYXEyCkXfmVJs3//GDBsH22pbtSFMuzAZd+8J9qsCo0EWpiDow7V9f1LischvEh3FbQ==
+  dependencies:
+    "@parcel/plugin" "2.8.3"
+    "@parcel/utils" "2.8.3"
+
+"@parcel/resolver-default@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/resolver-default/-/resolver-default-2.8.3.tgz#5ae41e537ae4a793c1abb47f094482b9e2ac3535"
+  integrity sha512-k0B5M/PJ+3rFbNj4xZSBr6d6HVIe6DH/P3dClLcgBYSXAvElNDfXgtIimbjCyItFkW9/BfcgOVKEEIZOeySH/A==
+  dependencies:
+    "@parcel/node-resolver-core" "2.8.3"
+    "@parcel/plugin" "2.8.3"
+
+"@parcel/runtime-browser-hmr@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/runtime-browser-hmr/-/runtime-browser-hmr-2.8.3.tgz#1fa74e1fbd1030b0a920c58afa3a9eb7dc4bcd1e"
+  integrity sha512-2O1PYi2j/Q0lTyGNV3JdBYwg4rKo6TEVFlYGdd5wCYU9ZIN9RRuoCnWWH2qCPj3pjIVtBeppYxzfVjPEHINWVg==
+  dependencies:
+    "@parcel/plugin" "2.8.3"
+    "@parcel/utils" "2.8.3"
+
+"@parcel/runtime-js@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/runtime-js/-/runtime-js-2.8.3.tgz#0baa4c8fbf77eabce05d01ccc186614968ffc0cd"
+  integrity sha512-IRja0vNKwvMtPgIqkBQh0QtRn0XcxNC8HU1jrgWGRckzu10qJWO+5ULgtOeR4pv9krffmMPqywGXw6l/gvJKYQ==
+  dependencies:
+    "@parcel/plugin" "2.8.3"
+    "@parcel/utils" "2.8.3"
+    nullthrows "^1.1.1"
+
+"@parcel/runtime-react-refresh@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/runtime-react-refresh/-/runtime-react-refresh-2.8.3.tgz#381a942fb81e8f5ac6c7e0ee1b91dbf34763c3f8"
+  integrity sha512-2v/qFKp00MfG0234OdOgQNAo6TLENpFYZMbVbAsPMY9ITiqG73MrEsrGXVoGbYiGTMB/Toer/lSWlJxtacOCuA==
+  dependencies:
+    "@parcel/plugin" "2.8.3"
+    "@parcel/utils" "2.8.3"
+    react-error-overlay "6.0.9"
+    react-refresh "^0.9.0"
+
+"@parcel/runtime-service-worker@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/runtime-service-worker/-/runtime-service-worker-2.8.3.tgz#54d92da9ff1dfbd27db0e84164a22fa59e99b348"
+  integrity sha512-/Skkw+EeRiwzOJso5fQtK8c9b452uWLNhQH1ISTodbmlcyB4YalAiSsyHCtMYD0c3/t5Sx4ZS7vxBAtQd0RvOw==
+  dependencies:
+    "@parcel/plugin" "2.8.3"
+    "@parcel/utils" "2.8.3"
+    nullthrows "^1.1.1"
+
+"@parcel/source-map@^2.1.1":
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/@parcel/source-map/-/source-map-2.1.1.tgz#fb193b82dba6dd62cc7a76b326f57bb35000a782"
+  integrity sha512-Ejx1P/mj+kMjQb8/y5XxDUn4reGdr+WyKYloBljpppUy8gs42T+BNoEOuRYqDVdgPc6NxduzIDoJS9pOFfV5Ew==
+  dependencies:
+    detect-libc "^1.0.3"
+
+"@parcel/transformer-babel@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/transformer-babel/-/transformer-babel-2.8.3.tgz#286bc6cb9afe4c0259f0b28e0f2f47322a24b130"
+  integrity sha512-L6lExfpvvC7T/g3pxf3CIJRouQl+sgrSzuWQ0fD4PemUDHvHchSP4SNUVnd6gOytF3Y1KpnEZIunQGi5xVqQCQ==
+  dependencies:
+    "@parcel/diagnostic" "2.8.3"
+    "@parcel/plugin" "2.8.3"
+    "@parcel/source-map" "^2.1.1"
+    "@parcel/utils" "2.8.3"
+    browserslist "^4.6.6"
+    json5 "^2.2.0"
+    nullthrows "^1.1.1"
+    semver "^5.7.0"
+
+"@parcel/transformer-css@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/transformer-css/-/transformer-css-2.8.3.tgz#d6c44100204e73841ad8e0f90472172ea8b9120c"
+  integrity sha512-xTqFwlSXtnaYen9ivAgz+xPW7yRl/u4QxtnDyDpz5dr8gSeOpQYRcjkd4RsYzKsWzZcGtB5EofEk8ayUbWKEUg==
+  dependencies:
+    "@parcel/diagnostic" "2.8.3"
+    "@parcel/plugin" "2.8.3"
+    "@parcel/source-map" "^2.1.1"
+    "@parcel/utils" "2.8.3"
+    browserslist "^4.6.6"
+    lightningcss "^1.16.1"
+    nullthrows "^1.1.1"
+
+"@parcel/transformer-html@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/transformer-html/-/transformer-html-2.8.3.tgz#5c68b28ee6b8c7a13b8aee87f7957ad3227bd83f"
+  integrity sha512-kIZO3qsMYTbSnSpl9cnZog+SwL517ffWH54JeB410OSAYF1ouf4n5v9qBnALZbuCCmPwJRGs4jUtE452hxwN4g==
+  dependencies:
+    "@parcel/diagnostic" "2.8.3"
+    "@parcel/hash" "2.8.3"
+    "@parcel/plugin" "2.8.3"
+    nullthrows "^1.1.1"
+    posthtml "^0.16.5"
+    posthtml-parser "^0.10.1"
+    posthtml-render "^3.0.0"
+    semver "^5.7.1"
+    srcset "4"
+
+"@parcel/transformer-image@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/transformer-image/-/transformer-image-2.8.3.tgz#73805b2bfc3c8919d7737544e5f8be39e3f303fe"
+  integrity sha512-cO4uptcCGTi5H6bvTrAWEFUsTNhA4kCo8BSvRSCHA2sf/4C5tGQPHt3JhdO0GQLPwZRCh/R41EkJs5HZ8A8DAg==
+  dependencies:
+    "@parcel/plugin" "2.8.3"
+    "@parcel/utils" "2.8.3"
+    "@parcel/workers" "2.8.3"
+    nullthrows "^1.1.1"
+
+"@parcel/transformer-js@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/transformer-js/-/transformer-js-2.8.3.tgz#fe400df428394d1e7fe5afb6dea5c7c858e44f03"
+  integrity sha512-9Qd6bib+sWRcpovvzvxwy/PdFrLUXGfmSW9XcVVG8pvgXsZPFaNjnNT8stzGQj1pQiougCoxMY4aTM5p1lGHEQ==
+  dependencies:
+    "@parcel/diagnostic" "2.8.3"
+    "@parcel/plugin" "2.8.3"
+    "@parcel/source-map" "^2.1.1"
+    "@parcel/utils" "2.8.3"
+    "@parcel/workers" "2.8.3"
+    "@swc/helpers" "^0.4.12"
+    browserslist "^4.6.6"
+    detect-libc "^1.0.3"
+    nullthrows "^1.1.1"
+    regenerator-runtime "^0.13.7"
+    semver "^5.7.1"
+
+"@parcel/transformer-json@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/transformer-json/-/transformer-json-2.8.3.tgz#25deb3a5138cc70a83269fc5d39d564609354d36"
+  integrity sha512-B7LmVq5Q7bZO4ERb6NHtRuUKWGysEeaj9H4zelnyBv+wLgpo4f5FCxSE1/rTNmP9u1qHvQ3scGdK6EdSSokGPg==
+  dependencies:
+    "@parcel/plugin" "2.8.3"
+    json5 "^2.2.0"
+
+"@parcel/transformer-postcss@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/transformer-postcss/-/transformer-postcss-2.8.3.tgz#df4fdc1c90893823445f2a8eb8e2bdd0349ccc58"
+  integrity sha512-e8luB/poIlz6jBsD1Izms+6ElbyzuoFVa4lFVLZnTAChI3UxPdt9p/uTsIO46HyBps/Bk8ocvt3J4YF84jzmvg==
+  dependencies:
+    "@parcel/diagnostic" "2.8.3"
+    "@parcel/hash" "2.8.3"
+    "@parcel/plugin" "2.8.3"
+    "@parcel/utils" "2.8.3"
+    clone "^2.1.1"
+    nullthrows "^1.1.1"
+    postcss-value-parser "^4.2.0"
+    semver "^5.7.1"
+
+"@parcel/transformer-posthtml@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/transformer-posthtml/-/transformer-posthtml-2.8.3.tgz#7c3912a5a631cb26485f6464e0d6eeabb6f1e718"
+  integrity sha512-pkzf9Smyeaw4uaRLsT41RGrPLT5Aip8ZPcntawAfIo+KivBQUV0erY1IvHYjyfFzq1ld/Fo2Ith9He6mxpPifA==
+  dependencies:
+    "@parcel/plugin" "2.8.3"
+    "@parcel/utils" "2.8.3"
+    nullthrows "^1.1.1"
+    posthtml "^0.16.5"
+    posthtml-parser "^0.10.1"
+    posthtml-render "^3.0.0"
+    semver "^5.7.1"
+
+"@parcel/transformer-raw@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/transformer-raw/-/transformer-raw-2.8.3.tgz#3a22213fe18a5f83fd78889cb49f06e059cfead7"
+  integrity sha512-G+5cXnd2/1O3nV/pgRxVKZY/HcGSseuhAe71gQdSQftb8uJEURyUHoQ9Eh0JUD3MgWh9V+nIKoyFEZdf9T0sUQ==
+  dependencies:
+    "@parcel/plugin" "2.8.3"
+
+"@parcel/transformer-react-refresh-wrap@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/transformer-react-refresh-wrap/-/transformer-react-refresh-wrap-2.8.3.tgz#8b0392638405dd470a886002229f7889d5464822"
+  integrity sha512-q8AAoEvBnCf/nPvgOwFwKZfEl/thwq7c2duxXkhl+tTLDRN2vGmyz4355IxCkavSX+pLWSQ5MexklSEeMkgthg==
+  dependencies:
+    "@parcel/plugin" "2.8.3"
+    "@parcel/utils" "2.8.3"
+    react-refresh "^0.9.0"
+
+"@parcel/transformer-svg@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/transformer-svg/-/transformer-svg-2.8.3.tgz#4df959cba4ebf45d7aaddd540f752e6e84df38b2"
+  integrity sha512-3Zr/gBzxi1ZH1fftH/+KsZU7w5GqkmxlB0ZM8ovS5E/Pl1lq1t0xvGJue9m2VuQqP8Mxfpl5qLFmsKlhaZdMIQ==
+  dependencies:
+    "@parcel/diagnostic" "2.8.3"
+    "@parcel/hash" "2.8.3"
+    "@parcel/plugin" "2.8.3"
+    nullthrows "^1.1.1"
+    posthtml "^0.16.5"
+    posthtml-parser "^0.10.1"
+    posthtml-render "^3.0.0"
+    semver "^5.7.1"
+
+"@parcel/ts-utils@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/ts-utils/-/ts-utils-2.8.3.tgz#f3590ca033c061779dc35ff3d14af2860ed106ac"
+  integrity sha512-4HMt9B9LF2pDFvSKGImho48tlCvCUl7ly1ZMXvQdmEq2i0yoS81tDsmxX3yly/RVUVeUCGAj1JRuuy1lw5zw1A==
+  dependencies:
+    nullthrows "^1.1.1"
+
+"@parcel/types@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/types/-/types-2.8.3.tgz#3306bc5391b6913bd619914894b8cd84a24b30fa"
+  integrity sha512-FECA1FB7+0UpITKU0D6TgGBpGxYpVSMNEENZbSJxFSajNy3wrko+zwBKQmFOLOiPcEtnGikxNs+jkFWbPlUAtw==
+  dependencies:
+    "@parcel/cache" "2.8.3"
+    "@parcel/diagnostic" "2.8.3"
+    "@parcel/fs" "2.8.3"
+    "@parcel/package-manager" "2.8.3"
+    "@parcel/source-map" "^2.1.1"
+    "@parcel/workers" "2.8.3"
+    utility-types "^3.10.0"
+
+"@parcel/utils@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/utils/-/utils-2.8.3.tgz#0d56c9e8e22c119590a5e044a0e01031965da40e"
+  integrity sha512-IhVrmNiJ+LOKHcCivG5dnuLGjhPYxQ/IzbnF2DKNQXWBTsYlHkJZpmz7THoeLtLliGmSOZ3ZCsbR8/tJJKmxjA==
+  dependencies:
+    "@parcel/codeframe" "2.8.3"
+    "@parcel/diagnostic" "2.8.3"
+    "@parcel/hash" "2.8.3"
+    "@parcel/logger" "2.8.3"
+    "@parcel/markdown-ansi" "2.8.3"
+    "@parcel/source-map" "^2.1.1"
+    chalk "^4.1.0"
+
+"@parcel/validator-typescript@^2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/validator-typescript/-/validator-typescript-2.8.3.tgz#6f9cb5b48df302b1d65e9b17dc1a20870e746976"
+  integrity sha512-2UYGCAwrxh7HIGcrXl8Vu9Sisd8vAu/6Jp/oJV5n9ZQuT5O9pQAlK2lZGSocYRucBtmb4WajII2S2GTzUZeEuQ==
+  dependencies:
+    "@parcel/diagnostic" "2.8.3"
+    "@parcel/plugin" "2.8.3"
+    "@parcel/ts-utils" "2.8.3"
+    "@parcel/types" "2.8.3"
+    "@parcel/utils" "2.8.3"
+
+"@parcel/watcher@^2.0.7":
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.1.0.tgz#5f32969362db4893922c526a842d8af7a8538545"
+  integrity sha512-8s8yYjd19pDSsBpbkOHnT6Z2+UJSuLQx61pCFM0s5wSRvKCEMDjd/cHY3/GI1szHIWbpXpsJdg3V6ISGGx9xDw==
+  dependencies:
+    is-glob "^4.0.3"
+    micromatch "^4.0.5"
+    node-addon-api "^3.2.1"
+    node-gyp-build "^4.3.0"
+
+"@parcel/workers@2.8.3":
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/@parcel/workers/-/workers-2.8.3.tgz#255450ccf4db234082407e4ddda5fd575f08c235"
+  integrity sha512-+AxBnKgjqVpUHBcHLWIHcjYgKIvHIpZjN33mG5LG9XXvrZiqdWvouEzqEXlVLq5VzzVbKIQQcmsvRy138YErkg==
+  dependencies:
+    "@parcel/diagnostic" "2.8.3"
+    "@parcel/logger" "2.8.3"
+    "@parcel/types" "2.8.3"
+    "@parcel/utils" "2.8.3"
+    chrome-trace-event "^1.0.2"
+    nullthrows "^1.1.1"
+
+"@swc/helpers@^0.4.12":
+  version "0.4.14"
+  resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.14.tgz#1352ac6d95e3617ccb7c1498ff019654f1e12a74"
+  integrity sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw==
+  dependencies:
+    tslib "^2.4.0"
+
+"@trysound/sax@0.2.0":
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad"
+  integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==
+
+"@types/parse-json@^4.0.0":
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
+  integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==
+
+abortcontroller-polyfill@^1.1.9:
+  version "1.7.5"
+  resolved "https://registry.yarnpkg.com/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.5.tgz#6738495f4e901fbb57b6c0611d0c75f76c485bed"
+  integrity sha512-JMJ5soJWP18htbbxJjG7bG6yuI6pRhgJ0scHHTfkUjf6wjP912xZWvM+A4sJK3gqd9E8fcPbDnOefbA9Th/FIQ==
+
+acorn@^8.5.0:
+  version "8.8.1"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.1.tgz#0a3f9cbecc4ec3bea6f0a80b66ae8dd2da250b73"
+  integrity sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==
+
+ansi-styles@^3.2.1:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
+  integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==
+  dependencies:
+    color-convert "^1.9.0"
+
+ansi-styles@^4.1.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
+  integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
+  dependencies:
+    color-convert "^2.0.1"
+
+base-x@^3.0.8:
+  version "3.0.9"
+  resolved "https://registry.yarnpkg.com/base-x/-/base-x-3.0.9.tgz#6349aaabb58526332de9f60995e548a53fe21320"
+  integrity sha512-H7JU6iBHTal1gp56aKoaa//YUxEaAOUiydvrV/pILqIHXTtqxSkATOnDA2u+jZ/61sD+L/412+7kzXRtWukhpQ==
+  dependencies:
+    safe-buffer "^5.0.1"
+
+boolbase@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
+  integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==
+
+braces@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
+  integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
+  dependencies:
+    fill-range "^7.0.1"
+
+browserslist@^4.6.6:
+  version "4.21.4"
+  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.4.tgz#e7496bbc67b9e39dd0f98565feccdcb0d4ff6987"
+  integrity sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw==
+  dependencies:
+    caniuse-lite "^1.0.30001400"
+    electron-to-chromium "^1.4.251"
+    node-releases "^2.0.6"
+    update-browserslist-db "^1.0.9"
+
+buffer-from@^1.0.0:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
+  integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
+
+callsites@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73"
+  integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==
+
+caniuse-lite@^1.0.30001400:
+  version "1.0.30001446"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001446.tgz#6d4ba828ab19f49f9bcd14a8430d30feebf1e0c5"
+  integrity sha512-fEoga4PrImGcwUUGEol/PoFCSBnSkA9drgdkxXkJLsUBOnJ8rs3zDv6ApqYXGQFOyMPsjh79naWhF4DAxbF8rw==
+
+chalk@^2.0.0:
+  version "2.4.2"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
+  integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
+  dependencies:
+    ansi-styles "^3.2.1"
+    escape-string-regexp "^1.0.5"
+    supports-color "^5.3.0"
+
+chalk@^4.1.0:
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
+  integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
+  dependencies:
+    ansi-styles "^4.1.0"
+    supports-color "^7.1.0"
+
+chart.js@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.2.0.tgz#dd281b2ce890bff32f3e249cf2972a1e74bc032c"
+  integrity sha512-wbtcV+QKeH0F7gQZaCJEIpsNriFheacouJQTVIjITi3eQA8bTlIBoknz0+dgV79aeKLNMAX+nDslIVE/nJ3rzA==
+  dependencies:
+    "@kurkle/color" "^0.3.0"
+
+chartjs-adapter-date-fns@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz#c25f63c7f317c1f96f9a7c44bd45eeedb8a478e5"
+  integrity sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==
+
+chrome-trace-event@^1.0.2:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz#1015eced4741e15d06664a957dbbf50d041e26ac"
+  integrity sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==
+
+clone@^2.1.1:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
+  integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==
+
+color-convert@^1.9.0:
+  version "1.9.3"
+  resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
+  integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
+  dependencies:
+    color-name "1.1.3"
+
+color-convert@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
+  integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==
+  dependencies:
+    color-name "~1.1.4"
+
+color-name@1.1.3:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
+  integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
+
+color-name@~1.1.4:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
+  integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
+
+commander@^2.20.0:
+  version "2.20.3"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
+  integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
+
+commander@^7.0.0, commander@^7.2.0:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
+  integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==
+
+cosmiconfig@^7.0.1:
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz#1443b9afa596b670082ea46cbd8f6a62b84635f6"
+  integrity sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==
+  dependencies:
+    "@types/parse-json" "^4.0.0"
+    import-fresh "^3.2.1"
+    parse-json "^5.0.0"
+    path-type "^4.0.0"
+    yaml "^1.10.0"
+
+css-select@^4.1.3:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.3.0.tgz#db7129b2846662fd8628cfc496abb2b59e41529b"
+  integrity sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==
+  dependencies:
+    boolbase "^1.0.0"
+    css-what "^6.0.1"
+    domhandler "^4.3.1"
+    domutils "^2.8.0"
+    nth-check "^2.0.1"
+
+css-tree@^1.1.2, css-tree@^1.1.3:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d"
+  integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==
+  dependencies:
+    mdn-data "2.0.14"
+    source-map "^0.6.1"
+
+css-what@^6.0.1:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4"
+  integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==
+
+csso@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/csso/-/csso-4.2.0.tgz#ea3a561346e8dc9f546d6febedd50187cf389529"
+  integrity sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==
+  dependencies:
+    css-tree "^1.1.2"
+
+date-fns@^2.29.3:
+  version "2.29.3"
+  resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.3.tgz#27402d2fc67eb442b511b70bbdf98e6411cd68a8"
+  integrity sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==
+
+detect-libc@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
+  integrity sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==
+
+dom-serializer@^1.0.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30"
+  integrity sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==
+  dependencies:
+    domelementtype "^2.0.1"
+    domhandler "^4.2.0"
+    entities "^2.0.0"
+
+domelementtype@^2.0.1, domelementtype@^2.2.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d"
+  integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==
+
+domhandler@^4.2.0, domhandler@^4.2.2, domhandler@^4.3.1:
+  version "4.3.1"
+  resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c"
+  integrity sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==
+  dependencies:
+    domelementtype "^2.2.0"
+
+domutils@^2.8.0:
+  version "2.8.0"
+  resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135"
+  integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==
+  dependencies:
+    dom-serializer "^1.0.1"
+    domelementtype "^2.2.0"
+    domhandler "^4.2.0"
+
+dotenv-expand@^5.1.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-5.1.0.tgz#3fbaf020bfd794884072ea26b1e9791d45a629f0"
+  integrity sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==
+
+dotenv@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-7.0.0.tgz#a2be3cd52736673206e8a85fb5210eea29628e7c"
+  integrity sha512-M3NhsLbV1i6HuGzBUH8vXrtxOk+tWmzWKDMbAVSUp3Zsjm7ywFeuwrUXhmhQyRK1q5B5GGy7hcXPbj3bnfZg2g==
+
+electron-to-chromium@^1.4.251:
+  version "1.4.284"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.284.tgz#61046d1e4cab3a25238f6bf7413795270f125592"
+  integrity sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==
+
+entities@^2.0.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55"
+  integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==
+
+entities@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/entities/-/entities-3.0.1.tgz#2b887ca62585e96db3903482d336c1006c3001d4"
+  integrity sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==
+
+error-ex@^1.3.1:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
+  integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==
+  dependencies:
+    is-arrayish "^0.2.1"
+
+escalade@^3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
+  integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==
+
+escape-string-regexp@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
+  integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==
+
+fill-range@^7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
+  integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
+  dependencies:
+    to-regex-range "^5.0.1"
+
+get-port@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/get-port/-/get-port-4.2.0.tgz#e37368b1e863b7629c43c5a323625f95cf24b119"
+  integrity sha512-/b3jarXkH8KJoOMQc3uVGHASwGLPq3gSFJ7tgJm2diza+bydJPTGOibin2steecKeOylE8oY2JERlVWkAJO6yw==
+
+globals@^13.2.0:
+  version "13.19.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-13.19.0.tgz#7a42de8e6ad4f7242fbcca27ea5b23aca367b5c8"
+  integrity sha512-dkQ957uSRWHw7CFXLUtUHQI3g3aWApYhfNR2O6jn/907riyTYKVBmxYVROkBcY614FSSeSJh7Xm7SrUWCxvJMQ==
+  dependencies:
+    type-fest "^0.20.2"
+
+has-flag@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
+  integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==
+
+has-flag@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
+  integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
+
+htmlnano@^2.0.0:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/htmlnano/-/htmlnano-2.0.3.tgz#50ee639ed63357d4a6c01309f52a35892e4edc2e"
+  integrity sha512-S4PGGj9RbdgW8LhbILNK7W9JhmYP8zmDY7KDV/8eCiJBQJlbmltp5I0gv8c5ntLljfdxxfmJ+UJVSqyH4mb41A==
+  dependencies:
+    cosmiconfig "^7.0.1"
+    posthtml "^0.16.5"
+    timsort "^0.3.0"
+
+htmlparser2@^7.1.1:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-7.2.0.tgz#8817cdea38bbc324392a90b1990908e81a65f5a5"
+  integrity sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog==
+  dependencies:
+    domelementtype "^2.0.1"
+    domhandler "^4.2.2"
+    domutils "^2.8.0"
+    entities "^3.0.1"
+
+import-fresh@^3.2.1:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
+  integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==
+  dependencies:
+    parent-module "^1.0.0"
+    resolve-from "^4.0.0"
+
+is-arrayish@^0.2.1:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
+  integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==
+
+is-extglob@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
+  integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
+
+is-glob@^4.0.3:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
+  integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
+  dependencies:
+    is-extglob "^2.1.1"
+
+is-json@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/is-json/-/is-json-2.0.1.tgz#6be166d144828a131d686891b983df62c39491ff"
+  integrity sha512-6BEnpVn1rcf3ngfmViLM6vjUjGErbdrL4rwlv+u1NO1XO8kqT4YGL8+19Q+Z/bas8tY90BTWMk2+fW1g6hQjbA==
+
+is-number@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
+  integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
+
+"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
+  integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
+
+json-parse-even-better-errors@^2.3.0:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d"
+  integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==
+
+json5@^2.2.0, json5@^2.2.1:
+  version "2.2.3"
+  resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
+  integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
+
+lightningcss-darwin-arm64@1.18.0:
+  version "1.18.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.18.0.tgz#bcd7d494d99c69947abd71136a42e80dfa80c682"
+  integrity sha512-OqjydwtiNPgdH1ByIjA1YzqvDG/OMR6L3LPN6wRl1729LB0y4Mik7L06kmZaTb+pvUHr+NmDd2KCwnlrQ4zO3w==
+
+lightningcss-darwin-x64@1.18.0:
+  version "1.18.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.18.0.tgz#952abea2405fe2bb8dd0bb57a9d5590f8d1d6414"
+  integrity sha512-mNiuPHj89/JHZmJMp+5H8EZSt6EL5DZRWJ31O6k3DrLLnRIQjXuXdDdN8kP7LoIkeWI5xvyD60CsReJm+YWYAw==
+
+lightningcss-linux-arm-gnueabihf@1.18.0:
+  version "1.18.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.18.0.tgz#23ca85e05dc4def9b4975aef307554ef292b56cd"
+  integrity sha512-S+25JjI6601HiAVoTDXW6SqH+E94a+FHA7WQqseyNHunOgVWKcAkNEc2LJvVxgwTq6z41sDIb9/M3Z9wa9lk4A==
+
+lightningcss-linux-arm64-gnu@1.18.0:
+  version "1.18.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.18.0.tgz#6c8e0a6e2c8b44cf180f3a0f0740402e8f656155"
+  integrity sha512-JSqh4+21dCgBecIQUet35dtE4PhhSEMyqe3y0ZNQrAJQ5kyUPSQHiw81WXnPJcOSTTpG0TyMLiC8K//+BsFGQA==
+
+lightningcss-linux-arm64-musl@1.18.0:
+  version "1.18.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.18.0.tgz#88393c101cf236ea0cdc97fddd66b82db964d835"
+  integrity sha512-2FWHa8iUhShnZnqhn2wfIcK5adJat9hAAaX7etNsoXJymlliDIOFuBQEsba2KBAZSM4QqfQtvRdR7m8i0I7ybQ==
+
+lightningcss-linux-x64-gnu@1.18.0:
+  version "1.18.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.18.0.tgz#ad068d24836568337bfe545650565e13f813c8ee"
+  integrity sha512-plCPGQJtDZHcLVKVRLnQVF2XRsIC32WvuJhQ7fJ7F6BV98b/VZX0OlX05qUaOESD9dCDHjYSfxsgcvOKgCWh7A==
+
+lightningcss-linux-x64-musl@1.18.0:
+  version "1.18.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.18.0.tgz#4d84de26b8185aa42450e0f4c83bbfb5a36ae750"
+  integrity sha512-na+BGtVU6fpZvOHKhnlA0XHeibkT3/46nj6vLluG3kzdJYoBKU6dIl7DSOk++8jv4ybZyFJ0aOFMMSc8g2h58A==
+
+lightningcss-win32-x64-msvc@1.18.0:
+  version "1.18.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.18.0.tgz#f83952d16b83dfce65f4615f87c867769220d117"
+  integrity sha512-5qeAH4RMNy2yMNEl7e5TI6upt/7xD2ZpHWH4RkT8iJ7/6POS5mjHbXWUO9Q1hhDhqkdzGa76uAdMzEouIeCyNw==
+
+lightningcss@^1.16.1:
+  version "1.18.0"
+  resolved "https://registry.yarnpkg.com/lightningcss/-/lightningcss-1.18.0.tgz#ca3327a1a7571a83bbb9733ed4e4cded775bdadf"
+  integrity sha512-uk10tNxi5fhZqU93vtYiQgx/8a9f0Kvtj5AXIm+VlOXY+t/DWDmCZWJEkZJmmALgvbS6aAW8or+Kq85eJ6TDTw==
+  dependencies:
+    detect-libc "^1.0.3"
+  optionalDependencies:
+    lightningcss-darwin-arm64 "1.18.0"
+    lightningcss-darwin-x64 "1.18.0"
+    lightningcss-linux-arm-gnueabihf "1.18.0"
+    lightningcss-linux-arm64-gnu "1.18.0"
+    lightningcss-linux-arm64-musl "1.18.0"
+    lightningcss-linux-x64-gnu "1.18.0"
+    lightningcss-linux-x64-musl "1.18.0"
+    lightningcss-win32-x64-msvc "1.18.0"
+
+lines-and-columns@^1.1.6:
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
+  integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
+
+lmdb@2.5.2:
+  version "2.5.2"
+  resolved "https://registry.yarnpkg.com/lmdb/-/lmdb-2.5.2.tgz#37e28a9fb43405f4dc48c44cec0e13a14c4a6ff1"
+  integrity sha512-V5V5Xa2Hp9i2XsbDALkBTeHXnBXh/lEmk9p22zdr7jtuOIY9TGhjK6vAvTpOOx9IKU4hJkRWZxn/HsvR1ELLtA==
+  dependencies:
+    msgpackr "^1.5.4"
+    node-addon-api "^4.3.0"
+    node-gyp-build-optional-packages "5.0.3"
+    ordered-binary "^1.2.4"
+    weak-lru-cache "^1.2.2"
+  optionalDependencies:
+    "@lmdb/lmdb-darwin-arm64" "2.5.2"
+    "@lmdb/lmdb-darwin-x64" "2.5.2"
+    "@lmdb/lmdb-linux-arm" "2.5.2"
+    "@lmdb/lmdb-linux-arm64" "2.5.2"
+    "@lmdb/lmdb-linux-x64" "2.5.2"
+    "@lmdb/lmdb-win32-x64" "2.5.2"
+
+loose-envify@^1.1.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
+  integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
+  dependencies:
+    js-tokens "^3.0.0 || ^4.0.0"
+
+mdn-data@2.0.14:
+  version "2.0.14"
+  resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50"
+  integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==
+
+micromatch@^4.0.5:
+  version "4.0.5"
+  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
+  integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==
+  dependencies:
+    braces "^3.0.2"
+    picomatch "^2.3.1"
+
+msgpackr-extract@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/msgpackr-extract/-/msgpackr-extract-2.2.0.tgz#4bb749b58d9764cfdc0d91c7977a007b08e8f262"
+  integrity sha512-0YcvWSv7ZOGl9Od6Y5iJ3XnPww8O7WLcpYMDwX+PAA/uXLDtyw94PJv9GLQV/nnp3cWlDhMoyKZIQLrx33sWog==
+  dependencies:
+    node-gyp-build-optional-packages "5.0.3"
+  optionalDependencies:
+    "@msgpackr-extract/msgpackr-extract-darwin-arm64" "2.2.0"
+    "@msgpackr-extract/msgpackr-extract-darwin-x64" "2.2.0"
+    "@msgpackr-extract/msgpackr-extract-linux-arm" "2.2.0"
+    "@msgpackr-extract/msgpackr-extract-linux-arm64" "2.2.0"
+    "@msgpackr-extract/msgpackr-extract-linux-x64" "2.2.0"
+    "@msgpackr-extract/msgpackr-extract-win32-x64" "2.2.0"
+
+msgpackr@^1.5.4:
+  version "1.8.1"
+  resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-1.8.1.tgz#2298aed8a14f83e99df77d344cbda3e436f29b5b"
+  integrity sha512-05fT4J8ZqjYlR4QcRDIhLCYKUOHXk7C/xa62GzMKj74l3up9k2QZ3LgFc6qWdsPHl91QA2WLWqWc8b8t7GLNNw==
+  optionalDependencies:
+    msgpackr-extract "^2.2.0"
+
+node-addon-api@^3.2.1:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161"
+  integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==
+
+node-addon-api@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.3.0.tgz#52a1a0b475193e0928e98e0426a0d1254782b77f"
+  integrity sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==
+
+node-gyp-build-optional-packages@5.0.3:
+  version "5.0.3"
+  resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.3.tgz#92a89d400352c44ad3975010368072b41ad66c17"
+  integrity sha512-k75jcVzk5wnnc/FMxsf4udAoTEUv2jY3ycfdSd3yWu6Cnd1oee6/CfZJApyscA4FJOmdoixWwiwOyf16RzD5JA==
+
+node-gyp-build@^4.3.0:
+  version "4.6.0"
+  resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.6.0.tgz#0c52e4cbf54bbd28b709820ef7b6a3c2d6209055"
+  integrity sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ==
+
+node-releases@^2.0.6:
+  version "2.0.8"
+  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.8.tgz#0f349cdc8fcfa39a92ac0be9bc48b7706292b9ae"
+  integrity sha512-dFSmB8fFHEH/s81Xi+Y/15DQY6VHW81nXRj86EMSL3lmuTmK1e+aT4wrFCkTbm+gSwkw4KpX+rT/pMM2c1mF+A==
+
+nth-check@^2.0.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d"
+  integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==
+  dependencies:
+    boolbase "^1.0.0"
+
+nullthrows@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/nullthrows/-/nullthrows-1.1.1.tgz#7818258843856ae971eae4208ad7d7eb19a431b1"
+  integrity sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==
+
+ordered-binary@^1.2.4:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/ordered-binary/-/ordered-binary-1.4.0.tgz#6bb53d44925f3b8afc33d1eed0fa15693b211389"
+  integrity sha512-EHQ/jk4/a9hLupIKxTfUsQRej1Yd/0QLQs3vGvIqg5ZtCYSzNhkzHoZc7Zf4e4kUlDaC3Uw8Q/1opOLNN2OKRQ==
+
+parcel@^2.8.3:
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/parcel/-/parcel-2.8.3.tgz#1ff71d7317274fd367379bc7310a52c6b75d30c2"
+  integrity sha512-5rMBpbNE72g6jZvkdR5gS2nyhwIXaJy8i65osOqs/+5b7zgf3eMKgjSsDrv6bhz3gzifsba6MBJiZdBckl+vnA==
+  dependencies:
+    "@parcel/config-default" "2.8.3"
+    "@parcel/core" "2.8.3"
+    "@parcel/diagnostic" "2.8.3"
+    "@parcel/events" "2.8.3"
+    "@parcel/fs" "2.8.3"
+    "@parcel/logger" "2.8.3"
+    "@parcel/package-manager" "2.8.3"
+    "@parcel/reporter-cli" "2.8.3"
+    "@parcel/reporter-dev-server" "2.8.3"
+    "@parcel/utils" "2.8.3"
+    chalk "^4.1.0"
+    commander "^7.0.0"
+    get-port "^4.2.0"
+    v8-compile-cache "^2.0.0"
+
+parent-module@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
+  integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==
+  dependencies:
+    callsites "^3.0.0"
+
+parse-json@^5.0.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd"
+  integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==
+  dependencies:
+    "@babel/code-frame" "^7.0.0"
+    error-ex "^1.3.1"
+    json-parse-even-better-errors "^2.3.0"
+    lines-and-columns "^1.1.6"
+
+path-type@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
+  integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
+
+picocolors@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
+  integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
+
+picomatch@^2.3.1:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
+  integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
+
+postcss-value-parser@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
+  integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
+
+posthtml-parser@^0.10.1:
+  version "0.10.2"
+  resolved "https://registry.yarnpkg.com/posthtml-parser/-/posthtml-parser-0.10.2.tgz#df364d7b179f2a6bf0466b56be7b98fd4e97c573"
+  integrity sha512-PId6zZ/2lyJi9LiKfe+i2xv57oEjJgWbsHGGANwos5AvdQp98i6AtamAl8gzSVFGfQ43Glb5D614cvZf012VKg==
+  dependencies:
+    htmlparser2 "^7.1.1"
+
+posthtml-parser@^0.11.0:
+  version "0.11.0"
+  resolved "https://registry.yarnpkg.com/posthtml-parser/-/posthtml-parser-0.11.0.tgz#25d1c7bf811ea83559bc4c21c189a29747a24b7a"
+  integrity sha512-QecJtfLekJbWVo/dMAA+OSwY79wpRmbqS5TeXvXSX+f0c6pW4/SE6inzZ2qkU7oAMCPqIDkZDvd/bQsSFUnKyw==
+  dependencies:
+    htmlparser2 "^7.1.1"
+
+posthtml-render@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/posthtml-render/-/posthtml-render-3.0.0.tgz#97be44931496f495b4f07b99e903cc70ad6a3205"
+  integrity sha512-z+16RoxK3fUPgwaIgH9NGnK1HKY9XIDpydky5eQGgAFVXTCSezalv9U2jQuNV+Z9qV1fDWNzldcw4eK0SSbqKA==
+  dependencies:
+    is-json "^2.0.1"
+
+posthtml@^0.16.4, posthtml@^0.16.5:
+  version "0.16.6"
+  resolved "https://registry.yarnpkg.com/posthtml/-/posthtml-0.16.6.tgz#e2fc407f67a64d2fa3567afe770409ffdadafe59"
+  integrity sha512-JcEmHlyLK/o0uGAlj65vgg+7LIms0xKXe60lcDOTU7oVX/3LuEuLwrQpW3VJ7de5TaFKiW4kWkaIpJL42FEgxQ==
+  dependencies:
+    posthtml-parser "^0.11.0"
+    posthtml-render "^3.0.0"
+
+process@^0.11.10:
+  version "0.11.10"
+  resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
+  integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==
+
+react-dom@^18.2.0:
+  version "18.2.0"
+  resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
+  integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
+  dependencies:
+    loose-envify "^1.1.0"
+    scheduler "^0.23.0"
+
+react-error-overlay@6.0.9:
+  version "6.0.9"
+  resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.9.tgz#3c743010c9359608c375ecd6bc76f35d93995b0a"
+  integrity sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==
+
+react-refresh@^0.9.0:
+  version "0.9.0"
+  resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.9.0.tgz#71863337adc3e5c2f8a6bfddd12ae3bfe32aafbf"
+  integrity sha512-Gvzk7OZpiqKSkxsQvO/mbTN1poglhmAV7gR/DdIrRrSMXraRQQlfikRJOr3Nb9GTMPC5kof948Zy6jJZIFtDvQ==
+
+react@^18.2.0:
+  version "18.2.0"
+  resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
+  integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
+  dependencies:
+    loose-envify "^1.1.0"
+
+regenerator-runtime@^0.13.7:
+  version "0.13.11"
+  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9"
+  integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==
+
+resolve-from@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
+  integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
+
+safe-buffer@^5.0.1:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
+  integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
+
+scheduler@^0.23.0:
+  version "0.23.0"
+  resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe"
+  integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==
+  dependencies:
+    loose-envify "^1.1.0"
+
+semver@^5.7.0, semver@^5.7.1:
+  version "5.7.1"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
+  integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
+
+source-map-support@~0.5.20:
+  version "0.5.21"
+  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"
+  integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==
+  dependencies:
+    buffer-from "^1.0.0"
+    source-map "^0.6.0"
+
+source-map@^0.6.0, source-map@^0.6.1:
+  version "0.6.1"
+  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
+  integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
+
+srcset@4:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/srcset/-/srcset-4.0.0.tgz#336816b665b14cd013ba545b6fe62357f86e65f4"
+  integrity sha512-wvLeHgcVHKO8Sc/H/5lkGreJQVeYMm9rlmt8PuR1xE31rIuXhuzznUUqAt8MqLhB3MqJdFzlNAfpcWnxiFUcPw==
+
+stable@^0.1.8:
+  version "0.1.8"
+  resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf"
+  integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==
+
+supports-color@^5.3.0:
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
+  integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==
+  dependencies:
+    has-flag "^3.0.0"
+
+supports-color@^7.1.0:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
+  integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
+  dependencies:
+    has-flag "^4.0.0"
+
+svgo@^2.4.0:
+  version "2.8.0"
+  resolved "https://registry.yarnpkg.com/svgo/-/svgo-2.8.0.tgz#4ff80cce6710dc2795f0c7c74101e6764cfccd24"
+  integrity sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==
+  dependencies:
+    "@trysound/sax" "0.2.0"
+    commander "^7.2.0"
+    css-select "^4.1.3"
+    css-tree "^1.1.3"
+    csso "^4.2.0"
+    picocolors "^1.0.0"
+    stable "^0.1.8"
+
+term-size@^2.2.1:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/term-size/-/term-size-2.2.1.tgz#2a6a54840432c2fb6320fea0f415531e90189f54"
+  integrity sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==
+
+terser@^5.2.0:
+  version "5.16.1"
+  resolved "https://registry.yarnpkg.com/terser/-/terser-5.16.1.tgz#5af3bc3d0f24241c7fb2024199d5c461a1075880"
+  integrity sha512-xvQfyfA1ayT0qdK47zskQgRZeWLoOQ8JQ6mIgRGVNwZKdQMU+5FkCBjmv4QjcrTzyZquRw2FVtlJSRUmMKQslw==
+  dependencies:
+    "@jridgewell/source-map" "^0.3.2"
+    acorn "^8.5.0"
+    commander "^2.20.0"
+    source-map-support "~0.5.20"
+
+timsort@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4"
+  integrity sha512-qsdtZH+vMoCARQtyod4imc2nIJwg9Cc7lPRrw9CzF8ZKR0khdr8+2nX80PBhET3tcyTtJDxAffGh2rXH4tyU8A==
+
+to-regex-range@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
+  integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
+  dependencies:
+    is-number "^7.0.0"
+
+tslib@^2.4.0:
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e"
+  integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==
+
+type-fest@^0.20.2:
+  version "0.20.2"
+  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
+  integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
+
+typescript@>=3.0.0:
+  version "4.9.4"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.4.tgz#a2a3d2756c079abda241d75f149df9d561091e78"
+  integrity sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==
+
+update-browserslist-db@^1.0.9:
+  version "1.0.10"
+  resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz#0f54b876545726f17d00cd9a2561e6dade943ff3"
+  integrity sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==
+  dependencies:
+    escalade "^3.1.1"
+    picocolors "^1.0.0"
+
+utility-types@^3.10.0:
+  version "3.10.0"
+  resolved "https://registry.yarnpkg.com/utility-types/-/utility-types-3.10.0.tgz#ea4148f9a741015f05ed74fd615e1d20e6bed82b"
+  integrity sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg==
+
+v8-compile-cache@^2.0.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
+  integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==
+
+weak-lru-cache@^1.2.2:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz#fdbb6741f36bae9540d12f480ce8254060dccd19"
+  integrity sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==
+
+xxhash-wasm@^0.4.2:
+  version "0.4.2"
+  resolved "https://registry.yarnpkg.com/xxhash-wasm/-/xxhash-wasm-0.4.2.tgz#752398c131a4dd407b5132ba62ad372029be6f79"
+  integrity sha512-/eyHVRJQCirEkSZ1agRSCwriMhwlyUcFkXD5TPVSLP+IPzjsqMVzZwdoczLp1SoQU0R3dxz1RpIK+4YNQbCVOA==
+
+yaml@^1.10.0:
+  version "1.10.2"
+  resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"
+  integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==