Basic Stats (#1673)

* Refactored ResponseCache profiles into consts

* Refactored code to use an extension method for getting user library ids.

* Started server statistics, added a charting library, and added a table sort column (not finished)

* Refactored code and have a fully working example of sortable headers. Still doesn't work with default sorting state, will work on that later.

* Implemented file size, but it's too expensive, so commented out.

* Added a migration to provide extension and length/size information in the DB to allow for faster stat apis.

* Added the ability to force a library scan from library settings.

* Refactored some apis to provide more of a file breakdown rather than just file size.

* Working on visualization of file breakdown

* Fixed the file breakdown visual

* Fixed up 2 visualizations

* Added back an api for member names, started work on top reads

* Hooked up the other library types and username/days.

* Preparing to remove top reads and refactor into Top users

* Added LibraryId to AppUserProgress to help with complex lookups.

* Added the new libraryId hook into some stats methods

* Updated api methods to use libraryId for progress

* More places where LibraryId is needed

* Added some high level server stats

* Got a ton done on server stats

* Updated default theme (dark) to be the default root variables. This will allow user themes to override just what they want, rather than maintain their own css variables.

* Implemented a monster query for top users by reading time. It's very slow and can be cleaned up likely.

* Hooked up top reads. Code needs a big refactor. Handing off for Robbie treatment and I'll switch to User stats.

* Implemented last 5 recently read series (broken) and added some basic css

* Fixed recently read query

* Cleanup the css a bit, Robbie we need you

* More css love

* Cleaned up DTOs that aren't needed anymore

* Fixed top readers query

* When calculating top readers, don't include read events where nothing is read (0 pages)

* Hooked up the date into GetTopUsers

* Hooked top readers up with days and refactored and cleaned up componets not used

* Fixed up query

* Started on a day by day breakdown, but going to take a break from stats.

* Added a temp task to run some migration manually for stats to work

* Ensure OPDS-PS uses new libraryId for progress reporting

* Fixed a code smell

* Adding some styling

* adding more styles

* Removed some debug stuff from user stats

* Bump qs from 6.5.2 to 6.5.3 in /UI/Web

Bumps [qs](https://github.com/ljharb/qs) from 6.5.2 to 6.5.3.
- [Release notes](https://github.com/ljharb/qs/releases)
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.5.2...v6.5.3)

---
updated-dependencies:
- dependency-name: qs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Tweaked some code for bad data cases

* Refactored a chapter lookup to remove un-needed Volume join in 5 places across the code.

* API push

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
Joe Milazzo 2022-12-07 08:01:49 -06:00 committed by GitHub
parent 4724dc5a76
commit c361e66b35
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
106 changed files with 6898 additions and 170 deletions

Binary file not shown.

413
UI/Web/package-lock.json generated
View file

@ -6188,6 +6188,26 @@
"@sinonjs/commons": "^1.7.0"
}
},
"@swimlane/ngx-charts": {
"version": "20.1.0",
"resolved": "https://registry.npmjs.org/@swimlane/ngx-charts/-/ngx-charts-20.1.0.tgz",
"integrity": "sha512-PY/X+eW+ZEvF3N1kuUVV5H3NHoFXlIWOvNnCKAs874yye//ttgfL/Qf9haHQpki5WIHQtpwn8xM1ylVEQT98bg==",
"requires": {
"@types/d3-shape": "^2.0.0",
"d3-array": "^2.9.1",
"d3-brush": "^2.1.0",
"d3-color": "^2.0.0",
"d3-format": "^2.0.0",
"d3-hierarchy": "^2.0.0",
"d3-interpolate": "^2.0.1",
"d3-scale": "^3.2.3",
"d3-selection": "^2.0.0",
"d3-shape": "^2.0.0",
"d3-time-format": "^3.0.0",
"d3-transition": "^2.0.0",
"tslib": "^2.0.0"
}
},
"@tootallnate/once": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
@ -6298,6 +6318,257 @@
"@types/node": "*"
}
},
"@types/d3": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.0.tgz",
"integrity": "sha512-jIfNVK0ZlxcuRDKtRS/SypEyOQ6UHaFQBKv032X45VvxSJ6Yi5G9behy9h6tNTHTDGh5Vq+KbmBjUWLgY4meCA==",
"dev": true,
"requires": {
"@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": "*"
}
},
"@types/d3-array": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz",
"integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==",
"dev": true
},
"@types/d3-axis": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.1.tgz",
"integrity": "sha512-zji/iIbdd49g9WN0aIsGcwcTBUkgLsCSwB+uH+LPVDAiKWENMtI3cJEWt+7/YYwelMoZmbBfzA3qCdrZ2XFNnw==",
"dev": true,
"requires": {
"@types/d3-selection": "*"
}
},
"@types/d3-brush": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.1.tgz",
"integrity": "sha512-B532DozsiTuQMHu2YChdZU0qsFJSio3Q6jmBYGYNp3gMDzBmuFFgPt9qKA4VYuLZMp4qc6eX7IUFUEsvHiXZAw==",
"dev": true,
"requires": {
"@types/d3-selection": "*"
}
},
"@types/d3-chord": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.1.tgz",
"integrity": "sha512-eQfcxIHrg7V++W8Qxn6QkqBNBokyhdWSAS73AbkbMzvLQmVVBviknoz2SRS/ZJdIOmhcmmdCRE/NFOm28Z1AMw==",
"dev": true
},
"@types/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==",
"dev": true
},
"@types/d3-contour": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.1.tgz",
"integrity": "sha512-C3zfBrhHZvrpAAK3YXqLWVAGo87A4SvJ83Q/zVJ8rFWJdKejUnDYaWZPkA8K84kb2vDA/g90LTQAz7etXcgoQQ==",
"dev": true,
"requires": {
"@types/d3-array": "*",
"@types/geojson": "*"
}
},
"@types/d3-delaunay": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz",
"integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==",
"dev": true
},
"@types/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-NhxMn3bAkqhjoxabVJWKryhnZXXYYVQxaBnbANu0O94+O/nX9qSjrA1P1jbAQJxJf+VC72TxDX/YJcKue5bRqw==",
"dev": true
},
"@types/d3-drag": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.1.tgz",
"integrity": "sha512-o1Va7bLwwk6h03+nSM8dpaGEYnoIG19P0lKqlic8Un36ymh9NSkNFX1yiXMKNMx8rJ0Kfnn2eovuFaL6Jvj0zA==",
"dev": true,
"requires": {
"@types/d3-selection": "*"
}
},
"@types/d3-dsv": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.0.tgz",
"integrity": "sha512-o0/7RlMl9p5n6FQDptuJVMxDf/7EDEv2SYEO/CwdG2tr1hTfUVi0Iavkk2ax+VpaQ/1jVhpnj5rq1nj8vwhn2A==",
"dev": true
},
"@types/d3-ease": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.0.tgz",
"integrity": "sha512-aMo4eaAOijJjA6uU+GIeW018dvy9+oH5Y2VPPzjjfxevvGQ/oRDs+tfYC9b50Q4BygRR8yE2QCLsrT0WtAVseA==",
"dev": true
},
"@types/d3-fetch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.1.tgz",
"integrity": "sha512-toZJNOwrOIqz7Oh6Q7l2zkaNfXkfR7mFSJvGvlD/Ciq/+SQ39d5gynHJZ/0fjt83ec3WL7+u3ssqIijQtBISsw==",
"dev": true,
"requires": {
"@types/d3-dsv": "*"
}
},
"@types/d3-force": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.3.tgz",
"integrity": "sha512-z8GteGVfkWJMKsx6hwC3SiTSLspL98VNpmvLpEFJQpZPq6xpA1I8HNBDNSpukfK0Vb0l64zGFhzunLgEAcBWSA==",
"dev": true
},
"@types/d3-format": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz",
"integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==",
"dev": true
},
"@types/d3-geo": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.0.2.tgz",
"integrity": "sha512-DbqK7MLYA8LpyHQfv6Klz0426bQEf7bRTvhMy44sNGVyZoWn//B0c+Qbeg8Osi2Obdc9BLLXYAKpyWege2/7LQ==",
"dev": true,
"requires": {
"@types/geojson": "*"
}
},
"@types/d3-hierarchy": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.0.tgz",
"integrity": "sha512-g+sey7qrCa3UbsQlMZZBOHROkFqx7KZKvUpRzI/tAp/8erZWpYq7FgNKvYwebi2LaEiVs1klhUfd3WCThxmmWQ==",
"dev": true
},
"@types/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==",
"dev": true,
"requires": {
"@types/d3-color": "*"
}
},
"@types/d3-path": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-2.0.2.tgz",
"integrity": "sha512-3YHpvDw9LzONaJzejXLOwZ3LqwwkoXb9LI2YN7Hbd6pkGo5nIlJ09ul4bQhBN4hQZJKmUpX8HkVqbzgUKY48cg=="
},
"@types/d3-polygon": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.0.tgz",
"integrity": "sha512-D49z4DyzTKXM0sGKVqiTDTYr+DHg/uxsiWDAkNrwXYuiZVd9o9wXZIo+YsHkifOiyBkmSWlEngHCQme54/hnHw==",
"dev": true
},
"@types/d3-quadtree": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.2.tgz",
"integrity": "sha512-QNcK8Jguvc8lU+4OfeNx+qnVy7c0VrDJ+CCVFS9srBo2GL9Y18CnIxBdTF3v38flrGy5s1YggcoAiu6s4fLQIw==",
"dev": true
},
"@types/d3-random": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.1.tgz",
"integrity": "sha512-IIE6YTekGczpLYo/HehAy3JGF1ty7+usI97LqraNa8IiDur+L44d0VOjAvFQWJVdZOJHukUJw+ZdZBlgeUsHOQ==",
"dev": true
},
"@types/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==",
"dev": true,
"requires": {
"@types/d3-time": "*"
}
},
"@types/d3-scale-chromatic": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz",
"integrity": "sha512-dsoJGEIShosKVRBZB0Vo3C8nqSDqVGujJU6tPznsBJxNJNwMF8utmS83nvCBKQYPpjCzaaHcrf66iTRpZosLPw==",
"dev": true
},
"@types/d3-selection": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.3.tgz",
"integrity": "sha512-Mw5cf6nlW1MlefpD9zrshZ+DAWL4IQ5LnWfRheW6xwsdaWOb6IRRu2H7XPAQcyXEx1D7XQWgdoKR83ui1/HlEA==",
"dev": true
},
"@types/d3-shape": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-2.1.3.tgz",
"integrity": "sha512-HAhCel3wP93kh4/rq+7atLdybcESZ5bRHDEZUojClyZWsRuEMo3A52NGYJSh48SxfxEU6RZIVbZL2YFZ2OAlzQ==",
"requires": {
"@types/d3-path": "^2"
}
},
"@types/d3-time": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz",
"integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==",
"dev": true
},
"@types/d3-time-format": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.0.tgz",
"integrity": "sha512-yjfBUe6DJBsDin2BMIulhSHmr5qNR5Pxs17+oW4DoVPyVIXZ+m6bs7j1UVKP08Emv6jRmYrYqxYzO63mQxy1rw==",
"dev": true
},
"@types/d3-timer": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.0.tgz",
"integrity": "sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g==",
"dev": true
},
"@types/d3-transition": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.2.tgz",
"integrity": "sha512-jo5o/Rf+/u6uerJ/963Dc39NI16FQzqwOc54bwvksGAdVfvDrqDpVeq95bEvPtBwLCVZutAEyAtmSyEMxN7vxQ==",
"dev": true,
"requires": {
"@types/d3-selection": "*"
}
},
"@types/d3-zoom": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.1.tgz",
"integrity": "sha512-7s5L9TjfqIYQmQQEUcpMAcBOahem7TRoSO/+Gkz02GbMVuULiZzjF2BOdw291dbO2aNon4m2OdFsRGaCq2caLQ==",
"dev": true,
"requires": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"@types/eslint": {
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.1.tgz",
@ -6347,6 +6618,12 @@
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ=="
},
"@types/geojson": {
"version": "7946.0.10",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz",
"integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==",
"dev": true
},
"@types/graceful-fs": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz",
@ -8197,6 +8474,131 @@
}
}
},
"d3-array": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
"integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
"requires": {
"internmap": "^1.0.0"
}
},
"d3-brush": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-2.1.0.tgz",
"integrity": "sha512-cHLLAFatBATyIKqZOkk/mDHUbzne2B3ZwxkzMHvFTCZCmLaXDpZRihQSn8UNXTkGD/3lb/W2sQz0etAftmHMJQ==",
"requires": {
"d3-dispatch": "1 - 2",
"d3-drag": "2",
"d3-interpolate": "1 - 2",
"d3-selection": "2",
"d3-transition": "2"
}
},
"d3-color": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz",
"integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ=="
},
"d3-dispatch": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz",
"integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA=="
},
"d3-drag": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-2.0.0.tgz",
"integrity": "sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==",
"requires": {
"d3-dispatch": "1 - 2",
"d3-selection": "2"
}
},
"d3-ease": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-2.0.0.tgz",
"integrity": "sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ=="
},
"d3-format": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-2.0.0.tgz",
"integrity": "sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA=="
},
"d3-hierarchy": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-2.0.0.tgz",
"integrity": "sha512-SwIdqM3HxQX2214EG9GTjgmCc/mbSx4mQBn+DuEETubhOw6/U3fmnji4uCVrmzOydMHSO1nZle5gh6HB/wdOzw=="
},
"d3-interpolate": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz",
"integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==",
"requires": {
"d3-color": "1 - 2"
}
},
"d3-path": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-2.0.0.tgz",
"integrity": "sha512-ZwZQxKhBnv9yHaiWd6ZU4x5BtCQ7pXszEV9CU6kRgwIQVQGLMv1oiL4M+MK/n79sYzsj+gcgpPQSctJUsLN7fA=="
},
"d3-scale": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.3.0.tgz",
"integrity": "sha512-1JGp44NQCt5d1g+Yy+GeOnZP7xHo0ii8zsQp6PGzd+C1/dl0KGsp9A7Mxwp+1D1o4unbTTxVdU/ZOIEBoeZPbQ==",
"requires": {
"d3-array": "^2.3.0",
"d3-format": "1 - 2",
"d3-interpolate": "1.2.0 - 2",
"d3-time": "^2.1.1",
"d3-time-format": "2 - 3"
}
},
"d3-selection": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz",
"integrity": "sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA=="
},
"d3-shape": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-2.1.0.tgz",
"integrity": "sha512-PnjUqfM2PpskbSLTJvAzp2Wv4CZsnAgTfcVRTwW03QR3MkXF8Uo7B1y/lWkAsmbKwuecto++4NlsYcvYpXpTHA==",
"requires": {
"d3-path": "1 - 2"
}
},
"d3-time": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz",
"integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==",
"requires": {
"d3-array": "2"
}
},
"d3-time-format": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz",
"integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==",
"requires": {
"d3-time": "1 - 2"
}
},
"d3-timer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz",
"integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA=="
},
"d3-transition": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-2.0.0.tgz",
"integrity": "sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==",
"requires": {
"d3-color": "1 - 2",
"d3-dispatch": "1 - 2",
"d3-ease": "1 - 2",
"d3-interpolate": "1 - 2",
"d3-timer": "1 - 2"
}
},
"damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@ -9820,6 +10222,11 @@
}
}
},
"internmap": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
"integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="
},
"ip": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz",
@ -14591,9 +14998,9 @@
},
"dependencies": {
"qs": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
"integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==",
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz",
"integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==",
"dev": true
}
}

