├── .gitattributes ├── .github └── workflows │ └── gh-pages.yml ├── .gitignore ├── LICENSE ├── pull_stats.py └── web ├── index.html ├── script.js └── style.css /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: github pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | schedule: 8 | - cron: "15 */12 * * *" 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-24.04 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Pull stats 17 | run: ./pull_stats.py 18 | 19 | - name: Deploy 20 | uses: peaceiris/actions-gh-pages@v3 21 | with: 22 | github_token: ${{ secrets.GITHUB_TOKEN }} 23 | publish_dir: ./web 24 | user_name: 'github-actions[bot]' 25 | user_email: 'github-actions[bot]@users.noreply.github.com' 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /stats 2 | /web/data 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Kristian Klausen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pull_stats.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import subprocess 4 | import json 5 | import shutil 6 | 7 | if subprocess.call(["wget", "--execute", "robots=off", "--recursive", "--no-host-directories", "--no-parent", "--reject-regex", "backup", "--accept", "*.json", "https://flathub.org/stats/"]) != 0: 8 | exit(1) 9 | 10 | files = [] 11 | refs = {} 12 | 13 | for (dirpath, _, filenames) in os.walk("stats"): 14 | files.extend(os.path.join(dirpath, filename) for filename in filenames) 15 | files.sort() 16 | # The latest stats aren't very accurate, please see: https://github.com/klausenbusk/flathub-stats/issues/5 17 | del files[-1] 18 | 19 | for f in files: 20 | print(f) 21 | with open(f) as json_file: 22 | data = json.load(json_file) 23 | date = data["date"] 24 | for ref in data["refs"]: 25 | refs.setdefault(ref, {"ref": ref, "stats": []})["stats"].append({"date": date, "arches": data["refs"][ref]}) 26 | 27 | 28 | os.chdir("web") 29 | if os.path.isdir("data"): 30 | shutil.rmtree("data") 31 | os.mkdir("data") 32 | 33 | def writeJson(f, data): 34 | with open(f, "w") as outfile: 35 | json.dump(data, outfile) 36 | 37 | for ref in refs: 38 | writeJson(f"data/{ref.replace('/', '_')}.json", refs[ref]) 39 | 40 | writeJson("data/refs.json", list(refs.keys())) 41 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Flathub Stats 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 23 | 24 | 25 |
26 | 27 | 28 | 35 | 41 | 46 |

