diff --git a/client/stillbox/package-lock.json b/client/stillbox/package-lock.json
index bb5a79a..1267460 100644
--- a/client/stillbox/package-lock.json
+++ b/client/stillbox/package-lock.json
@@ -19,6 +19,8 @@
"@angular/platform-browser-dynamic": "^19.0.5",
"@angular/router": "^19.0.5",
"@angular/service-worker": "^19.0.5",
+ "@types/d3": "^7.4.3",
+ "d3": "^7.9.0",
"rxjs": "~7.8.0",
"sass": "^1.82.0",
"tslib": "^2.3.0",
@@ -4925,6 +4927,259 @@
"@types/node": "*"
}
},
+ "node_modules/@types/d3": {
+ "version": "7.4.3",
+ "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
+ "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-array": "*",
+ "@types/d3-axis": "*",
+ "@types/d3-brush": "*",
+ "@types/d3-chord": "*",
+ "@types/d3-color": "*",
+ "@types/d3-contour": "*",
+ "@types/d3-delaunay": "*",
+ "@types/d3-dispatch": "*",
+ "@types/d3-drag": "*",
+ "@types/d3-dsv": "*",
+ "@types/d3-ease": "*",
+ "@types/d3-fetch": "*",
+ "@types/d3-force": "*",
+ "@types/d3-format": "*",
+ "@types/d3-geo": "*",
+ "@types/d3-hierarchy": "*",
+ "@types/d3-interpolate": "*",
+ "@types/d3-path": "*",
+ "@types/d3-polygon": "*",
+ "@types/d3-quadtree": "*",
+ "@types/d3-random": "*",
+ "@types/d3-scale": "*",
+ "@types/d3-scale-chromatic": "*",
+ "@types/d3-selection": "*",
+ "@types/d3-shape": "*",
+ "@types/d3-time": "*",
+ "@types/d3-time-format": "*",
+ "@types/d3-timer": "*",
+ "@types/d3-transition": "*",
+ "@types/d3-zoom": "*"
+ }
+ },
+ "node_modules/@types/d3-array": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
+ "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-axis": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
+ "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-brush": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
+ "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-chord": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
+ "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-contour": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
+ "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-array": "*",
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/d3-delaunay": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+ "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-dispatch": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz",
+ "integrity": "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-drag": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+ "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-dsv": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
+ "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-fetch": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
+ "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-dsv": "*"
+ }
+ },
+ "node_modules/@types/d3-force": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
+ "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-format": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
+ "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-geo": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
+ "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/d3-hierarchy": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
+ "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-polygon": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
+ "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-quadtree": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
+ "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-random": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
+ "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-scale-chromatic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+ "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-selection": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
+ "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
+ "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-time-format": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
+ "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-transition": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+ "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-zoom": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+ "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-interpolate": "*",
+ "@types/d3-selection": "*"
+ }
+ },
"node_modules/@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -4993,6 +5248,12 @@
"@types/send": "*"
}
},
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "license": "MIT"
+ },
"node_modules/@types/http-errors": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz",
@@ -6789,6 +7050,428 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/d3": {
+ "version": "7.9.0",
+ "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
+ "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "3",
+ "d3-axis": "3",
+ "d3-brush": "3",
+ "d3-chord": "3",
+ "d3-color": "3",
+ "d3-contour": "4",
+ "d3-delaunay": "6",
+ "d3-dispatch": "3",
+ "d3-drag": "3",
+ "d3-dsv": "3",
+ "d3-ease": "3",
+ "d3-fetch": "3",
+ "d3-force": "3",
+ "d3-format": "3",
+ "d3-geo": "3",
+ "d3-hierarchy": "3",
+ "d3-interpolate": "3",
+ "d3-path": "3",
+ "d3-polygon": "3",
+ "d3-quadtree": "3",
+ "d3-random": "3",
+ "d3-scale": "4",
+ "d3-scale-chromatic": "3",
+ "d3-selection": "3",
+ "d3-shape": "3",
+ "d3-time": "3",
+ "d3-time-format": "4",
+ "d3-timer": "3",
+ "d3-transition": "3",
+ "d3-zoom": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-axis": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
+ "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-brush": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
+ "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "3",
+ "d3-transition": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-chord": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
+ "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-contour": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
+ "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "^3.2.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-delaunay": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+ "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
+ "license": "ISC",
+ "dependencies": {
+ "delaunator": "5"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dsv": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
+ "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
+ "license": "ISC",
+ "dependencies": {
+ "commander": "7",
+ "iconv-lite": "0.6",
+ "rw": "1"
+ },
+ "bin": {
+ "csv2json": "bin/dsv2json.js",
+ "csv2tsv": "bin/dsv2dsv.js",
+ "dsv2dsv": "bin/dsv2dsv.js",
+ "dsv2json": "bin/dsv2json.js",
+ "json2csv": "bin/json2dsv.js",
+ "json2dsv": "bin/json2dsv.js",
+ "json2tsv": "bin/json2dsv.js",
+ "tsv2csv": "bin/dsv2dsv.js",
+ "tsv2json": "bin/dsv2json.js"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dsv/node_modules/commander": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
+ "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/d3-dsv/node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-fetch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
+ "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dsv": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-force": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
+ "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-quadtree": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
+ "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-geo": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
+ "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.5.0 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-hierarchy": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
+ "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-polygon": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
+ "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-quadtree": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
+ "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-random": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
+ "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale-chromatic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+ "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-interpolate": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-selection": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-transition": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/date-format": {
"version": "4.0.14",
"resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz",
@@ -6873,6 +7556,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/delaunator": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
+ "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
+ "license": "ISC",
+ "dependencies": {
+ "robust-predicates": "^3.0.2"
+ }
+ },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -8505,6 +9197,15 @@
"node": "^18.17.0 || >=20.5.0"
}
},
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/ip-address": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
@@ -11926,6 +12627,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/robust-predicates": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
+ "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
+ "license": "Unlicense"
+ },
"node_modules/rollup": {
"name": "@rollup/wasm-node",
"version": "4.29.0",
@@ -11984,6 +12691,12 @@
"queue-microtask": "^1.2.2"
}
},
+ "node_modules/rw": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
+ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/rxjs": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
@@ -12036,7 +12749,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
- "dev": true,
"license": "MIT"
},
"node_modules/sass": {
diff --git a/client/stillbox/package.json b/client/stillbox/package.json
index 33e1725..c275707 100644
--- a/client/stillbox/package.json
+++ b/client/stillbox/package.json
@@ -21,6 +21,8 @@
"@angular/platform-browser-dynamic": "^19.0.5",
"@angular/router": "^19.0.5",
"@angular/service-worker": "^19.0.5",
+ "@types/d3": "^7.4.3",
+ "d3": "^7.9.0",
"rxjs": "~7.8.0",
"sass": "^1.82.0",
"tslib": "^2.3.0",
diff --git a/client/stillbox/src/app/calls.ts b/client/stillbox/src/app/calls.ts
index ed581f5..a38502f 100644
--- a/client/stillbox/src/app/calls.ts
+++ b/client/stillbox/src/app/calls.ts
@@ -7,3 +7,13 @@ export interface CallRecord {
tgid: number;
incidents: number; // in incident
}
+
+export interface CallStats {
+ stats: CallStatsRecord[];
+ interval: string;
+}
+
+export interface CallStatsRecord {
+ count: number;
+ time: Date;
+}
diff --git a/client/stillbox/src/app/calls/calls.component.html b/client/stillbox/src/app/calls/calls.component.html
index 3a3972f..86ce14f 100644
--- a/client/stillbox/src/app/calls/calls.component.html
+++ b/client/stillbox/src/app/calls/calls.component.html
@@ -101,7 +101,7 @@
-
+
Talkgroup |
- {{ call | talkgroup: "alpha" | async }}
+ @let tgAlpha = call | talkgroup: "alpha" | async;
+ {{ tgAlpha }}
|
diff --git a/client/stillbox/src/app/calls/calls.component.scss b/client/stillbox/src/app/calls/calls.component.scss
index 01e9628..7d7bbd3 100644
--- a/client/stillbox/src/app/calls/calls.component.scss
+++ b/client/stillbox/src/app/calls/calls.component.scss
@@ -100,3 +100,7 @@ form {
.in-incident {
background-color: rgb(59, 0, 59);
}
+
+a.tgFilter:hover {
+ text-decoration: underline;
+}
diff --git a/client/stillbox/src/app/calls/calls.component.ts b/client/stillbox/src/app/calls/calls.component.ts
index 76b50df..030180f 100644
--- a/client/stillbox/src/app/calls/calls.component.ts
+++ b/client/stillbox/src/app/calls/calls.component.ts
@@ -1,7 +1,7 @@
-import { Component, inject, ViewChild } from '@angular/core';
+import { Component, ElementRef, inject, ViewChild } from '@angular/core';
import { CommonModule, AsyncPipe } from '@angular/common';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
-import { MatTableModule } from '@angular/material/table';
+import { MatTable, MatTableModule } from '@angular/material/table';
import {
MatPaginator,
MatPaginatorModule,
@@ -80,6 +80,7 @@ const reqPageSize = 200;
export class CallsComponent {
callsResult = new BehaviorSubject(new Array(0));
@ViewChild('paginator') paginator!: MatPaginator;
+ @ViewChild('callsTable', { read: ElementRef }) callsTable!: ElementRef;
count = 0;
dialog = inject(MatDialog);
page = 0;
@@ -135,6 +136,12 @@ export class CallsComponent {
return numSelected === numRows;
}
+ searchFilter(filt: string | null) {
+ if (filt) {
+ this.form.controls['filter'].setValue(filt);
+ }
+ }
+
buildParams(p: PageEvent, serverPage: number): CallsListParams {
const par: CallsListParams = {
start: new Date(this.form.controls['start'].value!),
@@ -251,6 +258,9 @@ export class CallsComponent {
this.isLoading = false;
this.count = calls.count;
this.currentSet = calls.calls;
+ if (this.callsTable) {
+ this.callsTable.nativeElement.scrollIntoView(true);
+ }
this.callsResult.next(
this.currentSet
? this.currentSet.slice(
diff --git a/client/stillbox/src/app/calls/calls.service.ts b/client/stillbox/src/app/calls/calls.service.ts
index 31816ff..570563f 100644
--- a/client/stillbox/src/app/calls/calls.service.ts
+++ b/client/stillbox/src/app/calls/calls.service.ts
@@ -1,7 +1,7 @@
import { Injectable, Pipe, PipeTransform } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { map, Observable } from 'rxjs';
-import { CallRecord } from '../calls';
+import { CallRecord, CallStats } from '../calls';
import { environment } from '.././../environments/environment';
import { TalkgroupService } from '../talkgroups/talkgroups.service';
import { Talkgroup } from '../talkgroup';
@@ -185,4 +185,8 @@ export class CallsService {
callAudioDownloadURL(id: string): string {
return environment.baseUrl + '/api/call/' + id + '/download';
}
+
+ getCallStats(interval: string): Observable {
+ return this.http.get(`/api/call/stats/${interval}`);
+ }
}
diff --git a/client/stillbox/src/app/charts/charts.component.html b/client/stillbox/src/app/charts/charts.component.html
new file mode 100644
index 0000000..439281b
--- /dev/null
+++ b/client/stillbox/src/app/charts/charts.component.html
@@ -0,0 +1 @@
+
diff --git a/client/stillbox/src/app/charts/charts.component.scss b/client/stillbox/src/app/charts/charts.component.scss
new file mode 100644
index 0000000..e69de29
diff --git a/client/stillbox/src/app/charts/charts.component.spec.ts b/client/stillbox/src/app/charts/charts.component.spec.ts
new file mode 100644
index 0000000..4c98a0b
--- /dev/null
+++ b/client/stillbox/src/app/charts/charts.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ChartsComponent } from './charts.component';
+
+describe('ChartsComponent', () => {
+ let component: ChartsComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ChartsComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ChartsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/client/stillbox/src/app/charts/charts.component.ts b/client/stillbox/src/app/charts/charts.component.ts
new file mode 100644
index 0000000..707ed5e
--- /dev/null
+++ b/client/stillbox/src/app/charts/charts.component.ts
@@ -0,0 +1,114 @@
+import { Component, ElementRef, Input } from '@angular/core';
+import * as d3 from 'd3';
+import { CallsService } from '../calls/calls.service';
+import { CallStatsRecord } from '../calls';
+
+@Component({
+ selector: 'chart',
+ imports: [],
+ templateUrl: './charts.component.html',
+ styleUrl: './charts.component.scss',
+})
+export class ChartsComponent {
+ @Input() interval!: string;
+ loading = true;
+ // I hate javascript so much
+ months = [
+ 'Jan',
+ 'Feb',
+ 'Mar',
+ 'Apr',
+ 'May',
+ 'Jun',
+ 'Jul',
+ 'Aug',
+ 'Sep',
+ 'Oct',
+ 'Nov',
+ 'Dec',
+ ];
+ constructor(
+ private elementRef: ElementRef,
+ private callsSvc: CallsService,
+ ) {}
+
+ dateFormat(d: Date): string {
+ switch (this.interval) {
+ case 'month':
+ return `${this.months[d.getMonth()]} ${d.getFullYear()}`;
+ case 'day':
+ return `${this.months[d.getMonth()]} ${d.getDate()}`;
+ case 'week':
+ return `${this.months[d.getMonth()]} ${d.getDate()}`;
+ }
+ return d.toDateString();
+ }
+
+ ngOnInit() {
+ this.callsSvc.getCallStats(this.interval).subscribe((stats) => {
+ let cMax = 0;
+ var cMin = 0;
+ let data = stats.stats.map((rec) => {
+ if (cMin == 0 && rec.count > cMin) {
+ cMin = rec.count;
+ }
+ if (rec.count < cMin) {
+ cMin = rec.count;
+ }
+ if (rec.count > cMax) {
+ cMax = rec.count;
+ }
+ return { count: rec.count, time: this.dateFormat(new Date(rec.time)) };
+ });
+ // set the dimensions and margins of the graph
+ var margin = { top: 30, right: 30, bottom: 70, left: 60 },
+ width = 460 - margin.left - margin.right,
+ height = 400 - margin.top - margin.bottom;
+ const svg = d3
+ .select(this.elementRef.nativeElement)
+ .select('.chart')
+ .append('svg')
+ .attr('width', width + margin.left + margin.right)
+ .attr('height', height + margin.top + margin.bottom)
+ .append('g')
+ .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
+ // X axis
+ var x = d3
+ .scaleBand()
+ .range([0, width])
+ .domain(
+ data.map(function (d) {
+ return d.time;
+ }),
+ )
+ .padding(0.2);
+ svg
+ .append('g')
+ .attr('transform', 'translate(0,' + height + ')')
+ .call(d3.axisBottom(x))
+ .selectAll('text')
+ .attr('transform', 'translate(-10,0)rotate(-45)')
+ .style('text-anchor', 'end');
+
+ // Add Y axis
+ var y = d3.scaleLinear().domain([0, cMax]).range([height, 0]);
+ svg.append('g').call(d3.axisLeft(y));
+ svg
+ .selectAll('mybar')
+ .data(data)
+ .enter()
+ .append('rect')
+ .attr('x', (d) => x(d.time)!)
+ .attr('y', function (d) {
+ return y(d.count);
+ })
+ .attr('width', x.bandwidth())
+ .attr('height', function (d) {
+ return height - y(d.count);
+ })
+ .attr('fill', function (d) {
+ return d3.interpolateTurbo((d.count - cMin) / (cMax - cMin));
+ });
+ });
+ }
+}
diff --git a/client/stillbox/src/app/home/home.component.html b/client/stillbox/src/app/home/home.component.html
index 841e271..191617f 100644
--- a/client/stillbox/src/app/home/home.component.html
+++ b/client/stillbox/src/app/home/home.component.html
@@ -1 +1,4 @@
-This will be a dashboard someday.
+
+ Calls By Week
+
+
diff --git a/client/stillbox/src/app/home/home.component.scss b/client/stillbox/src/app/home/home.component.scss
index e69de29..06e84df 100644
--- a/client/stillbox/src/app/home/home.component.scss
+++ b/client/stillbox/src/app/home/home.component.scss
@@ -0,0 +1,10 @@
+mat-card.chart {
+ width: 500px;
+ height: 400px;
+ margin: 30px 30px 40px 40px;
+}
+
+mat-card div {
+ padding-left: 10px;
+ padding-top: 10px;
+}
diff --git a/client/stillbox/src/app/home/home.component.ts b/client/stillbox/src/app/home/home.component.ts
index f1f850e..0c5d88d 100644
--- a/client/stillbox/src/app/home/home.component.ts
+++ b/client/stillbox/src/app/home/home.component.ts
@@ -1,8 +1,10 @@
import { Component } from '@angular/core';
+import { ChartsComponent } from '../charts/charts.component';
+import { MatCardModule } from '@angular/material/card';
@Component({
selector: 'app-home',
- imports: [],
+ imports: [ChartsComponent, MatCardModule],
templateUrl: './home.component.html',
styleUrl: './home.component.scss',
})
diff --git a/client/stillbox/src/app/navigation/navigation.component.html b/client/stillbox/src/app/navigation/navigation.component.html
index 692a97f..b90a54b 100644
--- a/client/stillbox/src/app/navigation/navigation.component.html
+++ b/client/stillbox/src/app/navigation/navigation.component.html
@@ -12,6 +12,7 @@
[routerLink]="r.url"
[routerLinkActiveOptions]="{ exact: r.exact }"
routerLinkActive
+ [title]="r.name"
#routerLinkActiveInstance="routerLinkActive"
[activated]="routerLinkActiveInstance.isActive"
mat-list-item
diff --git a/client/stillbox/src/app/shares/shares.component.ts b/client/stillbox/src/app/shares/shares.component.ts
index b7784ef..47132cd 100644
--- a/client/stillbox/src/app/shares/shares.component.ts
+++ b/client/stillbox/src/app/shares/shares.component.ts
@@ -14,10 +14,7 @@ import { BehaviorSubject, Subscription } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { MatFormFieldModule } from '@angular/material/form-field';
-import {
- FormsModule,
- ReactiveFormsModule,
-} from '@angular/forms';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import {
ShareListParams,
diff --git a/internal/cache/cache.go b/internal/cache/cache.go
index a364406..85b7823 100644
--- a/internal/cache/cache.go
+++ b/internal/cache/cache.go
@@ -1,6 +1,9 @@
package cache
-import "sync"
+import (
+ "sync"
+ "time"
+)
type Cache[K comparable, V any] interface {
Get(K) (V, bool)
@@ -9,28 +12,67 @@ type Cache[K comparable, V any] interface {
Clear()
}
-type inMem[K comparable, V any] struct {
- sync.RWMutex
- m map[K]V
+type cacheItem[V any] struct {
+ exp int64
+ elem V
}
-func New[K comparable, V any]() *inMem[K, V] {
- return &inMem[K, V]{
- m: make(map[K]V),
+type inMem[K comparable, V any] struct {
+ sync.RWMutex
+ expiration time.Duration
+ m map[K]cacheItem[V]
+}
+
+type cacheOpts struct {
+ exp time.Duration
+}
+
+type Option func(*cacheOpts)
+
+func WithExpiration(d time.Duration) Option {
+ return func(o *cacheOpts) {
+ o.exp = d
}
}
+func New[K comparable, V any](opts ...Option) *inMem[K, V] {
+ co := cacheOpts{}
+
+ for _, o := range opts {
+ o(&co)
+ }
+
+ c := &inMem[K, V]{
+ m: make(map[K]cacheItem[V]),
+ expiration: co.exp,
+ }
+
+ return c
+}
+
func (c *inMem[K, V]) Get(key K) (V, bool) {
c.RLock()
defer c.RUnlock()
- v, ok := c.m[key]
- return v, ok
+
+ v, found := c.m[key]
+ if !found || (v.exp > 0 && time.Now().UnixNano() > v.exp) {
+ return v.elem, false
+ }
+
+ return v.elem, true
}
func (c *inMem[K, V]) Set(key K, val V) {
c.Lock()
defer c.Unlock()
- c.m[key] = val
+
+ var ci cacheItem[V]
+ ci.elem = val
+ if c.expiration > 0 {
+ ci.exp = time.Now().Add(c.expiration).UnixNano()
+ }
+
+ c.m[key] = ci
}
func (c *inMem[K, V]) Delete(key K) {
diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go
index 23079f1..d7102ed 100644
--- a/internal/cache/cache_test.go
+++ b/internal/cache/cache_test.go
@@ -2,6 +2,7 @@ package cache_test
import (
"testing"
+ "time"
"dynatron.me/x/stillbox/internal/cache"
@@ -31,3 +32,33 @@ func TestCache(t *testing.T) {
assert.False(t, ok)
assert.NotEqual(t, "fg", g)
}
+
+func TestCacheTime(t *testing.T) {
+ c := cache.New[int, string](cache.WithExpiration(100 * time.Millisecond))
+ c.Set(4, "asd")
+ time.Sleep(20 * time.Millisecond)
+ g, ok := c.Get(4)
+ assert.Equal(t, "asd", g)
+ assert.True(t, ok)
+
+ c.Set(2, "gff")
+ time.Sleep(120 * time.Millisecond)
+ g, ok = c.Get(2)
+ assert.False(t, ok)
+
+ _, ok = c.Get(8)
+ assert.False(t, ok)
+
+ c.Set(7, "fg")
+
+ c.Delete(4)
+
+ g, ok = c.Get(4)
+ assert.False(t, ok)
+ assert.NotEqual(t, "asd", g)
+
+ c.Clear()
+ g, ok = c.Get(7)
+ assert.False(t, ok)
+ assert.NotEqual(t, "fg", g)
+}
diff --git a/pkg/alerting/alerting.go b/pkg/alerting/alerting.go
index 769781b..a5ad0aa 100644
--- a/pkg/alerting/alerting.go
+++ b/pkg/alerting/alerting.go
@@ -138,7 +138,7 @@ func (as *alerter) HUP(cfg *config.Config) {
// Go is the alerting loop. It does not start a goroutine.
func (as *alerter) Go(ctx context.Context) {
- ctx = entities.CtxWithSubject(ctx, &entities.SystemServiceSubject{Name: "alerter"})
+ ctx = entities.CtxWithServiceSubject(ctx, "alerter")
err := as.startBackfill(ctx)
if err != nil {
diff --git a/pkg/calls/callstore/store.go b/pkg/calls/callstore/store.go
index 297ddbb..d446b52 100644
--- a/pkg/calls/callstore/store.go
+++ b/pkg/calls/callstore/store.go
@@ -12,6 +12,7 @@ import (
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/rbac/entities"
+ "dynatron.me/x/stillbox/pkg/services"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"dynatron.me/x/stillbox/pkg/users"
@@ -35,14 +36,17 @@ type Store interface {
// Calls gets paginated Calls.
Calls(ctx context.Context, p CallsParams) (calls []database.ListCallsPRow, totalCount int, err error)
+
+ // CallStats gets call stats by interval.
+ CallStats(ctx context.Context, interval calls.StatsInterval, start, end jsontypes.Time) (*calls.Stats, error)
}
-type store struct {
+type postgresStore struct {
db database.Store
}
-func NewStore(db database.Store) *store {
- return &store{
+func NewStore(db database.Store) *postgresStore {
+ return &postgresStore{
db: db,
}
}
@@ -52,11 +56,11 @@ type storeCtxKey string
const StoreCtxKey storeCtxKey = "store"
func CtxWithStore(ctx context.Context, s Store) context.Context {
- return context.WithValue(ctx, StoreCtxKey, s)
+ return services.WithValue(ctx, StoreCtxKey, s)
}
func FromCtx(ctx context.Context) Store {
- s, ok := ctx.Value(StoreCtxKey).(Store)
+ s, ok := services.Value(ctx, StoreCtxKey).(Store)
if !ok {
panic("no call store in context")
}
@@ -86,7 +90,7 @@ func toAddCallParams(call *calls.Call) database.AddCallParams {
}
}
-func (s *store) AddCall(ctx context.Context, call *calls.Call) error {
+func (s *postgresStore) AddCall(ctx context.Context, call *calls.Call) error {
_, err := rbac.Check(ctx, call, rbac.WithActions(entities.ActionCreate))
if err != nil {
return err
@@ -124,7 +128,7 @@ func (s *store) AddCall(ctx context.Context, call *calls.Call) error {
return nil
}
-func (s *store) CallAudio(ctx context.Context, id uuid.UUID) (*calls.CallAudio, error) {
+func (s *postgresStore) CallAudio(ctx context.Context, id uuid.UUID) (*calls.CallAudio, error) {
_, err := rbac.Check(ctx, &calls.Call{ID: id}, rbac.WithActions(entities.ActionRead))
if err != nil {
return nil, err
@@ -145,7 +149,7 @@ func (s *store) CallAudio(ctx context.Context, id uuid.UUID) (*calls.CallAudio,
}, nil
}
-func (s *store) Call(ctx context.Context, id uuid.UUID) (*calls.Call, error) {
+func (s *postgresStore) Call(ctx context.Context, id uuid.UUID) (*calls.Call, error) {
_, err := rbac.Check(ctx, &calls.Call{ID: id}, rbac.WithActions(entities.ActionRead))
if err != nil {
return nil, err
@@ -195,7 +199,7 @@ type CallsParams struct {
AtLeastSeconds *float32 `json:"atLeastSeconds"`
}
-func (s *store) Calls(ctx context.Context, p CallsParams) (rows []database.ListCallsPRow, totalCount int, err error) {
+func (s *postgresStore) Calls(ctx context.Context, p CallsParams) (rows []database.ListCallsPRow, totalCount int, err error) {
_, err = rbac.Check(ctx, rbac.UseResource(entities.ResourceCall), rbac.WithActions(entities.ActionRead))
if err != nil {
return nil, 0, err
@@ -253,7 +257,7 @@ func (s *store) Calls(ctx context.Context, p CallsParams) (rows []database.ListC
return rows, int(count), err
}
-func (s *store) Delete(ctx context.Context, id uuid.UUID) error {
+func (s *postgresStore) Delete(ctx context.Context, id uuid.UUID) error {
callOwn, err := s.getCallOwner(ctx, id)
if err != nil {
return err
@@ -267,7 +271,7 @@ func (s *store) Delete(ctx context.Context, id uuid.UUID) error {
return database.FromCtx(ctx).DeleteCall(ctx, id)
}
-func (s *store) getCallOwner(ctx context.Context, id uuid.UUID) (calls.Call, error) {
+func (s *postgresStore) getCallOwner(ctx context.Context, id uuid.UUID) (calls.Call, error) {
subInt, err := database.FromCtx(ctx).GetCallSubmitter(ctx, id)
var sub *users.UserID
@@ -277,3 +281,35 @@ func (s *store) getCallOwner(ctx context.Context, id uuid.UUID) (calls.Call, err
}
return calls.Call{ID: id, Submitter: sub}, err
}
+
+func (s *postgresStore) CallStats(ctx context.Context, interval calls.StatsInterval, start, end jsontypes.Time) (*calls.Stats, error) {
+ if !interval.IsValid() {
+ return nil, calls.ErrInvalidInterval
+ }
+
+ cs := &calls.Stats{
+ Interval: interval,
+ }
+
+ _, err := rbac.Check(ctx, cs, rbac.WithActions(entities.ActionRead))
+ if err != nil {
+ return nil, err
+ }
+
+ db := database.FromCtx(ctx)
+
+ dbs, err := db.GetCallStatsByInterval(ctx, string(interval), start.PGTypeTSTZ(), end.PGTypeTSTZ())
+ if err != nil {
+ return nil, err
+ }
+
+ cs.Stats = make([]calls.Stat, 0, len(dbs))
+ for _, st := range dbs {
+ cs.Stats = append(cs.Stats, calls.Stat{
+ Count: st.Count,
+ Time: jsontypes.Time(st.Date.Time),
+ })
+ }
+
+ return cs, nil
+}
diff --git a/pkg/calls/stats.go b/pkg/calls/stats.go
new file mode 100644
index 0000000..7190c5c
--- /dev/null
+++ b/pkg/calls/stats.go
@@ -0,0 +1,46 @@
+package calls
+
+import (
+ "errors"
+
+ "dynatron.me/x/stillbox/internal/jsontypes"
+)
+
+type Stats struct {
+ Stats []Stat `json:"stats"`
+ Interval StatsInterval `json:"interval"`
+}
+
+type Stat struct {
+ Count int64 `json:"count"`
+ Time jsontypes.Time `json:"time"`
+}
+
+var (
+ ErrInvalidInterval = errors.New("invalid interval")
+)
+
+func (s *Stats) GetResourceName() string {
+ return "CallStats"
+}
+
+type StatsInterval string
+
+const (
+ IntervalMinute StatsInterval = "minute"
+ IntervalHour StatsInterval = "hour"
+ IntervalDay StatsInterval = "day"
+ IntervalWeek StatsInterval = "week"
+ IntervalMonth StatsInterval = "month"
+ IntervalQuarter StatsInterval = "quarter"
+ IntervalYear StatsInterval = "year"
+)
+
+func (si StatsInterval) IsValid() bool {
+ switch si {
+ case IntervalMinute, IntervalHour, IntervalDay, IntervalWeek, IntervalMonth, IntervalQuarter, IntervalYear:
+ return true
+ }
+
+ return false
+}
diff --git a/pkg/database/database.go b/pkg/database/database.go
index 874ab30..bf28295 100644
--- a/pkg/database/database.go
+++ b/pkg/database/database.go
@@ -7,6 +7,7 @@ import (
"strings"
"dynatron.me/x/stillbox/pkg/config"
+ "dynatron.me/x/stillbox/pkg/services"
sqlembed "dynatron.me/x/stillbox/sql"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/pgx/v5"
@@ -144,7 +145,8 @@ const DBCtxKey dBCtxKey = "dbctx"
// FromCtx returns the database handle from the provided Context.
func FromCtx(ctx context.Context) Store {
- c, ok := ctx.Value(DBCtxKey).(Store)
+ sv := services.Value(ctx, DBCtxKey)
+ c, ok := sv.(Store)
if !ok {
panic("no DB in context")
}
@@ -154,7 +156,7 @@ func FromCtx(ctx context.Context) Store {
// CtxWithDB returns a Context with the provided database handle.
func CtxWithDB(ctx context.Context, conn Store) context.Context {
- return context.WithValue(ctx, DBCtxKey, conn)
+ return services.WithValue(ctx, DBCtxKey, conn)
}
// IsNoRows is a convenience function that returns whether a returned error is a database
diff --git a/pkg/database/mocks/Store.go b/pkg/database/mocks/Store.go
index be3d593..34021cd 100644
--- a/pkg/database/mocks/Store.go
+++ b/pkg/database/mocks/Store.go
@@ -1520,6 +1520,128 @@ func (_c *Store_GetCallAudioByID_Call) RunAndReturn(run func(context.Context, uu
return _c
}
+// GetCallStatsByInterval provides a mock function with given fields: ctx, truncField, start, end
+func (_m *Store) GetCallStatsByInterval(ctx context.Context, truncField string, start pgtype.Timestamptz, end pgtype.Timestamptz) ([]database.GetCallStatsByIntervalRow, error) {
+ ret := _m.Called(ctx, truncField, start, end)
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetCallStatsByInterval")
+ }
+
+ var r0 []database.GetCallStatsByIntervalRow
+ var r1 error
+ if rf, ok := ret.Get(0).(func(context.Context, string, pgtype.Timestamptz, pgtype.Timestamptz) ([]database.GetCallStatsByIntervalRow, error)); ok {
+ return rf(ctx, truncField, start, end)
+ }
+ if rf, ok := ret.Get(0).(func(context.Context, string, pgtype.Timestamptz, pgtype.Timestamptz) []database.GetCallStatsByIntervalRow); ok {
+ r0 = rf(ctx, truncField, start, end)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]database.GetCallStatsByIntervalRow)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(context.Context, string, pgtype.Timestamptz, pgtype.Timestamptz) error); ok {
+ r1 = rf(ctx, truncField, start, end)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// Store_GetCallStatsByInterval_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCallStatsByInterval'
+type Store_GetCallStatsByInterval_Call struct {
+ *mock.Call
+}
+
+// GetCallStatsByInterval is a helper method to define mock.On call
+// - ctx context.Context
+// - truncField string
+// - start pgtype.Timestamptz
+// - end pgtype.Timestamptz
+func (_e *Store_Expecter) GetCallStatsByInterval(ctx interface{}, truncField interface{}, start interface{}, end interface{}) *Store_GetCallStatsByInterval_Call {
+ return &Store_GetCallStatsByInterval_Call{Call: _e.mock.On("GetCallStatsByInterval", ctx, truncField, start, end)}
+}
+
+func (_c *Store_GetCallStatsByInterval_Call) Run(run func(ctx context.Context, truncField string, start pgtype.Timestamptz, end pgtype.Timestamptz)) *Store_GetCallStatsByInterval_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(context.Context), args[1].(string), args[2].(pgtype.Timestamptz), args[3].(pgtype.Timestamptz))
+ })
+ return _c
+}
+
+func (_c *Store_GetCallStatsByInterval_Call) Return(_a0 []database.GetCallStatsByIntervalRow, _a1 error) *Store_GetCallStatsByInterval_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *Store_GetCallStatsByInterval_Call) RunAndReturn(run func(context.Context, string, pgtype.Timestamptz, pgtype.Timestamptz) ([]database.GetCallStatsByIntervalRow, error)) *Store_GetCallStatsByInterval_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetCallStatsByTalkgroup provides a mock function with given fields: ctx, truncField, start, end
+func (_m *Store) GetCallStatsByTalkgroup(ctx context.Context, truncField string, start pgtype.Timestamptz, end pgtype.Timestamptz) ([]database.GetCallStatsByTalkgroupRow, error) {
+ ret := _m.Called(ctx, truncField, start, end)
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetCallStatsByTalkgroup")
+ }
+
+ var r0 []database.GetCallStatsByTalkgroupRow
+ var r1 error
+ if rf, ok := ret.Get(0).(func(context.Context, string, pgtype.Timestamptz, pgtype.Timestamptz) ([]database.GetCallStatsByTalkgroupRow, error)); ok {
+ return rf(ctx, truncField, start, end)
+ }
+ if rf, ok := ret.Get(0).(func(context.Context, string, pgtype.Timestamptz, pgtype.Timestamptz) []database.GetCallStatsByTalkgroupRow); ok {
+ r0 = rf(ctx, truncField, start, end)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]database.GetCallStatsByTalkgroupRow)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(context.Context, string, pgtype.Timestamptz, pgtype.Timestamptz) error); ok {
+ r1 = rf(ctx, truncField, start, end)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// Store_GetCallStatsByTalkgroup_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCallStatsByTalkgroup'
+type Store_GetCallStatsByTalkgroup_Call struct {
+ *mock.Call
+}
+
+// GetCallStatsByTalkgroup is a helper method to define mock.On call
+// - ctx context.Context
+// - truncField string
+// - start pgtype.Timestamptz
+// - end pgtype.Timestamptz
+func (_e *Store_Expecter) GetCallStatsByTalkgroup(ctx interface{}, truncField interface{}, start interface{}, end interface{}) *Store_GetCallStatsByTalkgroup_Call {
+ return &Store_GetCallStatsByTalkgroup_Call{Call: _e.mock.On("GetCallStatsByTalkgroup", ctx, truncField, start, end)}
+}
+
+func (_c *Store_GetCallStatsByTalkgroup_Call) Run(run func(ctx context.Context, truncField string, start pgtype.Timestamptz, end pgtype.Timestamptz)) *Store_GetCallStatsByTalkgroup_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(context.Context), args[1].(string), args[2].(pgtype.Timestamptz), args[3].(pgtype.Timestamptz))
+ })
+ return _c
+}
+
+func (_c *Store_GetCallStatsByTalkgroup_Call) Return(_a0 []database.GetCallStatsByTalkgroupRow, _a1 error) *Store_GetCallStatsByTalkgroup_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *Store_GetCallStatsByTalkgroup_Call) RunAndReturn(run func(context.Context, string, pgtype.Timestamptz, pgtype.Timestamptz) ([]database.GetCallStatsByTalkgroupRow, error)) *Store_GetCallStatsByTalkgroup_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
// GetCallSubmitter provides a mock function with given fields: ctx, id
func (_m *Store) GetCallSubmitter(ctx context.Context, id uuid.UUID) (*int32, error) {
ret := _m.Called(ctx, id)
diff --git a/pkg/database/partman/partman.go b/pkg/database/partman/partman.go
index 391e284..9b7afcd 100644
--- a/pkg/database/partman/partman.go
+++ b/pkg/database/partman/partman.go
@@ -135,7 +135,7 @@ func New(db database.Store, cfg config.Partition) (*partman, error) {
var _ PartitionManager = (*partman)(nil)
func (pm *partman) Go(ctx context.Context) {
- ctx = entities.CtxWithSubject(ctx, &entities.SystemServiceSubject{Name: "partman"})
+ ctx = entities.CtxWithServiceSubject(ctx, "partman")
tick := time.NewTicker(CheckInterval)
select {
diff --git a/pkg/database/querier.go b/pkg/database/querier.go
index 36e68d7..f2c7e35 100644
--- a/pkg/database/querier.go
+++ b/pkg/database/querier.go
@@ -35,6 +35,8 @@ type Querier interface {
GetAppPrefs(ctx context.Context, appName string, uid int) ([]byte, error)
GetCall(ctx context.Context, id uuid.UUID) (GetCallRow, error)
GetCallAudioByID(ctx context.Context, id uuid.UUID) (GetCallAudioByIDRow, error)
+ GetCallStatsByInterval(ctx context.Context, truncField string, start pgtype.Timestamptz, end pgtype.Timestamptz) ([]GetCallStatsByIntervalRow, error)
+ GetCallStatsByTalkgroup(ctx context.Context, truncField string, start pgtype.Timestamptz, end pgtype.Timestamptz) ([]GetCallStatsByTalkgroupRow, error)
GetCallSubmitter(ctx context.Context, id uuid.UUID) (*int32, error)
GetDatabaseSize(ctx context.Context) (string, error)
GetIncident(ctx context.Context, id uuid.UUID) (Incident, error)
diff --git a/pkg/database/stats.sql.go b/pkg/database/stats.sql.go
new file mode 100644
index 0000000..3db6ae8
--- /dev/null
+++ b/pkg/database/stats.sql.go
@@ -0,0 +1,99 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.27.0
+// source: stats.sql
+
+package database
+
+import (
+ "context"
+
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const getCallStatsByInterval = `-- name: GetCallStatsByInterval :many
+SELECT
+ COUNT(*),
+ date_trunc($1, c.call_date)::TIMESTAMPTZ date
+FROM calls c
+WHERE
+CASE WHEN $2::TIMESTAMPTZ IS NOT NULL THEN
+ c.call_date >= $2 ELSE TRUE END AND
+CASE WHEN $3::TIMESTAMPTZ IS NOT NULL THEN
+ c.call_date <= $3 ELSE TRUE END
+GROUP BY date
+ORDER BY date DESC
+`
+
+type GetCallStatsByIntervalRow struct {
+ Count int64 `json:"count"`
+ Date pgtype.Timestamptz `json:"date"`
+}
+
+func (q *Queries) GetCallStatsByInterval(ctx context.Context, truncField string, start pgtype.Timestamptz, end pgtype.Timestamptz) ([]GetCallStatsByIntervalRow, error) {
+ rows, err := q.db.Query(ctx, getCallStatsByInterval, truncField, start, end)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetCallStatsByIntervalRow
+ for rows.Next() {
+ var i GetCallStatsByIntervalRow
+ if err := rows.Scan(&i.Count, &i.Date); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getCallStatsByTalkgroup = `-- name: GetCallStatsByTalkgroup :many
+SELECT
+ COUNT(*),
+ c.system,
+ c.talkgroup,
+ date_trunc($1, c.call_date)
+FROM calls c
+WHERE
+CASE WHEN $2::TIMESTAMPTZ IS NOT NULL THEN
+ c.call_date >= $2 ELSE TRUE END AND
+CASE WHEN $3::TIMESTAMPTZ IS NOT NULL THEN
+ c.call_date <= $3 ELSE TRUE END
+GROUP BY 2, 3, 4
+ORDER BY 4 DESC
+`
+
+type GetCallStatsByTalkgroupRow struct {
+ Count int64 `json:"count"`
+ System int `json:"system"`
+ Talkgroup int `json:"talkgroup"`
+ DateTrunc pgtype.Interval `json:"dateTrunc"`
+}
+
+func (q *Queries) GetCallStatsByTalkgroup(ctx context.Context, truncField string, start pgtype.Timestamptz, end pgtype.Timestamptz) ([]GetCallStatsByTalkgroupRow, error) {
+ rows, err := q.db.Query(ctx, getCallStatsByTalkgroup, truncField, start, end)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetCallStatsByTalkgroupRow
+ for rows.Next() {
+ var i GetCallStatsByTalkgroupRow
+ if err := rows.Scan(
+ &i.Count,
+ &i.System,
+ &i.Talkgroup,
+ &i.DateTrunc,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
diff --git a/pkg/incidents/incstore/store.go b/pkg/incidents/incstore/store.go
index 81ce14d..5bddd5c 100644
--- a/pkg/incidents/incstore/store.go
+++ b/pkg/incidents/incstore/store.go
@@ -11,6 +11,7 @@ import (
"dynatron.me/x/stillbox/pkg/incidents"
"dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/rbac/entities"
+ "dynatron.me/x/stillbox/pkg/services"
"dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/users"
"github.com/google/uuid"
@@ -67,11 +68,11 @@ type storeCtxKey string
const StoreCtxKey storeCtxKey = "store"
func CtxWithStore(ctx context.Context, s Store) context.Context {
- return context.WithValue(ctx, StoreCtxKey, s)
+ return services.WithValue(ctx, StoreCtxKey, s)
}
func FromCtx(ctx context.Context) Store {
- s, ok := ctx.Value(StoreCtxKey).(Store)
+ s, ok := services.Value(ctx, StoreCtxKey).(Store)
if !ok {
return NewStore()
}
diff --git a/pkg/nexus/nexus.go b/pkg/nexus/nexus.go
index 84d4020..766eaaf 100644
--- a/pkg/nexus/nexus.go
+++ b/pkg/nexus/nexus.go
@@ -39,7 +39,7 @@ func New() *Nexus {
}
func (n *Nexus) Go(ctx context.Context) {
- ctx = entities.CtxWithSubject(ctx, &entities.SystemServiceSubject{Name: "nexus"})
+ ctx = entities.CtxWithServiceSubject(ctx, "nexus")
for {
select {
case call, ok := <-n.callCh:
diff --git a/pkg/rbac/entities/entities.go b/pkg/rbac/entities/entities.go
index 42e1548..62481a0 100644
--- a/pkg/rbac/entities/entities.go
+++ b/pkg/rbac/entities/entities.go
@@ -22,6 +22,7 @@ const (
ResourceAlert = "Alert"
ResourceShare = "Share"
ResourceAPIKey = "APIKey"
+ ResourceCallStats = "CallStats"
ActionRead = "read"
ActionCreate = "create"
@@ -49,6 +50,10 @@ func CtxWithSubject(ctx context.Context, sub Subject) context.Context {
return context.WithValue(ctx, SubjectCtxKey, sub)
}
+func CtxWithServiceSubject(ctx context.Context, name string) context.Context {
+ return CtxWithSubject(ctx, &SystemServiceSubject{Name: name})
+}
+
type subjectContextKey string
const SubjectCtxKey subjectContextKey = "sub"
diff --git a/pkg/rbac/policy/policy.go b/pkg/rbac/policy/policy.go
index 6cf50c1..b6d8634 100644
--- a/pkg/rbac/policy/policy.go
+++ b/pkg/rbac/policy/policy.go
@@ -47,6 +47,9 @@ var Policy = &restrict.PolicyDefinition{
&restrict.Permission{Preset: PresetUpdateOwn},
&restrict.Permission{Preset: PresetDeleteOwn},
},
+ entities.ResourceCallStats: {
+ &restrict.Permission{Action: entities.ActionRead},
+ },
},
},
entities.RoleSubmitter: {
@@ -112,6 +115,7 @@ var Policy = &restrict.PolicyDefinition{
Parents: []string{entities.RoleAdmin},
},
entities.RolePublic: {
+ Description: "Everybody else",
Grants: restrict.GrantsMap{
entities.ResourceShare: {
&restrict.Permission{Action: entities.ActionRead},
diff --git a/pkg/rbac/rbac.go b/pkg/rbac/rbac.go
index b153885..9604bc2 100644
--- a/pkg/rbac/rbac.go
+++ b/pkg/rbac/rbac.go
@@ -5,6 +5,7 @@ import (
"errors"
"dynatron.me/x/stillbox/pkg/rbac/entities"
+ "dynatron.me/x/stillbox/pkg/services"
"github.com/el-mike/restrict/v2"
"github.com/el-mike/restrict/v2/adapters"
@@ -12,7 +13,7 @@ import (
)
var (
- ErrBadSubject = errors.New("bad subject in token")
+ ErrBadSubject = errors.New("bad subject in token")
ErrAccessDenied = errors.New("access denied")
)
@@ -33,7 +34,7 @@ type rbacCtxKey string
const RBACCtxKey rbacCtxKey = "rbac"
func FromCtx(ctx context.Context) RBAC {
- rbac, ok := ctx.Value(RBACCtxKey).(RBAC)
+ rbac, ok := services.Value(ctx, RBACCtxKey).(RBAC)
if !ok {
panic("no RBAC in context")
}
@@ -42,7 +43,7 @@ func FromCtx(ctx context.Context) RBAC {
}
func CtxWithRBAC(ctx context.Context, rbac RBAC) context.Context {
- return context.WithValue(ctx, RBACCtxKey, rbac)
+ return services.WithValue(ctx, RBACCtxKey, rbac)
}
var (
diff --git a/pkg/rest/api.go b/pkg/rest/api.go
index 6fc0fd8..6167393 100644
--- a/pkg/rest/api.go
+++ b/pkg/rest/api.go
@@ -6,6 +6,7 @@ import (
"net/url"
"dynatron.me/x/stillbox/internal/common"
+ "dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/shares"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
@@ -165,6 +166,7 @@ var statusMapping = map[error]errResponder{
shares.ErrNoShare: notFoundErrText,
ErrBadShare: notFoundErrText,
shares.ErrBadType: badRequestErrText,
+ calls.ErrInvalidInterval: badRequestErrText,
}
func autoError(err error) render.Renderer {
diff --git a/pkg/rest/calls.go b/pkg/rest/calls.go
index cdbc5bf..01478cc 100644
--- a/pkg/rest/calls.go
+++ b/pkg/rest/calls.go
@@ -10,8 +10,10 @@ import (
"dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/internal/forms"
+ "dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/calls/callstore"
"dynatron.me/x/stillbox/pkg/database"
+ "dynatron.me/x/stillbox/pkg/stats"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
@@ -34,6 +36,7 @@ func (ca *callsAPI) Subrouter() http.Handler {
r.Get(`/{call:[a-f0-9-]+}`, ca.getAudioRoute)
r.Get(`/{call:[a-f0-9-]+}/{download:download}`, ca.getAudioRoute)
r.Post(`/`, ca.listCalls)
+ r.Get(`/stats/{interval}`, ca.getCallStats)
return r
}
@@ -55,6 +58,29 @@ func (ca *callsAPI) getAudioRoute(w http.ResponseWriter, r *http.Request) {
ca.getAudio(p, w, r)
}
+func (ca *callsAPI) getCallStats(w http.ResponseWriter, r *http.Request) {
+ p := struct {
+ Interval calls.StatsInterval `param:"interval"`
+ }{}
+
+ err := decodeParams(&p, r)
+ if err != nil {
+ wErr(w, r, badRequest(err))
+ return
+ }
+
+ ctx := r.Context()
+ sts := stats.FromCtx(ctx)
+
+ st, err := sts.GetCallStats(ctx, p.Interval)
+ if err != nil {
+ wErr(w, r, autoError(err))
+ return
+ }
+
+ respond(w, r, st)
+}
+
func (ca *callsAPI) getAudio(p getAudioParams, w http.ResponseWriter, r *http.Request) {
if p.CallID == nil {
wErr(w, r, badRequest(ErrNoCall))
diff --git a/pkg/server/server.go b/pkg/server/server.go
index a2553d7..ec8d07b 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -19,9 +19,11 @@ import (
"dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/rbac/policy"
"dynatron.me/x/stillbox/pkg/rest"
+ "dynatron.me/x/stillbox/pkg/services"
"dynatron.me/x/stillbox/pkg/shares"
"dynatron.me/x/stillbox/pkg/sinks"
"dynatron.me/x/stillbox/pkg/sources"
+ "dynatron.me/x/stillbox/pkg/stats"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"dynatron.me/x/stillbox/pkg/users"
@@ -54,6 +56,7 @@ type Server struct {
incidents incstore.Store
share shares.Service
rbac rbac.RBAC
+ stats stats.Stats
}
func New(ctx context.Context, cfg *config.Configuration) (*Server, error) {
@@ -79,13 +82,16 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) {
}
tgCache := tgstore.NewCache(db)
- api := rest.New(cfg.BaseURL.URL())
rbacSvc, err := rbac.New(policy.Policy)
if err != nil {
return nil, err
}
+ callStore := callstore.NewStore(db)
+ statsSvc := stats.NewStats(callStore, stats.DefaultExpiration)
+ api := rest.New(cfg.BaseURL.URL())
+
srv := &Server{
auth: authenticator,
conf: cfg,
@@ -100,9 +106,10 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) {
rest: api,
share: shares.NewService(),
users: ust,
- calls: callstore.NewStore(db),
+ calls: callStore,
incidents: incstore.NewStore(),
rbac: rbacSvc,
+ stats: statsSvc,
}
if cfg.DB.Partition.Enabled {
@@ -158,6 +165,9 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) {
}
func (s *Server) fillCtx(ctx context.Context) context.Context {
+ svc := services.New()
+ ctx = services.CtxWith(ctx, svc)
+
ctx = database.CtxWithDB(ctx, s.db)
ctx = tgstore.CtxWithStore(ctx, s.tgs)
ctx = users.CtxWithStore(ctx, s.users)
@@ -165,6 +175,7 @@ func (s *Server) fillCtx(ctx context.Context) context.Context {
ctx = incstore.CtxWithStore(ctx, s.incidents)
ctx = shares.CtxWithStore(ctx, s.share)
ctx = rbac.CtxWithRBAC(ctx, s.rbac)
+ ctx = stats.CtxWithStats(ctx, s.stats)
return ctx
}
diff --git a/pkg/services/services.go b/pkg/services/services.go
new file mode 100644
index 0000000..9288bf1
--- /dev/null
+++ b/pkg/services/services.go
@@ -0,0 +1,66 @@
+// Package services avoids having to wrap contexts so much for multiple services.
+package services
+
+import (
+ "context"
+ "sync"
+)
+
+type Services interface {
+ Set(key, val any)
+ Value(key any) any
+}
+
+type services struct {
+ sync.RWMutex
+ m map[any]any
+}
+
+func New() Services {
+ return &services{
+ m: make(map[any]any),
+ }
+}
+
+func CtxWith(ctx context.Context, svc Services) context.Context {
+ return context.WithValue(ctx, ServiceKey, svc)
+}
+
+func (s *services) Value(key any) any {
+ s.RLock()
+ defer s.RUnlock()
+
+ return s.m[key]
+}
+
+func (s *services) Set(key, val any) {
+ s.Lock()
+ defer s.Unlock()
+
+ s.m[key] = val
+}
+
+type serviceKey string
+
+const ServiceKey serviceKey = "service"
+
+func WithValue(ctx context.Context, key, val any) context.Context {
+ v := ctx.Value(ServiceKey)
+ if v == nil {
+ return context.WithValue(ctx, key, val)
+ }
+
+ sv := v.(Services)
+ sv.Set(key, val)
+
+ return ctx
+}
+
+func Value(ctx context.Context, key any) any {
+ sv := ctx.Value(ServiceKey)
+ if sv == nil {
+ return ctx.Value(key)
+ }
+
+ return sv.(Services).Value(key)
+}
diff --git a/pkg/shares/service.go b/pkg/shares/service.go
index 245b61d..65f4dfa 100644
--- a/pkg/shares/service.go
+++ b/pkg/shares/service.go
@@ -23,7 +23,7 @@ type service struct {
}
func (s *service) Go(ctx context.Context) {
- ctx = entities.CtxWithSubject(ctx, &entities.SystemServiceSubject{Name: "share"})
+ ctx = entities.CtxWithServiceSubject(ctx, "share")
tick := time.NewTicker(PruneInterval)
diff --git a/pkg/shares/store.go b/pkg/shares/store.go
index f44c524..0f3308e 100644
--- a/pkg/shares/store.go
+++ b/pkg/shares/store.go
@@ -10,6 +10,7 @@ import (
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/rbac/entities"
+ "dynatron.me/x/stillbox/pkg/services"
"dynatron.me/x/stillbox/pkg/users"
"github.com/jackc/pgx/v5"
)
@@ -169,11 +170,11 @@ type storeCtxKey string
const StoreCtxKey storeCtxKey = "store"
func CtxWithStore(ctx context.Context, s Shares) context.Context {
- return context.WithValue(ctx, StoreCtxKey, s)
+ return services.WithValue(ctx, StoreCtxKey, s)
}
func FromCtx(ctx context.Context) Shares {
- s, ok := ctx.Value(StoreCtxKey).(Shares)
+ s, ok := services.Value(ctx, StoreCtxKey).(Shares)
if !ok {
panic("no shares store in context")
}
diff --git a/pkg/stats/stats.go b/pkg/stats/stats.go
new file mode 100644
index 0000000..b1e03bd
--- /dev/null
+++ b/pkg/stats/stats.go
@@ -0,0 +1,80 @@
+package stats
+
+import (
+ "context"
+ "time"
+
+ "dynatron.me/x/stillbox/internal/cache"
+ "dynatron.me/x/stillbox/internal/jsontypes"
+ "dynatron.me/x/stillbox/pkg/calls"
+ "dynatron.me/x/stillbox/pkg/calls/callstore"
+ "dynatron.me/x/stillbox/pkg/services"
+)
+
+const DefaultExpiration = 5 * time.Minute
+
+type Stats interface {
+ GetCallStats(ctx context.Context, interval calls.StatsInterval) (*calls.Stats, error)
+}
+
+type statsKeyType string
+
+const StatsKey statsKeyType = "stats"
+
+func CtxWithStats(ctx context.Context, s Stats) context.Context {
+ return services.WithValue(ctx, StatsKey, s)
+}
+
+func FromCtx(ctx context.Context) Stats {
+ s, ok := services.Value(ctx, StatsKey).(Stats)
+ if !ok {
+ panic("no stats in context")
+ }
+
+ return s
+}
+
+type stats struct {
+ cs callstore.Store
+ sc cache.Cache[calls.StatsInterval, *calls.Stats]
+}
+
+func NewStats(cst callstore.Store, cacheExp time.Duration) *stats {
+ s := &stats{
+ cs: cst,
+ sc: cache.New[calls.StatsInterval, *calls.Stats](cache.WithExpiration(cacheExp)),
+ }
+
+ return s
+}
+
+func (s *stats) GetCallStats(ctx context.Context, interval calls.StatsInterval) (*calls.Stats, error) {
+ st, has := s.sc.Get(interval)
+ if has {
+ return st, nil
+ }
+
+ var start time.Time
+ now := time.Now()
+ switch interval {
+ case calls.IntervalHour:
+ start = now.Add(-24 * time.Hour) // one day
+ case calls.IntervalDay:
+ start = now.Add(-7 * 24 * time.Hour) // one week
+ case calls.IntervalWeek:
+ start = now.Add(-30 * 24 * time.Hour) // one month
+ case calls.IntervalMonth:
+ start = now.Add(-365 * 24 * time.Hour) // one year
+ default:
+ return nil, calls.ErrInvalidInterval
+ }
+
+ st, err := s.cs.CallStats(ctx, interval, jsontypes.Time(start), jsontypes.Time(now))
+ if err != nil {
+ return nil, err
+ }
+
+ s.sc.Set(interval, st)
+
+ return st, nil
+}
diff --git a/pkg/talkgroups/tgstore/store.go b/pkg/talkgroups/tgstore/store.go
index b7dd472..bcad6da 100644
--- a/pkg/talkgroups/tgstore/store.go
+++ b/pkg/talkgroups/tgstore/store.go
@@ -13,6 +13,7 @@ import (
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/rbac/entities"
+ "dynatron.me/x/stillbox/pkg/services"
tgsp "dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/users"
@@ -172,11 +173,11 @@ type storeCtxKey string
const StoreCtxKey storeCtxKey = "store"
func CtxWithStore(ctx context.Context, s Store) context.Context {
- return context.WithValue(ctx, StoreCtxKey, s)
+ return services.WithValue(ctx, StoreCtxKey, s)
}
func FromCtx(ctx context.Context) Store {
- s, ok := ctx.Value(StoreCtxKey).(Store)
+ s, ok := services.Value(ctx, StoreCtxKey).(Store)
if !ok {
panic("no tg store in context")
}
diff --git a/pkg/users/store.go b/pkg/users/store.go
index 5722ab2..7326429 100644
--- a/pkg/users/store.go
+++ b/pkg/users/store.go
@@ -6,6 +6,7 @@ import (
"dynatron.me/x/stillbox/internal/cache"
"dynatron.me/x/stillbox/pkg/database"
+ "dynatron.me/x/stillbox/pkg/services"
)
var (
@@ -49,11 +50,11 @@ type storeCtxKey string
const StoreCtxKey storeCtxKey = "store"
func CtxWithStore(ctx context.Context, s Store) context.Context {
- return context.WithValue(ctx, StoreCtxKey, s)
+ return services.WithValue(ctx, StoreCtxKey, s)
}
func FromCtx(ctx context.Context) Store {
- s, ok := ctx.Value(StoreCtxKey).(Store)
+ s, ok := services.Value(ctx, StoreCtxKey).(Store)
if !ok {
panic("no users store in context")
}
diff --git a/pkg/users/user.go b/pkg/users/user.go
index b5d5d27..e505da5 100644
--- a/pkg/users/user.go
+++ b/pkg/users/user.go
@@ -72,7 +72,7 @@ func (u *User) GetName() string {
}
func (u *User) String() string {
- return "USER:"+u.GetName()
+ return "USER:" + u.GetName()
}
func (u *User) GetRoles() []string {
diff --git a/sql/postgres/queries/stats.sql b/sql/postgres/queries/stats.sql
new file mode 100644
index 0000000..e201da8
--- /dev/null
+++ b/sql/postgres/queries/stats.sql
@@ -0,0 +1,27 @@
+-- name: GetCallStatsByTalkgroup :many
+SELECT
+ COUNT(*),
+ c.system,
+ c.talkgroup,
+ date_trunc(@trunc_field, c.call_date)
+FROM calls c
+WHERE
+CASE WHEN sqlc.narg('start')::TIMESTAMPTZ IS NOT NULL THEN
+ c.call_date >= @start ELSE TRUE END AND
+CASE WHEN sqlc.narg('end')::TIMESTAMPTZ IS NOT NULL THEN
+ c.call_date <= sqlc.narg('end') ELSE TRUE END
+GROUP BY 2, 3, 4
+ORDER BY 4 DESC;
+
+-- name: GetCallStatsByInterval :many
+SELECT
+ COUNT(*),
+ date_trunc(@trunc_field, c.call_date)::TIMESTAMPTZ date
+FROM calls c
+WHERE
+CASE WHEN sqlc.narg('start')::TIMESTAMPTZ IS NOT NULL THEN
+ c.call_date >= @start ELSE TRUE END AND
+CASE WHEN sqlc.narg('end')::TIMESTAMPTZ IS NOT NULL THEN
+ c.call_date <= sqlc.narg('end') ELSE TRUE END
+GROUP BY date
+ORDER BY date DESC;
|