View file

@ -31,6 +31,7 @@
"@microsoft/signalr": "^6.0.2",
"@ng-bootstrap/ng-bootstrap": "^13.0.0",
"@popperjs/core": "^2.11.2",
"@swimlane/ngx-charts": "^20.1.0",
"@types/file-saver": "^2.0.5",
"bootstrap": "^5.2.0",
"bowser": "^2.11.0",
@ -54,6 +55,7 @@
"@angular/cli": "^14.1.1",
"@angular/compiler-cli": "^14.1.1",
"@playwright/test": "^1.23.2",
"@types/d3": "^7.4.0",
"@types/jest": "^27.4.0",
"@types/node": "^17.0.17",
"codelyzer": "^6.0.2",

View file

@ -245,8 +245,8 @@ export class ActionService implements OnDestroy {
* @param chapter Chapter, should have id, pages, volumeId populated
* @param callback Optional callback to perform actions after API completes
*/
markChapterAsRead(seriesId: number, chapter: Chapter, callback?: ChapterActionCallback) {
this.readerService.saveProgress(seriesId, chapter.volumeId, chapter.id, chapter.pages).pipe(take(1)).subscribe(results => {
markChapterAsRead(libraryId: number, seriesId: number, chapter: Chapter, callback?: ChapterActionCallback) {
this.readerService.saveProgress(libraryId, seriesId, chapter.volumeId, chapter.id, chapter.pages).pipe(take(1)).subscribe(results => {
chapter.pagesRead = chapter.pages;
this.toastr.success('Marked as Read');
if (callback) {
@ -261,8 +261,8 @@ export class ActionService implements OnDestroy {
* @param chapter Chapter, should have id, pages, volumeId populated
* @param callback Optional callback to perform actions after API completes
*/
markChapterAsUnread(seriesId: number, chapter: Chapter, callback?: ChapterActionCallback) {
this.readerService.saveProgress(seriesId, chapter.volumeId, chapter.id, 0).pipe(take(1)).subscribe(results => {
markChapterAsUnread(libraryId: number, seriesId: number, chapter: Chapter, callback?: ChapterActionCallback) {
this.readerService.saveProgress(libraryId, seriesId, chapter.volumeId, chapter.id, 0).pipe(take(1)).subscribe(results => {
chapter.pagesRead = 0;
this.toastr.success('Marked as Unread');
if (callback) {

View file

@ -106,8 +106,8 @@ export class ReaderService {
return this.httpClient.get<ChapterInfo>(this.baseUrl + 'reader/chapter-info?chapterId=' + chapterId);
}
saveProgress(seriesId: number, volumeId: number, chapterId: number, page: number, bookScrollId: string | null = null) {
return this.httpClient.post(this.baseUrl + 'reader/progress', {seriesId, volumeId, chapterId, pageNum: page, bookScrollId});
saveProgress(libraryId: number, seriesId: number, volumeId: number, chapterId: number, page: number, bookScrollId: string | null = null) {
return this.httpClient.post(this.baseUrl + 'reader/progress', {libraryId, seriesId, volumeId, chapterId, pageNum: page, bookScrollId});
}
markVolumeRead(seriesId: number, volumeId: number) {

View file

@ -34,6 +34,10 @@ export class ServerService {
return this.httpClient.post(this.baseUrl + 'server/backup-db', {});
}
analyzeFiles() {
return this.httpClient.post(this.baseUrl + 'server/analyze-files', {});
}
checkForUpdate() {
return this.httpClient.get<UpdateVersionEvent>(this.baseUrl + 'server/check-update', {});
}

View file

@ -0,0 +1,84 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { environment } from 'src/environments/environment';
import { UserReadStatistics } from '../statistics/_models/user-read-statistics';
import { PublicationStatusPipe } from '../pipe/publication-status.pipe';
import { map } from 'rxjs';
import { MangaFormatPipe } from '../pipe/manga-format.pipe';
import { FileExtensionBreakdown } from '../statistics/_models/file-breakdown';
import { TopUserRead } from '../statistics/_models/top-reads';
import { ReadHistoryEvent } from '../statistics/_models/read-history-event';
import { ServerStatistics } from '../statistics/_models/server-statistics';
import { StatCount } from '../statistics/_models/stat-count';
import { PublicationStatus } from '../_models/metadata/publication-status';
import { MangaFormat } from '../_models/manga-format';
const publicationStatusPipe = new PublicationStatusPipe();
const mangaFormatPipe = new MangaFormatPipe();
@Injectable({
providedIn: 'root'
})
export class StatisticsService {
baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient) { }
getUserStatistics(userId: number, libraryIds: Array<number> = []) {
// TODO: Convert to httpParams object
let url = 'stats/user/' + userId + '/read';
if (libraryIds.length > 0) url += '?libraryIds=' + libraryIds.join(',');
return this.httpClient.get<UserReadStatistics>(this.baseUrl + url);
}
getServerStatistics() {
return this.httpClient.get<ServerStatistics>(this.baseUrl + 'stats/server/stats');
}
getYearRange() {
return this.httpClient.get<StatCount<number>[]>(this.baseUrl + 'stats/server/count/year').pipe(
map(spreads => spreads.map(spread => {
return {name: spread.value + '', value: spread.count};
})));
}
getTopYears() {
return this.httpClient.get<StatCount<number>[]>(this.baseUrl + 'stats/server/top/years').pipe(
map(spreads => spreads.map(spread => {
return {name: spread.value + '', value: spread.count};
})));
}
getTopUsers(days: number = 0) {
return this.httpClient.get<TopUserRead[]>(this.baseUrl + 'stats/server/top/users?days=' + days);
}
getReadingHistory(userId: number) {
return this.httpClient.get<ReadHistoryEvent[]>(this.baseUrl + 'stats/user/reading-history?userId=' + userId);
}
getPublicationStatus() {
return this.httpClient.get<StatCount<PublicationStatus>[]>(this.baseUrl + 'stats/server/count/publication-status').pipe(
map(spreads => spreads.map(spread => {
return {name: publicationStatusPipe.transform(spread.value), value: spread.count};
})));
}
getMangaFormat() {
return this.httpClient.get<StatCount<MangaFormat>[]>(this.baseUrl + 'stats/server/count/manga-format').pipe(
map(spreads => spreads.map(spread => {
return {name: mangaFormatPipe.transform(spread.value), value: spread.count};
})));
}
getTotalSize() {
return this.httpClient.get<number>(this.baseUrl + 'stats/server/file-size', { responseType: 'text' as 'json'});
}
getFileBreakdown() {
return this.httpClient.get<FileExtensionBreakdown>(this.baseUrl + 'stats/server/file-breakdown');
}
}

View file

@ -0,0 +1,30 @@
import { Directive, EventEmitter, Input, Output } from "@angular/core";
export const compare = (v1: string | number, v2: string | number) => (v1 < v2 ? -1 : v1 > v2 ? 1 : 0);
export type SortColumn<T> = keyof T | '';
export type SortDirection = 'asc' | 'desc' | '';
const rotate: { [key: string]: SortDirection } = { asc: 'desc', desc: 'asc', '': 'asc' };
export interface SortEvent<T> {
column: SortColumn<T>;
direction: SortDirection;
}
@Directive({
selector: 'th[sortable]',
host: {
'[class.asc]': 'direction === "asc"',
'[class.desc]': 'direction === "desc"',
'(click)': 'rotate()',
},
})
export class SortableHeader<T> {
@Input() sortable: SortColumn<T> = '';
@Input() direction: SortDirection = '';
@Output() sort = new EventEmitter<SortEvent<T>>();
rotate() {
this.direction = rotate[this.direction];
this.sort.emit({ column: this.sortable, direction: this.direction });
}
}

View file

@ -0,0 +1,18 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SortableHeader } from './_directives/sortable-header.directive';
@NgModule({
declarations: [
SortableHeader
],
imports: [
CommonModule
],
exports: [
SortableHeader
]
})
export class TableModule { }

View file

@ -24,6 +24,7 @@ import { ManageEmailSettingsComponent } from './manage-email-settings/manage-ema
import { ManageTasksSettingsComponent } from './manage-tasks-settings/manage-tasks-settings.component';
import { ManageLogsComponent } from './manage-logs/manage-logs.component';
import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller';
import { StatisticsModule } from '../statistics/statistics.module';
@ -60,7 +61,9 @@ import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller';
PipeModule,
SidenavModule,
UserSettingsModule, // API-key componet
VirtualScrollerModule
VirtualScrollerModule,
StatisticsModule
],
providers: []
})

View file

@ -29,6 +29,9 @@
<ng-container *ngIf="tab.fragment === TabID.System">
<app-manage-system></app-manage-system>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.Statistics">
<app-server-stats></app-server-stats>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.Tasks">
<app-manage-tasks-settings></app-manage-tasks-settings>
</ng-container>

View file

@ -14,7 +14,9 @@ enum TabID {
System = 'system',
Plugins = 'plugins',
Tasks = 'tasks',
Logs = 'logs'
Logs = 'logs',
Statistics = 'statistics',
}
@Component({
@ -33,6 +35,7 @@ export class DashboardComponent implements OnInit {
{title: 'Email', fragment: TabID.Email},
//{title: 'Plugins', fragment: TabID.Plugins},
{title: 'Tasks', fragment: TabID.Tasks},
{title: 'Statistics', fragment: TabID.Statistics},
{title: 'System', fragment: TabID.System},
];
counter = this.tabs.length + 1;

View file

@ -63,6 +63,12 @@ export class ManageTasksSettingsComponent implements OnInit {
api: defer(() => of(this.downloadService.download('logs', undefined))),
successMessage: ''
},
{
name: 'Analyze Files',
description: 'Runs a long-running task which will analyze files to generate extension and size. This should only be ran once for the v0.7 release.',
api: this.serverService.analyzeFiles(),
successMessage: 'File analysis has been queued'
},
{
name: 'Check for Updates',
description: 'See if there are any Stable releases ahead of your version',

View file

@ -448,7 +448,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
if (!this.incognitoMode) {
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, tempPageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */});
this.readerService.saveProgress(this.libraryId, this.seriesId, this.volumeId, this.chapterId, tempPageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */});
}
}

View file

@ -194,7 +194,7 @@ export class CardDetailDrawerComponent implements OnInit, OnDestroy {
return;
}
this.actionService.markChapterAsRead(this.seriesId, chapter, () => { this.cdRef.markForCheck(); });
this.actionService.markChapterAsRead(this.libraryId, this.seriesId, chapter, () => { this.cdRef.markForCheck(); });
}
markChapterAsUnread(chapter: Chapter) {
@ -202,7 +202,7 @@ export class CardDetailDrawerComponent implements OnInit, OnDestroy {
return;
}
this.actionService.markChapterAsUnread(this.seriesId, chapter, () => { this.cdRef.markForCheck(); });
this.actionService.markChapterAsUnread(this.libraryId, this.seriesId, chapter, () => { this.cdRef.markForCheck(); });
}
handleChapterActionCallback(action: ActionItem<Chapter>, chapter: Chapter) {

View file

@ -1234,7 +1234,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
if (!this.incognitoMode && !this.bookmarkMode) {
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, tempPageNum).pipe(take(1)).subscribe(() => {/* No operation */});
this.readerService.saveProgress(this.libraryId, this.seriesId, this.volumeId, this.chapterId, tempPageNum).pipe(take(1)).subscribe(() => {/* No operation */});
}
}
@ -1382,7 +1382,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
window.history.replaceState({}, '', newRoute);
this.toastr.info('Incognito mode is off. Progress will now start being tracked.');
if (!this.bookmarkMode) {
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
this.readerService.saveProgress(this.libraryId, this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
}
}

View file

@ -197,7 +197,7 @@ export class PdfReaderComponent implements OnInit, OnDestroy {
saveProgress() {
if (this.incognitoMode) return;
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.currentPage).subscribe(() => {});
this.readerService.saveProgress(this.libraryId, this.seriesId, this.volumeId, this.chapterId, this.currentPage).subscribe(() => {});
}
closeReader() {

View file

@ -0,0 +1,42 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'bytes'
})
export class BytesPipe implements PipeTransform {
/**
* Format bytes as human-readable text.
*
* @param bytes Number of bytes.
* @param si True to use metric (SI) units, aka powers of 1000. False to use
* binary (IEC), aka powers of 1024.
* @param dp Number of decimal places to display.
*
* @return Formatted string.
*
* Credit: https://stackoverflow.com/questions/10420352/converting-file-size-in-bytes-to-human-readable-string
*/
transform(bytes: number, si=true, dp=0): string {
const thresh = si ? 1000 : 1024;
if (Math.abs(bytes) < thresh) {
return bytes + ' B';
}
const units = si
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
let u = -1;
const r = 10**dp;
do {
bytes /= thresh;
++u;
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
return bytes.toFixed(dp) + ' ' + units[u];
}
}

View file

@ -15,6 +15,7 @@ import { MangaFormatIconPipe } from './manga-format-icon.pipe';
import { LibraryTypePipe } from './library-type.pipe';
import { SafeStylePipe } from './safe-style.pipe';
import { DefaultDatePipe } from './default-date.pipe';
import { BytesPipe } from './bytes.pipe';
@ -35,6 +36,7 @@ import { DefaultDatePipe } from './default-date.pipe';
LibraryTypePipe,
SafeStylePipe,
DefaultDatePipe,
BytesPipe,
],
imports: [
CommonModule,
@ -55,6 +57,7 @@ import { DefaultDatePipe } from './default-date.pipe';
LibraryTypePipe,
SafeStylePipe,
DefaultDatePipe,
BytesPipe
]
})
export class PipeModule { }

View file

@ -639,7 +639,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
return;
}
this.actionService.markChapterAsRead(this.seriesId, chapter, () => {
this.actionService.markChapterAsRead(this.libraryId, this.seriesId, chapter, () => {
this.setContinuePoint();
});
}
@ -649,7 +649,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
return;
}
this.actionService.markChapterAsUnread(this.seriesId, chapter, () => {
this.actionService.markChapterAsUnread(this.libraryId, this.seriesId, chapter, () => {
this.setContinuePoint();
});
}

View file

@ -1,4 +1,4 @@
import { HttpClient, HttpErrorResponse, HttpEventType } from '@angular/common/http';
import { HttpClient } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { Series } from 'src/app/_models/series';
import { environment } from 'src/environments/environment';
@ -12,9 +12,12 @@ import { download, Download } from '../_models/download';
import { PageBookmark } from 'src/app/_models/readers/page-bookmark';
import { switchMap, takeWhile, throttleTime } from 'rxjs/operators';
import { AccountService } from 'src/app/_services/account.service';
import { BytesPipe } from 'src/app/pipe/bytes.pipe';
export const DEBOUNCE_TIME = 100;
const bytesPipe = new BytesPipe();
export interface DownloadEvent {
/**
* Type of entity being downloaded
@ -235,7 +238,7 @@ export class DownloadService {
}
private async confirmSize(size: number, entityType: DownloadEntityType) {
return (size < this.SIZE_WARNING || await this.confirmService.confirm('The ' + entityType + ' is ' + this.humanFileSize(size) + '. Are you sure you want to continue?'));
return (size < this.SIZE_WARNING || await this.confirmService.confirm('The ' + entityType + ' is ' + bytesPipe.transform(size) + '. Are you sure you want to continue?'));
}
private downloadBookmarks(bookmarks: PageBookmark[]) {
@ -253,38 +256,4 @@ export class DownloadService {
finalize(() => this.finalizeDownloadState(downloadType, subtitle))
);
}
/**
* Format bytes as human-readable text.
*
* @param bytes Number of bytes.
* @param si True to use metric (SI) units, aka powers of 1000. False to use
* binary (IEC), aka powers of 1024.
* @param dp Number of decimal places to display.
*
* @return Formatted string.
*
* Credit: https://stackoverflow.com/questions/10420352/converting-file-size-in-bytes-to-human-readable-string
*/
private humanFileSize(bytes: number, si=true, dp=0) {
const thresh = si ? 1000 : 1024;
if (Math.abs(bytes) < thresh) {
return bytes + ' B';
}
const units = si
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
let u = -1;
const r = 10**dp;
do {
bytes /= thresh;
++u;
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
return bytes.toFixed(dp) + ' ' + units[u];
}
}

View file

@ -150,6 +150,7 @@
</div>
</form>
<div class="modal-footer">
<button type="button" class="btn btn-light" (click)="forceScan()" position="above" ngbTooltip="This will force a scan on the library, treating like a fresh scan">Force Scan</button>
<button type="button" class="btn btn-light" (click)="reset()">Reset</button>
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>

View file

@ -135,6 +135,10 @@ export class LibrarySettingsModalComponent implements OnInit, OnDestroy {
this.modal.close(returnVal);
}
forceScan() {
this.libraryService.scan(this.library.id, true).subscribe(() => this.toastr.info('A forced scan has been started for ' + this.library.name));
}
async save() {
const model = this.libraryForm.value;
model.folders = this.selectedFolders;

View file

@ -0,0 +1,71 @@
<div class="row g-0 mb-2">
<div class="col-8">
<h4><span>Format</span>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="tooltip" role="button" tabindex="0"></i>
</h4>
</div>
<div class="col-4">
<form>
<div class="form-check form-switch mt-2">
<input id="pub-file-breakdown-viz" type="checkbox" class="form-check-input" [formControl]="formControl" role="switch">
<label for="pub-file-breakdown-viz" class="form-check-label">{{formControl.value ? 'Vizualization' : 'Data Table' }}</label>
</div>
</form>
</div>
</div>
<ng-template #tooltip>Non Classified means Kavita has not scanned some files. This occurs on old files existing prior to v0.7. You may need to run a forced scan via Library settings.</ng-template>
<ng-container *ngIf="files$ | async as files">
<ng-container *ngIf="formControl.value; else tableLayout">
<ngx-charts-advanced-pie-chart [results]="vizData2$ | async"></ngx-charts-advanced-pie-chart>
</ng-container>
<ng-template #tableLayout>
<table class="table table-light table-striped table-hover table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="extension" (sort)="onSort($event)">
Extension
</th>
<th scope="col" sortable="format" (sort)="onSort($event)">
Format
</th>
<th scope="col" sortable="totalSize" (sort)="onSort($event)">
Total Size
</th>
<th scope="col" sortable="totalFiles" (sort)="onSort($event)">
Total Files
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of files; let idx = index;">
<td id="adhoctask--{{idx}}">
{{item.extension || 'Not Classified'}}
</td>
<td>
{{item.format | mangaFormat}}
</td>
<td>
{{item.totalSize | bytes}}
</td>
<td>
{{item.totalFiles | number:'1.0-0'}}
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td>Total File Size:</td>
<td></td>
<td></td>
<td>{{((rawData$ | async)?.totalFileSize || 0) | bytes}}</td>
</tr>
</tfoot>
</table>
</ng-template>
</ng-container>

View file

@ -0,0 +1,4 @@
::ng-deep .advanced-pie-legend {
top: unset !important;
transform: unset !important;
}

View file

@ -0,0 +1,95 @@
import { ChangeDetectionStrategy, Component, OnInit, QueryList, ViewChildren } from '@angular/core';
import { FormControl } from '@angular/forms';
import { LegendPosition } from '@swimlane/ngx-charts';
import { Observable, Subject, BehaviorSubject, combineLatest, map, takeUntil, shareReplay } from 'rxjs';
import { MangaFormatPipe } from 'src/app/pipe/manga-format.pipe';
import { MangaFormat } from 'src/app/_models/manga-format';
import { StatisticsService } from 'src/app/_services/statistics.service';
import { SortableHeader, SortEvent, compare } from 'src/app/_single-module/table/_directives/sortable-header.directive';
import { FileExtension, FileExtensionBreakdown } from '../../_models/file-breakdown';
import { PieDataItem } from '../../_models/pie-data-item';
export interface StackedBarChartDataItem {
name: string,
series: Array<PieDataItem>;
}
const mangaFormatPipe = new MangaFormatPipe();
@Component({
selector: 'app-file-breakdown-stats',
templateUrl: './file-breakdown-stats.component.html',
styleUrls: ['./file-breakdown-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FileBreakdownStatsComponent implements OnInit {
@ViewChildren(SortableHeader<PieDataItem>) headers!: QueryList<SortableHeader<PieDataItem>>;
rawData$!: Observable<FileExtensionBreakdown>;
files$!: Observable<Array<FileExtension>>;
vizData$!: Observable<Array<StackedBarChartDataItem>>;
vizData2$!: Observable<Array<PieDataItem>>;
private readonly onDestroy = new Subject<void>();
currentSort = new BehaviorSubject<SortEvent<FileExtension>>({column: 'extension', direction: 'asc'});
currentSort$: Observable<SortEvent<FileExtension>> = this.currentSort.asObservable();
view: [number, number] = [700, 400];
gradient: boolean = true;
showLegend: boolean = true;
showLabels: boolean = true;
isDoughnut: boolean = false;
legendPosition: LegendPosition = LegendPosition.Right;
colorScheme = {
domain: ['#5AA454', '#A10A28', '#C7B42C', '#AAAAAA']
};
formControl: FormControl = new FormControl(true, []);
constructor(private statService: StatisticsService) {
this.rawData$ = this.statService.getFileBreakdown().pipe(takeUntil(this.onDestroy), shareReplay());
this.files$ = combineLatest([this.currentSort$, this.rawData$]).pipe(
map(([sortConfig, data]) => {
return {sortConfig, fileBreakdown: data.fileBreakdown};
}),
map(({ sortConfig, fileBreakdown }) => {
return (sortConfig.column) ? fileBreakdown.sort((a: FileExtension, b: FileExtension) => {
if (sortConfig.column === '') return 0;
const res = compare(a[sortConfig.column], b[sortConfig.column]);
return sortConfig.direction === 'asc' ? res : -res;
}) : fileBreakdown;
}),
takeUntil(this.onDestroy)
);
this.vizData2$ = this.files$.pipe(takeUntil(this.onDestroy), map(data => data.map(d => {
return {name: d.extension || 'Not Categorized', value: d.totalFiles, extra: d.totalSize};
})));
}
ngOnInit(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
onSort(evt: SortEvent<FileExtension>) {
this.currentSort.next(evt);
// Must clear out headers here
this.headers.forEach((header) => {
if (header.sortable !== evt.column) {
header.direction = '';
}
});
}
}

View file

@ -0,0 +1,58 @@
<div class="row g-0 mb-2">
<div class="col-8">
<h4><span>Format</span>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="tooltip" role="button" tabindex="0"></i>
</h4>
</div>
<div class="col-4">
<form>
<div class="form-check form-switch mt-2">
<input id="manga-format-viz" type="checkbox" class="form-check-input" [formControl]="formControl" role="switch">
<label for="manga-format-viz" class="form-check-label">{{formControl.value ? 'Vizualization' : 'Data Table' }}</label>
</div>
</form>
</div>
</div>
<ng-template #tooltip></ng-template>
<ng-container *ngIf="formats$ | async as formats">
<ng-container *ngIf="formControl.value; else tableLayout">
<ngx-charts-pie-chart
[view]="view"
[results]="formats"
[legend]="showLegend"
[legendPosition]="legendPosition"
[labels]="showLabels"
>
</ngx-charts-pie-chart>
</ng-container>
<ng-template #tableLayout>
<table class="table table-light table-striped table-hover table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="name" (sort)="onSort($event)">
Format
</th>
<th scope="col" sortable="value" (sort)="onSort($event)">
Count
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of formats; let idx = index;">
<td id="adhoctask--{{idx}}">
{{item.name}}
</td>
<td>
{{item.value | number:'1.0-0'}}
</td>
</tr>
</tbody>
</table>
</ng-template>
</ng-container>

View file

@ -0,0 +1,72 @@
import { ChangeDetectionStrategy, Component, OnInit, QueryList, ViewChildren } from '@angular/core';
import { FormControl } from '@angular/forms';
import { LegendPosition } from '@swimlane/ngx-charts';
import { Observable, Subject, BehaviorSubject, combineLatest, map, takeUntil } from 'rxjs';
import { StatisticsService } from 'src/app/_services/statistics.service';
import { compare, SortableHeader, SortEvent } from 'src/app/_single-module/table/_directives/sortable-header.directive';
import { PieDataItem } from '../../_models/pie-data-item';
@Component({
selector: 'app-manga-format-stats',
templateUrl: './manga-format-stats.component.html',
styleUrls: ['./manga-format-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MangaFormatStatsComponent implements OnInit {
@ViewChildren(SortableHeader<PieDataItem>) headers!: QueryList<SortableHeader<PieDataItem>>;
formats$!: Observable<Array<PieDataItem>>;
private readonly onDestroy = new Subject<void>();
currentSort = new BehaviorSubject<SortEvent<PieDataItem>>({column: 'value', direction: 'asc'});
currentSort$: Observable<SortEvent<PieDataItem>> = this.currentSort.asObservable();
view: [number, number] = [700, 400];
gradient: boolean = true;
showLegend: boolean = true;
showLabels: boolean = true;
isDoughnut: boolean = false;
legendPosition: LegendPosition = LegendPosition.Right;
colorScheme = {
domain: ['#5AA454', '#A10A28', '#C7B42C', '#AAAAAA']
};
formControl: FormControl = new FormControl(true, []);
constructor(private statService: StatisticsService) {
this.formats$ = combineLatest([this.currentSort$, this.statService.getMangaFormat()]).pipe(
map(([sortConfig, data]) => {
return (sortConfig.column) ? data.sort((a: PieDataItem, b: PieDataItem) => {
if (sortConfig.column === '') return 0;
const res = compare(a[sortConfig.column], b[sortConfig.column]);
return sortConfig.direction === 'asc' ? res : -res;
}) : data;
}),
takeUntil(this.onDestroy)
);
}
ngOnInit(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
onSort(evt: SortEvent<PieDataItem>) {
this.currentSort.next(evt);
// Must clear out headers here
this.headers.forEach((header) => {
if (header.sortable !== evt.column) {
header.direction = '';
}
});
}
}

View file

@ -0,0 +1,54 @@
<div class="row g-0 mb-2">
<div class="col-8">
<h4><span>Publication Status</span>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="tooltip" role="button" tabindex="0"></i>
</h4>
</div>
<div class="col-4">
<form>
<div class="form-check form-switch mt-2">
<input id="pub-status-viz" type="checkbox" class="form-check-input" [formControl]="formControl" role="switch">
<label for="pub-status-viz" class="form-check-label">{{formControl.value ? 'Vizualization' : 'Data Table' }}</label>
</div>
</form>
</div>
</div>
<ng-template #tooltip></ng-template>
<ng-container *ngIf="publicationStatues$ | async as statuses">
<ng-container *ngIf="formControl.value; else tableLayout">
<ngx-charts-advanced-pie-chart
[results]="statuses"
>
</ngx-charts-advanced-pie-chart>
</ng-container>
<ng-template #tableLayout>
<table class="table table-light table-hover table-striped table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="name" (sort)="onSort($event)">
Year
</th>
<th scope="col" sortable="value" (sort)="onSort($event)">
Count
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of statuses; let idx = index;">
<td id="adhoctask--{{idx}}">
{{item.name}}
</td>
<td>
{{item.value | number:'1.0-0'}}
</td>
</tr>
</tbody>
</table>
</ng-template>
</ng-container>

View file

@ -0,0 +1,3 @@
::ng-deep .pie-label {
color: var(--body-text-color) !important;
}

View file

@ -0,0 +1,71 @@
import { ChangeDetectionStrategy, Component, OnInit, QueryList, ViewChildren } from '@angular/core';
import { FormControl } from '@angular/forms';
import { LegendPosition } from '@swimlane/ngx-charts';
import { Observable, Subject, map, takeUntil, combineLatest, BehaviorSubject } from 'rxjs';
import { StatisticsService } from 'src/app/_services/statistics.service';
import { compare, SortableHeader, SortEvent } from 'src/app/_single-module/table/_directives/sortable-header.directive';
import { PieDataItem } from '../../_models/pie-data-item';
@Component({
selector: 'app-publication-status-stats',
templateUrl: './publication-status-stats.component.html',
styleUrls: ['./publication-status-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class PublicationStatusStatsComponent implements OnInit {
@ViewChildren(SortableHeader<PieDataItem>) headers!: QueryList<SortableHeader<PieDataItem>>;
publicationStatues$!: Observable<Array<PieDataItem>>;
private readonly onDestroy = new Subject<void>();
currentSort = new BehaviorSubject<SortEvent<PieDataItem>>({column: 'value', direction: 'asc'});
currentSort$: Observable<SortEvent<PieDataItem>> = this.currentSort.asObservable();
view: [number, number] = [700, 400];
gradient: boolean = true;
showLegend: boolean = true;
showLabels: boolean = true;
isDoughnut: boolean = false;
legendPosition: LegendPosition = LegendPosition.Right;
colorScheme = {
domain: ['#5AA454', '#A10A28', '#C7B42C', '#AAAAAA']
};
formControl: FormControl = new FormControl(true, []);
constructor(private statService: StatisticsService) {
this.publicationStatues$ = combineLatest([this.currentSort$, this.statService.getPublicationStatus()]).pipe(
map(([sortConfig, data]) => {
return (sortConfig.column) ? data.sort((a: PieDataItem, b: PieDataItem) => {
if (sortConfig.column === '') return 0;
const res = compare(a[sortConfig.column], b[sortConfig.column]);
return sortConfig.direction === 'asc' ? res : -res;
}) : data;
}),
takeUntil(this.onDestroy)
);
}
ngOnInit(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
onSort(evt: SortEvent<PieDataItem>) {
this.currentSort.next(evt);
// Must clear out headers here
this.headers.forEach((header) => {
if (header.sortable !== evt.column) {
header.direction = '';
}
});
}
}

View file

@ -0,0 +1,106 @@
<div class="container-fluid">
<div class="row g-0 mt-4 mb-3 d-flex justify-content-around" *ngIf="stats$ | async as stats">
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Series" [clickable]="false" fontClasses="fa-regular fa-calendar" title="Total Series">
{{stats.seriesCount | compactNumber}} Series
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container >
<div class="col-auto mb-2">
<app-icon-and-title label="Total Volumes" [clickable]="false" fontClasses="fas fa-eye" title="Total Volumes">
{{stats.volumeCount | compactNumber}} Volumes
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Chapters" [clickable]="false" fontClasses="fa-regular fa-file-lines" title="Total Chapters">
{{stats.chapterCount | compactNumber}} Chapters
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Files" [clickable]="false" fontClasses="fa-regular fa-file" title="Total Files">
{{stats.totalFiles | compactNumber}} Files
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Size" [clickable]="false" fontClasses="fa-solid fa-weight-scale" title="Total Size">
{{stats.totalSize | bytes}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Genres" [clickable]="false" fontClasses="fa-solid fa-tags" title="Total Genres">
{{stats.totalGenres | compactNumber}} Genres
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Tags" [clickable]="false" fontClasses="fa-solid fa-tags" title="Total Tags">
{{stats.totalTags | compactNumber}} Tags
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total People" [clickable]="false" fontClasses="fa-solid fa-user-tag" title="Total People">
{{stats.totalPeople | compactNumber}} People
</app-icon-and-title>
</div>
</ng-container>
</div>
<div class="grid row g-0 pt-2 pb-2">
<div class="col-auto">
<app-stat-list [data$]="releaseYears$" title="Release Years"></app-stat-list>
</div>
<div class="col-auto">
<app-stat-list [data$]="mostActiveUsers$" title="Most Active Users" label="events"></app-stat-list>
</div>
<div class="col-auto">
<app-stat-list [data$]="mostActiveLibrary$" title="Popular Libraries" label="events"></app-stat-list>
</div>
<div class="col-auto">
<app-stat-list [data$]="mostActiveSeries$" title="Popular Series"></app-stat-list>
</div>
<div class="col-auto">
<app-stat-list [data$]="recentlyRead$" title="Recently Read"></app-stat-list>
</div>
</div>
<div class="row g-0 pt-2 pb-2 ">
<app-top-readers></app-top-readers>
</div>
<div class="row g-0 pt-2 pb-2 " style="height: 242px">
<div class="col-md-6 col-sm-12">
<app-file-breakdown-stats></app-file-breakdown-stats>
</div>
<div class="col-md-6 col-sm-12">
<app-publication-status-stats></app-publication-status-stats>
</div>
</div>
</div>

View file

@ -0,0 +1,10 @@
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, 280px);
grid-gap: 0.5rem;
justify-content: space-evenly;
width: 100%;
overflow-y: auto;
overflow-x: hidden;
align-items: start;
}

View file

@ -0,0 +1,75 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { map, Observable, shareReplay, Subject, takeUntil } from 'rxjs';
import { DownloadService } from 'src/app/shared/_services/download.service';
import { User } from 'src/app/_models/user';
import { StatisticsService } from 'src/app/_services/statistics.service';
import { FileExtensionBreakdown } from '../../_models/file-breakdown';
import { PieDataItem } from '../../_models/pie-data-item';
import { ServerStatistics } from '../../_models/server-statistics';
import { StatCount } from '../../_models/stat-count';
@Component({
selector: 'app-server-stats',
templateUrl: './server-stats.component.html',
styleUrls: ['./server-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ServerStatsComponent implements OnInit, OnDestroy {
releaseYears$!: Observable<Array<PieDataItem>>;
mostActiveUsers$!: Observable<Array<PieDataItem>>;
mostActiveLibrary$!: Observable<Array<PieDataItem>>;
mostActiveSeries$!: Observable<Array<PieDataItem>>;
recentlyRead$!: Observable<Array<PieDataItem>>;
stats$!: Observable<ServerStatistics>;
private readonly onDestroy = new Subject<void>();
constructor(private statService: StatisticsService) {
this.stats$ = this.statService.getServerStatistics().pipe(takeUntil(this.onDestroy), shareReplay());
this.releaseYears$ = this.statService.getTopYears().pipe(takeUntil(this.onDestroy));
this.mostActiveUsers$ = this.stats$.pipe(
map(d => d.mostActiveUsers),
map(userCounts => userCounts.map(count => {
return {name: count.value.username, value: count.count};
})),
takeUntil(this.onDestroy)
);
this.mostActiveLibrary$ = this.stats$.pipe(
map(d => d.mostActiveLibraries),
map(counts => counts.map(count => {
return {name: count.value.name, value: count.count};
})),
takeUntil(this.onDestroy)
);
this.mostActiveSeries$ = this.stats$.pipe(
map(d => d.mostActiveLibraries),
map(counts => counts.map(count => {
return {name: count.value.name, value: count.count};
})),
takeUntil(this.onDestroy)
);
this.recentlyRead$ = this.stats$.pipe(
map(d => d.recentlyRead),
map(counts => counts.map(count => {
return {name: count.name, value: -1};
})),
takeUntil(this.onDestroy)
);
}
ngOnInit(): void {
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
}

View file

@ -0,0 +1,14 @@
<ng-container *ngIf="data$ | async as data">
<div class="card" style="width: 18rem;">
<div class="card-header text-center">
{{title}}
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="tooltip" role="button" tabindex="0" *ngIf="description && description.length > 0"></i>
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item" *ngFor="let item of data">
{{item.name}} <span class="float-end" *ngIf="item.value >= 0">{{item.value}} {{label}}</span>
</li>
</ul>
</div>
</ng-container>
<ng-template #tooltip></ng-template>

View file

@ -0,0 +1,3 @@
.card {
border: var(--bs-card-border-width) solid var(--bs-card-border-color);
}

View file

@ -0,0 +1,27 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { Observable } from 'rxjs';
import { PieDataItem } from '../../_models/pie-data-item';
@Component({
selector: 'app-stat-list',
templateUrl: './stat-list.component.html',
styleUrls: ['./stat-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class StatListComponent {
/**
* Title of list
*/
@Input() title: string = ''
/**
* Optional label to render after value
*/
@Input() label: string = ''
/**
* Optional data to put in tooltip
*/
@Input() description: string = '';
@Input() data$!: Observable<PieDataItem[]>;
}

View file

@ -0,0 +1,35 @@
<div class="row g-0 mb-2 align-items-center">
<div class="col-4">
<h4>Top Readers</h4>
</div>
<div class="col-8">
<form [formGroup]="formGroup" class="d-inline-flex float-end">
<div class="d-flex">
<label for="time-select-top-reads" class="form-check-label"></label>
<select id="time-select-top-reads" class="form-select" formControlName="days"
[class.is-invalid]="formGroup.get('days')?.invalid && formGroup.get('days')?.touched">
<option *ngFor="let item of timePeriods" [value]="item.value">{{item.title}}</option>
</select>
</div>
</form>
</div>
</div>
<ng-container>
<div class="grid row g-0">
<div class="card" *ngFor="let user of (users$ | async)">
<div class="card-header text-center">
{{user.username}}
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">Comics: {{user.comicsTime}} hrs</li>
<li class="list-group-item">Manga: {{user.mangaTime}} hrs</li>
<li class="list-group-item">Books: {{user.booksTime}} hrs</li>
</ul>
</div>
</div>
</ng-container>

View file

@ -0,0 +1,14 @@
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, 280px);
grid-gap: 0.5rem;
justify-content: space-evenly;
width: 100%;
overflow-y: auto;
overflow-x: hidden;
align-items: start;
}
.card {
border: var(--bs-card-border-width) solid var(--bs-card-border-color);
}

View file

@ -0,0 +1,44 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { FormGroup, FormControl } from '@angular/forms';
import { Observable, Subject, takeUntil, switchMap, shareReplay } from 'rxjs';
import { StatisticsService } from 'src/app/_services/statistics.service';
import { TopUserRead } from '../../_models/top-reads';
@Component({
selector: 'app-top-readers',
templateUrl: './top-readers.component.html',
styleUrls: ['./top-readers.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TopReadersComponent implements OnInit, OnDestroy {
formGroup: FormGroup;
timePeriods: Array<{title: string, value: number}> = [{title: 'Last 7 Days', value: 7}, {title: 'Last 30 Days', value: 30}, {title: 'Last 90 Days', value: 90}, {title: 'Last Year', value: 365}, {title: 'All Time', value: 0}];
users$: Observable<TopUserRead[]>;
private readonly onDestroy = new Subject<void>();
constructor(private statsService: StatisticsService, private readonly cdRef: ChangeDetectorRef) {
this.formGroup = new FormGroup({
'days': new FormControl(this.timePeriods[0].value, []),
});
this.users$ = this.formGroup.valueChanges.pipe(
switchMap(_ => this.statsService.getTopUsers(this.formGroup.get('days')?.value as number)),
takeUntil(this.onDestroy),
shareReplay(),
);
}
ngOnInit(): void {
// Needed so that other pipes work
this.users$.subscribe();
this.formGroup.get('days')?.setValue(this.timePeriods[0].value, {emitEvent: true});
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
}

View file

@ -0,0 +1,36 @@
<div class="row g-0 mt-4 mb-3">
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Pages Read" [clickable]="false" fontClasses="fa-regular fa-file-lines" title="Total Pages Read">
{{totalPagesRead | number}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container >
<div class="col-auto mb-2">
<app-icon-and-title label="Time Spent Reading" [clickable]="false" fontClasses="fas fa-eye" title="Time Spent Reading">
{{timeSpentReading}} hours
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Chapters Read" [clickable]="false" fontClasses="fa-regular fa-file-lines" title="Chapters Read">
{{chaptersRead | compactNumber}} Chapters
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Last Active" [clickable]="false" fontClasses="fa-regular fa-calendar" title="Last Active">
{{lastActive | date:'short'}}
</app-icon-and-title>
</div>
</ng-container>
</div>

View file

@ -0,0 +1,22 @@
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
@Component({
selector: 'app-user-stats-info-cards',
templateUrl: './user-stats-info-cards.component.html',
styleUrls: ['./user-stats-info-cards.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserStatsInfoCardsComponent implements OnInit {
@Input() totalPagesRead: number = 0;
@Input() timeSpentReading: number = 0;
@Input() chaptersRead: number = 0;
@Input() lastActive: string = '';
@Input() avgHoursPerWeekSpentReading: number = 0;
constructor() { }
ngOnInit(): void {
}
}

View file

@ -0,0 +1,20 @@
<div class="container-fluid">
<!-- High level stats (use same design as series metadata info cards)-->
<div class="row g-0">
<ng-container *ngIf="userStats$ | async as userStats">
<app-user-stats-info-cards [totalPagesRead]="userStats.totalPagesRead" [timeSpentReading]="userStats.timeSpentReading"
[chaptersRead]="userStats.chaptersRead" [lastActive]="userStats.lastActive"></app-user-stats-info-cards>
</ng-container>
</div>
<!-- <div class="row g-0">
Books Read (this can be chapters read fully)
Number of bookmarks
Last Active Time
Average days reading on server a week
Total Series in want to read list?
</div> -->
</div>

View file

@ -0,0 +1,66 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit, QueryList, ViewChildren } from '@angular/core';
import { map, Observable, of, Subject, takeUntil } from 'rxjs';
import { FilterUtilitiesService } from 'src/app/shared/_services/filter-utilities.service';
import { Series } from 'src/app/_models/series';
import { UserReadStatistics } from 'src/app/statistics/_models/user-read-statistics';
import { SeriesService } from 'src/app/_services/series.service';
import { StatisticsService } from 'src/app/_services/statistics.service';
import { SortableHeader, SortEvent } from 'src/app/_single-module/table/_directives/sortable-header.directive';
import { ReadHistoryEvent } from '../../_models/read-history-event';
type SeriesWithProgress = Series & {progress: number};
@Component({
selector: 'app-user-stats',
templateUrl: './user-stats.component.html',
styleUrls: ['./user-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserStatsComponent implements OnInit, OnDestroy {
@Input() userId!: number;
@ViewChildren(SortableHeader) headers!: QueryList<SortableHeader<SeriesWithProgress>>;
userStats$!: Observable<UserReadStatistics>;
readSeries$!: Observable<ReadHistoryEvent[]>;
private readonly onDestroy = new Subject<void>();
constructor(private readonly cdRef: ChangeDetectorRef, private statService: StatisticsService, private seriesService: SeriesService,
private filterService: FilterUtilitiesService) { }
ngOnInit(): void {
const filter = this.filterService.createSeriesFilter();
filter.readStatus = {read: true, notRead: false, inProgress: true};
this.userStats$ = this.statService.getUserStatistics(this.userId).pipe(takeUntil(this.onDestroy));
this.readSeries$ = this.statService.getReadingHistory(this.userId).pipe(
takeUntil(this.onDestroy),
);
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
onSort({ column, direction }: SortEvent<SeriesWithProgress>) {
// resetting other headers
this.headers.forEach((header) => {
if (header.sortable !== column) {
header.direction = '';
}
});
// sorting countries
// if (direction === '' || column === '') {
// this.countries = COUNTRIES;
// } else {
// this.countries = [...COUNTRIES].sort((a, b) => {
// const res = compare(a[column], b[column]);
// return direction === 'asc' ? res : -res;
// });
// }
}
}

View file

@ -0,0 +1,13 @@
import { MangaFormat } from "src/app/_models/manga-format";
export interface FileExtension {
extension: string;
format: MangaFormat;
totalSize: number;
totalFiles: number;
}
export interface FileExtensionBreakdown {
totalFileSize: number;
fileBreakdown: Array<FileExtension>;
}

View file

@ -0,0 +1,4 @@
export enum Mode {
Visualization = 0,
Table = 1
}

View file

@ -0,0 +1,5 @@
export interface PieDataItem {
name: string;
value: number;
extra?: any;
}

View file

@ -0,0 +1,10 @@
export interface ReadHistoryEvent {
userId: number;
userName: string;
seriesName: string;
seriesId: number;
libraryId: number;
readDate: string;
chapterId: number;
chapterNumber: string;
}

View file

@ -0,0 +1,19 @@
import { Library } from "src/app/_models/library";
import { Series } from "src/app/_models/series";
import { User } from "src/app/_models/user";
import { StatCount } from "./stat-count";
export interface ServerStatistics {
chapterCount: number;
volumeCount: number;
seriesCount: number;
totalFiles: number;
totalSize: number;
totalGenres: number;
totalTags: number;
totalPeople: number;
mostActiveUsers: Array<StatCount<User>>;
mostActiveLibraries: Array<StatCount<Library>>;
mostActiveSeries: Array<StatCount<Series>>;
recentlyRead: Array<Series>;
}

View file

@ -0,0 +1,4 @@
export interface StatCount<T> {
value: T;
count: number;
}

View file

@ -0,0 +1,7 @@
export interface TopUserRead {
userId: number;
username: string;
mangaTime: number;
comicsTime: number;
booksTime: number;
}

View file

@ -0,0 +1,8 @@
export interface UserReadStatistics {
totalPagesRead: number;
timeSpentReading: number;
favoriteGenres: Array<any>;
chaptersRead: number;
lastActive: string;
avgHoursPerWeekSpentReading: number;
}

View file

@ -0,0 +1,48 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UserStatsComponent } from './_components/user-stats/user-stats.component';
import { TableModule } from '../_single-module/table/table.module';
import { UserStatsInfoCardsComponent } from './_components/user-stats-info-cards/user-stats-info-cards.component';
import { SharedModule } from '../shared/shared.module';
import { ServerStatsComponent } from './_components/server-stats/server-stats.component';
import { NgxChartsModule } from '@swimlane/ngx-charts';
import { StatListComponent } from './_components/stat-list/stat-list.component';
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { PublicationStatusStatsComponent } from './_components/publication-status-stats/publication-status-stats.component';
import { ReactiveFormsModule } from '@angular/forms';
import { MangaFormatStatsComponent } from './_components/manga-format-stats/manga-format-stats.component';
import { FileBreakdownStatsComponent } from './_components/file-breakdown-stats/file-breakdown-stats.component';
import { PipeModule } from '../pipe/pipe.module';
import { TopReadersComponent } from './_components/top-readers/top-readers.component';
@NgModule({
declarations: [
UserStatsComponent,
UserStatsInfoCardsComponent,
ServerStatsComponent,
StatListComponent,
PublicationStatusStatsComponent,
MangaFormatStatsComponent,
FileBreakdownStatsComponent,
TopReadersComponent
],
imports: [
CommonModule,
TableModule,
SharedModule,
NgbTooltipModule,
ReactiveFormsModule,
PipeModule,
// Server only
NgxChartsModule
],
exports: [
UserStatsComponent,
ServerStatsComponent
]
})
export class StatisticsModule { }

View file

@ -308,6 +308,9 @@
<ng-container *ngIf="tab.fragment === FragmentID.Devices">
<app-manage-devices></app-manage-devices>
</ng-container>
<ng-container *ngIf="tab.fragment === FragmentID.Stats">
<app-user-stats [userId]="1"></app-user-stats>
</ng-container>
</ng-template>
</li>
</ul>

View file

@ -25,6 +25,7 @@ enum FragmentID {
Clients = 'clients',
Theme = 'theme',
Devices = 'devices',
Stats = 'stats',
}
@ -60,6 +61,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
{title: '3rd Party Clients', fragment: FragmentID.Clients},
{title: 'Theme', fragment: FragmentID.Theme},
{title: 'Devices', fragment: FragmentID.Devices},
{title: 'Stats', fragment: FragmentID.Stats},
];
active = this.tabs[1];
opdsEnabled: boolean = false;

View file

@ -17,6 +17,7 @@ import { ChangePasswordComponent } from './change-password/change-password.compo
import { ChangeEmailComponent } from './change-email/change-email.component';
import { ChangeAgeRestrictionComponent } from './change-age-restriction/change-age-restriction.component';
import { RestrictionSelectorComponent } from './restriction-selector/restriction-selector.component';
import { StatisticsModule } from '../statistics/statistics.module';
@NgModule({
@ -43,6 +44,8 @@ import { RestrictionSelectorComponent } from './restriction-selector/restriction
NgbCollapseModule,
ColorPickerModule, // User prefernces background color
StatisticsModule,
PipeModule,
SidenavModule,

View file

@ -2,6 +2,7 @@
// Import themes which define the css variables we use to customize the app
@import './theme/themes/light';
@import './theme/themes/dark';
// Import colors for overrides of bootstrap theme
@ -36,6 +37,7 @@
@import './theme/components/sidenav';
@import './theme/components/carousel';
@import './theme/components/offcanvas';
@import './theme/components/table';
@import './theme/utilities/utilities';

View file

@ -0,0 +1,23 @@
// th[sortable].desc:before, th[sortable].asc:before {
// content: "";
// display: block;
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAmxJREFUeAHtmksrRVEUx72fH8CIGQNJkpGUUmakDEiZSJRIZsRQmCkTJRmZmJgQE0kpX0D5DJKJgff7v+ru2u3O3vvc67TOvsdatdrnnP1Y///v7HvvubdbUiIhBISAEBACQkAICAEhIAQ4CXSh2DnyDfmCPEG2Iv9F9MPlM/LHyAecdyMzHYNwR3fdNK/OH9HXl1UCozD24TCvILxizEDWIEzA0FcM8woCgRrJCoS5PIwrANQSMAJX1LEI9bqpQo4JYNFFKRSvIgsxHDVnqZgIkPnNBM0rIGtYk9YOOsqgbgepRCfdbmFtqhFkVEDVPjJp0+Z6e6hRHhqBKgg6ZDCvYBygVmUoEGoh5JTRvIJwhJo1aUOoh4CLPMyvxxi7EWOMgnCGsXXI1GIXlZUYX7ucU+kbR8NW8lh3O7cue0Pk32MKndfUxQFAwxdirk3fHappAnc0oqDPzDfGTBrCfHP04dM4oTV8cxr0SVzH9FF07xD3ib6xCDE+M+aUcVygtWzzbtGX2rPBrEUYfecfQkaFzYi6HjVnGBdtL7epqAlc1+jRdAap74RrnPc4BCijttY2tRcdN0g17w7HqZrXhdJTYAuS3hd8z+vKgK3V1zWPae0mZDMykadBn1hTQBLnZNwVrJpSe/NwEeDsEwCctEOsJTsgxLvCqUl2ACftEGvJDgjxrnBqkh3ASTvEWrIDQrwrnJpkB3DSDrGW7IAQ7wqnJtkBnLRztejXXVu4+mxz/nQ9jR1w5VB86ejLTFcnnDwhzV+F6T+CHZlx6THSjn76eyyBIOPHyDakhBAQAkJACAgBISAEhIAQYCLwC8JxpAmsEGt6AAAAAElFTkSuQmCC) no-repeat;
// background-size: 22px;
// width: 22px;
// height: 22px;
// float: left;
// }
th[sortable] {
cursor: pointer;
}
th[sortable].desc:after, th[sortable].asc:after {
content: "";
display: block;
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAmxJREFUeAHtmksrRVEUx72fH8CIGQNJkpGUUmakDEiZSJRIZsRQmCkTJRmZmJgQE0kpX0D5DJKJgff7v+ru2u3O3vvc67TOvsdatdrnnP1Y///v7HvvubdbUiIhBISAEBACQkAICAEhIAQ4CXSh2DnyDfmCPEG2Iv9F9MPlM/LHyAecdyMzHYNwR3fdNK/OH9HXl1UCozD24TCvILxizEDWIEzA0FcM8woCgRrJCoS5PIwrANQSMAJX1LEI9bqpQo4JYNFFKRSvIgsxHDVnqZgIkPnNBM0rIGtYk9YOOsqgbgepRCfdbmFtqhFkVEDVPjJp0+Z6e6hRHhqBKgg6ZDCvYBygVmUoEGoh5JTRvIJwhJo1aUOoh4CLPMyvxxi7EWOMgnCGsXXI1GIXlZUYX7ucU+kbR8NW8lh3O7cue0Pk32MKndfUxQFAwxdirk3fHappAnc0oqDPzDfGTBrCfHP04dM4oTV8cxr0SVzH9FF07xD3ib6xCDE+M+aUcVygtWzzbtGX2rPBrEUYfecfQkaFzYi6HjVnGBdtL7epqAlc1+jRdAap74RrnPc4BCijttY2tRcdN0g17w7HqZrXhdJTYAuS3hd8z+vKgK3V1zWPae0mZDMykadBn1hTQBLnZNwVrJpSe/NwEeDsEwCctEOsJTsgxLvCqUl2ACftEGvJDgjxrnBqkh3ASTvEWrIDQrwrnJpkB3DSDrGW7IAQ7wqnJtkBnLRztejXXVu4+mxz/nQ9jR1w5VB86ejLTFcnnDwhzV+F6T+CHZlx6THSjn76eyyBIOPHyDakhBAQAkJACAgBISAEhIAQYCLwC8JxpAmsEGt6AAAAAElFTkSuQmCC) no-repeat;
background-size: 22px;
width: 22px;
height: 22px;
float: right;
}

View file

@ -240,7 +240,4 @@
/* List Card Item */
--card-list-item-bg-color: linear-gradient(180deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.15) 1%, rgba(0,0,0,0) 100%);
}