47 |
48 | 49 |
50 | 51 |
52 | 53 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /web/script.js: -------------------------------------------------------------------------------- 1 | let chart; 2 | let refs = new Set(); 3 | let stats; 4 | let ref; 5 | let interval; 6 | /** How many days to group into one data point. @type {number} */ 7 | let granularity; 8 | let downloadType; 9 | let min = null; 10 | 11 | function initChart() { 12 | let ctx = document.getElementById("chart").getContext("2d"); 13 | chart = new Chart(ctx, { 14 | // The type of chart we want to create 15 | type: "line", 16 | 17 | // Configuration options go here 18 | options: { 19 | tension: 0.5, 20 | borderCapStyle: "round", 21 | borderJoinStyle: "round", 22 | 23 | scales: { 24 | x: { 25 | type: "time", 26 | time: { 27 | minUnit: "day", 28 | } 29 | } 30 | }, 31 | tooltips: { 32 | mode: "x", 33 | intersect: false 34 | } 35 | } 36 | }); 37 | } 38 | 39 | function updateBasicStats() { 40 | let total = 0; 41 | let average = 0; 42 | let first = null; 43 | chart.data.datasets.forEach((dataset) => { 44 | dataset.data.forEach((dataPoint) => { 45 | if (!min || min <= dataPoint.x) { 46 | total += dataPoint.y; 47 | if (!first || dataPoint.x < first) { 48 | first = dataPoint.x; 49 | } 50 | } 51 | }) 52 | }); 53 | average = total / Math.round(((new Date()) - first) / (24*60*60*1000)); 54 | document.getElementById("basic-stats").textContent = `Total: ${total} downloads | Average: ${average.toFixed(2)} downloads per day`; 55 | } 56 | 57 | function updateDatasets() { 58 | let chartColors = [ 59 | "rgb(255, 99, 132)", // red 60 | "rgb(255, 159, 64)", // orange 61 | "rgb(255, 205, 86)", // yellow 62 | "rgb(75, 192, 192)", // green 63 | "rgb(54, 162, 235)", // blue 64 | "rgb(153, 102, 255)", // purple 65 | ]; 66 | 67 | let datasets = {}; 68 | for (let dataPoint of stats) { 69 | for (let arch of Object.keys(dataPoint.arches)) { 70 | if (!(arch in datasets)) { 71 | let color = chartColors.pop(); 72 | datasets[arch] = { 73 | label: arch, 74 | backgroundColor: Chart.helpers.color(color).alpha(0.2).rgbString(), 75 | borderColor: color, 76 | fill: true, 77 | data: [] 78 | }; 79 | } 80 | let downloads = 0; 81 | // Upstream logic: https://github.com/flathub/flathub-stats/blob/7711d11dd8224cd9a6655d3eaac97c9ae2ef46ea/update-stats.py#L23 82 | switch (downloadType) { 83 | case "installs+updates": 84 | downloads = dataPoint.arches[arch][0]; 85 | break; 86 | case "installs": 87 | downloads = dataPoint.arches[arch][0] - dataPoint.arches[arch][1]; 88 | break; 89 | case "updates": 90 | downloads = dataPoint.arches[arch][1]; 91 | break; 92 | } 93 | 94 | let dataset = datasets[arch]; 95 | dataset.data.push({ 96 | x: new Date(dataPoint.date), 97 | y: downloads 98 | }); 99 | } 100 | } 101 | 102 | if (granularity > 1) { 103 | for (let arch of Object.keys(datasets)) { 104 | let oldData = datasets[arch].data; 105 | let newData = []; 106 | for (let i = 0; i < oldData.length; i += granularity) { 107 | let sum = 0; 108 | for (let di = 0; di < granularity && i+di < oldData.length; di++) { 109 | sum += oldData[i+di].y; 110 | } 111 | newData.push({ 112 | x: oldData[i].x, 113 | y: sum 114 | }); 115 | } 116 | datasets[arch].data = newData; 117 | } 118 | } 119 | 120 | chart.data.datasets = Object.values(datasets); 121 | chart.update(); 122 | updateBasicStats(); 123 | } 124 | 125 | function updateURL() { 126 | const params = new URLSearchParams(); 127 | params.set("ref", ref); 128 | if (interval !== "infinity") params.set("interval", interval); 129 | if (granularity !== 1) params.set("granularity", granularity); 130 | if (downloadType !== "installs+updates") params.set("downloadType", downloadType); 131 | window.location.hash = '#' + params.toString(); 132 | } 133 | 134 | async function refHandler(event) { 135 | let refEventValue = event.target.value; 136 | if (!refs.has(refEventValue)) { 137 | return; 138 | } 139 | ref = refEventValue; 140 | let response = await fetch(`./data/${ref.replace("/", "_")}.json`); 141 | let json = await response.json(); 142 | 143 | stats = json.stats; 144 | updateDatasets(); 145 | updateURL(); 146 | } 147 | 148 | function intervalHandler(event) { 149 | interval = event.target.value; 150 | if (interval === "infinity") { 151 | delete chart.options.scales.x.min; 152 | min = null; 153 | } else { 154 | min = new Date(); 155 | min.setDate(min.getDate() - interval); 156 | chart.options.scales.x.min = min; 157 | } 158 | chart.update(); 159 | updateBasicStats(); 160 | updateURL(); 161 | } 162 | 163 | function granularityHandler(event) { 164 | granularity = parseInt(event.target.value); 165 | updateDatasets(); 166 | updateURL(); 167 | } 168 | 169 | function downloadTypeHandler(event) { 170 | downloadType = event.target.value; 171 | updateDatasets(); 172 | updateURL(); 173 | } 174 | 175 | async function init() { 176 | initChart(); 177 | 178 | let response = await fetch("./data/refs.json"); 179 | let json = await response.json(); 180 | json.forEach(ref => refs.add(ref)); 181 | let refsElement = document.getElementById("refs"); 182 | 183 | for (let ref of refs.keys()) { 184 | let option = document.createElement("option"); 185 | option.value = ref; 186 | refsElement.append(option); 187 | } 188 | 189 | let refElement = document.getElementById("ref"); 190 | let intervalSelectElement = document.getElementById("interval-select"); 191 | let granularitySelectElement = document.getElementById("granularity"); 192 | let downloadTypeElement = document.getElementById("downloadType"); 193 | let params = new URLSearchParams(window.location.hash.substring(1)); 194 | 195 | refElement.value = params.has("ref") ? params.get("ref") : refsElement.childNodes[0].value; 196 | if (params.has("interval")) { 197 | intervalSelectElement.value = params.get("interval"); 198 | } 199 | interval = intervalSelectElement.value; 200 | if (params.has("granularity")) { 201 | granularitySelectElement.value = params.get("granularity"); 202 | } 203 | granularity = parseInt(granularitySelectElement.value); 204 | if (params.has("downloadType")) { 205 | downloadTypeElement.value = params.get("downloadType"); 206 | } 207 | downloadType = downloadTypeElement.value; 208 | 209 | refElement.addEventListener("change", refHandler); 210 | intervalSelectElement.addEventListener("change", intervalHandler); 211 | granularitySelectElement.addEventListener("change", granularityHandler); 212 | downloadTypeElement.addEventListener("change", downloadTypeHandler); 213 | 214 | await refHandler({target: {value: refElement.value}}); 215 | intervalSelectElement.dispatchEvent(new Event("change")); 216 | downloadTypeElement.dispatchEvent(new Event("change")); 217 | } 218 | 219 | window.addEventListener("DOMContentLoaded", init); 220 | -------------------------------------------------------------------------------- /web/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | color-scheme: light dark; 3 | } 4 | html, body { 5 | height: 100%; 6 | margin: 0; 7 | padding: 0; 8 | } 9 | body { 10 | display: flex; 11 | flex-flow: column nowrap; 12 | } 13 | main { 14 | display: flex; 15 | flex: 0 1 100%; 16 | align-items: center; 17 | justify-content: center; 18 | } 19 | main canvas { 20 | max-width: 100%; 21 | max-height: 100%; 22 | } 23 | 24 | header, 25 | footer { 26 | flex: 0 0 auto; 27 | background: rgba(100, 100, 100, 0.1); 28 | padding: 1rem; 29 | } 30 | --------------------------------------------------------------------------------