Compare commits

...
366 Commits
Author SHA1 Message Date
tdgao 7eebd690d0 fix: game version dropdown width 2026-05-29 13:22:47 -06:00
tdgao 3631e1c9af fix: no option to see release/all game versions in selected filter dropdown 2026-05-29 13:20:50 -06:00
tdgao 2b175beef6 fix: download threshold not width fit 2026-05-29 13:17:13 -06:00
tdgao 7057966c75 refactor: pnpm prepr:frontend 2026-05-29 11:38:41 -06:00
tdgao 54223be674 refactor: colocate query builder url with analytics-dashboard component 2026-05-29 11:37:05 -06:00
tdgao 8ea5420818 refactor: move query builder types into types file and move components into components/analytics-dashboard 2026-05-29 11:28:04 -06:00
tdgao cdb19ee189 refactor: rename types 2026-05-29 11:19:05 -06:00
tdgao d4a9c1f4e4 refactor: pnpm prepr 2026-05-29 11:15:38 -06:00
tdgao d2d3de9646 refactor: name main components to index.vue and keep folder name as component name 2026-05-29 11:10:02 -06:00
tdgao 78851f3140 refactor: same rename for analytics table for consistency 2026-05-29 10:44:16 -06:00
tdgao fb2d894b24 refactor: rename folder that with components to match main component name 2026-05-29 10:42:04 -06:00
tdgao 089cb080c5 fix: playtime was choosing y axis ticks in seconds instead of hours 2026-05-29 10:29:44 -06:00
tdgao f70aa9c999 revert: format number composable change 2026-05-29 10:21:31 -06:00
tdgao 1192f6ba1a Merge branch 'main' into truman/analytics 2026-05-29 10:13:57 -06:00
tdgao a9cb04a542 i18n pass 2026-05-29 10:13:25 -06:00
tdgao 6bb9f73e9b refactor: split up giant analytics provider with utils 2026-05-28 22:27:24 -06:00
tdgao bde052c83c fix: legend is stale after resetting query 2026-05-28 21:14:26 -06:00
tdgao 57faade025 refactor: split up large analytics chart and table component files into smaller components and ts modules 2026-05-28 21:03:18 -06:00
tdgao 8bb030eb60 refactor: kabab case 2026-05-28 20:23:08 -06:00
tdgao 246dbde369 fix: bugs around selected series from table not persisting with timeframe or filter changes 2026-05-28 20:19:41 -06:00
tdgao 8609dea0c4 fix: cant click in gap in toggle 2026-05-28 20:02:02 -06:00
tdgao 1af1426385 pnpm prepr 2026-05-28 19:59:02 -06:00
tdgao 64d641ff2d feat: improve analytics events grouping logic for modrinth events to be close to target 2026-05-28 19:57:07 -06:00
tdgao f3d03dcb58 feat: add gap for date picker 2 calendar view 2026-05-28 19:35:24 -06:00
tdgao e79dc7fba8 feat: remove gap inside chart tooltip 2026-05-28 19:27:34 -06:00
tdgao 3c2dcfa31c feat: event icon consume scroll for tooltip panel 2026-05-28 19:25:10 -06:00
tdgao a50c2ee4b9 feat: change hidden legend items to not contribute to ratio percentages 2026-05-28 19:24:59 -06:00
tdgao ef51b74a51 refactor: pnpm prepr 2026-05-28 19:20:19 -06:00
tdgao e790b95c5b feat: use tabs for game version select, and remove prev period when change breakdown or project selection 2026-05-28 19:18:07 -06:00
tdgao 598190ffaa feat: improve y axis formatting for playtime and others 2026-05-28 19:11:09 -06:00
tdgao 898c7d0ff1 fix: playtime y axis labels 2026-05-28 18:43:20 -06:00
tdgao fd4f95e105 feat: remove show more and show less in legend, always show all 2026-05-28 18:24:03 -06:00
tdgao ac636a617c fix: project events incorrectly toggling on for first page load 2026-05-28 17:59:39 -06:00
tdgao b53429c21c fix: z-index and padding issues 2026-05-28 16:52:40 -06:00
tdgao 16ed0c7355 feat: dont conditionally show multiselect selection actions 2026-05-28 16:34:47 -06:00
tdgao 3d4ed94cc6 fix: cannot click in graph 2026-05-28 16:34:32 -06:00
tdgao db2cff2596 fix: loading bar not fading away 2026-05-28 16:20:13 -06:00
tdgao 64bbeb7f5a feat: update controls dropdown to have fainter border 2026-05-28 16:02:39 -06:00
tdgao 09ae1eb3a8 refactor: remove chart event height being controlled by parent 2026-05-28 16:01:01 -06:00
tdgao 2e5c880a39 Merge branch 'main' into truman/analytics 2026-05-28 15:13:06 -06:00
tdgao d11eaef7ed performance: cache project version options to fix lag on open sub menu 2026-05-28 15:01:58 -06:00
tdgao 600dc9be2b fix: refreshing causes multiple projects to do breakdown=none 2026-05-28 14:19:46 -06:00
tdgao 88e467bbe8 performance: deter non-critical fetches to after analytics is in 2026-05-28 14:13:02 -06:00
tdgao 6f436df6bb feat: implement user get all projects 2026-05-28 13:51:20 -06:00
tdgao 384b890dc8 feat: implement using new backend facets route 2026-05-28 13:18:41 -06:00
tdgao 6e2450e025 small: style 2026-05-28 13:17:18 -06:00
tdgao 8c74aefd36 fix: styles in events table 2026-05-28 13:08:38 -06:00
tdgao b4e423890f fix date picker typing into time input 2026-05-28 13:08:29 -06:00
tdgao 7142232251 feat: implement graph controls dropdown 2026-05-28 12:47:48 -06:00
tdgao b51e11e32e fix: a number of qa issues
- Hide project events based on visible project legend/table selection
- Filter project status events by end status and add explicit copy for approved, private, and unlisted
- Style Modrinth analytics events with blue icon, marker, guide, and range borders
- Add scroll fade shadows to analytics chart and event tooltips
- Show previous-period date range in the chart tooltip
- Make project breakdown conditional on multiple selected projects and allow no breakdown when none are selected
- Add breakdown selection actions and fix “Group by day” copy
2026-05-28 12:14:06 -06:00
tdgao 10e326cdd7 fix: qa issues 2026-05-28 10:53:16 -06:00
tdgao da6c86f3c0 feat: fix emptys state height content shift 2026-05-28 10:48:27 -06:00
tdgao f96ce42fb8 fix: frontend still filtering after backend already filters 2026-05-28 10:20:24 -06:00
tdgao 37f4475abe fix: add user select none on graph and fix zoom into download threshold input 2026-05-27 16:23:34 -06:00
tdgao 69dcbb9524 feat: tap to toggle event tooltip 2026-05-27 16:23:01 -06:00
tdgao 74b9af8230 feat: improve chart tooltip date range label formatter to be much more consistent 2026-05-27 16:08:10 -06:00
tdgao a2cef735dc fix: callback to Organization instead of org id 2026-05-27 16:04:19 -06:00
tdgao 8691a28ca4 fix: date picker scrolling page after picking date in mobile by suppressing focus 2026-05-27 16:02:37 -06:00
tdgao c288151b6d fix: using plural instead of single for project breakdown 2026-05-27 15:58:53 -06:00
tdgao f0b15aaa31 feat: keep tooltip open after drag in mobile 2026-05-27 15:38:13 -06:00
tdgao 09310915ea fix: dropdown filter bar briefly empty when switching pages in mobile 2026-05-27 15:37:56 -06:00
tdgao d5b2a71688 fix: remove project breakdownoption as it is default breakdown when none are selected 2026-05-27 15:35:30 -06:00
tdgao 87a7c79ca7 fix: chart tooltip scroll in mobile 2026-05-27 15:28:10 -06:00
tdgao 4156077b60 fix: toggle not having touch manipulation 2026-05-27 15:10:45 -06:00
tdgao 9f3a8878e5 feat: add click to show chart event for mobile 2026-05-27 15:10:38 -06:00
tdgao 7d1d4f412f fix: prevent double tap to zoom on inputs 2026-05-27 15:01:18 -06:00
tdgao 8d197666f6 fix: tooltip expanding page width briefly 2026-05-27 14:57:57 -06:00
tdgao b299460e75 feat: improve performance on analytics events grouping 2026-05-27 14:23:17 -06:00
tdgao 6b2564b49f fix: analytics events grouping causing overflow 2026-05-27 14:16:44 -06:00
tdgao 172bc6f126 feat: deduplicate version uploads that have same version number and are uploaded on same day 2026-05-27 13:45:05 -06:00
tdgao 3ec41cddc5 small: 2 sig figs on playtime 2026-05-27 13:30:47 -06:00
tdgao 55f2f40648 fix-mobile: improve graph touch interactions 2026-05-27 13:23:13 -06:00
tdgao 67e7ff45f9 feat: default to projects breakdown instead of no breakdown with multiple projects 2026-05-27 13:01:18 -06:00
tdgao dc27bc044c refactor: pnpm prepr 2026-05-27 12:56:30 -06:00
tdgao ce20e90eaf feat: date picker default read only 2026-05-27 12:41:49 -06:00
tdgao 8908ffc0f6 feat: add close on select for time frame picker mobile 2026-05-27 12:39:17 -06:00
tdgao 2f227f91a4 add close on select prop 2026-05-27 12:39:03 -06:00
tdgao 1e345cd7ef feat-mobile: style fixes 2026-05-27 12:34:54 -06:00
tdgao e42b060290 feat: grey out dimmed lined on legend item hover 2026-05-27 12:03:14 -06:00
tdgao b3cc052bac feat: implement analytics performance updates, including facets, and v3 user projects 2026-05-27 12:01:55 -06:00
tdgao 0a232b6855 feat: default hide project events if there are multiple projects, and default show if only 1 project 2026-05-27 11:27:29 -06:00
tdgao f885ac43c1 fix: event icon hover color 2026-05-27 11:13:01 -06:00
tdgao c8d5fad6fa Merge branch 'main' into truman/analytics 2026-05-27 09:54:40 -06:00
tdgao 10a880ebe2 feat: remove clear project and breakdown 2026-05-27 09:46:30 -06:00
tdgao b8cdd2520b small: add select none 2026-05-27 09:41:04 -06:00
tdgao a00b47afca feat: handle events overflow 2026-05-27 09:40:23 -06:00
tdgao 124d271128 feat: implement better mobile view design for query builder 2026-05-26 20:16:29 -06:00
tdgao ec08069c87 feat: implement project side events 2026-05-26 18:15:57 -06:00
tdgao d50d79b40b fix: dropdown filter bar scroll while search 2026-05-26 17:09:32 -06:00
tdgao 3512eeaa18 fix-mobile: smaller metric card font 2026-05-26 17:00:01 -06:00
tdgao 5cf169aae4 Merge branch 'main' into truman/analytics 2026-05-26 16:44:15 -06:00
tdgao be89aee53c fix-mobile: combobox and multiselect scroll bug when mobile search bar open, fix timeframe picker mobile pick date, and dropdown filter bar click outside to close 2026-05-26 16:37:27 -06:00
tdgao 6a1f510442 feat: consolidate is mobile ref into context 2026-05-26 16:04:05 -06:00
tdgao b778e40b96 fix-mobile: fix multiselect scroll on mobile 2026-05-26 13:53:20 -06:00
tdgao b3e0a90a1f feat-mobile: time frame picker to use different start and end date pickers for mobile 2026-05-26 13:52:56 -06:00
tdgao eb8650391b feat-mobile: dropdown filter to replace dropdown for submenus on small screen 2026-05-26 13:49:27 -06:00
tdgao 73944fb54b feat-mobile: remove drag to select time frame in graph 2026-05-26 13:48:46 -06:00
tdgao e7271bbe35 feat: enabling prev period will cross out prev for current ones already crossed out 2026-05-26 12:55:19 -06:00
tdgao e377b27e10 feat: add right side checkmark for dropdown filtr bar 2026-05-25 13:07:35 -06:00
tdgao e88b02415d fix: multiple breakdown legend and tooltip labels 2026-05-25 12:59:40 -06:00
tdgao 4dc980c3a1 fix: cannot unpin on page click 2026-05-25 12:42:25 -06:00
tdgao 4a5a2d9407 fix: couple UX things for multiple breakdown 2026-05-25 12:12:12 -06:00
tdgao 8d336728fd feat: (to playtest) implement multiple breakdowns 2026-05-25 11:51:58 -06:00
tdgao 1e443b1e9b fix: chart icon add hover delay 2026-05-25 11:16:36 -06:00
tdgao ccbc448f0b fix: multiselect and combobox sizes 2026-05-25 11:14:05 -06:00
tdgao 88913219a3 refactor: pnpm prepr 2026-05-25 09:36:01 -06:00
tdgao 27514c3350 Merge branch 'main' into truman/analytics 2026-05-25 09:35:35 -06:00
tdgao bb2d006d0f feat: force legend order and graph colour for monetization 2026-05-25 09:34:03 -06:00
tdgao 64d549d93c fix: wrong all projects icon 2026-05-25 09:27:38 -06:00
tdgao 47f3ebced3 feat: update analytics tooltip 2026-05-25 09:23:32 -06:00
tdgao f0c8e2ea1f fix: still download type in filter 2026-05-25 09:22:19 -06:00
tdgao 9ab31afd08 refactor: pnpm prepr 2026-05-23 10:58:05 -06:00
tdgao 47a95282d3 feat: change playtime column to display in hours 2026-05-23 10:56:03 -06:00
tdgao b10307a6b1 feat: update export csv filename 2026-05-23 10:53:00 -06:00
tdgao 1859e52eae feat: implement format table playtime column 2026-05-23 10:47:48 -06:00
tdgao 57ee2a290f fix: brief select items empty state when switch breakdown 2026-05-23 10:37:48 -06:00
tdgao a2a87a7546 fix: remove number if its just top 1 2026-05-23 10:29:49 -06:00
tdgao 576603a6ab refactor: pnpm prepr 2026-05-23 10:26:14 -06:00
tdgao 5020dd2756 feat: implement prev period analytics fetch into the same current period fetch by shifting start date 2026-05-23 10:26:06 -06:00
tdgao ccbfd2fd71 fix: show top 8 button in graph not defaulting to top 8 for other metrics 2026-05-23 10:17:28 -06:00
tdgao aaa249e2c3 feat: persist table sort by and sort direction 2026-05-23 10:05:28 -06:00
tdgao bdd8e8f05f fix: formatting label for table 2026-05-23 09:52:41 -06:00
tdgao 12a8faa307 fix: rename download type to download reason 2026-05-23 09:36:51 -06:00
tdgao 080c65c624 feat: implement show top 8 button in graph subheading 2026-05-22 12:43:48 -06:00
tdgao 65bf029f36 feat: change project selection to reset to show top 8 only if reconciled down to 0 items 2026-05-22 12:14:07 -06:00
tdgao e74bde4ecc fix: tooltip styles 2026-05-22 11:59:32 -06:00
tdgao 30d5284280 fix: when breakdown switches, reset selected series 2026-05-22 11:57:34 -06:00
tdgao 23092f0e46 fix: style on prev item value in legend 2026-05-22 11:28:39 -06:00
tdgao a9e8389c3f feat: keep table selected persisted in query parameter 2026-05-22 11:26:31 -06:00
tdgao b2f420c6d5 Merge branch 'truman/analytics' of github.com:modrinth/code into truman/analytics 2026-05-22 11:12:37 -06:00
tdgao 7e7673c9a5 fix: adjust style 2026-05-22 11:12:35 -06:00
Calum H. (IMB11) 21443756d5 feat: extract TimeFramePicker to packages/ui 2026-05-22 13:10:00 +01:00
tdgao 9363a5c337 feat: implement show prev period toggle 2026-05-21 22:54:49 -06:00
tdgao b39c6898e4 fix: padding 2026-05-21 22:54:28 -06:00
tdgao 3090e69419 feat: add time in analytics event and normalize date/time so its correct to timezones 2026-05-21 22:25:18 -06:00
tdgao ad1f2d8f28 add clear button 2026-05-21 22:15:54 -06:00
tdgao a4903cb700 refactor: pnpm prepr 2026-05-21 22:01:37 -06:00
tdgao 5203518ead feat: mobile pass 2026-05-21 21:59:24 -06:00
tdgao 0cec8fab7a performance: implement limit on how many lines can be shown in graph 2026-05-21 21:50:23 -06:00
tdgao 300e598bec feat: add unmonetized explaination tooltip 2026-05-21 21:33:01 -06:00
tdgao ffff579eea feat: add subheading in graph for showing N items from table 2026-05-21 20:41:17 -06:00
tdgao 5175f3981e fix: keep scroll position with selection action row appearing when selecting one 2026-05-21 20:41:02 -06:00
tdgao 901a5ef779 fix: polish admin analytic events 2026-05-21 17:08:28 -06:00
tdgao 83e0c8dabe fix: small styles 2026-05-21 16:47:16 -06:00
tdgao 9a96d78b98 fix: adjust padding in combobox and multiselect and fix not unfocusing when deselect 2026-05-21 16:40:59 -06:00
tdgao f248a08417 fix: bugs with ratio mode and hiding chart lines 2026-05-21 16:39:59 -06:00
tdgao 36043c1d0b feat: dont show select all when its just one item in section 2026-05-21 16:25:42 -06:00
tdgao 7ab65aa769 fix: QA polish issues around style and copy 2026-05-21 16:16:24 -06:00
tdgao 55306e36d6 Merge branch 'main' into truman/analytics 2026-05-21 15:41:31 -06:00
tdgao 7816fb7473 performance: only do queries to populate graph first before other analytics queries 2026-05-20 23:02:41 -06:00
tdgao 5b98442aba Merge branch 'main' into truman/analytics 2026-05-20 16:44:49 -06:00
tdgao aa39bab614 fix: add back in batch with 300ms interval for projects to prevent backend rate limiting 2026-05-20 16:30:47 -06:00
tdgao f6643640fd refactor: pnpm prepr 2026-05-20 16:25:08 -06:00
tdgao 0baf998218 fix: custom time range picker being weird 2026-05-20 16:24:00 -06:00
tdgao d58fb917d0 feat: dropdown filter always open on one side and improve styles 2026-05-20 16:13:41 -06:00
tdgao eff50b60f1 performance: use precomputed map for project version options to remove first hover lag 2026-05-20 15:56:55 -06:00
tdgao b89862e7dd fix: add loading state for filter and parallel fetch 2026-05-20 15:48:57 -06:00
tdgao 09bdf35fbf fix: getting project name for project versions 2026-05-20 15:38:59 -06:00
tdgao a9d03a3c6d feat: sort projects in project dropdown 2026-05-20 15:29:08 -06:00
tdgao 067350a5d9 feat: improve table sorting 2026-05-20 15:28:50 -06:00
tdgao 3058c9a2c1 feat: remove breakdown = none when there are multiple projects 2026-05-20 15:20:54 -06:00
tdgao 58ec28297b fix: query filter options not coming in from analytics fetch 2026-05-20 15:15:06 -06:00
tdgao 02f2163158 refactor: pnpm prepr 2026-05-19 15:03:27 -06:00
tdgao 683476a90e Merge branch 'main' into truman/analytics 2026-05-19 15:02:12 -06:00
tdgao a9526b8021 feat: add analytics table search 2026-05-19 14:49:26 -06:00
tdgao bfc1520d2d performance: batch analytics requests by 15 project ids, with 150 ms delay between, so backend is happy 2026-05-19 14:39:34 -06:00
tdgao 175da90290 performance: add caching for metric types so switching between them is snappy 2026-05-19 14:21:33 -06:00
tdgao a999795125 feat: add shift click to select a range in table 2026-05-19 13:52:37 -06:00
tdgao fb3497e51f fix: table checked items not in graph if 0 2026-05-19 13:42:58 -06:00
tdgao 0aa16341ad performance: reduce memory usage from project versions query by keeping only whats necessary 2026-05-19 13:39:30 -06:00
tdgao a414e2face performance: frontend memory reduction 2026-05-19 13:16:43 -06:00
tdgao 724570be8a feat: implement breakdown = none 2026-05-19 13:08:42 -06:00
tdgao 28f59d3127 fix: export csv to always be dropdown 2026-05-19 12:45:56 -06:00
tdgao 06e2bcfa58 feat: add hover legend item to highlight line in tooltip 2026-05-19 12:42:31 -06:00
tdgao 428056ee52 feat: fix click to cross out in legend 2026-05-19 12:41:21 -06:00
tdgao bcad49a9a3 feat: change download source to use user_agent 2026-05-19 11:54:49 -06:00
tdgao 96ff380985 feat (playtest): table component controls what graph shows 2026-05-18 21:32:38 -06:00
tdgao 78b5c8307f feat (playtest): cross out legend items in tooltip and allow hide/show in tooltip 2026-05-18 21:08:46 -06:00
tdgao 1a626830bc fix: preview is incorrect when selecting same date in range date picker 2026-05-18 20:43:43 -06:00
Truman GaoandGitHub 94ee519ced Merge branch 'main' into truman/analytics
Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
2026-05-18 20:00:41 -06:00
tdgao 5f6cfaf2bd feat: implement filter by top 8 2026-05-18 19:49:00 -06:00
tdgao 7e928cf35f fix: focus styles pt2 2026-05-18 19:26:50 -06:00
tdgao 77f7ac9c6a fix: focus styles 2026-05-18 19:25:04 -06:00
tdgao d07d184d8a feat: keep crossed out legend items still shown in tooltip but also crossed out 2026-05-18 19:02:35 -06:00
tdgao f509967107 feat: implement right side checkmark for multiselect 2026-05-18 18:54:49 -06:00
tdgao 30ac9374d0 feat: rename breakdown "none" to "project" 2026-05-18 18:40:33 -06:00
tdgao 7da4730e91 fix: query filter project version lag and borked virtualization 2026-05-18 16:31:50 -06:00
tdgao 9c66aa5105 Revert "feat: use user_agent for download source"
This reverts commit d6dc8a99f1.
2026-05-18 16:22:52 -06:00
tdgao d6dc8a99f1 feat: use user_agent for download source 2026-05-18 16:03:24 -06:00
tdgao c1afb22e66 feat: MORE PADDING fixes 2026-05-18 15:50:03 -06:00
tdgao 5dcae6ba28 feat: a bunch of style fixes to combobox, multiselect and dropdown filter bar 2026-05-18 15:39:34 -06:00
tdgao 51a1daaaf7 feat: add overlay scrollbar for combobox 2026-05-18 14:48:58 -06:00
tdgao aa493a3272 feat: reduce padding on multiselect 2026-05-18 14:42:31 -06:00
tdgao ba6c8be1fb feat: reduce combobox padding 2026-05-18 12:56:47 -06:00
tdgao 29ea3ffe5e fix: type error 2026-05-18 12:46:34 -06:00
tdgao 4b2d1b672d feat: hook up analytics events backend 2026-05-16 10:21:41 -06:00
tdgao ec2c1bd017 fix: remove labels annoying 2026-05-15 17:54:59 -06:00
tdgao f3911a4235 feat: focus title input on analytics create event modal 2026-05-15 17:46:28 -06:00
tdgao 79a5ef49e0 feat: improve analytics events admin page 2026-05-15 17:40:44 -06:00
tdgao 42ec8a8573 feat: implement mock analytics events 2026-05-15 17:33:41 -06:00
tdgao 46d66c97b8 pnpm prepr 2026-05-15 17:18:09 -06:00
tdgao 72940f3f49 fix: switch column order 2026-05-15 17:14:37 -06:00
tdgao 8d06d46dd9 feat: implement analytics events admin page 2026-05-15 17:04:48 -06:00
tdgao 8bf965847b feat: improve custom time frame range select 2026-05-15 16:10:41 -06:00
tdgao c5c199aed0 feat: add tooltip for other item 2026-05-15 15:18:55 -06:00
tdgao 0e4b384790 feat: more performance optimization to analytics dashboard state, chart, and table 2026-05-15 15:14:13 -06:00
tdgao 60f4ca117c fix: defer commit in query builder/filter and style fixes 2026-05-15 14:53:39 -06:00
tdgao 5c9b9f8638 feat: implement hover on legend to highlight graph 2026-05-15 13:39:44 -06:00
tdgao b93aa1df37 feat: implement big performance improvement for table 2026-05-15 13:00:00 -06:00
tdgao 1786b37e22 fix: custom timeframe picker 2026-05-15 11:56:26 -06:00
tdgao 10f2c7f8a0 fix: change to > 1 day 2026-05-15 11:34:34 -06:00
tdgao a4172cd028 feat: implement auto switching temporarily to group by day for renvenue metric and disable revenue metric for time range < 2 days 2026-05-15 11:32:43 -06:00
tdgao 9664eb104a fix: legend colors moving between metric by determining color based on only downloads metric index 2026-05-15 11:13:04 -06:00
tdgao ccc2ff89e4 feat: add reset timeframe button 2026-05-15 10:48:12 -06:00
tdgao ebebec7522 fix: qa 2026-05-15 10:37:56 -06:00
tdgao 4f607d8a11 feat: implement query params for graph component settings 2026-05-15 10:17:31 -06:00
tdgao 04110fc810 feat: shorten time to not repeat am/pm 2026-05-14 21:06:48 -06:00
tdgao b15afcb42c fix: adjust charts event tooltip 2026-05-14 14:06:47 -06:00
tdgao 38664c191a feat: implement tooltip scroll 2026-05-14 13:38:00 -06:00
tdgao 309f0fcfbb fix: loading state text on table 2026-05-14 12:33:38 -06:00
tdgao 99a8a6773b fix: graph overflow issues 2026-05-14 12:31:47 -06:00
tdgao 4b289613a7 fix: multiselect keyboard use 2026-05-14 12:15:13 -06:00
tdgao 816349c1c3 feat: improve dropdown filter bar and multiselect performance 2026-05-14 12:03:58 -06:00
tdgao 2c5d4781ac refactor: pnpm prepr 2026-05-13 14:00:42 -06:00
tdgao a74316cf45 fix: loading state showing empty state text 2026-05-13 14:00:14 -06:00
tdgao 228bf74fa8 feat: implement analytics range highlight 2026-05-13 13:51:12 -06:00
tdgao dda69609fb fix: border radius 2026-05-13 13:06:14 -06:00
tdgao 626d55ced8 Merge branch 'main' into truman/analytics 2026-05-13 12:59:00 -06:00
tdgao 3714cb3fda feat: implement modrinth sided events 2026-05-13 12:54:17 -06:00
tdgao 48c957dc54 feat: implement showing project for project version breakdown/filter when there are multiple projects 2026-05-13 12:10:18 -06:00
tdgao 077cc60023 revert: no longer switch breakdown/filter option if same category 2026-05-13 11:31:28 -06:00
tdgao 5a7a6e66d3 feat: implement windows history for query builder 2026-05-13 11:16:46 -06:00
tdgao 5fc9f47c0c feat: implement click and drag to select date range 2026-05-13 11:08:34 -06:00
tdgao 8261b8bc4e feat: implement multiselect sections headers, project select org sections, and project options icons 2026-05-12 23:44:08 -06:00
tdgao 4788369c41 feat: filter out draft projects from analytics 2026-05-12 22:27:34 -06:00
tdgao 02aa76a3d0 feat: add project icon to project select 2026-05-12 22:22:23 -06:00
tdgao ae0cea0003 fix: loading state to include legend in blur 2026-05-12 18:52:41 -06:00
tdgao 635ae777d8 feat: graph style updates and dont show year in x axis unless more than 2 year timeframe 2026-05-12 18:07:02 -06:00
tdgao c9397010b9 fix: clear selected X above number when appropriate 2026-05-12 17:30:31 -06:00
tdgao 28d56e8f3a fix: dont show project status filter when from project settings/analytics 2026-05-12 17:07:27 -06:00
tdgao 3c406c82d6 feat: more aggressively trim allowed minimum group by option 2026-05-12 17:02:31 -06:00
tdgao e38d7af02c feat: clear button to clear breakdown 2026-05-12 16:57:57 -06:00
tdgao 92ad6e2258 feat: implement reset query button 2026-05-12 16:47:40 -06:00
tdgao cba07e4062 feat: add pick and plop for date range start/end dates 2026-05-11 16:54:30 -06:00
tdgao aad7b13edf refactor: pnpm prepr 2026-05-08 15:29:46 -06:00
tdgao 843529bbfa feat: implement show more batching excess lines into "Other" bucket 2026-05-08 15:27:02 -06:00
tdgao df196cc2be feat: if ratio mode, dont show total 2026-05-08 15:10:20 -06:00
tdgao 37f9564cdd feat: add max date to be today and show todays date 2026-05-08 14:52:53 -06:00
tdgao 2e3d6a035e feat: implement dot for showing todays date 2026-05-08 14:52:46 -06:00
tdgao 5d20585bed feat: implement cancel/apply for custom timeframe range picker 2026-05-08 14:47:59 -06:00
tdgao d8c311e33b feat: implement ratio mode 2026-05-08 14:17:52 -06:00
tdgao eac9daa0ca feat: implement filter by project status 2026-05-08 14:00:04 -06:00
tdgao 8b5cf0489c feat: implement better no projects empty state 2026-05-08 13:09:29 -06:00
tdgao 1c5b37b3b0 feat: update date/no date sorting 2026-05-08 11:58:00 -06:00
tdgao 8181b05890 feat: implement virtualized table 2026-05-08 11:29:10 -06:00
tdgao 089b37d0f6 fix: graph colors to be unique 2026-05-08 11:01:38 -06:00
tdgao e4799173ff feat: dont show prev period for all time/ when no prev period 2026-05-08 01:15:10 -06:00
tdgao c7e141c177 fix: switch breakdown/filter selection if one already exists 2026-05-08 01:04:52 -06:00
tdgao 9fea83b338 fix: graph and table seeding 0s from filter options instead of backend results 2026-05-08 00:54:56 -06:00
tdgao b78f16e20c refactor: pnpm prepr 2026-05-08 00:32:55 -06:00
tdgao 40cfcaf44d feat: disable toggle line if only one exists, and show graph as soon as loaded 2026-05-08 00:29:20 -06:00
tdgao 5392577ea3 feat: add loader colors for graph 2026-05-08 00:07:27 -06:00
tdgao 174915823d fix: stat card overflow 2026-05-08 00:06:59 -06:00
tdgao 0c5a3faf63 feat: implement better x-axis and tooltip showing timeframe 2026-05-08 00:00:02 -06:00
tdgao 8fb19616dd feat: implement hover guide on graph 2026-05-07 12:51:13 -06:00
tdgao 45c8af9fbc feat: default 30 days by day, and shift click to in legend to show all but clicked one 2026-05-07 12:44:55 -06:00
tdgao f77788f8b7 fix: exclude draft projects from analytics 2026-05-07 12:42:36 -06:00
tdgao 6ad653ab0f feat: implement above num downloads in preview dropdown 2026-05-07 12:39:05 -06:00
tdgao 722feb9baf feat: add country downloads above... filter 2026-05-07 11:57:25 -06:00
tdgao 6114d175c8 feat: add total to tooltip and handle overflow 2026-05-07 11:43:26 -06:00
tdgao 3ef95b6faf fix: graph offset 2026-05-07 11:34:33 -06:00
tdgao 067d307992 fix: remove spinner 2026-05-07 11:31:55 -06:00
tdgao 4fdf7d7b32 fix: QA issues - show year if relevant, press enter to run query, hide project column when not relevant, and reasonable group by selection 2026-05-07 11:29:57 -06:00
tdgao 6a71e77b33 feat: implement calendar only date picker and custom range timeframe for with date picker 2026-05-07 10:47:11 -06:00
tdgao 7e324ae7a8 Merge branch 'main' into truman/analytics 2026-05-07 09:47:32 -06:00
tdgao 2482ea5e5d feat: implement generic DropdownFilterBar component 2026-05-06 10:30:01 -06:00
tdgao dbcf26790e feat: improve filter bar styles 2026-05-06 09:35:08 -06:00
tdgao 8aba8bdc7e feat: implement pretty loading state 2026-05-04 17:35:31 -06:00
tdgao b2067bc693 fix: back fill 0 when there is no data at a point in time 2026-05-04 16:29:05 -06:00
tdgao 662075cc86 fix: small style update 2026-05-04 16:06:40 -06:00
tdgao 45d697efda fix: dropdown cancel button 2026-05-04 16:04:44 -06:00
tdgao 5be9d2482d feat: wire up downloads.monetized 2026-05-04 15:50:05 -06:00
tdgao 1e41ae1082 feat: improve graph UI 2026-05-04 15:43:08 -06:00
tdgao cfb1fbfa82 feat: hook up backend for new metrics 2026-05-04 15:35:55 -06:00
tdgao b63db68eed Merge branch 'main' into truman/analytics 2026-05-04 14:58:32 -06:00
tdgao bcb8566848 feat: improve how time frame picker opens for custom timeframe 2026-05-04 14:45:14 -06:00
tdgao b1e3d09587 feat: only displays loaders and game versions relevant to project + qa style fixes 2026-05-04 14:27:44 -06:00
tdgao ab1183cefe feat: update analytics query to match new format 2026-05-04 14:05:14 -06:00
tdgao d7cbe1798b feat: all switch between seeing only release or all game versions + QA style fixes 2026-05-04 13:37:48 -06:00
tdgao 27b3cc1b48 fix: qa issues 2026-05-04 12:51:55 -06:00
tdgao 1ddb8b29cc feat: implement independant all projects select and num selected row 2026-05-04 12:23:45 -06:00
tdgao 47b41e9e80 refactor: pnpm prepr 2026-04-30 21:39:12 -06:00
tdgao 646f803a86 feat; change how the query filter options are fetched 2026-04-30 21:34:32 -06:00
tdgao 3a55afb5cd fix: include date disabled in analytics table 2026-04-30 21:07:08 -06:00
tdgao 872c9de392 fix: analytics query bugs 2026-04-30 21:02:43 -06:00
tdgao de192d6fc5 feat: add pinned icon in chart tooltip 2026-04-30 20:34:03 -06:00
tdgao 48c652df46 feat: more concise axis labels 2026-04-30 20:33:50 -06:00
tdgao e1d3b6ff76 feat: update stat card component styles 2026-04-30 20:13:36 -06:00
tdgao a3b28367cb refactor: pnpm prepr 2026-04-30 20:02:58 -06:00
tdgao 3b3fcc10e5 fix: table breakdown column not actual breakdown name 2026-04-30 19:58:18 -06:00
tdgao dcab62d165 feat: implement include date 2026-04-30 19:54:04 -06:00
tdgao e43286195c refactor: use chips and button component in table 2026-04-30 19:42:03 -06:00
tdgao aa868e3255 fix: graph loading state on refresh 2026-04-30 19:35:19 -06:00
tdgao 12c18d6d93 feat: use tabs component in graph 2026-04-30 19:26:01 -06:00
tdgao f8c93ec074 feat: implement tabs component 2026-04-30 19:25:54 -06:00
tdgao c7908fb70d fix: spacing 2026-04-30 19:13:24 -06:00
tdgao 424bf6b19c fix: empty state y axis 2026-04-30 19:06:53 -06:00
tdgao 1c2d8c34c6 feat: implement filter by game version downloads above value 2026-04-30 17:29:47 -06:00
tdgao 0b4ac38b76 feat: hide filters as needed based on breakdown 2026-04-30 17:14:24 -06:00
tdgao 109cb0508d feat: add query filter sub menu empty state 2026-04-30 16:44:07 -06:00
tdgao 0ec0714d57 feat: add category filter sorting and get download source filter options 2026-04-30 16:34:11 -06:00
tdgao 99e2d657ef fix: filter sub menu first opens badly 2026-04-30 16:29:32 -06:00
tdgao 88c2a30109 feat: implement loader type options and remove "All" option 2026-04-30 16:28:11 -06:00
tdgao fdf4ac6410 feat: small style improvements 2026-04-30 14:29:11 -06:00
tdgao 4a61322617 feat: implement version number display and also searchable category filters 2026-04-30 13:55:42 -06:00
tdgao 560a4423a1 refactor: move query filter components into co-located folder 2026-04-30 13:41:05 -06:00
tdgao 3c366d135c fix: custom timeframe UX and styles 2026-04-30 13:38:59 -06:00
tdgao 7d0c04ac4d refactor: separate out query builder timeframe inputs 2026-04-30 13:27:26 -06:00
tdgao 56876cc130 feat: implement custom timeframe pickers and projects above num downloads 2026-04-30 13:17:36 -06:00
tdgao 31476c2150 feat: implement country filter 2026-04-30 12:49:01 -06:00
tdgao 6f2117c7a5 feat: implement bottom slot in combobox and multiselect dropdowns 2026-04-30 12:37:38 -06:00
tdgao b3eea2c38c feat: add anchored line to analytics chart and remove darkening points 2026-04-29 13:48:36 -06:00
tdgao c8ad19cb7e fix: version_id breakdown incorrectly named 2026-04-29 13:37:14 -06:00
tdgao 05cd3002c3 feat: update combobox active item to green 2026-04-29 13:28:02 -06:00
tdgao 4b31e465cb fix: remove projects from filters 2026-04-29 12:31:38 -06:00
tdgao 2eee097870 feat: improve query filter and how it commits changes 2026-04-29 12:25:37 -06:00
tdgao a0144f5c3c feat: small style improvements 2026-04-29 12:15:53 -06:00
tdgao 4414ea8f46 feat: implement better chart tooltip menu anchoring and dragging on mobile 2026-04-29 11:09:25 -06:00
tdgao c6a47493c0 feat: improve query filter menu styles and switch out of Menu 2026-04-29 10:52:57 -06:00
tdgao 0c991708cb fix: QA Issues 2026-04-29 10:13:35 -06:00
tdgao 0cedf43604 feat: add dropdown width and min dropdown width on combobox and multiselect 2026-04-29 10:13:25 -06:00
tdgao 628712e580 refactor: pnpm prepr 2026-04-27 20:53:41 -06:00
tdgao 53a04cf941 feat: update columns widths 2026-04-27 20:52:00 -06:00
tdgao 849edf0740 fix: queries not caching properly 2026-04-27 20:49:53 -06:00
tdgao df8aff28ca feat: hide/show specific series in graph, formatted legend labels, loading states 2026-04-27 20:30:20 -06:00
tdgao 34b736fd06 feat: implement adding project id analytics 2026-04-27 19:54:37 -06:00
tdgao 8c7e485e8f fix: broken lock file 2026-04-27 18:45:01 -06:00
tdgao 8694971610 refactor: pnpm prepr 2026-04-27 18:43:15 -06:00
tdgao 40d8007655 Merge branch 'main' into truman/analytics 2026-04-27 18:40:12 -06:00
tdgao 329cc3bf7d feat: use mutliselect for active filtered by options and use table header 2026-04-27 18:33:02 -06:00
tdgao 5cf6941fa8 feat: implement multiselect input content slow 2026-04-27 18:32:20 -06:00
tdgao ff0968b074 implement header slot for table 2026-04-27 18:31:17 -06:00
tdgao 43abf27985 fix: triangle safe area for query filter sub menus 2026-04-27 18:14:36 -06:00
tdgao 34b392bb26 fix: type 2026-04-27 17:38:44 -06:00
tdgao 4231250fa2 feat: separate out query filter component 2026-04-27 17:05:34 -06:00
tdgao ec723c7247 feat: style improvements to project select 2026-04-27 16:20:02 -06:00
tdgao f17b97847a feat: filter server projects 2026-04-24 13:31:17 -06:00
tdgao bfdb386f52 refactor: object destructure for context 2026-04-23 13:13:17 -06:00
tdgao d5239bd520 feat: implement disabled statcard for non-applicable ones 2026-04-23 12:53:56 -06:00
tdgao b126bc475f feat: add table empty state 2026-04-23 12:25:02 -06:00
tdgao 3daa527595 feat: improve query builder styles and timeframes 2026-04-23 12:03:22 -06:00
tdgao 8840a1315a refactor: pnpm prepr 2026-04-23 11:48:58 -06:00
tdgao fd07f8e218 fix: type error 2026-04-23 11:47:45 -06:00
tdgao 270e73fd04 remove: events toggle button for now since it does nothing 2026-04-23 11:43:04 -06:00
tdgao 6ce8be3b57 feat: style improvements 2026-04-23 11:25:20 -06:00
tdgao fbc996b7b2 fix: query builder disable group-by options if not relavant 2026-04-23 11:19:38 -06:00
tdgao 5297b173dc fix: date display to show time conditionally 2026-04-23 11:15:57 -06:00
tdgao 97fde95a03 feat: implement analytics table and breakdown 2026-04-22 18:29:08 -06:00
tdgao e52d6420de feat: add query to url params for query builder 2026-04-22 17:57:33 -06:00
tdgao 31f13a20ae feat: improve query builder styles 2026-04-22 17:40:55 -06:00
tdgao 5234270a04 feat: implement analytics chart 2026-04-22 17:33:52 -06:00
tdgao 661765d902 feat: style consistency updates 2026-04-22 14:37:09 -06:00
tdgao 1c368d8755 feat: initial implementation of analytics DI, query builder component, and stat cards component 2026-04-22 14:14:19 -06:00
tdgao e09845b0b8 feat: wire up shared analytics dashboard page 2026-04-21 22:22:55 -06:00
tdgao 1db663ba77 remove: delete current analytics implementation 2026-04-21 21:28:02 -06:00
tdgao 0301f4a8be feat: implement analytics route in api client 2026-04-21 11:46:15 -06:00
100 changed files with 23707 additions and 2981 deletions
+1
View File
@@ -55,6 +55,7 @@
"@vueuse/core": "^11.1.0",
"ace-builds": "^1.36.2",
"ansi-to-html": "^0.7.2",
"chart.js": "^4.5.1",
"dayjs": "^1.11.7",
"dompurify": "^3.1.7",
"floating-vue": "^5.2.2",
@@ -50,6 +50,7 @@
@media screen and (max-width: 1024px) {
margin-top: 1.5rem;
padding: 0 1rem;
}
.normal-page__sidebar {
@@ -0,0 +1,152 @@
<template>
<div class="analytics-loading-bar" :style="{ opacity: isVisible ? 1 : 0 }" aria-hidden="true">
<div
class="analytics-loading-bar__track"
:style="{
width: `${progress}%`,
transition: !isTransitioning
? 'none'
: isFinishing
? 'width 0.1s ease-in-out'
: isCreeping
? 'width 2s linear'
: 'width 0.9s ease-in-out',
}"
/>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
const props = defineProps<{
loading: boolean
}>()
const progress = ref(0)
const isVisible = ref(false)
const isFinishing = ref(false)
const isCreeping = ref(false)
const isTransitioning = ref(false)
let startFrame: number | null = null
let showFrame: number | null = null
let creepTimeout: ReturnType<typeof setTimeout> | null = null
let hideTimeout: ReturnType<typeof setTimeout> | null = null
let resetTimeout: ReturnType<typeof setTimeout> | null = null
function clearTimers() {
if (showFrame !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(showFrame)
}
if (startFrame !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(startFrame)
}
if (creepTimeout) clearTimeout(creepTimeout)
if (hideTimeout) clearTimeout(hideTimeout)
if (resetTimeout) clearTimeout(resetTimeout)
showFrame = null
startFrame = null
creepTimeout = null
hideTimeout = null
resetTimeout = null
}
function start() {
clearTimers()
isVisible.value = false
progress.value = 0
isFinishing.value = false
isCreeping.value = false
isTransitioning.value = false
if (typeof window === 'undefined') {
progress.value = 98
return
}
showFrame = window.requestAnimationFrame(() => {
isVisible.value = true
showFrame = null
startFrame = window.requestAnimationFrame(() => {
isTransitioning.value = true
progress.value = 85
startFrame = null
})
})
creepTimeout = setTimeout(() => {
isCreeping.value = true
progress.value = 98
creepTimeout = null
}, 900)
}
function finish() {
clearTimers()
isVisible.value = true
isFinishing.value = true
isCreeping.value = false
isTransitioning.value = true
progress.value = 100
if (typeof window === 'undefined') {
isVisible.value = false
progress.value = 0
isFinishing.value = false
isCreeping.value = false
isTransitioning.value = false
return
}
hideTimeout = setTimeout(() => {
isVisible.value = false
resetTimeout = setTimeout(() => {
isTransitioning.value = false
progress.value = 0
isFinishing.value = false
isCreeping.value = false
}, 400)
}, 350)
}
watch(
() => props.loading,
(loading) => {
if (loading) {
start()
} else if (
isVisible.value ||
progress.value > 0 ||
showFrame !== null ||
startFrame !== null ||
creepTimeout !== null
) {
finish()
}
},
{ immediate: true },
)
onBeforeUnmount(clearTimers)
</script>
<style scoped>
.analytics-loading-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 20;
height: 2px;
overflow: hidden;
background: color-mix(in srgb, var(--color-brand) 18%, transparent);
pointer-events: none;
transition: opacity 0.4s;
}
.analytics-loading-bar__track {
height: 100%;
border-radius: 999px;
background: var(--loading-bar-gradient);
}
</style>
@@ -0,0 +1,78 @@
import type { Labrinth } from '@modrinth/api-client'
import type { AnalyticsDashboardStat } from '~/providers/analytics/analytics'
export const ANALYTICS_DASHBOARD_STATS: readonly AnalyticsDashboardStat[] = [
'views',
'downloads',
'revenue',
'playtime',
]
export const TOP_GRAPH_DATASET_LIMIT = 8
export const GRAPH_RENDER_DATASET_LIMIT = 250
export const PREVIOUS_PERIOD_DATASET_ID_PREFIX = 'previous-period:'
export const PREVIOUS_PERIOD_BORDER_DASH = [6, 4]
export const PROJECT_VERSION_UPLOAD_DEDUPE_WINDOW_MS = 24 * 60 * 60 * 1000
export const ALL_PROJECTS_DATASET_ID = 'all'
export const PROJECT_EVENT_DATE_FORMATTER = new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})
export const MONETIZATION_LEGEND_ENTRY_ORDER = new Map([
['breakdown:monetized', 0],
['breakdown:unmonetized', 1],
])
export const VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUSES = [
'approved',
'unlisted',
'private',
] as const satisfies readonly Labrinth.Projects.v2.ProjectStatus[]
export type VisibleProjectStatusChangeEventStatus =
(typeof VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUSES)[number]
export const VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUS_SET =
new Set<Labrinth.Projects.v2.ProjectStatus>(VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUSES)
export const LIGHT_LEGEND_PALETTE = [
'hsl(152, 100%, 34%)',
'hsl(26, 100%, 42%)',
'hsl(202, 100%, 35%)',
'hsl(327, 45%, 64%)',
'hsl(41, 100%, 45%)',
'hsl(250, 60%, 33%)',
'hsl(170, 43%, 47%)',
'hsl(330, 60%, 33%)',
'hsl(46, 100%, 36%)',
'hsl(167, 100%, 30%)',
'hsl(343, 38%, 45%)',
'hsl(222, 100%, 28%)',
'hsl(270, 62%, 60%)',
'hsl(32, 100%, 37%)',
'hsl(349, 57%, 51%)',
'hsl(191, 43%, 37%)',
]
export const DARK_LEGEND_PALETTE = [
'hsl(145, 78%, 48%)',
'hsl(41, 100%, 50%)',
'hsl(202, 77%, 63%)',
'hsl(323, 66%, 72%)',
'hsl(56, 85%, 60%)',
'hsl(255, 92%, 80%)',
'hsl(12, 100%, 67%)',
'hsl(176, 58%, 56%)',
'hsl(60, 100%, 41%)',
'hsl(165, 80%, 38%)',
'hsl(341, 36%, 56%)',
'hsl(226, 60%, 49%)',
'hsl(252, 53%, 62%)',
'hsl(75, 59%, 50%)',
'hsl(195, 56%, 42%)',
'hsl(30, 59%, 56%)',
]
@@ -0,0 +1,297 @@
<template>
<Menu
theme="analytics-controls-menu"
placement="bottom-end"
:shown="isControlsMenuOpen"
:triggers="[]"
:popper-triggers="[]"
:aria-id="controlsMenuId"
no-auto-focus
@update:shown="isControlsMenuOpen = $event"
>
<button
ref="controlsMenuTrigger"
type="button"
:aria-expanded="isControlsMenuOpen"
:aria-controls="controlsMenuId"
:aria-label="
formatMessage(analyticsChartMessages.controlsAria, {
activeCount: activeControlCountLabel,
})
"
class="btn-dropdown-animation inline-flex min-h-5 cursor-pointer items-center justify-between gap-2 rounded-xl border-0 bg-surface-4 px-3 py-2 text-left text-sm font-semibold text-button-text shadow-none transition-all duration-200 hover:brightness-[115%] focus-visible:brightness-[115%] active:brightness-[115%]"
@click="toggleControlsMenu"
>
<Settings2Icon class="size-4 text-secondary" aria-hidden="true" />
<span class="leading-tight text-primary">
{{ formatMessage(analyticsChartMessages.controlsButton) }}
</span>
<span
v-if="activeControlCount > 0"
class="inline-flex min-w-5 items-center justify-center rounded-full bg-highlight-green px-1.5 text-xs font-semibold leading-5 text-green"
>
{{ activeControlCount }}
</span>
<DropdownIcon class="size-4 text-secondary" aria-hidden="true" />
</button>
<template #popper>
<div
ref="controlsMenuPanel"
role="dialog"
:aria-label="formatMessage(analyticsChartMessages.controlsDialogAria)"
class="mt-1 flex w-[228px] max-w-[calc(100vw_-_2rem)] flex-col overflow-hidden rounded-[14px] border border-solid border-surface-4 bg-surface-3 text-sm shadow-2xl"
>
<div class="flex items-center justify-between gap-3 px-3 py-2.5 text-xs font-medium">
<span class="font-semibold text-primary">{{ activeControlCountLabel }}</span>
<button
type="button"
:disabled="isResetDisabled"
class="border-0 bg-transparent p-0 text-xs font-semibold text-primary transition-all disabled:cursor-not-allowed disabled:opacity-50"
:class="isResetDisabled ? '' : 'hover:text-contrast focus-visible:text-contrast'"
@click="resetControls"
>
{{ formatMessage(analyticsMessages.resetButton) }}
</button>
</div>
<div
v-if="hasDisplayControls"
class="flex flex-col gap-1 border-0 border-t border-solid border-surface-4 px-3 py-2.5"
>
<div class="mb-0.5 text-xs font-semibold text-secondary">
{{ formatMessage(analyticsChartMessages.displayControls) }}
</div>
<div v-if="canShowPreviousPeriod" class="flex min-h-7 items-center justify-between">
<label
:for="previousPeriodToggleId"
class="flex min-h-7 min-w-0 grow cursor-pointer items-center gap-1.5 pr-3 font-semibold leading-tight text-primary"
>
<HistoryIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
<span class="min-w-0 truncate">
{{ formatMessage(analyticsChartMessages.previousPeriod) }}
</span>
</label>
<Toggle
:id="previousPeriodToggleId"
v-model="showPreviousPeriodModel"
:small="smallToggles"
/>
</div>
<div v-if="canUseRatioMode" class="flex min-h-7 items-center justify-between">
<label
:for="ratioModeToggleId"
class="flex min-h-7 min-w-0 grow cursor-pointer items-center gap-1.5 pr-3 font-semibold leading-tight text-primary"
>
<span
class="inline-flex size-4 shrink-0 items-center justify-center text-sm font-semibold leading-none text-secondary"
aria-hidden="true"
>
%
</span>
<span class="min-w-0 truncate">
{{ formatMessage(analyticsChartMessages.ratio) }}
</span>
</label>
<Toggle :id="ratioModeToggleId" v-model="ratioModeModel" :small="smallToggles" />
</div>
</div>
<div
class="flex flex-col gap-1 border-0 border-t border-solid border-surface-4 px-3 py-2.5"
>
<div class="mb-0.5 text-xs font-semibold text-secondary">
{{ formatMessage(analyticsChartMessages.annotations) }}
</div>
<div
v-tooltip="projectEventsDisabledTooltip"
class="justify3 flex min-h-7 items-center"
:aria-disabled="!hasProjectEvents"
>
<label
:for="projectEventsToggleId"
class="flex min-h-7 min-w-0 grow items-center gap-1.5 pr-3 font-semibold leading-tight text-primary"
:class="hasProjectEvents ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
>
<TagCategoryFlagIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
<span class="min-w-0 truncate">
{{ formatMessage(analyticsChartMessages.projectEvents) }}
</span>
</label>
<Toggle
:id="projectEventsToggleId"
v-model="showProjectEventsControlModel"
:small="smallToggles"
:disabled="!hasProjectEvents"
/>
</div>
<div
v-tooltip="modrinthEventsDisabledTooltip"
class="justify3 flex min-h-7 items-center"
:aria-disabled="!hasChartEvents"
>
<label
:for="modrinthEventsToggleId"
class="flex min-h-7 min-w-0 grow items-center gap-1.5 pr-3 font-semibold leading-tight text-primary"
:class="hasChartEvents ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
>
<InfoIcon class="size-4 shrink-0 text-blue" aria-hidden="true" />
<span class="min-w-0 truncate">
{{ formatMessage(analyticsChartMessages.modrinthEvents) }}
</span>
</label>
<Toggle
:id="modrinthEventsToggleId"
v-model="showChartEventsControlModel"
:small="smallToggles"
:disabled="!hasChartEvents"
/>
</div>
</div>
</div>
</template>
</Menu>
</template>
<script setup lang="ts">
import {
DropdownIcon,
HistoryIcon,
InfoIcon,
Settings2Icon,
TagCategoryFlagIcon,
} from '@modrinth/assets'
import { Toggle, useVIntl } from '@modrinth/ui'
import { Menu } from 'floating-vue'
import { analyticsChartMessages, analyticsMessages } from '../../analytics-messages'
const props = defineProps<{
ratioMode: boolean
showChartEvents: boolean
showProjectEvents: boolean
showPreviousPeriod: boolean
canUseRatioMode: boolean
canShowPreviousPeriod: boolean
hasChartEvents: boolean
hasProjectEvents: boolean
smallToggles: boolean
defaultRatioMode: boolean
defaultShowChartEvents: boolean
defaultShowProjectEvents: boolean
defaultShowPreviousPeriod: boolean
}>()
const emit = defineEmits<{
(
e:
| 'update:ratioMode'
| 'update:showChartEvents'
| 'update:showProjectEvents'
| 'update:showPreviousPeriod',
value: boolean,
): void
}>()
const isControlsMenuOpen = ref(false)
const controlsMenuTrigger = ref<HTMLElement | null>(null)
const controlsMenuPanel = ref<HTMLElement | null>(null)
const controlsMenuId = useId()
const ratioModeToggleId = useId()
const previousPeriodToggleId = useId()
const modrinthEventsToggleId = useId()
const projectEventsToggleId = useId()
const { formatMessage } = useVIntl()
const ratioModeModel = computed({
get: () => props.ratioMode,
set: (value: boolean) => emit('update:ratioMode', value),
})
const showChartEventsModel = computed({
get: () => props.showChartEvents,
set: (value: boolean) => emit('update:showChartEvents', value),
})
const showChartEventsControlModel = computed({
get: () => props.hasChartEvents && props.showChartEvents,
set: (value: boolean) => emit('update:showChartEvents', value),
})
const showProjectEventsModel = computed({
get: () => props.showProjectEvents,
set: (value: boolean) => emit('update:showProjectEvents', value),
})
const showProjectEventsControlModel = computed({
get: () => props.hasProjectEvents && props.showProjectEvents,
set: (value: boolean) => emit('update:showProjectEvents', value),
})
const showPreviousPeriodModel = computed({
get: () => props.showPreviousPeriod,
set: (value: boolean) => emit('update:showPreviousPeriod', value),
})
const hasDisplayControls = computed(() => props.canShowPreviousPeriod || props.canUseRatioMode)
const projectEventsDisabledTooltip = computed(() =>
props.hasProjectEvents ? undefined : formatMessage(analyticsChartMessages.noProjectEvents),
)
const modrinthEventsDisabledTooltip = computed(() =>
props.hasChartEvents ? undefined : formatMessage(analyticsChartMessages.noModrinthEvents),
)
const activeControlCount = computed(() => {
let count = 0
if (props.canShowPreviousPeriod && props.showPreviousPeriod) count += 1
if (props.canUseRatioMode && props.ratioMode) count += 1
if (props.hasProjectEvents && props.showProjectEvents) count += 1
if (props.hasChartEvents && props.showChartEvents) count += 1
return count
})
const activeControlCountLabel = computed(() =>
formatMessage(analyticsChartMessages.activeControlCount, { count: activeControlCount.value }),
)
const isResetDisabled = computed(
() =>
props.showPreviousPeriod === props.defaultShowPreviousPeriod &&
props.ratioMode === props.defaultRatioMode &&
props.showProjectEvents === props.defaultShowProjectEvents &&
props.showChartEvents === props.defaultShowChartEvents,
)
function toggleControlsMenu() {
isControlsMenuOpen.value = !isControlsMenuOpen.value
}
function resetControls() {
if (isResetDisabled.value) return
showPreviousPeriodModel.value = props.defaultShowPreviousPeriod
ratioModeModel.value = props.defaultRatioMode
showProjectEventsModel.value = props.defaultShowProjectEvents
showChartEventsModel.value = props.defaultShowChartEvents
}
function onDocumentPointerDown(event: PointerEvent) {
if (!isControlsMenuOpen.value || !(event.target instanceof Node)) return
if (controlsMenuTrigger.value?.contains(event.target)) return
if (controlsMenuPanel.value?.contains(event.target)) return
isControlsMenuOpen.value = false
}
onMounted(() => {
document.addEventListener('pointerdown', onDocumentPointerDown, true)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
})
</script>
<style>
.v-popper--theme-analytics-controls-menu .v-popper__inner {
overflow: visible !important;
background: transparent !important;
padding: 0 !important;
border: 0 !important;
box-shadow: none !important;
}
.v-popper--theme-analytics-controls-menu .v-popper__arrow-container {
display: none;
}
</style>
@@ -0,0 +1,188 @@
<template>
<div class="relative">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-5"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-5"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showLegendTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-5 bg-gradient-to-b from-surface-3 to-transparent"
/>
</Transition>
<div
ref="legendContainer"
class="flex max-h-[130px] flex-wrap items-center gap-y-1 overflow-y-auto px-3"
@scroll="checkLegendScrollState"
>
<div
v-for="legendEntry in legendEntries"
:key="legendEntry.id"
class="inline-flex items-center"
>
<button
v-tooltip="legendEntry.projectName ?? ''"
type="button"
class="inline-flex items-center gap-1.5 px-2 py-0.5 text-sm !outline-0 transition-all focus-within:!outline-0 focus:!outline-0 focus-visible:!outline-0"
:class="[
legendEntry.hidden ? 'text-secondary opacity-70' : 'text-primary',
isLegendEntryToggleDisabled(legendEntry) && !isShiftKeyPressed
? 'cursor-default'
: 'cursor-pointer hover:brightness-125',
]"
:aria-pressed="!legendEntry.hidden"
@mouseenter="emit('entry-hover', legendEntry.id)"
@mouseleave="emit('entry-hover-clear', legendEntry.id)"
@focus="emit('entry-hover', legendEntry.id)"
@blur="emit('entry-hover-clear', legendEntry.id)"
@click="emit('entry-click', $event, legendEntry.id)"
>
<span
:class="
legendEntry.isPreviousPeriod
? 'h-0 w-2 rounded-none border-0 border-t-2 border-dashed bg-transparent'
: 'size-2 rounded-full'
"
:style="
legendEntry.isPreviousPeriod
? { borderColor: legendEntry.color }
: { backgroundColor: legendEntry.color }
"
/>
<span
:class="{
'line-through': legendEntry.hidden,
capitalize: shouldCapitalizeDatasetLabels,
}"
>
{{ legendEntry.name }}
</span>
</button>
<Dropdown
v-if="showUnmonetizedInfo && legendEntry.id === 'breakdown:unmonetized'"
theme="analytics-monetization-popover"
:triggers="['hover', 'focus']"
:popper-triggers="['hover', 'focus']"
:delay="{ show: 0, hide: 250 }"
placement="top"
:aria-id="monetizationPopoverId"
no-auto-focus
>
<InfoIcon
class="-ml-1 mt-px inline-flex size-4 items-center justify-center rounded-full border-0 bg-transparent p-0 text-secondary transition-all hover:text-contrast focus-visible:text-contrast"
:aria-label="formatMessage(analyticsChartMessages.viewMonetizedAnalyticsDetails)"
/>
<template #popper>
<div
role="dialog"
:aria-label="formatMessage(analyticsChartMessages.monetizedAnalyticsDetails)"
class="font-base w-[292px] rounded-xl border border-solid border-surface-5 bg-surface-3 p-3 text-sm leading-snug shadow-2xl"
>
{{ formatMessage(analyticsChartMessages.monetizedAnalyticsDetailsDescription) }}
</div>
</template>
</Dropdown>
</div>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-5"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-5"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showLegendBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-5 bg-gradient-to-t from-surface-3 to-transparent"
/>
</Transition>
</div>
</template>
<script setup lang="ts">
import { InfoIcon } from '@modrinth/assets'
import { useScrollIndicator, useVIntl } from '@modrinth/ui'
import { Dropdown } from 'floating-vue'
import { analyticsChartMessages } from '../../analytics-messages'
import type { AnalyticsChartLegendEntry } from '../analytics-chart-types'
const props = defineProps<{
legendEntries: AnalyticsChartLegendEntry[]
shouldCapitalizeDatasetLabels: boolean
showUnmonetizedInfo: boolean
}>()
const emit = defineEmits<{
'entry-hover': [datasetId: string]
'entry-hover-clear': [datasetId: string]
'entry-click': [event: MouseEvent, datasetId: string]
}>()
const monetizationPopoverId = useId()
const legendContainer = ref<HTMLElement | null>(null)
const isShiftKeyPressed = ref(false)
const { formatMessage } = useVIntl()
const {
showTopFade: showLegendTopFade,
showBottomFade: showLegendBottomFade,
checkScrollState: checkLegendScrollState,
forceCheck: forceCheckLegendScrollState,
} = useScrollIndicator(legendContainer)
function updateShiftKeyState(event: KeyboardEvent) {
isShiftKeyPressed.value = event.shiftKey
}
function clearShiftKeyState() {
isShiftKeyPressed.value = false
}
function isLegendEntryToggleDisabled(legendEntry: AnalyticsChartLegendEntry) {
if (legendEntry.hidden) return false
const visibleCount = props.legendEntries.filter((entry) => !entry.hidden).length
return visibleCount <= 1
}
watch(
() => props.legendEntries,
() => {
nextTick(() => {
forceCheckLegendScrollState()
})
},
{ immediate: true, flush: 'post' },
)
onMounted(() => {
window.addEventListener('keydown', updateShiftKeyState)
window.addEventListener('keyup', updateShiftKeyState)
window.addEventListener('blur', clearShiftKeyState)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', updateShiftKeyState)
window.removeEventListener('keyup', updateShiftKeyState)
window.removeEventListener('blur', clearShiftKeyState)
})
</script>
<style>
.v-popper--theme-analytics-monetization-popover .v-popper__inner {
overflow: visible !important;
background: transparent !important;
padding: 0 !important;
border: 0 !important;
box-shadow: none !important;
}
.v-popper--theme-analytics-monetization-popover .v-popper__arrow-container {
display: none;
}
</style>
@@ -0,0 +1,63 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(analyticsChartMessages.renderLimitHeader, { count: tableProjectCount })"
fade="warning"
width="500px"
max-width="calc(100vw - 2rem)"
>
<p class="m-0 max-w-[32rem] text-primary">
{{ formatMessage(analyticsChartMessages.renderLimitDescription) }}
</p>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="transparent">
<button @click="modal?.hide()">
{{ formatMessage(analyticsChartMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button class="!shadow-none" @click="confirm">
{{ formatMessage(analyticsChartMessages.showAll) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { ButtonStyled, NewModal, useVIntl } from '@modrinth/ui'
import { analyticsChartMessages } from '../../analytics-messages'
defineProps<{
tableProjectCount: number
}>()
const emit = defineEmits<{
confirm: []
}>()
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal> | null>(null)
function show(event: MouseEvent) {
modal.value?.show(event)
}
function hide() {
modal.value?.hide()
}
function confirm() {
emit('confirm')
hide()
}
defineExpose({
show,
hide,
})
</script>
@@ -0,0 +1,122 @@
<template>
<div class="flex w-full flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div
class="flex min-h-[84px] w-full flex-col items-start justify-between gap-3 rounded-t-2xl border-0 border-b border-solid border-surface-5 bg-surface-3 p-4 sm:flex-row sm:items-center"
>
<div class="flex flex-col gap-0.5">
<div class="w-max text-xl font-semibold text-contrast">
{{ graphTitle }}
</div>
<div
v-if="showTableSelectionSubheading"
class="m-0 flex w-max flex-wrap items-center gap-2 text-sm text-secondary"
>
<span>{{ tableSelectionSubheading }}</span>
<button
v-if="showGraphRenderLimitButton"
type="button"
class="font-base border-0 bg-transparent p-0 text-sm underline transition-all hover:brightness-125"
@click="emit('toggle-graph-render-limit', $event)"
>
{{ graphRenderLimitButtonLabel }}
</button>
<button
v-if="showTopGraphDatasetsButton"
type="button"
class="font-base border-0 bg-transparent p-0 text-sm underline transition-all hover:brightness-125"
@click="emit('show-top-graph-datasets')"
>
{{ formatMessage(analyticsChartMessages.showTopEight) }}
</button>
</div>
</div>
<div class="flex grow select-none flex-wrap-reverse items-center justify-end gap-2 gap-y-2">
<AnalyticsChartControls
v-model:ratio-mode="ratioMode"
v-model:show-chart-events="showChartEvents"
v-model:show-project-events="showProjectEvents"
v-model:show-previous-period="showPreviousPeriod"
:can-use-ratio-mode="canUseRatioMode"
:can-show-previous-period="canShowPreviousPeriod"
:has-chart-events="hasChartEvents"
:has-project-events="hasProjectEvents"
:small-toggles="smallToggles"
:default-ratio-mode="DEFAULT_ANALYTICS_GRAPH_RATIO_MODE"
:default-show-chart-events="DEFAULT_ANALYTICS_GRAPH_EVENTS_VISIBILITY"
:default-show-project-events="defaultShowProjectEvents"
:default-show-previous-period="DEFAULT_ANALYTICS_GRAPH_PREVIOUS_PERIOD_VISIBILITY"
/>
<Tabs
:value="activeGraphViewMode"
:tabs="viewModeTabs"
@update:value="activeGraphViewMode = $event as AnalyticsGraphViewMode"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ChartAreaIcon, ChartColumnBigIcon, ChartSplineIcon } from '@modrinth/assets'
import { Tabs, type TabsTab, useVIntl } from '@modrinth/ui'
import {
DEFAULT_ANALYTICS_GRAPH_EVENTS_VISIBILITY,
DEFAULT_ANALYTICS_GRAPH_PREVIOUS_PERIOD_VISIBILITY,
DEFAULT_ANALYTICS_GRAPH_RATIO_MODE,
} from '~/components/analytics-dashboard/analytics-route-query'
import type { AnalyticsGraphViewMode } from '~/providers/analytics/analytics'
import { analyticsChartMessages } from '../../analytics-messages.ts'
import AnalyticsChartControls from './AnalyticsChartControls.vue'
const activeGraphViewMode = defineModel<AnalyticsGraphViewMode>('activeGraphViewMode', {
required: true,
})
const ratioMode = defineModel<boolean>('ratioMode', { required: true })
const showChartEvents = defineModel<boolean>('showChartEvents', { required: true })
const showProjectEvents = defineModel<boolean>('showProjectEvents', { required: true })
const showPreviousPeriod = defineModel<boolean>('showPreviousPeriod', { required: true })
const props = defineProps<{
graphTitle: string
showTableSelectionSubheading: boolean
tableSelectionSubheading: string
showGraphRenderLimitButton: boolean
graphRenderLimitButtonLabel: string
showTopGraphDatasetsButton: boolean
canUseRatioMode: boolean
canShowPreviousPeriod: boolean
hasChartEvents: boolean
hasProjectEvents: boolean
smallToggles: boolean
defaultShowProjectEvents: boolean
isMobileLayout: boolean
}>()
const { formatMessage } = useVIntl()
const emit = defineEmits<{
'toggle-graph-render-limit': [event: MouseEvent]
'show-top-graph-datasets': []
}>()
const viewModeTabs = computed<TabsTab[]>(() => [
{
value: 'line',
label: props.isMobileLayout ? '' : formatMessage(analyticsChartMessages.lineView),
icon: ChartSplineIcon,
},
{
value: 'area',
label: props.isMobileLayout ? '' : formatMessage(analyticsChartMessages.areaView),
icon: ChartAreaIcon,
},
{
value: 'bar',
label: props.isMobileLayout ? '' : formatMessage(analyticsChartMessages.barView),
icon: ChartColumnBigIcon,
},
])
</script>
@@ -0,0 +1,450 @@
import { useVIntl } from '@modrinth/ui'
import { computed, type ComputedRef, type Ref, ref, watch } from 'vue'
import type {
AnalyticsBreakdownPreset,
AnalyticsDashboardProject,
} from '~/providers/analytics/analytics'
import { analyticsChartMessages } from '../../analytics-messages.ts'
import { COMBINED_BREAKDOWN_DATASET_ID_PREFIX } from '../../breakdown.ts'
import {
ALL_PROJECTS_DATASET_ID,
MONETIZATION_LEGEND_ENTRY_ORDER,
PREVIOUS_PERIOD_BORDER_DASH,
} from '../analytics-chart-constants.ts'
import type { AnalyticsChartEvent } from '../analytics-chart-plot/AnalyticsChartEvents.vue'
import type { AnalyticsChartLegendEntry } from '../analytics-chart-types.ts'
import {
areStringArraysEqual,
type ChartDataset,
decodeBreakdownDatasetValue,
getChartDatasetTotal,
getPreviousPeriodDatasetId,
} from '../analytics-chart-utils.ts'
export function useAnalyticsChartLegend({
selectableChartDatasets,
allChartDatasets,
previousChartDatasets,
shouldShowPreviousPeriod,
isRatioMode,
hiddenGraphDatasetIds,
selectedBreakdowns,
isGraphDatasetSelectionActive,
selectedProjects,
selectedProjectIdSet,
selectedProjectEventIdSet,
}: {
selectableChartDatasets: ComputedRef<ChartDataset[]>
allChartDatasets: ComputedRef<ChartDataset[]>
previousChartDatasets: ComputedRef<ChartDataset[]>
shouldShowPreviousPeriod: ComputedRef<boolean>
isRatioMode: Ref<boolean>
hiddenGraphDatasetIds: Ref<string[]>
selectedBreakdowns: Ref<readonly AnalyticsBreakdownPreset[]>
isGraphDatasetSelectionActive: Ref<boolean>
selectedProjects: ComputedRef<AnalyticsDashboardProject[]>
selectedProjectIdSet: ComputedRef<Set<string>>
selectedProjectEventIdSet: ComputedRef<Set<string>>
}) {
const { formatMessage } = useVIntl()
const hoveredLegendEntryId = ref<string | null>(null)
const hiddenDatasetIds = computed(() => new Set(hiddenGraphDatasetIds.value))
const previousChartDatasetByOriginalId = computed(() => {
const datasets = new Map<string, ChartDataset>()
for (const dataset of previousChartDatasets.value) {
datasets.set(dataset.projectId, dataset)
}
return datasets
})
const currentLegendEntries = computed<AnalyticsChartLegendEntry[]>(() =>
selectableChartDatasets.value
.map((dataset) => ({
id: dataset.projectId,
name: dataset.label,
projectName: dataset.projectName,
color: dataset.borderColor,
totalValue: getChartDatasetTotal(dataset),
hidden: hiddenDatasetIds.value.has(dataset.projectId),
}))
.sort(compareLegendEntries),
)
const visibleProjectEventIdSet = computed(() => {
if (!selectedBreakdowns.value.includes('project')) {
return selectedProjectEventIdSet.value
}
const visibleProjectIds = new Set<string>()
const projectIdsWithLegendEntries = new Set<string>()
for (const legendEntry of currentLegendEntries.value) {
const projectId = getLegendEntryProjectId(legendEntry)
if (!projectId) {
continue
}
projectIdsWithLegendEntries.add(projectId)
if (!legendEntry.hidden) {
visibleProjectIds.add(projectId)
}
}
if (isGraphDatasetSelectionActive.value) {
return visibleProjectIds
}
if (projectIdsWithLegendEntries.size === 0) {
return selectedProjectEventIdSet.value
}
const eventProjectIds = new Set<string>()
for (const projectId of selectedProjectEventIdSet.value) {
if (!projectIdsWithLegendEntries.has(projectId) || visibleProjectIds.has(projectId)) {
eventProjectIds.add(projectId)
}
}
return eventProjectIds
})
const legendEntries = computed<AnalyticsChartLegendEntry[]>(() => {
if (!shouldShowPreviousPeriod.value) {
return currentLegendEntries.value
}
return currentLegendEntries.value.flatMap((entry) => {
const previousDataset = previousChartDatasetByOriginalId.value.get(entry.id)
const previousEntry: AnalyticsChartLegendEntry = {
id: getPreviousPeriodDatasetId(entry.id),
name: formatMessage(analyticsChartMessages.previousPeriodSuffix, { name: entry.name }),
projectName: entry.projectName,
color: entry.color,
totalValue: previousDataset ? getChartDatasetTotal(previousDataset) : 0,
hidden: hiddenDatasetIds.value.has(getPreviousPeriodDatasetId(entry.id)),
isPreviousPeriod: true,
}
return [entry, previousEntry]
})
})
const hiddenCurrentLegendEntryIds = computed(() =>
currentLegendEntries.value.filter((entry) => entry.hidden).map((entry) => entry.id),
)
const hiddenCurrentLegendEntryIdsKey = computed(() =>
hiddenCurrentLegendEntryIds.value.join('\u0000'),
)
const chartDatasetById = computed(() => {
const datasets = new Map<string, ChartDataset>()
for (const dataset of selectableChartDatasets.value) {
datasets.set(dataset.projectId, dataset)
if (!shouldShowPreviousPeriod.value) {
continue
}
const previousDataset = previousChartDatasetByOriginalId.value.get(dataset.projectId)
const previousData = Array.from(
{ length: dataset.data.length },
(_, index) => previousDataset?.data[index] ?? 0,
)
datasets.set(getPreviousPeriodDatasetId(dataset.projectId), {
projectId: getPreviousPeriodDatasetId(dataset.projectId),
label: formatMessage(analyticsChartMessages.previousPeriodSuffix, {
name: dataset.label,
}),
projectName: dataset.projectName,
data: previousData,
borderColor: dataset.borderColor,
backgroundColor: dataset.backgroundColor,
borderDash: PREVIOUS_PERIOD_BORDER_DASH,
})
}
return datasets
})
const hoverRatioSliceTotals = computed(() => {
const sliceLength = selectableChartDatasets.value.reduce(
(maxLength, dataset) => Math.max(maxLength, dataset.data.length),
0,
)
const totals = new Array<number>(sliceLength).fill(0)
for (const legendEntry of legendEntries.value) {
if (legendEntry.hidden) continue
const dataset = chartDatasetById.value.get(legendEntry.id)
if (!dataset) continue
for (let i = 0; i < sliceLength; i++) {
totals[i] += dataset.data[i] ?? 0
}
}
return totals
})
const baseVisibleChartDatasets = computed(() =>
legendEntries.value
.filter((legendEntry) => !legendEntry.hidden)
.map((legendEntry) => {
const dataset = chartDatasetById.value.get(legendEntry.id)
if (!dataset) return null
return {
...dataset,
borderColor: legendEntry.color,
backgroundColor: legendEntry.color,
}
})
.filter((dataset): dataset is ChartDataset => Boolean(dataset)),
)
const visibleChartDatasets = computed<ChartDataset[]>(() => {
const datasets = baseVisibleChartDatasets.value
if (!isRatioMode.value || datasets.length === 0) return datasets
const sliceLength = datasets.reduce(
(maxLength, dataset) => Math.max(maxLength, dataset.data.length),
0,
)
const totals = new Array<number>(sliceLength).fill(0)
for (const dataset of datasets) {
for (let i = 0; i < sliceLength; i++) {
totals[i] += dataset.data[i] ?? 0
}
}
return datasets.map((dataset) => ({
...dataset,
data: dataset.data.map((value, i) => (totals[i] === 0 ? 0 : (value / totals[i]) * 100)),
}))
})
const visibleChartDatasetById = computed(() => {
const datasets = new Map<string, ChartDataset>()
for (const dataset of visibleChartDatasets.value) {
datasets.set(dataset.projectId, dataset)
}
return datasets
})
const highlightedChartDatasetId = computed(() => {
const datasetId = hoveredLegendEntryId.value
if (!datasetId || !visibleChartDatasetById.value.has(datasetId)) return null
return datasetId
})
function compareLegendEntries(a: AnalyticsChartLegendEntry, b: AnalyticsChartLegendEntry) {
if (selectedBreakdowns.value.length === 1 && selectedBreakdowns.value[0] === 'monetization') {
const aOrder = MONETIZATION_LEGEND_ENTRY_ORDER.get(a.id)
const bOrder = MONETIZATION_LEGEND_ENTRY_ORDER.get(b.id)
if (aOrder !== undefined || bOrder !== undefined) {
return (aOrder ?? Number.MAX_SAFE_INTEGER) - (bOrder ?? Number.MAX_SAFE_INTEGER)
}
}
return b.totalValue - a.totalValue || a.name.localeCompare(b.name)
}
function isProjectChartEventVisibleForLegend(event: AnalyticsChartEvent) {
return !event.projectId || visibleProjectEventIdSet.value.has(event.projectId)
}
function getLegendEntryProjectId(legendEntry: AnalyticsChartLegendEntry) {
const projectBreakdownIndex = selectedBreakdowns.value.findIndex(
(breakdown) => breakdown === 'project',
)
if (projectBreakdownIndex === -1) {
if (selectedProjects.value.length === 1 && legendEntry.id === ALL_PROJECTS_DATASET_ID) {
return selectedProjects.value[0]?.id ?? null
}
return null
}
if (selectedBreakdowns.value.length === 1) {
return selectedProjectIdSet.value.has(legendEntry.id) ? legendEntry.id : null
}
if (!legendEntry.id.startsWith(COMBINED_BREAKDOWN_DATASET_ID_PREFIX)) {
return null
}
const values = legendEntry.id
.slice(COMBINED_BREAKDOWN_DATASET_ID_PREFIX.length)
.split('+')
.map(decodeBreakdownDatasetValue)
const projectId = values[projectBreakdownIndex]
return projectId && selectedProjectIdSet.value.has(projectId) ? projectId : null
}
function hidePreviousPeriodEntriesForHiddenCurrentEntries() {
if (hiddenCurrentLegendEntryIds.value.length === 0) return
const nextHiddenDatasetIds = new Set(hiddenGraphDatasetIds.value)
for (const datasetId of hiddenCurrentLegendEntryIds.value) {
nextHiddenDatasetIds.add(getPreviousPeriodDatasetId(datasetId))
}
const nextHiddenDatasetIdList = Array.from(nextHiddenDatasetIds)
if (!areStringArraysEqual(hiddenGraphDatasetIds.value, nextHiddenDatasetIdList)) {
hiddenGraphDatasetIds.value = nextHiddenDatasetIdList
}
}
function isLegendEntryToggleDisabled(legendEntry: AnalyticsChartLegendEntry) {
if (legendEntry.hidden) return false
const visibleCount = legendEntries.value.filter((entry) => !entry.hidden).length
return visibleCount <= 1
}
function getLegendEntryTooltip(legendEntry: AnalyticsChartLegendEntry) {
return legendEntry.projectName ?? ''
}
function isUnmonetizedLegendEntry(legendEntry: AnalyticsChartLegendEntry) {
return (
selectedBreakdowns.value.length === 1 &&
selectedBreakdowns.value[0] === 'monetization' &&
legendEntry.id === 'breakdown:unmonetized'
)
}
function setHoveredLegendEntryId(datasetId: string) {
hoveredLegendEntryId.value = datasetId
}
function clearHoveredLegendEntryId(datasetId: string) {
if (hoveredLegendEntryId.value === datasetId) {
hoveredLegendEntryId.value = null
}
}
function clearLegendHoverState() {
hoveredLegendEntryId.value = null
}
function toggleLegendEntryVisibility(datasetId: string) {
const nextHiddenDatasetIds = new Set(hiddenDatasetIds.value)
if (nextHiddenDatasetIds.has(datasetId)) {
nextHiddenDatasetIds.delete(datasetId)
} else {
const visibleCount = legendEntries.value.filter((entry) => !entry.hidden).length
if (visibleCount <= 1) return
nextHiddenDatasetIds.add(datasetId)
}
hiddenGraphDatasetIds.value = Array.from(nextHiddenDatasetIds)
}
function soloLegendEntry(datasetId: string) {
const currentLegendEntryIds = new Set(legendEntries.value.map((entry) => entry.id))
const otherIds = legendEntries.value.map((entry) => entry.id).filter((id) => id !== datasetId)
const isAlreadySolo =
!hiddenDatasetIds.value.has(datasetId) &&
otherIds.every((id) => hiddenDatasetIds.value.has(id))
if (isAlreadySolo) {
hiddenGraphDatasetIds.value = hiddenGraphDatasetIds.value.filter(
(hiddenDatasetId) => !currentLegendEntryIds.has(hiddenDatasetId),
)
return
}
const nextHiddenDatasetIds = new Set(hiddenDatasetIds.value)
for (const legendEntry of legendEntries.value) {
if (legendEntry.id === datasetId) {
nextHiddenDatasetIds.delete(legendEntry.id)
} else {
nextHiddenDatasetIds.add(legendEntry.id)
}
}
hiddenGraphDatasetIds.value = Array.from(nextHiddenDatasetIds)
}
function onLegendEntryClick(event: MouseEvent, datasetId: string) {
if (event.shiftKey) {
soloLegendEntry(datasetId)
clearLegendHoverState()
return
}
toggleLegendEntryVisibility(datasetId)
clearLegendHoverState()
}
function onTooltipEntryClick(datasetId: string, shiftKey: boolean) {
if (!chartDatasetById.value.has(datasetId)) return
if (shiftKey) {
soloLegendEntry(datasetId)
clearLegendHoverState()
return
}
toggleLegendEntryVisibility(datasetId)
clearLegendHoverState()
}
watch(
[shouldShowPreviousPeriod, hiddenCurrentLegendEntryIdsKey],
([showPreviousPeriod]) => {
if (!showPreviousPeriod) return
hidePreviousPeriodEntriesForHiddenCurrentEntries()
},
{ immediate: true },
)
watch(
[allChartDatasets, legendEntries],
([datasets]) => {
if (datasets.length === 0) return
const availableDatasetIds = new Set(legendEntries.value.map((entry) => entry.id))
const nextHiddenDatasetIds = hiddenGraphDatasetIds.value.filter((datasetId) =>
availableDatasetIds.has(datasetId),
)
if (
legendEntries.value.length > 0 &&
legendEntries.value.every((entry) => nextHiddenDatasetIds.includes(entry.id))
) {
const firstLegendEntry = legendEntries.value[0]
if (firstLegendEntry) {
const firstLegendEntryIndex = nextHiddenDatasetIds.indexOf(firstLegendEntry.id)
if (firstLegendEntryIndex !== -1) {
nextHiddenDatasetIds.splice(firstLegendEntryIndex, 1)
}
}
}
if (!areStringArraysEqual(hiddenGraphDatasetIds.value, nextHiddenDatasetIds)) {
hiddenGraphDatasetIds.value = nextHiddenDatasetIds
}
},
{ immediate: true },
)
return {
hoveredLegendEntryId,
hiddenDatasetIds,
previousChartDatasetByOriginalId,
currentLegendEntries,
visibleProjectEventIdSet,
legendEntries,
hiddenCurrentLegendEntryIds,
hiddenCurrentLegendEntryIdsKey,
chartDatasetById,
hoverRatioSliceTotals,
baseVisibleChartDatasets,
visibleChartDatasets,
visibleChartDatasetById,
highlightedChartDatasetId,
isProjectChartEventVisibleForLegend,
getLegendEntryProjectId,
hidePreviousPeriodEntriesForHiddenCurrentEntries,
isLegendEntryToggleDisabled,
getLegendEntryTooltip,
isUnmonetizedLegendEntry,
setHoveredLegendEntryId,
clearHoveredLegendEntryId,
clearLegendHoverState,
toggleLegendEntryVisibility,
soloLegendEntry,
onLegendEntryClick,
onTooltipEntryClick,
}
}
@@ -0,0 +1,487 @@
<template>
<div
v-show="visible"
ref="tooltipElement"
class="analytics-chart-tooltip absolute left-0 top-0 z-10 flex max-h-[356px] flex-col overflow-hidden rounded-lg border border-solid border-surface-5 bg-surface-3 py-2 text-sm shadow-lg"
:class="pinned ? '' : 'pointer-events-none'"
:style="positionStyle"
@wheel.stop
@click.stop
>
<div
class="mb-1.5 flex shrink-0 items-start justify-between gap-2 border-0 border-b border-solid border-surface-5 px-3 pb-1.5 font-medium text-contrast"
>
<div class="flex min-w-0 flex-col gap-0.5">
<span class="min-w-0 truncate">
{{ rangeLabel }}
<span v-if="durationLabel" class="text-xs font-normal text-secondary">
({{ durationLabel }})
</span>
</span>
<span v-if="previousRangeLabel" class="min-w-0 truncate text-xs text-primary">
<span class="font-medium">{{ previousRangeLabel }}</span>
<span class="font-normal text-secondary">
{{ formatMessage(analyticsChartMessages.previousPeriodShort) }}
</span>
</span>
</div>
<PinIcon
v-if="pinned"
v-tooltip="formatMessage(analyticsChartMessages.tooltipPinned)"
class="pointer-events-none size-4 shrink-0 font-normal text-contrast"
:aria-label="formatMessage(analyticsChartMessages.pinned)"
/>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-6"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-6"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showEntriesTopFade"
class="analytics-chart-tooltip-entries-fade-top pointer-events-none absolute left-0 right-0 z-10 -mt-1 h-6 bg-gradient-to-b from-surface-3 to-transparent"
/>
</Transition>
<div
ref="entriesElement"
class="analytics-chart-tooltip-entries flex min-h-0 flex-col overflow-y-auto overscroll-contain px-3"
@scroll="checkEntriesScrollState"
@touchstart="onEntriesTouchStart"
@touchmove="onEntriesTouchMove"
@touchend="clearEntriesTouchScroll"
@touchcancel="clearEntriesTouchScroll"
>
<div v-if="!ratioMode" class="flex shrink-0 items-center justify-between gap-4">
<span class="font-medium text-primary">
{{ formatMessage(analyticsChartMessages.total) }}
</span>
<span class="font-semibold text-contrast">{{ formattedTotal }}</span>
</div>
<div
v-for="entry in entries"
:key="entry.projectId"
class="flex w-full min-w-0 items-center justify-between gap-4 text-primary"
>
<button
type="button"
class="inline-flex min-w-0 items-center gap-1.5 border-0 bg-transparent p-0 py-0.5 text-left focus-visible:!outline-none"
:class="
entry.toggleDisabled && !shiftKeyPressed
? 'cursor-default'
: entry.hidden
? 'cursor-pointer text-secondary opacity-70'
: 'cursor-pointer text-primary transition-all hover:brightness-125'
"
:aria-label="getEntryAriaLabel(entry)"
@mouseenter="emit('entry-hover', entry.projectId)"
@mouseleave="emit('entry-hover-clear', entry.projectId)"
@focus="emit('entry-hover', entry.projectId)"
@blur="emit('entry-hover-clear', entry.projectId)"
@click="onEntryClick($event, entry)"
>
<span
:class="
entry.isPreviousPeriod
? 'h-0 w-2 rounded-none border-0 border-t-2 border-dashed bg-transparent'
: 'size-2 rounded-full'
"
class="shrink-0"
:style="
entry.isPreviousPeriod
? { borderColor: entry.color }
: { backgroundColor: entry.color }
"
/>
<span
v-tooltip="entry.projectName ?? ''"
class="min-w-0 truncate"
:class="{
'line-through': entry.hidden,
capitalize: capitalizeLabels,
}"
>
{{ entry.name }}
</span>
</button>
<span
:class="[
'shrink-0',
entry.isPreviousPeriod ? 'font-medium text-secondary' : 'font-semibold',
entry.hidden ? 'text-primary line-through opacity-70' : 'text-contrast',
]"
>
{{ entry.formattedValue }}
</span>
</div>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-6"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-6"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showEntriesBottomFade"
class="analytics-chart-tooltip-entries-fade-bottom pointer-events-none absolute left-0 right-0 z-10 -mb-1 h-6 bg-gradient-to-t from-surface-3 to-transparent"
/>
</Transition>
</div>
</template>
<script setup lang="ts">
import { PinIcon } from '@modrinth/assets'
import { useScrollIndicator, useVIntl } from '@modrinth/ui'
import { analyticsChartMessages } from '../../analytics-messages'
export type AnalyticsChartTooltipEntry = {
projectId: string
name: string
projectName?: string
color: string
formattedValue: string
hidden: boolean
toggleDisabled: boolean
isPreviousPeriod?: boolean
}
const props = defineProps<{
visible: boolean
x: number
y: number
start: Date | null
end: Date | null
previousStart: Date | null
previousEnd: Date | null
chartStart: Date | null
chartEnd: Date | null
formattedTotal: string
entries: AnalyticsChartTooltipEntry[]
containerWidth: number
containerHeight: number
pinned: boolean
ratioMode: boolean
capitalizeLabels: boolean
shiftKeyPressed: boolean
}>()
const emit = defineEmits<{
'entry-click': [projectId: string, shiftKey: boolean]
'entry-hover': [projectId: string]
'entry-hover-clear': [projectId: string]
}>()
const { formatMessage } = useVIntl()
function onEntryClick(event: MouseEvent, entry: AnalyticsChartTooltipEntry) {
if (entry.toggleDisabled && !event.shiftKey) return
emit('entry-click', entry.projectId, event.shiftKey)
}
function getEntryAriaLabel(entry: AnalyticsChartTooltipEntry) {
return formatMessage(
entry.hidden
? analyticsChartMessages.showEntryInGraph
: analyticsChartMessages.hideEntryInGraph,
{ name: entry.name },
)
}
const ONE_DAY_MS = 24 * 60 * 60 * 1000
const ONE_HOUR_MS = 60 * 60 * 1000
const ONE_MINUTE_MS = 60 * 1000
function formatRangeLabel(
start: Date,
end: Date,
chartStart: Date | null,
chartEnd: Date | null,
): string {
const includeTime = end.getTime() - start.getTime() < ONE_DAY_MS
const yearsDiffer = start.getFullYear() !== end.getFullYear()
const chartYearsDiffer =
chartStart !== null && chartEnd !== null && chartStart.getFullYear() !== chartEnd.getFullYear()
const rangeYearDiffersFromChart =
chartStart !== null && start.getFullYear() !== chartStart.getFullYear()
const showTrailingYear = !yearsDiffer && (chartYearsDiffer || rangeYearDiffersFromChart)
const monthsDiffer = yearsDiffer || start.getMonth() !== end.getMonth()
const timeOptions: Intl.DateTimeFormatOptions = includeTime
? { hour: 'numeric', minute: '2-digit', hour12: true }
: {}
const startOptions: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
...(yearsDiffer ? { year: 'numeric' } : {}),
...timeOptions,
}
if (includeTime) {
const startLabel = new Intl.DateTimeFormat(undefined, startOptions).format(start)
const endLabel = new Intl.DateTimeFormat(undefined, timeOptions).format(end)
const range = `${startLabel}${endLabel}`
if (!showTrailingYear) return range
const yearLabel = new Intl.DateTimeFormat(undefined, { year: 'numeric' }).format(end)
return `${range}, ${yearLabel}`
}
let endOptions: Intl.DateTimeFormatOptions
if (yearsDiffer) {
endOptions = { month: 'short', day: 'numeric', year: 'numeric' }
} else if (monthsDiffer) {
endOptions = { month: 'short', day: 'numeric' }
} else {
endOptions = { day: 'numeric' }
}
const startLabel = new Intl.DateTimeFormat(undefined, startOptions).format(start)
const endLabel = new Intl.DateTimeFormat(undefined, endOptions).format(end)
const range = `${startLabel}${endLabel}`
if (!showTrailingYear) return range
const yearLabel = new Intl.DateTimeFormat(undefined, { year: 'numeric' }).format(end)
return `${range}, ${yearLabel}`
}
function formatDurationLabel(start: Date, end: Date): string {
const durationMs = end.getTime() - start.getTime()
if (!Number.isFinite(durationMs) || durationMs <= 0) return ''
if (durationMs >= ONE_DAY_MS) {
const days = Math.round(durationMs / ONE_DAY_MS)
return formatMessage(analyticsChartMessages.durationDays, { count: days })
}
if (durationMs >= ONE_HOUR_MS) {
const hours = Math.round(durationMs / ONE_HOUR_MS)
return formatMessage(analyticsChartMessages.durationHours, { count: hours })
}
const minutes = Math.max(1, Math.round(durationMs / ONE_MINUTE_MS))
return formatMessage(analyticsChartMessages.durationMinutes, { count: minutes })
}
const rangeLabel = computed(() =>
props.start && props.end
? formatRangeLabel(props.start, props.end, props.chartStart, props.chartEnd)
: '',
)
const durationLabel = computed(() =>
props.start && props.end ? formatDurationLabel(props.start, props.end) : '',
)
const previousRangeLabel = computed(() =>
props.previousStart && props.previousEnd
? formatRangeLabel(props.previousStart, props.previousEnd, props.chartStart, props.chartEnd)
: '',
)
const tooltipElement = ref<HTMLDivElement | null>(null)
const entriesElement = ref<HTMLDivElement | null>(null)
const {
showTopFade: showEntriesTopFade,
showBottomFade: showEntriesBottomFade,
checkScrollState: checkEntriesScrollState,
forceCheck: forceCheckEntriesScrollState,
} = useScrollIndicator(entriesElement)
const tooltipWidth = ref(0)
const tooltipHeight = ref(0)
const entriesTopOffset = ref(0)
const entriesBottomOffset = ref(0)
const tooltipOffsetParentLeft = ref(0)
const viewportWidth = ref(0)
let entriesTouchStartY = 0
let entriesTouchStartScrollTop = 0
const CURSOR_OFFSET = 12
const EDGE_PADDING = 8
const TOOLTIP_MAX_WIDTH = 26 * 16
const WHEEL_DELTA_LINE = 1
const WHEEL_DELTA_PAGE = 2
const WHEEL_LINE_HEIGHT = 16
function getTooltipFallbackWidth() {
const availableWidth = (viewportWidth.value || props.containerWidth) - EDGE_PADDING * 2
if (availableWidth <= 0) return TOOLTIP_MAX_WIDTH
return Math.min(TOOLTIP_MAX_WIDTH, availableWidth)
}
function updateTooltipMeasurements() {
nextTick(() => {
const element = tooltipElement.value
if (!element) return
tooltipWidth.value = element.offsetWidth
tooltipHeight.value = element.offsetHeight
const entries = entriesElement.value
if (entries) {
entriesTopOffset.value = entries.offsetTop
entriesBottomOffset.value = Math.max(
0,
element.offsetHeight - entries.offsetTop - entries.offsetHeight,
)
}
const offsetParent =
element.offsetParent instanceof HTMLElement ? element.offsetParent : element.parentElement
tooltipOffsetParentLeft.value = offsetParent?.getBoundingClientRect().left ?? 0
viewportWidth.value =
document.documentElement.clientWidth || window.innerWidth || props.containerWidth
forceCheckEntriesScrollState()
})
}
watch(
() => [
props.visible,
props.entries,
rangeLabel.value,
durationLabel.value,
previousRangeLabel.value,
props.pinned,
props.containerWidth,
props.containerHeight,
],
updateTooltipMeasurements,
{ deep: true, immediate: true },
)
onMounted(() => {
updateTooltipMeasurements()
window.addEventListener('resize', updateTooltipMeasurements)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateTooltipMeasurements)
})
function getNormalizedWheelDeltaY(event: WheelEvent, element: HTMLElement) {
if (event.deltaMode === WHEEL_DELTA_PAGE) return event.deltaY * element.clientHeight
if (event.deltaMode === WHEEL_DELTA_LINE) return event.deltaY * WHEEL_LINE_HEIGHT
return event.deltaY
}
function getMaxScrollTop(element: HTMLElement) {
return Math.max(0, element.scrollHeight - element.clientHeight)
}
function consumeWheel(event: WheelEvent): boolean {
const element = entriesElement.value
if (!props.visible || !element) return false
const maxScrollTop = getMaxScrollTop(element)
if (maxScrollTop <= 0) return false
const deltaY = getNormalizedWheelDeltaY(event, element)
if (deltaY === 0) return false
const scrollTop = element.scrollTop
element.scrollTop = Math.min(maxScrollTop, Math.max(0, scrollTop + deltaY))
event.preventDefault()
return true
}
function onEntriesTouchStart(event: TouchEvent) {
const element = entriesElement.value
const touch = event.touches[0]
if (!element || !touch) return
entriesTouchStartY = touch.clientY
entriesTouchStartScrollTop = element.scrollTop
}
function onEntriesTouchMove(event: TouchEvent) {
const element = entriesElement.value
const touch = event.touches[0]
if (!props.visible || !element || !touch) return
const maxScrollTop = getMaxScrollTop(element)
if (maxScrollTop <= 0) return
const nextScrollTop = Math.min(
maxScrollTop,
Math.max(0, entriesTouchStartScrollTop + entriesTouchStartY - touch.clientY),
)
element.scrollTop = nextScrollTop
event.preventDefault()
event.stopPropagation()
}
function clearEntriesTouchScroll() {
entriesTouchStartY = 0
entriesTouchStartScrollTop = 0
}
defineExpose({
consumeWheel,
})
const positionStyle = computed(() => {
const tooltipMaxWidth = getTooltipFallbackWidth()
const tooltipWidthForPosition = tooltipWidth.value || tooltipMaxWidth
const desiredLeft = props.x + CURSOR_OFFSET
const viewportRight = viewportWidth.value || tooltipOffsetParentLeft.value + props.containerWidth
const desiredViewportRight = tooltipOffsetParentLeft.value + desiredLeft + tooltipWidthForPosition
const shouldPlaceLeft =
props.x <= props.containerWidth / 4 || desiredViewportRight > viewportRight - EDGE_PADDING
const candidateLeft = shouldPlaceLeft
? props.x - tooltipWidthForPosition - CURSOR_OFFSET
: desiredLeft
const minLeft = EDGE_PADDING - tooltipOffsetParentLeft.value
const maxLeft = Math.max(
minLeft,
viewportRight - tooltipOffsetParentLeft.value - tooltipWidthForPosition - EDGE_PADDING,
)
const clampedLeft = Math.min(maxLeft, Math.max(minLeft, candidateLeft))
const desiredTop = props.y - tooltipHeight.value / 2
const maxTop = Math.max(EDGE_PADDING, props.containerHeight - tooltipHeight.value - EDGE_PADDING)
const clampedTop = Math.min(maxTop, Math.max(EDGE_PADDING, desiredTop))
return {
'--analytics-chart-tooltip-max-width': `${tooltipMaxWidth}px`,
'--analytics-chart-tooltip-entries-top': `${entriesTopOffset.value}px`,
'--analytics-chart-tooltip-entries-bottom': `${entriesBottomOffset.value}px`,
transform: `translate3d(${clampedLeft}px, ${clampedTop}px, 0)`,
}
})
</script>
<style scoped>
.analytics-chart-tooltip {
min-width: min(14rem, var(--analytics-chart-tooltip-max-width, calc(100vw - 1rem)));
max-width: var(--analytics-chart-tooltip-max-width, min(26rem, calc(100vw - 1rem)));
transition: transform 750ms cubic-bezier(0.22, 1, 0.36, 1);
will-change: transform;
}
.analytics-chart-tooltip-entries {
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
touch-action: pan-y;
}
.analytics-chart-tooltip-entries-fade-top {
top: var(--analytics-chart-tooltip-entries-top, 0rem);
}
.analytics-chart-tooltip-entries-fade-bottom {
bottom: var(--analytics-chart-tooltip-entries-bottom, 0rem);
}
@media (pointer: coarse) {
.analytics-chart-tooltip {
pointer-events: auto;
}
}
</style>
@@ -0,0 +1,219 @@
<template>
<div
ref="chartContainer"
class="relative -ml-4 h-[460px] select-none"
@click="onChartClick"
@wheel.capture="onChartWheel"
>
<div :class="['h-full']">
<div v-if="showEmptyChartState" class="flex h-full items-center justify-center rounded-xl">
<div v-if="!isDataLoading" class="relative bottom-6 text-base font-normal text-secondary">
{{ emptyChartMessage }}
</div>
</div>
<template v-else>
<ClientOnly>
<AnalyticsChartClient
:type="chartType"
:fill="isArea"
:stacked="isStacked"
:ratio-mode="isRatioMode"
:datasets="visibleChartDatasets"
:labels="chartLabels"
:x-axis-tick-limit="xAxisTickLimit"
:active-stat="activeStat"
:pinned-slice-index="pinnedSliceIndex"
:highlighted-dataset-id="highlightedChartDatasetId"
@hover="onChartHover"
@geometry="onChartGeometry"
@pinned-drag="onPinnedDrag"
@range-select="onRangeSelect"
@touch-drag="onTouchDragEnd"
/>
</ClientOnly>
<AnalyticsChartEvents
v-if="hasVisibleTimelineEvents"
:events="visibleTimelineEvents"
:active-stat="activeStat"
:group-by="selectedGroupBy"
:chart-start="chartRangeBounds?.start ?? null"
:chart-end="chartRangeBounds?.end ?? null"
:geometry="chartGeometry"
/>
<div
v-if="showHoverGuide"
aria-hidden="true"
class="pointer-events-none absolute bottom-0 left-0 top-0 z-10 mb-[1.8rem] mt-2.5 border-0 border-l border-solid border-contrast opacity-25"
:style="{ transform: `translate(${hoverState.x}px, 0)` }"
/>
<div
v-if="showPinnedGuide"
aria-hidden="true"
class="pointer-events-none absolute bottom-0 left-0 top-0 z-10 mb-[1.8rem] mt-2.5 border-0 border-l border-dashed border-green opacity-75"
:style="{ transform: `translate(${hoverState.x}px, 0)` }"
/>
<AnalyticsChartTooltip
ref="chartTooltip"
:visible="hoverState.visible"
:x="hoverState.x"
:y="hoverState.y"
:start="hoverBucketRange?.start ?? null"
:end="hoverBucketRange?.end ?? null"
:previous-start="previousHoverBucketRange?.start ?? null"
:previous-end="previousHoverBucketRange?.end ?? null"
:chart-start="chartRangeBounds?.start ?? null"
:chart-end="chartRangeBounds?.end ?? null"
:formatted-total="hoverFormattedTotal"
:entries="hoverEntries"
:container-width="containerSize.width"
:container-height="containerSize.height"
:pinned="isHoverPinned"
:ratio-mode="isRatioMode"
:capitalize-labels="shouldCapitalizeDatasetLabels"
:shift-key-pressed="isShiftKeyPressed"
@entry-click="(datasetId, shiftKey) => emit('entry-click', datasetId, shiftKey)"
@entry-hover="emit('entry-hover', $event)"
@entry-hover-clear="emit('entry-hover-clear', $event)"
/>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { useFormatNumber, useVIntl } from '@modrinth/ui'
import type {
AnalyticsDashboardStat,
AnalyticsGroupByPreset,
} from '~/providers/analytics/analytics'
import type {
AnalyticsChartLegendEntry,
AnalyticsChartRangeBounds,
} from '../analytics-chart-types.ts'
import type { ChartDataset } from '../analytics-chart-utils.ts'
import { formatMetricValue } from '../analytics-chart-utils.ts'
import AnalyticsChartClient from '../AnalyticsChart.client.vue'
import AnalyticsChartEvents, { type AnalyticsChartEvent } from './AnalyticsChartEvents.vue'
import AnalyticsChartTooltip from './AnalyticsChartTooltip.vue'
import { useAnalyticsChartInteractions } from './use-analytics-chart-interactions.ts'
const props = defineProps<{
chartType: 'line' | 'bar'
isArea: boolean
isStacked: boolean
isRatioMode: boolean
isDataLoading: boolean
showEmptyChartState: boolean
emptyChartMessage: string
visibleChartDatasets: ChartDataset[]
chartLabels: string[]
xAxisTickLimit?: number
activeStat: AnalyticsDashboardStat
highlightedChartDatasetId: string | null
hasVisibleTimelineEvents: boolean
visibleTimelineEvents: AnalyticsChartEvent[]
selectedGroupBy: AnalyticsGroupByPreset
chartRangeBounds: AnalyticsChartRangeBounds | null
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null
sliceCount: number
shouldShowPreviousPeriod: boolean
allChartDatasets: ChartDataset[]
currentLegendEntries: AnalyticsChartLegendEntry[]
legendEntries: AnalyticsChartLegendEntry[]
chartDatasetById: Map<string, ChartDataset>
hoverRatioSliceTotals: number[]
shouldCapitalizeDatasetLabels: boolean
}>()
const emit = defineEmits<{
'range-select': [start: Date, end: Date, groupBy: AnalyticsGroupByPreset]
'entry-click': [datasetId: string, shiftKey: boolean]
'entry-hover': [datasetId: string]
'entry-hover-clear': [datasetId: string]
}>()
const formatNumber = useFormatNumber()
const { formatMessage } = useVIntl()
const {
chartContainer,
chartTooltip,
chartGeometry,
containerSize,
hoverState,
isHoverPinned,
isShiftKeyPressed,
onChartHover,
onPinnedDrag,
onTouchDragEnd,
onChartGeometry,
onRangeSelect,
onChartClick,
onChartWheel,
pinnedSliceIndex,
showHoverGuide,
showPinnedGuide,
hoverBucketRange,
previousHoverBucketRange,
} = useAnalyticsChartInteractions({
isDataLoading: computed(() => props.isDataLoading),
fetchRequest: computed(() => props.fetchRequest),
sliceCount: computed(() => props.sliceCount),
chartLabels: computed(() => props.chartLabels),
allChartDatasets: computed(() => props.allChartDatasets),
chartRangeBounds: computed(() => props.chartRangeBounds),
shouldShowPreviousPeriod: computed(() => props.shouldShowPreviousPeriod),
onRangeSelected: (start, end, groupBy) => emit('range-select', start, end, groupBy),
})
const hoverTotalValue = computed(() => {
if (hoverState.sliceIndex === null) return 0
const sliceIndex = hoverState.sliceIndex
if (props.isRatioMode) return props.hoverRatioSliceTotals[sliceIndex] ?? 0
return props.currentLegendEntries.reduce((sum, legendEntry) => {
if (legendEntry.hidden) return sum
const dataset = props.chartDatasetById.get(legendEntry.id)
return sum + (dataset?.data[sliceIndex] ?? 0)
}, 0)
})
const hoverFormattedTotal = computed(() => {
if (props.isRatioMode) {
return hoverTotalValue.value > 0 ? '100%' : '0%'
}
return formatMetricValue(hoverTotalValue.value, props.activeStat, formatNumber, formatMessage)
})
const hoverEntries = computed(() => {
if (hoverState.sliceIndex === null) return []
const sliceIndex = hoverState.sliceIndex
const totalValue = hoverTotalValue.value
return props.legendEntries.map((legendEntry) => {
const dataset = props.chartDatasetById.get(legendEntry.id)
const value = dataset?.data[sliceIndex] ?? 0
const ratioValue = legendEntry.hidden || totalValue === 0 ? 0 : (value / totalValue) * 100
return {
projectId: legendEntry.id,
name: legendEntry.name,
projectName: legendEntry.projectName,
color: legendEntry.color,
formattedValue: props.isRatioMode
? `${ratioValue.toFixed(1)}%`
: formatMetricValue(value, props.activeStat, formatNumber, formatMessage),
hidden: legendEntry.hidden,
toggleDisabled: !legendEntry.hidden && isLegendEntryToggleDisabled(legendEntry),
isPreviousPeriod: legendEntry.isPreviousPeriod,
}
})
})
function isLegendEntryToggleDisabled(legendEntry: AnalyticsChartLegendEntry) {
if (legendEntry.hidden) return false
const visibleCount = props.legendEntries.filter((entry) => !entry.hidden).length
return visibleCount <= 1
}
</script>
@@ -0,0 +1,232 @@
import type { Labrinth } from '@modrinth/api-client'
import { injectModrinthClient, useVIntl } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed, type ComputedRef } from 'vue'
import type { AnalyticsDashboardContextValue } from '~/providers/analytics/analytics'
import { analyticsProjectEventMessages, type FormatMessage } from '../../analytics-messages.ts'
import {
PROJECT_EVENT_DATE_FORMATTER,
PROJECT_VERSION_UPLOAD_DEDUPE_WINDOW_MS,
VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUS_SET,
type VisibleProjectStatusChangeEventStatus,
} from '../analytics-chart-constants.ts'
import type { AnalyticsChartRangeBounds } from '../analytics-chart-types.ts'
import type { AnalyticsChartEvent } from './AnalyticsChartEvents.vue'
const analyticsEventsQueryKey = ['analytics-events'] as const
export function useAnalyticsChartEvents(
context: Pick<
AnalyticsDashboardContextValue,
| 'activeStat'
| 'showChartEvents'
| 'showProjectEvents'
| 'displayedProjectEvents'
| 'hasCompletedAnalyticsLoading'
>,
chartRangeBounds: ComputedRef<AnalyticsChartRangeBounds | null>,
selectedProjectNameById: ComputedRef<Map<string, string>>,
selectedProjectEventIdSet: ComputedRef<Set<string>>,
visibleProjectEventIdSet: ComputedRef<Set<string>>,
) {
const client = injectModrinthClient()
const { formatMessage } = useVIntl()
const { data: analyticsEvents } = useQuery({
queryKey: analyticsEventsQueryKey,
queryFn: () => client.labrinth.analytics_v3.getEvents(),
enabled: computed(() => context.hasCompletedAnalyticsLoading.value),
placeholderData: [],
refetchOnMount: 'always',
retry: false,
staleTime: 0,
})
const localAnalyticsChartEvents = computed(() => analyticsEvents.value ?? [])
const hasChartEvents = computed(() =>
localAnalyticsChartEvents.value.some(isTimelineEventVisibleInCurrentGraph),
)
const visibleModrinthChartEvents = computed<AnalyticsChartEvent[]>(() =>
context.showChartEvents.value
? localAnalyticsChartEvents.value.map((event) => ({
...event,
markerIcon: 'info' as const,
groupKey: 'modrinth',
}))
: [],
)
const localProjectChartEvents = computed<AnalyticsChartEvent[]>(() =>
dedupeProjectVersionUploadEvents(
context.displayedProjectEvents.value.filter(
(event) =>
selectedProjectEventIdSet.value.has(event.project_id) && shouldShowProjectEvent(event),
),
).map((event) => ({
title: getProjectEventTitle(event, formatMessage),
starts: event.timestamp,
ends: event.timestamp,
projectId: event.project_id,
projectName: selectedProjectNameById.value.get(event.project_id),
subtitle: formatProjectEventDate(event.timestamp),
markerIcon: 'flag' as const,
groupKey: 'project',
})),
)
const hasProjectEvents = computed(() =>
localProjectChartEvents.value.some(
(event) =>
isProjectChartEventVisibleForLegend(event) && isTimelineEventVisibleInCurrentGraph(event),
),
)
const visibleProjectChartEvents = computed(() =>
context.showProjectEvents.value
? localProjectChartEvents.value.filter(isProjectChartEventVisibleForLegend)
: [],
)
const visibleTimelineEvents = computed(() => [
...visibleModrinthChartEvents.value,
...visibleProjectChartEvents.value,
])
const hasVisibleTimelineEvents = computed(
() => visibleModrinthChartEvents.value.length > 0 || visibleProjectChartEvents.value.length > 0,
)
function isTimelineEventVisibleInCurrentGraph(event: AnalyticsChartEvent) {
const rangeBounds = chartRangeBounds.value
if (!rangeBounds) return false
if (!doesTimelineEventMatchActiveStat(event)) return false
const eventStartMs = new Date(event.starts).getTime()
const eventEndMs = new Date(event.ends).getTime()
if (!Number.isFinite(eventStartMs) || !Number.isFinite(eventEndMs)) return false
if (eventEndMs < eventStartMs) return false
return eventEndMs >= rangeBounds.start.getTime() && eventStartMs <= rangeBounds.end.getTime()
}
function doesTimelineEventMatchActiveStat(event: AnalyticsChartEvent) {
if (!event.for_metric_kind?.length) return true
return event.for_metric_kind.some((metricKind) => metricKind === context.activeStat.value)
}
function isProjectChartEventVisibleForLegend(event: AnalyticsChartEvent) {
return !event.projectId || visibleProjectEventIdSet.value.has(event.projectId)
}
return {
localAnalyticsChartEvents,
hasChartEvents,
visibleModrinthChartEvents,
localProjectChartEvents,
hasProjectEvents,
visibleProjectChartEvents,
visibleTimelineEvents,
hasVisibleTimelineEvents,
isTimelineEventVisibleInCurrentGraph,
isProjectChartEventVisibleForLegend,
}
}
function getProjectEventTitle(
event: Labrinth.Analytics.v3.ProjectAnalyticsEvent,
formatMessage: FormatMessage,
) {
if (event.kind === 'version_uploaded') {
const versionNumber = event.version_number.trim()
return versionNumber
? formatMessage(analyticsProjectEventMessages.versionReleased, { version: versionNumber })
: formatMessage(analyticsProjectEventMessages.versionUploaded)
}
if (isVisibleProjectStatusChangeEventStatus(event.status_to)) {
return getProjectStatusEventTitle(event.status_to, formatMessage)
}
return formatMessage(analyticsProjectEventMessages.projectStatusChanged)
}
function getProjectStatusEventTitle(
status: VisibleProjectStatusChangeEventStatus,
formatMessage: FormatMessage,
) {
switch (status) {
case 'approved':
return formatMessage(analyticsProjectEventMessages.projectApproved)
case 'unlisted':
return formatMessage(analyticsProjectEventMessages.projectUnlisted)
case 'private':
return formatMessage(analyticsProjectEventMessages.projectPrivate)
}
}
function shouldShowProjectEvent(event: Labrinth.Analytics.v3.ProjectAnalyticsEvent) {
if (event.kind !== 'status_changed') {
return true
}
return isVisibleProjectStatusChangeEventStatus(event.status_to)
}
function isVisibleProjectStatusChangeEventStatus(
status: Labrinth.Projects.v2.ProjectStatus,
): status is VisibleProjectStatusChangeEventStatus {
return VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUS_SET.has(status)
}
function dedupeProjectVersionUploadEvents(events: Labrinth.Analytics.v3.ProjectAnalyticsEvent[]) {
const keptEvents: Labrinth.Analytics.v3.ProjectAnalyticsEvent[] = []
const keptVersionUploadEventsByKey = new Map<
string,
Labrinth.Analytics.v3.ProjectAnalyticsEvent[]
>()
for (const event of events) {
const key = getProjectVersionUploadDedupeKey(event)
if (!key) {
keptEvents.push(event)
continue
}
const matchingEvents = keptVersionUploadEventsByKey.get(key) ?? []
if (
matchingEvents.some((matchingEvent) =>
areProjectEventsWithinDedupeWindow(event, matchingEvent),
)
) {
continue
}
keptEvents.push(event)
matchingEvents.push(event)
keptVersionUploadEventsByKey.set(key, matchingEvents)
}
return keptEvents
}
function getProjectVersionUploadDedupeKey(event: Labrinth.Analytics.v3.ProjectAnalyticsEvent) {
if (event.kind !== 'version_uploaded') return null
const versionNumber = event.version_number.trim()
if (versionNumber.length === 0) return null
return `${event.project_id}:${versionNumber}`
}
function areProjectEventsWithinDedupeWindow(
left: Labrinth.Analytics.v3.ProjectAnalyticsEvent,
right: Labrinth.Analytics.v3.ProjectAnalyticsEvent,
) {
const leftTimestamp = new Date(left.timestamp).getTime()
const rightTimestamp = new Date(right.timestamp).getTime()
if (!Number.isFinite(leftTimestamp) || !Number.isFinite(rightTimestamp)) return false
return Math.abs(leftTimestamp - rightTimestamp) <= PROJECT_VERSION_UPLOAD_DEDUPE_WINDOW_MS
}
function formatProjectEventDate(timestamp: string) {
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) return timestamp
return PROJECT_EVENT_DATE_FORMATTER.format(date)
}
@@ -0,0 +1,303 @@
import type { Labrinth } from '@modrinth/api-client'
import { computed, type ComputedRef, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import type { AnalyticsGroupByPreset } from '~/providers/analytics/analytics'
import {
ensureMinimumTimeRange,
getDefaultAnalyticsGroupByForDurationMinutes,
} from '../../query-builder/timeframe.ts'
import type {
AnalyticsChartHoverState,
AnalyticsChartRangeBounds,
} from '../analytics-chart-types.ts'
import type { ChartDataset } from '../analytics-chart-utils.ts'
import { getSliceBucketRange } from '../analytics-chart-utils.ts'
import type {
AnalyticsChartGeometryPayload,
AnalyticsChartRangeSelectPayload,
} from '../AnalyticsChart.client.vue'
import type AnalyticsChartTooltip from './AnalyticsChartTooltip.vue'
export function useAnalyticsChartInteractions({
isDataLoading,
fetchRequest,
sliceCount,
chartLabels,
allChartDatasets,
chartRangeBounds,
shouldShowPreviousPeriod,
onRangeSelected,
}: {
isDataLoading: ComputedRef<boolean>
fetchRequest: ComputedRef<Labrinth.Analytics.v3.FetchRequest | null>
sliceCount: ComputedRef<number>
chartLabels: ComputedRef<string[]>
allChartDatasets: ComputedRef<ChartDataset[]>
chartRangeBounds: ComputedRef<AnalyticsChartRangeBounds | null>
shouldShowPreviousPeriod: ComputedRef<boolean>
onRangeSelected: (start: Date, end: Date, groupBy: AnalyticsGroupByPreset) => void
}) {
const chartContainer = ref<HTMLElement | null>(null)
const chartTooltip = ref<InstanceType<typeof AnalyticsChartTooltip> | null>(null)
const chartGeometry = ref<AnalyticsChartGeometryPayload | null>(null)
const containerSize = reactive({ width: 0, height: 0 })
const hoverState = reactive<AnalyticsChartHoverState>({
visible: false,
x: 0,
y: 0,
sliceIndex: null,
})
const isHoverPinned = ref(false)
const ignoreNextChartClick = ref(false)
const isShiftKeyPressed = ref(false)
let resizeObserver: ResizeObserver | null = null
let clearIgnoredChartClickTimeout: ReturnType<typeof setTimeout> | null = null
function setHoverState(payload: AnalyticsChartHoverState) {
hoverState.visible = payload.visible
hoverState.x = payload.x
hoverState.y = payload.y
hoverState.sliceIndex = payload.sliceIndex
}
function clearHoverState() {
hoverState.visible = false
hoverState.sliceIndex = null
}
function unpinHoverState() {
isHoverPinned.value = false
clearHoverState()
}
function updateShiftKeyState(event: KeyboardEvent) {
isShiftKeyPressed.value = event.shiftKey
}
function clearShiftKeyState() {
isShiftKeyPressed.value = false
}
function onDocumentClick(event: MouseEvent) {
if (!isHoverPinned.value) return
if (event.target instanceof Node && chartContainer.value?.contains(event.target)) return
unpinHoverState()
}
function onChartHover(payload: AnalyticsChartHoverState) {
if (isDataLoading.value) return
if (isHoverPinned.value) return
setHoverState(payload)
}
function ignoreUpcomingChartClick() {
ignoreNextChartClick.value = true
if (clearIgnoredChartClickTimeout) {
clearTimeout(clearIgnoredChartClickTimeout)
}
clearIgnoredChartClickTimeout = setTimeout(() => {
ignoreNextChartClick.value = false
clearIgnoredChartClickTimeout = null
}, 350)
}
function onPinnedDrag(payload: AnalyticsChartHoverState) {
if (isDataLoading.value || !isHoverPinned.value) return
ignoreUpcomingChartClick()
setHoverState(payload)
}
function onTouchDragEnd() {
ignoreUpcomingChartClick()
}
function onChartGeometry(payload: AnalyticsChartGeometryPayload) {
chartGeometry.value = payload
}
function getDefaultGroupByForRange(start: Date, end: Date) {
const ensuredRange = ensureMinimumTimeRange(start, end)
const durationMinutes = Math.max(
1,
Math.floor((ensuredRange.end.getTime() - ensuredRange.start.getTime()) / 60000),
)
return getDefaultAnalyticsGroupByForDurationMinutes(durationMinutes)
}
function onRangeSelect(payload: AnalyticsChartRangeSelectPayload) {
if (isDataLoading.value) return
const nextFetchRequest = fetchRequest.value
if (!nextFetchRequest) return
if (payload.startSliceIndex === payload.endSliceIndex) {
ignoreUpcomingChartClick()
return
}
const startSliceIndex = Math.min(payload.startSliceIndex, payload.endSliceIndex)
const endSliceIndex = Math.max(payload.startSliceIndex, payload.endSliceIndex)
const startBucketRange = getSliceBucketRange(
nextFetchRequest.time_range,
sliceCount.value,
startSliceIndex,
)
const endBucketRange = getSliceBucketRange(
nextFetchRequest.time_range,
sliceCount.value,
endSliceIndex,
)
const start = startBucketRange.start
const end = endBucketRange.end
if (
!Number.isFinite(start.getTime()) ||
!Number.isFinite(end.getTime()) ||
end.getTime() <= start.getTime()
) {
return
}
ignoreUpcomingChartClick()
unpinHoverState()
onRangeSelected(start, end, getDefaultGroupByForRange(start, end))
}
function onChartClick() {
if (isDataLoading.value) return
if (ignoreNextChartClick.value) {
ignoreNextChartClick.value = false
return
}
if (!hoverState.visible || hoverState.sliceIndex === null) {
if (isHoverPinned.value) {
unpinHoverState()
}
return
}
if (isHoverPinned.value) {
unpinHoverState()
return
}
isHoverPinned.value = true
}
function onChartWheel(event: WheelEvent) {
if (isAnalyticsEventTooltipTrigger(event.target)) return
if (!hoverState.visible) return
chartTooltip.value?.consumeWheel(event)
}
function isAnalyticsEventTooltipTrigger(target: EventTarget | null) {
return (
target instanceof Element && target.closest('[data-analytics-event-tooltip-trigger]') !== null
)
}
const pinnedSliceIndex = computed(() => (isHoverPinned.value ? hoverState.sliceIndex : null))
const showHoverGuide = computed(
() =>
!isDataLoading.value &&
!isHoverPinned.value &&
hoverState.visible &&
hoverState.sliceIndex !== null,
)
const showPinnedGuide = computed(
() =>
!isDataLoading.value &&
isHoverPinned.value &&
hoverState.visible &&
hoverState.sliceIndex !== null,
)
const hoverBucketRange = computed(() => {
const nextFetchRequest = fetchRequest.value
if (!nextFetchRequest || hoverState.sliceIndex === null) return null
return getSliceBucketRange(nextFetchRequest.time_range, sliceCount.value, hoverState.sliceIndex)
})
const previousHoverBucketRange = computed(() => {
if (!shouldShowPreviousPeriod.value) return null
const bucketRange = hoverBucketRange.value
const rangeBounds = chartRangeBounds.value
if (!bucketRange || !rangeBounds) return null
const periodMs = rangeBounds.end.getTime() - rangeBounds.start.getTime()
if (!Number.isFinite(periodMs) || periodMs <= 0) return null
return {
start: new Date(bucketRange.start.getTime() - periodMs),
end: new Date(bucketRange.end.getTime() - periodMs),
}
})
onMounted(() => {
if (chartContainer.value && typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0]
if (!entry) return
containerSize.width = entry.contentRect.width
containerSize.height = entry.contentRect.height
})
resizeObserver.observe(chartContainer.value)
}
window.addEventListener('keydown', updateShiftKeyState)
window.addEventListener('keyup', updateShiftKeyState)
window.addEventListener('blur', clearShiftKeyState)
document.addEventListener('click', onDocumentClick, true)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
resizeObserver = null
window.removeEventListener('keydown', updateShiftKeyState)
window.removeEventListener('keyup', updateShiftKeyState)
window.removeEventListener('blur', clearShiftKeyState)
document.removeEventListener('click', onDocumentClick, true)
if (clearIgnoredChartClickTimeout) {
clearTimeout(clearIgnoredChartClickTimeout)
clearIgnoredChartClickTimeout = null
}
})
watch([chartLabels, allChartDatasets], () => {
isHoverPinned.value = false
clearHoverState()
})
watch(isDataLoading, (loading) => {
if (!loading) return
isHoverPinned.value = false
clearHoverState()
})
return {
chartContainer,
chartTooltip,
chartGeometry,
containerSize,
hoverState,
isHoverPinned,
isShiftKeyPressed,
setHoverState,
clearHoverState,
unpinHoverState,
onChartHover,
onPinnedDrag,
onTouchDragEnd,
onChartGeometry,
onRangeSelect,
onChartClick,
onChartWheel,
pinnedSliceIndex,
showHoverGuide,
showPinnedGuide,
hoverBucketRange,
previousHoverBucketRange,
}
}
@@ -0,0 +1,50 @@
import { computed, type ComputedRef, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
export function useAnalyticsChartLayout(showEmptyChartState: ComputedRef<boolean>) {
const graphSection = ref<HTMLElement | null>(null)
const rememberedGraphSectionHeight = ref(0)
const graphSectionStyle = computed(() =>
showEmptyChartState.value && rememberedGraphSectionHeight.value > 0
? { height: `${rememberedGraphSectionHeight.value}px` }
: undefined,
)
let graphSectionResizeObserver: ResizeObserver | null = null
function rememberGraphSectionHeight() {
if (!graphSection.value) return
const height = graphSection.value.getBoundingClientRect().height
if (height > 0) {
rememberedGraphSectionHeight.value = height
}
}
onMounted(() => {
if (graphSection.value && typeof ResizeObserver !== 'undefined') {
graphSectionResizeObserver = new ResizeObserver(() => {
if (showEmptyChartState.value) return
rememberGraphSectionHeight()
})
graphSectionResizeObserver.observe(graphSection.value)
}
})
onBeforeUnmount(() => {
graphSectionResizeObserver?.disconnect()
graphSectionResizeObserver = null
})
watch(showEmptyChartState, (showEmpty) => {
if (showEmpty) {
rememberGraphSectionHeight()
} else {
nextTick(rememberGraphSectionHeight)
}
})
return {
graphSection,
graphSectionStyle,
rememberGraphSectionHeight,
}
}
@@ -0,0 +1,21 @@
export type AnalyticsChartRangeBounds = {
start: Date
end: Date
}
export type AnalyticsChartHoverState = {
visible: boolean
x: number
y: number
sliceIndex: number | null
}
export type AnalyticsChartLegendEntry = {
id: string
name: string
projectName?: string
color: string
totalValue: number
hidden: boolean
isPreviousPeriod?: boolean
}
@@ -0,0 +1,862 @@
import type { Labrinth } from '@modrinth/api-client'
import type {
AnalyticsBreakdownPreset,
AnalyticsDashboardProject,
AnalyticsDashboardStat,
AnalyticsGroupByPreset,
} from '~/providers/analytics/analytics'
import {
analyticsChartMessages,
analyticsMessages,
analyticsStatCardMessages,
formatAnalyticsDownloadReasonLabel,
formatAnalyticsLoaderLabel,
formatAnalyticsMonetizationLabel,
type FormatMessage,
} from '../analytics-messages'
import {
ALL_BREAKDOWN_VALUE,
COMBINED_BREAKDOWN_LABEL_SEPARATOR,
getAnalyticsBreakdownDatasetId,
getAnalyticsBreakdownKey,
getAnalyticsBreakdownValues,
UNKNOWN_BREAKDOWN_VALUE,
} from '../breakdown'
import { PREVIOUS_PERIOD_DATASET_ID_PREFIX } from './analytics-chart-constants'
export type ChartDataset = {
projectId: string
label: string
projectName?: string
data: number[]
borderColor: string
backgroundColor: string
borderDash?: number[]
}
export function getChartDatasetTotal(dataset: ChartDataset) {
return dataset.data.reduce((sum, value) => sum + value, 0)
}
export function getPreviousPeriodDatasetId(datasetId: string) {
return `${PREVIOUS_PERIOD_DATASET_ID_PREFIX}${datasetId}`
}
export function decodeBreakdownDatasetValue(value: string) {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
export function areStringArraysEqual(left: string[], right: string[]) {
if (left.length !== right.length) return false
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) return false
}
return true
}
const LOADER_CHART_COLORS: Record<string, string> = {
fabric: 'var(--color-platform-fabric)',
'legacy-fabric': 'var(--color-platform-fabric)',
quilt: 'var(--color-platform-quilt)',
forge: 'var(--color-platform-forge)',
neoforge: 'var(--color-platform-neoforge)',
neo_forge: 'var(--color-platform-neoforge)',
liteloader: 'var(--color-platform-liteloader)',
bukkit: 'var(--color-platform-bukkit)',
bungeecord: 'var(--color-platform-bungeecord)',
folia: 'var(--color-platform-folia)',
paper: 'var(--color-platform-paper)',
purpur: 'var(--color-platform-purpur)',
spigot: 'var(--color-platform-spigot)',
velocity: 'var(--color-platform-velocity)',
waterfall: 'var(--color-platform-waterfall)',
sponge: 'var(--color-platform-sponge)',
ornithe: 'var(--color-platform-ornithe)',
'bta-babric': 'var(--color-platform-bta-babric)',
nilloader: 'var(--color-platform-nilloader)',
}
const REGION_CODE_PATTERN = /^[a-z]{2}$/i
const OTHER_COUNTRY_CODE = 'XX'
const ALL_PROJECTS_DATASET_ID = 'all'
const MONETIZATION_CHART_COLOR_INDEX: Record<string, number> = {
monetized: 0,
unmonetized: 1,
}
const regionDisplayNamesByLocale = new Map<string, Intl.DisplayNames | null>()
function getRegionDisplayNames(locale: string): Intl.DisplayNames | null {
if (regionDisplayNamesByLocale.has(locale)) {
return regionDisplayNamesByLocale.get(locale) ?? null
}
try {
const displayNames = new Intl.DisplayNames(locale, { type: 'region' })
regionDisplayNamesByLocale.set(locale, displayNames)
return displayNames
} catch {
regionDisplayNamesByLocale.set(locale, null)
return null
}
}
function formatCountryCode(countryCode: string, formatMessage: FormatMessage): string {
const normalized = countryCode.trim().toUpperCase()
if (normalized === OTHER_COUNTRY_CODE) {
return formatMessage(analyticsMessages.unknown)
}
if (!REGION_CODE_PATTERN.test(normalized)) {
return countryCode
}
const locale = new Intl.DateTimeFormat().resolvedOptions().locale || 'en'
const localizedDisplayNames = getRegionDisplayNames(locale)
const localizedValue = localizedDisplayNames?.of(normalized)
if (localizedValue && localizedValue !== normalized) {
return localizedValue
}
const englishDisplayNames = getRegionDisplayNames('en')
const englishValue = englishDisplayNames?.of(normalized)
if (englishValue && englishValue !== normalized) {
return englishValue
}
return countryCode
}
export function formatBreakdownLabel(
breakdownValue: string,
selectedBreakdown: AnalyticsBreakdownPreset,
getVersionDisplayName: ((versionId: string) => string) | undefined,
formatMessage: FormatMessage,
): string {
const normalizedValue = breakdownValue.trim()
const normalizedLowercaseValue = normalizedValue.toLowerCase()
if (
normalizedValue === UNKNOWN_BREAKDOWN_VALUE ||
normalizedLowercaseValue === 'other' ||
normalizedLowercaseValue === 'unknown'
) {
return formatMessage(analyticsMessages.unknown)
}
if (selectedBreakdown === 'country') {
return formatCountryCode(breakdownValue, formatMessage)
}
if (selectedBreakdown === 'monetization') {
return formatAnalyticsMonetizationLabel(normalizedLowercaseValue, formatMessage)
}
if (selectedBreakdown === 'download_reason') {
return formatAnalyticsDownloadReasonLabel(normalizedLowercaseValue, formatMessage)
}
if (selectedBreakdown === 'version_id') {
return getVersionDisplayName?.(breakdownValue) ?? breakdownValue
}
if (selectedBreakdown === 'loader') {
return formatAnalyticsLoaderLabel(normalizedValue, formatMessage)
}
return breakdownValue
}
export function formatBreakdownLabels(
breakdownValues: readonly string[],
selectedBreakdowns: readonly AnalyticsBreakdownPreset[],
getVersionDisplayName: ((versionId: string) => string) | undefined,
formatMessage: FormatMessage,
): string {
return collapseRepeatedUnknownBreakdownLabels(
selectedBreakdowns
.filter((breakdown) => breakdown !== 'none')
.map((breakdown, index) =>
formatBreakdownLabel(
breakdownValues[index] ?? '',
breakdown,
getVersionDisplayName,
formatMessage,
),
),
formatMessage,
).join(COMBINED_BREAKDOWN_LABEL_SEPARATOR)
}
function collapseRepeatedUnknownBreakdownLabels(
labels: string[],
formatMessage: FormatMessage,
): string[] {
let hasUnknownLabel = false
const collapsedLabels: string[] = []
const unknownBreakdownLabel = formatMessage(analyticsMessages.unknown)
for (const label of labels) {
if (label === unknownBreakdownLabel) {
if (hasUnknownLabel) {
continue
}
hasUnknownLabel = true
}
collapsedLabels.push(label)
}
return collapsedLabels
}
export function shouldCapitalizeBreakdownLabel(
selectedBreakdown: AnalyticsBreakdownPreset | readonly AnalyticsBreakdownPreset[],
): boolean {
const selectedBreakdowns = Array.isArray(selectedBreakdown)
? selectedBreakdown
: [selectedBreakdown]
return (
selectedBreakdowns.length > 0 &&
selectedBreakdowns.every(
(breakdown) =>
breakdown === 'download_reason' ||
breakdown === 'monetization' ||
breakdown === 'loader' ||
breakdown === 'country',
)
)
}
function getBreakdownColor(
breakdownValue: string,
selectedBreakdown: AnalyticsBreakdownPreset,
fallbackColor: string,
palette: string[],
): string {
if (selectedBreakdown === 'monetization') {
const colorIndex = MONETIZATION_CHART_COLOR_INDEX[breakdownValue]
if (colorIndex !== undefined) {
return getPaletteColorForIndex(colorIndex, palette)
}
}
if (selectedBreakdown !== 'loader') {
return fallbackColor
}
const normalizedLoader = breakdownValue.trim().toLowerCase()
return LOADER_CHART_COLORS[normalizedLoader] ?? fallbackColor
}
type PaletteRankEntry = {
key: string
label: string
total: number
}
function getPaletteColorForIndex(index: number, palette: string[]): string {
if (palette.length === 0) return ''
return palette[index % palette.length]
}
function buildPaletteColorsByDownloadRank(
entries: PaletteRankEntry[],
palette: string[],
): Map<string, string> {
const colorsByKey = new Map<string, string>()
if (palette.length === 0) return colorsByKey
const sortedEntries = [...entries].sort(
(a, b) => b.total - a.total || a.label.localeCompare(b.label) || a.key.localeCompare(b.key),
)
sortedEntries.forEach((entry, index) => {
colorsByKey.set(entry.key, getPaletteColorForIndex(index, palette))
})
return colorsByKey
}
export function getMetricValue(
point: Labrinth.Analytics.v3.ProjectAnalytics,
activeStat: AnalyticsDashboardStat,
): number {
switch (activeStat) {
case 'views':
return point.metric_kind === 'views' ? point.views : 0
case 'downloads':
return point.metric_kind === 'downloads' ? point.downloads : 0
case 'playtime':
return point.metric_kind === 'playtime' ? point.seconds : 0
case 'revenue': {
if (point.metric_kind !== 'revenue') return 0
const value = Number.parseFloat(point.revenue)
return Number.isFinite(value) ? value : 0
}
}
}
function isMetricKindForStat(
point: Labrinth.Analytics.v3.ProjectAnalytics,
activeStat: AnalyticsDashboardStat,
): boolean {
return point.metric_kind === activeStat
}
function isProjectAnalyticsPointInSelectedProjects(
point: Labrinth.Analytics.v3.AnalyticsData,
selectedProjectIds: Set<string>,
): point is Labrinth.Analytics.v3.ProjectAnalytics {
return 'source_project' in point && selectedProjectIds.has(point.source_project)
}
export function buildChartDatasets(
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
selectedProjects: AnalyticsDashboardProject[],
activeStat: AnalyticsDashboardStat,
palette: string[],
selectedBreakdowns: readonly AnalyticsBreakdownPreset[],
getVersionDisplayName: ((versionId: string) => string) | undefined,
getVersionProjectName: ((versionId: string) => string | undefined) | undefined,
formatMessage: FormatMessage,
sliceCount: number = timeSlices.length,
): ChartDataset[] {
const selectedProjectIds = new Set(selectedProjects.map((project) => project.id))
if (selectedProjectIds.size === 0) {
return []
}
const dataLength = Math.max(sliceCount, timeSlices.length)
const normalizedBreakdowns = selectedBreakdowns.filter((breakdown) => breakdown !== 'none')
const projectNamesById = new Map(selectedProjects.map((project) => [project.id, project.name]))
function formatChartBreakdownLabels(breakdownValues: readonly string[]): string {
return collapseRepeatedUnknownBreakdownLabels(
normalizedBreakdowns.map((breakdown, index) => {
const breakdownValue = breakdownValues[index] ?? ''
if (breakdown === 'project') {
return projectNamesById.get(breakdownValue) ?? breakdownValue
}
return formatBreakdownLabel(breakdownValue, breakdown, getVersionDisplayName, formatMessage)
}),
formatMessage,
).join(COMBINED_BREAKDOWN_LABEL_SEPARATOR)
}
if (
normalizedBreakdowns.length > 0 &&
!(normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'project')
) {
const dataByBreakdown = new Map<string, number[]>()
const breakdownValuesByKey = new Map<string, string[]>()
const downloadTotalsByBreakdown = new Map<string, number>()
timeSlices.forEach((slice, sliceIndex) => {
for (const point of slice) {
if (!isProjectAnalyticsPointInSelectedProjects(point, selectedProjectIds)) continue
const breakdownValues = getAnalyticsBreakdownValues(
point,
normalizedBreakdowns,
formatMessage,
)
if (breakdownValues.some((breakdownValue) => breakdownValue === ALL_BREAKDOWN_VALUE)) {
continue
}
const breakdownKey = getAnalyticsBreakdownKey(breakdownValues)
if (!dataByBreakdown.has(breakdownKey)) {
dataByBreakdown.set(breakdownKey, new Array(dataLength).fill(0))
breakdownValuesByKey.set(breakdownKey, breakdownValues)
}
if (point.metric_kind === 'downloads') {
downloadTotalsByBreakdown.set(
breakdownKey,
(downloadTotalsByBreakdown.get(breakdownKey) ?? 0) + getMetricValue(point, 'downloads'),
)
}
if (!isMetricKindForStat(point, activeStat)) continue
const breakdownData = dataByBreakdown.get(breakdownKey)
if (!breakdownData) continue
breakdownData[sliceIndex] += getMetricValue(point, activeStat)
}
})
const colorsByBreakdown = buildPaletteColorsByDownloadRank(
Array.from(dataByBreakdown.keys()).map((breakdownKey) => ({
key: breakdownKey,
label: formatChartBreakdownLabels(breakdownValuesByKey.get(breakdownKey) ?? []),
total: downloadTotalsByBreakdown.get(breakdownKey) ?? 0,
})),
palette,
)
return Array.from(dataByBreakdown.entries()).map(([breakdownKey, data]) => {
const breakdownValues = breakdownValuesByKey.get(breakdownKey) ?? []
const fallbackColor = colorsByBreakdown.get(breakdownKey) ?? ''
const color =
normalizedBreakdowns.length === 1
? getBreakdownColor(
breakdownValues[0] ?? '',
normalizedBreakdowns[0],
fallbackColor,
palette,
)
: fallbackColor
return {
projectId: getAnalyticsBreakdownDatasetId(breakdownValues, normalizedBreakdowns),
label: formatChartBreakdownLabels(breakdownValues),
projectName:
normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'version_id'
? getVersionProjectName?.(breakdownValues[0] ?? '')
: undefined,
data,
borderColor: color,
backgroundColor: color,
}
})
}
if (normalizedBreakdowns.length === 0) {
const data = new Array(dataLength).fill(0)
let downloadTotal = 0
timeSlices.forEach((slice, sliceIndex) => {
for (const point of slice) {
if (!isProjectAnalyticsPointInSelectedProjects(point, selectedProjectIds)) continue
if (point.metric_kind === 'downloads') {
downloadTotal += getMetricValue(point, 'downloads')
}
if (!isMetricKindForStat(point, activeStat)) continue
data[sliceIndex] += getMetricValue(point, activeStat)
}
})
const color =
buildPaletteColorsByDownloadRank(
[
{
key: ALL_PROJECTS_DATASET_ID,
label: formatMessage(analyticsMessages.allProjects),
total: downloadTotal,
},
],
palette,
).get(ALL_PROJECTS_DATASET_ID) ?? ''
const selectedProject = selectedProjects.length === 1 ? selectedProjects[0] : undefined
return [
{
projectId: ALL_PROJECTS_DATASET_ID,
label: selectedProject?.name ?? formatMessage(analyticsMessages.allProjects),
data,
borderColor: color,
backgroundColor: color,
},
]
}
const dataByProjectBreakdown = new Map<string, number[]>()
const breakdownValuesByKey = new Map<string, string[]>()
const downloadTotalsByProjectBreakdown = new Map<string, number>()
for (const project of selectedProjects) {
const breakdownValues = [project.id]
const breakdownKey = getAnalyticsBreakdownKey(breakdownValues)
dataByProjectBreakdown.set(breakdownKey, new Array(dataLength).fill(0))
breakdownValuesByKey.set(breakdownKey, breakdownValues)
downloadTotalsByProjectBreakdown.set(breakdownKey, 0)
}
timeSlices.forEach((slice, sliceIndex) => {
for (const point of slice) {
if (!isProjectAnalyticsPointInSelectedProjects(point, selectedProjectIds)) continue
const breakdownValues = getAnalyticsBreakdownValues(
point,
normalizedBreakdowns,
formatMessage,
)
if (breakdownValues.some((breakdownValue) => breakdownValue === ALL_BREAKDOWN_VALUE)) {
continue
}
const breakdownKey = getAnalyticsBreakdownKey(breakdownValues)
if (!dataByProjectBreakdown.has(breakdownKey)) {
dataByProjectBreakdown.set(breakdownKey, new Array(dataLength).fill(0))
breakdownValuesByKey.set(breakdownKey, breakdownValues)
downloadTotalsByProjectBreakdown.set(breakdownKey, 0)
}
if (point.metric_kind === 'downloads') {
downloadTotalsByProjectBreakdown.set(
breakdownKey,
(downloadTotalsByProjectBreakdown.get(breakdownKey) ?? 0) +
getMetricValue(point, 'downloads'),
)
}
if (!isMetricKindForStat(point, activeStat)) continue
const projectData = dataByProjectBreakdown.get(breakdownKey)
if (!projectData) continue
projectData[sliceIndex] += getMetricValue(point, activeStat)
}
})
const colorsByBreakdown = buildPaletteColorsByDownloadRank(
Array.from(dataByProjectBreakdown.keys()).map((breakdownKey) => ({
key: breakdownKey,
label: formatChartBreakdownLabels(breakdownValuesByKey.get(breakdownKey) ?? []),
total: downloadTotalsByProjectBreakdown.get(breakdownKey) ?? 0,
})),
palette,
)
return Array.from(dataByProjectBreakdown.entries()).map(([breakdownKey, data]) => {
const breakdownValues = breakdownValuesByKey.get(breakdownKey) ?? []
const fallbackColor = colorsByBreakdown.get(breakdownKey) ?? ''
const color =
normalizedBreakdowns.length === 1
? getBreakdownColor(
breakdownValues[0] ?? '',
normalizedBreakdowns[0],
fallbackColor,
palette,
)
: fallbackColor
return {
projectId: getAnalyticsBreakdownDatasetId(breakdownValues, normalizedBreakdowns),
label: formatChartBreakdownLabels(breakdownValues),
projectName:
normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'version_id'
? getVersionProjectName?.(breakdownValues[0] ?? '')
: undefined,
data,
borderColor: color,
backgroundColor: color,
}
})
}
export function getSliceCount(
timeRange: Labrinth.Analytics.v3.TimeRange,
fallback: number,
): number {
if ('slices' in timeRange.resolution) {
return Math.max(1, timeRange.resolution.slices)
}
if ('minutes' in timeRange.resolution) {
const duration = new Date(timeRange.end).getTime() - new Date(timeRange.start).getTime()
const bucketMs = timeRange.resolution.minutes * 60 * 1000
if (bucketMs > 0 && duration > 0) {
return Math.max(1, Math.ceil(duration / bucketMs))
}
}
return Math.max(1, fallback)
}
export function getSliceBucketRange(
timeRange: Labrinth.Analytics.v3.TimeRange,
sliceCount: number,
index: number,
): { start: Date; end: Date } {
const startMs = new Date(timeRange.start).getTime()
const endMs = new Date(timeRange.end).getTime()
const bucketMs = sliceCount > 0 ? (endMs - startMs) / sliceCount : 0
return {
start: new Date(startMs + index * bucketMs),
end: new Date(startMs + (index + 1) * bucketMs),
}
}
const ONE_DAY_MS = 24 * 60 * 60 * 1000
const ONE_MINUTE_MS = 60 * 1000
const YEAR_LABEL_TIME_RANGE_YEARS = 2
const COMPACT_AXIS_THRESHOLD = 5
const SHORT_HOURLY_TIME_LABEL_DURATION_MS = 6 * ONE_DAY_MS
export const DEFAULT_X_AXIS_TICK_LIMIT = 12
export const SHORT_HOURLY_AXIS_TICK_LIMIT = 8
export function buildTimeAxisLabels(
timeRange: Labrinth.Analytics.v3.TimeRange,
sliceCount: number,
groupBy: AnalyticsGroupByPreset,
): string[] {
const startMs = new Date(timeRange.start).getTime()
const endMs = new Date(timeRange.end).getTime()
const totalMs = endMs - startMs
const bucketMs = sliceCount > 0 ? totalMs / sliceCount : 0
const includeTime = shouldShowTimeForHourlyAxis(timeRange, groupBy)
const includeYear = isYearRelevantForTimeRange(timeRange) || groupBy === 'year'
const dates: Date[] = []
const dateKeys: string[] = []
for (let i = 0; i < sliceCount; i++) {
const date = new Date(startMs + (i + 1) * bucketMs)
dates.push(date)
dateKeys.push(`${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`)
}
const dateFormatter = new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
...(includeYear ? { year: 'numeric' } : {}),
})
if (!includeTime) {
return dates.map((date) => dateFormatter.format(date))
}
const timeFormatter = new Intl.DateTimeFormat(undefined, { hour: 'numeric' })
const uniqueDateCount = new Set(dateKeys).size
if (uniqueDateCount <= 1 || isSingleFullDayTimeRange(new Date(startMs), new Date(endMs))) {
return dates.map((date) => timeFormatter.format(date))
}
if (includeTime || sliceCount <= COMPACT_AXIS_THRESHOLD) {
const dateAndTimeFormatter = new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
...(includeYear ? { year: 'numeric' } : {}),
})
return dates.map((date) => dateAndTimeFormatter.format(date))
}
return dates.map((date) => dateFormatter.format(date))
}
export function isTimeRelevantForGroupBy(groupBy: AnalyticsGroupByPreset): boolean {
return groupBy === '1h' || groupBy === '6h'
}
export function shouldUseShortHourlyAxis(
timeRange: Labrinth.Analytics.v3.TimeRange,
groupBy: AnalyticsGroupByPreset,
): boolean {
if (!isTimeRelevantForGroupBy(groupBy)) {
return false
}
const durationMs = getTimeRangeDurationMs(timeRange)
return (
Number.isFinite(durationMs) &&
durationMs > 0 &&
durationMs <= DEFAULT_X_AXIS_TICK_LIMIT * ONE_DAY_MS
)
}
export function getShortHourlyAxisTickLimit(
timeRange: Labrinth.Analytics.v3.TimeRange,
groupBy: AnalyticsGroupByPreset,
): number | undefined {
if (!shouldUseShortHourlyAxis(timeRange, groupBy)) {
return undefined
}
const durationMs = getTimeRangeDurationMs(timeRange)
if (durationMs > SHORT_HOURLY_TIME_LABEL_DURATION_MS) {
return Math.min(DEFAULT_X_AXIS_TICK_LIMIT, Math.ceil(durationMs / ONE_DAY_MS))
}
return SHORT_HOURLY_AXIS_TICK_LIMIT
}
function shouldShowTimeForHourlyAxis(
timeRange: Labrinth.Analytics.v3.TimeRange,
groupBy: AnalyticsGroupByPreset,
): boolean {
const durationMs = getTimeRangeDurationMs(timeRange)
return (
isTimeRelevantForGroupBy(groupBy) &&
Number.isFinite(durationMs) &&
durationMs > 0 &&
durationMs <= SHORT_HOURLY_TIME_LABEL_DURATION_MS
)
}
function getTimeRangeDurationMs(timeRange: Labrinth.Analytics.v3.TimeRange): number {
return new Date(timeRange.end).getTime() - new Date(timeRange.start).getTime()
}
export function isYearRelevantForTimeRange(timeRange: Labrinth.Analytics.v3.TimeRange): boolean {
const start = new Date(timeRange.start)
const end = new Date(timeRange.end)
const yearLabelThreshold = new Date(start)
yearLabelThreshold.setFullYear(start.getFullYear() + YEAR_LABEL_TIME_RANGE_YEARS)
return (
Number.isFinite(start.getTime()) &&
Number.isFinite(end.getTime()) &&
end.getTime() > yearLabelThreshold.getTime()
)
}
export function formatBucketEndLabel(end: Date, includeTime: boolean, includeYear = false): string {
if (includeTime) {
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
...(includeYear ? { year: 'numeric' } : {}),
hour: 'numeric',
minute: '2-digit',
}).format(end)
}
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
...(includeYear ? { year: 'numeric' } : {}),
}).format(end)
}
function isStartOfDay(date: Date): boolean {
return (
date.getHours() === 0 &&
date.getMinutes() === 0 &&
date.getSeconds() === 0 &&
date.getMilliseconds() === 0
)
}
function isSingleFullDayTimeRange(start: Date, end: Date): boolean {
const durationMs = end.getTime() - start.getTime()
return (
Math.abs(durationMs - ONE_DAY_MS) < ONE_MINUTE_MS && isStartOfDay(start) && isStartOfDay(end)
)
}
export function formatMetricValue(
value: number,
activeStat: AnalyticsDashboardStat,
formatNumber: (value: number) => string,
formatMessage: FormatMessage,
): string {
switch (activeStat) {
case 'revenue': {
const amount = Math.round(value * 100) / 100
return formatMessage(analyticsStatCardMessages.revenueValue, {
value: formatNumber(amount),
})
}
case 'playtime': {
const hours = value / 3600
return formatMessage(analyticsStatCardMessages.playtimeHours, {
hours: hours.toFixed(1),
})
}
case 'views':
case 'downloads':
default:
return formatNumber(Math.round(value))
}
}
function formatSmallAxisNumber(value: number): string {
const rounded = Math.round(value)
if (Math.abs(value - rounded) < 0.0000001) {
return String(rounded)
}
const formattedValue = Math.abs(value) < 1 ? value.toFixed(2) : value.toFixed(1)
return formattedValue.replace(/\.?0+$/, '')
}
const COMPACT_AXIS_UNITS = [
{ threshold: 1_000_000, divisor: 1_000_000, suffix: 'M' },
{ threshold: 1_000, divisor: 1_000, suffix: 'K' },
] as const
const MAX_COMPACT_AXIS_DIGITS = 3
function getCompactAxisUnit(values: readonly number[]) {
let maxAbsoluteValue = 0
for (const value of values) {
if (Number.isFinite(value)) {
maxAbsoluteValue = Math.max(maxAbsoluteValue, Math.abs(value))
}
}
return COMPACT_AXIS_UNITS.find((unit) => maxAbsoluteValue >= unit.threshold) ?? null
}
function formatCompactAxisNumber(value: number, axisValues: readonly number[]): string | null {
if (Math.abs(value) === 0) return '0'
const unit = getCompactAxisUnit(axisValues)
if (!unit) return null
return `${formatCompactAxisValue(value / unit.divisor)}${unit.suffix}`
}
function formatCompactAxisValue(value: number): string {
const absoluteValue = Math.abs(value)
if (absoluteValue === 0) return '0'
const integerDigitCount = absoluteValue < 1 ? 1 : Math.floor(absoluteValue).toString().length
const fractionDigitCount = Math.max(0, MAX_COMPACT_AXIS_DIGITS - integerDigitCount)
const roundedValue = Number(value.toFixed(fractionDigitCount))
const roundedIntegerDigitCount =
Math.abs(roundedValue) < 1 ? 1 : Math.floor(Math.abs(roundedValue)).toString().length
if (roundedIntegerDigitCount > MAX_COMPACT_AXIS_DIGITS) {
const truncatedValue = Math.sign(value) * (10 ** MAX_COMPACT_AXIS_DIGITS - 1)
return String(truncatedValue)
}
return roundedValue.toFixed(fractionDigitCount).replace(/\.?0+$/, '')
}
export function formatAxisValue(
value: number,
activeStat: AnalyticsDashboardStat,
formatCompact: (value: number) => string,
formatMessage: FormatMessage,
axisValues: readonly number[] = [value],
): string {
switch (activeStat) {
case 'revenue': {
const amount = Math.round(value * 100) / 100
const axisAmounts = axisValues.map((axisValue) => Math.round(axisValue * 100) / 100)
return formatMessage(analyticsStatCardMessages.revenueValue, {
value: formatCompactAxisNumber(amount, axisAmounts) ?? formatCompact(amount),
})
}
case 'playtime': {
const formattedHours = formatCompactAxisNumber(value, axisValues)
if (formattedHours) {
return formatMessage(analyticsChartMessages.playtimeAxisHours, { hours: formattedHours })
}
if (Math.abs(value) < 10) {
return formatMessage(analyticsChartMessages.playtimeAxisHours, {
hours: formatSmallAxisNumber(value),
})
}
return formatMessage(analyticsChartMessages.playtimeAxisHours, {
hours: formatCompact(Math.round(value)),
})
}
case 'views':
case 'downloads':
default: {
const roundedValue = Math.round(value)
const roundedAxisValues = axisValues.map((axisValue) => Math.round(axisValue))
const formattedValue = formatCompactAxisNumber(roundedValue, roundedAxisValues)
if (formattedValue) return formattedValue
if (Math.abs(value) < 10) {
return formatSmallAxisNumber(value)
}
return formatCompact(roundedValue)
}
}
}
@@ -0,0 +1,252 @@
<template>
<AnalyticsChartRenderLimitModal
ref="showAllSelectedGraphDatasetsModal"
:table-project-count="tableProjectCount"
@confirm="confirmShowAllSelectedGraphDatasets"
/>
<section
ref="graphSection"
class="relative flex flex-col rounded-2xl border border-solid border-surface-5 bg-surface-3"
:style="graphSectionStyle"
>
<AnalyticsChartHeader
v-model:active-graph-view-mode="activeGraphViewMode"
v-model:ratio-mode="isRatioMode"
v-model:show-chart-events="showChartEvents"
v-model:show-project-events="showProjectEvents"
v-model:show-previous-period="showPreviousPeriod"
:graph-title="graphTitle"
:show-table-selection-subheading="showTableSelectionSubheading"
:table-selection-subheading="tableSelectionSubheading"
:show-graph-render-limit-button="showGraphRenderLimitButton"
:graph-render-limit-button-label="graphRenderLimitButtonLabel"
:show-top-graph-datasets-button="showTopGraphDatasetsButton"
:can-use-ratio-mode="canUseRatioMode"
:can-show-previous-period="canShowPreviousPeriodToggle"
:has-chart-events="hasChartEvents"
:has-project-events="hasProjectEvents"
:small-toggles="!isMobileLayout"
:default-show-project-events="defaultShowProjectEvents"
:is-mobile-layout="isMobileLayout"
@toggle-graph-render-limit="toggleGraphRenderLimit"
@show-top-graph-datasets="showTopGraphDatasets"
/>
<div
class="flex flex-col gap-6 px-4 pb-6 pt-5"
:class="['transition-opacity', isDataLoading ? 'pointer-events-none opacity-75' : '']"
>
<AnalyticsChartLegend
:legend-entries="legendEntries"
:should-capitalize-dataset-labels="shouldCapitalizeDatasetLabels"
:show-unmonetized-info="showUnmonetizedInfo"
@entry-hover="setHoveredLegendEntryId"
@entry-hover-clear="clearHoveredLegendEntryId"
@entry-click="onLegendEntryClick"
/>
<AnalyticsChartPlot
:chart-type="chartType"
:is-area="isArea"
:is-stacked="isStacked"
:is-ratio-mode="isRatioMode"
:is-data-loading="isDataLoading"
:show-empty-chart-state="showEmptyChartState"
:empty-chart-message="emptyChartMessage"
:visible-chart-datasets="visibleChartDatasets"
:chart-labels="chartLabels"
:x-axis-tick-limit="xAxisTickLimit"
:active-stat="activeStat"
:highlighted-chart-dataset-id="highlightedChartDatasetId"
:has-visible-timeline-events="hasVisibleTimelineEvents"
:visible-timeline-events="visibleTimelineEvents"
:selected-group-by="selectedGroupBy"
:chart-range-bounds="chartRangeBounds"
:fetch-request="fetchRequest"
:slice-count="sliceCount"
:should-show-previous-period="shouldShowPreviousPeriod"
:all-chart-datasets="allChartDatasets"
:current-legend-entries="currentLegendEntries"
:legend-entries="legendEntries"
:chart-dataset-by-id="chartDatasetById"
:hover-ratio-slice-totals="hoverRatioSliceTotals"
:should-capitalize-dataset-labels="shouldCapitalizeDatasetLabels"
@range-select="onRangeSelect"
@entry-click="onTooltipEntryClick"
@entry-hover="setHoveredLegendEntryId"
@entry-hover-clear="clearHoveredLegendEntryId"
/>
</div>
<div class="pointer-events-none absolute inset-0 z-[20] overflow-hidden rounded-xl">
<AnalyticsLoadingBar :loading="isDataLoading" />
</div>
<div v-if="isDataLoading" class="absolute inset-0 z-[19] overflow-hidden rounded-xl">
<div class="absolute inset-0 bg-surface-3 opacity-50" />
<div class="absolute inset-0 backdrop-blur-[3px]" />
<div class="absolute inset-0 flex items-center justify-center">
<div
class="relative bottom-6 inline-flex items-center gap-2 text-lg font-semibold text-primary"
>
<span>{{ formatMessage(analyticsMessages.fetchingResults) }}</span>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { useVIntl } from '@modrinth/ui'
import { getDefaultAnalyticsGraphProjectEventsVisibility } from '~/components/analytics-dashboard/analytics-route-query'
import type { AnalyticsGroupByPreset } from '~/providers/analytics/analytics'
import { injectAnalyticsDashboardContext } from '~/providers/analytics/analytics'
import { analyticsMessages } from '../analytics-messages.ts'
import AnalyticsLoadingBar from '../AnalyticsLoadingBar.vue'
import AnalyticsChartLegend from './analytics-chart-header/AnalyticsChartLegend.vue'
import AnalyticsChartRenderLimitModal from './analytics-chart-header/AnalyticsChartRenderLimitModal.vue'
import AnalyticsChartHeader from './analytics-chart-header/index.vue'
import { useAnalyticsChartLegend } from './analytics-chart-header/use-analytics-chart-legend.ts'
import AnalyticsChartPlot from './analytics-chart-plot/index.vue'
import { useAnalyticsChartEvents } from './analytics-chart-plot/use-analytics-chart-events.ts'
import { useAnalyticsChartLayout } from './analytics-chart-plot/use-analytics-chart-layout.ts'
import { useAnalyticsChartDatasets } from './use-analytics-chart-datasets.ts'
import { useAnalyticsChartProjects } from './use-analytics-chart-projects.ts'
const dashboardContext = injectAnalyticsDashboardContext()
const { formatMessage } = useVIntl()
const {
activeStat,
activeGraphViewMode,
isRatioMode,
showChartEvents,
showProjectEvents,
showPreviousPeriod,
isMobileLayout,
hiddenGraphDatasetIds,
isGraphDatasetSelectionActive,
selectedProjectIds: currentSelectedProjectIds,
selectedTimeframeMode,
selectedCustomTimeframeStartDate,
selectedCustomTimeframeEndDate,
selectedGroupBy: selectedDashboardGroupBy,
displayedFetchRequest: fetchRequest,
displayedSelectedGroupBy: selectedGroupBy,
displayedSelectedBreakdowns: selectedBreakdowns,
isLoading,
} = dashboardContext
const isDataLoading = computed(() => isLoading.value)
const defaultShowProjectEvents = computed(() =>
getDefaultAnalyticsGraphProjectEventsVisibility(currentSelectedProjectIds.value),
)
const {
selectedProjectIdSet,
hasAvailableProjects,
selectedProjects,
selectedProjectNameById,
selectedProjectEventIdSet,
} = useAnalyticsChartProjects(dashboardContext)
const {
showAllSelectedGraphDatasets,
chartRangeBounds,
tableProjectCount,
showEmptyChartState,
emptyChartMessage,
graphTitle,
showTableSelectionSubheading,
shouldCapitalizeDatasetLabels,
chartType,
canShowPreviousPeriodToggle,
shouldShowPreviousPeriod,
isArea,
isStacked,
sliceCount,
chartLabels,
xAxisTickLimit,
allChartDatasets,
previousChartDatasets,
tableSelectionSubheading,
showGraphRenderLimitButton,
graphRenderLimitButtonLabel,
showTopGraphDatasetsButton,
selectableChartDatasets,
showTopGraphDatasets,
} = useAnalyticsChartDatasets(dashboardContext, selectedProjects, hasAvailableProjects)
const {
currentLegendEntries,
visibleProjectEventIdSet,
legendEntries,
chartDatasetById,
hoverRatioSliceTotals,
visibleChartDatasets,
highlightedChartDatasetId,
setHoveredLegendEntryId,
clearHoveredLegendEntryId,
onLegendEntryClick,
onTooltipEntryClick,
} = useAnalyticsChartLegend({
selectableChartDatasets,
allChartDatasets,
previousChartDatasets,
shouldShowPreviousPeriod,
isRatioMode,
hiddenGraphDatasetIds,
selectedBreakdowns,
isGraphDatasetSelectionActive,
selectedProjects,
selectedProjectIdSet,
selectedProjectEventIdSet,
})
const { hasChartEvents, hasProjectEvents, visibleTimelineEvents, hasVisibleTimelineEvents } =
useAnalyticsChartEvents(
dashboardContext,
chartRangeBounds,
selectedProjectNameById,
selectedProjectEventIdSet,
visibleProjectEventIdSet,
)
const { graphSection, graphSectionStyle } = useAnalyticsChartLayout(showEmptyChartState)
const showAllSelectedGraphDatasetsModal = ref<InstanceType<
typeof AnalyticsChartRenderLimitModal
> | null>(null)
const canUseRatioMode = computed(
() =>
(activeGraphViewMode.value === 'area' || activeGraphViewMode.value === 'bar') &&
legendEntries.value.length > 1,
)
const showUnmonetizedInfo = computed(
() => selectedBreakdowns.value.length === 1 && selectedBreakdowns.value[0] === 'monetization',
)
function toggleGraphRenderLimit(event: MouseEvent) {
if (showAllSelectedGraphDatasets.value) {
showAllSelectedGraphDatasets.value = false
return
}
showAllSelectedGraphDatasetsModal.value?.show(event)
}
function confirmShowAllSelectedGraphDatasets() {
showAllSelectedGraphDatasets.value = true
}
function onRangeSelect(start: Date, end: Date, groupBy: AnalyticsGroupByPreset) {
selectedTimeframeMode.value = 'custom_datetime_range'
selectedCustomTimeframeStartDate.value = start.toISOString()
selectedCustomTimeframeEndDate.value = end.toISOString()
selectedDashboardGroupBy.value = groupBy
}
watch(canUseRatioMode, (canUse) => {
if (!canUse) {
isRatioMode.value = false
}
})
</script>
@@ -0,0 +1,395 @@
import { useVIntl } from '@modrinth/ui'
import { computed, type ComputedRef, ref, watch } from 'vue'
import { useTheme } from '~/composables/nuxt-accessors'
import { isDarkTheme } from '~/plugins/theme/index.ts'
import type {
AnalyticsDashboardContextValue,
AnalyticsDashboardProject,
AnalyticsDashboardStat,
} from '~/providers/analytics/analytics'
import {
analyticsChartMessages,
analyticsMessages,
formatAnalyticsGraphTitle,
type FormatMessage,
getAnalyticsBreakdownItemType,
} from '../analytics-messages'
import {
ANALYTICS_DASHBOARD_STATS,
DARK_LEGEND_PALETTE,
GRAPH_RENDER_DATASET_LIMIT,
LIGHT_LEGEND_PALETTE,
TOP_GRAPH_DATASET_LIMIT,
} from './analytics-chart-constants'
import {
buildChartDatasets,
buildTimeAxisLabels,
type ChartDataset,
getChartDatasetTotal,
getShortHourlyAxisTickLimit,
getSliceCount,
shouldCapitalizeBreakdownLabel,
} from './analytics-chart-utils'
export function useAnalyticsChartDatasets(
context: Pick<
AnalyticsDashboardContextValue,
| 'activeStat'
| 'activeGraphViewMode'
| 'isRatioMode'
| 'showPreviousPeriod'
| 'hasPreviousPeriodComparison'
| 'hasProjectContext'
| 'displayedFetchRequest'
| 'displayedTimeSlices'
| 'displayedPreviousTimeSlices'
| 'displayedSelectedGroupBy'
| 'displayedSelectedBreakdowns'
| 'hiddenGraphDatasetIds'
| 'hasExplicitGraphDatasetSelection'
| 'isGraphDatasetSelectionActive'
| 'selectedGraphDatasetIds'
| 'defaultGraphDatasetIds'
| 'topGraphDatasetIds'
| 'getVersionDisplayName'
| 'getVersionProjectName'
>,
selectedProjects: ComputedRef<AnalyticsDashboardProject[]>,
hasAvailableProjects: ComputedRef<boolean>,
) {
const theme = useTheme()
const { formatMessage } = useVIntl()
const showAllSelectedGraphDatasets = ref(false)
const chartRangeBounds = computed(() => {
const nextFetchRequest = context.displayedFetchRequest.value
if (!nextFetchRequest) return null
return {
start: new Date(nextFetchRequest.time_range.start),
end: new Date(nextFetchRequest.time_range.end),
}
})
const showProjectVersionNames = computed(
() =>
context.displayedSelectedBreakdowns.value.includes('version_id') &&
selectedProjects.value.length > 1,
)
const tableProjectCount = computed(() => context.selectedGraphDatasetIds.value.length)
const isTableGraphSelectionEmpty = computed(
() =>
context.isGraphDatasetSelectionActive.value &&
context.hasExplicitGraphDatasetSelection.value &&
tableProjectCount.value === 0,
)
const showEmptyChartState = computed(
() => selectedProjects.value.length === 0 || isTableGraphSelectionEmpty.value,
)
const emptyChartMessage = computed(() => {
if (isTableGraphSelectionEmpty.value) {
return formatMessage(analyticsChartMessages.selectTableItemsEmpty)
}
if (context.hasProjectContext.value) {
return formatMessage(analyticsMessages.noDataAvailableForAnalytics)
}
return hasAvailableProjects.value
? formatMessage(analyticsMessages.selectAtLeastOneProject)
: formatMessage(analyticsMessages.noProjectsAvailableForAnalytics)
})
const legendPalette = computed(() =>
isDarkTheme(theme.active) ? DARK_LEGEND_PALETTE : LIGHT_LEGEND_PALETTE,
)
const graphTitle = computed(() =>
formatAnalyticsGraphTitle(context.activeStat.value, formatMessage),
)
const showTableSelectionSubheading = computed(
() => context.isGraphDatasetSelectionActive.value && tableProjectCount.value > 0,
)
const tableBreakdownItemType = computed(() =>
getAnalyticsBreakdownItemType(context.displayedSelectedBreakdowns.value),
)
const shouldCapitalizeDatasetLabels = computed(() =>
shouldCapitalizeBreakdownLabel(context.displayedSelectedBreakdowns.value),
)
const chartType = computed<'line' | 'bar'>(() =>
context.activeGraphViewMode.value === 'bar' ? 'bar' : 'line',
)
const canShowPreviousPeriodToggle = computed(
() => context.activeGraphViewMode.value === 'line' && context.hasPreviousPeriodComparison.value,
)
const shouldShowPreviousPeriod = computed(
() => canShowPreviousPeriodToggle.value && context.showPreviousPeriod.value,
)
const isArea = computed(() => context.activeGraphViewMode.value === 'area')
const isStacked = computed(
() =>
context.isRatioMode.value ||
context.activeGraphViewMode.value === 'area' ||
context.activeGraphViewMode.value === 'bar',
)
const sliceCount = computed(() => {
const nextFetchRequest = context.displayedFetchRequest.value
const fallback = context.displayedTimeSlices.value.length
if (!nextFetchRequest) return Math.max(1, fallback)
return getSliceCount(nextFetchRequest.time_range, fallback)
})
const chartLabels = computed(() => {
const nextFetchRequest = context.displayedFetchRequest.value
if (!nextFetchRequest) return []
return buildTimeAxisLabels(
nextFetchRequest.time_range,
sliceCount.value,
context.displayedSelectedGroupBy.value,
)
})
const xAxisTickLimit = computed(() => {
const nextFetchRequest = context.displayedFetchRequest.value
return nextFetchRequest
? getShortHourlyAxisTickLimit(
nextFetchRequest.time_range,
context.displayedSelectedGroupBy.value,
)
: undefined
})
const chartDatasetsByStat = computed<Record<AnalyticsDashboardStat, ChartDataset[]>>(() =>
buildDatasetsByStat(
context.displayedTimeSlices.value,
selectedProjects.value,
legendPalette.value,
context.displayedSelectedBreakdowns.value,
context.getVersionDisplayName,
showProjectVersionNames.value ? context.getVersionProjectName : undefined,
formatMessage,
sliceCount.value,
),
)
const previousChartDatasetsByStat = computed<Record<AnalyticsDashboardStat, ChartDataset[]>>(() =>
buildDatasetsByStat(
context.displayedPreviousTimeSlices.value,
selectedProjects.value,
legendPalette.value,
context.displayedSelectedBreakdowns.value,
context.getVersionDisplayName,
showProjectVersionNames.value ? context.getVersionProjectName : undefined,
formatMessage,
sliceCount.value,
),
)
const allChartDatasets = computed(() => chartDatasetsByStat.value[context.activeStat.value])
const previousChartDatasets = computed(
() => previousChartDatasetsByStat.value[context.activeStat.value],
)
const sortedChartDatasetIds = computed(() => sortDatasetsByTotal(allChartDatasets.value))
const chartTopGraphDatasetIds = computed(() =>
sortedChartDatasetIds.value.slice(0, TOP_GRAPH_DATASET_LIMIT),
)
const fallbackDefaultGraphDatasetIds = computed(() =>
context.defaultGraphDatasetIds.value.length > 0
? context.defaultGraphDatasetIds.value
: chartTopGraphDatasetIds.value,
)
const isShowingAllTableItems = computed(() => {
if (context.selectedGraphDatasetIds.value.length !== sortedChartDatasetIds.value.length) {
return false
}
const selectedDatasetIds = new Set(context.selectedGraphDatasetIds.value)
return sortedChartDatasetIds.value.every((datasetId) => selectedDatasetIds.has(datasetId))
})
const isShowingTopGraphDatasets = computed(() => {
if (
context.selectedGraphDatasetIds.value.length !== fallbackDefaultGraphDatasetIds.value.length
) {
return false
}
const selectedDatasetIds = new Set(context.selectedGraphDatasetIds.value)
return fallbackDefaultGraphDatasetIds.value.every((datasetId) =>
selectedDatasetIds.has(datasetId),
)
})
const isShowingTopTableItems = computed(() => {
const topDatasetIds = new Set(
context.topGraphDatasetIds.value.slice(0, context.selectedGraphDatasetIds.value.length),
)
return context.selectedGraphDatasetIds.value.every((datasetId) => topDatasetIds.has(datasetId))
})
const isGraphRenderDatasetOverLimit = computed(
() =>
context.isGraphDatasetSelectionActive.value &&
selectedChartDatasets.value.length > GRAPH_RENDER_DATASET_LIMIT,
)
const isGraphRenderDatasetLimitActive = computed(
() => isGraphRenderDatasetOverLimit.value && !showAllSelectedGraphDatasets.value,
)
const tableSelectionSubheading = computed(() => {
if (isGraphRenderDatasetLimitActive.value) {
return formatMessage(analyticsChartMessages.tableSelectionLimited, {
limit: GRAPH_RENDER_DATASET_LIMIT,
itemType: tableBreakdownItemType.value,
})
}
if (isShowingAllTableItems.value) {
return formatMessage(analyticsChartMessages.tableSelectionAll, {
count: tableProjectCount.value,
itemType: tableBreakdownItemType.value,
})
}
if (isShowingTopTableItems.value) {
return formatMessage(analyticsChartMessages.tableSelectionTop, {
count: tableProjectCount.value,
itemType: tableBreakdownItemType.value,
})
}
return formatMessage(analyticsChartMessages.tableSelectionCount, {
count: tableProjectCount.value,
itemType: tableBreakdownItemType.value,
})
})
const shouldUseDefaultGraphDatasetSelection = computed(
() =>
context.isGraphDatasetSelectionActive.value &&
!context.hasExplicitGraphDatasetSelection.value &&
context.selectedGraphDatasetIds.value.length === 0,
)
const selectedGraphDatasetIdSet = computed(() => {
if (shouldUseDefaultGraphDatasetSelection.value) {
return new Set(fallbackDefaultGraphDatasetIds.value)
}
return new Set(context.selectedGraphDatasetIds.value)
})
const selectedChartDatasets = computed(() => {
if (!context.isGraphDatasetSelectionActive.value) {
return allChartDatasets.value
}
return allChartDatasets.value.filter((dataset) =>
selectedGraphDatasetIdSet.value.has(dataset.projectId),
)
})
const sortedSelectedChartDatasetIds = computed(() =>
sortDatasetsByTotal(selectedChartDatasets.value),
)
const showGraphRenderLimitButton = computed(() => isGraphRenderDatasetOverLimit.value)
const graphRenderLimitButtonLabel = computed(() =>
showAllSelectedGraphDatasets.value
? formatMessage(analyticsChartMessages.showLimited)
: formatMessage(analyticsChartMessages.showAll),
)
const showTopGraphDatasetsButton = computed(
() =>
context.isGraphDatasetSelectionActive.value &&
context.topGraphDatasetIds.value.length > 0 &&
!isShowingTopGraphDatasets.value,
)
const limitedGraphDatasetIds = computed(
() => new Set(sortedSelectedChartDatasetIds.value.slice(0, GRAPH_RENDER_DATASET_LIMIT)),
)
const selectableChartDatasets = computed(() => {
if (!isGraphRenderDatasetLimitActive.value) {
return selectedChartDatasets.value
}
return selectedChartDatasets.value.filter((dataset) =>
limitedGraphDatasetIds.value.has(dataset.projectId),
)
})
function showTopGraphDatasets() {
context.selectedGraphDatasetIds.value = []
context.hasExplicitGraphDatasetSelection.value = false
showAllSelectedGraphDatasets.value = false
}
watch([() => context.selectedGraphDatasetIds.value.join('\u0000'), allChartDatasets], () => {
showAllSelectedGraphDatasets.value = false
})
return {
showAllSelectedGraphDatasets,
chartRangeBounds,
showProjectVersionNames,
tableProjectCount,
isTableGraphSelectionEmpty,
showEmptyChartState,
emptyChartMessage,
legendPalette,
graphTitle,
showTableSelectionSubheading,
shouldCapitalizeDatasetLabels,
chartType,
canShowPreviousPeriodToggle,
shouldShowPreviousPeriod,
isArea,
isStacked,
sliceCount,
chartLabels,
xAxisTickLimit,
chartDatasetsByStat,
previousChartDatasetsByStat,
allChartDatasets,
previousChartDatasets,
sortedChartDatasetIds,
chartTopGraphDatasetIds,
fallbackDefaultGraphDatasetIds,
isShowingAllTableItems,
isShowingTopGraphDatasets,
isShowingTopTableItems,
tableSelectionSubheading,
shouldUseDefaultGraphDatasetSelection,
selectedGraphDatasetIdSet,
selectedChartDatasets,
sortedSelectedChartDatasetIds,
isGraphRenderDatasetOverLimit,
showGraphRenderLimitButton,
graphRenderLimitButtonLabel,
showTopGraphDatasetsButton,
isGraphRenderDatasetLimitActive,
limitedGraphDatasetIds,
selectableChartDatasets,
showTopGraphDatasets,
}
}
function buildDatasetsByStat(
timeSlices: Parameters<typeof buildChartDatasets>[0],
selectedProjects: AnalyticsDashboardProject[],
palette: string[],
selectedBreakdowns: Parameters<typeof buildChartDatasets>[4],
getVersionDisplayName: Parameters<typeof buildChartDatasets>[5],
getVersionProjectName: Parameters<typeof buildChartDatasets>[6],
formatMessage: FormatMessage,
sliceCount: number,
) {
const datasetsByStat = {} as Record<AnalyticsDashboardStat, ChartDataset[]>
for (const stat of ANALYTICS_DASHBOARD_STATS) {
datasetsByStat[stat] = buildChartDatasets(
timeSlices,
selectedProjects,
stat,
palette,
selectedBreakdowns,
getVersionDisplayName,
getVersionProjectName,
formatMessage,
sliceCount,
)
}
return datasetsByStat
}
function sortDatasetsByTotal(datasets: ChartDataset[]) {
return [...datasets]
.sort((a, b) => {
const totalDifference = getChartDatasetTotal(b) - getChartDatasetTotal(a)
return (
totalDifference || a.label.localeCompare(b.label) || a.projectId.localeCompare(b.projectId)
)
})
.map((dataset) => dataset.projectId)
}
@@ -0,0 +1,38 @@
import { computed } from 'vue'
import {
type AnalyticsDashboardContextValue,
doesProjectStatusMatchFilters,
} from '~/providers/analytics/analytics'
export function useAnalyticsChartProjects(
context: Pick<
AnalyticsDashboardContextValue,
'displayedSelectedProjectIds' | 'projects' | 'displayedSelectedFilters'
>,
) {
const selectedProjectIdSet = computed(() => new Set(context.displayedSelectedProjectIds.value))
const hasAvailableProjects = computed(() => context.projects.value.length > 0)
const selectedProjects = computed(() =>
context.projects.value.filter(
(project) =>
selectedProjectIdSet.value.has(project.id) &&
doesProjectStatusMatchFilters(project.status, context.displayedSelectedFilters.value),
),
)
const selectedProjectNameById = computed(
() => new Map(selectedProjects.value.map((project) => [project.id, project.name])),
)
const selectedProjectEventIdSet = computed(
() => new Set(selectedProjects.value.map((project) => project.id)),
)
return {
selectedProjectIdSet,
hasAvailableProjects,
selectedProjects,
selectedProjectNameById,
selectedProjectEventIdSet,
}
}
@@ -0,0 +1,897 @@
import { defineMessages, getLoaderMessage, type VIntlFormatters } from '@modrinth/ui'
import type {
AnalyticsBreakdownPreset,
AnalyticsDashboardStat,
AnalyticsGroupByPreset,
} from '~/providers/analytics/analytics'
export type FormatMessage = VIntlFormatters['formatMessage']
export type AnalyticsBreakdownItemType =
| 'country'
| 'downloadReason'
| 'downloadSource'
| 'gameVersion'
| 'loader'
| 'monetization'
| 'project'
| 'projectVersion'
| 'other'
export const analyticsMessages = defineMessages({
title: {
id: 'analytics.title',
defaultMessage: 'Analytics',
},
resetButton: {
id: 'analytics.action.reset',
defaultMessage: 'Reset',
},
refreshButton: {
id: 'analytics.action.refresh',
defaultMessage: 'Refresh',
},
fetchingResults: {
id: 'analytics.loading.fetching-results',
defaultMessage: 'Fetching results...',
},
allProjects: {
id: 'analytics.project.all',
defaultMessage: 'All projects',
},
selectProjects: {
id: 'analytics.project.select',
defaultMessage: 'Select projects',
},
projectCount: {
id: 'analytics.project.count',
defaultMessage: '{count, plural, one {# project} other {# projects}}',
},
projectIconAlt: {
id: 'analytics.project.icon-alt',
defaultMessage: '{name} Icon',
},
noDataAvailable: {
id: 'analytics.empty.no-data',
defaultMessage: 'No data available',
},
noDataAvailableForAnalytics: {
id: 'analytics.empty.no-data-for-analytics',
defaultMessage: 'No data available for analytics',
},
noProjectsAvailable: {
id: 'analytics.empty.no-projects',
defaultMessage: 'No projects available',
},
noProjectsAvailableForAnalytics: {
id: 'analytics.empty.no-projects-for-analytics',
defaultMessage: 'No projects available for analytics',
},
selectAtLeastOneProject: {
id: 'analytics.empty.select-project',
defaultMessage: 'Select at least one project to view data',
},
unknown: {
id: 'analytics.value.unknown',
defaultMessage: 'Unknown',
},
other: {
id: 'analytics.value.other',
defaultMessage: 'Other',
},
none: {
id: 'analytics.value.none',
defaultMessage: 'None',
},
noBreakdown: {
id: 'analytics.breakdown.none.selected',
defaultMessage: 'No breakdown',
},
breakdownBy: {
id: 'analytics.breakdown.selected',
defaultMessage: 'Breakdown by {breakdown}',
},
projectLabel: {
id: 'analytics.query.label.project',
defaultMessage: 'Project:',
},
timeframeLabel: {
id: 'analytics.query.label.timeframe',
defaultMessage: 'Timeframe:',
},
groupedByLabel: {
id: 'analytics.query.label.grouped-by',
defaultMessage: 'Grouped by',
},
breakdownLabel: {
id: 'analytics.query.label.breakdown',
defaultMessage: 'Breakdown:',
},
addFilterButton: {
id: 'analytics.query.filter.add',
defaultMessage: 'Add filter',
},
addButton: {
id: 'analytics.action.add',
defaultMessage: 'Add',
},
downloadsSuffix: {
id: 'analytics.downloads.suffix',
defaultMessage: 'downloads',
},
projectsAbove: {
id: 'analytics.threshold.projects-above',
defaultMessage: 'Projects above',
},
countriesAbove: {
id: 'analytics.threshold.countries-above',
defaultMessage: 'Countries above',
},
projectVersionsAbove: {
id: 'analytics.threshold.project-versions-above',
defaultMessage: 'Project versions above',
},
gameVersionsAbove: {
id: 'analytics.threshold.game-versions-above',
defaultMessage: 'Game versions above',
},
projectDownloadsThresholdAria: {
id: 'analytics.threshold.project-downloads-aria',
defaultMessage: 'Project downloads threshold',
},
countryDownloadsThresholdAria: {
id: 'analytics.threshold.country-downloads-aria',
defaultMessage: 'Country downloads threshold',
},
projectVersionDownloadsThresholdAria: {
id: 'analytics.threshold.project-version-downloads-aria',
defaultMessage: 'Project version downloads threshold',
},
gameVersionDownloadsThresholdAria: {
id: 'analytics.threshold.game-version-downloads-aria',
defaultMessage: 'Game version downloads threshold',
},
loadingOptions: {
id: 'analytics.options.loading',
defaultMessage: 'Loading...',
},
searchCountriesPlaceholder: {
id: 'analytics.filter.search.countries',
defaultMessage: 'Search countries...',
},
searchDownloadSourcesPlaceholder: {
id: 'analytics.filter.search.download-sources',
defaultMessage: 'Search download sources...',
},
searchProjectVersionsPlaceholder: {
id: 'analytics.filter.search.project-versions',
defaultMessage: 'Search project versions...',
},
searchVersionsPlaceholder: {
id: 'analytics.filter.search.versions',
defaultMessage: 'Search versions...',
},
gameVersionTypeAria: {
id: 'analytics.filter.game-version-type',
defaultMessage: 'Game version type',
},
releaseTab: {
id: 'analytics.filter.game-version-type.release',
defaultMessage: 'Release',
},
allTab: {
id: 'analytics.filter.game-version-type.all',
defaultMessage: 'All',
},
})
export const analyticsStatMessages = defineMessages({
views: {
id: 'analytics.stat.views',
defaultMessage: 'Views',
},
downloads: {
id: 'analytics.stat.downloads',
defaultMessage: 'Downloads',
},
revenue: {
id: 'analytics.stat.revenue',
defaultMessage: 'Revenue',
},
playtime: {
id: 'analytics.stat.playtime',
defaultMessage: 'Playtime',
},
})
export const analyticsGraphTitleMessages = defineMessages({
views: {
id: 'analytics.graph.title.views',
defaultMessage: 'Views Over Time',
},
downloads: {
id: 'analytics.graph.title.downloads',
defaultMessage: 'Downloads Over Time',
},
revenue: {
id: 'analytics.graph.title.revenue',
defaultMessage: 'Revenue Over Time',
},
playtime: {
id: 'analytics.graph.title.playtime',
defaultMessage: 'Playtime Over Time',
},
})
export const analyticsStatCardMessages = defineMessages({
revenueValue: {
id: 'analytics.stat.revenue-value',
defaultMessage: '${value}',
},
playtimeHours: {
id: 'analytics.stat.playtime-hours',
defaultMessage: '{hours} hrs',
},
unavailableTooltip: {
id: 'analytics.stat.unavailable-tooltip',
defaultMessage: 'Stat unavailable for current query',
},
unavailableLabel: {
id: 'analytics.stat.unavailable',
defaultMessage: 'N/A',
},
previousPeriodComparison: {
id: 'analytics.stat.previous-period-comparison',
defaultMessage: 'vs prev. period',
},
previousPeriodComparisonShort: {
id: 'analytics.stat.previous-period-comparison-short',
defaultMessage: 'vs prev.',
},
})
export const analyticsGroupByMessages = defineMessages({
oneHour: {
id: 'analytics.group-by.1h',
defaultMessage: '1h',
},
sixHours: {
id: 'analytics.group-by.6h',
defaultMessage: '6h',
},
day: {
id: 'analytics.group-by.day',
defaultMessage: 'Day',
},
week: {
id: 'analytics.group-by.week',
defaultMessage: 'Week',
},
month: {
id: 'analytics.group-by.month',
defaultMessage: 'Month',
},
year: {
id: 'analytics.group-by.year',
defaultMessage: 'Year',
},
date: {
id: 'analytics.group-by.date',
defaultMessage: 'Date',
},
groupByHour: {
id: 'analytics.group-by.selected.hour',
defaultMessage: 'Group by hour',
},
groupBySixHours: {
id: 'analytics.group-by.selected.six-hours',
defaultMessage: 'Group by 6 hours',
},
groupByDay: {
id: 'analytics.group-by.selected.day',
defaultMessage: 'Group by day',
},
groupByWeek: {
id: 'analytics.group-by.selected.week',
defaultMessage: 'Group by week',
},
groupByMonth: {
id: 'analytics.group-by.selected.month',
defaultMessage: 'Group by month',
},
groupByYear: {
id: 'analytics.group-by.selected.year',
defaultMessage: 'Group by year',
},
})
export const analyticsBreakdownMessages = defineMessages({
breakdown: {
id: 'analytics.breakdown.generic',
defaultMessage: 'Breakdown',
},
project: {
id: 'analytics.breakdown.project',
defaultMessage: 'Project',
},
country: {
id: 'analytics.breakdown.country',
defaultMessage: 'Country',
},
monetization: {
id: 'analytics.breakdown.monetization',
defaultMessage: 'Monetization',
},
userAgent: {
id: 'analytics.breakdown.download-source',
defaultMessage: 'Download source',
},
downloadReason: {
id: 'analytics.breakdown.download-reason',
defaultMessage: 'Download reason',
},
versionId: {
id: 'analytics.breakdown.project-version',
defaultMessage: 'Project version',
},
loader: {
id: 'analytics.breakdown.loader',
defaultMessage: 'Loader',
},
gameVersion: {
id: 'analytics.breakdown.game-version',
defaultMessage: 'Game version',
},
projectStatus: {
id: 'analytics.breakdown.project-status',
defaultMessage: 'Project status',
},
})
export const analyticsMonetizationMessages = defineMessages({
monetized: {
id: 'analytics.value.monetized',
defaultMessage: 'Monetized',
},
unmonetized: {
id: 'analytics.value.unmonetized',
defaultMessage: 'Unmonetized',
},
})
export const analyticsDownloadReasonMessages = defineMessages({
standalone: {
id: 'analytics.download-reason.standalone',
defaultMessage: 'Standalone',
},
dependency: {
id: 'analytics.download-reason.dependency',
defaultMessage: 'Dependency',
},
modpack: {
id: 'analytics.download-reason.modpack',
defaultMessage: 'Modpack',
},
update: {
id: 'analytics.download-reason.update',
defaultMessage: 'Update',
},
})
export const analyticsDownloadSourceMessages = defineMessages({
website: {
id: 'analytics.download-source.website',
defaultMessage: 'Modrinth Website',
},
app: {
id: 'analytics.download-source.app',
defaultMessage: 'Modrinth App',
},
})
export const analyticsProjectStatusMessages = defineMessages({
approved: {
id: 'analytics.project-status.approved',
defaultMessage: 'Approved',
},
archived: {
id: 'analytics.project-status.archived',
defaultMessage: 'Archived',
},
rejected: {
id: 'analytics.project-status.rejected',
defaultMessage: 'Rejected',
},
draft: {
id: 'analytics.project-status.draft',
defaultMessage: 'Draft',
},
unlisted: {
id: 'analytics.project-status.unlisted',
defaultMessage: 'Unlisted',
},
withheld: {
id: 'analytics.project-status.withheld',
defaultMessage: 'Withheld',
},
private: {
id: 'analytics.project-status.private',
defaultMessage: 'Private',
},
other: {
id: 'analytics.project-status.other',
defaultMessage: 'Other',
},
})
export const analyticsTableMessages = defineMessages({
searchPlaceholder: {
id: 'analytics.table.search.placeholder',
defaultMessage: 'Search...',
},
exportCsvButton: {
id: 'analytics.table.export-csv',
defaultMessage: 'Export CSV',
},
cumulativeCsv: {
id: 'analytics.table.export.cumulative',
defaultMessage: 'Cumulative',
},
groupedCsv: {
id: 'analytics.table.export.grouped',
defaultMessage: 'Grouped by {groupBy}',
},
noMatchingRows: {
id: 'analytics.table.empty.no-matching-rows',
defaultMessage: 'No matching analytics rows',
},
paginationSummary: {
id: 'analytics.table.pagination.summary',
defaultMessage: 'Showing {start} to {end} of {total}',
},
playtimeSecondsHeader: {
id: 'analytics.table.csv.header.playtime-seconds',
defaultMessage: 'Playtime (seconds)',
},
csvSelectedRange: {
id: 'analytics.table.csv.selected-range',
defaultMessage: 'Selected Range',
},
csvDateRange: {
id: 'analytics.table.csv.date-range',
defaultMessage: '{start} to {end}',
},
csvFilename: {
id: 'analytics.table.csv.filename',
defaultMessage: 'Modrinth Analytics {breakdown} Breakdown - {dateRange}',
},
durationDays: {
id: 'analytics.table.duration.days',
defaultMessage: '{count, plural, one {# day} other {# days}}',
},
durationHours: {
id: 'analytics.table.duration.hours',
defaultMessage: '{count, plural, one {# hour} other {# hours}}',
},
durationMinutes: {
id: 'analytics.table.duration.minutes',
defaultMessage: '{count, plural, one {# minute} other {# minutes}}',
},
})
export const analyticsChartMessages = defineMessages({
selectTableItemsEmpty: {
id: 'analytics.chart.empty.select-table-items',
defaultMessage: 'Select items from table below to visualize your data.',
},
showLimited: {
id: 'analytics.chart.action.show-limited',
defaultMessage: 'Show limited',
},
showAll: {
id: 'analytics.chart.action.show-all',
defaultMessage: 'Show all',
},
showTopEight: {
id: 'analytics.chart.action.show-top-eight',
defaultMessage: 'Show top 8',
},
tableSelectionLimited: {
id: 'analytics.chart.table-selection.limited',
defaultMessage:
'Showing {limit} {itemType, select, project {{limit, plural, one {project} other {projects}}} country {{limit, plural, one {country} other {countries}}} monetization {{limit, plural, one {monetization value} other {monetization values}}} downloadSource {{limit, plural, one {download source} other {download sources}}} downloadReason {{limit, plural, one {download reason} other {download reasons}}} projectVersion {{limit, plural, one {project version} other {project versions}}} loader {{limit, plural, one {loader} other {loaders}}} gameVersion {{limit, plural, one {game version} other {game versions}}} other {{limit, plural, one {item} other {items}}}} from table',
},
tableSelectionAll: {
id: 'analytics.chart.table-selection.all',
defaultMessage:
'Showing all {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table',
},
tableSelectionTop: {
id: 'analytics.chart.table-selection.top',
defaultMessage:
'Showing top {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table',
},
tableSelectionCount: {
id: 'analytics.chart.table-selection.count',
defaultMessage:
'Showing {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table',
},
lineView: {
id: 'analytics.chart.view.line',
defaultMessage: 'Line',
},
areaView: {
id: 'analytics.chart.view.area',
defaultMessage: 'Area',
},
barView: {
id: 'analytics.chart.view.bar',
defaultMessage: 'Bar',
},
controlsButton: {
id: 'analytics.chart.controls.button',
defaultMessage: 'Controls',
},
controlsAria: {
id: 'analytics.chart.controls.aria',
defaultMessage: 'Analytics graph controls, {activeCount}',
},
controlsDialogAria: {
id: 'analytics.chart.controls.dialog-aria',
defaultMessage: 'Analytics graph controls',
},
activeControlCount: {
id: 'analytics.chart.controls.active-count',
defaultMessage: '{count} active',
},
displayControls: {
id: 'analytics.chart.controls.display',
defaultMessage: 'Display',
},
previousPeriod: {
id: 'analytics.chart.controls.previous-period',
defaultMessage: 'Previous period',
},
ratio: {
id: 'analytics.chart.controls.ratio',
defaultMessage: 'Ratio',
},
annotations: {
id: 'analytics.chart.controls.annotations',
defaultMessage: 'Annotations',
},
projectEvents: {
id: 'analytics.chart.controls.project-events',
defaultMessage: 'Project events',
},
modrinthEvents: {
id: 'analytics.chart.controls.modrinth-events',
defaultMessage: 'Modrinth events',
},
noProjectEvents: {
id: 'analytics.chart.controls.no-project-events',
defaultMessage: 'No project events in graph.',
},
noModrinthEvents: {
id: 'analytics.chart.controls.no-modrinth-events',
defaultMessage: 'No Modrinth events in graph.',
},
viewMonetizedAnalyticsDetails: {
id: 'analytics.chart.legend.monetization-details.aria',
defaultMessage: 'View monetized analytics details',
},
monetizedAnalyticsDetails: {
id: 'analytics.chart.legend.monetization-details.title',
defaultMessage: 'Monetized analytics details',
},
monetizedAnalyticsDetailsDescription: {
id: 'analytics.chart.legend.monetization-details.description',
defaultMessage:
'Only views and downloads made through Modrinth count toward monetization, and downloads require users to be logged in.',
},
previousPeriodSuffix: {
id: 'analytics.chart.legend.previous-period-suffix',
defaultMessage: '{name} (Prev.)',
},
previousPeriodShort: {
id: 'analytics.chart.tooltip.previous-period-short',
defaultMessage: '(prev.)',
},
tooltipPinned: {
id: 'analytics.chart.tooltip.pinned',
defaultMessage: 'Chart tooltip pinned',
},
pinned: {
id: 'analytics.chart.tooltip.pinned-aria',
defaultMessage: 'Pinned',
},
total: {
id: 'analytics.chart.tooltip.total',
defaultMessage: 'Total',
},
showEntryInGraph: {
id: 'analytics.chart.tooltip.show-entry',
defaultMessage: 'Show {name} in graph',
},
hideEntryInGraph: {
id: 'analytics.chart.tooltip.hide-entry',
defaultMessage: 'Hide {name} in graph',
},
durationDays: {
id: 'analytics.chart.tooltip.duration.days',
defaultMessage: '{count, plural, one {# day} other {# days}}',
},
durationHours: {
id: 'analytics.chart.tooltip.duration.hours',
defaultMessage: '{count, plural, one {# hour} other {# hours}}',
},
durationMinutes: {
id: 'analytics.chart.tooltip.duration.minutes',
defaultMessage: '{count, plural, one {# minute} other {# minutes}}',
},
playtimeAxisHours: {
id: 'analytics.chart.axis.playtime-hours',
defaultMessage: '{hours} h',
},
renderLimitHeader: {
id: 'analytics.chart.render-limit.header',
defaultMessage: 'Show all {count} lines in graph?',
},
renderLimitDescription: {
id: 'analytics.chart.render-limit.description',
defaultMessage: 'Showing all selected lines from table may degrade page performance.',
},
cancelButton: {
id: 'analytics.action.cancel',
defaultMessage: 'Cancel',
},
analyticsEventsCount: {
id: 'analytics.chart.events.count-aria',
defaultMessage: '{count, plural, one {# analytics event} other {# analytics events}}',
},
seeAnnouncement: {
id: 'analytics.chart.events.see-announcement',
defaultMessage: 'See announcement',
},
projectEventTitle: {
id: 'analytics.chart.events.project-title',
defaultMessage: '<project>{projectName}</project>: {title}',
},
})
export const analyticsProjectEventMessages = defineMessages({
versionReleased: {
id: 'analytics.project-event.version-released',
defaultMessage: '{version} released',
},
versionUploaded: {
id: 'analytics.project-event.version-uploaded',
defaultMessage: 'Version uploaded',
},
projectApproved: {
id: 'analytics.project-event.project-approved',
defaultMessage: 'Project approved',
},
projectUnlisted: {
id: 'analytics.project-event.project-unlisted',
defaultMessage: 'Project unlisted',
},
projectPrivate: {
id: 'analytics.project-event.project-private',
defaultMessage: 'Project set to private',
},
projectStatusChanged: {
id: 'analytics.project-event.project-status-changed',
defaultMessage: 'Project status changed',
},
})
export function formatAnalyticsStatLabel(
stat: AnalyticsDashboardStat,
formatMessage: FormatMessage,
): string {
return formatMessage(analyticsStatMessages[stat])
}
export function formatAnalyticsGraphTitle(
stat: AnalyticsDashboardStat,
formatMessage: FormatMessage,
): string {
return formatMessage(analyticsGraphTitleMessages[stat])
}
export function formatAnalyticsGroupByLabel(
groupBy: AnalyticsGroupByPreset,
formatMessage: FormatMessage,
): string {
switch (groupBy) {
case '1h':
return formatMessage(analyticsGroupByMessages.oneHour)
case '6h':
return formatMessage(analyticsGroupByMessages.sixHours)
case 'day':
return formatMessage(analyticsGroupByMessages.day)
case 'week':
return formatMessage(analyticsGroupByMessages.week)
case 'month':
return formatMessage(analyticsGroupByMessages.month)
case 'year':
return formatMessage(analyticsGroupByMessages.year)
default:
return formatMessage(analyticsGroupByMessages.date)
}
}
export function formatAnalyticsGroupBySelectedLabel(
groupBy: AnalyticsGroupByPreset,
formatMessage: FormatMessage,
): string {
switch (groupBy) {
case '1h':
return formatMessage(analyticsGroupByMessages.groupByHour)
case '6h':
return formatMessage(analyticsGroupByMessages.groupBySixHours)
case 'day':
return formatMessage(analyticsGroupByMessages.groupByDay)
case 'week':
return formatMessage(analyticsGroupByMessages.groupByWeek)
case 'month':
return formatMessage(analyticsGroupByMessages.groupByMonth)
case 'year':
return formatMessage(analyticsGroupByMessages.groupByYear)
default:
return formatMessage(analyticsGroupByMessages.groupByDay)
}
}
export function formatAnalyticsBreakdownLabel(
breakdown: AnalyticsBreakdownPreset,
formatMessage: FormatMessage,
): string {
switch (breakdown) {
case 'none':
case 'project':
return formatMessage(analyticsBreakdownMessages.project)
case 'country':
return formatMessage(analyticsBreakdownMessages.country)
case 'monetization':
return formatMessage(analyticsBreakdownMessages.monetization)
case 'user_agent':
return formatMessage(analyticsBreakdownMessages.userAgent)
case 'download_reason':
return formatMessage(analyticsBreakdownMessages.downloadReason)
case 'version_id':
return formatMessage(analyticsBreakdownMessages.versionId)
case 'loader':
return formatMessage(analyticsBreakdownMessages.loader)
case 'game_version':
return formatMessage(analyticsBreakdownMessages.gameVersion)
default:
return formatMessage(analyticsBreakdownMessages.breakdown)
}
}
export function getAnalyticsBreakdownItemType(
breakdowns: readonly AnalyticsBreakdownPreset[],
): AnalyticsBreakdownItemType {
if (breakdowns.length !== 1) {
return 'other'
}
switch (breakdowns[0]) {
case 'project':
return 'project'
case 'country':
return 'country'
case 'monetization':
return 'monetization'
case 'user_agent':
return 'downloadSource'
case 'download_reason':
return 'downloadReason'
case 'version_id':
return 'projectVersion'
case 'loader':
return 'loader'
case 'game_version':
return 'gameVersion'
default:
return 'other'
}
}
export function formatAnalyticsMonetizationLabel(
value: string,
formatMessage: FormatMessage,
): string {
switch (value.trim().toLowerCase()) {
case 'monetized':
return formatMessage(analyticsMonetizationMessages.monetized)
case 'unmonetized':
return formatMessage(analyticsMonetizationMessages.unmonetized)
default:
return value
}
}
export function formatAnalyticsDownloadReasonLabel(
reason: string,
formatMessage: FormatMessage,
): string {
switch (reason.trim().toLowerCase()) {
case 'standalone':
return formatMessage(analyticsDownloadReasonMessages.standalone)
case 'dependency':
return formatMessage(analyticsDownloadReasonMessages.dependency)
case 'modpack':
return formatMessage(analyticsDownloadReasonMessages.modpack)
case 'update':
return formatMessage(analyticsDownloadReasonMessages.update)
default:
return reason
}
}
export function formatAnalyticsDownloadSourceLabel(
source: string,
formatMessage: FormatMessage,
): string {
const normalized = source.trim()
const normalizedLowercase = normalized.toLowerCase()
if (normalizedLowercase === 'website') {
return formatMessage(analyticsDownloadSourceMessages.website)
}
if (normalizedLowercase === 'modrinth_app') {
return formatMessage(analyticsDownloadSourceMessages.app)
}
if (!normalized.includes('_')) {
return normalized
}
return normalizedLowercase
.split('_')
.filter((part) => part.length > 0)
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join(' ')
}
export function formatAnalyticsProjectStatusLabel(
status: string,
formatMessage: FormatMessage,
): string {
switch (status.trim().toLowerCase()) {
case 'approved':
return formatMessage(analyticsProjectStatusMessages.approved)
case 'archived':
return formatMessage(analyticsProjectStatusMessages.archived)
case 'rejected':
return formatMessage(analyticsProjectStatusMessages.rejected)
case 'draft':
return formatMessage(analyticsProjectStatusMessages.draft)
case 'unlisted':
return formatMessage(analyticsProjectStatusMessages.unlisted)
case 'withheld':
return formatMessage(analyticsProjectStatusMessages.withheld)
case 'private':
return formatMessage(analyticsProjectStatusMessages.private)
case 'other':
return formatMessage(analyticsProjectStatusMessages.other)
default:
return capitalizeAnalyticsValue(status)
}
}
export function formatAnalyticsLoaderLabel(loader: string, formatMessage: FormatMessage): string {
const normalizedLoader = loader.trim()
const loaderMessage = getLoaderMessage(normalizedLoader)
return loaderMessage ? formatMessage(loaderMessage) : capitalizeAnalyticsValue(normalizedLoader)
}
function capitalizeAnalyticsValue(value: string): string {
const normalizedValue = value.trim()
if (normalizedValue.length === 0) {
return value
}
return `${normalizedValue.charAt(0).toUpperCase()}${normalizedValue.slice(1)}`
}
@@ -0,0 +1,908 @@
import type { LocationQuery, LocationQueryValue, LocationQueryValueRaw } from 'vue-router'
import type {
AnalyticsBreakdownPreset,
AnalyticsDashboardStat,
AnalyticsGraphState,
AnalyticsGraphViewMode,
AnalyticsGroupByPreset,
AnalyticsLastTimeframeUnit,
AnalyticsQueryBuilderState,
AnalyticsQueryFilterCategory,
AnalyticsSelectedBreakdowns,
AnalyticsSelectedFilters,
AnalyticsTableSortColumn,
AnalyticsTableSortDirection,
AnalyticsTableSortState,
AnalyticsTimeframeMode,
AnalyticsTimeframePreset,
MutableRouteQuery,
} from '~/providers/analytics/analytics-types'
export const DEFAULT_TIMEFRAME_PRESET: AnalyticsTimeframePreset = 'last_30_days'
export const DEFAULT_TIMEFRAME_MODE: AnalyticsTimeframeMode = 'preset'
export const DEFAULT_LAST_TIMEFRAME_AMOUNT = 1
export const DEFAULT_LAST_TIMEFRAME_UNIT: AnalyticsLastTimeframeUnit = 'days'
export const DEFAULT_GROUP_BY_PRESET: AnalyticsGroupByPreset = 'day'
export const DEFAULT_BREAKDOWN_PRESET: AnalyticsBreakdownPreset = 'none'
export const DEFAULT_ANALYTICS_DASHBOARD_STAT: AnalyticsDashboardStat = 'views'
export const DEFAULT_ANALYTICS_GRAPH_VIEW_MODE: AnalyticsGraphViewMode = 'line'
export const DEFAULT_ANALYTICS_GRAPH_RATIO_MODE = false
export const DEFAULT_ANALYTICS_GRAPH_EVENTS_VISIBILITY = true
export const DEFAULT_ANALYTICS_GRAPH_PREVIOUS_PERIOD_VISIBILITY = false
export const MAX_ANALYTICS_BREAKDOWN_PRESETS = 2
const TIMEFRAME_PRESET_VALUES: AnalyticsTimeframePreset[] = [
'today',
'yesterday',
'last_7_days',
'last_14_days',
'last_30_days',
'last_90_days',
'last_180_days',
'year_to_date',
'all_time',
]
const TIMEFRAME_MODE_VALUES: AnalyticsTimeframeMode[] = [
'preset',
'last',
'custom_range',
'custom_datetime_range',
]
const LAST_TIMEFRAME_UNIT_VALUES: AnalyticsLastTimeframeUnit[] = [
'hours',
'days',
'weeks',
'months',
]
const GROUP_BY_PRESET_VALUES: AnalyticsGroupByPreset[] = [
'1h',
'6h',
'day',
'week',
'month',
'year',
]
const BREAKDOWN_PRESET_VALUES: AnalyticsBreakdownPreset[] = [
'none',
'project',
'country',
'monetization',
'user_agent',
'download_reason',
'version_id',
'loader',
'game_version',
]
const ANALYTICS_DASHBOARD_STAT_VALUES: AnalyticsDashboardStat[] = [
'views',
'downloads',
'revenue',
'playtime',
]
const ANALYTICS_GRAPH_VIEW_MODE_VALUES: AnalyticsGraphViewMode[] = ['line', 'area', 'bar']
const ANALYTICS_TABLE_SORT_COLUMN_VALUES: AnalyticsTableSortColumn[] = [
'date',
'project',
'breakdown',
'breakdown_project',
'breakdown_country',
'breakdown_monetization',
'breakdown_user_agent',
'breakdown_download_reason',
'breakdown_version_id',
'breakdown_loader',
'breakdown_game_version',
'views',
'downloads',
'revenue',
'playtime',
]
const ANALYTICS_TABLE_SORT_DIRECTION_VALUES: AnalyticsTableSortDirection[] = ['asc', 'desc']
const PROJECT_STATUS_FILTER_VALUES = [
'approved',
'archived',
'rejected',
'draft',
'unlisted',
'withheld',
'private',
'other',
]
const QUERY_KEY_PROJECT_IDS = 'a_projects'
const QUERY_KEY_TIMEFRAME_MODE = 'a_timeframe_mode'
const QUERY_KEY_TIMEFRAME = 'a_timeframe'
const QUERY_KEY_TIMEFRAME_LAST_AMOUNT = 'a_timeframe_last_amount'
const QUERY_KEY_TIMEFRAME_LAST_UNIT = 'a_timeframe_last_unit'
const QUERY_KEY_TIMEFRAME_START = 'a_timeframe_start'
const QUERY_KEY_TIMEFRAME_END = 'a_timeframe_end'
const QUERY_KEY_GROUP_BY = 'a_group_by'
const QUERY_KEY_BREAKDOWN = 'a_breakdown'
const QUERY_KEY_FILTER_PROJECT_STATUS = 'a_project_status'
const QUERY_KEY_FILTER_COUNTRY = 'a_country'
const QUERY_KEY_FILTER_MONETIZATION = 'a_monetization'
const QUERY_KEY_FILTER_USER_AGENT = 'a_user_agent'
const QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE = 'a_download_source'
const QUERY_KEY_FILTER_DOWNLOAD_REASON = 'a_download_reason'
const QUERY_KEY_FILTER_VERSION_ID = 'a_version_id'
const QUERY_KEY_FILTER_GAME_VERSION = 'a_game_version'
const QUERY_KEY_FILTER_LOADER_TYPE = 'a_loader_type'
const QUERY_KEY_STAT = 'a_stat'
const QUERY_KEY_GRAPH_VIEW_MODE = 'a_chart'
const QUERY_KEY_GRAPH_RATIO_MODE = 'a_ratio'
const QUERY_KEY_GRAPH_EVENTS_VISIBILITY = 'a_events'
const QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY = 'a_project_events'
const QUERY_KEY_GRAPH_PREVIOUS_PERIOD_VISIBILITY = 'a_prev_period'
const QUERY_KEY_GRAPH_HIDDEN_SERIES = 'a_hidden_series'
const QUERY_KEY_GRAPH_SELECTED_SERIES = 'a_selected_series'
const QUERY_KEY_TABLE_SORT = 'a_table_sort'
const QUERY_KEY_TABLE_SORT_DIRECTION = 'a_table_sort_direction'
const QUERY_KEY_LEGACY_GRAPH_TOP_BREAKDOWN_FILTER = 'a_top_breakdown'
const QUERY_KEY_LEGACY_GRAPH_LEGEND_EXPANSION = 'a_legend_expanded'
const URL_FILTER_CATEGORIES: Exclude<AnalyticsQueryFilterCategory, 'project'>[] = [
'project_status',
'country',
'monetization',
'user_agent',
'download_reason',
'version_id',
'game_version',
'loader_type',
]
const FILTER_QUERY_KEY_BY_CATEGORY: Record<
Exclude<AnalyticsQueryFilterCategory, 'project'>,
string
> = {
project_status: QUERY_KEY_FILTER_PROJECT_STATUS,
country: QUERY_KEY_FILTER_COUNTRY,
monetization: QUERY_KEY_FILTER_MONETIZATION,
user_agent: QUERY_KEY_FILTER_USER_AGENT,
download_reason: QUERY_KEY_FILTER_DOWNLOAD_REASON,
version_id: QUERY_KEY_FILTER_VERSION_ID,
game_version: QUERY_KEY_FILTER_GAME_VERSION,
loader_type: QUERY_KEY_FILTER_LOADER_TYPE,
}
const ANALYTICS_QUERY_KEYS = [
QUERY_KEY_PROJECT_IDS,
QUERY_KEY_TIMEFRAME_MODE,
QUERY_KEY_TIMEFRAME,
QUERY_KEY_TIMEFRAME_LAST_AMOUNT,
QUERY_KEY_TIMEFRAME_LAST_UNIT,
QUERY_KEY_TIMEFRAME_START,
QUERY_KEY_TIMEFRAME_END,
QUERY_KEY_GROUP_BY,
QUERY_KEY_BREAKDOWN,
QUERY_KEY_FILTER_PROJECT_STATUS,
QUERY_KEY_FILTER_COUNTRY,
QUERY_KEY_FILTER_MONETIZATION,
QUERY_KEY_FILTER_USER_AGENT,
QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE,
QUERY_KEY_FILTER_DOWNLOAD_REASON,
QUERY_KEY_FILTER_VERSION_ID,
QUERY_KEY_FILTER_GAME_VERSION,
QUERY_KEY_FILTER_LOADER_TYPE,
QUERY_KEY_STAT,
QUERY_KEY_GRAPH_VIEW_MODE,
QUERY_KEY_GRAPH_RATIO_MODE,
QUERY_KEY_GRAPH_EVENTS_VISIBILITY,
QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY,
QUERY_KEY_GRAPH_PREVIOUS_PERIOD_VISIBILITY,
QUERY_KEY_GRAPH_HIDDEN_SERIES,
QUERY_KEY_GRAPH_SELECTED_SERIES,
QUERY_KEY_LEGACY_GRAPH_TOP_BREAKDOWN_FILTER,
QUERY_KEY_LEGACY_GRAPH_LEGEND_EXPANSION,
]
export function buildEmptySelectedFilters(): AnalyticsSelectedFilters {
return {
project: [],
project_status: [],
country: [],
monetization: [],
user_agent: [],
download_reason: [],
version_id: [],
game_version: [],
loader_type: [],
}
}
function parseListQueryValue(
value: LocationQueryValue | LocationQueryValue[] | undefined,
): string[] {
if (value === undefined) return []
const values = Array.isArray(value) ? value : [value]
const parsedValues: string[] = []
for (const item of values) {
if (!item) continue
const parts = item.split(',')
for (const part of parts) {
const trimmed = part.trim()
if (trimmed.length > 0) {
parsedValues.push(trimmed)
}
}
}
return Array.from(new Set(parsedValues))
}
function parseSelectedSeriesQueryValue(
value: LocationQueryValue | LocationQueryValue[] | undefined,
): string[] {
return parseListQueryValue(value).filter((item) => item.toLowerCase() !== 'null')
}
function normalizeFilterQueryValues(
category: Exclude<AnalyticsQueryFilterCategory, 'project'>,
values: string[],
): string[] {
if (category === 'project_status') {
return values
.map((value) => value.trim().toLowerCase())
.filter((value) => PROJECT_STATUS_FILTER_VALUES.includes(value))
}
if (category !== 'loader_type') {
return values
}
return Array.from(
new Set(values.map((value) => value.trim().toLowerCase()).filter((value) => value.length > 0)),
)
}
function parsePresetQueryValue<T extends string>(
value: LocationQueryValue | LocationQueryValue[] | undefined,
allowedValues: readonly T[],
fallbackValue: T,
): T {
const rawValue = Array.isArray(value) ? value[0] : value
if (!rawValue) return fallbackValue
if (!allowedValues.includes(rawValue as T)) return fallbackValue
return rawValue as T
}
function parseAnalyticsBreakdownsQueryValue(
value: LocationQueryValue | LocationQueryValue[] | undefined,
fallbackValues: AnalyticsSelectedBreakdowns,
): AnalyticsBreakdownPreset[] {
const rawValues = parseListQueryValue(value)
if (rawValues.length === 0) {
return [...fallbackValues]
}
const parsedBreakdowns: AnalyticsBreakdownPreset[] = []
for (const rawValue of rawValues) {
const normalizedValue = rawValue === 'download_source' ? 'user_agent' : rawValue
if (BREAKDOWN_PRESET_VALUES.includes(normalizedValue as AnalyticsBreakdownPreset)) {
parsedBreakdowns.push(normalizedValue as AnalyticsBreakdownPreset)
}
}
return parsedBreakdowns
}
function parsePositiveIntegerQueryValue(
value: LocationQueryValue | LocationQueryValue[] | undefined,
fallbackValue: number,
): number {
const rawValue = Array.isArray(value) ? value[0] : value
if (!rawValue) return fallbackValue
const parsedValue = Number.parseInt(rawValue, 10)
if (!Number.isFinite(parsedValue) || parsedValue < 1) return fallbackValue
return parsedValue
}
function parseEnabledQueryValue(
value: LocationQueryValue | LocationQueryValue[] | undefined,
): boolean {
const rawValue = Array.isArray(value) ? value[0] : value
return rawValue === '1'
}
function parseVisibleQueryValue(
value: LocationQueryValue | LocationQueryValue[] | undefined,
fallbackValue: boolean,
): boolean {
const rawValue = Array.isArray(value) ? value[0] : value
if (rawValue === undefined) return fallbackValue
return rawValue !== '0'
}
function getLocalDateQueryValue(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function getDefaultCustomStartDate(): string {
const date = new Date()
date.setDate(date.getDate() - 1)
return getLocalDateQueryValue(date)
}
function getDefaultCustomEndDate(): string {
return getLocalDateQueryValue(new Date())
}
function getDefaultCustomDateTimeValue(value: string): string {
return new Date(`${value}T00:00:00`).toISOString()
}
function parseDateQueryValue(
value: LocationQueryValue | LocationQueryValue[] | undefined,
fallbackValue: string,
): string {
const rawValue = Array.isArray(value) ? value[0] : value
if (!rawValue || !/^\d{4}-\d{2}-\d{2}$/.test(rawValue)) return fallbackValue
const date = new Date(`${rawValue}T00:00:00`)
if (Number.isNaN(date.getTime())) return fallbackValue
if (getLocalDateQueryValue(date) !== rawValue) return fallbackValue
return rawValue
}
function parseDateTimeQueryValue(
value: LocationQueryValue | LocationQueryValue[] | undefined,
fallbackValue: string,
): string {
const rawValue = Array.isArray(value) ? value[0] : value
if (!rawValue || !/^\d{4}-\d{2}-\d{2}T/.test(rawValue)) return fallbackValue
const date = new Date(rawValue)
if (Number.isNaN(date.getTime())) return fallbackValue
return date.toISOString()
}
function isTimeframeRangeEndBeforeStart(
mode: AnalyticsTimeframeMode,
startValue: string,
endValue: string,
): boolean {
if (mode === 'custom_datetime_range') {
return new Date(endValue).getTime() < new Date(startValue).getTime()
}
return endValue < startValue
}
export function getDefaultAnalyticsGraphProjectEventsVisibility(
selectedProjectIds: readonly string[] = [],
): boolean {
return selectedProjectIds.length <= 1
}
export function buildDefaultAnalyticsGraphState(
selectedProjectIds: readonly string[] = [],
): AnalyticsGraphState {
return {
activeStat: DEFAULT_ANALYTICS_DASHBOARD_STAT,
activeGraphViewMode: DEFAULT_ANALYTICS_GRAPH_VIEW_MODE,
isRatioMode: DEFAULT_ANALYTICS_GRAPH_RATIO_MODE,
showChartEvents: DEFAULT_ANALYTICS_GRAPH_EVENTS_VISIBILITY,
showProjectEvents: getDefaultAnalyticsGraphProjectEventsVisibility(selectedProjectIds),
showPreviousPeriod: DEFAULT_ANALYTICS_GRAPH_PREVIOUS_PERIOD_VISIBILITY,
hiddenGraphDatasetIds: [],
selectedGraphDatasetIds: null,
}
}
export function buildDefaultAnalyticsQueryBuilderState(
availableProjectIds: string[],
): AnalyticsQueryBuilderState {
return {
selectedProjectIds: [...availableProjectIds],
selectedTimeframeMode: DEFAULT_TIMEFRAME_MODE,
selectedTimeframe: DEFAULT_TIMEFRAME_PRESET,
selectedLastTimeframeAmount: DEFAULT_LAST_TIMEFRAME_AMOUNT,
selectedLastTimeframeUnit: DEFAULT_LAST_TIMEFRAME_UNIT,
selectedCustomTimeframeStartDate: getDefaultCustomStartDate(),
selectedCustomTimeframeEndDate: getDefaultCustomEndDate(),
selectedGroupBy: DEFAULT_GROUP_BY_PRESET,
selectedBreakdowns: getDefaultAnalyticsBreakdownPresets(availableProjectIds),
selectedFilters: buildEmptySelectedFilters(),
}
}
export function getDefaultAnalyticsBreakdownPresets(
selectedProjectIds: readonly string[],
): AnalyticsSelectedBreakdowns {
return selectedProjectIds.length > 1 ? ['project'] : []
}
export function getDefaultAnalyticsBreakdownPreset(
selectedProjectIds: readonly string[],
): AnalyticsBreakdownPreset {
return selectedProjectIds.length > 1 ? 'project' : DEFAULT_BREAKDOWN_PRESET
}
export function getAnalyticsBreakdownPresetsForProjectSelection(
breakdowns: readonly AnalyticsBreakdownPreset[],
selectedProjectIds: readonly string[],
): AnalyticsSelectedBreakdowns {
const normalizedBreakdowns: AnalyticsSelectedBreakdowns = []
const canBreakDownByProject = selectedProjectIds.length > 1
for (const breakdown of breakdowns) {
if (breakdown === 'none') {
continue
}
if (breakdown === 'project' && !canBreakDownByProject) {
continue
}
if (!normalizedBreakdowns.includes(breakdown)) {
normalizedBreakdowns.push(breakdown)
}
if (normalizedBreakdowns.length >= MAX_ANALYTICS_BREAKDOWN_PRESETS) {
break
}
}
return normalizedBreakdowns
}
export function getAnalyticsBreakdownPresetForProjectSelection(
breakdown: AnalyticsBreakdownPreset,
selectedProjectIds: readonly string[],
): AnalyticsBreakdownPreset {
const defaultBreakdown = getDefaultAnalyticsBreakdownPreset(selectedProjectIds)
if (
(breakdown === 'none' && defaultBreakdown === 'project') ||
(breakdown === 'project' && defaultBreakdown === 'none')
) {
return defaultBreakdown
}
return breakdown
}
export function isAnalyticsQueryBuilderStateDefault(
state: AnalyticsQueryBuilderState,
availableProjectIds: string[],
): boolean {
const defaultState = buildDefaultAnalyticsQueryBuilderState(availableProjectIds)
const areDefaultProjectsSelected =
availableProjectIds.length === 0
? state.selectedProjectIds.length === 0
: areAllProjectsSelected(state.selectedProjectIds, availableProjectIds)
return (
areDefaultProjectsSelected &&
state.selectedTimeframeMode === defaultState.selectedTimeframeMode &&
state.selectedTimeframe === defaultState.selectedTimeframe &&
state.selectedLastTimeframeAmount === defaultState.selectedLastTimeframeAmount &&
state.selectedLastTimeframeUnit === defaultState.selectedLastTimeframeUnit &&
state.selectedCustomTimeframeStartDate === defaultState.selectedCustomTimeframeStartDate &&
state.selectedCustomTimeframeEndDate === defaultState.selectedCustomTimeframeEndDate &&
state.selectedGroupBy === defaultState.selectedGroupBy &&
areStringArraysEqual(
state.selectedBreakdowns,
getDefaultAnalyticsBreakdownPresets(state.selectedProjectIds),
) &&
areSelectedFiltersEqual(state.selectedFilters, defaultState.selectedFilters)
)
}
export function isAnalyticsGraphStateDefault(
state: AnalyticsGraphState,
selectedProjectIds: readonly string[] = [],
): boolean {
const defaultState = buildDefaultAnalyticsGraphState(selectedProjectIds)
return (
state.activeStat === defaultState.activeStat &&
state.activeGraphViewMode === defaultState.activeGraphViewMode &&
state.isRatioMode === defaultState.isRatioMode &&
state.showChartEvents === defaultState.showChartEvents &&
state.showProjectEvents === defaultState.showProjectEvents &&
state.showPreviousPeriod === defaultState.showPreviousPeriod &&
areStringArraysEqual(state.hiddenGraphDatasetIds, defaultState.hiddenGraphDatasetIds) &&
state.selectedGraphDatasetIds === defaultState.selectedGraphDatasetIds
)
}
function serializeListQueryValue(values: string[]): string | undefined {
if (values.length === 0) return undefined
return values.join(',')
}
function serializeExplicitListQueryValue(values: string[]): string {
return values.join(',')
}
function serializeVisibleQueryValue(value: boolean, defaultValue: boolean): string | undefined {
if (value === defaultValue) return undefined
return value ? '1' : '0'
}
function normalizeQueryValue(
value:
| LocationQueryValue
| LocationQueryValue[]
| LocationQueryValueRaw
| LocationQueryValueRaw[]
| undefined,
): string[] {
if (value === undefined || value === null) return []
if (Array.isArray(value)) {
return value
.filter(
(item): item is LocationQueryValue | LocationQueryValueRaw =>
item !== undefined && item !== null,
)
.map((item) => String(item))
}
return [String(value)]
}
function areQueryValuesEqual(
left:
| LocationQueryValue
| LocationQueryValue[]
| LocationQueryValueRaw
| LocationQueryValueRaw[]
| undefined,
right:
| LocationQueryValue
| LocationQueryValue[]
| LocationQueryValueRaw
| LocationQueryValueRaw[]
| undefined,
): boolean {
const leftValues = normalizeQueryValue(left)
const rightValues = normalizeQueryValue(right)
if (leftValues.length !== rightValues.length) return false
for (let index = 0; index < leftValues.length; index += 1) {
if (leftValues[index] !== rightValues[index]) return false
}
return true
}
export function areStringArraysEqual(left: string[], right: string[]): boolean {
if (left.length !== right.length) return false
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) return false
}
return true
}
export function areSelectedFiltersEqual(
left: AnalyticsSelectedFilters,
right: AnalyticsSelectedFilters,
): boolean {
if (!areStringArraysEqual(left.project, right.project)) return false
for (const category of URL_FILTER_CATEGORIES) {
if (!areStringArraysEqual(left[category], right[category])) return false
}
return true
}
function areAllProjectsSelected(selectedProjectIds: string[], allProjectIds: string[]): boolean {
if (allProjectIds.length === 0 || selectedProjectIds.length !== allProjectIds.length) {
return false
}
const allProjectIdSet = new Set(allProjectIds)
return selectedProjectIds.every((projectId) => allProjectIdSet.has(projectId))
}
export function readAnalyticsGraphState(
query: LocationQuery,
selectedProjectIds: readonly string[] = [],
): AnalyticsGraphState {
const defaultState = buildDefaultAnalyticsGraphState(selectedProjectIds)
return {
activeStat: parsePresetQueryValue(
query[QUERY_KEY_STAT],
ANALYTICS_DASHBOARD_STAT_VALUES,
defaultState.activeStat,
),
activeGraphViewMode: parsePresetQueryValue(
query[QUERY_KEY_GRAPH_VIEW_MODE],
ANALYTICS_GRAPH_VIEW_MODE_VALUES,
defaultState.activeGraphViewMode,
),
isRatioMode: parseEnabledQueryValue(query[QUERY_KEY_GRAPH_RATIO_MODE]),
showChartEvents: parseVisibleQueryValue(
query[QUERY_KEY_GRAPH_EVENTS_VISIBILITY],
defaultState.showChartEvents,
),
showProjectEvents: parseVisibleQueryValue(
query[QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY],
defaultState.showProjectEvents,
),
showPreviousPeriod: parseEnabledQueryValue(query[QUERY_KEY_GRAPH_PREVIOUS_PERIOD_VISIBILITY]),
hiddenGraphDatasetIds: parseListQueryValue(query[QUERY_KEY_GRAPH_HIDDEN_SERIES]),
selectedGraphDatasetIds:
query[QUERY_KEY_GRAPH_SELECTED_SERIES] === undefined
? null
: parseSelectedSeriesQueryValue(query[QUERY_KEY_GRAPH_SELECTED_SERIES]),
}
}
export function readAnalyticsTableSortState(
query: LocationQuery,
defaultState: AnalyticsTableSortState,
): AnalyticsTableSortState {
const rawSortColumn = Array.isArray(query[QUERY_KEY_TABLE_SORT])
? query[QUERY_KEY_TABLE_SORT][0]
: query[QUERY_KEY_TABLE_SORT]
const rawSortDirection = Array.isArray(query[QUERY_KEY_TABLE_SORT_DIRECTION])
? query[QUERY_KEY_TABLE_SORT_DIRECTION][0]
: query[QUERY_KEY_TABLE_SORT_DIRECTION]
if (
!rawSortColumn ||
!rawSortDirection ||
!ANALYTICS_TABLE_SORT_COLUMN_VALUES.includes(rawSortColumn as AnalyticsTableSortColumn) ||
!ANALYTICS_TABLE_SORT_DIRECTION_VALUES.includes(rawSortDirection as AnalyticsTableSortDirection)
) {
return defaultState
}
return {
sortColumn: rawSortColumn as AnalyticsTableSortColumn,
sortDirection: rawSortDirection as AnalyticsTableSortDirection,
}
}
export function readAnalyticsQueryBuilderState(
query: LocationQuery,
availableProjectIds: string[],
): AnalyticsQueryBuilderState {
const defaultState = buildDefaultAnalyticsQueryBuilderState(availableProjectIds)
const selectedProjectIdsFromQuery = parseListQueryValue(query[QUERY_KEY_PROJECT_IDS])
const selectedProjectIds =
selectedProjectIdsFromQuery.length > 0
? selectedProjectIdsFromQuery
: defaultState.selectedProjectIds
const selectedFilters = buildEmptySelectedFilters()
for (const category of URL_FILTER_CATEGORIES) {
const categoryQueryKey = FILTER_QUERY_KEY_BY_CATEGORY[category]
const rawQueryValue =
category === 'user_agent' && query[categoryQueryKey] === undefined
? query[QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE]
: query[categoryQueryKey]
selectedFilters[category] = normalizeFilterQueryValues(
category,
parseListQueryValue(rawQueryValue),
)
}
const selectedTimeframeMode = parsePresetQueryValue(
query[QUERY_KEY_TIMEFRAME_MODE],
TIMEFRAME_MODE_VALUES,
defaultState.selectedTimeframeMode,
)
const isCustomDateTimeRange = selectedTimeframeMode === 'custom_datetime_range'
const parseTimeframeRangeQueryValue = isCustomDateTimeRange
? parseDateTimeQueryValue
: parseDateQueryValue
const customTimeframeStartFallback = isCustomDateTimeRange
? getDefaultCustomDateTimeValue(defaultState.selectedCustomTimeframeStartDate)
: defaultState.selectedCustomTimeframeStartDate
const customTimeframeEndFallback = isCustomDateTimeRange
? getDefaultCustomDateTimeValue(defaultState.selectedCustomTimeframeEndDate)
: defaultState.selectedCustomTimeframeEndDate
const selectedCustomTimeframeStartDate = parseTimeframeRangeQueryValue(
query[QUERY_KEY_TIMEFRAME_START],
customTimeframeStartFallback,
)
const rawCustomTimeframeEndDate = parseTimeframeRangeQueryValue(
query[QUERY_KEY_TIMEFRAME_END],
customTimeframeEndFallback,
)
const selectedCustomTimeframeEndDate = isTimeframeRangeEndBeforeStart(
selectedTimeframeMode,
selectedCustomTimeframeStartDate,
rawCustomTimeframeEndDate,
)
? selectedCustomTimeframeStartDate
: rawCustomTimeframeEndDate
const selectedBreakdowns = getAnalyticsBreakdownPresetsForProjectSelection(
parseAnalyticsBreakdownsQueryValue(
query[QUERY_KEY_BREAKDOWN],
getDefaultAnalyticsBreakdownPresets(selectedProjectIds),
),
selectedProjectIds,
)
return {
selectedProjectIds,
selectedTimeframeMode,
selectedTimeframe: parsePresetQueryValue(
query[QUERY_KEY_TIMEFRAME],
TIMEFRAME_PRESET_VALUES,
defaultState.selectedTimeframe,
),
selectedLastTimeframeAmount: parsePositiveIntegerQueryValue(
query[QUERY_KEY_TIMEFRAME_LAST_AMOUNT],
defaultState.selectedLastTimeframeAmount,
),
selectedLastTimeframeUnit: parsePresetQueryValue(
query[QUERY_KEY_TIMEFRAME_LAST_UNIT],
LAST_TIMEFRAME_UNIT_VALUES,
defaultState.selectedLastTimeframeUnit,
),
selectedCustomTimeframeStartDate,
selectedCustomTimeframeEndDate,
selectedGroupBy: parsePresetQueryValue(
query[QUERY_KEY_GROUP_BY],
GROUP_BY_PRESET_VALUES,
defaultState.selectedGroupBy,
),
selectedBreakdowns,
selectedFilters,
}
}
export function hasAnalyticsBreakdownQuery(query: LocationQuery): boolean {
return parseListQueryValue(query[QUERY_KEY_BREAKDOWN]).length > 0
}
export function hasAnalyticsProjectSelectionQuery(query: LocationQuery): boolean {
return parseListQueryValue(query[QUERY_KEY_PROJECT_IDS]).length > 0
}
export function hasAnalyticsGraphProjectEventsVisibilityQuery(query: LocationQuery): boolean {
return query[QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY] !== undefined
}
export function hasAnalyticsTableSortQuery(query: LocationQuery): boolean {
return (
query[QUERY_KEY_TABLE_SORT] !== undefined || query[QUERY_KEY_TABLE_SORT_DIRECTION] !== undefined
)
}
export function buildAnalyticsQueryBuilderRouteQuery(
currentRouteQuery: LocationQuery,
state: AnalyticsQueryBuilderState,
availableProjectIds: string[],
graphState?: AnalyticsGraphState,
): MutableRouteQuery {
const nextRouteQuery = {
...currentRouteQuery,
} as MutableRouteQuery
const projectIdsQueryValue = areAllProjectsSelected(state.selectedProjectIds, availableProjectIds)
? undefined
: serializeListQueryValue(state.selectedProjectIds)
const isCustomTimeframeMode =
state.selectedTimeframeMode === 'custom_range' ||
state.selectedTimeframeMode === 'custom_datetime_range'
nextRouteQuery[QUERY_KEY_PROJECT_IDS] = projectIdsQueryValue
nextRouteQuery[QUERY_KEY_TIMEFRAME_MODE] =
state.selectedTimeframeMode !== DEFAULT_TIMEFRAME_MODE ? state.selectedTimeframeMode : undefined
nextRouteQuery[QUERY_KEY_TIMEFRAME] =
state.selectedTimeframeMode === 'preset' && state.selectedTimeframe !== DEFAULT_TIMEFRAME_PRESET
? state.selectedTimeframe
: undefined
nextRouteQuery[QUERY_KEY_TIMEFRAME_LAST_AMOUNT] =
state.selectedTimeframeMode === 'last' ? String(state.selectedLastTimeframeAmount) : undefined
nextRouteQuery[QUERY_KEY_TIMEFRAME_LAST_UNIT] =
state.selectedTimeframeMode === 'last' ? state.selectedLastTimeframeUnit : undefined
nextRouteQuery[QUERY_KEY_TIMEFRAME_START] = isCustomTimeframeMode
? state.selectedCustomTimeframeStartDate
: undefined
nextRouteQuery[QUERY_KEY_TIMEFRAME_END] = isCustomTimeframeMode
? state.selectedCustomTimeframeEndDate
: undefined
nextRouteQuery[QUERY_KEY_GROUP_BY] =
state.selectedGroupBy !== DEFAULT_GROUP_BY_PRESET ? state.selectedGroupBy : undefined
const defaultBreakdowns = getDefaultAnalyticsBreakdownPresets(state.selectedProjectIds)
const selectedBreakdowns = getAnalyticsBreakdownPresetsForProjectSelection(
state.selectedBreakdowns,
state.selectedProjectIds,
)
nextRouteQuery[QUERY_KEY_BREAKDOWN] = areStringArraysEqual(selectedBreakdowns, defaultBreakdowns)
? undefined
: selectedBreakdowns.length === 0
? 'none'
: serializeListQueryValue(selectedBreakdowns)
for (const category of URL_FILTER_CATEGORIES) {
const categoryQueryKey = FILTER_QUERY_KEY_BY_CATEGORY[category]
nextRouteQuery[categoryQueryKey] = serializeListQueryValue(state.selectedFilters[category])
}
nextRouteQuery[QUERY_KEY_FILTER_LEGACY_DOWNLOAD_SOURCE] = undefined
if (graphState) {
const defaultGraphState = buildDefaultAnalyticsGraphState(state.selectedProjectIds)
nextRouteQuery[QUERY_KEY_STAT] =
graphState.activeStat !== DEFAULT_ANALYTICS_DASHBOARD_STAT ? graphState.activeStat : undefined
nextRouteQuery[QUERY_KEY_GRAPH_VIEW_MODE] =
graphState.activeGraphViewMode !== DEFAULT_ANALYTICS_GRAPH_VIEW_MODE
? graphState.activeGraphViewMode
: undefined
nextRouteQuery[QUERY_KEY_GRAPH_RATIO_MODE] = graphState.isRatioMode ? '1' : undefined
nextRouteQuery[QUERY_KEY_GRAPH_EVENTS_VISIBILITY] = serializeVisibleQueryValue(
graphState.showChartEvents,
defaultGraphState.showChartEvents,
)
nextRouteQuery[QUERY_KEY_GRAPH_PROJECT_EVENTS_VISIBILITY] = serializeVisibleQueryValue(
graphState.showProjectEvents,
defaultGraphState.showProjectEvents,
)
nextRouteQuery[QUERY_KEY_GRAPH_PREVIOUS_PERIOD_VISIBILITY] = graphState.showPreviousPeriod
? '1'
: undefined
nextRouteQuery[QUERY_KEY_LEGACY_GRAPH_TOP_BREAKDOWN_FILTER] = undefined
nextRouteQuery[QUERY_KEY_LEGACY_GRAPH_LEGEND_EXPANSION] = undefined
nextRouteQuery[QUERY_KEY_GRAPH_HIDDEN_SERIES] = serializeListQueryValue(
[...graphState.hiddenGraphDatasetIds].sort((left, right) => left.localeCompare(right)),
)
nextRouteQuery[QUERY_KEY_GRAPH_SELECTED_SERIES] =
graphState.selectedGraphDatasetIds === null
? undefined
: serializeExplicitListQueryValue(graphState.selectedGraphDatasetIds)
}
return nextRouteQuery
}
export function buildAnalyticsTableSortRouteQuery(
currentRouteQuery: LocationQuery,
state: AnalyticsTableSortState,
defaultState: AnalyticsTableSortState,
): MutableRouteQuery {
const nextRouteQuery = {
...currentRouteQuery,
} as MutableRouteQuery
const isDefaultSort =
state.sortColumn === defaultState.sortColumn &&
state.sortDirection === defaultState.sortDirection
nextRouteQuery[QUERY_KEY_TABLE_SORT] =
isDefaultSort || state.sortColumn === undefined ? undefined : state.sortColumn
nextRouteQuery[QUERY_KEY_TABLE_SORT_DIRECTION] =
isDefaultSort || state.sortColumn === undefined ? undefined : state.sortDirection
return nextRouteQuery
}
export function hasAnalyticsQueryBuilderRouteChange(
currentRouteQuery: LocationQuery,
nextRouteQuery: MutableRouteQuery,
): boolean {
return ANALYTICS_QUERY_KEYS.some(
(key) => !areQueryValuesEqual(currentRouteQuery[key], nextRouteQuery[key]),
)
}
export function hasAnalyticsTableSortRouteChange(
currentRouteQuery: LocationQuery,
nextRouteQuery: MutableRouteQuery,
): boolean {
return (
!areQueryValuesEqual(
currentRouteQuery[QUERY_KEY_TABLE_SORT],
nextRouteQuery[QUERY_KEY_TABLE_SORT],
) ||
!areQueryValuesEqual(
currentRouteQuery[QUERY_KEY_TABLE_SORT_DIRECTION],
nextRouteQuery[QUERY_KEY_TABLE_SORT_DIRECTION],
)
)
}
@@ -0,0 +1,143 @@
import type { TableColumn } from '@modrinth/ui'
import type {
AnalyticsBreakdownPreset,
AnalyticsDashboardStat,
AnalyticsSelectedFilters,
} from '~/providers/analytics/analytics'
import {
analyticsGroupByMessages,
formatAnalyticsBreakdownLabel,
formatAnalyticsStatLabel,
type FormatMessage,
} from '../analytics-messages'
import type {
AnalyticsTableBreakdownColumnKey,
AnalyticsTableBreakdownPreset,
AnalyticsTableColumnKey,
} from './analytics-table-types'
type BuildAnalyticsTableColumnsOptions = {
includeDate: boolean
selectedBreakdowns: readonly AnalyticsTableBreakdownPreset[]
selectedFilters: AnalyticsSelectedFilters
showBreakdownColumn: boolean
showProjectVersionProjectColumn: boolean
formatMessage: FormatMessage
getRelevantAnalyticsDashboardStats: (
breakdowns: readonly AnalyticsBreakdownPreset[],
filters?: AnalyticsSelectedFilters,
) => readonly AnalyticsDashboardStat[]
}
export function getAnalyticsTableBreakdownColumnLabel(
breakdown: AnalyticsBreakdownPreset,
formatMessage: FormatMessage,
): string {
return formatAnalyticsBreakdownLabel(breakdown, formatMessage)
}
export function buildAnalyticsTableColumns({
includeDate,
selectedBreakdowns,
selectedFilters,
showBreakdownColumn,
showProjectVersionProjectColumn,
formatMessage,
getRelevantAnalyticsDashboardStats,
}: BuildAnalyticsTableColumnsOptions): TableColumn<AnalyticsTableColumnKey>[] {
const nextColumns: TableColumn<AnalyticsTableColumnKey>[] = []
const stats = getRelevantAnalyticsDashboardStats(selectedBreakdowns, selectedFilters)
if (includeDate) {
nextColumns.push({
key: 'date',
label: formatMessage(analyticsGroupByMessages.date),
enableSorting: true,
defaultSortDirection: 'desc',
width: stats.length > 2 ? '20%' : '',
})
}
if (showBreakdownColumn) {
for (const breakdown of selectedBreakdowns) {
nextColumns.push({
key: getAnalyticsTableBreakdownColumnKey(breakdown),
label: getAnalyticsTableBreakdownColumnLabel(breakdown, formatMessage),
enableSorting: true,
})
}
}
if (showProjectVersionProjectColumn) {
nextColumns.push({
key: 'project',
label: formatAnalyticsBreakdownLabel('project', formatMessage),
enableSorting: true,
})
}
for (const stat of stats) {
const column = getAnalyticsTableMetricColumn(stat, formatMessage)
if (column) {
nextColumns.push(column)
}
}
return nextColumns
}
export function getAnalyticsTableMetricColumn(
stat: AnalyticsDashboardStat,
formatMessage: FormatMessage,
): TableColumn<AnalyticsTableColumnKey> | null {
switch (stat) {
case 'views':
return {
key: 'views',
label: formatAnalyticsStatLabel('views', formatMessage),
enableSorting: true,
defaultSortDirection: 'desc',
align: 'right',
}
case 'downloads':
return {
key: 'downloads',
label: formatAnalyticsStatLabel('downloads', formatMessage),
enableSorting: true,
defaultSortDirection: 'desc',
align: 'right',
}
case 'revenue':
return {
key: 'revenue',
label: formatAnalyticsStatLabel('revenue', formatMessage),
enableSorting: true,
defaultSortDirection: 'desc',
align: 'right',
}
case 'playtime':
return {
key: 'playtime',
label: formatAnalyticsStatLabel('playtime', formatMessage),
enableSorting: true,
defaultSortDirection: 'desc',
align: 'right',
}
default:
return null
}
}
export function getAnalyticsTableBreakdownColumnKey(
breakdown: AnalyticsTableBreakdownPreset,
): AnalyticsTableBreakdownColumnKey {
return `breakdown_${breakdown}`
}
export function isAnalyticsTableBreakdownColumnKey(
key: AnalyticsTableColumnKey,
): key is AnalyticsTableBreakdownColumnKey {
return key.startsWith('breakdown_')
}
@@ -0,0 +1,147 @@
import type { Labrinth } from '@modrinth/api-client'
import type { TableColumn } from '@modrinth/ui'
import { analyticsTableMessages, type FormatMessage } from '../analytics-messages'
import { isAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
import type { AnalyticsTableColumnKey, AnalyticsTableRow } from './analytics-table-types'
export function buildAnalyticsTableCsvContent(
rows: AnalyticsTableRow[],
visibleColumns: TableColumn<AnalyticsTableColumnKey>[],
formatMessage: FormatMessage,
): string {
const header = visibleColumns
.map((column) =>
escapeAnalyticsTableCsvField(getAnalyticsTableCsvHeaderLabel(column, formatMessage)),
)
.join(',')
const csvRows = rows.map((row) =>
visibleColumns
.map((column) => escapeAnalyticsTableCsvField(getAnalyticsTableCsvCellValue(row, column.key)))
.join(','),
)
return [header, ...csvRows].join('\n')
}
export function downloadAnalyticsTableCsv(filename: string, csvContent: string) {
if (!import.meta.client) {
return
}
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const downloadLink = document.createElement('a')
downloadLink.setAttribute('href', url)
downloadLink.setAttribute('download', filename)
downloadLink.style.visibility = 'hidden'
document.body.appendChild(downloadLink)
downloadLink.click()
document.body.removeChild(downloadLink)
URL.revokeObjectURL(url)
}
export function getAnalyticsTableCsvFilename(
breakdownColumnLabel: string,
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
formatMessage: FormatMessage,
): string {
return `${sanitizeAnalyticsTableCsvFilename(
formatMessage(analyticsTableMessages.csvFilename, {
breakdown: breakdownColumnLabel,
dateRange: getAnalyticsTableCsvFilenameDateRange(fetchRequest, formatMessage),
}),
)}.csv`
}
function getAnalyticsTableCsvCellValue(
row: AnalyticsTableRow,
key: AnalyticsTableColumnKey,
): string | number {
switch (key) {
case 'date':
return row.date
case 'project':
return row.project
case 'breakdown':
return row.breakdownDisplay
case 'views':
return row.views
case 'downloads':
return row.downloads
case 'revenue':
return row.revenue
case 'playtime':
return row.playtime
default:
return isAnalyticsTableBreakdownColumnKey(key) ? String(row[key] ?? '') : ''
}
}
function getAnalyticsTableCsvHeaderLabel(
column: TableColumn<AnalyticsTableColumnKey>,
formatMessage: FormatMessage,
): string {
if (column.key === 'playtime') {
return formatMessage(analyticsTableMessages.playtimeSecondsHeader)
}
return column.label ?? column.key
}
function escapeAnalyticsTableCsvField(value: string | number): string {
const stringValue = String(value)
if (
stringValue.includes(',') ||
stringValue.includes('"') ||
stringValue.includes('\n') ||
stringValue.includes('\r')
) {
return `"${stringValue.replace(/"/g, '""')}"`
}
return stringValue
}
function formatAnalyticsTableCsvFilenameDate(date: Date): string {
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
function getAnalyticsTableCsvFilenameDateRange(
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
formatMessage: FormatMessage,
): string {
const timeRange = fetchRequest?.time_range
if (!timeRange) {
return formatMessage(analyticsTableMessages.csvSelectedRange)
}
const start = new Date(timeRange.start)
const end = new Date(timeRange.end)
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
return formatMessage(analyticsTableMessages.csvSelectedRange)
}
const startLabel = formatAnalyticsTableCsvFilenameDate(start)
const endLabel = formatAnalyticsTableCsvFilenameDate(end)
return startLabel === endLabel
? startLabel
: formatMessage(analyticsTableMessages.csvDateRange, {
start: startLabel,
end: endLabel,
})
}
function sanitizeAnalyticsTableCsvFilename(value: string): string {
return value
.replace(/[<>:"/\\|?*]/g, '')
.replace(/\s+/g, ' ')
.trim()
}
@@ -0,0 +1,65 @@
import type { AnalyticsGroupByPreset } from '~/providers/analytics/analytics'
import {
analyticsStatCardMessages,
analyticsTableMessages,
formatAnalyticsGroupByLabel,
type FormatMessage,
} from '../analytics-messages'
const SECONDS_PER_MINUTE = 60
const SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE
export function getAnalyticsTableGroupByLabel(
groupBy: AnalyticsGroupByPreset,
formatMessage: FormatMessage,
): string {
return formatAnalyticsGroupByLabel(groupBy, formatMessage)
}
export function formatAnalyticsTableInteger(
formatNumber: (value: number) => string,
value: number,
): string {
return formatNumber(Math.round(value))
}
export function formatAnalyticsTableRevenue(
formatter: Intl.NumberFormat,
value: number,
formatMessage: FormatMessage,
): string {
const rounded = Math.round(value * 100) / 100
return formatMessage(analyticsStatCardMessages.revenueValue, {
value: formatter.format(rounded),
})
}
export function formatAnalyticsTableCompactPlaytime(
value: number,
formatMessage: FormatMessage,
): string {
const totalSeconds = Math.max(0, Math.round(value))
return formatMessage(analyticsStatCardMessages.playtimeHours, {
hours: (totalSeconds / SECONDS_PER_HOUR).toLocaleString(undefined, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}),
})
}
export function formatAnalyticsTableFullPlaytime(
value: number,
formatMessage: FormatMessage,
): string {
const totalMinutes = Math.max(0, Math.round(value / SECONDS_PER_MINUTE))
const days = Math.floor(totalMinutes / (24 * 60))
const hours = Math.floor((totalMinutes % (24 * 60)) / 60)
const minutes = totalMinutes % 60
return [
formatMessage(analyticsTableMessages.durationDays, { count: days }),
formatMessage(analyticsTableMessages.durationHours, { count: hours }),
formatMessage(analyticsTableMessages.durationMinutes, { count: minutes }),
].join(', ')
}
@@ -0,0 +1,286 @@
import type { Labrinth } from '@modrinth/api-client'
import type {
AnalyticsBreakdownPreset,
AnalyticsDashboardStat,
} from '~/providers/analytics/analytics'
import {
formatBreakdownLabel,
formatBucketEndLabel,
getSliceBucketRange,
getSliceCount,
} from '../analytics-chart/analytics-chart-utils'
import type { FormatMessage } from '../analytics-messages'
import {
ALL_BREAKDOWN_VALUE,
COMBINED_BREAKDOWN_LABEL_SEPARATOR,
getAnalyticsBreakdownDatasetId,
getAnalyticsBreakdownKey,
getAnalyticsBreakdownValues,
} from '../breakdown'
import { getAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
import type {
AnalyticsTableBreakdownDisplayValues,
AnalyticsTableBreakdownPreset,
AnalyticsTableMode,
AnalyticsTableRow,
} from './analytics-table-types'
const ALL_PROJECTS_BREAKDOWN_VALUE = 'all'
type BuildAnalyticsTableRowsOptions = {
mode: AnalyticsTableMode
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null
timeSlices: Labrinth.Analytics.v3.TimeSlice[]
selectedBreakdowns: readonly AnalyticsTableBreakdownPreset[]
selectedProjectIds: ReadonlySet<string>
relevantStats: ReadonlySet<AnalyticsDashboardStat>
projectNamesById: ReadonlyMap<string, string>
getVersionDisplayName: (versionId: string) => string
getVersionProjectName: (versionId: string) => string | undefined
showTimeInBucketLabel: boolean
showYearInBucketLabel: boolean
formatMessage: FormatMessage
}
export function buildAnalyticsTableRows({
mode,
fetchRequest,
timeSlices,
selectedBreakdowns,
selectedProjectIds,
relevantStats,
projectNamesById,
getVersionDisplayName,
getVersionProjectName,
showTimeInBucketLabel,
showYearInBucketLabel,
formatMessage,
}: BuildAnalyticsTableRowsOptions): AnalyticsTableRow[] {
if (!fetchRequest || selectedProjectIds.size === 0) {
return []
}
const timeRange = fetchRequest.time_range
const sliceCount = getSliceCount(timeRange, timeSlices.length)
const includeDate = mode === 'date_breakdown'
const breakdownDisplayValues = new Map<string, string>()
const projectDisplayValues = new Map<string, string>()
const nextRows = new Map<string, AnalyticsTableRow>()
const bucketLabelsBySliceIndex = new Map<number, { date: string; dateMs: number }>()
function getBreakdownDisplayValue(
breakdownValue: string,
breakdown: AnalyticsTableBreakdownPreset,
) {
const key = `${breakdown}:${breakdownValue}`
let displayValue = breakdownDisplayValues.get(key)
if (displayValue === undefined) {
displayValue = formatAnalyticsTableBreakdownDisplayValue(
breakdownValue,
breakdown,
projectNamesById,
getVersionDisplayName,
formatMessage,
)
breakdownDisplayValues.set(key, displayValue)
}
return displayValue
}
function getProjectDisplayValueForBreakdownValues(breakdownValues: readonly string[]) {
const versionBreakdownIndex = selectedBreakdowns.indexOf('version_id')
if (versionBreakdownIndex === -1 || selectedBreakdowns.includes('project')) {
return ''
}
const versionId = breakdownValues[versionBreakdownIndex]
if (!versionId) {
return ''
}
let displayValue = projectDisplayValues.get(versionId)
if (displayValue === undefined) {
displayValue = getVersionProjectName(versionId) ?? ''
projectDisplayValues.set(versionId, displayValue)
}
return displayValue
}
function getBreakdownDisplays(breakdownValues: readonly string[]) {
const displays: AnalyticsTableBreakdownDisplayValues = {}
selectedBreakdowns.forEach((breakdown, index) => {
displays[breakdown] = getBreakdownDisplayValue(breakdownValues[index] ?? '', breakdown)
})
return displays
}
function getCombinedBreakdownDisplay(displays: AnalyticsTableBreakdownDisplayValues) {
return selectedBreakdowns
.map((breakdown) => displays[breakdown])
.filter((displayValue): displayValue is string => Boolean(displayValue))
.join(COMBINED_BREAKDOWN_LABEL_SEPARATOR)
}
function getBucketLabel(sliceIndex: number) {
let bucketLabel = bucketLabelsBySliceIndex.get(sliceIndex)
if (!bucketLabel) {
const bucketRange = getSliceBucketRange(timeRange, sliceCount, sliceIndex)
bucketLabel = {
date: formatBucketEndLabel(bucketRange.end, showTimeInBucketLabel, showYearInBucketLabel),
dateMs: bucketRange.end.getTime(),
}
bucketLabelsBySliceIndex.set(sliceIndex, bucketLabel)
}
return bucketLabel
}
function createRow(
rowId: string,
breakdownValues: readonly string[],
bucketLabel?: { date: string; dateMs: number },
) {
const breakdownKey =
breakdownValues.length === 0
? ALL_PROJECTS_BREAKDOWN_VALUE
: getAnalyticsBreakdownKey(breakdownValues)
const breakdownDisplays = getBreakdownDisplays(breakdownValues)
const row: AnalyticsTableRow = {
id: rowId,
date: bucketLabel?.date ?? '',
dateMs: bucketLabel?.dateMs ?? 0,
project: getProjectDisplayValueForBreakdownValues(breakdownValues),
breakdown: breakdownKey,
breakdownValues: Object.fromEntries(
selectedBreakdowns.map((breakdown, index) => [breakdown, breakdownValues[index] ?? '']),
) as AnalyticsTableBreakdownDisplayValues,
breakdownDisplays,
graphDatasetId: getAnalyticsTableGraphDatasetId(breakdownValues, selectedBreakdowns),
breakdownDisplay: getCombinedBreakdownDisplay(breakdownDisplays),
views: 0,
downloads: 0,
revenue: 0,
playtime: 0,
}
for (const breakdown of selectedBreakdowns) {
row[getAnalyticsTableBreakdownColumnKey(breakdown)] = breakdownDisplays[breakdown] ?? ''
}
nextRows.set(rowId, row)
return row
}
if (!includeDate && selectedBreakdowns.length === 0) {
createRow(ALL_PROJECTS_BREAKDOWN_VALUE, [])
}
if (!includeDate && selectedBreakdowns.length === 1 && selectedBreakdowns[0] === 'project') {
for (const projectId of selectedProjectIds) {
createRow(projectId, [projectId])
}
}
timeSlices.forEach((slice, sliceIndex) => {
const bucketLabel = includeDate ? getBucketLabel(sliceIndex) : undefined
for (const point of slice) {
if (!isProjectAnalyticsPoint(point)) {
continue
}
if (!selectedProjectIds.has(point.source_project)) {
continue
}
const pointStat = getAnalyticsTableStatForMetric(point.metric_kind)
if (!pointStat || !relevantStats.has(pointStat)) {
continue
}
const breakdownValues =
selectedBreakdowns.length === 0
? []
: getAnalyticsBreakdownValues(point, selectedBreakdowns, formatMessage)
if (breakdownValues.some((breakdownValue) => breakdownValue === ALL_BREAKDOWN_VALUE)) {
continue
}
const nextBucketLabel = includeDate ? (bucketLabel ?? getBucketLabel(sliceIndex)) : undefined
const breakdownKey =
breakdownValues.length === 0
? ALL_PROJECTS_BREAKDOWN_VALUE
: getAnalyticsBreakdownKey(breakdownValues)
const rowId = includeDate ? `${nextBucketLabel?.dateMs ?? 0}::${breakdownKey}` : breakdownKey
const row = nextRows.get(rowId) ?? createRow(rowId, breakdownValues, nextBucketLabel)
addAnalyticsMetricToTableRow(row, point)
}
})
return Array.from(nextRows.values())
}
function isProjectAnalyticsPoint(
point: Labrinth.Analytics.v3.AnalyticsData,
): point is Labrinth.Analytics.v3.ProjectAnalytics {
return 'source_project' in point
}
function addAnalyticsMetricToTableRow(
row: AnalyticsTableRow,
point: Labrinth.Analytics.v3.ProjectAnalytics,
) {
switch (point.metric_kind) {
case 'views':
row.views += point.views
break
case 'downloads':
row.downloads += point.downloads
break
case 'playtime':
row.playtime += point.seconds
break
case 'revenue': {
const parsed = Number.parseFloat(point.revenue)
row.revenue += Number.isFinite(parsed) ? parsed : 0
break
}
}
}
function getAnalyticsTableStatForMetric(
metricKind: Labrinth.Analytics.v3.ProjectAnalytics['metric_kind'],
): AnalyticsDashboardStat | null {
switch (metricKind) {
case 'views':
case 'downloads':
case 'revenue':
case 'playtime':
return metricKind
default:
return null
}
}
function getAnalyticsTableGraphDatasetId(
breakdownValues: readonly string[],
selectedBreakdowns: readonly AnalyticsBreakdownPreset[],
): string {
return getAnalyticsBreakdownDatasetId(breakdownValues, selectedBreakdowns)
}
function formatAnalyticsTableBreakdownDisplayValue(
value: string,
breakdown: AnalyticsTableBreakdownPreset,
projectNamesById: ReadonlyMap<string, string>,
getVersionDisplayName: (versionId: string) => string,
formatMessage: FormatMessage,
): string {
if (breakdown === 'project') {
return projectNamesById.get(value) ?? value
}
return formatBreakdownLabel(value, breakdown, getVersionDisplayName, formatMessage)
}
@@ -0,0 +1,47 @@
import type { TableColumn } from '@modrinth/ui'
import { isAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
import type { AnalyticsTableColumnKey, AnalyticsTableRow } from './analytics-table-types'
const SEARCHABLE_COLUMN_KEYS = new Set<AnalyticsTableColumnKey>(['date', 'project'])
export function getAnalyticsTableSearchableColumns(
columns: TableColumn<AnalyticsTableColumnKey>[],
): TableColumn<AnalyticsTableColumnKey>[] {
return columns.filter(
(column) =>
SEARCHABLE_COLUMN_KEYS.has(column.key) || isAnalyticsTableBreakdownColumnKey(column.key),
)
}
export function filterAnalyticsTableRowsBySearch(
rows: AnalyticsTableRow[],
searchableColumns: TableColumn<AnalyticsTableColumnKey>[],
query: string,
): AnalyticsTableRow[] {
if (!query || searchableColumns.length === 0) {
return rows
}
return rows.filter((row) =>
searchableColumns.some((column) =>
String(getAnalyticsTableSearchableCellValue(row, column.key)).toLowerCase().includes(query),
),
)
}
function getAnalyticsTableSearchableCellValue(
row: AnalyticsTableRow,
key: AnalyticsTableColumnKey,
): string {
switch (key) {
case 'date':
return row.date
case 'project':
return row.project
case 'breakdown':
return row.breakdownDisplay
default:
return isAnalyticsTableBreakdownColumnKey(key) ? String(row[key] ?? '') : ''
}
}
@@ -0,0 +1,109 @@
import type { TableColumn } from '@modrinth/ui'
import type { LocationQuery } from 'vue-router'
import {
buildAnalyticsTableSortRouteQuery,
readAnalyticsTableSortState,
} from '~/components/analytics-dashboard/analytics-route-query'
import type { AnalyticsDashboardStat } from '~/providers/analytics/analytics'
import { isAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
import {
getAnalyticsTableDefaultSortColumn,
getAnalyticsTableDefaultSortDirection,
} from './analytics-table-sorting'
import type {
AnalyticsTableColumnKey,
AnalyticsTableSortDirectionValue,
AnalyticsTableSortState,
} from './analytics-table-types'
type GetDefaultAnalyticsTableSortStateOptions = {
columns: TableColumn<AnalyticsTableColumnKey>[]
showGraphDatasetSelection: boolean
activeStat: AnalyticsDashboardStat
}
export function getRouteAnalyticsTableSortState(
query: LocationQuery,
columns: TableColumn<AnalyticsTableColumnKey>[],
defaultSortOptions: GetDefaultAnalyticsTableSortStateOptions,
): AnalyticsTableSortState {
return getAvailableAnalyticsTableSortState(
readAnalyticsTableSortState(query, getDefaultAnalyticsTableSortState(defaultSortOptions)),
columns,
defaultSortOptions,
)
}
export function getAvailableAnalyticsTableSortState(
state: AnalyticsTableSortState,
columns: TableColumn<AnalyticsTableColumnKey>[],
defaultSortOptions: GetDefaultAnalyticsTableSortStateOptions,
): AnalyticsTableSortState {
const availableColumns = new Set(columns.map((column) => column.key))
if (state.sortColumn && availableColumns.has(state.sortColumn)) {
return state
}
if (state.sortColumn === 'breakdown') {
const firstBreakdownColumn = columns.find((column) =>
isAnalyticsTableBreakdownColumnKey(column.key),
)
if (firstBreakdownColumn) {
return {
sortColumn: firstBreakdownColumn.key,
sortDirection: state.sortDirection,
}
}
}
return getDefaultAnalyticsTableSortState(defaultSortOptions)
}
export function getDefaultAnalyticsTableSortState({
columns,
showGraphDatasetSelection,
activeStat,
}: GetDefaultAnalyticsTableSortStateOptions): AnalyticsTableSortState {
const nextSortColumn = getAnalyticsTableDefaultSortColumn(
columns,
showGraphDatasetSelection,
activeStat,
)
return {
sortColumn: nextSortColumn,
sortDirection: getAnalyticsTableDefaultSortDirection(nextSortColumn, columns),
}
}
export function areAnalyticsTableSortStatesEqual(
left: AnalyticsTableSortState,
right: AnalyticsTableSortState,
): boolean {
return left.sortColumn === right.sortColumn && left.sortDirection === right.sortDirection
}
export function buildSyncedAnalyticsTableSortRouteQuery(
query: LocationQuery,
sortState: AnalyticsTableSortState,
columns: TableColumn<AnalyticsTableColumnKey>[],
defaultSortOptions: GetDefaultAnalyticsTableSortStateOptions,
) {
const nextSortState = getAvailableAnalyticsTableSortState(sortState, columns, defaultSortOptions)
return buildAnalyticsTableSortRouteQuery(
query,
nextSortState,
getDefaultAnalyticsTableSortState(defaultSortOptions),
)
}
export function toAnalyticsTableSortState(
sortColumn: AnalyticsTableColumnKey | undefined,
sortDirection: AnalyticsTableSortDirectionValue,
): AnalyticsTableSortState {
return {
sortColumn,
sortDirection,
}
}
@@ -0,0 +1,210 @@
import type { TableColumn } from '@modrinth/ui'
import type { AnalyticsDashboardStat } from '~/providers/analytics/analytics'
import { isAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
import type {
AnalyticsTableColumnKey,
AnalyticsTableRow,
AnalyticsTableSortDirectionValue,
} from './analytics-table-types'
export function sortAnalyticsTableRows(
rows: AnalyticsTableRow[],
sortColumn: AnalyticsTableColumnKey | undefined,
sortDirection: AnalyticsTableSortDirectionValue,
sortCollator: Intl.Collator,
): AnalyticsTableRow[] {
const nextRows = [...rows]
if (!sortColumn) {
return nextRows
}
const directionFactor = sortDirection === 'asc' ? 1 : -1
nextRows.sort(getAnalyticsTableRowComparator(sortColumn, directionFactor, sortCollator))
return nextRows
}
export function getAnalyticsTableDefaultSortColumn(
nextColumns: TableColumn<AnalyticsTableColumnKey>[],
showGraphDatasetSelection: boolean,
activeStat: AnalyticsDashboardStat,
): AnalyticsTableColumnKey | undefined {
const availableColumns = new Set(nextColumns.map((column) => column.key))
if (availableColumns.has('date')) {
return 'date'
}
if (showGraphDatasetSelection && availableColumns.has(activeStat)) {
return activeStat
}
if (availableColumns.has('downloads')) {
return 'downloads'
}
return nextColumns[0]?.key
}
export function getAnalyticsTableDefaultSortDirection(
column: AnalyticsTableColumnKey | undefined,
nextColumns: TableColumn<AnalyticsTableColumnKey>[],
): AnalyticsTableSortDirectionValue {
return nextColumns.find((nextColumn) => nextColumn.key === column)?.defaultSortDirection ?? 'asc'
}
export function getAnalyticsTableMetricSortedGraphDatasetIds(
rows: AnalyticsTableRow[],
sortColumn: AnalyticsTableColumnKey | undefined,
sortCollator: Intl.Collator,
): string[] {
const metricColumn = getAnalyticsTableMetricSortColumn(sortColumn)
if (!metricColumn) {
return []
}
const totalsByGraphDatasetId = new Map<string, number>()
const labelsByGraphDatasetId = new Map<string, string>()
for (const row of rows) {
totalsByGraphDatasetId.set(
row.graphDatasetId,
(totalsByGraphDatasetId.get(row.graphDatasetId) ?? 0) + row[metricColumn],
)
if (!labelsByGraphDatasetId.has(row.graphDatasetId)) {
labelsByGraphDatasetId.set(row.graphDatasetId, row.breakdownDisplay)
}
}
return Array.from(totalsByGraphDatasetId.keys()).sort((left, right) => {
const totalDifference =
(totalsByGraphDatasetId.get(right) ?? 0) - (totalsByGraphDatasetId.get(left) ?? 0)
return (
totalDifference ||
sortCollator.compare(
labelsByGraphDatasetId.get(left) ?? left,
labelsByGraphDatasetId.get(right) ?? right,
) ||
left.localeCompare(right)
)
})
}
export function getAnalyticsTableMetricSortColumn(
column: AnalyticsTableColumnKey | undefined,
): AnalyticsDashboardStat | null {
switch (column) {
case 'views':
case 'downloads':
case 'revenue':
case 'playtime':
return column
default:
return null
}
}
function getAnalyticsTableRowComparator(
column: AnalyticsTableColumnKey,
directionFactor: number,
sortCollator: Intl.Collator,
): (left: AnalyticsTableRow, right: AnalyticsTableRow) => number {
switch (column) {
case 'date':
return (left, right) =>
compareAnalyticsTableRows(
left,
right,
left.dateMs - right.dateMs,
directionFactor,
sortCollator,
)
case 'project':
return (left, right) =>
compareAnalyticsTableRows(
left,
right,
sortCollator.compare(left.project, right.project),
directionFactor,
sortCollator,
)
case 'breakdown':
return (left, right) =>
compareAnalyticsTableRows(
left,
right,
sortCollator.compare(left.breakdownDisplay, right.breakdownDisplay),
directionFactor,
sortCollator,
)
case 'views':
return (left, right) =>
compareAnalyticsTableRows(
left,
right,
left.views - right.views,
directionFactor,
sortCollator,
)
case 'downloads':
return (left, right) =>
compareAnalyticsTableRows(
left,
right,
left.downloads - right.downloads,
directionFactor,
sortCollator,
)
case 'revenue':
return (left, right) =>
compareAnalyticsTableRows(
left,
right,
left.revenue - right.revenue,
directionFactor,
sortCollator,
)
case 'playtime':
return (left, right) =>
compareAnalyticsTableRows(
left,
right,
left.playtime - right.playtime,
directionFactor,
sortCollator,
)
default:
if (isAnalyticsTableBreakdownColumnKey(column)) {
return (left, right) =>
compareAnalyticsTableRows(
left,
right,
sortCollator.compare(String(left[column] ?? ''), String(right[column] ?? '')),
directionFactor,
sortCollator,
)
}
return () => 0
}
}
function compareAnalyticsTableRows(
left: AnalyticsTableRow,
right: AnalyticsTableRow,
primaryResult: number,
directionFactor: number,
sortCollator: Intl.Collator,
): number {
if (primaryResult !== 0) {
return primaryResult * directionFactor
}
const dateResult = left.dateMs - right.dateMs
if (dateResult !== 0) {
return dateResult * directionFactor
}
return sortCollator.compare(left.breakdown, right.breakdown) * directionFactor
}
@@ -0,0 +1,43 @@
import type {
AnalyticsBreakdownPreset,
AnalyticsTableSortColumn,
AnalyticsTableSortDirection,
} from '~/providers/analytics/analytics'
export type AnalyticsTableMode = 'date_breakdown' | 'breakdown_only'
export type AnalyticsTableBreakdownPreset = Exclude<AnalyticsBreakdownPreset, 'none'>
export type AnalyticsTableBreakdownColumnKey = `breakdown_${AnalyticsTableBreakdownPreset}`
export type AnalyticsTableBreakdownDisplayValues = Partial<
Record<AnalyticsTableBreakdownPreset, string>
>
export type AnalyticsTableColumnKey = AnalyticsTableSortColumn
export type AnalyticsTableSortState = {
sortColumn: AnalyticsTableColumnKey | undefined
sortDirection: AnalyticsTableSortDirection
}
export type AnalyticsTableSortDirectionValue = AnalyticsTableSortDirection
export type AnalyticsTableRow = {
[key: string]: string | number | AnalyticsTableBreakdownDisplayValues
id: string
date: string
dateMs: number
project: string
breakdown: string
breakdownValues: AnalyticsTableBreakdownDisplayValues
breakdownDisplays: AnalyticsTableBreakdownDisplayValues
graphDatasetId: string
breakdownDisplay: string
views: number
downloads: number
revenue: number
playtime: number
}
export type AnalyticsTableDisplayedRowsCache = {
generation: number
mode: AnalyticsTableMode
sortColumn: AnalyticsTableColumnKey | undefined
sortDirection: AnalyticsTableSortDirectionValue
rows: AnalyticsTableRow[]
}
@@ -0,0 +1,655 @@
<template>
<div class="relative overflow-hidden rounded-2xl">
<AnalyticsLoadingBar :loading="isDataLoading" />
<Table
v-model:selected-ids="tableSelectedGraphDatasetIds"
:sort-column="displayedSortColumn"
:sort-direction="displayedSortDirection"
:columns="columns"
:data="paginatedRows"
row-key="id"
selection-key="graphDatasetId"
:selection-ids="filteredSelectableGraphDatasetIds"
:show-selection="showGraphDatasetSelection"
table-min-width="44rem"
virtualized
:virtual-row-height="56"
@sort="applyRequestedSort"
>
<template #header>
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div class="text-xl font-semibold text-contrast">
{{ formatMessage(analyticsBreakdownMessages.breakdown) }}
</div>
<div class="flex w-full flex-wrap items-center gap-2 md:w-auto">
<StyledInput
v-model="searchQuery"
:icon="SearchIcon"
:placeholder="formatMessage(analyticsTableMessages.searchPlaceholder)"
clearable
wrapper-class="w-full sm:w-64"
@focusin="selectSearchInputText"
/>
<ButtonStyled>
<OverflowMenu
class="!shadow-none"
:options="csvExportOptions"
:disabled="isDataLoading || filteredRows.length === 0"
>
<DownloadIcon />
{{ formatMessage(analyticsTableMessages.exportCsvButton) }}
<DropdownIcon />
<template #cumulative-csv>
{{ formatMessage(analyticsTableMessages.cumulativeCsv) }}
</template>
<template #grouped-csv>
{{ formatMessage(analyticsTableMessages.groupedCsv, { groupBy: groupByLabel }) }}
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
</template>
<template #cell-date="{ value }">
<span class="text-primary">{{ value }}</span>
</template>
<template #cell-breakdown_project="{ value }">
<span class="text-primary">{{ value }}</span>
</template>
<template #cell-breakdown_country="{ value }">
<span class="text-primary">{{ value }}</span>
</template>
<template #cell-breakdown_monetization="{ value }">
<span class="text-primary">{{ value }}</span>
</template>
<template #cell-breakdown_user_agent="{ value }">
<span class="text-primary">{{ value }}</span>
</template>
<template #cell-breakdown_download_reason="{ value }">
<span class="text-primary">{{ value }}</span>
</template>
<template #cell-breakdown_version_id="{ value }">
<span class="text-primary">{{ value }}</span>
</template>
<template #cell-breakdown_loader="{ value }">
<span class="text-primary">{{ value }}</span>
</template>
<template #cell-breakdown_game_version="{ value }">
<span class="text-primary">{{ value }}</span>
</template>
<template #cell-project="{ value }">
<span class="text-primary">{{ value }}</span>
</template>
<template #cell-views="{ row }">
<span>{{ formatInteger(row.views) }}</span>
</template>
<template #cell-downloads="{ row }">
<span>{{ formatInteger(row.downloads) }}</span>
</template>
<template #cell-revenue="{ row }">
<span>{{ formatRevenue(row.revenue) }}</span>
</template>
<template #cell-playtime="{ row }">
<span v-tooltip="formatFullPlaytime(row.playtime)">
{{ formatCompactPlaytime(row.playtime) }}
</span>
</template>
<template #empty-state>
<div class="flex h-64 items-center justify-center text-secondary">
{{ !isDataLoading ? emptyTableMessage : '' }}
</div>
</template>
</Table>
<div
v-if="filteredRows.length > PAGE_SIZE"
class="mt-3 flex flex-wrap items-center justify-between gap-3 px-1 text-sm text-secondary"
>
<span>
{{
formatMessage(analyticsTableMessages.paginationSummary, {
start: visibleRowStart,
end: visibleRowEnd,
total: filteredRows.length,
})
}}
</span>
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
</div>
<div v-if="isDataLoading" class="absolute inset-0 z-10 overflow-hidden rounded-xl">
<div class="absolute inset-0 bg-surface-3 opacity-50" />
<div class="absolute inset-0 backdrop-blur-[4px]" />
<div class="absolute inset-0 flex h-full max-h-[500px] items-center justify-center pt-10">
<div class="inline-flex items-center gap-2 text-lg font-semibold text-primary opacity-100">
<span>{{ formatMessage(analyticsMessages.fetchingResults) }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { DownloadIcon, DropdownIcon, SearchIcon } from '@modrinth/assets'
import {
ButtonStyled,
OverflowMenu,
type OverflowMenuOption,
Pagination,
StyledInput,
Table,
useFormatNumber,
useVIntl,
} from '@modrinth/ui'
import type { LocationQuery } from 'vue-router'
import {
hasAnalyticsTableSortQuery,
hasAnalyticsTableSortRouteChange,
readAnalyticsTableSortState,
} from '~/components/analytics-dashboard/analytics-route-query'
import {
doesProjectStatusMatchFilters,
injectAnalyticsDashboardContext,
} from '~/providers/analytics/analytics'
import {
isTimeRelevantForGroupBy,
isYearRelevantForTimeRange,
} from '../analytics-chart/analytics-chart-utils.ts'
import {
analyticsBreakdownMessages,
analyticsMessages,
analyticsTableMessages,
} from '../analytics-messages.ts'
import AnalyticsLoadingBar from '../AnalyticsLoadingBar.vue'
import {
buildAnalyticsTableColumns,
getAnalyticsTableBreakdownColumnLabel,
} from './analytics-table-columns.ts'
import {
buildAnalyticsTableCsvContent,
downloadAnalyticsTableCsv,
getAnalyticsTableCsvFilename,
} from './analytics-table-csv-export.ts'
import {
formatAnalyticsTableCompactPlaytime,
formatAnalyticsTableFullPlaytime,
formatAnalyticsTableInteger,
formatAnalyticsTableRevenue,
getAnalyticsTableGroupByLabel,
} from './analytics-table-formatting.ts'
import { buildAnalyticsTableRows } from './analytics-table-row-builder.ts'
import {
filterAnalyticsTableRowsBySearch,
getAnalyticsTableSearchableColumns,
} from './analytics-table-search-filtering.ts'
import {
areAnalyticsTableSortStatesEqual,
buildSyncedAnalyticsTableSortRouteQuery,
getRouteAnalyticsTableSortState,
toAnalyticsTableSortState,
} from './analytics-table-sort-route.ts'
import { sortAnalyticsTableRows } from './analytics-table-sorting.ts'
import type {
AnalyticsTableColumnKey,
AnalyticsTableMode,
AnalyticsTableSortDirectionValue,
} from './analytics-table-types.ts'
import { useAnalyticsTableGraphSelection } from './use-analytics-table-graph-selection.ts'
import { useAnalyticsTablePagination } from './use-analytics-table-pagination.ts'
import { useAnalyticsTableRowCache } from './use-analytics-table-row-cache.ts'
const {
hasProjectContext,
projects,
selectedProjectIds: currentSelectedProjectIds,
selectedBreakdowns: currentSelectedBreakdowns,
displayedSelectedProjectIds: selectedProjectIds,
displayedSelectedGroupBy: selectedGroupBy,
displayedSelectedBreakdowns: selectedBreakdowns,
displayedSelectedFilters: selectedFilters,
displayedFetchRequest: fetchRequest,
displayedTimeSlices: timeSlices,
activeStat,
hasExplicitGraphDatasetSelection,
isGraphDatasetSelectionActive,
selectedGraphDatasetIds,
defaultGraphDatasetIds,
topGraphDatasetIds,
queryResetToken,
getRelevantAnalyticsDashboardStats,
isLoading,
versionNumbersById,
versionProjectNamesById,
getVersionDisplayName,
getVersionProjectName,
} = injectAnalyticsDashboardContext()
const formatNumber = useFormatNumber()
const { formatMessage } = useVIntl()
const isDataLoading = computed(() => isLoading.value)
const route = useRoute()
const router = useRouter()
const initialTableSortState = readAnalyticsTableSortState(route.query, {
sortColumn: 'date',
sortDirection: 'desc',
})
const tableMode = ref<AnalyticsTableMode>('breakdown_only')
const sortColumn = ref<AnalyticsTableColumnKey | undefined>(initialTableSortState.sortColumn)
const sortDirection = ref<AnalyticsTableSortDirectionValue>(initialTableSortState.sortDirection)
const PAGE_SIZE = 500
const GRAPH_DATASET_SELECTION_LIMIT = 8
const INACTIVE_MODE_WARMUP_POINT_LIMIT = 12000
const searchQuery = ref('')
const sortCollator = new Intl.Collator(undefined, { sensitivity: 'base' })
const selectedProjectIdSet = computed(
() =>
new Set(
projects.value
.filter(
(project) =>
selectedProjectIds.value.includes(project.id) &&
doesProjectStatusMatchFilters(project.status, selectedFilters.value),
)
.map((project) => project.id),
),
)
const selectedBreakdownSet = computed(() => new Set(selectedBreakdowns.value))
const showBreakdownColumn = computed(() => selectedBreakdowns.value.length > 0)
const showGraphDatasetSelection = computed(() =>
selectedBreakdowns.value.length === 1 && selectedBreakdowns.value[0] === 'project'
? selectedProjectIdSet.value.size > 1
: selectedBreakdowns.value.length > 0,
)
const showProjectVersionProjectColumn = computed(
() =>
selectedBreakdownSet.value.has('version_id') &&
!selectedBreakdownSet.value.has('project') &&
selectedProjectIdSet.value.size > 1,
)
const includeDateColumn = computed(
() =>
selectedBreakdowns.value.length === 0 ||
(!showGraphDatasetSelection.value && tableMode.value === 'date_breakdown'),
)
const activeTableMode = computed<AnalyticsTableMode>(() =>
selectedBreakdowns.value.length === 0
? 'date_breakdown'
: showGraphDatasetSelection.value
? 'breakdown_only'
: tableMode.value,
)
const displayedIncludeDateColumn = computed(() =>
selectedBreakdowns.value.length === 0
? true
: showGraphDatasetSelection.value
? false
: displayedTableMode.value === 'date_breakdown',
)
const groupByLabel = computed(() =>
getAnalyticsTableGroupByLabel(selectedGroupBy.value, formatMessage),
)
const csvExportOptions = computed<OverflowMenuOption[]>(() => {
if (showGraphDatasetSelection.value) {
return [
{
id: 'cumulative-csv',
action: () => downloadCsv('breakdown_only'),
},
{
id: 'grouped-csv',
action: () => downloadCsv('date_breakdown'),
},
]
}
const mode = displayedTableMode.value
return [
{
id: mode === 'date_breakdown' ? 'grouped-csv' : 'cumulative-csv',
action: () => downloadCsv(mode),
},
]
})
const projectNamesById = computed(
() => new Map(projects.value.map((project) => [project.id, project.name])),
)
const hasAvailableProjects = computed(() => projects.value.length > 0)
const analyticsPointCount = computed(() =>
timeSlices.value.reduce((sum, slice) => sum + slice.length, 0),
)
const emptyTableMessage = computed(() => {
if (trimmedSearchQuery.value && sortedRows.value.length > 0) {
return formatMessage(analyticsTableMessages.noMatchingRows)
}
if (hasProjectContext.value) {
return formatMessage(analyticsMessages.noDataAvailableForAnalytics)
}
return hasAvailableProjects.value
? formatMessage(analyticsMessages.noDataAvailable)
: formatMessage(analyticsMessages.noProjectsAvailableForAnalytics)
})
const breakdownColumnLabel = computed(() =>
selectedBreakdowns.value.length === 1
? getAnalyticsTableBreakdownColumnLabel(selectedBreakdowns.value[0], formatMessage)
: formatMessage(analyticsBreakdownMessages.breakdown),
)
const relevantStats = computed(
() =>
new Set(getRelevantAnalyticsDashboardStats(selectedBreakdowns.value, selectedFilters.value)),
)
const showTimeInBucketLabel = computed(() => isTimeRelevantForGroupBy(selectedGroupBy.value))
const showYearInBucketLabel = computed(() => {
const nextFetchRequest = fetchRequest.value
return nextFetchRequest
? isYearRelevantForTimeRange(nextFetchRequest.time_range) || selectedGroupBy.value === 'year'
: false
})
function buildTableRows(mode: AnalyticsTableMode) {
return buildAnalyticsTableRows({
mode,
fetchRequest: fetchRequest.value,
timeSlices: timeSlices.value,
selectedBreakdowns: selectedBreakdowns.value,
selectedProjectIds: selectedProjectIdSet.value,
relevantStats: relevantStats.value,
projectNamesById: projectNamesById.value,
getVersionDisplayName,
getVersionProjectName,
showTimeInBucketLabel: showTimeInBucketLabel.value,
showYearInBucketLabel: showYearInBucketLabel.value,
formatMessage,
})
}
const columns = computed(() => buildColumns(displayedIncludeDateColumn.value))
const activeColumns = computed(() => buildColumns(includeDateColumn.value))
function buildColumns(includeDate: boolean) {
return buildAnalyticsTableColumns({
includeDate,
selectedBreakdowns: selectedBreakdowns.value,
selectedFilters: selectedFilters.value,
showBreakdownColumn: showBreakdownColumn.value,
showProjectVersionProjectColumn: showProjectVersionProjectColumn.value,
formatMessage,
getRelevantAnalyticsDashboardStats,
})
}
watch(
activeColumns,
(nextColumns) => {
applyRouteOrDefaultSort(nextColumns)
},
{ immediate: true },
)
function sortTableRows(rows: ReturnType<typeof buildTableRows>) {
return sortAnalyticsTableRows(rows, sortColumn.value, sortDirection.value, sortCollator)
}
const {
displayedTableMode,
displayedSortColumn,
displayedSortDirection,
displayedSortedRows,
invalidateTableCaches,
invalidateSortedCaches,
scheduleRowsForMode,
scheduleInactiveModeWarmup,
resortDisplayedRowsForCurrentSort,
getSortedRowsForMode,
} = useAnalyticsTableRowCache({
activeTableMode,
showBreakdownColumn,
analyticsPointCount,
sortColumn,
sortDirection,
buildRows: buildTableRows,
sortRows: sortTableRows,
inactiveModeWarmupPointLimit: INACTIVE_MODE_WARMUP_POINT_LIMIT,
})
const sortedRows = computed(() => {
return displayedSortedRows.value
})
const trimmedSearchQuery = computed(() => searchQuery.value.trim().toLowerCase())
const searchableColumns = computed(() => getAnalyticsTableSearchableColumns(columns.value))
const filteredRows = computed(() => {
if (!trimmedSearchQuery.value) {
return sortedRows.value
}
return filterAnalyticsTableRowsBySearch(
sortedRows.value,
searchableColumns.value,
trimmedSearchQuery.value,
)
})
watch(
[
fetchRequest,
timeSlices,
selectedProjectIds,
selectedGroupBy,
selectedBreakdowns,
selectedFilters,
projects,
versionNumbersById,
versionProjectNamesById,
],
() => {
invalidateTableCaches()
scheduleRowsForMode(activeTableMode.value)
scheduleInactiveModeWarmup()
},
{ immediate: true, flush: 'post' },
)
watch(activeTableMode, () => {
scheduleRowsForMode(activeTableMode.value)
scheduleInactiveModeWarmup()
})
watch(
() => route.query,
(nextQuery) => {
const nextSortState = getRouteTableSortState(nextQuery, activeColumns.value)
if (!areAnalyticsTableSortStatesEqual(getCurrentSortState(), nextSortState)) {
applyTableSortState(nextSortState)
return
}
syncTableSortRouteQuery()
},
)
watch([sortColumn, sortDirection], () => {
syncTableSortRouteQuery()
if (resortDisplayedRowsForCurrentSort()) {
scheduleInactiveModeWarmup()
return
}
invalidateSortedCaches()
scheduleRowsForMode(activeTableMode.value)
scheduleInactiveModeWarmup()
})
const { filteredSelectableGraphDatasetIds, tableSelectedGraphDatasetIds } =
useAnalyticsTableGraphSelection({
sortedRows,
filteredRows,
sortColumn,
showGraphDatasetSelection,
selectedGraphDatasetIds,
hasExplicitGraphDatasetSelection,
isGraphDatasetSelectionActive,
defaultGraphDatasetIds,
topGraphDatasetIds,
queryResetToken,
currentSelectedBreakdowns,
currentSelectedProjectIds,
activeStat,
sortCollator,
hasTableSortQuery: () => hasAnalyticsTableSortQuery(route.query),
applyActiveStatSort,
graphDatasetSelectionLimit: GRAPH_DATASET_SELECTION_LIMIT,
})
const { currentPage, pageCount, visibleRowStart, visibleRowEnd, paginatedRows, switchPage } =
useAnalyticsTablePagination({
filteredRows,
pageSize: PAGE_SIZE,
})
const revenueFormatter = computed(
() =>
new Intl.NumberFormat(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
)
function formatInteger(value: number): string {
return formatAnalyticsTableInteger(formatNumber, value)
}
function formatRevenue(value: number): string {
return formatAnalyticsTableRevenue(revenueFormatter.value, value, formatMessage)
}
function formatCompactPlaytime(value: number): string {
return formatAnalyticsTableCompactPlaytime(value, formatMessage)
}
function formatFullPlaytime(value: number): string {
return formatAnalyticsTableFullPlaytime(value, formatMessage)
}
function applyRouteOrDefaultSort(nextColumns = activeColumns.value) {
const nextSortState = getRouteTableSortState(route.query, nextColumns)
if (!areAnalyticsTableSortStatesEqual(getCurrentSortState(), nextSortState)) {
applyTableSortState(nextSortState)
}
syncTableSortRouteQuery()
}
function applyTableSortState(state: {
sortColumn: AnalyticsTableColumnKey | undefined
sortDirection: AnalyticsTableSortDirectionValue
}) {
sortColumn.value = state.sortColumn
sortDirection.value = state.sortDirection
}
function getRouteTableSortState(
query: LocationQuery,
nextColumns = activeColumns.value,
): {
sortColumn: AnalyticsTableColumnKey | undefined
sortDirection: AnalyticsTableSortDirectionValue
} {
return getRouteAnalyticsTableSortState(query, nextColumns, getDefaultSortOptions(nextColumns))
}
function getCurrentSortState() {
return toAnalyticsTableSortState(sortColumn.value, sortDirection.value)
}
function getDefaultSortOptions(nextColumns = activeColumns.value) {
return {
columns: nextColumns,
showGraphDatasetSelection: showGraphDatasetSelection.value,
activeStat: activeStat.value,
}
}
function syncTableSortRouteQuery() {
if (import.meta.server) {
return
}
const nextRouteQuery = buildSyncedAnalyticsTableSortRouteQuery(
route.query,
getCurrentSortState(),
activeColumns.value,
getDefaultSortOptions(),
)
if (!hasAnalyticsTableSortRouteChange(route.query, nextRouteQuery)) {
return
}
router.replace({
path: route.path,
query: nextRouteQuery,
})
}
function applyActiveStatSort() {
const availableColumns = new Set(activeColumns.value.map((column) => column.key))
if (!availableColumns.has(activeStat.value)) {
return
}
sortColumn.value = activeStat.value
sortDirection.value = 'desc'
}
function applyRequestedSort(column: string, direction: AnalyticsTableSortDirectionValue) {
sortColumn.value = column as AnalyticsTableColumnKey
sortDirection.value = direction
}
function selectSearchInputText(event: FocusEvent) {
const target = event.target
if (target instanceof HTMLInputElement) {
target.select()
}
}
function getCsvRows(mode: AnalyticsTableMode) {
const visibleColumns = getCsvColumns(mode)
return filterAnalyticsTableRowsBySearch(
getSortedRowsForMode(mode),
visibleColumns,
trimmedSearchQuery.value,
)
}
function getCsvColumns(mode: AnalyticsTableMode) {
return buildColumns(mode === 'date_breakdown' || !showBreakdownColumn.value)
}
function getCsvFilename(): string {
return getAnalyticsTableCsvFilename(breakdownColumnLabel.value, fetchRequest.value, formatMessage)
}
function downloadCsv(mode: AnalyticsTableMode) {
if (!import.meta.client) {
return
}
const csvRows = getCsvRows(mode)
if (csvRows.length === 0) {
return
}
const visibleColumns = getCsvColumns(mode)
const csvContent = buildAnalyticsTableCsvContent(csvRows, visibleColumns, formatMessage)
downloadAnalyticsTableCsv(getCsvFilename(), csvContent)
}
</script>
@@ -0,0 +1,191 @@
import type { ComputedRef, Ref, WritableComputedRef } from 'vue'
import { computed, watch } from 'vue'
import { areStringArraysEqual } from '~/components/analytics-dashboard/analytics-route-query'
import type {
AnalyticsDashboardStat,
AnalyticsSelectedBreakdowns,
} from '~/providers/analytics/analytics'
import { getAnalyticsTableMetricSortedGraphDatasetIds } from './analytics-table-sorting'
import type { AnalyticsTableColumnKey, AnalyticsTableRow } from './analytics-table-types'
type UseAnalyticsTableGraphSelectionOptions = {
sortedRows: ComputedRef<AnalyticsTableRow[]>
filteredRows: ComputedRef<AnalyticsTableRow[]>
sortColumn: Ref<AnalyticsTableColumnKey | undefined>
showGraphDatasetSelection: ComputedRef<boolean>
selectedGraphDatasetIds: Ref<string[]>
hasExplicitGraphDatasetSelection: Ref<boolean>
isGraphDatasetSelectionActive: Ref<boolean>
defaultGraphDatasetIds: Ref<string[]>
topGraphDatasetIds: Ref<string[]>
queryResetToken: Ref<number>
currentSelectedBreakdowns: Ref<AnalyticsSelectedBreakdowns>
currentSelectedProjectIds: Ref<string[]>
activeStat: Ref<AnalyticsDashboardStat>
sortCollator: Intl.Collator
hasTableSortQuery: () => boolean
applyActiveStatSort: () => void
graphDatasetSelectionLimit: number
}
export function useAnalyticsTableGraphSelection({
sortedRows,
filteredRows,
sortColumn,
showGraphDatasetSelection,
selectedGraphDatasetIds,
hasExplicitGraphDatasetSelection,
isGraphDatasetSelectionActive,
defaultGraphDatasetIds,
topGraphDatasetIds,
queryResetToken,
currentSelectedBreakdowns,
currentSelectedProjectIds,
activeStat,
sortCollator,
hasTableSortQuery,
applyActiveStatSort,
graphDatasetSelectionLimit,
}: UseAnalyticsTableGraphSelectionOptions): {
filteredSelectableGraphDatasetIds: ComputedRef<string[]>
tableSelectedGraphDatasetIds: WritableComputedRef<unknown[]>
} {
const selectableGraphDatasetIds = computed(() =>
getAnalyticsTableSelectableGraphDatasetIds(sortedRows.value),
)
const filteredSelectableGraphDatasetIds = computed(() =>
getAnalyticsTableSelectableGraphDatasetIds(filteredRows.value),
)
const sortedMetricGraphDatasetIds = computed(() =>
getAnalyticsTableMetricSortedGraphDatasetIds(sortedRows.value, sortColumn.value, sortCollator),
)
const defaultSelectedGraphDatasetIds = computed(() => {
const sortedMetricIds = sortedMetricGraphDatasetIds.value
const defaultIds =
sortedMetricIds.length > 0 ? sortedMetricIds : selectableGraphDatasetIds.value
return defaultIds.slice(0, graphDatasetSelectionLimit)
})
const tableSelectedGraphDatasetIds = computed<unknown[]>({
get: () => selectedGraphDatasetIds.value,
set: (ids) => {
const nextGraphDatasetIds = ids.filter((id): id is string => typeof id === 'string')
if (showGraphDatasetSelection.value && isDefaultGraphDatasetSelection(nextGraphDatasetIds)) {
setSelectedGraphDatasetIds(defaultSelectedGraphDatasetIds.value, false)
return
}
selectedGraphDatasetIds.value = nextGraphDatasetIds
hasExplicitGraphDatasetSelection.value = showGraphDatasetSelection.value
},
})
function setSelectedGraphDatasetIds(ids: string[], explicit: boolean) {
selectedGraphDatasetIds.value = ids
hasExplicitGraphDatasetSelection.value = explicit
}
function resetGraphDatasetSelection() {
setSelectedGraphDatasetIds([], false)
}
function isDefaultGraphDatasetSelection(ids: string[]) {
const defaultIds = defaultSelectedGraphDatasetIds.value
if (defaultIds.length === 0 || ids.length !== defaultIds.length) {
return false
}
const selectedIdSet = new Set(ids)
return defaultIds.every((id) => selectedIdSet.has(id))
}
watch(
[showGraphDatasetSelection, queryResetToken],
([nextShowSelection]) => {
isGraphDatasetSelectionActive.value = nextShowSelection
},
{ immediate: true },
)
watch(activeStat, () => {
if (!showGraphDatasetSelection.value) {
return
}
if (hasTableSortQuery()) {
return
}
applyActiveStatSort()
})
watch(
currentSelectedBreakdowns,
(nextBreakdowns, previousBreakdowns) => {
if (areStringArraysEqual([...nextBreakdowns], [...(previousBreakdowns ?? [])])) {
return
}
resetGraphDatasetSelection()
},
{ deep: true },
)
watch(
currentSelectedProjectIds,
(nextProjectIds, previousProjectIds) => {
if (areStringArraysEqual(nextProjectIds, previousProjectIds ?? [])) {
return
}
resetGraphDatasetSelection()
},
{ deep: true },
)
watch(
[defaultSelectedGraphDatasetIds, sortedMetricGraphDatasetIds, showGraphDatasetSelection],
([nextDefaultGraphDatasetIds, nextTopGraphDatasetIds, nextShowGraphDatasetSelection]) => {
defaultGraphDatasetIds.value = nextShowGraphDatasetSelection
? [...nextDefaultGraphDatasetIds]
: []
topGraphDatasetIds.value = nextShowGraphDatasetSelection ? [...nextTopGraphDatasetIds] : []
},
{ immediate: true },
)
watch(
[
defaultSelectedGraphDatasetIds,
showGraphDatasetSelection,
hasExplicitGraphDatasetSelection,
queryResetToken,
],
([nextDefaultGraphDatasetIds, nextShowGraphDatasetSelection, nextHasExplicitSelection]) => {
if (!nextShowGraphDatasetSelection) {
return
}
if (nextHasExplicitSelection) {
if (isDefaultGraphDatasetSelection(selectedGraphDatasetIds.value)) {
setSelectedGraphDatasetIds(nextDefaultGraphDatasetIds, false)
}
return
}
if (!areStringArraysEqual(selectedGraphDatasetIds.value, nextDefaultGraphDatasetIds)) {
setSelectedGraphDatasetIds(nextDefaultGraphDatasetIds, false)
}
},
{ immediate: true },
)
function getAnalyticsTableSelectableGraphDatasetIds(rows: AnalyticsTableRow[]): string[] {
return Array.from(new Set(rows.map((row) => row.graphDatasetId)))
}
return {
filteredSelectableGraphDatasetIds,
tableSelectedGraphDatasetIds,
}
}
@@ -0,0 +1,56 @@
import type { ComputedRef, Ref } from 'vue'
import { computed, ref, watch } from 'vue'
import type { AnalyticsTableRow } from './analytics-table-types'
type UseAnalyticsTablePaginationOptions = {
filteredRows: ComputedRef<AnalyticsTableRow[]>
pageSize: number
}
export function useAnalyticsTablePagination({
filteredRows,
pageSize,
}: UseAnalyticsTablePaginationOptions): {
currentPage: Ref<number>
pageCount: ComputedRef<number>
visibleRowStart: ComputedRef<number>
visibleRowEnd: ComputedRef<number>
paginatedRows: ComputedRef<AnalyticsTableRow[]>
switchPage: (page: number) => void
} {
const currentPage = ref(1)
const pageCount = computed(() => Math.max(Math.ceil(filteredRows.value.length / pageSize), 1))
const visibleRowStart = computed(() =>
filteredRows.value.length === 0 ? 0 : (currentPage.value - 1) * pageSize + 1,
)
const visibleRowEnd = computed(() =>
Math.min(currentPage.value * pageSize, filteredRows.value.length),
)
const paginatedRows = computed<AnalyticsTableRow[]>(() =>
filteredRows.value.slice((currentPage.value - 1) * pageSize, currentPage.value * pageSize),
)
watch(filteredRows, () => {
currentPage.value = 1
})
watch(pageCount, (nextPageCount) => {
if (currentPage.value > nextPageCount) {
currentPage.value = nextPageCount
}
})
function switchPage(page: number) {
currentPage.value = page
}
return {
currentPage,
pageCount,
visibleRowStart,
visibleRowEnd,
paginatedRows,
switchPage,
}
}
@@ -0,0 +1,230 @@
import type { ComputedRef, Ref, ShallowRef } from 'vue'
import { ref, shallowRef } from 'vue'
import type {
AnalyticsTableColumnKey,
AnalyticsTableDisplayedRowsCache,
AnalyticsTableMode,
AnalyticsTableRow,
AnalyticsTableSortDirectionValue,
} from './analytics-table-types'
type UseAnalyticsTableRowCacheOptions = {
activeTableMode: ComputedRef<AnalyticsTableMode>
showBreakdownColumn: ComputedRef<boolean>
analyticsPointCount: ComputedRef<number>
sortColumn: Ref<AnalyticsTableColumnKey | undefined>
sortDirection: Ref<AnalyticsTableSortDirectionValue>
buildRows: (mode: AnalyticsTableMode) => AnalyticsTableRow[]
sortRows: (rows: AnalyticsTableRow[]) => AnalyticsTableRow[]
inactiveModeWarmupPointLimit: number
}
export function useAnalyticsTableRowCache({
activeTableMode,
showBreakdownColumn,
analyticsPointCount,
sortColumn,
sortDirection,
buildRows,
sortRows,
inactiveModeWarmupPointLimit,
}: UseAnalyticsTableRowCacheOptions): {
displayedTableMode: Ref<AnalyticsTableMode>
displayedSortColumn: Ref<AnalyticsTableColumnKey | undefined>
displayedSortDirection: Ref<AnalyticsTableSortDirectionValue>
displayedSortedRows: ShallowRef<AnalyticsTableRow[]>
invalidateTableCaches: () => void
invalidateSortedCaches: () => void
scheduleRowsForMode: (mode: AnalyticsTableMode) => void
scheduleInactiveModeWarmup: () => void
resortDisplayedRowsForCurrentSort: () => boolean
getSortedRowsForMode: (mode: AnalyticsTableMode) => AnalyticsTableRow[]
} {
const modeBuildRequestIds: Record<AnalyticsTableMode, number> = {
date_breakdown: 0,
breakdown_only: 0,
}
let tableCacheGeneration = 0
let displayedSortedRowsGeneration = 0
const displayedTableMode = ref<AnalyticsTableMode>('breakdown_only')
const displayedSortColumn = ref<AnalyticsTableColumnKey | undefined>(sortColumn.value)
const displayedSortDirection = ref<AnalyticsTableSortDirectionValue>(sortDirection.value)
const displayedSortedRows = shallowRef<AnalyticsTableRow[]>([])
const displayedRowsCache = shallowRef<AnalyticsTableDisplayedRowsCache | null>(null)
function invalidateTableCaches() {
tableCacheGeneration++
invalidateSortedCaches()
}
function invalidateSortedCaches() {
displayedRowsCache.value = null
}
function hasSortedRowsForMode(mode: AnalyticsTableMode): boolean {
const cached = displayedRowsCache.value
return (
cached !== null &&
cached.generation === tableCacheGeneration &&
cached.mode === mode &&
cached.sortColumn === sortColumn.value &&
cached.sortDirection === sortDirection.value
)
}
function setDisplayedRowsForMode(
mode: AnalyticsTableMode,
rows: AnalyticsTableRow[],
generation = tableCacheGeneration,
) {
displayedRowsCache.value = {
generation,
mode,
sortColumn: sortColumn.value,
sortDirection: sortDirection.value,
rows,
}
if (mode === activeTableMode.value) {
displayedSortedRowsGeneration = generation
displayedTableMode.value = mode
displayedSortColumn.value = sortColumn.value
displayedSortDirection.value = sortDirection.value
displayedSortedRows.value = rows
}
}
function scheduleRowsForMode(mode: AnalyticsTableMode) {
if (hasSortedRowsForMode(mode)) {
if (mode === activeTableMode.value) {
displayRowsForMode(mode)
}
return
}
const requestId = ++modeBuildRequestIds[mode]
const generation = tableCacheGeneration
void buildRowsForMode(mode, generation, requestId)
}
function displayRowsForMode(mode: AnalyticsTableMode) {
const cached = displayedRowsCache.value
if (!cached || cached.generation !== tableCacheGeneration || cached.mode !== mode) {
return
}
displayedSortedRowsGeneration = cached.generation
displayedTableMode.value = mode
displayedSortColumn.value = cached.sortColumn
displayedSortDirection.value = cached.sortDirection
displayedSortedRows.value = cached.rows
}
async function buildRowsForMode(mode: AnalyticsTableMode, generation: number, requestId: number) {
await waitForDeferredTableWork()
if (isStaleBuild(mode, generation, requestId)) {
return
}
const rows = sortRows(buildRows(mode))
if (isStaleBuild(mode, generation, requestId)) {
return
}
setDisplayedRowsForMode(mode, rows, generation)
}
function isStaleBuild(mode: AnalyticsTableMode, generation: number, requestId: number): boolean {
return tableCacheGeneration !== generation || modeBuildRequestIds[mode] !== requestId
}
function waitForDeferredTableWork(): Promise<void> {
if (!import.meta.client) {
return Promise.resolve()
}
return new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve())
})
})
}
function scheduleInactiveModeWarmup() {
if (!showBreakdownColumn.value) {
return
}
if (analyticsPointCount.value > inactiveModeWarmupPointLimit) {
return
}
const inactiveMode: AnalyticsTableMode =
activeTableMode.value === 'date_breakdown' ? 'breakdown_only' : 'date_breakdown'
if (hasSortedRowsForMode(inactiveMode)) {
return
}
if (!import.meta.client) {
scheduleRowsForMode(inactiveMode)
return
}
const windowWithIdleCallback = window as Window & {
requestIdleCallback?: (callback: () => void, options?: { timeout?: number }) => number
}
if (windowWithIdleCallback.requestIdleCallback) {
windowWithIdleCallback.requestIdleCallback(() => scheduleRowsForMode(inactiveMode), {
timeout: 2000,
})
} else {
window.setTimeout(() => scheduleRowsForMode(inactiveMode), 250)
}
}
function resortDisplayedRowsForCurrentSort(): boolean {
const mode = activeTableMode.value
if (
displayedTableMode.value !== mode ||
displayedSortedRowsGeneration !== tableCacheGeneration
) {
return false
}
setDisplayedRowsForMode(mode, sortRows(displayedSortedRows.value))
return true
}
function getSortedRowsForMode(mode: AnalyticsTableMode): AnalyticsTableRow[] {
const cached = displayedRowsCache.value
if (
cached &&
cached.generation === tableCacheGeneration &&
cached.mode === mode &&
cached.sortColumn === sortColumn.value &&
cached.sortDirection === sortDirection.value
) {
return cached.rows
}
return sortRows(buildRows(mode))
}
return {
displayedTableMode,
displayedSortColumn,
displayedSortDirection,
displayedSortedRows,
invalidateTableCaches,
invalidateSortedCaches,
scheduleRowsForMode,
scheduleInactiveModeWarmup,
resortDisplayedRowsForCurrentSort,
getSortedRowsForMode,
}
}
@@ -0,0 +1,102 @@
import type { Labrinth } from '@modrinth/api-client'
import type { AnalyticsBreakdownPreset } from '~/providers/analytics/analytics'
import { formatAnalyticsDownloadSourceLabel, type FormatMessage } from './analytics-messages'
export const ALL_BREAKDOWN_VALUE = '__all__'
export const UNKNOWN_BREAKDOWN_VALUE = '__unknown__'
export const COMBINED_BREAKDOWN_LABEL_SEPARATOR = ' + '
export const COMBINED_BREAKDOWN_DATASET_ID_PREFIX = 'breakdowns:'
export function getAnalyticsBreakdownValue(
point: Labrinth.Analytics.v3.ProjectAnalytics,
selectedBreakdown: AnalyticsBreakdownPreset,
formatMessage: FormatMessage,
): string {
switch (selectedBreakdown) {
case 'none':
return ALL_BREAKDOWN_VALUE
case 'project':
return normalizeBreakdownValue('source_project' in point ? point.source_project : undefined)
case 'country':
return normalizeBreakdownValue('country' in point ? point.country?.toUpperCase() : undefined)
case 'monetization': {
if ('monetized' in point && typeof point.monetized === 'boolean') {
return point.monetized ? 'monetized' : 'unmonetized'
}
return ALL_BREAKDOWN_VALUE
}
case 'user_agent': {
const downloadSource = normalizeBreakdownValue(
'user_agent' in point ? point.user_agent : undefined,
)
return downloadSource === ALL_BREAKDOWN_VALUE
? ALL_BREAKDOWN_VALUE
: getDownloadSourceLabel(downloadSource, formatMessage)
}
case 'download_reason':
return normalizeBreakdownValue(
'reason' in point ? point.reason : undefined,
UNKNOWN_BREAKDOWN_VALUE,
)
case 'version_id':
return normalizeBreakdownValue('version_id' in point ? point.version_id : undefined)
case 'loader':
return normalizeBreakdownValue(
'loader' in point ? point.loader : undefined,
UNKNOWN_BREAKDOWN_VALUE,
)
case 'game_version':
return normalizeBreakdownValue(
'game_version' in point ? point.game_version : undefined,
UNKNOWN_BREAKDOWN_VALUE,
)
default:
return ALL_BREAKDOWN_VALUE
}
}
export function getAnalyticsBreakdownValues(
point: Labrinth.Analytics.v3.ProjectAnalytics,
selectedBreakdowns: readonly AnalyticsBreakdownPreset[],
formatMessage: FormatMessage,
): string[] {
return selectedBreakdowns
.filter((breakdown) => breakdown !== 'none')
.map((breakdown) => getAnalyticsBreakdownValue(point, breakdown, formatMessage))
}
export function getAnalyticsBreakdownKey(values: readonly string[]): string {
return values.map((value) => encodeURIComponent(value)).join('+')
}
export function getAnalyticsBreakdownDatasetId(
values: readonly string[],
selectedBreakdowns: readonly AnalyticsBreakdownPreset[],
): string {
const normalizedBreakdowns = selectedBreakdowns.filter((breakdown) => breakdown !== 'none')
if (normalizedBreakdowns.length === 0) {
return 'all'
}
if (normalizedBreakdowns.length === 1) {
if (normalizedBreakdowns[0] === 'project') {
return values[0] ?? 'all'
}
return `breakdown:${values[0] ?? 'all'}`
}
return `${COMBINED_BREAKDOWN_DATASET_ID_PREFIX}${getAnalyticsBreakdownKey(values)}`
}
export function getDownloadSourceLabel(value: string, formatMessage: FormatMessage): string {
return formatAnalyticsDownloadSourceLabel(value, formatMessage)
}
function normalizeBreakdownValue(
value: string | undefined,
fallback = ALL_BREAKDOWN_VALUE,
): string {
const normalized = value?.trim()
return normalized && normalized.length > 0 ? normalized : fallback
}
@@ -0,0 +1,74 @@
<template>
<div class="flex touch-manipulation flex-col gap-4 pb-20 lg:pl-4 lg:pt-1.5">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-2">
<span class="text-xl font-semibold text-contrast md:text-2xl">
{{ formatMessage(analyticsMessages.title) }}
</span>
<div class="flex flex-wrap items-center justify-end gap-2">
<ButtonStyled type="transparent">
<button
type="button"
:disabled="isAnalyticsQueryBuilderDefault"
@click="resetAnalyticsQueryBuilder"
>
{{ formatMessage(analyticsMessages.resetButton) }}
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button
type="button"
:disabled="projects.length === 0 || !fetchRequest || isRefetching"
@click="refreshAnalyticsQuery"
>
<RefreshCwIcon :class="isRefetching ? 'animate-spin' : ''" />
{{ formatMessage(analyticsMessages.refreshButton) }}
</button>
</ButtonStyled>
</div>
</div>
<QueryBuilder />
</div>
<StatCards />
<AnalyticsChart />
<AnalyticsTable />
</div>
</template>
<script setup lang="ts">
import { RefreshCwIcon } from '@modrinth/assets'
import { ButtonStyled, injectProjectPageContext, useVIntl } from '@modrinth/ui'
import {
createAnalyticsDashboardContext,
provideAnalyticsDashboardContext,
} from '~/providers/analytics/analytics'
import { injectOrganizationContext } from '~/providers/organization-context'
import AnalyticsChart from './analytics-chart/index.vue'
import { analyticsMessages } from './analytics-messages.ts'
import AnalyticsTable from './analytics-table/index.vue'
import QueryBuilder from './query-builder/index.vue'
import StatCards from './stat-cards/StatCards.vue'
const auth = await useAuth()
const { formatMessage } = useVIntl()
const projectPageContext = injectProjectPageContext(null)
const organizationContext = injectOrganizationContext(null)
const analyticsDashboardContext = createAnalyticsDashboardContext({
auth,
projectPageContext,
organizationContext,
})
const {
fetchRequest,
isAnalyticsQueryBuilderDefault,
isRefetching,
projects,
refreshAnalyticsQuery,
resetAnalyticsQueryBuilder,
} = analyticsDashboardContext
provideAnalyticsDashboardContext(analyticsDashboardContext)
</script>
@@ -0,0 +1,150 @@
<template>
<div class="flex flex-wrap items-center gap-3">
<span class="shrink-0 whitespace-nowrap text-sm font-semibold text-primary">
{{ props.label }}
</span>
<input
v-model="inputValue"
type="text"
inputmode="numeric"
placeholder="0"
class="h-8 rounded-lg border border-solid border-surface-5 bg-surface-3 px-2 text-center text-sm font-semibold text-primary outline-none transition-[box-shadow,color] focus:text-contrast focus:ring-4 focus:ring-brand-shadow max-sm:text-base"
:class="props.inputWidthClass"
:aria-label="props.inputAriaLabel"
@blur="formatInput"
@keydown.enter.prevent.stop="submitInput"
/>
<span class="shrink-0 text-sm font-semibold text-primary">{{ suffixLabel }}</span>
</div>
</template>
<script setup lang="ts">
import { useVIntl } from '@modrinth/ui'
import { analyticsMessages } from '../analytics-messages'
const props = withDefaults(
defineProps<{
label: string
inputAriaLabel: string
threshold?: number | null
suffix?: string
inputWidthClass?: string
}>(),
{
inputWidthClass: 'w-20',
},
)
const { formatMessage } = useVIntl()
const emit = defineEmits<{
'update:threshold': [threshold: number | null]
submit: [event: KeyboardEvent]
}>()
const suffixLabel = computed(() => props.suffix ?? formatMessage(analyticsMessages.downloadsSuffix))
const inputValue = ref('')
let isSyncingThreshold = false
let hasPendingEmittedThreshold = false
let pendingEmittedThreshold: number | null = null
function parseDownloadsThreshold(value: string): number | null | undefined {
const normalizedValue = value.trim().toLowerCase().replace(/,/g, '')
if (!normalizedValue) {
return null
}
const match = normalizedValue.match(/^(\d+(?:\.\d+)?)([kmb])?$/)
if (!match) {
return undefined
}
const amount = Number.parseFloat(match[1])
if (!Number.isFinite(amount)) {
return undefined
}
const multiplierBySuffix: Record<string, number> = {
k: 1_000,
m: 1_000_000,
b: 1_000_000_000,
}
const multiplier = match[2] ? multiplierBySuffix[match[2]] : 1
return Math.max(0, Math.floor(amount * multiplier))
}
function formatCompactNumber(value: number): string {
const formatWithSuffix = (divisor: number, suffix: string) => {
const dividedValue = value / divisor
const fractionDigits = Number.isInteger(dividedValue) ? 0 : 1
return `${dividedValue.toFixed(fractionDigits).replace(/\.0$/, '')}${suffix}`
}
if (value >= 1_000_000_000) return formatWithSuffix(1_000_000_000, 'B')
if (value >= 1_000_000) return formatWithSuffix(1_000_000, 'M')
if (value >= 1_000) return formatWithSuffix(1_000, 'k')
return String(value)
}
function formatInput() {
const threshold = parseDownloadsThreshold(inputValue.value)
if (threshold === undefined || threshold === null) {
return
}
inputValue.value = formatCompactNumber(threshold)
}
function submitInput(event: KeyboardEvent) {
const threshold = parseDownloadsThreshold(inputValue.value)
if (threshold === undefined) {
return
}
if (threshold !== null) {
inputValue.value = formatCompactNumber(threshold)
}
emit('update:threshold', threshold)
emit('submit', event)
}
watch(inputValue, (value) => {
if (isSyncingThreshold) {
return
}
const threshold = parseDownloadsThreshold(value)
if (threshold === undefined) {
return
}
hasPendingEmittedThreshold = true
pendingEmittedThreshold = threshold
emit('update:threshold', threshold)
nextTick(() => {
if (hasPendingEmittedThreshold && pendingEmittedThreshold === threshold) {
hasPendingEmittedThreshold = false
}
})
})
watch(
() => props.threshold,
(threshold) => {
if (hasPendingEmittedThreshold && threshold === pendingEmittedThreshold) {
hasPendingEmittedThreshold = false
return
}
isSyncingThreshold = true
inputValue.value =
threshold === null || threshold === undefined ? '' : formatCompactNumber(threshold)
nextTick(() => {
isSyncingThreshold = false
})
},
{ immediate: true },
)
</script>
@@ -0,0 +1,944 @@
<template>
<DropdownFilterBar
v-model="selectedFilterValue"
:categories="filterCategories"
:show-clear="showClearAction && canClearSelectedBreakdown"
:show-label="showLabel"
:show-preview-filter-icon="showPreviewFilterIcon"
:preview-trigger-class="previewTriggerClass"
:add-button-class="addButtonClass"
:clear-label="formatMessage(analyticsMessages.resetButton)"
:add-label="resolvedAddLabel"
checkbox-position="right"
@clear="clearFilterBar"
>
<template #search-actions="{ category, setSelectedValues }">
<div v-if="category.key === 'game_version'" class="mr-2 flex min-w-[124px] justify-end">
<Tabs
:value="gameVersionType"
:tabs="gameVersionTypeTabs"
:aria-label="formatMessage(analyticsMessages.gameVersionTypeAria)"
@update:value="(type) => setGameVersionType(type, setSelectedValues)"
/>
</div>
</template>
<template #option="{ category, option, selected }">
<div class="flex min-w-0 flex-1 items-center gap-2">
<template v-if="category.key === 'version_id'">
<span
v-for="metadata in getProjectVersionOptionProjectMetadata(option.value)"
:key="`${option.value}-${metadata.name}`"
v-tooltip="metadata.name"
class="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded text-primary"
>
<img
v-if="metadata.iconUrl"
:src="metadata.iconUrl"
:alt="formatMessage(analyticsMessages.projectIconAlt, { name: metadata.name })"
class="h-6 w-6 rounded object-cover"
/>
<BoxIcon v-else class="h-full w-full" />
</span>
</template>
<span
class="min-w-0 truncate font-semibold leading-tight"
:class="selected ? 'text-contrast' : 'text-primary'"
>
{{ option.label }}
</span>
</div>
</template>
<template #category-footer="{ category, setSelectedValues, closeMenu }">
<DownloadsThresholdInput
v-if="category.key === 'country'"
class="border-0 border-t border-solid border-surface-5 px-3 py-2.5"
:label="formatMessage(analyticsMessages.countriesAbove)"
:input-aria-label="formatMessage(analyticsMessages.countryDownloadsThresholdAria)"
:threshold="countryDownloadsThreshold"
input-width-class="w-16"
@update:threshold="
(threshold) => setCountryDownloadsThreshold(threshold, setSelectedValues)
"
@submit="
(event) =>
runDownloadsThresholdQuery(
applyCountryDownloadsThreshold,
setSelectedValues,
closeMenu,
event,
)
"
/>
<DownloadsThresholdInput
v-else-if="category.key === 'version_id'"
class="border-0 border-t border-solid border-surface-5 px-3 py-2.5"
:label="formatMessage(analyticsMessages.projectVersionsAbove)"
:input-aria-label="formatMessage(analyticsMessages.projectVersionDownloadsThresholdAria)"
:threshold="projectVersionDownloadsThreshold"
input-width-class="w-16"
@update:threshold="
(threshold) => setProjectVersionDownloadsThreshold(threshold, setSelectedValues)
"
@submit="
(event) =>
runDownloadsThresholdQuery(
applyProjectVersionDownloadsThreshold,
setSelectedValues,
closeMenu,
event,
)
"
/>
<DownloadsThresholdInput
v-else-if="category.key === 'game_version'"
class="border-0 border-t border-solid border-surface-5 px-3 py-2.5"
:label="formatMessage(analyticsMessages.gameVersionsAbove)"
:input-aria-label="formatMessage(analyticsMessages.gameVersionDownloadsThresholdAria)"
:threshold="gameVersionDownloadsThreshold"
input-width-class="w-16"
@update:threshold="
(threshold) => setGameVersionDownloadsThreshold(threshold, setSelectedValues)
"
@submit="
(event) =>
runDownloadsThresholdQuery(
applyGameVersionDownloadsThreshold,
setSelectedValues,
closeMenu,
event,
)
"
/>
</template>
<template #preview-footer="{ category, setSelectedValues, closeMenu }">
<DownloadsThresholdInput
v-if="category.key === 'country'"
class="border-0 border-t border-solid border-surface-5 px-3 py-2.5"
:label="formatMessage(analyticsMessages.countriesAbove)"
:input-aria-label="formatMessage(analyticsMessages.countryDownloadsThresholdAria)"
:threshold="countryDownloadsThreshold"
input-width-class="w-16"
@update:threshold="
(threshold) => setCountryDownloadsThreshold(threshold, setSelectedValues)
"
@submit="
(event) =>
runDownloadsThresholdQuery(
applyCountryDownloadsThreshold,
setSelectedValues,
closeMenu,
event,
)
"
/>
<DownloadsThresholdInput
v-else-if="category.key === 'version_id'"
class="border-0 border-t border-solid border-surface-5 px-3 py-2.5"
:label="formatMessage(analyticsMessages.projectVersionsAbove)"
:input-aria-label="formatMessage(analyticsMessages.projectVersionDownloadsThresholdAria)"
:threshold="projectVersionDownloadsThreshold"
input-width-class="w-16"
@update:threshold="
(threshold) => setProjectVersionDownloadsThreshold(threshold, setSelectedValues)
"
@submit="
(event) =>
runDownloadsThresholdQuery(
applyProjectVersionDownloadsThreshold,
setSelectedValues,
closeMenu,
event,
)
"
/>
<DownloadsThresholdInput
v-else-if="category.key === 'game_version'"
class="border-0 border-t border-solid border-surface-5 px-3 py-2.5"
:label="formatMessage(analyticsMessages.gameVersionsAbove)"
:input-aria-label="formatMessage(analyticsMessages.gameVersionDownloadsThresholdAria)"
:threshold="gameVersionDownloadsThreshold"
input-width-class="w-16"
@update:threshold="
(threshold) => setGameVersionDownloadsThreshold(threshold, setSelectedValues)
"
@submit="
(event) =>
runDownloadsThresholdQuery(
applyGameVersionDownloadsThreshold,
setSelectedValues,
closeMenu,
event,
)
"
/>
</template>
</DropdownFilterBar>
</template>
<script setup lang="ts">
import { BoxIcon } from '@modrinth/assets'
import {
DropdownFilterBar,
type DropdownFilterBarCategory,
type DropdownFilterBarOption,
Tabs,
type TabsTab,
type TabsValue,
useVIntl,
} from '@modrinth/ui'
import { useFormattedCountries } from '@/composables/country.ts'
import {
areStringArraysEqual,
getDefaultAnalyticsBreakdownPresets,
} from '~/components/analytics-dashboard/analytics-route-query'
import { useGeneratedState } from '~/composables/generated'
import {
type AnalyticsQueryFilterCategory,
type AnalyticsSelectedFilters,
doesProjectStatusMatchFilters,
injectAnalyticsDashboardContext,
} from '~/providers/analytics/analytics'
import {
analyticsBreakdownMessages,
analyticsMessages,
analyticsMonetizationMessages,
formatAnalyticsDownloadReasonLabel,
formatAnalyticsLoaderLabel,
formatAnalyticsProjectStatusLabel,
} from '../analytics-messages.ts'
import { getDownloadSourceLabel } from '../breakdown.ts'
import DownloadsThresholdInput from './DownloadsThresholdInput.vue'
import {
areSelectedFiltersEqual,
buildProjectVersionFilterOptionProjectMetadataById,
buildProjectVersionFilterOptions,
cloneSelectedFilters,
FILTER_VALUE_CATEGORIES,
getOptionsWithSelectedValues,
getProjectVersionFilterOptionMetadataIds,
getProjectVersionFilterOptionProjectMetadataCacheKey,
getProjectVersionFilterOptionsCacheKey,
getVisibleAnalyticsFilterCategoriesForState,
normalizeSelectedValues as normalizeSelectedFilterValues,
type ProjectVersionFilterOption,
type ProjectVersionFilterOptionProjectMetadata,
} from './query-filter.ts'
type AnalyticsFilterValueCategory = Exclude<AnalyticsQueryFilterCategory, 'project'>
type GameVersionType = 'release' | 'all'
type SetDropdownFilterValues = (values: string[]) => void
type ApplyDownloadsThreshold = (setSelectedValues: SetDropdownFilterValues) => void
type CloseDownloadsThresholdMenu = (event?: Event) => void
const props = withDefaults(
defineProps<{
addLabel?: string
showLabel?: boolean
showPreviewFilterIcon?: boolean
previewTriggerClass?: string
addButtonClass?: string
showClearAction?: boolean
}>(),
{
showLabel: true,
showPreviewFilterIcon: false,
showClearAction: true,
},
)
const { formatMessage } = useVIntl()
const {
hasProjectContext,
projects,
selectedProjectIds,
availableProjectStatuses,
filterOptions,
projectVersionDownloadsById,
gameVersionDownloadsByVersion,
countryDownloadsByCode,
isAnalyticsFilterOptionsLoading,
selectedBreakdowns,
selectedFilters,
queryResetToken,
refreshAnalyticsQuery,
hasCompletedAnalyticsLoading,
versionNumbersById,
versionPublishedDatesById,
versionProjectNamesById,
versionProjectIconUrlsById,
getVersionDisplayName,
} = injectAnalyticsDashboardContext()
const formattedCountries = useFormattedCountries()
const generatedState = useGeneratedState()
const gameVersionType = ref<GameVersionType>('release')
const countryDownloadsThreshold = ref<number | null>(null)
const projectVersionDownloadsThreshold = ref<number | null>(null)
const gameVersionDownloadsThreshold = ref<number | null>(null)
const gameVersionTypeTabs = computed<TabsTab[]>(() => [
{ value: 'release', label: formatMessage(analyticsMessages.releaseTab) },
{ value: 'all', label: formatMessage(analyticsMessages.allTab) },
])
const resolvedAddLabel = computed(
() => props.addLabel ?? formatMessage(analyticsMessages.addButton),
)
const filterValueCategoryKeys = new Set<string>(FILTER_VALUE_CATEGORIES)
const downloadsThresholdFilterCategories = ['country', 'version_id', 'game_version'] as const
type DownloadsThresholdFilterCategory = (typeof downloadsThresholdFilterCategories)[number]
const downloadsThresholdSelections = ref<
Partial<Record<DownloadsThresholdFilterCategory, string[]>>
>({})
const projectStatusFilterOptions = computed<DropdownFilterBarOption[]>(() =>
availableProjectStatuses.value.map((status) => ({
value: status,
label: getProjectStatusFilterOptionLabel(status),
})),
)
const selectedProjectIdSet = computed(() => new Set(selectedProjectIds.value))
const effectiveSelectedProjectCount = computed(
() =>
projects.value.filter(
(project) =>
selectedProjectIdSet.value.has(project.id) &&
doesProjectStatusMatchFilters(project.status, selectedFilters.value),
).length,
)
const showProjectVersionProjectIcons = computed(() => effectiveSelectedProjectCount.value > 1)
const defaultSelectedBreakdown = computed(() =>
getDefaultAnalyticsBreakdownPresets(selectedProjectIds.value),
)
const canClearSelectedBreakdown = computed(
() => !areStringArraysEqual(selectedBreakdowns.value, defaultSelectedBreakdown.value),
)
const analyticsFilterOptionsEmptyLabel = computed(() =>
isAnalyticsFilterOptionsLoading.value
? formatMessage(analyticsMessages.loadingOptions)
: undefined,
)
const projectVersionFilterOptions = shallowRef<ProjectVersionFilterOption[]>([])
const projectVersionFilterOptionProjectMetadataById = shallowRef(
new Map<string, ProjectVersionFilterOptionProjectMetadata[]>(),
)
const draftSelectedFilters = ref<AnalyticsSelectedFilters>(
cloneSelectedFilters(selectedFilters.value),
)
let selectedFiltersCommitRequestId = 0
let projectVersionFilterOptionsCacheKey = ''
let projectVersionFilterOptionProjectMetadataCacheKey = ''
const selectedFilterValue = computed<Record<string, string[]>>({
get: () => getSelectedFilterBarValue(),
set: (nextValue) => {
const nextFilters = cloneSelectedFilters(draftSelectedFilters.value)
for (const [categoryKey, values] of Object.entries(nextValue)) {
if (!isAnalyticsFilterValueCategory(categoryKey)) {
continue
}
nextFilters[categoryKey] = normalizeSelectedFilterValues(categoryKey, values, [])
}
draftSelectedFilters.value = nextFilters
void scheduleSelectedFiltersCommit()
},
})
function getSelectedFilterBarValue(): AnalyticsSelectedFilters {
return cloneSelectedFilters(draftSelectedFilters.value)
}
function clearSelectedBreakdown() {
selectedBreakdowns.value = defaultSelectedBreakdown.value
}
function clearFilterBar() {
clearSelectedBreakdown()
clearDownloadsThresholds()
}
watch(queryResetToken, () => {
selectedFiltersCommitRequestId++
draftSelectedFilters.value = cloneSelectedFilters(selectedFilters.value)
clearDownloadsThresholds()
})
watch(
selectedFilters,
(nextFilters, previousFilters) => {
selectedFiltersCommitRequestId++
draftSelectedFilters.value = cloneSelectedFilters(nextFilters)
clearDownloadsThresholdsForChangedFilters(previousFilters, nextFilters)
},
{ deep: true },
)
watch(
[
hasCompletedAnalyticsLoading,
filterOptions,
versionNumbersById,
versionPublishedDatesById,
versionProjectNamesById,
],
([
hasCompletedLoading,
nextFilterOptions,
nextVersionNumbersById,
nextVersionPublishedDatesById,
nextVersionProjectNamesById,
]) => {
if (!hasCompletedLoading) {
projectVersionFilterOptionsCacheKey = ''
if (projectVersionFilterOptions.value.length > 0) {
projectVersionFilterOptions.value = []
}
return
}
const nextCacheKey = getProjectVersionFilterOptionsCacheKey(
nextFilterOptions.versionIds,
nextVersionNumbersById,
nextVersionPublishedDatesById,
nextVersionProjectNamesById,
)
if (nextCacheKey === projectVersionFilterOptionsCacheKey) {
return
}
projectVersionFilterOptionsCacheKey = nextCacheKey
projectVersionFilterOptions.value = buildProjectVersionFilterOptions(
nextFilterOptions.versionIds,
nextVersionNumbersById,
nextVersionPublishedDatesById,
nextVersionProjectNamesById,
)
},
{ immediate: true },
)
watch(
[
hasCompletedAnalyticsLoading,
filterOptions,
selectedFilters,
versionProjectNamesById,
versionProjectIconUrlsById,
],
([
hasCompletedLoading,
nextFilterOptions,
nextSelectedFilters,
nextVersionProjectNamesById,
nextVersionProjectIconUrlsById,
]) => {
if (!hasCompletedLoading) {
projectVersionFilterOptionProjectMetadataCacheKey = ''
if (projectVersionFilterOptionProjectMetadataById.value.size > 0) {
projectVersionFilterOptionProjectMetadataById.value = new Map()
}
return
}
const metadataIds = getProjectVersionFilterOptionMetadataIds(
nextFilterOptions.versionIds,
nextSelectedFilters.version_id,
)
const nextCacheKey = getProjectVersionFilterOptionProjectMetadataCacheKey(
metadataIds,
nextVersionProjectNamesById,
nextVersionProjectIconUrlsById,
)
if (nextCacheKey === projectVersionFilterOptionProjectMetadataCacheKey) {
return
}
projectVersionFilterOptionProjectMetadataCacheKey = nextCacheKey
projectVersionFilterOptionProjectMetadataById.value =
buildProjectVersionFilterOptionProjectMetadataById(
metadataIds,
nextVersionProjectNamesById,
nextVersionProjectIconUrlsById,
)
},
{ immediate: true },
)
async function scheduleSelectedFiltersCommit() {
const requestId = ++selectedFiltersCommitRequestId
const nextFilters = cloneSelectedFilters(draftSelectedFilters.value)
await waitForDeferredQueryFilterCommit()
if (requestId !== selectedFiltersCommitRequestId) {
return
}
if (!areSelectedFiltersEqual(selectedFilters.value, nextFilters)) {
selectedFilters.value = nextFilters
}
}
function waitForDeferredQueryFilterCommit(): Promise<void> {
if (!import.meta.client) {
return nextTick()
}
return new Promise((resolve) => {
nextTick(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve())
})
})
})
}
const filterCategories = computed<DropdownFilterBarCategory[]>(() => {
const visibleCategoryKeys = new Set(
getVisibleAnalyticsFilterCategoriesForState(selectedBreakdowns.value, selectedFilters.value),
)
const categories: DropdownFilterBarCategory[] = []
if (!hasProjectContext.value) {
categories.push({
key: 'project_status',
label: formatMessage(analyticsBreakdownMessages.projectStatus),
options: withSelectedOptions('project_status', projectStatusFilterOptions.value),
})
}
categories.push(
{
key: 'country',
label: formatMessage(analyticsBreakdownMessages.country),
searchable: countryFilterOptions.value.length > 6,
searchPlaceholder: formatMessage(analyticsMessages.searchCountriesPlaceholder),
emptyOptionsLabel: analyticsFilterOptionsEmptyLabel.value,
emptySearchLabel: analyticsFilterOptionsEmptyLabel.value,
options: withSelectedOptions('country', countryFilterOptions.value),
submenuClass: 'w-fit',
previewDropdownWidth: 'fit-content',
},
{
key: 'monetization',
label: formatMessage(analyticsBreakdownMessages.monetization),
options: withSelectedOptions('monetization', [
{ value: 'monetized', label: formatMessage(analyticsMonetizationMessages.monetized) },
{ value: 'unmonetized', label: formatMessage(analyticsMonetizationMessages.unmonetized) },
]),
},
{
key: 'user_agent',
label: formatMessage(analyticsBreakdownMessages.userAgent),
searchable: downloadSourceFilterOptions.value.length > 6,
searchPlaceholder: formatMessage(analyticsMessages.searchDownloadSourcesPlaceholder),
emptyOptionsLabel: analyticsFilterOptionsEmptyLabel.value,
emptySearchLabel: analyticsFilterOptionsEmptyLabel.value,
options: withSelectedOptions('user_agent', downloadSourceFilterOptions.value),
},
{
key: 'download_reason',
label: formatMessage(analyticsBreakdownMessages.downloadReason),
emptyOptionsLabel: analyticsFilterOptionsEmptyLabel.value,
emptySearchLabel: analyticsFilterOptionsEmptyLabel.value,
options: withSelectedOptions('download_reason', downloadReasonFilterOptions.value),
},
{
key: 'version_id',
label: formatMessage(analyticsBreakdownMessages.versionId),
searchable: projectVersionFilterOptions.value.length > 6,
searchPlaceholder: formatMessage(analyticsMessages.searchProjectVersionsPlaceholder),
submenuClass: 'w-fit',
previewDropdownWidth: 'fit-content',
options: withSelectedOptions('version_id', projectVersionFilterOptions.value),
},
{
key: 'game_version',
label: formatMessage(analyticsBreakdownMessages.gameVersion),
searchable: true,
searchPlaceholder: formatMessage(analyticsMessages.searchVersionsPlaceholder),
submenuClass: 'w-fit max-w-[338px]',
previewDropdownWidth: '338px',
options: withSelectedOptions('game_version', gameVersionFilterOptions.value),
},
{
key: 'loader_type',
label: formatMessage(analyticsBreakdownMessages.loader),
options: withSelectedOptions('loader_type', loaderTypeFilterOptions.value),
},
)
return categories.filter((category) =>
visibleCategoryKeys.has(category.key as AnalyticsFilterValueCategory),
)
})
const countryLabelsByCode = computed(
() =>
new Map(
formattedCountries.value.map(
(country) => [country.value.toUpperCase(), country.label] as const,
),
),
)
const countryFilterOptions = computed<DropdownFilterBarOption[]>(() =>
filterOptions.value.countries
.map((countryCode) => ({
value: countryCode,
label: getCountryFilterOptionLabel(countryCode),
searchTerms: [countryCode],
}))
.sort((left, right) => left.label.localeCompare(right.label)),
)
const gameVersionReleaseDatesByVersion = computed(
() =>
new Map(
generatedState.value.gameVersions.map(
(gameVersion) => [gameVersion.version, gameVersion.date] as const,
),
),
)
const gameVersionTypesByVersion = computed(
() =>
new Map(
generatedState.value.gameVersions.map(
(gameVersion) => [gameVersion.version, gameVersion.version_type] as const,
),
),
)
const downloadSourceFilterOptions = computed<DropdownFilterBarOption[]>(() =>
filterOptions.value.downloadSources
.map((downloadSource) => ({
value: downloadSource,
label: getDownloadSourceLabel(downloadSource, formatMessage),
}))
.sort((left, right) => left.label.localeCompare(right.label)),
)
const downloadReasonFilterOptions = computed<DropdownFilterBarOption[]>(() =>
filterOptions.value.downloadReasons.map((downloadReason) => ({
value: downloadReason,
label: getDownloadReasonFilterOptionLabel(downloadReason),
})),
)
const gameVersionFilterOptions = computed<DropdownFilterBarOption[]>(() =>
filterOptions.value.gameVersions
.filter((gameVersion) => {
const versionType = gameVersionTypesByVersion.value.get(gameVersion)
return (
gameVersionType.value === 'all' || versionType === undefined || versionType === 'release'
)
})
.map((gameVersion) => ({
value: gameVersion,
label: gameVersion,
}))
.sort((left, right) =>
compareOptionalDateStringsDescending(
gameVersionReleaseDatesByVersion.value.get(left.value),
gameVersionReleaseDatesByVersion.value.get(right.value),
left.label,
right.label,
),
),
)
const loaderTypeFilterOptions = computed<DropdownFilterBarOption[]>(() =>
filterOptions.value.loaderTypes
.map((loaderType) => ({
value: loaderType,
label: getLoaderTypeFilterOptionLabel(loaderType),
searchTerms: [loaderType],
}))
.sort((left, right) => left.label.localeCompare(right.label)),
)
function isAnalyticsFilterValueCategory(
categoryKey: string,
): categoryKey is AnalyticsFilterValueCategory {
return filterValueCategoryKeys.has(categoryKey)
}
function withSelectedOptions(
categoryKey: AnalyticsFilterValueCategory,
options: DropdownFilterBarOption[],
): DropdownFilterBarOption[] {
return getOptionsWithSelectedValues(
options,
selectedFilters.value[categoryKey],
getMissingSelectedOptionLabel(categoryKey),
)
}
function getMissingSelectedOptionLabel(
categoryKey: AnalyticsFilterValueCategory,
): ((value: string) => string) | undefined {
if (categoryKey === 'country') {
return getCountryFilterOptionLabel
}
if (categoryKey === 'version_id') {
return getVersionDisplayName
}
if (categoryKey === 'download_reason') {
return getDownloadReasonFilterOptionLabel
}
if (categoryKey === 'user_agent') {
return (value) => getDownloadSourceLabel(value, formatMessage)
}
if (categoryKey === 'loader_type') {
return getLoaderTypeFilterOptionLabel
}
return undefined
}
function getProjectVersionOptionProjectMetadata(versionId: string) {
if (!showProjectVersionProjectIcons.value) {
return []
}
return projectVersionFilterOptionProjectMetadataById.value.get(versionId) ?? []
}
function getCountryFilterOptionLabel(countryCode: string): string {
const normalizedCode = countryCode.trim().toUpperCase()
if (normalizedCode === 'XX') {
return formatMessage(analyticsMessages.other)
}
return countryLabelsByCode.value.get(normalizedCode) ?? countryCode
}
function getProjectStatusFilterOptionLabel(status: string): string {
return formatAnalyticsProjectStatusLabel(status, formatMessage)
}
function getLoaderTypeFilterOptionLabel(loaderType: string): string {
return formatAnalyticsLoaderLabel(loaderType, formatMessage)
}
function getDownloadReasonFilterOptionLabel(reason: string): string {
return formatAnalyticsDownloadReasonLabel(reason, formatMessage)
}
function getDateTimestamp(date: string | undefined): number | undefined {
if (!date) {
return undefined
}
const timestamp = new Date(date).getTime()
return Number.isFinite(timestamp) ? timestamp : undefined
}
function compareOptionalDateStringsDescending(
leftDate: string | undefined,
rightDate: string | undefined,
leftFallback: string,
rightFallback: string,
): number {
const leftTimestamp = getDateTimestamp(leftDate)
const rightTimestamp = getDateTimestamp(rightDate)
if (leftTimestamp !== undefined && rightTimestamp !== undefined) {
return rightTimestamp - leftTimestamp
}
if (leftTimestamp !== undefined) {
return -1
}
if (rightTimestamp !== undefined) {
return 1
}
return leftFallback.localeCompare(rightFallback)
}
function applyGameVersionDownloadsThreshold(setSelectedValues: SetDropdownFilterValues) {
const threshold = gameVersionDownloadsThreshold.value
if (threshold === null) {
return
}
const selectedValues = gameVersionFilterOptions.value
.filter((gameVersion) => {
return (gameVersionDownloadsByVersion.value.get(gameVersion.value) ?? 0) >= threshold
})
.map((gameVersion) => gameVersion.value)
setDownloadsThresholdSelectedValues('game_version', selectedValues, setSelectedValues)
}
function applyCountryDownloadsThreshold(setSelectedValues: SetDropdownFilterValues) {
const threshold = countryDownloadsThreshold.value
if (threshold === null) {
return
}
const selectedValues = countryFilterOptions.value
.filter((country) => {
return (
(countryDownloadsByCode.value.get(country.value.trim().toUpperCase()) ?? 0) >= threshold
)
})
.map((country) => country.value)
setDownloadsThresholdSelectedValues('country', selectedValues, setSelectedValues)
}
function applyProjectVersionDownloadsThreshold(setSelectedValues: SetDropdownFilterValues) {
const threshold = projectVersionDownloadsThreshold.value
if (threshold === null) {
return
}
const selectedValues = projectVersionFilterOptions.value
.filter((version) => {
return (projectVersionDownloadsById.value.get(version.value) ?? 0) >= threshold
})
.map((version) => version.value)
setDownloadsThresholdSelectedValues('version_id', selectedValues, setSelectedValues)
}
function setCountryDownloadsThreshold(
threshold: number | null,
setSelectedValues: SetDropdownFilterValues,
) {
countryDownloadsThreshold.value = threshold
if (threshold === null) {
clearDownloadsThreshold('country')
setSelectedValues([])
return
}
applyCountryDownloadsThreshold(setSelectedValues)
}
function setProjectVersionDownloadsThreshold(
threshold: number | null,
setSelectedValues: SetDropdownFilterValues,
) {
projectVersionDownloadsThreshold.value = threshold
if (threshold === null) {
clearDownloadsThreshold('version_id')
setSelectedValues([])
return
}
applyProjectVersionDownloadsThreshold(setSelectedValues)
}
function setGameVersionDownloadsThreshold(
threshold: number | null,
setSelectedValues: SetDropdownFilterValues,
) {
gameVersionDownloadsThreshold.value = threshold
if (threshold === null) {
clearDownloadsThreshold('game_version')
setSelectedValues([])
return
}
applyGameVersionDownloadsThreshold(setSelectedValues)
}
function clearDownloadsThresholdsForChangedFilters(
previousFilters: AnalyticsSelectedFilters,
nextFilters: AnalyticsSelectedFilters,
) {
for (const categoryKey of downloadsThresholdFilterCategories) {
if (areFilterSelectionsEqual(previousFilters[categoryKey], nextFilters[categoryKey])) {
continue
}
const thresholdSelection = downloadsThresholdSelections.value[categoryKey]
if (
thresholdSelection &&
areFilterSelectionsEqual(thresholdSelection, nextFilters[categoryKey])
) {
continue
}
if (previousFilters[categoryKey].length > 0 || nextFilters[categoryKey].length > 0) {
clearDownloadsThreshold(categoryKey)
}
}
}
function setDownloadsThresholdSelectedValues(
categoryKey: DownloadsThresholdFilterCategory,
selectedValues: string[],
setSelectedValues: SetDropdownFilterValues,
) {
downloadsThresholdSelections.value = {
...downloadsThresholdSelections.value,
[categoryKey]: normalizeSelectedFilterValues(categoryKey, selectedValues, []),
}
setSelectedValues(selectedValues)
}
function clearDownloadsThreshold(categoryKey: DownloadsThresholdFilterCategory) {
switch (categoryKey) {
case 'country':
countryDownloadsThreshold.value = null
break
case 'version_id':
projectVersionDownloadsThreshold.value = null
break
case 'game_version':
gameVersionDownloadsThreshold.value = null
break
}
const { [categoryKey]: _removedSelection, ...nextSelections } = downloadsThresholdSelections.value
downloadsThresholdSelections.value = nextSelections
}
function clearDownloadsThresholds() {
for (const categoryKey of downloadsThresholdFilterCategories) {
clearDownloadsThreshold(categoryKey)
}
}
function areFilterSelectionsEqual(left: string[], right: string[]): boolean {
const leftValues = new Set(left)
const rightValues = new Set(right)
if (leftValues.size !== rightValues.size) {
return false
}
return [...leftValues].every((value) => rightValues.has(value))
}
async function runDownloadsThresholdQuery(
applyDownloadsThreshold: ApplyDownloadsThreshold,
setSelectedValues: SetDropdownFilterValues,
closeMenu: CloseDownloadsThresholdMenu,
event?: KeyboardEvent,
) {
applyDownloadsThreshold(setSelectedValues)
closeMenu(event)
await scheduleSelectedFiltersCommit()
await refreshAnalyticsQuery()
}
function setGameVersionType(type: TabsValue, setSelectedValues: SetDropdownFilterValues) {
if (!isGameVersionType(type)) {
return
}
gameVersionType.value = type
applyGameVersionDownloadsThreshold(setSelectedValues)
}
function isGameVersionType(type: TabsValue): type is GameVersionType {
return type === 'release' || type === 'all'
}
</script>
@@ -0,0 +1,131 @@
<template>
<BaseTimeFramePicker
v-model:mode="selectedTimeframeMode"
v-model:preset="selectedTimeframe"
v-model:last-amount="selectedLastTimeframeAmount"
v-model:last-unit="selectedLastTimeframeUnit"
v-model:custom-start-date="selectedCustomTimeframeStartDate"
v-model:custom-end-date="selectedCustomTimeframeEndDate"
:min-date="ANALYTICS_START_DATE_INPUT_VALUE"
:now-timestamp="queryRefreshTimestamp"
:trigger-class="triggerClass"
@open="handleTimeframeOpen"
@commit="handleTimeframeCommit"
@apply="handleTimeframeApply"
@draft-change="handleTimeframeDraftChange"
@preset-select="handleTimeframePresetSelect"
>
<template #prefix>
<slot name="prefix"></slot>
</template>
</BaseTimeFramePicker>
</template>
<script setup lang="ts">
import {
type ComboboxOption,
TimeFramePicker as BaseTimeFramePicker,
type TimeFramePickerSelection,
type TimeFramePreset,
} from '@modrinth/ui'
import {
ANALYTICS_START_DATE_INPUT_VALUE,
type AnalyticsGroupByPreset,
type AnalyticsTimeframePreset,
injectAnalyticsDashboardContext,
} from '~/providers/analytics/analytics'
import {
ensureMinimumTimeRange,
getAnalyticsTimeRange,
getDateInputValue,
getDefaultAnalyticsGroupByForDurationMinutes,
} from './timeframe'
const {
selectedTimeframeMode,
selectedTimeframe,
selectedLastTimeframeAmount,
selectedLastTimeframeUnit,
selectedCustomTimeframeStartDate,
selectedCustomTimeframeEndDate,
selectedGroupBy,
queryRefreshTimestamp,
refreshAnalyticsQuery,
} = injectAnalyticsDashboardContext()
defineProps<{
triggerClass?: string
}>()
const draftSelectedGroupBy = ref(selectedGroupBy.value)
const defaultGroupByForPreset: Partial<Record<AnalyticsTimeframePreset, AnalyticsGroupByPreset>> = {
today: '1h',
yesterday: '1h',
last_7_days: '6h',
last_14_days: 'day',
last_30_days: 'day',
last_90_days: 'day',
last_180_days: 'week',
year_to_date: 'week',
}
function handleTimeframeOpen() {
draftSelectedGroupBy.value = selectedGroupBy.value
}
function handleTimeframeCommit() {
selectedGroupBy.value = draftSelectedGroupBy.value
}
async function handleTimeframeApply() {
await refreshAnalyticsQuery()
}
function handleTimeframePresetSelect(option: ComboboxOption<TimeFramePreset>) {
const defaultGroupBy = defaultGroupByForPreset[option.value as AnalyticsTimeframePreset]
if (defaultGroupBy) {
draftSelectedGroupBy.value = defaultGroupBy
}
}
function handleTimeframeDraftChange(selection: TimeFramePickerSelection) {
if (selection.mode !== 'last' && selection.mode !== 'custom_range') {
return
}
if (selection.mode === 'custom_range' && !hasCompleteCustomDateRange(selection)) {
return
}
const range = getAnalyticsTimeRange({
mode: selection.mode,
preset: selection.preset,
lastAmount: selection.lastAmount,
lastUnit: selection.lastUnit,
customStartDate: selection.customStartDate,
customEndDate: selection.customEndDate,
nowTimestamp: queryRefreshTimestamp.value,
})
const { start, end } = ensureMinimumTimeRange(range.start, range.end)
const durationMinutes = Math.max(1, Math.floor((end.getTime() - start.getTime()) / 60000))
draftSelectedGroupBy.value = getDefaultAnalyticsGroupByForDurationMinutes(durationMinutes)
}
function hasCompleteCustomDateRange(selection: TimeFramePickerSelection) {
return Boolean(
getDateFromInputValue(selection.customStartDate) &&
getDateFromInputValue(selection.customEndDate),
)
}
function getDateFromInputValue(value: string): Date | undefined {
const date = new Date(`${value}T00:00:00`)
if (Number.isNaN(date.getTime()) || getDateInputValue(date) !== value) {
return undefined
}
return date
}
</script>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,496 @@
import type {
AnalyticsBreakdownPreset,
AnalyticsDashboardStat,
AnalyticsQueryFilterCategory,
AnalyticsSelectedFilters,
} from '~/providers/analytics/analytics'
export type AnalyticsDashboardDimension =
| 'project'
| 'project_status'
| 'version_id'
| 'country'
| 'monetization'
| 'user_agent'
| 'download_reason'
| 'game_version'
| 'loader_type'
export const ALL_FILTER_VALUE = '__all__'
export const FILTER_VALUE_CATEGORIES: Exclude<AnalyticsQueryFilterCategory, 'project'>[] = [
'project_status',
'country',
'monetization',
'user_agent',
'download_reason',
'version_id',
'game_version',
'loader_type',
]
const ANALYTICS_DASHBOARD_STAT_ORDER: AnalyticsDashboardStat[] = [
'views',
'downloads',
'revenue',
'playtime',
]
const ANALYTICS_STATS_BY_DIMENSION: Record<
AnalyticsDashboardDimension,
readonly AnalyticsDashboardStat[]
> = {
project: ANALYTICS_DASHBOARD_STAT_ORDER,
version_id: ['downloads', 'playtime'],
country: ['views', 'downloads', 'playtime'],
monetization: ['views', 'downloads'],
user_agent: ['downloads'],
download_reason: ['downloads'],
game_version: ['downloads', 'playtime'],
loader_type: ['downloads', 'playtime'],
project_status: ANALYTICS_DASHBOARD_STAT_ORDER,
}
const ANALYTICS_DIMENSION_BY_BREAKDOWN: Record<
AnalyticsBreakdownPreset,
AnalyticsDashboardDimension
> = {
none: 'project',
project: 'project',
country: 'country',
monetization: 'monetization',
user_agent: 'user_agent',
download_reason: 'download_reason',
version_id: 'version_id',
loader: 'loader_type',
game_version: 'game_version',
}
const ANALYTICS_DIMENSION_BY_FILTER_CATEGORY: Record<
Exclude<AnalyticsQueryFilterCategory, 'project'>,
AnalyticsDashboardDimension
> = {
project_status: 'project_status',
country: 'country',
monetization: 'monetization',
user_agent: 'user_agent',
download_reason: 'download_reason',
version_id: 'version_id',
game_version: 'game_version',
loader_type: 'loader_type',
}
const ANALYTICS_FILTER_CATEGORY_BY_BREAKDOWN: Record<
AnalyticsBreakdownPreset,
Exclude<AnalyticsQueryFilterCategory, 'project'> | null
> = {
none: null,
project: null,
country: 'country',
monetization: 'monetization',
user_agent: 'user_agent',
download_reason: 'download_reason',
version_id: 'version_id',
loader: 'loader_type',
game_version: 'game_version',
}
export type FilterOption = {
value: string
label: string
searchTerms?: string[]
}
export type ProjectVersionFilterOption = FilterOption
export type ProjectVersionFilterOptionProjectMetadata = {
name: string
iconUrl?: string
}
type AnalyticsBreakdownInput = AnalyticsBreakdownPreset | readonly AnalyticsBreakdownPreset[]
function getOptionalDateTimestamp(date: string | undefined): number | undefined {
if (!date) {
return undefined
}
const timestamp = new Date(date).getTime()
return Number.isFinite(timestamp) ? timestamp : undefined
}
export function getProjectVersionFilterOptionsCacheKey(
versionIds: string[],
versionNumbersById: Map<string, string>,
versionPublishedDatesById: Map<string, string>,
versionProjectNamesById: Map<string, string>,
): string {
return versionIds
.map(
(versionId) =>
`${versionId}\x1f${versionNumbersById.get(versionId) ?? ''}\x1f${
versionPublishedDatesById.get(versionId) ?? ''
}\x1f${versionProjectNamesById.get(versionId) ?? ''}`,
)
.join('\x1e')
}
export function getProjectVersionFilterOptionProjectMetadataCacheKey(
versionIds: string[],
versionProjectNamesById: Map<string, string>,
versionProjectIconUrlsById: Map<string, string>,
): string {
return versionIds
.map(
(versionId) =>
`${versionId}\x1f${versionProjectNamesById.get(versionId) ?? ''}\x1f${
versionProjectIconUrlsById.get(versionId) ?? ''
}`,
)
.join('\x1e')
}
export function getProjectVersionFilterOptionMetadataIds(
versionIds: string[],
selectedVersionIds: string[],
): string[] {
const knownVersionIds = new Set(versionIds)
const metadataIds = [...versionIds]
for (const versionId of selectedVersionIds) {
if (!knownVersionIds.has(versionId)) {
metadataIds.push(versionId)
knownVersionIds.add(versionId)
}
}
return metadataIds
}
export function buildProjectVersionFilterOptions(
versionIds: string[],
versionNumbersById: Map<string, string>,
versionPublishedDatesById: Map<string, string>,
versionProjectNamesById: Map<string, string>,
): ProjectVersionFilterOption[] {
return versionIds
.map((versionId) => {
const projectName = versionProjectNamesById.get(versionId)
return {
option: {
value: versionId,
label: versionNumbersById.get(versionId) ?? versionId,
searchTerms: projectName ? [versionId, projectName] : [versionId],
},
publishedTimestamp: getOptionalDateTimestamp(versionPublishedDatesById.get(versionId)),
}
})
.sort((left, right) => {
if (left.publishedTimestamp !== undefined && right.publishedTimestamp !== undefined) {
return right.publishedTimestamp - left.publishedTimestamp
}
if (left.publishedTimestamp !== undefined) {
return -1
}
if (right.publishedTimestamp !== undefined) {
return 1
}
return left.option.label.localeCompare(right.option.label)
})
.map(({ option }) => option)
}
export function buildProjectVersionFilterOptionProjectMetadataById(
versionIds: string[],
versionProjectNamesById: Map<string, string>,
versionProjectIconUrlsById: Map<string, string>,
): Map<string, ProjectVersionFilterOptionProjectMetadata[]> {
const metadataById = new Map<string, ProjectVersionFilterOptionProjectMetadata[]>()
for (const versionId of versionIds) {
const projectName = versionProjectNamesById.get(versionId)
if (!projectName) {
continue
}
const metadata: ProjectVersionFilterOptionProjectMetadata = { name: projectName }
const iconUrl = versionProjectIconUrlsById.get(versionId)
if (iconUrl) {
metadata.iconUrl = iconUrl
}
metadataById.set(versionId, [metadata])
}
return metadataById
}
function intersectAnalyticsStats(
left: readonly AnalyticsDashboardStat[],
right: readonly AnalyticsDashboardStat[],
): AnalyticsDashboardStat[] {
const rightStats = new Set(right)
return left.filter((stat) => rightStats.has(stat))
}
function haveAnalyticsStatOverlap(
left: readonly AnalyticsDashboardStat[],
right: readonly AnalyticsDashboardStat[],
): boolean {
return left.some((stat) => right.includes(stat))
}
export function getAnalyticsStatsForDimension(
dimension: AnalyticsDashboardDimension,
): readonly AnalyticsDashboardStat[] {
return ANALYTICS_STATS_BY_DIMENSION[dimension]
}
export function getAnalyticsStatsForBreakdown(
breakdown: AnalyticsBreakdownPreset,
): readonly AnalyticsDashboardStat[] {
return getAnalyticsStatsForDimension(ANALYTICS_DIMENSION_BY_BREAKDOWN[breakdown])
}
function normalizeAnalyticsBreakdowns(
breakdowns: AnalyticsBreakdownInput,
): Exclude<AnalyticsBreakdownPreset, 'none'>[] {
const values = Array.isArray(breakdowns) ? breakdowns : [breakdowns]
const normalizedBreakdowns: Exclude<AnalyticsBreakdownPreset, 'none'>[] = []
for (const breakdown of values) {
if (breakdown === 'none') {
continue
}
if (!normalizedBreakdowns.includes(breakdown)) {
normalizedBreakdowns.push(breakdown)
}
}
return normalizedBreakdowns
}
export function getAnalyticsStatsForBreakdowns(
breakdowns: AnalyticsBreakdownInput,
): readonly AnalyticsDashboardStat[] {
const normalizedBreakdowns = normalizeAnalyticsBreakdowns(breakdowns)
if (normalizedBreakdowns.length === 0) {
return getAnalyticsStatsForBreakdown('none')
}
let stats = [...getAnalyticsStatsForBreakdown(normalizedBreakdowns[0])]
for (const breakdown of normalizedBreakdowns.slice(1)) {
stats = intersectAnalyticsStats(stats, getAnalyticsStatsForBreakdown(breakdown))
}
return stats
}
export function getAnalyticsStatsForFilterCategory(
category: AnalyticsQueryFilterCategory,
): readonly AnalyticsDashboardStat[] {
if (category === 'project') {
return ANALYTICS_DASHBOARD_STAT_ORDER
}
return getAnalyticsStatsForDimension(ANALYTICS_DIMENSION_BY_FILTER_CATEGORY[category])
}
export function getAnalyticsFilterCategoryForBreakdown(
breakdown: AnalyticsBreakdownPreset,
): Exclude<AnalyticsQueryFilterCategory, 'project'> | null {
return ANALYTICS_FILTER_CATEGORY_BY_BREAKDOWN[breakdown]
}
function getAnalyticsStatsForFilterScope(
breakdowns: AnalyticsBreakdownInput,
filters: AnalyticsSelectedFilters,
ignoredCategory?: AnalyticsQueryFilterCategory,
): readonly AnalyticsDashboardStat[] {
let stats = [...getAnalyticsStatsForBreakdowns(breakdowns)]
for (const category of FILTER_VALUE_CATEGORIES) {
if (category === ignoredCategory || filters[category].length === 0) {
continue
}
stats = intersectAnalyticsStats(stats, getAnalyticsStatsForFilterCategory(category))
}
return stats
}
export function getEnabledAnalyticsStatsForState(
breakdowns: AnalyticsBreakdownInput,
filters: AnalyticsSelectedFilters,
): readonly AnalyticsDashboardStat[] {
return getAnalyticsStatsForFilterScope(breakdowns, filters)
}
export function getVisibleAnalyticsFilterCategoriesForState(
breakdowns: AnalyticsBreakdownInput,
filters: AnalyticsSelectedFilters,
): readonly Exclude<AnalyticsQueryFilterCategory, 'project'>[] {
return FILTER_VALUE_CATEGORIES.filter((category) =>
haveAnalyticsStatOverlap(
getAnalyticsStatsForFilterScope(breakdowns, filters, category),
getAnalyticsStatsForFilterCategory(category),
),
)
}
export function sanitizeAnalyticsSelectedFilters(
breakdowns: AnalyticsBreakdownInput,
filters: AnalyticsSelectedFilters,
): AnalyticsSelectedFilters {
const nextFilters = cloneSelectedFilters(filters)
let availableStats = [...getAnalyticsStatsForBreakdowns(breakdowns)]
for (const category of FILTER_VALUE_CATEGORIES) {
if (filters[category].length === 0) {
continue
}
const categoryStats = getAnalyticsStatsForFilterCategory(category)
if (!haveAnalyticsStatOverlap(availableStats, categoryStats)) {
nextFilters[category] = []
continue
}
availableStats = intersectAnalyticsStats(availableStats, categoryStats)
}
return nextFilters
}
export function cloneSelectedFilters(filters: AnalyticsSelectedFilters): AnalyticsSelectedFilters {
return {
project: [...filters.project],
project_status: [...filters.project_status],
country: [...filters.country],
monetization: [...filters.monetization],
user_agent: [...filters.user_agent],
download_reason: [...filters.download_reason],
version_id: [...filters.version_id],
game_version: [...filters.game_version],
loader_type: [...filters.loader_type],
}
}
export function areStringArraysEqual(left: string[], right: string[]): boolean {
if (left.length !== right.length) {
return false
}
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) {
return false
}
}
return true
}
export function areSelectedFiltersEqual(
left: AnalyticsSelectedFilters,
right: AnalyticsSelectedFilters,
): boolean {
if (!areStringArraysEqual(left.project, right.project)) {
return false
}
for (const categoryKey of FILTER_VALUE_CATEGORIES) {
if (!areStringArraysEqual(left[categoryKey], right[categoryKey])) {
return false
}
}
return true
}
export function getOptionsWithSelectedValues(
options: FilterOption[],
selectedValues: string[],
getMissingSelectedOptionLabel: (value: string) => string = (value) => value,
): FilterOption[] {
if (selectedValues.length === 0) {
return options
}
const knownValues = new Set(options.map((option) => option.value))
const missingSelectedOptions = selectedValues
.filter((value) => !knownValues.has(value))
.map((value) => ({
value,
label: getMissingSelectedOptionLabel(value),
}))
return missingSelectedOptions.length === 0 ? options : [...options, ...missingSelectedOptions]
}
export function normalizeSelectedValues(
categoryKey: AnalyticsQueryFilterCategory,
values: string[],
projectIds: string[],
): string[] {
const uniqueValues = Array.from(new Set(values))
if (categoryKey === 'project') {
if (uniqueValues.includes(ALL_FILTER_VALUE)) {
return projectIds
}
const allProjectIds = new Set(projectIds)
const selectedProjects = uniqueValues.filter((value) => allProjectIds.has(value))
return selectedProjects.length > 0 ? selectedProjects : projectIds
}
if (uniqueValues.includes(ALL_FILTER_VALUE) || uniqueValues.length === 0) {
return []
}
const selectedValues = uniqueValues.filter((value) => value !== ALL_FILTER_VALUE)
if (categoryKey === 'project_status') {
return selectedValues
.map((value) => value.trim().toLowerCase())
.filter(isProjectStatusFilterValue)
}
if (categoryKey === 'loader_type') {
return Array.from(
new Set(
selectedValues
.map((value) => value.trim().toLowerCase())
.filter((value) => value.length > 0),
),
)
}
return selectedValues
}
export const PROJECT_STATUS_FILTER_VALUES = [
'approved',
'archived',
'rejected',
'draft',
'unlisted',
'withheld',
'private',
'other',
] as const
export type ProjectStatusFilterValue = (typeof PROJECT_STATUS_FILTER_VALUES)[number]
const projectStatusFilterValueSet = new Set<string>(PROJECT_STATUS_FILTER_VALUES)
export function isProjectStatusFilterValue(value: string): value is ProjectStatusFilterValue {
return projectStatusFilterValueSet.has(value)
}
export function getProjectStatusFilterValue(
status: string | null | undefined,
): ProjectStatusFilterValue {
const normalizedStatus = status?.trim().toLowerCase() ?? ''
return isProjectStatusFilterValue(normalizedStatus) ? normalizedStatus : 'other'
}
@@ -0,0 +1,299 @@
import {
type AnalyticsGroupByPreset,
type AnalyticsLastTimeframeUnit,
type AnalyticsTimeframeMode,
type AnalyticsTimeframePreset,
injectAnalyticsDashboardContext,
} from '~/providers/analytics/analytics'
const MIN_RANGE_MS = 60 * 60 * 1000
const TIME_RANGE_ROUNDING_MS = 60 * 1000
export const MAX_ANALYTICS_TIME_SLICES = 256
const GROUP_BY_PRESET_MINUTES: Record<AnalyticsGroupByPreset, number> = {
'1h': 60,
'6h': 360,
day: 24 * 60,
week: 7 * 24 * 60,
month: 30 * 24 * 60,
year: 365 * 24 * 60,
}
export type AnalyticsTimeRange = {
start: Date
end: Date
}
export function startOfDay(date: Date): Date {
const nextDate = new Date(date)
nextDate.setHours(0, 0, 0, 0)
return nextDate
}
export function getRoundedNow(timestamp: number): Date {
const roundedTimestamp = Math.floor(timestamp / TIME_RANGE_ROUNDING_MS) * TIME_RANGE_ROUNDING_MS
return new Date(roundedTimestamp)
}
export function getDateInputValue(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export function parseDateInputValue(value: string): Date {
const parsedDate = new Date(`${value}T00:00:00`)
return Number.isNaN(parsedDate.getTime()) ? startOfDay(new Date()) : parsedDate
}
export function parseDateTimeInputValue(value: string): Date {
const parsedDate = new Date(value)
return Number.isNaN(parsedDate.getTime()) ? getRoundedNow(Date.now()) : parsedDate
}
export function addDays(date: Date, days: number): Date {
const nextDate = new Date(date)
nextDate.setDate(nextDate.getDate() + days)
return nextDate
}
function isStartOfDay(date: Date): boolean {
return (
date.getHours() === 0 &&
date.getMinutes() === 0 &&
date.getSeconds() === 0 &&
date.getMilliseconds() === 0
)
}
export function getInclusiveEndDateInputValue(end: Date): string {
return getDateInputValue(isStartOfDay(end) ? addDays(end, -1) : end)
}
function subtractCalendarMonths(date: Date, months: number): Date {
const nextDate = new Date(date)
const day = nextDate.getDate()
nextDate.setDate(1)
nextDate.setMonth(nextDate.getMonth() - months)
const daysInMonth = new Date(nextDate.getFullYear(), nextDate.getMonth() + 1, 0).getDate()
nextDate.setDate(Math.min(day, daysInMonth))
return nextDate
}
export function getTimeRangeForPreset(
preset: AnalyticsTimeframePreset,
nowTimestamp: number,
): AnalyticsTimeRange {
const now = getRoundedNow(nowTimestamp)
const end = new Date(now)
switch (preset) {
case 'today':
return { start: startOfDay(now), end }
case 'yesterday': {
const todayStart = startOfDay(now)
return {
start: new Date(todayStart.getTime() - 24 * 60 * 60 * 1000),
end: todayStart,
}
}
case 'last_7_days':
return {
start: new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000),
end,
}
case 'last_14_days':
return {
start: new Date(end.getTime() - 14 * 24 * 60 * 60 * 1000),
end,
}
case 'last_30_days':
return {
start: new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000),
end,
}
case 'last_90_days':
return {
start: new Date(end.getTime() - 90 * 24 * 60 * 60 * 1000),
end,
}
case 'last_180_days':
return {
start: new Date(end.getTime() - 180 * 24 * 60 * 60 * 1000),
end,
}
case 'year_to_date': {
const yearStart = new Date(now.getFullYear(), 0, 1)
yearStart.setHours(0, 0, 0, 0)
return { start: yearStart, end }
}
case 'all_time':
return {
start: new Date(Date.UTC(2023, 0, 1, 0, 0, 0, 0)),
end,
}
default:
return {
start: new Date(end.getTime() - 24 * 60 * 60 * 1000),
end,
}
}
}
export function getTimeRangeForLastTimeframe(
amountValue: number,
unit: AnalyticsLastTimeframeUnit,
nowTimestamp: number,
): AnalyticsTimeRange {
const end = getRoundedNow(nowTimestamp)
const amount = Math.max(1, Math.floor(amountValue))
switch (unit) {
case 'hours':
return { start: new Date(end.getTime() - amount * 60 * 60 * 1000), end }
case 'days':
return { start: new Date(end.getTime() - amount * 24 * 60 * 60 * 1000), end }
case 'weeks':
return { start: new Date(end.getTime() - amount * 7 * 24 * 60 * 60 * 1000), end }
case 'months':
return { start: subtractCalendarMonths(end, amount), end }
default:
return { start: new Date(end.getTime() - 24 * 60 * 60 * 1000), end }
}
}
export function getTimeRangeForCustomDateRange(
startDate: string,
endDate: string,
): AnalyticsTimeRange {
const start = parseDateInputValue(startDate)
const inclusiveEnd = parseDateInputValue(endDate)
return {
start,
end: addDays(inclusiveEnd, 1),
}
}
export function getTimeRangeForCustomDateTimeRange(
startDateTime: string,
endDateTime: string,
): AnalyticsTimeRange {
return {
start: parseDateTimeInputValue(startDateTime),
end: parseDateTimeInputValue(endDateTime),
}
}
export function getAnalyticsTimeRange({
mode,
preset,
lastAmount,
lastUnit,
customStartDate,
customEndDate,
nowTimestamp,
}: {
mode: AnalyticsTimeframeMode
preset: AnalyticsTimeframePreset
lastAmount: number
lastUnit: AnalyticsLastTimeframeUnit
customStartDate: string
customEndDate: string
nowTimestamp: number
}): AnalyticsTimeRange {
switch (mode) {
case 'last':
return getTimeRangeForLastTimeframe(lastAmount, lastUnit, nowTimestamp)
case 'custom_range':
return getTimeRangeForCustomDateRange(customStartDate, customEndDate)
case 'custom_datetime_range':
return getTimeRangeForCustomDateTimeRange(customStartDate, customEndDate)
case 'preset':
default:
return getTimeRangeForPreset(preset, nowTimestamp)
}
}
export function getDefaultAnalyticsGroupByForDurationMinutes(
durationMinutes: number,
): AnalyticsGroupByPreset {
const days = durationMinutes / (24 * 60)
if (days <= 2) return '1h'
if (days <= 7) return '6h'
if (days <= 90) return 'day'
if (days <= 365) return 'week'
if (days <= 365 * 3) return 'month'
return 'year'
}
export function getAnalyticsGroupByPresetMinutes(preset: AnalyticsGroupByPreset): number {
return GROUP_BY_PRESET_MINUTES[preset]
}
export function isAnalyticsGroupByAvailableForDurationMinutes(
preset: AnalyticsGroupByPreset,
durationMinutes: number,
): boolean {
const groupByMinutes = getAnalyticsGroupByPresetMinutes(preset)
const isTooCoarse = groupByMinutes >= durationMinutes
const isTooFine = durationMinutes / groupByMinutes > MAX_ANALYTICS_TIME_SLICES
return !isTooCoarse && !isTooFine
}
export function ensureMinimumTimeRange(start: Date, end: Date): AnalyticsTimeRange {
if (end.getTime() <= start.getTime()) {
return {
start: new Date(end.getTime() - MIN_RANGE_MS),
end,
}
}
if (end.getTime() - start.getTime() < MIN_RANGE_MS) {
return {
start: new Date(end.getTime() - MIN_RANGE_MS),
end,
}
}
return { start, end }
}
export function useSelectedAnalyticsTimeRange() {
const {
selectedTimeframeMode,
selectedTimeframe,
selectedLastTimeframeAmount,
selectedLastTimeframeUnit,
selectedCustomTimeframeStartDate,
selectedCustomTimeframeEndDate,
queryRefreshTimestamp,
} = injectAnalyticsDashboardContext()
const selectedTimeRange = computed(() =>
getAnalyticsTimeRange({
mode: selectedTimeframeMode.value,
preset: selectedTimeframe.value,
lastAmount: selectedLastTimeframeAmount.value,
lastUnit: selectedLastTimeframeUnit.value,
customStartDate: selectedCustomTimeframeStartDate.value,
customEndDate: selectedCustomTimeframeEndDate.value,
nowTimestamp: queryRefreshTimestamp.value,
}),
)
const selectedTimeframeDurationMinutes = computed(() => {
const { start, end } = ensureMinimumTimeRange(
selectedTimeRange.value.start,
selectedTimeRange.value.end,
)
const durationMs = end.getTime() - start.getTime()
return Math.max(1, Math.floor(durationMs / (60 * 1000)))
})
return {
selectedTimeRange,
selectedTimeframeDurationMinutes,
}
}
@@ -0,0 +1,156 @@
<template>
<button
v-tooltip="disabled ? formatMessage(analyticsStatCardMessages.unavailableTooltip) : ''"
type="button"
class="flex h-full appearance-none flex-col gap-2.5 rounded-2xl border border-solid p-5 px-4 text-left transition-colors sm:gap-4"
:class="{
'cursor-not-allowed border-surface-5 bg-surface-2 opacity-60': disabled,
'cursor-default border-brand bg-highlight-green': !disabled && active,
'border-surface-5 bg-surface-3 hover:bg-surface-4 active:scale-95': !disabled && !active,
}"
:disabled="disabled"
@click="emit('click')"
>
<div class="flex items-center justify-between gap-3">
<div
class="text-base font-medium"
:class="{
'text-secondary': disabled,
'text-primary': !disabled,
}"
>
{{ label }}
</div>
<div class="inline-flex items-center justify-center">
<component
:is="iconComponent"
aria-hidden="true"
class="size-5 sm:size-6"
:class="{
'text-secondary': disabled,
'text-brand': !disabled && active,
'text-primary': !disabled && !active,
}"
/>
</div>
</div>
<div class="flex flex-col gap-2.5">
<div
class="text-2xl font-semibold leading-none md:text-4xl"
:class="{
'text-primary': disabled,
'text-contrast': !disabled,
}"
>
{{ disabled ? '-' : statLabel }}
</div>
<template v-if="disabled">
<span class="inline-flex items-center gap-1 text-xs text-secondary">
{{ formatMessage(analyticsStatCardMessages.unavailableLabel) }}
</span>
</template>
<template v-else>
<div v-if="vsPrevPeriodPercent" class="flex items-center gap-1 text-sm">
<span
class="inline-flex items-center gap-1 font-semibold"
:class="{
'text-secondary': disabled,
'text-green': !disabled && trendValue > 0,
'text-red': !disabled && trendValue < 0,
'text-primary': !disabled && trendValue === 0,
}"
>
<component
:is="trendDirectionIcon"
v-if="showTrendDirectionIcon"
aria-hidden="true"
class="size-3"
/>
{{ vsPrevPeriodPercent }}
</span>
<span
class="mt-px text-xs max-sm:hidden"
:class="{
'text-secondary': disabled,
'text-primary': !disabled,
}"
>
{{ formatMessage(analyticsStatCardMessages.previousPeriodComparison) }}
</span>
<span
class="visible mt-px text-xs sm:hidden"
:class="{
'text-secondary': disabled,
'text-primary': !disabled,
}"
>
{{ formatMessage(analyticsStatCardMessages.previousPeriodComparisonShort) }}
</span>
</div>
</template>
</div>
</button>
</template>
<script setup lang="ts">
import {
ClockIcon,
CurrencyIcon,
DownloadIcon,
EyeIcon,
type IconComponent,
PlayIcon,
TimerIcon,
TrendingDownIcon,
TrendingUpIcon,
} from '@modrinth/assets'
import { useVIntl } from '@modrinth/ui'
import { analyticsStatCardMessages } from '../analytics-messages'
const props = defineProps<{
label: string
statLabel: string
vsPrevPeriodPercent: string | null
icon: string
active?: boolean
disabled?: boolean
}>()
const emit = defineEmits<{
(event: 'click'): void
}>()
const { formatMessage } = useVIntl()
const statCardIconMap: Record<string, IconComponent> = {
clock: ClockIcon,
timer: TimerIcon,
play: PlayIcon,
eye: EyeIcon,
download: DownloadIcon,
currency: CurrencyIcon,
dollar: CurrencyIcon,
}
const iconComponent = computed<IconComponent>(() => {
const normalizedIconName = props.icon
.toLowerCase()
.replace(/icon$/u, '')
.replace(/[^a-z]/gu, '')
return statCardIconMap[normalizedIconName] ?? ClockIcon
})
const trendValue = computed(() => {
const parsed = Number.parseFloat(props.vsPrevPeriodPercent?.replace(/[^0-9.-]/gu, '') ?? '')
return Number.isNaN(parsed) ? 0 : parsed
})
const showTrendDirectionIcon = computed(() => !props.disabled && trendValue.value !== 0)
const trendDirectionIcon = computed<IconComponent>(() =>
trendValue.value >= 0 ? TrendingUpIcon : TrendingDownIcon,
)
</script>
@@ -0,0 +1,119 @@
<template>
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard
v-for="card in statCards"
:key="card.key"
:label="card.label"
:stat-label="card.statLabel"
:vs-prev-period-percent="card.vsPrevPeriodPercent"
:icon="card.icon"
:active="activeStat === card.key"
:disabled="card.disabled"
@click="setActiveStat(card.key)"
/>
</div>
</template>
<script setup lang="ts">
import { useFormatNumber, useVIntl } from '@modrinth/ui'
import {
type AnalyticsDashboardStat,
injectAnalyticsDashboardContext,
} from '~/providers/analytics/analytics'
import { analyticsStatCardMessages, formatAnalyticsStatLabel } from '../analytics-messages.ts'
import StatCard from './StatCard.vue'
const {
activeStat,
setActiveStat,
currentTotals,
percentChanges,
hasPreviousPeriodComparison,
selectedBreakdowns,
isAnalyticsDashboardStatRelevant,
} = injectAnalyticsDashboardContext()
const formatNumber = useFormatNumber()
const { formatMessage } = useVIntl()
const compactNumberFormatter = computed(
() =>
new Intl.NumberFormat(undefined, {
notation: 'compact',
maximumSignificantDigits: 2,
}),
)
function formatStatNumber(value: number): string {
const rounded = Math.round(value)
if (Math.abs(rounded) >= 1000) {
return compactNumberFormatter.value.format(rounded)
}
return formatNumber(rounded)
}
function formatPercent(value: number): string {
const rounded = Math.round(value * 10) / 10
const signPrefix = rounded > 0 ? '+' : ''
return `${signPrefix}${rounded.toFixed(1)}%`
}
function formatPreviousPeriodPercent(value: number): string | null {
if (!hasPreviousPeriodComparison.value) {
return null
}
return formatPercent(value)
}
const statCards = computed<
{
key: AnalyticsDashboardStat
label: string
statLabel: string
vsPrevPeriodPercent: string | null
icon: string
disabled: boolean
}[]
>(() => [
{
key: 'views',
label: formatAnalyticsStatLabel('views', formatMessage),
statLabel: formatStatNumber(currentTotals.value.views),
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.views),
icon: 'eye',
disabled: !isAnalyticsDashboardStatRelevant('views', selectedBreakdowns.value),
},
{
key: 'downloads',
label: formatAnalyticsStatLabel('downloads', formatMessage),
statLabel: formatStatNumber(currentTotals.value.downloads),
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.downloads),
icon: 'download',
disabled: !isAnalyticsDashboardStatRelevant('downloads', selectedBreakdowns.value),
},
{
key: 'revenue',
label: formatAnalyticsStatLabel('revenue', formatMessage),
statLabel: formatMessage(analyticsStatCardMessages.revenueValue, {
value: formatStatNumber(currentTotals.value.revenue),
}),
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.revenue),
icon: 'dollar',
disabled: !isAnalyticsDashboardStatRelevant('revenue', selectedBreakdowns.value),
},
{
key: 'playtime',
label: formatAnalyticsStatLabel('playtime', formatMessage),
statLabel: formatMessage(analyticsStatCardMessages.playtimeHours, {
hours: formatStatNumber(currentTotals.value.playtime / 3600),
}),
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.playtime),
icon: 'clock',
disabled: !isAnalyticsDashboardStatRelevant('playtime', selectedBreakdowns.value),
},
])
</script>
@@ -0,0 +1,306 @@
import type { Ref } from 'vue'
import type { LocationQuery } from 'vue-router'
import {
areSelectedFiltersEqual,
areStringArraysEqual,
buildAnalyticsQueryBuilderRouteQuery,
getAnalyticsBreakdownPresetsForProjectSelection,
hasAnalyticsQueryBuilderRouteChange,
readAnalyticsGraphState,
readAnalyticsQueryBuilderState,
} from '~/components/analytics-dashboard/analytics-route-query'
import type {
AnalyticsBreakdownPreset,
AnalyticsDashboardStat,
AnalyticsGraphViewMode,
AnalyticsGroupByPreset,
AnalyticsLastTimeframeUnit,
AnalyticsSelectedBreakdowns,
AnalyticsSelectedFilters,
AnalyticsTimeframeMode,
AnalyticsTimeframePreset,
} from '~/providers/analytics/analytics-types'
export type AnalyticsQueryBuilderRouteNavigationMode = 'push' | 'replace'
export interface AnalyticsQueryBuilderRefs {
selectedProjectIds: Ref<string[]>
selectedTimeframeMode: Ref<AnalyticsTimeframeMode>
selectedTimeframe: Ref<AnalyticsTimeframePreset>
selectedLastTimeframeAmount: Ref<number>
selectedLastTimeframeUnit: Ref<AnalyticsLastTimeframeUnit>
selectedCustomTimeframeStartDate: Ref<string>
selectedCustomTimeframeEndDate: Ref<string>
selectedGroupBy: Ref<AnalyticsGroupByPreset>
selectedBreakdowns: Ref<AnalyticsSelectedBreakdowns>
selectedFilters: Ref<AnalyticsSelectedFilters>
}
export interface AnalyticsGraphRefs {
activeStat: Ref<AnalyticsDashboardStat>
activeGraphViewMode: Ref<AnalyticsGraphViewMode>
isRatioMode: Ref<boolean>
showChartEvents: Ref<boolean>
showProjectEvents: Ref<boolean>
showPreviousPeriod: Ref<boolean>
hiddenGraphDatasetIds: Ref<string[]>
hasExplicitGraphDatasetSelection: Ref<boolean>
selectedGraphDatasetIds: Ref<string[]>
}
export interface UseAnalyticsRouteSyncOptions {
queryBuilder: AnalyticsQueryBuilderRefs
graph: AnalyticsGraphRefs
availableProjectIds: Ref<string[]>
sanitizeSelectedFilters: (
breakdowns: readonly AnalyticsBreakdownPreset[],
filters: AnalyticsSelectedFilters,
) => AnalyticsSelectedFilters
}
export function useAnalyticsRouteSync(options: UseAnalyticsRouteSyncOptions) {
const { queryBuilder, graph, availableProjectIds, sanitizeSelectedFilters } = options
const route = useRoute()
const router = useRouter()
let nextAnalyticsRouteNavigationMode: AnalyticsQueryBuilderRouteNavigationMode = 'replace'
function replaceNextAnalyticsRouteNavigation() {
nextAnalyticsRouteNavigationMode = 'replace'
}
function consumeAnalyticsRouteNavigationMode(): AnalyticsQueryBuilderRouteNavigationMode {
const navigationMode = nextAnalyticsRouteNavigationMode
nextAnalyticsRouteNavigationMode = 'push'
return navigationMode
}
function getSelectedAnalyticsQueryBuilderState() {
return {
selectedProjectIds: queryBuilder.selectedProjectIds.value,
selectedTimeframeMode: queryBuilder.selectedTimeframeMode.value,
selectedTimeframe: queryBuilder.selectedTimeframe.value,
selectedLastTimeframeAmount: queryBuilder.selectedLastTimeframeAmount.value,
selectedLastTimeframeUnit: queryBuilder.selectedLastTimeframeUnit.value,
selectedCustomTimeframeStartDate: queryBuilder.selectedCustomTimeframeStartDate.value,
selectedCustomTimeframeEndDate: queryBuilder.selectedCustomTimeframeEndDate.value,
selectedGroupBy: queryBuilder.selectedGroupBy.value,
selectedBreakdowns: queryBuilder.selectedBreakdowns.value,
selectedFilters: queryBuilder.selectedFilters.value,
}
}
function getSelectedAnalyticsGraphState() {
return {
activeStat: graph.activeStat.value,
activeGraphViewMode: graph.activeGraphViewMode.value,
isRatioMode: graph.isRatioMode.value,
showChartEvents: graph.showChartEvents.value,
showProjectEvents: graph.showProjectEvents.value,
showPreviousPeriod: graph.showPreviousPeriod.value,
hiddenGraphDatasetIds: graph.hiddenGraphDatasetIds.value,
selectedGraphDatasetIds: graph.hasExplicitGraphDatasetSelection.value
? graph.selectedGraphDatasetIds.value
: null,
}
}
function syncAnalyticsRouteQuery(navigationMode: AnalyticsQueryBuilderRouteNavigationMode) {
if (import.meta.server) {
return
}
const nextRouteQuery = buildAnalyticsQueryBuilderRouteQuery(
route.query,
getSelectedAnalyticsQueryBuilderState(),
availableProjectIds.value,
getSelectedAnalyticsGraphState(),
)
const hasAnalyticsQueryChange = hasAnalyticsQueryBuilderRouteChange(route.query, nextRouteQuery)
if (!hasAnalyticsQueryChange) return
if (navigationMode === 'replace') {
router.replace({
path: route.path,
query: nextRouteQuery,
})
} else {
router.push({
path: route.path,
query: nextRouteQuery,
})
}
}
function syncQueryBuilderRouteQuery() {
syncAnalyticsRouteQuery(consumeAnalyticsRouteNavigationMode())
}
function syncGraphRouteQuery() {
syncAnalyticsRouteQuery('replace')
}
function applyRouteQueryToState(nextQuery: LocationQuery) {
const nextQueryState = readAnalyticsQueryBuilderState(nextQuery, availableProjectIds.value)
const availableProjectIdSet = new Set(availableProjectIds.value)
const nextSelectedProjectIds = nextQueryState.selectedProjectIds.filter((projectId) =>
availableProjectIdSet.has(projectId),
)
const nextGraphState = readAnalyticsGraphState(nextQuery, nextSelectedProjectIds)
const nextSelectedBreakdowns = getAnalyticsBreakdownPresetsForProjectSelection(
nextQueryState.selectedBreakdowns,
nextSelectedProjectIds,
)
const nextSelectedFilters = sanitizeSelectedFilters(
nextSelectedBreakdowns,
nextQueryState.selectedFilters,
)
const shouldUpdateSelectedProjectIds = !areStringArraysEqual(
queryBuilder.selectedProjectIds.value,
nextSelectedProjectIds,
)
const shouldUpdateSelectedTimeframeMode =
queryBuilder.selectedTimeframeMode.value !== nextQueryState.selectedTimeframeMode
const shouldUpdateSelectedTimeframe =
queryBuilder.selectedTimeframe.value !== nextQueryState.selectedTimeframe
const shouldUpdateSelectedLastTimeframeAmount =
queryBuilder.selectedLastTimeframeAmount.value !== nextQueryState.selectedLastTimeframeAmount
const shouldUpdateSelectedLastTimeframeUnit =
queryBuilder.selectedLastTimeframeUnit.value !== nextQueryState.selectedLastTimeframeUnit
const shouldUpdateSelectedCustomTimeframeStartDate =
queryBuilder.selectedCustomTimeframeStartDate.value !==
nextQueryState.selectedCustomTimeframeStartDate
const shouldUpdateSelectedCustomTimeframeEndDate =
queryBuilder.selectedCustomTimeframeEndDate.value !==
nextQueryState.selectedCustomTimeframeEndDate
const shouldUpdateSelectedGroupBy =
queryBuilder.selectedGroupBy.value !== nextQueryState.selectedGroupBy
const shouldUpdateSelectedBreakdowns = !areStringArraysEqual(
queryBuilder.selectedBreakdowns.value,
nextSelectedBreakdowns,
)
const shouldUpdateSelectedFilters = !areSelectedFiltersEqual(
queryBuilder.selectedFilters.value,
nextSelectedFilters,
)
const shouldUpdateActiveStat = graph.activeStat.value !== nextGraphState.activeStat
const shouldUpdateActiveGraphViewMode =
graph.activeGraphViewMode.value !== nextGraphState.activeGraphViewMode
const shouldUpdateIsRatioMode = graph.isRatioMode.value !== nextGraphState.isRatioMode
const shouldUpdateShowChartEvents =
graph.showChartEvents.value !== nextGraphState.showChartEvents
const shouldUpdateShowProjectEvents =
graph.showProjectEvents.value !== nextGraphState.showProjectEvents
const shouldUpdateShowPreviousPeriod =
graph.showPreviousPeriod.value !== nextGraphState.showPreviousPeriod
const shouldUpdateHiddenGraphDatasetIds = !areStringArraysEqual(
graph.hiddenGraphDatasetIds.value,
nextGraphState.hiddenGraphDatasetIds,
)
const nextHasExplicitGraphDatasetSelection = nextGraphState.selectedGraphDatasetIds !== null
const nextSelectedGraphDatasetIds = nextGraphState.selectedGraphDatasetIds ?? []
const shouldUpdateHasExplicitGraphDatasetSelection =
graph.hasExplicitGraphDatasetSelection.value !== nextHasExplicitGraphDatasetSelection
const shouldUpdateSelectedGraphDatasetIds =
(nextHasExplicitGraphDatasetSelection || graph.hasExplicitGraphDatasetSelection.value) &&
!areStringArraysEqual(graph.selectedGraphDatasetIds.value, nextSelectedGraphDatasetIds)
const hasRouteStateUpdate =
shouldUpdateSelectedProjectIds ||
shouldUpdateSelectedTimeframeMode ||
shouldUpdateSelectedTimeframe ||
shouldUpdateSelectedLastTimeframeAmount ||
shouldUpdateSelectedLastTimeframeUnit ||
shouldUpdateSelectedCustomTimeframeStartDate ||
shouldUpdateSelectedCustomTimeframeEndDate ||
shouldUpdateSelectedGroupBy ||
shouldUpdateSelectedBreakdowns ||
shouldUpdateSelectedFilters ||
shouldUpdateActiveStat ||
shouldUpdateActiveGraphViewMode ||
shouldUpdateIsRatioMode ||
shouldUpdateShowChartEvents ||
shouldUpdateShowProjectEvents ||
shouldUpdateShowPreviousPeriod ||
shouldUpdateHiddenGraphDatasetIds ||
shouldUpdateHasExplicitGraphDatasetSelection ||
shouldUpdateSelectedGraphDatasetIds
if (hasRouteStateUpdate) {
replaceNextAnalyticsRouteNavigation()
}
if (shouldUpdateSelectedProjectIds) {
queryBuilder.selectedProjectIds.value = nextSelectedProjectIds
}
if (shouldUpdateSelectedTimeframeMode) {
queryBuilder.selectedTimeframeMode.value = nextQueryState.selectedTimeframeMode
}
if (shouldUpdateSelectedTimeframe) {
queryBuilder.selectedTimeframe.value = nextQueryState.selectedTimeframe
}
if (shouldUpdateSelectedLastTimeframeAmount) {
queryBuilder.selectedLastTimeframeAmount.value = nextQueryState.selectedLastTimeframeAmount
}
if (shouldUpdateSelectedLastTimeframeUnit) {
queryBuilder.selectedLastTimeframeUnit.value = nextQueryState.selectedLastTimeframeUnit
}
if (shouldUpdateSelectedCustomTimeframeStartDate) {
queryBuilder.selectedCustomTimeframeStartDate.value =
nextQueryState.selectedCustomTimeframeStartDate
}
if (shouldUpdateSelectedCustomTimeframeEndDate) {
queryBuilder.selectedCustomTimeframeEndDate.value =
nextQueryState.selectedCustomTimeframeEndDate
}
if (shouldUpdateSelectedGroupBy) {
queryBuilder.selectedGroupBy.value = nextQueryState.selectedGroupBy
}
if (shouldUpdateSelectedBreakdowns) {
queryBuilder.selectedBreakdowns.value = nextSelectedBreakdowns
}
if (shouldUpdateSelectedFilters) {
queryBuilder.selectedFilters.value = nextSelectedFilters
}
if (shouldUpdateActiveStat) {
graph.activeStat.value = nextGraphState.activeStat
}
if (shouldUpdateActiveGraphViewMode) {
graph.activeGraphViewMode.value = nextGraphState.activeGraphViewMode
}
if (shouldUpdateIsRatioMode) {
graph.isRatioMode.value = nextGraphState.isRatioMode
}
if (shouldUpdateShowChartEvents) {
graph.showChartEvents.value = nextGraphState.showChartEvents
}
if (shouldUpdateShowProjectEvents) {
graph.showProjectEvents.value = nextGraphState.showProjectEvents
}
if (shouldUpdateShowPreviousPeriod) {
graph.showPreviousPeriod.value = nextGraphState.showPreviousPeriod
}
if (shouldUpdateHiddenGraphDatasetIds) {
graph.hiddenGraphDatasetIds.value = nextGraphState.hiddenGraphDatasetIds
}
if (shouldUpdateHasExplicitGraphDatasetSelection) {
graph.hasExplicitGraphDatasetSelection.value = nextHasExplicitGraphDatasetSelection
}
if (shouldUpdateSelectedGraphDatasetIds) {
graph.selectedGraphDatasetIds.value = nextSelectedGraphDatasetIds
}
if (!hasRouteStateUpdate) {
syncAnalyticsRouteQuery('replace')
}
}
return {
replaceNextAnalyticsRouteNavigation,
syncQueryBuilderRouteQuery,
syncGraphRouteQuery,
applyRouteQueryToState,
}
}
@@ -1,495 +0,0 @@
<script setup>
import { useFormatDateTime, useFormatMoney, useFormatNumber } from '@modrinth/ui'
import VueApexCharts from 'vue3-apexcharts'
const props = defineProps({
name: {
type: String,
required: true,
},
labels: {
type: Array,
required: true,
},
data: {
type: Array,
required: true,
},
formatLabels: {
type: Function,
},
colors: {
type: Array,
default: () => [],
},
prefix: {
type: String,
default: '',
},
suffix: {
type: String,
default: '',
},
hideToolbar: {
type: Boolean,
default: false,
},
hideLegend: {
type: Boolean,
default: false,
},
stacked: {
type: Boolean,
default: false,
},
type: {
type: String,
default: 'bar',
},
hideTotal: {
type: Boolean,
default: false,
},
isMoney: {
type: Boolean,
default: false,
},
legendPosition: {
type: String,
default: 'right',
},
xAxisType: {
type: String,
default: 'datetime',
},
percentStacked: {
type: Boolean,
default: false,
},
horizontalBar: {
type: Boolean,
default: false,
},
disableAnimations: {
type: Boolean,
default: false,
},
})
const formatNumber = useFormatNumber()
const formatMoney = useFormatMoney()
const formatDate = useFormatDateTime({
month: 'short',
day: 'numeric',
})
function formatTooltipValue(value, props) {
return props.isMoney ? formatMoney(value) : formatNumber(value)
}
function generateListEntry(value, index, _, w, props) {
const color = w.globals.colors?.[index]
return `<div class="list-entry">
<span class="circle" style="background-color: ${color}"></span>
<div class="label">
${w.globals.seriesNames[index]}
</div>
<div class="value">
${props.prefix}${formatTooltipValue(value, props)}${props.suffix}
</div>
</div>`
}
function generateTooltip({ series, seriesIndex, dataPointIndex, w }, props) {
const label = w.globals.lastXAxis.categories?.[dataPointIndex]
const formattedLabel = props.formatLabels ? props.formatLabels(label) : formatDate(label)
let tooltip = `<div class="bar-tooltip">
<div class="seperated-entry title">
<div class="label">${formattedLabel}</div>`
// Logic for total and percent stacked
if (!props.hideTotal) {
if (props.percentStacked) {
const total = series.reduce((a, b) => a + (b?.[dataPointIndex] || 0), 0)
const percentValue = (100 * series[seriesIndex][dataPointIndex]) / total
tooltip += `<div class="value">${props.prefix}${formatNumber(percentValue)}%${
props.suffix
}</div>`
} else {
const totalValue = series.reduce((a, b) => a + (b?.[dataPointIndex] || 0), 0)
tooltip += `<div class="value">${props.prefix}${formatTooltipValue(totalValue, props)}${
props.suffix
}</div>`
}
}
tooltip += '</div><hr class="card-divider" />'
// Logic for generating list entries
if (props.percentStacked) {
tooltip += generateListEntry(
series[seriesIndex][dataPointIndex],
seriesIndex,
seriesIndex,
w,
props,
)
} else {
const returnTopN = 15
const listEntries = series
.map((value, index) => [
value[dataPointIndex],
generateListEntry(value[dataPointIndex], index, seriesIndex, w, props),
])
.filter((value) => value[0] > 0)
.sort((a, b) => b[0] - a[0])
.slice(0, returnTopN) // Return only the top X entries
.map((value) => value[1])
.join('')
tooltip += listEntries
}
tooltip += '</div>'
return tooltip
}
const chartOptions = computed(() => {
return {
chart: {
id: props.name,
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
foreColor: 'var(--color-base)',
selection: {
enabled: true,
fill: {
color: 'var(--color-brand)',
},
},
toolbar: {
show: false,
},
stacked: props.stacked,
stackType: props.percentStacked ? '100%' : 'normal',
zoom: {
autoScaleYaxis: true,
},
animations: {
enabled: props.disableAnimations,
},
},
xaxis: {
type: props.xAxisType,
categories: props.labels,
labels: {
style: {
borderRadius: 'var(--radius-sm)',
},
},
axisTicks: {
show: false,
},
tooltip: {
enabled: false,
},
},
yaxis: {
tooltip: {
enabled: false,
},
},
colors: props.colors,
dataLabels: {
enabled: false,
background: {
enabled: true,
borderRadius: 20,
},
},
grid: {
borderColor: 'var(--color-button-bg)',
tickColor: 'var(--color-button-bg)',
},
legend: {
show: !props.hideLegend,
position: props.legendPosition,
showForZeroSeries: false,
showForSingleSeries: false,
showForNullSeries: false,
fontSize: 'var(--font-size-nm)',
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
onItemClick: {
toggleDataSeries: true,
},
},
markers: {
size: 0,
strokeColor: 'var(--color-contrast)',
strokeWidth: 3,
strokeOpacity: 1,
fillOpacity: 1,
hover: {
size: 6,
},
},
plotOptions: {
bar: {
horizontal: props.horizontalBar,
columnWidth: '80%',
endingShape: 'rounded',
borderRadius: 5,
borderRadiusApplication: 'end',
borderRadiusWhenStacked: 'last',
},
},
stroke: {
curve: 'smooth',
width: 2,
},
tooltip: {
custom: (d) => generateTooltip(d, props),
},
fill:
props.type === 'area'
? {
colors: props.colors,
type: 'gradient',
opacity: 1,
gradient: {
shade: 'light',
type: 'vertical',
shadeIntensity: 0,
gradientToColors: props.colors,
inverseColors: true,
opacityFrom: 0.5,
opacityTo: 0,
stops: [0, 100],
colorStops: [],
},
}
: {},
}
})
const chart = ref(null)
const legendValues = ref(
[...props.data].map((project, index) => {
return { name: project.name, visible: true, color: props.colors[index] }
}),
)
const flipLegend = (legend, newVal) => {
legend.visible = newVal
chart.value.toggleSeries(legend.name)
}
const resetChart = () => {
if (!chart.value?.chart) return
chart.value.updateSeries([...props.data])
chart.value.updateOptions({
xaxis: {
categories: props.labels,
},
})
chart.value.resetSeries()
legendValues.value.forEach((legend) => {
legend.visible = true
})
}
defineExpose({
resetChart,
flipLegend,
})
</script>
<template>
<VueApexCharts ref="chart" :type="type" :options="chartOptions" :series="data" class="chart" />
</template>
<style scoped lang="scss">
.chart {
width: 100%;
height: 100%;
}
svg {
width: 100%;
height: 100%;
}
.btn {
svg {
width: 1.25rem;
height: 1.25rem;
}
}
.bar-chart {
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
}
.title-bar {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-xs);
}
.toolbar {
display: flex;
flex-direction: row;
gap: var(--gap-xs);
z-index: 1;
margin-left: auto;
}
:deep(.apexcharts-menu),
:deep(.apexcharts-tooltip),
:deep(.apexcharts-yaxistooltip) {
background: var(--color-raised-bg) !important;
border-radius: var(--radius-sm) !important;
border: 1px solid var(--color-divider) !important;
box-shadow: var(--shadow-floating) !important;
font-size: var(--font-size-nm) !important;
}
:deep(.apexcharts-grid-borders) {
line {
stroke: var(--color-button-bg) !important;
}
}
:deep(.apexcharts-yaxistooltip),
:deep(.apexcharts-xaxistooltip) {
background: var(--color-raised-bg) !important;
border-radius: var(--radius-sm) !important;
border: 1px solid var(--color-divider) !important;
font-size: var(--font-size-nm) !important;
color: var(--color-base) !important;
.apexcharts-xaxistooltip-text {
font-size: var(--font-size-nm) !important;
color: var(--color-base) !important;
}
}
:deep(.apexcharts-yaxistooltip-left:after) {
border-left-color: var(--color-raised-bg) !important;
}
:deep(.apexcharts-yaxistooltip-left:before) {
border-left-color: var(--color-button-bg) !important;
}
:deep(.apexcharts-xaxistooltip-bottom:after) {
border-bottom-color: var(--color-raised-bg) !important;
}
:deep(.apexcharts-xaxistooltip-bottom:before) {
border-bottom-color: var(--color-button-bg) !important;
}
:deep(.apexcharts-menu-item) {
border-radius: var(--radius-sm) !important;
padding: var(--gap-xs) var(--gap-sm) !important;
&:hover {
transition: all 0.3s !important;
color: var(--color-accent-contrast) !important;
background: var(--color-brand) !important;
}
}
:deep(.apexcharts-tooltip) {
.bar-tooltip {
min-width: 10rem;
display: flex;
flex-direction: column;
gap: var(--gap-xs);
padding: var(--gap-sm);
.card-divider {
margin: var(--gap-xs) 0;
}
.seperated-entry {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.title {
font-weight: bolder;
}
.label {
margin-right: var(--gap-xl);
color: var(--color-contrast);
}
.value {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-xs);
color: var(--color-base);
}
.list-entry {
display: flex;
flex-direction: row;
align-items: center;
font-size: var(--font-size-sm);
.value {
margin-left: auto;
}
}
.circle {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
display: inline-block;
margin-right: var(--gap-sm);
border: 2px solid var(--color-base);
}
svg {
height: 1em;
width: 1em;
}
}
}
.legend {
display: flex;
flex-wrap: wrap;
flex-direction: row;
align-items: center;
gap: var(--gap-lg);
justify-content: center;
}
:deep(.checkbox) {
white-space: nowrap;
}
.legend-checkbox :deep(.checkbox.checked) {
background-color: var(--color);
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,281 +0,0 @@
<script setup>
import { Card } from '@modrinth/ui'
import VueApexCharts from 'vue3-apexcharts'
// let VueApexCharts
// if (import.meta.client) {
// VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
// }
const props = defineProps({
value: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
data: {
type: Array,
default: () => [],
},
labels: {
type: Array,
default: () => [],
},
prefix: {
type: String,
default: '',
},
suffix: {
type: String,
default: '',
},
isMoney: {
type: Boolean,
default: false,
},
color: {
type: String,
default: 'var(--color-brand)',
},
})
// no grid lines, no toolbar, no legend, no data labels
const chartOptions = {
chart: {
id: props.title,
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
foreColor: 'var(--color-base)',
toolbar: {
show: false,
},
zoom: {
enabled: false,
},
sparkline: {
enabled: true,
},
parentHeightOffset: 0,
},
stroke: {
curve: 'smooth',
width: 2,
},
fill: {
colors: [props.color],
type: 'gradient',
opacity: 1,
gradient: {
shade: 'light',
type: 'vertical',
shadeIntensity: 0,
gradientToColors: [props.color],
inverseColors: true,
opacityFrom: 0.5,
opacityTo: 0,
stops: [0, 100],
colorStops: [],
},
},
grid: {
show: false,
},
legend: {
show: false,
},
colors: [props.color],
dataLabels: {
enabled: false,
},
xaxis: {
type: 'datetime',
categories: props.labels,
labels: {
show: false,
},
axisTicks: {
show: false,
},
tooltip: {
enabled: false,
},
},
yaxis: {
labels: {
show: false,
},
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
tooltip: {
enabled: false,
},
},
tooltip: {
enabled: false,
},
}
const chart = ref(null)
const resetChart = () => {
if (!chart.value?.chart) return
chart.value.updateSeries([...props.data])
chart.value.updateOptions({
xaxis: {
categories: props.labels,
},
})
chart.value.resetSeries()
}
defineExpose({
resetChart,
})
</script>
<template>
<Card class="compact-chart">
<h1 class="value">
{{ value }}
</h1>
<div class="subtitle">
{{ title }}
</div>
<div class="chart">
<VueApexCharts ref="chart" type="area" :options="chartOptions" :series="data" height="70" />
</div>
</Card>
</template>
<style scoped lang="scss">
.compact-chart {
display: flex;
flex-direction: column;
gap: var(--gap-xs);
border: 1px solid var(--color-divider);
border-radius: var(--radius-md);
background-color: var(--color-raised-bg);
box-shadow: var(--shadow-card);
color: var(--color-base);
font-size: var(--font-size-nm);
width: 100%;
padding-top: var(--gap-xl);
padding-bottom: 0;
.value {
margin: 0;
}
}
.chart {
// width: calc(100% + 3rem);
margin: 0 -1.5rem 0.25rem -1.5rem;
}
svg {
width: 100%;
height: 100%;
}
:deep(.apexcharts-menu),
:deep(.apexcharts-tooltip),
:deep(.apexcharts-yaxistooltip) {
background: var(--color-raised-bg) !important;
border-radius: var(--radius-sm) !important;
border: 1px solid var(--color-divider) !important;
box-shadow: var(--shadow-floating) !important;
font-size: var(--font-size-nm) !important;
}
:deep(.apexcharts-graphical) {
width: 100%;
}
:deep(.apexcharts-tooltip) {
.bar-tooltip {
display: flex;
flex-direction: column;
gap: var(--gap-xs);
padding: var(--gap-sm);
.card-divider {
margin: var(--gap-xs) 0;
}
.label {
display: flex;
flex-direction: row;
align-items: center;
}
.value {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-xs);
color: var(--color-base);
}
.list-entry {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: var(--gap-md);
}
.circle {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
display: inline-block;
margin-right: var(--gap-sm);
}
svg {
height: 1em;
width: 1em;
}
.divider {
font-size: var(--font-size-lg);
font-weight: 400;
}
}
}
.legend {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-md);
justify-content: center;
}
:deep(.apexcharts-grid-borders) {
line {
stroke: var(--color-button-bg) !important;
}
}
:deep(.apexcharts-xaxis) {
line {
stroke: none;
}
}
.legend-checkbox :deep(.checkbox.checked) {
background-color: var(--color);
}
</style>
+5 -1
View File
@@ -467,7 +467,7 @@
<OverflowMenu
v-if="auth.user"
:dropdown-id="`${basePopoutId}-user`"
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1 pr-1"
:options="userMenuOptions"
>
<Avatar :src="auth.user.avatar_url" aria-hidden="true" circle />
@@ -1256,6 +1256,10 @@ async function logoutUser() {
}
function runAnalytics() {
if (import.meta.dev) {
return
}
const config = useRuntimeConfig()
const replacedUrl = config.public.apiBaseUrl.replace('v2/', '')
+468
View File
@@ -8,6 +8,474 @@
"admin.billing.error.not-found": {
"message": "User not found"
},
"analytics.action.add": {
"message": "Add"
},
"analytics.action.cancel": {
"message": "Cancel"
},
"analytics.action.refresh": {
"message": "Refresh"
},
"analytics.action.reset": {
"message": "Reset"
},
"analytics.breakdown.country": {
"message": "Country"
},
"analytics.breakdown.download-reason": {
"message": "Download reason"
},
"analytics.breakdown.download-source": {
"message": "Download source"
},
"analytics.breakdown.game-version": {
"message": "Game version"
},
"analytics.breakdown.generic": {
"message": "Breakdown"
},
"analytics.breakdown.loader": {
"message": "Loader"
},
"analytics.breakdown.monetization": {
"message": "Monetization"
},
"analytics.breakdown.none.selected": {
"message": "No breakdown"
},
"analytics.breakdown.project": {
"message": "Project"
},
"analytics.breakdown.project-status": {
"message": "Project status"
},
"analytics.breakdown.project-version": {
"message": "Project version"
},
"analytics.breakdown.selected": {
"message": "Breakdown by {breakdown}"
},
"analytics.chart.action.show-all": {
"message": "Show all"
},
"analytics.chart.action.show-limited": {
"message": "Show limited"
},
"analytics.chart.action.show-top-eight": {
"message": "Show top 8"
},
"analytics.chart.axis.playtime-hours": {
"message": "{hours} h"
},
"analytics.chart.controls.active-count": {
"message": "{count} active"
},
"analytics.chart.controls.annotations": {
"message": "Annotations"
},
"analytics.chart.controls.aria": {
"message": "Analytics graph controls, {activeCount}"
},
"analytics.chart.controls.button": {
"message": "Controls"
},
"analytics.chart.controls.dialog-aria": {
"message": "Analytics graph controls"
},
"analytics.chart.controls.display": {
"message": "Display"
},
"analytics.chart.controls.modrinth-events": {
"message": "Modrinth events"
},
"analytics.chart.controls.no-modrinth-events": {
"message": "No Modrinth events in graph."
},
"analytics.chart.controls.no-project-events": {
"message": "No project events in graph."
},
"analytics.chart.controls.previous-period": {
"message": "Previous period"
},
"analytics.chart.controls.project-events": {
"message": "Project events"
},
"analytics.chart.controls.ratio": {
"message": "Ratio"
},
"analytics.chart.empty.select-table-items": {
"message": "Select items from table below to visualize your data."
},
"analytics.chart.events.count-aria": {
"message": "{count, plural, one {# analytics event} other {# analytics events}}"
},
"analytics.chart.events.project-title": {
"message": "<project>{projectName}</project>: {title}"
},
"analytics.chart.events.see-announcement": {
"message": "See announcement"
},
"analytics.chart.legend.monetization-details.aria": {
"message": "View monetized analytics details"
},
"analytics.chart.legend.monetization-details.description": {
"message": "Only views and downloads made through Modrinth count toward monetization, and downloads require users to be logged in."
},
"analytics.chart.legend.monetization-details.title": {
"message": "Monetized analytics details"
},
"analytics.chart.legend.previous-period-suffix": {
"message": "{name} (Prev.)"
},
"analytics.chart.render-limit.description": {
"message": "Showing all selected lines from table may degrade page performance."
},
"analytics.chart.render-limit.header": {
"message": "Show all {count} lines in graph?"
},
"analytics.chart.table-selection.all": {
"message": "Showing all {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table"
},
"analytics.chart.table-selection.count": {
"message": "Showing {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table"
},
"analytics.chart.table-selection.limited": {
"message": "Showing {limit} {itemType, select, project {{limit, plural, one {project} other {projects}}} country {{limit, plural, one {country} other {countries}}} monetization {{limit, plural, one {monetization value} other {monetization values}}} downloadSource {{limit, plural, one {download source} other {download sources}}} downloadReason {{limit, plural, one {download reason} other {download reasons}}} projectVersion {{limit, plural, one {project version} other {project versions}}} loader {{limit, plural, one {loader} other {loaders}}} gameVersion {{limit, plural, one {game version} other {game versions}}} other {{limit, plural, one {item} other {items}}}} from table"
},
"analytics.chart.table-selection.top": {
"message": "Showing top {count} {itemType, select, project {{count, plural, one {project} other {projects}}} country {{count, plural, one {country} other {countries}}} monetization {{count, plural, one {monetization value} other {monetization values}}} downloadSource {{count, plural, one {download source} other {download sources}}} downloadReason {{count, plural, one {download reason} other {download reasons}}} projectVersion {{count, plural, one {project version} other {project versions}}} loader {{count, plural, one {loader} other {loaders}}} gameVersion {{count, plural, one {game version} other {game versions}}} other {{count, plural, one {item} other {items}}}} from table"
},
"analytics.chart.tooltip.duration.days": {
"message": "{count, plural, one {# day} other {# days}}"
},
"analytics.chart.tooltip.duration.hours": {
"message": "{count, plural, one {# hour} other {# hours}}"
},
"analytics.chart.tooltip.duration.minutes": {
"message": "{count, plural, one {# minute} other {# minutes}}"
},
"analytics.chart.tooltip.hide-entry": {
"message": "Hide {name} in graph"
},
"analytics.chart.tooltip.pinned": {
"message": "Chart tooltip pinned"
},
"analytics.chart.tooltip.pinned-aria": {
"message": "Pinned"
},
"analytics.chart.tooltip.previous-period-short": {
"message": "(prev.)"
},
"analytics.chart.tooltip.show-entry": {
"message": "Show {name} in graph"
},
"analytics.chart.tooltip.total": {
"message": "Total"
},
"analytics.chart.view.area": {
"message": "Area"
},
"analytics.chart.view.bar": {
"message": "Bar"
},
"analytics.chart.view.line": {
"message": "Line"
},
"analytics.download-reason.dependency": {
"message": "Dependency"
},
"analytics.download-reason.modpack": {
"message": "Modpack"
},
"analytics.download-reason.standalone": {
"message": "Standalone"
},
"analytics.download-reason.update": {
"message": "Update"
},
"analytics.download-source.app": {
"message": "Modrinth App"
},
"analytics.download-source.website": {
"message": "Modrinth Website"
},
"analytics.downloads.suffix": {
"message": "downloads"
},
"analytics.empty.no-data": {
"message": "No data available"
},
"analytics.empty.no-data-for-analytics": {
"message": "No data available for analytics"
},
"analytics.empty.no-projects": {
"message": "No projects available"
},
"analytics.empty.no-projects-for-analytics": {
"message": "No projects available for analytics"
},
"analytics.empty.select-project": {
"message": "Select at least one project to view data"
},
"analytics.filter.game-version-type": {
"message": "Game version type"
},
"analytics.filter.game-version-type.all": {
"message": "All"
},
"analytics.filter.game-version-type.release": {
"message": "Release"
},
"analytics.filter.search.countries": {
"message": "Search countries..."
},
"analytics.filter.search.download-sources": {
"message": "Search download sources..."
},
"analytics.filter.search.project-versions": {
"message": "Search project versions..."
},
"analytics.filter.search.versions": {
"message": "Search versions..."
},
"analytics.graph.title.downloads": {
"message": "Downloads Over Time"
},
"analytics.graph.title.playtime": {
"message": "Playtime Over Time"
},
"analytics.graph.title.revenue": {
"message": "Revenue Over Time"
},
"analytics.graph.title.views": {
"message": "Views Over Time"
},
"analytics.group-by.1h": {
"message": "1h"
},
"analytics.group-by.6h": {
"message": "6h"
},
"analytics.group-by.date": {
"message": "Date"
},
"analytics.group-by.day": {
"message": "Day"
},
"analytics.group-by.month": {
"message": "Month"
},
"analytics.group-by.selected.day": {
"message": "Group by day"
},
"analytics.group-by.selected.hour": {
"message": "Group by hour"
},
"analytics.group-by.selected.month": {
"message": "Group by month"
},
"analytics.group-by.selected.six-hours": {
"message": "Group by 6 hours"
},
"analytics.group-by.selected.week": {
"message": "Group by week"
},
"analytics.group-by.selected.year": {
"message": "Group by year"
},
"analytics.group-by.week": {
"message": "Week"
},
"analytics.group-by.year": {
"message": "Year"
},
"analytics.loading.fetching-results": {
"message": "Fetching results..."
},
"analytics.options.loading": {
"message": "Loading..."
},
"analytics.project-event.project-approved": {
"message": "Project approved"
},
"analytics.project-event.project-private": {
"message": "Project set to private"
},
"analytics.project-event.project-status-changed": {
"message": "Project status changed"
},
"analytics.project-event.project-unlisted": {
"message": "Project unlisted"
},
"analytics.project-event.version-released": {
"message": "{version} released"
},
"analytics.project-event.version-uploaded": {
"message": "Version uploaded"
},
"analytics.project-status.approved": {
"message": "Approved"
},
"analytics.project-status.archived": {
"message": "Archived"
},
"analytics.project-status.draft": {
"message": "Draft"
},
"analytics.project-status.other": {
"message": "Other"
},
"analytics.project-status.private": {
"message": "Private"
},
"analytics.project-status.rejected": {
"message": "Rejected"
},
"analytics.project-status.unlisted": {
"message": "Unlisted"
},
"analytics.project-status.withheld": {
"message": "Withheld"
},
"analytics.project.all": {
"message": "All projects"
},
"analytics.project.count": {
"message": "{count, plural, one {# project} other {# projects}}"
},
"analytics.project.icon-alt": {
"message": "{name} Icon"
},
"analytics.project.select": {
"message": "Select projects"
},
"analytics.query.filter.add": {
"message": "Add filter"
},
"analytics.query.label.breakdown": {
"message": "Breakdown:"
},
"analytics.query.label.grouped-by": {
"message": "Grouped by"
},
"analytics.query.label.project": {
"message": "Project:"
},
"analytics.query.label.timeframe": {
"message": "Timeframe:"
},
"analytics.stat.downloads": {
"message": "Downloads"
},
"analytics.stat.playtime": {
"message": "Playtime"
},
"analytics.stat.playtime-hours": {
"message": "{hours} hrs"
},
"analytics.stat.previous-period-comparison": {
"message": "vs prev. period"
},
"analytics.stat.previous-period-comparison-short": {
"message": "vs prev."
},
"analytics.stat.revenue": {
"message": "Revenue"
},
"analytics.stat.revenue-value": {
"message": "${value}"
},
"analytics.stat.unavailable": {
"message": "N/A"
},
"analytics.stat.unavailable-tooltip": {
"message": "Stat unavailable for current query"
},
"analytics.stat.views": {
"message": "Views"
},
"analytics.table.csv.date-range": {
"message": "{start} to {end}"
},
"analytics.table.csv.filename": {
"message": "Modrinth Analytics {breakdown} Breakdown - {dateRange}"
},
"analytics.table.csv.header.playtime-seconds": {
"message": "Playtime (seconds)"
},
"analytics.table.csv.selected-range": {
"message": "Selected Range"
},
"analytics.table.duration.days": {
"message": "{count, plural, one {# day} other {# days}}"
},
"analytics.table.duration.hours": {
"message": "{count, plural, one {# hour} other {# hours}}"
},
"analytics.table.duration.minutes": {
"message": "{count, plural, one {# minute} other {# minutes}}"
},
"analytics.table.empty.no-matching-rows": {
"message": "No matching analytics rows"
},
"analytics.table.export-csv": {
"message": "Export CSV"
},
"analytics.table.export.cumulative": {
"message": "Cumulative"
},
"analytics.table.export.grouped": {
"message": "Grouped by {groupBy}"
},
"analytics.table.pagination.summary": {
"message": "Showing {start} to {end} of {total}"
},
"analytics.table.search.placeholder": {
"message": "Search..."
},
"analytics.threshold.countries-above": {
"message": "Countries above"
},
"analytics.threshold.country-downloads-aria": {
"message": "Country downloads threshold"
},
"analytics.threshold.game-version-downloads-aria": {
"message": "Game version downloads threshold"
},
"analytics.threshold.game-versions-above": {
"message": "Game versions above"
},
"analytics.threshold.project-downloads-aria": {
"message": "Project downloads threshold"
},
"analytics.threshold.project-version-downloads-aria": {
"message": "Project version downloads threshold"
},
"analytics.threshold.project-versions-above": {
"message": "Project versions above"
},
"analytics.threshold.projects-above": {
"message": "Projects above"
},
"analytics.title": {
"message": "Analytics"
},
"analytics.value.monetized": {
"message": "Monetized"
},
"analytics.value.none": {
"message": "None"
},
"analytics.value.other": {
"message": "Other"
},
"analytics.value.unknown": {
"message": "Unknown"
},
"analytics.value.unmonetized": {
"message": "Unmonetized"
},
"app-marketing.download.description": {
"message": "Our desktop app is available across all platforms, choose your desired version."
},
@@ -1,30 +1,7 @@
<template>
<div>
<div class="universal-card">
<h2>Analytics</h2>
<p>
This page shows you the analytics for your project,
<strong>{{ project.title }}</strong
>. You can see the number of downloads, page views and revenue earned for your project, as
well as the total downloads and page views for {{ project.title }} by country.
</p>
</div>
<ChartDisplay :projects="[project]" />
</div>
<AnalyticsDashboard />
</template>
<script setup>
import { injectProjectPageContext } from '@modrinth/ui'
import ChartDisplay from '~/components/ui/charts/ChartDisplay.vue'
const { projectV2: project } = injectProjectPageContext()
<script setup lang="ts">
import AnalyticsDashboard from '~/components/analytics-dashboard/index.vue'
</script>
<style scoped lang="scss">
.markdown-body {
margin-bottom: var(--gap-md);
}
</style>
@@ -0,0 +1,842 @@
<template>
<ConfirmModal
ref="deleteEventModal"
title="Delete analytics event?"
:description="deleteEventDescription"
proceed-label="Delete event"
@proceed="confirmDeleteEvent"
/>
<NewModal
ref="eventModal"
:header="modalMode === 'create' ? 'New event' : 'Edit event'"
width="480px"
max-width="calc(100vw - 2rem)"
:on-hide="resetForm"
:close-on-click-outside="false"
>
<div class="flex flex-col gap-5" @submit.prevent="saveEvent">
<div class="flex flex-col gap-2">
<span class="label__title font-semibold">Title</span>
<StyledInput
id="analytics-event-title"
ref="titleInput"
v-model="form.title"
type="text"
autocomplete="off"
placeholder="Event title..."
:maxlength="120"
/>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="label__title font-semibold">Announcement link (optional)</span>
<ButtonStyled v-if="committedNormalizedAnnouncementUrl" type="transparent" size="small">
<a
:href="committedNormalizedAnnouncementUrl"
target="_blank"
rel="noopener noreferrer"
aria-label="Check announcement link"
title="Check announcement link"
class="text-sm"
>
<ExternalIcon aria-hidden="true" />
Open link
</a>
</ButtonStyled>
</div>
<div class="flex items-center gap-2">
<StyledInput
id="analytics-event-link"
v-model="form.announcementUrl"
type="url"
autocomplete="off"
placeholder="Announcement link..."
wrapper-class="w-full"
@change="commitAnnouncementUrl"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-1">
<span class="label__title font-semibold">Start date ({{ EVENT_TIME_ZONE_LABEL }})</span>
</div>
<DatePicker
id="analytics-event-starts"
v-model="form.startsAt"
enable-time
date-format="Y-m-d H:i"
alt-format="F j, Y at h:i K"
placeholder="Select start..."
input-class="w-full"
wrapper-class="w-full"
show-today
/>
</div>
<div class="flex flex-col gap-2">
<span class="label__title font-semibold"
>End date ({{ EVENT_TIME_ZONE_LABEL }}, optional)</span
>
<DatePicker
id="analytics-event-ends"
v-model="form.endsAt"
enable-time
date-format="Y-m-d H:i"
alt-format="F j, Y at h:i K"
placeholder="Select end..."
input-class="w-full"
wrapper-class="w-full"
show-today
/>
</div>
<div class="flex flex-col gap-2">
<span class="label__title font-semibold">Metric</span>
<MultiSelect
v-model="form.metricKinds"
:options="metricKindOptions"
:clearable="false"
:max-tag-rows="2"
placeholder="Select metrics this applies to"
include-select-all-option
/>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="transparent">
<button @click="eventModal?.hide()">Cancel</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!canSaveEvent || isSaving" @click="saveEvent">
<SaveIcon aria-hidden="true" />
{{ modalMode === 'create' ? 'Create event' : 'Save' }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
<div class="normal-page no-sidebar">
<div class="normal-page__content flex flex-col gap-4">
<div class="mt-8 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<h1 class="m-0 text-2xl font-extrabold text-contrast">Analytics Events</h1>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<StyledInput
v-model="searchQuery"
:icon="SearchIcon"
type="search"
placeholder="Search..."
clearable
wrapper-class="w-full sm:w-72"
/>
<ButtonStyled color="brand">
<button :disabled="isSaving" @click="openCreateModal">
<PlusIcon aria-hidden="true" />
New event
</button>
</ButtonStyled>
</div>
</div>
<Table
v-model:sort-column="sortColumn"
v-model:sort-direction="sortDirection"
:columns="columns"
:data="sortedEvents"
row-key="id"
>
<template #cell-title="{ row }">
<span class="line-clamp-2 font-medium text-primary">{{ row.title }}</span>
</template>
<template #cell-announcement="{ row }">
<a
v-if="row.announcement_url"
:href="row.announcement_url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 font-medium text-primary hover:text-contrast"
>
Open link
<ExternalIcon class="size-4" aria-hidden="true" />
</a>
<span v-else class="text-xs font-medium text-primary"></span>
</template>
<template #cell-date="{ row }">
<div
v-if="isEventDateRange(row)"
class="flex flex-col gap-0.5 text-sm font-medium leading-5 text-primary"
>
<span>{{ formatEventDateRangeStart(row) }} -</span>
<span>{{ formatEventDateRangeEnd(row) }}</span>
</div>
<span v-else class="font-medium text-primary">{{ formatEventDateRange(row) }}</span>
</template>
<template #cell-metrics="{ row }">
<div class="flex flex-wrap gap-1">
<span
v-for="metric in getMetricKindOptions(row.for_metric_kind)"
:key="metric.value"
class="inline-flex items-center rounded-full border border-solid border-surface-5 px-2 py-0.5 text-xs font-medium text-secondary"
>
{{ metric.label }}
</span>
</div>
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end gap-2">
<ButtonStyled circular type="outlined" color="red">
<button
:aria-label="`Delete ${row.title}`"
:disabled="isDeletingEvent(row.id)"
@click="openDeleteEventModal(row)"
>
<TrashIcon aria-hidden="true" />
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button :disabled="isSaving || isDeletingEvent(row.id)" @click="openEditModal(row)">
Edit
<EditIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</template>
<template #empty-state>
<div class="flex h-64 items-center justify-center text-secondary">
{{ isLoadingEvents ? 'Loading analytics events...' : 'No results.' }}
</div>
</template>
</Table>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { EditIcon, ExternalIcon, PlusIcon, SaveIcon, SearchIcon, TrashIcon } from '@modrinth/assets'
import {
ButtonStyled,
ConfirmModal,
DatePicker,
injectModrinthClient,
injectNotificationManager,
MultiSelect,
type MultiSelectOption,
NewModal,
type SortDirection,
StyledInput,
Table,
type TableColumn,
} from '@modrinth/ui'
import { isAdmin } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
definePageMeta({
middleware: [
'auth',
async () => {
const auth = await useAuth()
if (!auth.value.user || !isAdmin(auth.value.user)) {
throw createError({
fatal: true,
statusCode: 401,
statusMessage: 'Unauthorized',
})
}
},
],
})
type EventColumnKey = 'title' | 'announcement' | 'date' | 'metrics' | 'actions'
type AnalyticsEventMetricKind = Labrinth.Analytics.v3.AnalyticsEventMetricKind
type AnalyticsEventRow = Labrinth.Analytics.v3.AnalyticsEvent & {
announcement: string
date: string
metrics: string
actions: string
}
type EventForm = {
title: string
announcementUrl: string
startsAt: DatePickerValue
endsAt: DatePickerValue
metricKinds: AnalyticsEventMetricKind[]
}
type DatePickerValue = string | Date | null | undefined
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const analyticsEventsQueryKey = ['analytics-events'] as const
const EVENT_TIME_ZONE = 'America/Los_Angeles'
const EVENT_TIME_ZONE_LABEL = 'PST'
const columns: TableColumn<EventColumnKey>[] = [
{ key: 'date', label: 'Date (PST)', width: '18%', enableSorting: true },
{ key: 'title', label: 'Title' },
{ key: 'announcement', label: 'Announcement link', width: '18%' },
{ key: 'metrics', label: 'Metric', width: '18%' },
{ key: 'actions', label: 'Actions', width: '15%', align: 'right' },
]
const metricKindOptions: MultiSelectOption<AnalyticsEventMetricKind>[] = [
{ value: 'views', label: 'Views' },
{ value: 'downloads', label: 'Downloads' },
{ value: 'revenue', label: 'Revenue' },
{ value: 'playtime', label: 'Playtime' },
]
const allMetricKinds = metricKindOptions.map((option) => option.value)
const deleteEventModal = ref<InstanceType<typeof ConfirmModal> | null>(null)
const eventModal = ref<InstanceType<typeof NewModal> | null>(null)
const titleInput = ref<InstanceType<typeof StyledInput> | null>(null)
const searchQuery = ref('')
const sortColumn = ref<EventColumnKey | undefined>('date')
const sortDirection = ref<SortDirection>('desc')
const modalMode = ref<'create' | 'edit'>('create')
const editingEventId = ref<Labrinth.Analytics.v3.AnalyticsEventId | null>(null)
const pendingDeleteEvent = ref<Labrinth.Analytics.v3.AnalyticsEvent | null>(null)
const form = ref<EventForm>(getEmptyForm())
const isSaving = ref(false)
const deletingEventIds = ref(new Set<Labrinth.Analytics.v3.AnalyticsEventId>())
const notifiedEventsErrorMessage = ref<string | null>(null)
const committedAnnouncementUrl = ref('')
let resetFormTimeout: ReturnType<typeof setTimeout> | null = null
const {
data: analyticsEvents,
error: eventsError,
isLoading: isLoadingEvents,
} = useQuery({
queryKey: analyticsEventsQueryKey,
queryFn: () => client.labrinth.analytics_v3.getEvents(),
placeholderData: [],
refetchOnMount: 'always',
retry: false,
staleTime: 0,
})
watch(eventsError, (error) => {
if (!error) {
notifiedEventsErrorMessage.value = null
return
}
const message = error.message
if (notifiedEventsErrorMessage.value === message) {
return
}
notifiedEventsErrorMessage.value = message
addNotification({
title: 'Failed to load analytics events',
text: message,
type: 'error',
})
})
onBeforeUnmount(() => {
clearResetFormTimeout()
})
const trimmedSearchQuery = computed(() => searchQuery.value.trim().toLowerCase())
const normalizedAnnouncementUrl = computed(() => normalizeUrl(form.value.announcementUrl))
const committedNormalizedAnnouncementUrl = computed(() =>
normalizeUrl(committedAnnouncementUrl.value),
)
const canSaveEvent = computed(
() =>
form.value.title.trim().length > 0 &&
Boolean(getEventFormDateRange()) &&
form.value.metricKinds.length > 0,
)
const deleteEventDescription = computed(() => {
if (!pendingDeleteEvent.value) {
return 'This analytics event will be deleted. This cannot be undone.'
}
return `This will delete "${pendingDeleteEvent.value.title}" from analytics events. This cannot be undone.`
})
const eventRows = computed<AnalyticsEventRow[]>(() =>
(analyticsEvents.value ?? []).map((event) => ({
...event,
announcement: '',
date: '',
metrics: '',
actions: '',
})),
)
const filteredEvents = computed(() => {
if (!trimmedSearchQuery.value) {
return eventRows.value
}
return eventRows.value.filter((event) => {
const dateRange = formatEventDateRange(event).toLowerCase()
return [event.title, event.announcement_url ?? '', dateRange].some((value) =>
value.toLowerCase().includes(trimmedSearchQuery.value),
)
})
})
const sortedEvents = computed(() => {
const sorted = [...filteredEvents.value]
if (sortColumn.value === 'date') {
sorted.sort((left, right) => {
const direction = sortDirection.value === 'asc' ? 1 : -1
return (getDateTime(left.starts) - getDateTime(right.starts)) * direction
})
}
return sorted
})
function getEmptyForm(): EventForm {
return {
title: '',
announcementUrl: '',
startsAt: '',
endsAt: '',
metricKinds: [],
}
}
function openCreateModal() {
modalMode.value = 'create'
editingEventId.value = null
form.value = {
...getEmptyForm(),
}
committedAnnouncementUrl.value = ''
clearResetFormTimeout()
eventModal.value?.show()
void focusTitleInput()
}
function openEditModal(event: Labrinth.Analytics.v3.AnalyticsEvent) {
modalMode.value = 'edit'
editingEventId.value = event.id
form.value = {
title: event.title,
announcementUrl: event.announcement_url ?? '',
startsAt: getDateTimeInputValue(event.starts),
endsAt: getDateTimeInputValue(event.ends),
metricKinds: event.for_metric_kind?.length ? [...event.for_metric_kind] : [...allMetricKinds],
}
committedAnnouncementUrl.value = event.announcement_url ?? ''
clearResetFormTimeout()
eventModal.value?.show()
void focusTitleInput()
}
function openDeleteEventModal(event: Labrinth.Analytics.v3.AnalyticsEvent) {
pendingDeleteEvent.value = event
deleteEventModal.value?.show()
}
async function focusTitleInput() {
await nextTick()
setTimeout(() => {
titleInput.value?.focus()
}, 75)
}
async function saveEvent() {
if (!canSaveEvent.value || isSaving.value) {
return
}
isSaving.value = true
try {
const payload = buildEventPayload()
if (modalMode.value === 'edit' && editingEventId.value !== null) {
await client.labrinth.analytics_v3.editEvent(editingEventId.value, payload)
} else {
await client.labrinth.analytics_v3.createEvent(payload)
}
await queryClient.invalidateQueries({ queryKey: analyticsEventsQueryKey })
eventModal.value?.hide()
addNotification({
title: modalMode.value === 'edit' ? 'Analytics event updated' : 'Analytics event created',
type: 'success',
})
} catch (error) {
addNotification({
title:
modalMode.value === 'edit'
? 'Failed to update analytics event'
: 'Failed to create analytics event',
text: getErrorMessage(error),
type: 'error',
})
} finally {
isSaving.value = false
}
}
async function confirmDeleteEvent() {
if (!pendingDeleteEvent.value) {
return
}
const eventId = pendingDeleteEvent.value.id
pendingDeleteEvent.value = null
await deleteEvent(eventId)
}
async function deleteEvent(eventId: Labrinth.Analytics.v3.AnalyticsEventId) {
if (isDeletingEvent(eventId)) {
return
}
setDeletingEvent(eventId, true)
try {
await client.labrinth.analytics_v3.deleteEvent(eventId)
await queryClient.invalidateQueries({ queryKey: analyticsEventsQueryKey })
addNotification({
title: 'Analytics event deleted',
type: 'success',
})
} catch (error) {
addNotification({
title: 'Failed to delete analytics event',
text: getErrorMessage(error),
type: 'error',
})
} finally {
setDeletingEvent(eventId, false)
}
}
function resetForm() {
clearResetFormTimeout()
resetFormTimeout = setTimeout(() => {
form.value = getEmptyForm()
editingEventId.value = null
committedAnnouncementUrl.value = ''
resetFormTimeout = null
}, 500)
}
function clearResetFormTimeout() {
if (!resetFormTimeout) {
return
}
clearTimeout(resetFormTimeout)
resetFormTimeout = null
}
function normalizeUrl(value: string): string | undefined {
const trimmed = value.trim()
if (!trimmed) {
return undefined
}
if (/^https?:\/\//i.test(trimmed)) {
return trimmed
}
return `https://${trimmed}`
}
function commitAnnouncementUrl() {
committedAnnouncementUrl.value = form.value.announcementUrl
}
function buildEventPayload(): Labrinth.Analytics.v3.AnalyticsEventUpsert {
const selectedRange = getEventFormDateRange()
if (!selectedRange) {
throw new Error('Select a valid start and end date')
}
const starts = parseDateTimeInputValue(selectedRange[0]).toISOString()
const ends = parseDateTimeInputValue(selectedRange[1]).toISOString()
return {
announcement_url: normalizedAnnouncementUrl.value ?? null,
for_metric_kind: [...form.value.metricKinds],
title: form.value.title.trim(),
ends,
starts,
}
}
function getMetricKindOptions(
metricKinds: AnalyticsEventMetricKind[] | null,
): MultiSelectOption<AnalyticsEventMetricKind>[] {
const visibleKinds = metricKinds?.length ? metricKinds : allMetricKinds
return metricKindOptions.filter((option) => visibleKinds.includes(option.value))
}
function formatEventDateRange(event: Labrinth.Analytics.v3.AnalyticsEvent): string {
const startDate = new Date(event.starts)
const endDate = new Date(event.ends)
const startDateValue = getDateInputValueInTimeZone(startDate, EVENT_TIME_ZONE)
const endDateValue = getDateInputValueInTimeZone(endDate, EVENT_TIME_ZONE)
if (startDate.getTime() === endDate.getTime()) {
return formatDateTime(startDate)
}
const sameYear = startDateValue.slice(0, 4) === endDateValue.slice(0, 4)
const sameMonth = sameYear && startDateValue.slice(5, 7) === endDateValue.slice(5, 7)
const sameDay = startDateValue === endDateValue
if (sameDay) {
return `${formatLongDate(startDate)}, ${formatTime(startDate)} - ${formatTime(endDate)}`
}
if (sameMonth) {
return `${formatMonthDayTime(startDate)} - ${formatMonthDayTime(endDate)}, ${endDateValue.slice(0, 4)}`
}
if (sameYear) {
return `${formatMonthDayTime(startDate)} - ${formatLongDateTime(endDate)}`
}
return `${formatLongDateTime(startDate)} - ${formatLongDateTime(endDate)}`
}
function isEventDateRange(event: Labrinth.Analytics.v3.AnalyticsEvent): boolean {
return new Date(event.starts).getTime() !== new Date(event.ends).getTime()
}
function formatEventDateRangeStart(event: Labrinth.Analytics.v3.AnalyticsEvent): string {
return formatLongDateTime(new Date(event.starts))
}
function formatEventDateRangeEnd(event: Labrinth.Analytics.v3.AnalyticsEvent): string {
return formatLongDateTime(new Date(event.ends))
}
function getEventFormDateRange(): [string, string] | null {
const startValue = getDatePickerValueString(form.value.startsAt)
const endValue = isEmptyDatePickerValue(form.value.endsAt)
? startValue
: getDatePickerValueString(form.value.endsAt)
if (!startValue || !endValue) {
return null
}
const startDate = parseDateTimeInputValue(startValue)
const endDate = parseDateTimeInputValue(endValue)
if (startDate.getTime() > endDate.getTime()) {
return null
}
return [startValue, endValue]
}
function isEmptyDatePickerValue(value: DatePickerValue): boolean {
return value === '' || value === null || value === undefined
}
function getDatePickerValueString(value: DatePickerValue): string | null {
if (typeof value === 'string') {
return isValidDateTimeInputValue(value) ? value : null
}
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return formatDateTimeInputValue(value)
}
return null
}
function isValidDateTimeInputValue(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(value)) return false
const parsedDate = parseDateTimeInputValue(value)
return (
!Number.isNaN(parsedDate.getTime()) &&
formatDateTimeInputValueInTimeZone(parsedDate, EVENT_TIME_ZONE) === value
)
}
function getDateTimeInputValue(value: string): string {
const date = new Date(value)
return Number.isNaN(date.getTime())
? ''
: formatDateTimeInputValueInTimeZone(date, EVENT_TIME_ZONE)
}
function formatDateTimeInputValue(date: Date): string {
return formatDateTimeInputValueInTimeZone(date, EVENT_TIME_ZONE)
}
function formatDateTimeInputValueInTimeZone(date: Date, timeZone: string): string {
const parts = getTimeZoneDateParts(date, timeZone)
if (!parts) return ''
const year = `${parts.year}`.padStart(4, '0')
const month = `${parts.month}`.padStart(2, '0')
const day = `${parts.day}`.padStart(2, '0')
const hours = `${parts.hour}`.padStart(2, '0')
const minutes = `${parts.minute}`.padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
function getDateInputValueInTimeZone(date: Date, timeZone: string): string {
const parts = getTimeZoneDateParts(date, timeZone)
if (!parts) return ''
const year = `${parts.year}`.padStart(4, '0')
const month = `${parts.month}`.padStart(2, '0')
const day = `${parts.day}`.padStart(2, '0')
return `${year}-${month}-${day}`
}
function parseDateTimeInputValue(value: string): Date {
return getDateTimeInTimeZone(value, EVENT_TIME_ZONE)
}
function getDateTimeInTimeZone(value: string, timeZone: string): Date {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/)
if (!match) return new Date(Number.NaN)
const [, yearValue, monthValue, dayValue, hourValue, minuteValue] = match
const year = Number(yearValue)
const month = Number(monthValue)
const day = Number(dayValue)
const hour = Number(hourValue)
const minute = Number(minuteValue)
const utcGuess = Date.UTC(year, month - 1, day, hour, minute)
let offset = getTimeZoneOffsetMs(new Date(utcGuess), timeZone)
offset = getTimeZoneOffsetMs(new Date(utcGuess - offset), timeZone)
return new Date(utcGuess - offset)
}
function getTimeZoneOffsetMs(date: Date, timeZone: string): number {
const parts = getTimeZoneDateParts(date, timeZone)
if (!parts) return 0
const zonedDateAsUtc = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second,
)
return zonedDateAsUtc - date.getTime()
}
function getTimeZoneDateParts(date: Date, timeZone: string) {
if (Number.isNaN(date.getTime())) return null
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
}).formatToParts(date)
const valueByType = Object.fromEntries(parts.map((part) => [part.type, part.value]))
return {
year: Number(valueByType.year),
month: Number(valueByType.month),
day: Number(valueByType.day),
hour: Number(valueByType.hour),
minute: Number(valueByType.minute),
second: Number(valueByType.second),
}
}
function formatDateTime(date: Date): string {
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZone: EVENT_TIME_ZONE,
}).format(date)
}
function formatLongDateTime(date: Date): string {
return `${formatLongDate(date)}, ${formatTime(date)}`
}
function formatMonthDayTime(date: Date): string {
return `${formatMonthDay(date)}, ${formatTime(date)}`
}
function formatLongDate(date: Date): string {
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: EVENT_TIME_ZONE,
}).format(date)
}
function formatMonthDay(date: Date): string {
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
timeZone: EVENT_TIME_ZONE,
}).format(date)
}
function formatTime(date: Date): string {
return new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: '2-digit',
timeZone: EVENT_TIME_ZONE,
}).format(date)
}
function getDateTime(value: string): number {
return Date.parse(value)
}
function isDeletingEvent(eventId: Labrinth.Analytics.v3.AnalyticsEventId): boolean {
return deletingEventIds.value.has(eventId)
}
function setDeletingEvent(eventId: Labrinth.Analytics.v3.AnalyticsEventId, deleting: boolean) {
const nextIds = new Set(deletingEventIds.value)
if (deleting) {
nextIds.add(eventId)
} else {
nextIds.delete(eventId)
}
deletingEventIds.value = nextIds
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
</script>
@@ -1,30 +1,14 @@
<template>
<div>
<Suspense>
<ChartDisplay :projects="projects" :personal="true" />
<template #fallback>
<div class="universal-card">
<h2><span class="label__title">Loading analytics...</span></h2>
</div>
</template>
</Suspense>
</div>
<AnalyticsDashboard />
</template>
<script setup>
import {
commonProjectSettingsMessages,
injectModrinthClient,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import { commonProjectSettingsMessages, useVIntl } from '@modrinth/ui'
import ChartDisplay from '~/components/ui/charts/ChartDisplay.vue'
import AnalyticsDashboard from '~/components/analytics-dashboard/index.vue'
const { formatMessage } = useVIntl()
const debug = useDebugLogger('analytics.vue')
definePageMeta({
middleware: 'auth',
})
@@ -32,14 +16,4 @@ definePageMeta({
useHead({
title: () => `${formatMessage(commonProjectSettingsMessages.analytics)} - Modrinth`,
})
const auth = await useAuth()
const client = injectModrinthClient()
const id = auth.value?.user?.id
debug('auth resolved', { id })
const projects = await client.labrinth.users_v2.getProjects(id)
debug('projects resolved', { count: projects?.length })
</script>
@@ -5,7 +5,7 @@
:preloaded-payment-data="preloadedPaymentMethods"
@refresh-data="refreshData"
/>
<div class="mb-20 flex flex-col gap-6 lg:pl-8">
<div class="mb-20 flex flex-col gap-6 lg:pl-4 lg:pt-1.5">
<div class="flex flex-col gap-4 md:gap-5">
<div class="flex flex-col gap-1">
<span class="text-xl font-semibold text-contrast md:text-2xl">{{
@@ -1,28 +1,9 @@
<template>
<div class="normal-page__content">
<div class="universal-card">
<h2>Analytics</h2>
<p>
This page shows you the analytics for your organization's projects. You can see the number
of downloads, page views and revenue earned for all of your projects, as well as the total
downloads and page views for each project by country.
</p>
</div>
<ChartDisplay :projects="projects.map((x) => ({ title: x.name, ...x }))" />
<AnalyticsDashboard />
</div>
</template>
<script setup>
import ChartDisplay from '~/components/ui/charts/ChartDisplay.vue'
import { injectOrganizationContext } from '~/providers/organization-context.ts'
const { projects } = injectOrganizationContext()
<script setup lang="ts">
import AnalyticsDashboard from '~/components/analytics-dashboard/index.vue'
</script>
<style scoped lang="scss">
.markdown-body {
margin-bottom: var(--gap-md);
}
</style>
@@ -0,0 +1,531 @@
import type { Labrinth } from '@modrinth/api-client'
import type { ProjectStatusFilterValue } from '~/components/analytics-dashboard/query-builder/query-filter'
import { getProjectIdsMatchingStatusFilter } from './analytics-project-utils'
import type {
AnalyticsDashboardTotals,
AnalyticsFetchData,
AnalyticsGroupByPreset,
AnalyticsLastTimeframeUnit,
AnalyticsProjectFetchRequest,
AnalyticsSelectedFilters,
AnalyticsTimeframeMode,
AnalyticsTimeframePreset,
AnalyticsTimeSliceSplit,
} from './analytics-types'
const ANALYTICS_START_TIMESTAMP = '2023-01-01T00:00:00.000Z'
export const ANALYTICS_START_DATE_INPUT_VALUE = ANALYTICS_START_TIMESTAMP.slice(0, 10)
const ANALYTICS_START_TIME = new Date(ANALYTICS_START_TIMESTAMP).getTime()
export const REVENUE_MIN_TIMEFRAME_MS = 1 * 24 * 60 * 60 * 1000 // need at least 1 day in timeframe range to show revenue
const ANALYTICS_DAY_MS = 24 * 60 * 60 * 1000
const ANALYTICS_MAX_TIME_SLICES = 256 // controls granularity allowed in "group by" for timeframe ranges
const ANALYTICS_PROJECT_IDS_FETCH_BATCH_SIZE = 2000
const ANALYTICS_PROJECT_IDS_FETCH_BATCH_DELAY_MS = 300
function isProjectAnalyticsPoint(
dataPoint: Labrinth.Analytics.v3.AnalyticsData,
): dataPoint is Labrinth.Analytics.v3.ProjectAnalytics {
return 'source_project' in dataPoint
}
export function buildComparisonFetchRequest(
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
): AnalyticsProjectFetchRequest | null {
if (!isAnalyticsFetchRequestReady(fetchRequest)) {
return null
}
const startTimestamp = new Date(fetchRequest.time_range.start).getTime()
const endTimestamp = new Date(fetchRequest.time_range.end).getTime()
const duration = endTimestamp - startTimestamp
if (!Number.isFinite(duration) || duration <= 0) {
return null
}
const previousStart = new Date(startTimestamp - duration)
if (previousStart.getTime() < ANALYTICS_START_TIME) {
return null
}
return {
...fetchRequest,
time_range: {
start: previousStart.toISOString(),
end: fetchRequest.time_range.end,
resolution:
'slices' in fetchRequest.time_range.resolution
? {
slices: fetchRequest.time_range.resolution.slices * 2,
}
: fetchRequest.time_range.resolution,
},
}
}
export function isAnalyticsFetchRequestReady(
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
): fetchRequest is AnalyticsProjectFetchRequest {
return Array.isArray(fetchRequest?.project_ids) && fetchRequest.project_ids.length > 0
}
function getAnalyticsTimeSliceCount(
timeRange: Labrinth.Analytics.v3.TimeRange,
fallback: number,
): number {
if ('slices' in timeRange.resolution) {
return Math.max(1, timeRange.resolution.slices)
}
const startTime = new Date(timeRange.start).getTime()
const endTime = new Date(timeRange.end).getTime()
const bucketMs = timeRange.resolution.minutes * 60 * 1000
if (bucketMs > 0 && endTime > startTime) {
return Math.max(1, Math.floor((endTime - startTime) / bucketMs))
}
return Math.max(1, fallback)
}
export function splitAnalyticsTimeSlices(
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
): AnalyticsTimeSliceSplit {
if (!isAnalyticsFetchRequestReady(fetchRequest) || !buildComparisonFetchRequest(fetchRequest)) {
return {
currentTimeSlices: timeSlices,
previousTimeSlices: [],
}
}
const currentSliceCount = getAnalyticsTimeSliceCount(fetchRequest.time_range, timeSlices.length)
const currentStartIndex = Math.max(0, timeSlices.length - currentSliceCount)
const previousStartIndex = Math.max(0, currentStartIndex - currentSliceCount)
return {
currentTimeSlices: timeSlices.slice(currentStartIndex),
previousTimeSlices: timeSlices.slice(previousStartIndex, currentStartIndex),
}
}
export function getAnalyticsProjectEventsInTimeRange(
projectEvents: Labrinth.Analytics.v3.ProjectAnalyticsEvent[],
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
): Labrinth.Analytics.v3.ProjectAnalyticsEvent[] {
if (!isAnalyticsFetchRequestReady(fetchRequest)) {
return projectEvents
}
const startTime = new Date(fetchRequest.time_range.start).getTime()
const endTime = new Date(fetchRequest.time_range.end).getTime()
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime < startTime) {
return []
}
return projectEvents.filter((event) => {
const eventTime = new Date(event.timestamp).getTime()
return Number.isFinite(eventTime) && eventTime >= startTime && eventTime <= endTime
})
}
function buildAnalyticsFetchRequestBatches(
fetchRequest: AnalyticsProjectFetchRequest,
): AnalyticsProjectFetchRequest[] {
if (fetchRequest.project_ids.length <= ANALYTICS_PROJECT_IDS_FETCH_BATCH_SIZE) {
return [fetchRequest]
}
const requests: AnalyticsProjectFetchRequest[] = []
for (
let index = 0;
index < fetchRequest.project_ids.length;
index += ANALYTICS_PROJECT_IDS_FETCH_BATCH_SIZE
) {
requests.push({
...fetchRequest,
project_ids: fetchRequest.project_ids.slice(
index,
index + ANALYTICS_PROJECT_IDS_FETCH_BATCH_SIZE,
),
})
}
return requests
}
function mergeAnalyticsTimeSlices(
timeSliceGroups: Labrinth.Analytics.v3.TimeSlice[][],
): Labrinth.Analytics.v3.TimeSlice[] {
const mergedTimeSlices: Labrinth.Analytics.v3.TimeSlice[] = []
for (const timeSlices of timeSliceGroups) {
timeSlices.forEach((timeSlice, index) => {
if (!mergedTimeSlices[index]) {
mergedTimeSlices[index] = []
}
for (const dataPoint of timeSlice) {
mergedTimeSlices[index].push(dataPoint)
}
})
}
return mergedTimeSlices
}
function mergeAnalyticsProjectEvents(
projectEventGroups: Labrinth.Analytics.v3.ProjectAnalyticsEvent[][],
): Labrinth.Analytics.v3.ProjectAnalyticsEvent[] {
const mergedProjectEvents: Labrinth.Analytics.v3.ProjectAnalyticsEvent[] = []
for (const projectEvents of projectEventGroups) {
for (const projectEvent of projectEvents) {
mergedProjectEvents.push(projectEvent)
}
}
return mergedProjectEvents.sort((left, right) => {
const timestampDifference =
new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime()
return (
timestampDifference ||
left.project_id.localeCompare(right.project_id) ||
left.kind.localeCompare(right.kind)
)
})
}
function waitForAnalyticsFetchBatchDelay(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ANALYTICS_PROJECT_IDS_FETCH_BATCH_DELAY_MS))
}
export async function fetchAnalyticsData(
fetchRequest: AnalyticsProjectFetchRequest,
fetchAnalytics: (
request: Labrinth.Analytics.v3.FetchRequest,
) => Promise<Labrinth.Analytics.v3.FetchResponse>,
): Promise<AnalyticsFetchData> {
const fetchRequests = buildAnalyticsFetchRequestBatches(fetchRequest)
const timeSliceGroups: Labrinth.Analytics.v3.TimeSlice[][] = []
const projectEventGroups: Labrinth.Analytics.v3.ProjectAnalyticsEvent[][] = []
for (let index = 0; index < fetchRequests.length; index++) {
if (index > 0) {
await waitForAnalyticsFetchBatchDelay()
}
const response = await fetchAnalytics(fetchRequests[index])
timeSliceGroups.push(response.metrics)
projectEventGroups.push(response.project_events ?? [])
}
return {
metrics: mergeAnalyticsTimeSlices(timeSliceGroups),
project_events: mergeAnalyticsProjectEvents(projectEventGroups),
}
}
export async function fetchAnalyticsTimeSlices(
fetchRequest: AnalyticsProjectFetchRequest,
fetchAnalytics: (
request: Labrinth.Analytics.v3.FetchRequest,
) => Promise<Labrinth.Analytics.v3.FetchResponse>,
): Promise<Labrinth.Analytics.v3.TimeSlice[]> {
const response = await fetchAnalyticsData(fetchRequest, fetchAnalytics)
return response.metrics
}
export function areAnalyticsFetchRequestsEqual(
left: Labrinth.Analytics.v3.FetchRequest | null,
right: Labrinth.Analytics.v3.FetchRequest,
): boolean {
return JSON.stringify(left) === JSON.stringify(right)
}
export function buildAnalyticsCurrentTimeSlicesQueryKey(
userId: string | undefined,
nextFetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
refreshTimestamp: number,
) {
return ['analytics', 'dashboard', userId, 'current', nextFetchRequest, refreshTimestamp]
}
export function isRevenueHourlyGroupBy(groupBy: AnalyticsGroupByPreset): boolean {
return groupBy === '1h' || groupBy === '6h'
}
export function buildDailyAnalyticsFetchRequest(
nextFetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
): Labrinth.Analytics.v3.FetchRequest | null {
if (!isAnalyticsFetchRequestReady(nextFetchRequest)) {
return null
}
const startTime = new Date(nextFetchRequest.time_range.start).getTime()
const endTime = new Date(nextFetchRequest.time_range.end).getTime()
const durationMs = endTime - startTime
if (!Number.isFinite(durationMs) || durationMs <= 0) {
return null
}
const desiredSlices = Math.max(1, Math.floor(durationMs / ANALYTICS_DAY_MS))
return {
...nextFetchRequest,
time_range: {
...nextFetchRequest.time_range,
resolution: {
slices: Math.min(ANALYTICS_MAX_TIME_SLICES, desiredSlices),
},
},
}
}
export function buildAnalyticsFacetsRequest(
projectIds: string[],
timeRange: Labrinth.Analytics.v3.TimeRange,
): Labrinth.Analytics.v3.FetchRequest {
return {
time_range: {
start: timeRange.start,
end: timeRange.end,
resolution: {
slices: 1,
},
},
project_ids: projectIds,
return_metrics: {
project_downloads: {
bucket_by: [
'project_id',
'domain',
'user_agent',
'version_id',
'monetized',
'country',
'reason',
'game_version',
'loader',
],
},
},
}
}
function addAnalyticsDays(date: Date, days: number): Date {
const nextDate = new Date(date)
nextDate.setDate(nextDate.getDate() + days)
return nextDate
}
function parseAnalyticsDateInputValue(value: string): Date | null {
const parsedDate = new Date(`${value}T00:00:00`)
return Number.isNaN(parsedDate.getTime()) ? null : parsedDate
}
function parseAnalyticsDateTimeInputValue(value: string): Date | null {
const parsedDate = new Date(value)
return Number.isNaN(parsedDate.getTime()) ? null : parsedDate
}
export function getAnalyticsTimeframeDurationMs({
mode,
preset,
lastAmount,
lastUnit,
customStartDate,
customEndDate,
nowTimestamp,
}: {
mode: AnalyticsTimeframeMode
preset: AnalyticsTimeframePreset
lastAmount: number
lastUnit: AnalyticsLastTimeframeUnit
customStartDate: string
customEndDate: string
nowTimestamp: number
}): number {
if (mode === 'preset') {
switch (preset) {
case 'today':
case 'yesterday':
return 24 * 60 * 60 * 1000
case 'last_7_days':
return 7 * 24 * 60 * 60 * 1000
case 'last_14_days':
return 14 * 24 * 60 * 60 * 1000
case 'last_30_days':
return 30 * 24 * 60 * 60 * 1000
case 'last_90_days':
return 90 * 24 * 60 * 60 * 1000
case 'last_180_days':
return 180 * 24 * 60 * 60 * 1000
case 'year_to_date': {
const now = new Date(nowTimestamp)
const yearStart = new Date(now.getFullYear(), 0, 1)
yearStart.setHours(0, 0, 0, 0)
return now.getTime() - yearStart.getTime()
}
case 'all_time':
return REVENUE_MIN_TIMEFRAME_MS
}
}
if (mode === 'last') {
const amount = Math.max(1, Math.floor(lastAmount))
switch (lastUnit) {
case 'hours':
return amount * 60 * 60 * 1000
case 'days':
return amount * 24 * 60 * 60 * 1000
case 'weeks':
return amount * 7 * 24 * 60 * 60 * 1000
case 'months':
return REVENUE_MIN_TIMEFRAME_MS
}
}
if (mode === 'custom_range') {
const start = parseAnalyticsDateInputValue(customStartDate)
const inclusiveEnd = parseAnalyticsDateInputValue(customEndDate)
if (!start || !inclusiveEnd) {
return 0
}
return addAnalyticsDays(inclusiveEnd, 1).getTime() - start.getTime()
}
const start = parseAnalyticsDateTimeInputValue(customStartDate)
const end = parseAnalyticsDateTimeInputValue(customEndDate)
if (!start || !end) {
return 0
}
return end.getTime() - start.getTime()
}
export function getPercentChange(currentValue: number, previousValue: number): number {
if (previousValue === 0) {
if (currentValue === 0) {
return 0
}
return 100
}
return ((currentValue - previousValue) / previousValue) * 100
}
export function computeTotals(
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
selectedProjectIds: Set<string>,
availableProjectIds: Set<string>,
projectStatusById: Map<string, ProjectStatusFilterValue>,
filters: AnalyticsSelectedFilters,
): AnalyticsDashboardTotals {
const totals: AnalyticsDashboardTotals = {
views: 0,
downloads: 0,
revenue: 0,
playtime: 0,
}
if (availableProjectIds.size === 0) {
return totals
}
const effectiveProjectIds = selectedProjectIds.size > 0 ? selectedProjectIds : availableProjectIds
const filteredProjectIds = new Set(
getProjectIdsMatchingStatusFilter([...effectiveProjectIds], projectStatusById, filters),
)
if (filteredProjectIds.size === 0) {
return totals
}
for (const timeSlice of timeSlices) {
for (const dataPoint of timeSlice) {
if (!isProjectAnalyticsPoint(dataPoint)) {
continue
}
if (!filteredProjectIds.has(dataPoint.source_project)) {
continue
}
switch (dataPoint.metric_kind) {
case 'views':
totals.views += dataPoint.views
break
case 'downloads':
totals.downloads += dataPoint.downloads
break
case 'playtime':
totals.playtime += dataPoint.seconds
break
case 'revenue': {
const value = Number.parseFloat(dataPoint.revenue)
totals.revenue += Number.isFinite(value) ? value : 0
break
}
}
}
}
return totals
}
export function cloneAnalyticsFetchRequest(
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
): Labrinth.Analytics.v3.FetchRequest | null {
return fetchRequest ? JSON.parse(JSON.stringify(fetchRequest)) : null
}
export function addVersionIdsFromTimeSlices(
versionIds: Set<string>,
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
) {
for (const timeSlice of timeSlices) {
for (const dataPoint of timeSlice) {
if (!isProjectAnalyticsPoint(dataPoint)) {
continue
}
if (
(dataPoint.metric_kind === 'downloads' || dataPoint.metric_kind === 'playtime') &&
dataPoint.version_id
) {
const versionId = dataPoint.version_id.trim()
if (versionId.length > 0) {
versionIds.add(versionId)
}
}
}
}
}
export function addVersionProjectNamesFromTimeSlices(
versionProjectNames: Map<string, string>,
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
projectNamesById: Map<string, string>,
) {
for (const timeSlice of timeSlices) {
for (const dataPoint of timeSlice) {
if (!isProjectAnalyticsPoint(dataPoint)) {
continue
}
if (
(dataPoint.metric_kind === 'downloads' || dataPoint.metric_kind === 'playtime') &&
dataPoint.version_id
) {
const versionId = dataPoint.version_id.trim()
const projectName = projectNamesById.get(dataPoint.source_project)
if (versionId.length > 0 && projectName && !versionProjectNames.has(versionId)) {
versionProjectNames.set(versionId, projectName)
}
}
}
}
}
@@ -0,0 +1,470 @@
import type { Labrinth } from '@modrinth/api-client'
import type {
AnalyticsDashboardFilterOptions,
AnalyticsFacetsFilterOptionSummary,
AnalyticsProjectVersionSource,
AnalyticsSelectedFilters,
AnalyticsVersionMetadata,
NormalizedAnalyticsSelectedFilters,
ProjectVersionFilterOptionSummary,
} from './analytics-types'
export function sortStringValues(values: string[]): string[] {
return [...values].sort((left, right) => left.localeCompare(right))
}
function toAnalyticsVersionMetadata(
version: Labrinth.Versions.v3.Version,
): AnalyticsVersionMetadata {
return {
id: version.id,
versionNumber: version.version_number,
datePublished: version.date_published,
projectId: version.project_id,
downloads: version.downloads,
gameVersions: [...version.game_versions],
loaders:
version.mrpack_loaders && version.mrpack_loaders.length > 0
? [...version.mrpack_loaders]
: [...version.loaders],
}
}
export function getProjectVersionFilterOptionSummary(
versions: AnalyticsVersionMetadata[],
): ProjectVersionFilterOptionSummary {
const gameVersions = new Set<string>()
const loaders = new Set<string>()
const versionIds = new Set<string>()
for (const version of versions) {
versionIds.add(version.id)
for (const gameVersion of version.gameVersions) {
const normalizedGameVersion = gameVersion.trim()
if (normalizedGameVersion.length > 0) {
gameVersions.add(normalizedGameVersion)
}
}
for (const loader of version.loaders) {
const normalizedLoader = loader.trim().toLowerCase()
if (normalizedLoader.length > 0 && normalizedLoader !== 'mrpack') {
loaders.add(normalizedLoader)
}
}
}
return {
gameVersions: sortStringValues([...gameVersions]),
loaderTypes: sortStringValues([...loaders]),
versionIds: sortStringValues([...versionIds]),
}
}
export async function fetchAnalyticsVersionMetadataByIds(
versionIds: string[],
getVersions: (ids: string[]) => Promise<Labrinth.Versions.v3.Version[]>,
): Promise<AnalyticsVersionMetadata[]> {
const metadata: AnalyticsVersionMetadata[] = []
const segmentSize = 800
for (let index = 0; index < versionIds.length; index += segmentSize) {
const versions = await getVersions(versionIds.slice(index, index + segmentSize))
metadata.push(...versions.map(toAnalyticsVersionMetadata))
}
return metadata
}
export function getAnalyticsVersionIdsFromProjects(
projects: readonly AnalyticsProjectVersionSource[],
projectIds: readonly string[],
): string[] {
const selectedProjectIds = new Set(projectIds)
const versionIds = new Set<string>()
for (const project of projects) {
if (!selectedProjectIds.has(project.id)) {
continue
}
for (const versionId of project.versions ?? []) {
const normalizedVersionId = versionId.trim()
if (normalizedVersionId.length > 0) {
versionIds.add(normalizedVersionId)
}
}
}
return sortStringValues([...versionIds])
}
function retainAvailableSelectedFilterValues(
values: string[],
availableValues: string[],
): string[] {
const availableValueSet = new Set(availableValues)
return values.filter((value) => availableValueSet.has(value))
}
export function sanitizeAnalyticsSelectedFiltersForAvailableOptions(
filters: AnalyticsSelectedFilters,
filterOptions: AnalyticsDashboardFilterOptions,
): AnalyticsSelectedFilters {
return {
...filters,
download_reason: retainAvailableSelectedFilterValues(
filters.download_reason,
filterOptions.downloadReasons,
),
game_version: retainAvailableSelectedFilterValues(
filters.game_version,
filterOptions.gameVersions,
),
loader_type: retainAvailableSelectedFilterValues(
filters.loader_type,
filterOptions.loaderTypes,
),
}
}
export function cloneAnalyticsSelectedFilters(
filters: AnalyticsSelectedFilters,
): AnalyticsSelectedFilters {
return {
project: [...filters.project],
project_status: [...filters.project_status],
country: [...filters.country],
monetization: [...filters.monetization],
user_agent: [...filters.user_agent],
download_reason: [...filters.download_reason],
version_id: [...filters.version_id],
game_version: [...filters.game_version],
loader_type: [...filters.loader_type],
}
}
export function cloneAnalyticsFilterOptions(
filterOptions: AnalyticsDashboardFilterOptions,
): AnalyticsDashboardFilterOptions {
return {
countries: [...filterOptions.countries],
downloadSources: [...filterOptions.downloadSources],
downloadReasons: [...filterOptions.downloadReasons],
gameVersions: [...filterOptions.gameVersions],
loaderTypes: [...filterOptions.loaderTypes],
versionIds: [...filterOptions.versionIds],
}
}
function getEmptyAnalyticsFacetsFilterOptionSummary(): AnalyticsFacetsFilterOptionSummary {
return {
countries: [],
downloadSources: [],
downloadReasons: [],
gameVersions: [],
loaderTypes: [],
versionIds: [],
projectDownloadsById: new Map(),
projectVersionDownloadsById: new Map(),
gameVersionDownloadsByVersion: new Map(),
countryDownloadsByCode: new Map(),
}
}
function getAnalyticsFacetValues<T>(
facets: Labrinth.Analytics.v3.AnalyticsFacet<T>[] | null | undefined,
): T[] {
return facets?.map((facet) => facet.value) ?? []
}
function getAnalyticsFacetDownloadsByValue<T>(
facets: Labrinth.Analytics.v3.AnalyticsFacet<T>[] | null | undefined,
getKey: (value: T) => string,
): Map<string, number> {
const downloadsByValue = new Map<string, number>()
for (const facet of facets ?? []) {
const key = getKey(facet.value)
if (key.length === 0) {
continue
}
const downloads = Number.isFinite(facet.downloads) ? facet.downloads : 0
downloadsByValue.set(key, (downloadsByValue.get(key) ?? 0) + downloads)
}
return downloadsByValue
}
export function getAnalyticsFacetsFilterOptionSummary(
facets: Labrinth.Analytics.v3.AnalyticsFacets | null | undefined,
): AnalyticsFacetsFilterOptionSummary {
if (!facets) {
return getEmptyAnalyticsFacetsFilterOptionSummary()
}
const downloadCountries = getAnalyticsFacetValues(facets.project_downloads.country)
const downloadGameVersions = getAnalyticsFacetValues(facets.project_downloads.game_version)
const downloadLoaders = getAnalyticsFacetValues(facets.project_downloads.loader)
const downloadVersionIds = getAnalyticsFacetValues(facets.project_downloads.version_id)
const viewCountries = getAnalyticsFacetValues(facets.project_views.country)
const playtimeCountries = getAnalyticsFacetValues(facets.project_playtime.country)
const playtimeGameVersions = getAnalyticsFacetValues(facets.project_playtime.game_version)
const playtimeLoaders = getAnalyticsFacetValues(facets.project_playtime.loader)
const playtimeVersionIds = getAnalyticsFacetValues(facets.project_playtime.version_id)
const countries = new Set([...viewCountries, ...downloadCountries, ...playtimeCountries])
const gameVersions = new Set([...downloadGameVersions, ...playtimeGameVersions])
const loaderTypes = new Set<string>()
for (const loader of [...downloadLoaders, ...playtimeLoaders]) {
const normalizedLoader = loader.trim().toLowerCase()
if (normalizedLoader.length > 0 && normalizedLoader !== 'mrpack') {
loaderTypes.add(normalizedLoader)
}
}
return {
countries: sortStringValues(
[...countries]
.map((country) => country.trim().toUpperCase())
.filter((country) => country.length > 0),
),
downloadSources: sortStringValues(getAnalyticsFacetValues(facets.project_downloads.user_agent)),
downloadReasons: sortStringValues(getAnalyticsFacetValues(facets.project_downloads.reason)),
gameVersions: sortStringValues(
[...gameVersions]
.map((gameVersion) => gameVersion.trim())
.filter((gameVersion) => gameVersion.length > 0),
),
loaderTypes: sortStringValues([...loaderTypes]),
versionIds: sortStringValues([...new Set([...downloadVersionIds, ...playtimeVersionIds])]),
projectDownloadsById: getAnalyticsFacetDownloadsByValue(
facets.project_downloads.project_id,
(projectId) => projectId.trim(),
),
projectVersionDownloadsById: getAnalyticsFacetDownloadsByValue(
facets.project_downloads.version_id,
(versionId) => versionId.trim(),
),
gameVersionDownloadsByVersion: getAnalyticsFacetDownloadsByValue(
facets.project_downloads.game_version,
(gameVersion) => gameVersion.trim(),
),
countryDownloadsByCode: getAnalyticsFacetDownloadsByValue(
facets.project_downloads.country,
(country) => country.trim().toUpperCase(),
),
}
}
export function doesAnalyticsPointMatchFilters(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
filters: AnalyticsSelectedFilters,
): boolean {
return doesAnalyticsPointMatchNormalizedFilters(
dataPoint,
normalizeAnalyticsSelectedFilters(filters),
)
}
export function normalizeAnalyticsSelectedFilters(
filters: AnalyticsSelectedFilters,
): NormalizedAnalyticsSelectedFilters {
return {
country: normalizeAnalyticsFilterValues(filters.country),
monetization: normalizeAnalyticsFilterValues(filters.monetization),
userAgent: normalizeAnalyticsFilterValues(filters.user_agent),
downloadReason: normalizeAnalyticsFilterValues(filters.download_reason),
versionId: normalizeAnalyticsFilterValues(filters.version_id),
gameVersion: normalizeAnalyticsFilterValues(filters.game_version),
loaderType: normalizeAnalyticsFilterValues(filters.loader_type),
}
}
function normalizeAnalyticsFilterValues(values: string[]): ReadonlySet<string> {
const normalizedValues = new Set<string>()
for (const value of values) {
const normalizedValue = value.trim().toLowerCase()
if (normalizedValue.length > 0) {
normalizedValues.add(normalizedValue)
}
}
return normalizedValues
}
export function doesAnalyticsPointMatchNormalizedFilters(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
filters: NormalizedAnalyticsSelectedFilters,
): boolean {
switch (dataPoint.metric_kind) {
case 'views':
return (
doesAnalyticsPointMatchNormalizedFilter(
dataPoint,
filters.country,
getCountryFilterValue,
) &&
doesAnalyticsPointMatchNormalizedFilter(
dataPoint,
filters.monetization,
getMonetizationFilterValue,
)
)
case 'downloads':
return (
doesAnalyticsPointMatchNormalizedFilter(
dataPoint,
filters.country,
getCountryFilterValue,
) &&
doesAnalyticsPointMatchNormalizedFilter(
dataPoint,
filters.monetization,
getMonetizationFilterValue,
) &&
doesAnalyticsPointMatchNormalizedFilter(
dataPoint,
filters.userAgent,
getDownloadSourceFilterValue,
) &&
doesAnalyticsPointMatchNormalizedFilter(
dataPoint,
filters.downloadReason,
getDownloadReasonFilterValue,
) &&
doesAnalyticsPointMatchNormalizedFilter(
dataPoint,
filters.versionId,
getVersionFilterValue,
) &&
doesAnalyticsPointMatchNormalizedFilter(
dataPoint,
filters.gameVersion,
getGameVersionFilterValue,
) &&
doesAnalyticsPointMatchNormalizedFilter(dataPoint, filters.loaderType, getLoaderFilterValue)
)
case 'playtime':
return (
doesAnalyticsPointMatchNormalizedFilter(
dataPoint,
filters.country,
getCountryFilterValue,
) &&
doesAnalyticsPointMatchNormalizedFilter(
dataPoint,
filters.versionId,
getVersionFilterValue,
) &&
doesAnalyticsPointMatchNormalizedFilter(
dataPoint,
filters.gameVersion,
getGameVersionFilterValue,
) &&
doesAnalyticsPointMatchNormalizedFilter(dataPoint, filters.loaderType, getLoaderFilterValue)
)
case 'revenue':
return true
default:
return true
}
}
function doesAnalyticsPointMatchNormalizedFilter(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
filterValues: ReadonlySet<string>,
getPointValue: (dataPoint: Labrinth.Analytics.v3.ProjectAnalytics) => string | null | undefined,
): boolean {
if (filterValues.size === 0) {
return true
}
const pointValue = getPointValue(dataPoint)
if (pointValue === undefined) {
return true
}
if (pointValue === null) {
return false
}
const normalizedPointValue = pointValue.trim().toLowerCase()
return filterValues.has(normalizedPointValue)
}
function getCountryFilterValue(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
): string | null | undefined {
if (
dataPoint.metric_kind !== 'views' &&
dataPoint.metric_kind !== 'downloads' &&
dataPoint.metric_kind !== 'playtime'
) {
return undefined
}
return dataPoint.country ?? null
}
function getMonetizationFilterValue(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
): string | null | undefined {
if (dataPoint.metric_kind !== 'views' && dataPoint.metric_kind !== 'downloads') {
return undefined
}
if (typeof dataPoint.monetized !== 'boolean') {
return null
}
return dataPoint.monetized ? 'monetized' : 'unmonetized'
}
function getDownloadSourceFilterValue(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
): string | null | undefined {
if (dataPoint.metric_kind !== 'downloads') {
return undefined
}
return dataPoint.user_agent ?? null
}
function getDownloadReasonFilterValue(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
): string | null | undefined {
if (dataPoint.metric_kind !== 'downloads') {
return undefined
}
return dataPoint.reason ?? null
}
function getVersionFilterValue(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
): string | null | undefined {
if (dataPoint.metric_kind !== 'downloads' && dataPoint.metric_kind !== 'playtime') {
return undefined
}
return dataPoint.version_id ?? null
}
function getGameVersionFilterValue(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
): string | null | undefined {
if (dataPoint.metric_kind !== 'downloads' && dataPoint.metric_kind !== 'playtime') {
return undefined
}
return dataPoint.game_version ?? null
}
function getLoaderFilterValue(
dataPoint: Labrinth.Analytics.v3.ProjectAnalytics,
): string | null | undefined {
if (dataPoint.metric_kind !== 'downloads' && dataPoint.metric_kind !== 'playtime') {
return undefined
}
return dataPoint.loader ?? null
}
@@ -0,0 +1,101 @@
import {
getProjectStatusFilterValue,
type ProjectStatusFilterValue,
} from '~/components/analytics-dashboard/query-builder/query-filter'
import type {
AnalyticsDashboardProject,
AnalyticsDashboardProjectSource,
AnalyticsSelectedFilters,
ProjectTypeMetadata,
} from './analytics-types'
const MINECRAFT_JAVA_SERVER_PROJECT_TYPE = 'minecraft_java_server'
export const UNKNOWN_ORGANIZATION_NAME = 'Organization'
function isServerProject(project: ProjectTypeMetadata): boolean {
if (project.project_type === MINECRAFT_JAVA_SERVER_PROJECT_TYPE) {
return true
}
return project.project_types?.includes(MINECRAFT_JAVA_SERVER_PROJECT_TYPE) ?? false
}
export function isAnalyticsEligibleProject(
project: ProjectTypeMetadata & { status?: string | null },
): boolean {
return !isServerProject(project) && getProjectStatusFilterValue(project.status) !== 'draft'
}
export function getSingleQueryValue(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined
}
const normalizedValue = value.trim()
return normalizedValue.length > 0 ? normalizedValue : undefined
}
export function toAnalyticsDashboardProject(
project: AnalyticsDashboardProjectSource,
): AnalyticsDashboardProject {
return {
id: project.id,
name: project.name ?? project.title ?? project.id,
iconUrl: project.icon_url ?? undefined,
downloads: project.downloads ?? 0,
status: getProjectStatusFilterValue(project.status),
}
}
export function getUniqueAnalyticsDashboardProjects(
projects: AnalyticsDashboardProjectSource[],
seenProjectIds: Set<string>,
): AnalyticsDashboardProject[] {
const analyticsProjects: AnalyticsDashboardProject[] = []
for (const project of projects) {
if (seenProjectIds.has(project.id) || !isAnalyticsEligibleProject(project)) {
continue
}
seenProjectIds.add(project.id)
analyticsProjects.push(toAnalyticsDashboardProject(project))
}
return analyticsProjects
}
export function getProjectOrganizationId(
project: AnalyticsDashboardProjectSource,
): string | undefined {
return typeof project.organization === 'string' && project.organization.trim().length > 0
? project.organization
: undefined
}
export function doesProjectStatusMatchFilters(
status: string | null | undefined,
filters: AnalyticsSelectedFilters,
): boolean {
if (filters.project_status.length === 0) {
return true
}
return filters.project_status.includes(getProjectStatusFilterValue(status))
}
export function getProjectIdsMatchingStatusFilter(
projectIds: string[],
projectStatusById: Map<string, ProjectStatusFilterValue>,
filters: AnalyticsSelectedFilters,
): string[] {
if (filters.project_status.length === 0) {
return projectIds
}
return projectIds.filter((projectId) =>
doesProjectStatusMatchFilters(projectStatusById.get(projectId), filters),
)
}
@@ -0,0 +1,202 @@
import type { Labrinth } from '@modrinth/api-client'
import type { LocationQueryValueRaw } from 'vue-router'
import type { ProjectStatusFilterValue } from '~/components/analytics-dashboard/query-builder/query-filter'
export type AnalyticsQueryFilterCategory =
| 'project'
| 'project_status'
| 'country'
| 'monetization'
| 'user_agent'
| 'download_reason'
| 'version_id'
| 'game_version'
| 'loader_type'
export type AnalyticsTimeframePreset =
| 'today'
| 'yesterday'
| 'last_7_days'
| 'last_14_days'
| 'last_30_days'
| 'last_90_days'
| 'last_180_days'
| 'year_to_date'
| 'all_time'
export type AnalyticsTimeframeMode = 'preset' | 'last' | 'custom_range' | 'custom_datetime_range'
export type AnalyticsLastTimeframeUnit = 'hours' | 'days' | 'weeks' | 'months'
export type AnalyticsGroupByPreset = '1h' | '6h' | 'day' | 'week' | 'month' | 'year'
export type AnalyticsBreakdownPreset =
| 'none'
| 'project'
| 'country'
| 'monetization'
| 'user_agent'
| 'download_reason'
| 'version_id'
| 'loader'
| 'game_version'
export type AnalyticsSelectedBreakdowns = Exclude<AnalyticsBreakdownPreset, 'none'>[]
export type AnalyticsDashboardStat = 'views' | 'downloads' | 'revenue' | 'playtime'
export type AnalyticsGraphViewMode = 'line' | 'area' | 'bar'
export type AnalyticsTableSortColumn =
| 'date'
| 'project'
| 'breakdown'
| `breakdown_${Exclude<AnalyticsBreakdownPreset, 'none'>}`
| 'views'
| 'downloads'
| 'revenue'
| 'playtime'
export type AnalyticsTableSortDirection = 'asc' | 'desc'
export type AnalyticsSelectedFilters = Record<AnalyticsQueryFilterCategory, string[]>
export type AnalyticsQueryBuilderState = {
selectedProjectIds: string[]
selectedTimeframeMode: AnalyticsTimeframeMode
selectedTimeframe: AnalyticsTimeframePreset
selectedLastTimeframeAmount: number
selectedLastTimeframeUnit: AnalyticsLastTimeframeUnit
selectedCustomTimeframeStartDate: string
selectedCustomTimeframeEndDate: string
selectedGroupBy: AnalyticsGroupByPreset
selectedBreakdowns: AnalyticsSelectedBreakdowns
selectedFilters: AnalyticsSelectedFilters
}
export type AnalyticsGraphState = {
activeStat: AnalyticsDashboardStat
activeGraphViewMode: AnalyticsGraphViewMode
isRatioMode: boolean
showChartEvents: boolean
showProjectEvents: boolean
showPreviousPeriod: boolean
hiddenGraphDatasetIds: string[]
selectedGraphDatasetIds: string[] | null
}
export type AnalyticsTableSortState = {
sortColumn: AnalyticsTableSortColumn | undefined
sortDirection: AnalyticsTableSortDirection
}
export type MutableRouteQuery = Record<
string,
LocationQueryValueRaw | LocationQueryValueRaw[] | undefined
>
export type ProjectTypeMetadata = {
project_type?: string | null
project_types?: readonly string[] | null
}
export type AnalyticsProjectFetchRequest = Labrinth.Analytics.v3.FetchRequest & {
project_ids: string[]
}
export type AnalyticsDashboardProjectSource = ProjectTypeMetadata & {
id: string
name?: string | null
title?: string | null
organization?: string | null
icon_url?: string | null
downloads?: number | null
status?: string | null
}
export type AnalyticsProjectVersionSource = {
id: string
versions?: readonly string[] | null
}
export interface AnalyticsDashboardProject {
id: string
name: string
iconUrl?: string
downloads: number
status: ProjectStatusFilterValue
}
export interface AnalyticsDashboardProjectGroup {
key?: string
title?: string
projects: AnalyticsDashboardProject[]
}
export interface AnalyticsDashboardTotals {
views: number
downloads: number
revenue: number
playtime: number
}
export interface AnalyticsDashboardPercentChanges {
views: number
downloads: number
revenue: number
playtime: number
}
export interface AnalyticsDashboardFilterOptions {
countries: string[]
downloadSources: string[]
downloadReasons: string[]
gameVersions: string[]
loaderTypes: string[]
versionIds: string[]
}
export interface NormalizedAnalyticsSelectedFilters {
country: ReadonlySet<string>
monetization: ReadonlySet<string>
userAgent: ReadonlySet<string>
downloadReason: ReadonlySet<string>
versionId: ReadonlySet<string>
gameVersion: ReadonlySet<string>
loaderType: ReadonlySet<string>
}
export interface AnalyticsFacetsFilterOptionSummary {
countries: string[]
downloadSources: string[]
downloadReasons: string[]
gameVersions: string[]
loaderTypes: string[]
versionIds: string[]
projectDownloadsById: Map<string, number>
projectVersionDownloadsById: Map<string, number>
gameVersionDownloadsByVersion: Map<string, number>
countryDownloadsByCode: Map<string, number>
}
export interface ProjectVersionFilterOptionSummary {
gameVersions: string[]
loaderTypes: string[]
versionIds: string[]
}
export interface AnalyticsVersionMetadata {
id: string
versionNumber: string
datePublished: string
projectId: string
downloads: number
gameVersions: string[]
loaders: string[]
}
export type AnalyticsTimeSliceSplit = {
currentTimeSlices: Labrinth.Analytics.v3.TimeSlice[]
previousTimeSlices: Labrinth.Analytics.v3.TimeSlice[]
}
export type AnalyticsFetchData = {
metrics: Labrinth.Analytics.v3.TimeSlice[]
project_events: Labrinth.Analytics.v3.ProjectAnalyticsEvent[]
}
File diff suppressed because it is too large Load Diff
-493
View File
@@ -1,493 +0,0 @@
import { injectI18n, useDebugLogger } from '@modrinth/ui'
import dayjs from 'dayjs'
import { computed, ref, watch } from 'vue'
import { useTheme } from '~/composables/nuxt-accessors.ts'
// note: build step can miss unix import for some reason, so
// we have to import it like this
const { unix } = dayjs
export function useCountryNames(style = 'long') {
const { locale } = injectI18n()
const displayNames = computed(
() => new Intl.DisplayNames([locale.value], { type: 'region', style }),
)
return function formatCountryName(code) {
try {
return displayNames.value.of(code) ?? code
} catch {
return code
}
}
}
export const countryCodeToName = (code) => {
const formatCountryName = useCountryNames()
return formatCountryName(code)
}
export const countryCodeToFlag = (code) => {
if (code === 'XX') {
return undefined
}
return `https://flagcdn.com/h240/${code.toLowerCase()}.png`
}
export const formatTimestamp = (timestamp) => {
return unix(timestamp).format()
}
export const formatPercent = (value, sum) => {
return `${((value / sum) * 100).toFixed(2)}%`
}
const hashProjectId = (projectId) => {
return projectId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % 30
}
export const defaultColors = [
'#ff496e', // Original: Bright pink
'#ffa347', // Original: Bright orange
'#1bd96a', // Original: Bright green
'#4f9cff', // Original: Bright blue
'#c78aff', // Original: Bright purple
'#ffeb3b', // Added: Bright yellow
'#00bcd4', // Added: Bright cyan
'#ff5722', // Added: Bright red-orange
'#9c27b0', // Added: Bright deep purple
'#3f51b5', // Added: Bright indigo
'#009688', // Added: Bright teal
'#cddc39', // Added: Bright lime
'#795548', // Added: Bright brown
'#607d8b', // Added: Bright blue-grey
]
/**
* @param {string | number} value
* @returns {string} color
*/
export const getDefaultColor = (value) => {
if (typeof value === 'string') {
value = hashProjectId(value)
}
return defaultColors[value % defaultColors.length]
}
export const intToRgba = (color, projectId = 'Unknown', theme = 'dark', alpha = '1') => {
const hash = hashProjectId(projectId)
if (!color || color === 0) {
return getDefaultColor(hash)
}
// if color is a string, return that instead
if (typeof color === 'string') {
return color
}
// Extract RGB values
let r = (color >> 16) & 255
let g = (color >> 8) & 255
let b = color & 255
// Hash function to alter color slightly based on project_id
r = (r + hash) % 256
g = (g + hash) % 256
b = (b + hash) % 256
// Adjust brightness for theme
const brightness = r * 0.299 + g * 0.587 + b * 0.114
const threshold = theme === 'dark' ? 50 : 200
if (theme === 'dark' && brightness < threshold) {
// Increase brightness for dark theme
r += threshold / 2
g += threshold / 2
b += threshold / 2
} else if (theme === 'light' && brightness > threshold) {
// Decrease brightness for light theme
r -= threshold / 4
g -= threshold / 4
b -= threshold / 4
}
// Ensure RGB values are within 0-255
r = Math.min(255, Math.max(0, r))
g = Math.min(255, Math.max(0, g))
b = Math.min(255, Math.max(0, b))
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
const emptyAnalytics = {
sum: 0,
len: 0,
chart: {
labels: [],
data: [],
sumData: [
{
name: '',
data: [],
},
],
colors: [],
defaultColors: [],
},
projectIds: [],
}
export const analyticsSetToCSVString = (analytics) => {
if (!analytics) {
return ''
}
const newline = '\n'
const labels = analytics.chart.labels
const projects = analytics.chart.data
const projectNames = projects.map((p) => p.name)
const header = ['Date', ...projectNames].join(',')
const data = labels.map((label, i) => {
const values = projects.map((p) => p.data?.[i] || '')
return [label, ...values].join(',')
})
return [header, ...data].join(newline)
}
export const processAnalytics = (category, projects, labelFn, sortFn, mapFn, chartName, theme) => {
if (!category || !projects) {
return emptyAnalytics
}
// Get an intersection of category keys and project ids
const projectIds = projects.map((p) => p.id)
const loadedProjectIds = Object.keys(category).filter((id) => projectIds.includes(id))
if (!loadedProjectIds?.length) {
return emptyAnalytics
}
const loadedProjectData = loadedProjectIds.map((id) => category[id])
// Convert each project's data into a list of [unix_ts_str, number] pairs
const projectData = loadedProjectData
.map((data) => Object.entries(data))
.map((data) => data.sort(sortFn))
.map((data) => (mapFn ? data.map(mapFn) : data))
// Each project may not include the same timestamps, so we should use the union of all timestamps
const timestamps = Array.from(
new Set(projectData.flatMap((data) => data.map(([ts]) => ts))),
).sort()
const chartData = projectData
.map((data, i) => {
const project = projects.find((p) => p.id === loadedProjectIds[i])
if (!project) {
throw new Error(`Project ${loadedProjectIds[i]} not found`)
}
return {
name: `${project.title}`,
data: timestamps.map((ts) => {
const entry = data.find(([ets]) => ets === ts)
return entry ? entry[1] : 0
}),
id: project.id,
color: project.color,
}
})
.sort(
(a, b) =>
b.data.reduce((acc, cur) => acc + cur, 0) - a.data.reduce((acc, cur) => acc + cur, 0),
)
const projectIdsSortedBySum = chartData.map((p) => p.id)
return {
// The total count of all the values across all projects
sum: projectData.reduce((acc, cur) => acc + cur.reduce((a, c) => a + c[1], 0), 0),
len: timestamps.length,
chart: {
labels: timestamps.map(labelFn),
data: chartData.map((x) => ({ name: x.name, data: x.data })),
sumData: [
{
name: chartName,
data: timestamps.map((ts) => {
const entries = projectData.flat().filter(([ets]) => ets === ts)
return entries.reduce((acc, cur) => acc + cur[1], 0)
}),
},
],
colors: projectData.map((_, i) => {
const project = chartData[i]
return intToRgba(project.color, project.id, theme)
}),
defaultColors: projectData.map((_, i) => {
const project = chartData[i]
return getDefaultColor(project.id)
}),
},
projectIds: projectIdsSortedBySum,
}
}
export const processAnalyticsByCountry = (category, projects, sortFn) => {
if (!category || !projects) {
return {
sum: 0,
len: 0,
data: [],
}
}
// Get an intersection of category keys and project ids
const projectIds = projects.map((p) => p.id)
const loadedProjectIds = Object.keys(category).filter((id) => projectIds.includes(id))
if (!loadedProjectIds?.length) {
return {
sum: 0,
len: 0,
data: [],
}
}
const loadedProjectData = loadedProjectIds.map((id) => category[id])
// Convert each project's data into a list of [countrycode, number] pairs
// Fold into a single list with summed values for each country over all projects
const countrySums = new Map()
loadedProjectData.forEach((data) => {
Object.entries(data).forEach(([country, value]) => {
const countryCode = country || 'XX'
const current = countrySums.get(countryCode) || 0
countrySums.set(countryCode, current + value)
})
})
const entries = Array.from(countrySums.entries())
return {
sum: entries.reduce((acc, cur) => acc + cur[1], 0),
len: entries.length,
data: entries.sort(sortFn),
}
}
const sortCount = ([, a], [, b]) => b - a
const sortTimestamp = ([a], [b]) => a - b
const roundValue = ([ts, value]) => [ts, Math.round(parseFloat(value) * 100) / 100]
const processCountryAnalytics = (c, projects) => processAnalyticsByCountry(c, projects, sortCount)
const processNumberAnalytics = (c, projects, theme) =>
processAnalytics(c, projects, formatTimestamp, sortTimestamp, null, 'Downloads', theme)
const processRevAnalytics = (c, projects, theme) =>
processAnalytics(c, projects, formatTimestamp, sortTimestamp, roundValue, 'Revenue', theme)
const useFetchAnalytics = (
url,
baseOptions = {
apiVersion: 3,
},
) => {
return useBaseFetch(url, baseOptions)
}
/**
* @param {Ref<any[]>} projects
* @param {undefined | () => any} onDataRefresh
*/
export const useFetchAllAnalytics = (
onDataRefresh,
projects,
selectedProjects,
personalRevenue = false,
startDate = ref(dayjs().subtract(30, 'days')),
endDate = ref(dayjs()),
timeResolution = ref(1440),
) => {
const debug = useDebugLogger('useFetchAllAnalytics')
debug('init', {
projectCount: projects.value?.length,
personalRevenue,
startDate: startDate.value?.toISOString(),
endDate: endDate.value?.toISOString(),
})
const downloadData = ref(null)
const viewData = ref(null)
const revenueData = ref(null)
const downloadsByCountry = ref(null)
const viewsByCountry = ref(null)
const loading = ref(true)
const error = ref(null)
const formattedData = computed(() => ({
downloads: processNumberAnalytics(downloadData.value, selectedProjects.value),
views: processNumberAnalytics(viewData.value, selectedProjects.value),
revenue: processRevAnalytics(revenueData.value, selectedProjects.value),
downloadsByCountry: processCountryAnalytics(downloadsByCountry.value, selectedProjects.value),
viewsByCountry: processCountryAnalytics(viewsByCountry.value, selectedProjects.value),
}))
const theme = useTheme()
const totalData = computed(() => ({
downloads: processNumberAnalytics(downloadData.value, projects.value, theme.active),
views: processNumberAnalytics(viewData.value, projects.value, theme.active),
revenue: processRevAnalytics(revenueData.value, projects.value, theme.active),
}))
const buildQuery = () => {
const q = {
start_date: startDate.value.toISOString(),
end_date: endDate.value.toISOString(),
resolution_minutes: timeResolution.value,
}
if (projects.value?.length) {
q.project_ids = JSON.stringify(projects.value.map((p) => p.id))
}
return q
}
const fetchData = async (query) => {
debug('fetchData called', { query })
const normalQuery = new URLSearchParams(query)
const revenueQuery = new URLSearchParams(query)
if (personalRevenue) {
revenueQuery.delete('project_ids')
}
const qs = normalQuery.toString()
const revenueQs = revenueQuery.toString()
try {
loading.value = true
error.value = null
debug('fetching all 5 endpoints...')
const responses = await Promise.all([
useFetchAnalytics(`analytics/downloads?${qs}`),
useFetchAnalytics(`analytics/views?${qs}`),
useFetchAnalytics(`analytics/revenue?${revenueQs}`),
useFetchAnalytics(`analytics/countries/downloads?${qs}`),
useFetchAnalytics(`analytics/countries/views?${qs}`),
])
debug('all 5 endpoints resolved', {
downloads: Object.keys(responses[0] || {}).length,
views: Object.keys(responses[1] || {}).length,
revenue: Object.keys(responses[2] || {}).length,
})
const projectIds = new Set()
if (projects.value) {
projects.value.forEach((p) => projectIds.add(p.id))
} else {
Object.keys(responses[0] || {}).forEach((id) => projectIds.add(id))
}
debug('filtering to projectIds', { count: projectIds.size })
const filterProjectIds = (data) => {
const filtered = {}
Object.entries(data).forEach(([id, values]) => {
if (projectIds.has(id)) {
filtered[id] = values
}
})
return filtered
}
downloadData.value = filterProjectIds(responses[0] || {})
viewData.value = filterProjectIds(responses[1] || {})
revenueData.value = filterProjectIds(responses[2] || {})
downloadsByCountry.value = responses[3] || {}
viewsByCountry.value = responses[4] || {}
} catch (e) {
debug('fetchData error', e)
error.value = e
} finally {
loading.value = false
debug('fetchData done, loading=false')
}
}
const fetch = async () => {
debug('fetch() called', { projectCount: projects.value?.length })
await fetchData(buildQuery())
if (onDataRefresh) {
onDataRefresh()
}
}
watch(
[() => startDate.value, () => endDate.value, () => timeResolution.value, () => projects.value],
(newVals, oldVals) => {
debug('watch triggered', { new: newVals, old: oldVals })
fetch()
},
)
const validProjectIds = computed(() => {
const ids = new Set()
if (downloadData.value) {
Object.keys(downloadData.value).forEach((id) => ids.add(id))
}
if (viewData.value) {
Object.keys(viewData.value).forEach((id) => ids.add(id))
}
if (revenueData.value) {
// revenue will always have all project ids, but the ids may have an empty object or a ton of keys below a cent (0.00...) as values. We want to filter those out
Object.entries(revenueData.value).forEach(([id, data]) => {
if (Object.keys(data).length) {
if (Object.values(data).some((v) => v >= 0.01)) {
ids.add(id)
}
}
})
}
return Array.from(ids)
})
return {
// Configuration
timeResolution,
startDate,
endDate,
// Data
downloadData,
viewData,
revenueData,
downloadsByCountry,
viewsByCountry,
// Computed state
validProjectIds,
formattedData,
totalData,
loading,
error,
fetch,
}
}
+4
View File
@@ -14,6 +14,7 @@ import { KyrosLogsV1Module } from './kyros/logs/v1'
import { KyrosUploadSessionsV1Module } from './kyros/upload-sessions/v1'
import { LabrinthVersionsV2Module, LabrinthVersionsV3Module } from './labrinth'
import { LabrinthAffiliateInternalModule } from './labrinth/affiliate/internal'
import { LabrinthAnalyticsV3Module } from './labrinth/analytics/v3'
import { LabrinthAuthInternalModule } from './labrinth/auth/internal'
import { LabrinthAuthV2Module } from './labrinth/auth/v2'
import { LabrinthBillingInternalModule } from './labrinth/billing/internal'
@@ -40,6 +41,7 @@ import { LabrinthTeamsV3Module } from './labrinth/teams/v3'
import { LabrinthTechReviewInternalModule } from './labrinth/tech-review/internal'
import { LabrinthThreadsV3Module } from './labrinth/threads/v3'
import { LabrinthUsersV2Module } from './labrinth/users/v2'
import { LabrinthUsersV3Module } from './labrinth/users/v3'
import { LauncherMetaManifestV0Module } from './launcher-meta/v0'
import { MclogsInsightsV1Module } from './mclogs/insights/v1'
import { MclogsLogsV1Module } from './mclogs/logs/v1'
@@ -74,6 +76,7 @@ export const MODULE_REGISTRY = {
kyros_logs_v1: KyrosLogsV1Module,
kyros_upload_sessions_v1: KyrosUploadSessionsV1Module,
labrinth_affiliate_internal: LabrinthAffiliateInternalModule,
labrinth_analytics_v3: LabrinthAnalyticsV3Module,
labrinth_auth_internal: LabrinthAuthInternalModule,
labrinth_auth_v2: LabrinthAuthV2Module,
labrinth_billing_internal: LabrinthBillingInternalModule,
@@ -100,6 +103,7 @@ export const MODULE_REGISTRY = {
labrinth_tech_review_internal: LabrinthTechReviewInternalModule,
labrinth_threads_v3: LabrinthThreadsV3Module,
labrinth_users_v2: LabrinthUsersV2Module,
labrinth_users_v3: LabrinthUsersV3Module,
labrinth_versions_v2: LabrinthVersionsV2Module,
labrinth_versions_v3: LabrinthVersionsV3Module,
paper_versions_v3: PaperVersionsV3Module,
@@ -0,0 +1,116 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthAnalyticsV3Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_analytics_v3'
}
/**
* Fetch analytics data for the authenticated user's accessible projects
* and affiliate codes.
*
* @param data - Analytics request body defining time range and requested metrics
* @returns Promise resolving to the analytics response, with time slices in `metrics`
*
* @example
* ```typescript
* const response = await client.labrinth.analytics_v3.fetch({
* time_range: {
* start: '2026-01-01T00:00:00Z',
* end: '2026-02-01T00:00:00Z',
* resolution: { slices: 31 },
* },
* project_ids: ['A1B2C3D4'],
* return_metrics: {
* project_views: { bucket_by: ['project_id'] },
* },
* })
* const timeSlices = response.metrics
* ```
*/
public async fetch(
data: Labrinth.Analytics.v3.FetchRequest,
): Promise<Labrinth.Analytics.v3.FetchResponse> {
return this.client.request<Labrinth.Analytics.v3.FetchResponse>('/analytics', {
api: 'labrinth',
version: 3,
method: 'POST',
body: data,
timeout: 100 * 1000,
})
}
/**
* Fetch available analytics filter facets for the authenticated user's
* accessible projects.
*
* POST /v3/analytics/facets
*/
public async fetchFacets(
data: Labrinth.Analytics.v3.FetchRequest,
): Promise<Labrinth.Analytics.v3.FacetsResponse> {
return this.client.request<Labrinth.Analytics.v3.FacetsResponse>('/analytics/facets', {
api: 'labrinth',
version: 3,
method: 'POST',
body: data,
timeout: 100 * 1000,
})
}
/**
* Fetch all analytics events.
* GET /v3/analytics-event
*/
public async getEvents(): Promise<Labrinth.Analytics.v3.AnalyticsEvent[]> {
return this.client.request<Labrinth.Analytics.v3.AnalyticsEvent[]>('/analytics-event', {
api: 'labrinth',
version: 3,
method: 'GET',
})
}
/**
* Create an analytics event.
* POST /v3/analytics-event
*/
public async createEvent(
data: Labrinth.Analytics.v3.AnalyticsEventUpsert,
): Promise<Labrinth.Analytics.v3.AnalyticsEvent> {
return this.client.request<Labrinth.Analytics.v3.AnalyticsEvent>('/analytics-event', {
api: 'labrinth',
version: 3,
method: 'POST',
body: data,
})
}
/**
* Edit an analytics event.
* PATCH /v3/analytics-event/{id}
*/
public async editEvent(
id: Labrinth.Analytics.v3.AnalyticsEventId,
data: Labrinth.Analytics.v3.AnalyticsEventUpsert,
): Promise<Labrinth.Analytics.v3.AnalyticsEvent> {
return this.client.request<Labrinth.Analytics.v3.AnalyticsEvent>(`/analytics-event/${id}`, {
api: 'labrinth',
version: 3,
method: 'PATCH',
body: data,
})
}
/**
* Delete an analytics event.
* DELETE /v3/analytics-event/{id}
*/
public async deleteEvent(id: Labrinth.Analytics.v3.AnalyticsEventId): Promise<void> {
return this.client.request<void>(`/analytics-event/${id}`, {
api: 'labrinth',
version: 3,
method: 'DELETE',
})
}
}
@@ -1,3 +1,4 @@
export * from './analytics/v3'
export * from './auth/internal'
export * from './auth/v2'
export * from './billing/internal'
@@ -21,5 +22,6 @@ export * from './state'
export * from './tech-review/internal'
export * from './threads/v3'
export * from './users/v2'
export * from './users/v3'
export * from './versions/v2'
export * from './versions/v3'
@@ -238,6 +238,267 @@ export namespace Labrinth {
}
}
export namespace Analytics {
export namespace v3 {
export type AnalyticsEventId = number
export type AnalyticsEventMetricKind = 'views' | 'revenue' | 'downloads' | 'playtime'
export type AnalyticsEvent = {
announcement_url: string | null
for_metric_kind: AnalyticsEventMetricKind[] | null
title: string
ends: string
id: AnalyticsEventId
starts: string
}
export type AnalyticsEventUpsert = {
announcement_url: string | null
for_metric_kind: AnalyticsEventMetricKind[] | null
title: string
ends: string
starts: string
}
export type FetchRequest = {
time_range: TimeRange
return_metrics: ReturnMetrics
project_ids?: string[]
}
export type TimeRange = {
start: string
end: string
resolution: TimeRangeResolution
}
export type TimeRangeResolution = { slices: number } | { minutes: number }
export type ReturnMetrics = {
project_views?: Metrics<ProjectViewsField, ProjectViewsFilters>
project_downloads?: Metrics<ProjectDownloadsField, ProjectDownloadsFilters>
project_playtime?: Metrics<ProjectPlaytimeField, ProjectPlaytimeFilters>
project_revenue?: Metrics<ProjectRevenueField, ProjectRevenueFilters>
affiliate_code_clicks?: Metrics<AffiliateCodeClicksField, AffiliateCodeClicksFilters>
affiliate_code_conversions?: Metrics<
AffiliateCodeConversionsField,
AffiliateCodeConversionsFilters
>
affiliate_code_revenue?: Metrics<AffiliateCodeRevenueField, AffiliateCodeRevenueFilters>
}
export type Metrics<BucketBy, FilterBy> = {
bucket_by?: BucketBy[]
filter_by?: FilterBy
}
export type ProjectViewsField =
| 'project_id'
| 'domain'
| 'site_path'
| 'monetized'
| 'country'
export type ProjectDownloadsField =
| 'project_id'
| 'version_id'
| 'user_agent'
| 'domain'
| 'country'
| 'monetized'
| 'reason'
| 'game_version'
| 'loader'
export type ProjectPlaytimeField =
| 'project_id'
| 'version_id'
| 'loader'
| 'game_version'
| 'country'
export type ProjectRevenueField = 'project_id'
export type DownloadReason = 'standalone' | 'dependency' | 'modpack' | 'update'
export type AffiliateCodeClicksField = 'affiliate_code_id'
export type AffiliateCodeConversionsField = 'affiliate_code_id'
export type AffiliateCodeRevenueField = 'affiliate_code_id'
export type ProjectViewsFilters = {
domain?: string[]
site_path?: string[]
monetized?: boolean[]
country?: string[]
}
export type ProjectDownloadsFilters = {
version_id?: string[]
domain?: string[]
user_agent?: string[]
monetized?: boolean[]
country?: string[]
reason?: DownloadReason[]
game_version?: string[]
loader?: string[]
}
export type ProjectPlaytimeFilters = {
version_id?: string[]
loader?: string[]
game_version?: string[]
country?: string[]
}
export type ProjectRevenueFilters = Record<string, never>
export type AffiliateCodeClicksFilters = {
affiliate_code_id?: string[]
}
export type AffiliateCodeConversionsFilters = {
affiliate_code_id?: string[]
}
export type AffiliateCodeRevenueFilters = {
affiliate_code_id?: string[]
}
export type FetchResponse = {
metrics: TimeSlice[]
project_events: ProjectAnalyticsEvent[]
}
export type FacetsResponse = {
facets: AnalyticsFacets
}
export type AnalyticsFacet<T> = {
value: T
downloads: number
}
export type AnalyticsFacets = {
project_views: ProjectViewsFacets
project_downloads: ProjectDownloadsFacets
project_playtime: ProjectPlaytimeFacets
}
export type ProjectViewsFacets = {
domain: AnalyticsFacet<string>[]
site_path: AnalyticsFacet<string>[]
monetized: AnalyticsFacet<boolean>[]
country: AnalyticsFacet<string>[]
}
export type ProjectDownloadsFacets = {
project_id: AnalyticsFacet<string>[]
domain: AnalyticsFacet<string>[]
user_agent: AnalyticsFacet<string>[]
version_id: AnalyticsFacet<string>[]
monetized: AnalyticsFacet<boolean>[]
country: AnalyticsFacet<string>[]
reason: AnalyticsFacet<DownloadReason>[]
game_version: AnalyticsFacet<string>[]
loader: AnalyticsFacet<string>[]
}
export type ProjectPlaytimeFacets = {
version_id: AnalyticsFacet<string>[]
loader: AnalyticsFacet<string>[]
game_version: AnalyticsFacet<string>[]
country: AnalyticsFacet<string>[]
}
export type TimeSlice = AnalyticsData[]
export type ProjectAnalyticsEvent = {
project_id: string
timestamp: string
} & ProjectAnalyticsEventKind
export type ProjectAnalyticsEventKind =
| {
kind: 'version_uploaded'
version_id: string
version_name: string
version_number: string
}
| {
kind: 'status_changed'
status_from: Projects.v2.ProjectStatus
status_to: Projects.v2.ProjectStatus
}
export type AnalyticsData = ProjectAnalytics | AffiliateCodeAnalytics
export type ProjectAnalytics = {
source_project: string
} & ProjectMetrics
export type ProjectMetrics =
| ({ metric_kind: 'views' } & ProjectViews)
| ({ metric_kind: 'downloads' } & ProjectDownloads)
| ({ metric_kind: 'playtime' } & ProjectPlaytime)
| ({ metric_kind: 'revenue' } & ProjectRevenue)
export type ProjectViews = {
domain?: string
site_path?: string
monetized?: boolean
country?: string
views: number
}
export type ProjectDownloads = {
user_agent?: string
domain?: string
version_id?: string
country?: string
monetized?: boolean
reason?: DownloadReason
game_version?: string
loader?: string
downloads: number
}
export type ProjectPlaytime = {
version_id?: string
loader?: string
game_version?: string
country?: string
seconds: number
}
export type ProjectRevenue = {
revenue: string
}
export type AffiliateCodeAnalytics = {
source_affiliate_code: string
} & AffiliateCodeMetrics
export type AffiliateCodeMetrics =
| ({ metric_kind: 'clicks' } & AffiliateCodeClicks)
| ({ metric_kind: 'conversions' } & AffiliateCodeConversions)
| ({ metric_kind: 'revenue' } & AffiliateCodeRevenue)
export type AffiliateCodeClicks = {
clicks: number
}
export type AffiliateCodeConversions = {
conversions: number
}
export type AffiliateCodeRevenue = {
revenue: string
}
}
}
export namespace Auth {
export namespace Internal {
export type SubscriptionStatus = {
@@ -1060,6 +1321,11 @@ export namespace Labrinth {
allow_friend_requests?: boolean
github_id?: number
}
export type AllProjectsResponse = {
projects: Projects.v3.Project[]
organizations: Record<string, Organizations.v3.Organization>
}
}
}
@@ -0,0 +1,29 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthUsersV3Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_users_v3'
}
/**
* Get all projects the authenticated user can access directly or through
* their organizations.
*
* @param idOrUsername - User ID or username. Must be the authenticated user.
*
* GET /v3/user/{id}/all-projects
*/
public async getAllProjects(
idOrUsername: string,
): Promise<Labrinth.Users.v3.AllProjectsResponse> {
return this.client.request<Labrinth.Users.v3.AllProjectsResponse>(
`/user/${encodeURIComponent(idOrUsername)}/all-projects`,
{
api: 'labrinth',
version: 3,
method: 'GET',
},
)
}
}
+14
View File
@@ -46,6 +46,9 @@ import _CalendarArrowDownIcon from './icons/calendar-arrow-down.svg?component'
import _CardIcon from './icons/card.svg?component'
import _ChangeSkinIcon from './icons/change-skin.svg?component'
import _ChartIcon from './icons/chart.svg?component'
import _ChartAreaIcon from './icons/chart-area.svg?component'
import _ChartColumnBigIcon from './icons/chart-column-big.svg?component'
import _ChartSplineIcon from './icons/chart-spline.svg?component'
import _CheckIcon from './icons/check.svg?component'
import _CheckCheckIcon from './icons/check-check.svg?component'
import _CheckCircleIcon from './icons/check-circle.svg?component'
@@ -135,6 +138,7 @@ import _KeyIcon from './icons/key.svg?component'
import _KeyboardIcon from './icons/keyboard.svg?component'
import _LandmarkIcon from './icons/landmark.svg?component'
import _LanguagesIcon from './icons/languages.svg?component'
import _LayersIcon from './icons/layers.svg?component'
import _LayoutTemplateIcon from './icons/layout-template.svg?component'
import _LeftArrowIcon from './icons/left-arrow.svg?component'
import _LibraryIcon from './icons/library.svg?component'
@@ -179,6 +183,7 @@ import _PaintbrushIcon from './icons/paintbrush.svg?component'
import _PaletteIcon from './icons/palette.svg?component'
import _PencilIcon from './icons/pencil.svg?component'
import _PickaxeIcon from './icons/pickaxe.svg?component'
import _PinIcon from './icons/pin.svg?component'
import _PlayIcon from './icons/play.svg?component'
import _PlugIcon from './icons/plug.svg?component'
import _PlusIcon from './icons/plus.svg?component'
@@ -370,6 +375,8 @@ import _ToggleRightIcon from './icons/toggle-right.svg?component'
import _TransferIcon from './icons/transfer.svg?component'
import _TrashIcon from './icons/trash.svg?component'
import _TrashExclamationIcon from './icons/trash-exclamation.svg?component'
import _TrendingDownIcon from './icons/trending-down.svg?component'
import _TrendingUpIcon from './icons/trending-up.svg?component'
import _TriangleAlertIcon from './icons/triangle-alert.svg?component'
import _UnderlineIcon from './icons/underline.svg?component'
import _UndoIcon from './icons/undo.svg?component'
@@ -439,6 +446,9 @@ export const CalendarArrowDownIcon = _CalendarArrowDownIcon
export const CardIcon = _CardIcon
export const ChangeSkinIcon = _ChangeSkinIcon
export const ChartIcon = _ChartIcon
export const ChartAreaIcon = _ChartAreaIcon
export const ChartColumnBigIcon = _ChartColumnBigIcon
export const ChartSplineIcon = _ChartSplineIcon
export const CheckIcon = _CheckIcon
export const CheckCheckIcon = _CheckCheckIcon
export const CheckCircleIcon = _CheckCircleIcon
@@ -528,6 +538,7 @@ export const KeyIcon = _KeyIcon
export const KeyboardIcon = _KeyboardIcon
export const LandmarkIcon = _LandmarkIcon
export const LanguagesIcon = _LanguagesIcon
export const LayersIcon = _LayersIcon
export const LayoutTemplateIcon = _LayoutTemplateIcon
export const LeftArrowIcon = _LeftArrowIcon
export const LibraryIcon = _LibraryIcon
@@ -572,6 +583,7 @@ export const PaintbrushIcon = _PaintbrushIcon
export const PaletteIcon = _PaletteIcon
export const PencilIcon = _PencilIcon
export const PickaxeIcon = _PickaxeIcon
export const PinIcon = _PinIcon
export const PlayIcon = _PlayIcon
export const PlugIcon = _PlugIcon
export const PlusIcon = _PlusIcon
@@ -763,6 +775,8 @@ export const ToggleRightIcon = _ToggleRightIcon
export const TransferIcon = _TransferIcon
export const TrashIcon = _TrashIcon
export const TrashExclamationIcon = _TrashExclamationIcon
export const TrendingDownIcon = _TrendingDownIcon
export const TrendingUpIcon = _TrendingUpIcon
export const TriangleAlertIcon = _TriangleAlertIcon
export const UnderlineIcon = _UnderlineIcon
export const UndoIcon = _UndoIcon
+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-chart-area"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 3v16a2 2 0 0 0 2 2h16" />
<path d="M7 11.207a.5.5 0 0 1 .146-.353l2-2a.5.5 0 0 1 .708 0l3.292 3.292a.5.5 0 0 0 .708 0l4.292-4.292a.5.5 0 0 1 .854.353V16a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1z" />
</svg>

After

Width:  |  Height:  |  Size: 494 B

@@ -0,0 +1,17 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-chart-column-big"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 3v16a2 2 0 0 0 2 2h16" />
<rect x="15" y="5" width="4" height="12" rx="1" />
<rect x="7" y="8" width="4" height="9" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 440 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-chart-spline"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 3v16a2 2 0 0 0 2 2h16" />
<path d="M7 16c.5-2 1.5-7 4-7 2 0 2 3 4 3 2.5 0 4.5-5 5-7" />
</svg>

After

Width:  |  Height:  |  Size: 396 B

+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-layers"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" />
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
</svg>

After

Width:  |  Height:  |  Size: 588 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-pin"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 17v5" />
<path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z" />
</svg>

After

Width:  |  Height:  |  Size: 525 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-trending-down"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M16 17h6v-6" />
<path d="m22 17-8.5-8.5-5 5L2 7" />
</svg>

After

Width:  |  Height:  |  Size: 358 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-trending-up"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M16 7h6v6" />
<path d="m22 7-8.5 8.5-5-5L2 17" />
</svg>

After

Width:  |  Height:  |  Size: 354 B

+7
View File
@@ -168,6 +168,13 @@ textarea {
margin: 0; /* 2 */
}
button,
input,
select,
textarea {
touch-action: manipulation;
}
/**
* Show the overflow in IE.
* 1. Show the overflow in Edge.
+1
View File
@@ -86,6 +86,7 @@
"lru-cache": "^11.2.4",
"markdown-it": "^13.0.2",
"motion-v": "^2.2.1",
"overlayscrollbars": "^2.15.1",
"postprocessing": "^6.37.6",
"qrcode.vue": "^3.4.1",
"three": "^0.172.0",
@@ -188,7 +188,7 @@ const colorVariables = computed(() => {
}
const hoverColors = JSON.parse(JSON.stringify(colors))
const boxShadow =
props.type === 'chip' && colorVar.value ? `0 0 0 2px ${colorVar.value}` : defaultShadow
props.type === 'chip' && colorVar.value ? `0 0 0 1px ${colorVar.value}` : defaultShadow
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_icon: ${colors.icon}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text}; --_hover-icon: ${hoverColors.icon}; --_box-shadow: ${boxShadow};`
}
@@ -226,7 +226,7 @@ const colorVariables = computed(() => {
}
const boxShadow =
props.type === 'chip' && colorVar.value ? `0 0 0 2px ${colorVar.value}` : defaultShadow
props.type === 'chip' && colorVar.value ? `0 0 0 1px ${colorVar.value}` : defaultShadow
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text}; --_box-shadow: ${boxShadow};`
})
@@ -266,7 +266,7 @@ const fontSize = computed(() => {
> *:first-child
> *:first-child
> :is(button, a, .button-like):first-child {
@apply flex cursor-pointer flex-row items-center justify-center border-solid border-2 border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight] whitespace-nowrap;
@apply flex touch-manipulation cursor-pointer flex-row items-center justify-center border-solid border border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight] whitespace-nowrap;
box-shadow: var(--_box-shadow, inset 0 0 0 transparent);
transition:
scale 0.125s ease-in-out,
+3 -3
View File
@@ -35,7 +35,7 @@ import { CheckIcon, MinusIcon } from '@modrinth/assets'
import type { HTMLAttributes } from 'vue'
const emit = defineEmits<{
'update:modelValue': [boolean]
'update:modelValue': [modelValue: boolean, event?: MouseEvent]
}>()
const props = withDefaults(
@@ -59,9 +59,9 @@ const props = withDefaults(
},
)
function toggle() {
function toggle(event: MouseEvent) {
if (!props.disabled) {
emit('update:modelValue', !props.modelValue)
emit('update:modelValue', !props.modelValue, event)
}
}
</script>
+3 -4
View File
@@ -75,6 +75,7 @@ function toggleItem(item: T) {
flex-wrap: wrap;
.btn {
border: 1px solid var(--surface-5);
&.capitalize {
text-transform: capitalize;
}
@@ -91,11 +92,9 @@ function toggleItem(item: T) {
}
.selected {
color: var(--color-contrast);
color: var(--color-brand);
background-color: var(--color-brand-highlight);
box-shadow:
inset 0 0 0 transparent,
0 0 0 1px var(--color-brand);
border: 1px solid var(--color-brand);
}
}
</style>
+282 -88
View File
@@ -47,13 +47,13 @@
ref="triggerRef"
role="button"
tabindex="0"
class="relative flex min-h-5 w-full items-center justify-between overflow-hidden rounded-xl bg-surface-4 px-4 py-2.5 text-left transition-all duration-200 text-button-text"
class="relative flex min-h-5 w-full items-center justify-between overflow-hidden rounded-xl bg-surface-4 px-4 py-2 text-left transition-all duration-200 text-button-text gap-2.5"
:class="[
props.triggerClass,
{
'z-[9999]': isOpen,
'cursor-not-allowed opacity-50': disabled,
'cursor-pointer hover:brightness-125 active:brightness-125': !disabled,
'cursor-pointer hover:brightness-[115%] active:brightness-[115%]': !disabled,
},
]"
:aria-expanded="isOpen"
@@ -62,14 +62,14 @@
@click="handleTriggerClick($event)"
@keydown="handleTriggerKeydown"
>
<div class="flex items-center gap-2">
<div class="flex min-w-0 items-center gap-2">
<slot name="prefix"></slot>
<component
:is="selectedOption?.icon"
v-if="showIconInSelected && selectedOption?.icon"
class="h-5 w-5"
class="h-5 w-5 shrink-0"
/>
<span class="text-primary font-semibold leading-tight">
<span class="min-w-0 truncate text-primary font-semibold leading-tight">
<slot name="selected">{{ triggerText }}</slot>
</span>
</div>
@@ -95,6 +95,7 @@
ref="dropdownRef"
class="fixed z-[9999] flex flex-col overflow-hidden rounded-[14px] bg-surface-4 border border-solid border-surface-5"
:class="[
props.dropdownClass,
openDirection === 'up' ? 'shadow-[0_-25px_50px_-12px_rgb(0,0,0,0.25)]' : 'shadow-2xl',
]"
:style="dropdownStyle"
@@ -104,59 +105,73 @@
>
<div
v-if="filteredOptions.length > 0"
ref="optionsContainerRef"
class="flex flex-col gap-2 overflow-y-auto p-3"
:style="{ maxHeight: `${maxHeight}px` }"
ref="optionsScrollbarRef"
class="combobox-options-scrollbar bg-surface-4"
data-overlayscrollbars-initialize
>
<template v-for="(item, index) in filteredOptions" :key="item.key">
<div v-if="item.type === 'divider'" class="h-px bg-surface-5"></div>
<component
:is="item.type === 'link' ? 'a' : 'span'"
v-else
:ref="(el: HTMLElement) => setOptionRef(el as HTMLElement, index)"
:href="item.type === 'link' && !item.disabled ? item.href : undefined"
:target="item.type === 'link' && !item.disabled ? item.target : undefined"
:role="listbox ? 'option' : 'menuitem'"
:aria-selected="listbox && item.value === modelValue"
:aria-disabled="item.disabled || undefined"
:data-focused="focusedIndex === index"
class="group/option flex items-center gap-2.5 cursor-pointer rounded-xl p-3 text-left transition-colors duration-150 text-contrast hover:bg-surface-5 focus:bg-surface-5"
:class="getOptionClasses(item, index)"
tabindex="-1"
@mousedown.prevent
@click="handleOptionClick(item, index)"
@mouseenter="handleOptionMouseEnter(item, index)"
>
<slot
name="option"
:item="item"
:index="index"
:is-selected="!!(listbox && item.value === modelValue)"
>
<div class="flex w-full items-center justify-between gap-2">
<div class="flex items-center gap-2">
<component :is="item.icon" v-if="item.icon" class="h-5 w-5" />
<div class="flex flex-col gap-1.5">
<span
class="font-semibold leading-tight"
:class="item.value === modelValue ? 'text-contrast' : 'text-primary'"
>
{{ item.label }}
</span>
<span
v-if="item.subLabel"
class="text-sm"
:class="item.value === modelValue ? 'text-contrast' : 'text-secondary'"
>
{{ item.subLabel }}
</span>
<div
ref="optionsContainerRef"
class="overflow-y-auto"
:style="{ maxHeight: `${maxHeight}px` }"
data-overlayscrollbars-viewport
>
<div ref="optionsListRef" class="flex flex-col">
<template v-for="(item, index) in filteredOptions" :key="item.key">
<div v-if="item.type === 'divider'" class="h-px bg-surface-5"></div>
<component
:is="item.type === 'link' ? 'a' : 'span'"
v-else
:ref="(el: HTMLElement) => setOptionRef(el as HTMLElement, index)"
:href="item.type === 'link' && !item.disabled ? item.href : undefined"
:target="item.type === 'link' && !item.disabled ? item.target : undefined"
:role="listbox ? 'option' : 'menuitem'"
:aria-selected="listbox && item.value === modelValue"
:aria-disabled="item.disabled || undefined"
:data-focused="focusedIndex === index"
class="group/option flex items-center gap-2.5 cursor-pointer px-4 py-3 text-left transition-all duration-150"
:class="getOptionClasses(item, index)"
tabindex="-1"
@mousedown.prevent
@click="handleOptionClick(item, index)"
@mouseenter="handleOptionMouseEnter(item, index)"
>
<slot
name="option"
:item="item"
:index="index"
:is-selected="!!(listbox && item.value === modelValue)"
>
<div class="flex w-full items-center justify-between gap-2">
<div class="flex items-center gap-2">
<component
:is="item.icon"
v-if="item.icon"
class="h-5 w-5"
:class="item.value === modelValue ? 'text-green' : 'text-primary'"
/>
<div class="flex flex-col gap-1.5">
<span
class="font-semibold leading-tight"
:class="item.value === modelValue ? 'text-green' : 'text-primary'"
>
{{ item.label }}
</span>
<span
v-if="item.subLabel"
class="text-sm"
:class="item.value === modelValue ? 'text-green' : 'text-secondary'"
>
{{ item.subLabel }}
</span>
</div>
</div>
<slot name="option-suffix" :item="item"></slot>
</div>
</div>
<slot name="option-suffix" :item="item"></slot>
</div>
</slot>
</component>
</template>
</slot>
</component>
</template>
</div>
</div>
</div>
<div v-else-if="searchQuery" class="p-4 mb-2 text-center text-sm text-secondary">
@@ -171,8 +186,11 @@
</template>
<script setup lang="ts" generic="T">
import 'overlayscrollbars/overlayscrollbars.css'
import { ChevronLeftIcon, SearchIcon } from '@modrinth/assets'
import { onClickOutside } from '@vueuse/core'
import { OverlayScrollbars, type PartialOptions } from 'overlayscrollbars'
import {
type Component,
computed,
@@ -200,9 +218,28 @@ export interface ComboboxOption<T> {
searchTerms?: string[]
}
type OverlayScrollbarsInstance = NonNullable<ReturnType<typeof OverlayScrollbars>>
type ViewportRect = {
width: number
height: number
offsetTop: number
offsetLeft: number
}
const DROPDOWN_VIEWPORT_MARGIN = 8
const DROPDOWN_GAP = 12
const DROPDOWN_GAP = 8
const DEFAULT_MAX_HEIGHT = 300
const OPTIONS_OVERLAY_SCROLLBARS_OPTIONS = Object.freeze<PartialOptions>({
overflow: {
x: 'hidden',
y: 'scroll',
},
scrollbars: {
theme: 'os-theme-modrinth',
autoHide: 'leave',
autoHideSuspend: true,
},
})
function isDropdownOption<T>(
opt: ComboboxOption<T> | { type: 'divider' },
@@ -229,6 +266,13 @@ const props = withDefaults(
displayValue?: string
searchValue?: string
triggerClass?: string
dropdownClass?: string
/** Additional selectors to ignore when detecting outside clicks */
outsideClickIgnore?: string[]
/** Width for the teleported dropdown; defaults to the trigger/input width */
dropdownWidth?: string | number
/** Minimum width for the teleported dropdown */
dropdownMinWidth?: string | number
forceDirection?: 'up' | 'down'
noOptionsMessage?: string
disableSearchFilter?: boolean
@@ -252,6 +296,7 @@ const props = withDefaults(
syncWithSelection: true,
selectSearchTextOnFocus: false,
showSearchIcon: false,
outsideClickIgnore: () => [],
},
)
@@ -275,9 +320,12 @@ const containerRef = ref<HTMLElement>()
const triggerRef = ref<HTMLElement>()
const searchTriggerRef = ref<InstanceType<typeof StyledInput>>()
const dropdownRef = ref<HTMLElement>()
const optionsScrollbarRef = ref<HTMLElement>()
const optionsContainerRef = ref<HTMLElement>()
const optionsListRef = ref<HTMLElement>()
const optionRefs = ref<(HTMLElement | null)[]>([])
const rafId = ref<number | null>(null)
const optionsOverlayScrollbars = ref<OverlayScrollbarsInstance | null>(null)
const effectiveTriggerEl = computed(() => {
if (props.searchable && searchTriggerRef.value) {
@@ -285,11 +333,17 @@ const effectiveTriggerEl = computed(() => {
}
return triggerRef.value
})
const outsideClickIgnoreTargets = computed(() => [
triggerRef,
containerRef,
...props.outsideClickIgnore,
])
const dropdownStyle = ref({
top: '0px',
left: '0px',
width: '0px',
minWidth: '0px',
})
const openDirection = ref<'down' | 'up'>('down')
@@ -352,13 +406,15 @@ const shouldRenderDropdown = computed(() => {
return isOpen.value && hasDropdownContent.value
})
function getOptionClasses(item: ComboboxOption<T> & { key: string }, index: number) {
function getOptionClasses(item: ComboboxOption<T> & { key: string }, _index: number) {
const isSelected = props.listbox && item.value === props.modelValue
return [
item.class,
{
'bg-surface-5':
(props.listbox && item.value === props.modelValue) ||
(focusedIndex.value === index && !(props.listbox && item.value === props.modelValue)),
'bg-surface-4 text-contrast hover:brightness-[115%] focus:brightness-[115%]': !isSelected,
'bg-highlight-green text-green !cursor-default hover:bg-highlight-green focus:bg-highlight-green':
isSelected,
'cursor-not-allowed opacity-50 pointer-events-none': item.disabled,
},
]
@@ -381,17 +437,20 @@ function setInitialFocus() {
function determineOpenDirection(
triggerRect: DOMRect,
dropdownRect: DOMRect,
viewportHeight: number,
viewport: ViewportRect,
): 'up' | 'down' {
if (props.forceDirection) {
return props.forceDirection
}
const triggerTop = triggerRect.top + viewport.offsetTop
const triggerBottom = triggerRect.bottom + viewport.offsetTop
const viewportTop = viewport.offsetTop
const viewportBottom = viewport.offsetTop + viewport.height
const hasSpaceBelow =
triggerRect.bottom + dropdownRect.height + DROPDOWN_GAP + DROPDOWN_VIEWPORT_MARGIN <=
viewportHeight
triggerBottom + dropdownRect.height + DROPDOWN_GAP + DROPDOWN_VIEWPORT_MARGIN <= viewportBottom
const hasSpaceAbove =
triggerRect.top - dropdownRect.height - DROPDOWN_GAP - DROPDOWN_VIEWPORT_MARGIN > 0
triggerTop - dropdownRect.height - DROPDOWN_GAP - DROPDOWN_VIEWPORT_MARGIN > viewportTop
return !hasSpaceBelow && hasSpaceAbove ? 'up' : 'down'
}
@@ -400,52 +459,129 @@ function calculateVerticalPosition(
triggerRect: DOMRect,
dropdownRect: DOMRect,
direction: 'up' | 'down',
viewport: ViewportRect,
): number {
return direction === 'up'
? triggerRect.top - dropdownRect.height - DROPDOWN_GAP
: triggerRect.bottom + DROPDOWN_GAP
const top =
direction === 'up'
? triggerRect.top - dropdownRect.height - DROPDOWN_GAP
: triggerRect.bottom + DROPDOWN_GAP
return top + viewport.offsetTop
}
function calculateHorizontalPosition(
triggerRect: DOMRect,
dropdownRect: DOMRect,
viewportWidth: number,
viewport: ViewportRect,
): number {
let left = triggerRect.left
const minLeft = viewport.offsetLeft + DROPDOWN_VIEWPORT_MARGIN
const maxRight = viewport.offsetLeft + viewport.width - DROPDOWN_VIEWPORT_MARGIN
let left = triggerRect.left + viewport.offsetLeft
if (left + dropdownRect.width > viewportWidth - DROPDOWN_VIEWPORT_MARGIN) {
left = Math.max(
DROPDOWN_VIEWPORT_MARGIN,
viewportWidth - dropdownRect.width - DROPDOWN_VIEWPORT_MARGIN,
)
if (left + dropdownRect.width > maxRight) {
left = Math.max(minLeft, maxRight - dropdownRect.width)
}
return left
}
function getViewportRect(): ViewportRect {
const visualViewport = window.visualViewport
return {
width: visualViewport?.width ?? window.innerWidth,
height: visualViewport?.height ?? window.innerHeight,
offsetTop: visualViewport?.offsetTop ?? 0,
offsetLeft: visualViewport?.offsetLeft ?? 0,
}
}
function resolveDropdownWidth(triggerWidth: number): string {
if (props.dropdownWidth === undefined) return `${triggerWidth}px`
if (typeof props.dropdownWidth === 'number') return `${props.dropdownWidth}px`
return props.dropdownWidth
}
function resolveCssSize(size: string | number | undefined): string | undefined {
if (size === undefined) return undefined
if (typeof size === 'number') return `${size}px`
return size
}
async function updateDropdownPosition() {
if (!effectiveTriggerEl.value || !dropdownRef.value) return
await nextTick()
const triggerRect = effectiveTriggerEl.value.getBoundingClientRect()
const dropdownRect = dropdownRef.value.getBoundingClientRect()
const viewportHeight = window.innerHeight
const viewportWidth = window.innerWidth
const width = resolveDropdownWidth(triggerRect.width)
const minWidth = resolveCssSize(props.dropdownMinWidth) ?? '0px'
const direction = determineOpenDirection(triggerRect, dropdownRect, viewportHeight)
const top = calculateVerticalPosition(triggerRect, dropdownRect, direction)
const left = calculateHorizontalPosition(triggerRect, dropdownRect, viewportWidth)
dropdownStyle.value = {
...dropdownStyle.value,
width,
minWidth,
}
await nextTick()
const dropdownRect = dropdownRef.value.getBoundingClientRect()
const viewport = getViewportRect()
const direction = determineOpenDirection(triggerRect, dropdownRect, viewport)
const top = calculateVerticalPosition(triggerRect, dropdownRect, direction, viewport)
const left = calculateHorizontalPosition(triggerRect, dropdownRect, viewport)
dropdownStyle.value = {
top: `${top}px`,
left: `${left}px`,
width: `${triggerRect.width}px`,
width,
minWidth,
}
openDirection.value = direction
}
async function initializeOptionsOverlayScrollbars() {
await nextTick()
if (!isOpen.value || filteredOptions.value.length === 0) {
destroyOptionsOverlayScrollbars()
return
}
if (!optionsScrollbarRef.value || !optionsContainerRef.value || !optionsListRef.value) {
return
}
if (optionsOverlayScrollbars.value) {
optionsOverlayScrollbars.value.update(true)
return
}
optionsOverlayScrollbars.value = OverlayScrollbars(
{
target: optionsScrollbarRef.value,
elements: {
viewport: optionsContainerRef.value,
content: optionsListRef.value,
},
},
OPTIONS_OVERLAY_SCROLLBARS_OPTIONS,
)
}
function updateOptionsOverlayScrollbars() {
nextTick(() => {
optionsOverlayScrollbars.value?.update(true)
})
}
function destroyOptionsOverlayScrollbars() {
optionsOverlayScrollbars.value?.destroy()
optionsOverlayScrollbars.value = null
}
async function openDropdown() {
if (props.disabled || isOpen.value || !hasDropdownContent.value) return
@@ -454,6 +590,7 @@ async function openDropdown() {
await nextTick()
await updateDropdownPosition()
await initializeOptionsOverlayScrollbars()
setInitialFocus()
startPositionTracking()
@@ -463,6 +600,7 @@ function closeDropdown() {
if (!isOpen.value) return
stopPositionTracking()
destroyOptionsOverlayScrollbars()
isOpen.value = false
userHasTyped.value = false
focusedIndex.value = -1
@@ -489,6 +627,8 @@ function handleTriggerClick(event: MouseEvent) {
function handleOptionClick(option: ComboboxOption<T>, index: number) {
if (option.disabled || option.type === 'divider') return
const isSelected = props.listbox && option.value === props.modelValue
if (isSelected) return
focusedIndex.value = index
@@ -682,19 +822,36 @@ function handleSearchClick() {
function handleWindowResize() {
if (isOpen.value) {
scheduleDropdownPositionUpdate()
}
}
function scheduleDropdownPositionUpdate() {
if (rafId.value !== null) return
rafId.value = requestAnimationFrame(() => {
rafId.value = null
updateDropdownPosition()
})
}
function handleViewportChange() {
if (isOpen.value) {
scheduleDropdownPositionUpdate()
}
}
function startPositionTracking() {
function track() {
updateDropdownPosition()
rafId.value = requestAnimationFrame(track)
}
rafId.value = requestAnimationFrame(track)
window.addEventListener('scroll', handleViewportChange, true)
window.visualViewport?.addEventListener('scroll', handleViewportChange)
window.visualViewport?.addEventListener('resize', handleViewportChange)
}
function stopPositionTracking() {
window.removeEventListener('scroll', handleViewportChange, true)
window.visualViewport?.removeEventListener('scroll', handleViewportChange)
window.visualViewport?.removeEventListener('resize', handleViewportChange)
if (rafId.value !== null) {
cancelAnimationFrame(rafId.value)
rafId.value = null
@@ -706,7 +863,7 @@ onClickOutside(
() => {
closeDropdown()
},
{ ignore: [triggerRef, containerRef] },
{ ignore: outsideClickIgnoreTargets },
)
onMounted(() => {
@@ -716,6 +873,7 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('resize', handleWindowResize)
stopPositionTracking()
destroyOptionsOverlayScrollbars()
})
watch(isOpen, (value) => {
@@ -727,12 +885,18 @@ watch(isOpen, (value) => {
watch(shouldRenderDropdown, (value) => {
if (value) {
updateDropdownPosition()
initializeOptionsOverlayScrollbars()
}
})
watch(filteredOptions, () => {
if (isOpen.value) {
updateDropdownPosition()
if (filteredOptions.value.length > 0) {
initializeOptionsOverlayScrollbars()
} else {
destroyOptionsOverlayScrollbars()
}
}
})
@@ -749,7 +913,37 @@ watch(
const opt = props.options.find((o) => isDropdownOption(o) && o.value === val)
searchQuery.value = opt && isDropdownOption(opt) ? opt.label : ''
}
if (isOpen.value) {
updateOptionsOverlayScrollbars()
}
},
{ immediate: true },
)
watch(
() => props.maxHeight,
() => {
if (isOpen.value) {
updateOptionsOverlayScrollbars()
}
},
)
</script>
<style scoped>
.combobox-options-scrollbar :deep(.os-theme-modrinth) {
--os-size: 10px;
--os-padding-perpendicular: 2px;
--os-padding-axis: 2px;
--os-track-bg: transparent;
--os-track-bg-hover: transparent;
--os-track-bg-active: transparent;
--os-handle-border-radius: 9999px;
--os-handle-border: 2px solid var(--color-surface-4);
--os-handle-border-hover: 2px solid var(--color-surface-4);
--os-handle-border-active: 2px solid var(--color-surface-4);
--os-handle-bg: var(--color-scrollbar, var(--color-surface-5));
--os-handle-bg-hover: var(--color-scrollbar, var(--color-surface-5));
--os-handle-bg-active: var(--color-scrollbar, var(--color-surface-5));
}
</style>
+240 -39
View File
@@ -32,13 +32,22 @@
:aria-hidden="calendarOnly ? 'true' : undefined"
type="text"
/>
<button
v-if="hasClearButton"
type="button"
class="absolute right-0.5 z-[1] touch-manipulation cursor-pointer select-none border-none bg-transparent p-2 text-secondary transition-colors hover:text-contrast"
aria-label="Clear date"
@click.stop="clearValue"
>
<XIcon class="h-5 w-5" aria-hidden="true" />
</button>
</div>
</template>
<script setup lang="ts">
import 'flatpickr/dist/flatpickr.css'
import { CalendarIcon } from '@modrinth/assets'
import { CalendarIcon, XIcon } from '@modrinth/assets'
import chevronLeftIcon from '@modrinth/assets/icons/chevron-left.svg?raw'
import chevronRightIcon from '@modrinth/assets/icons/chevron-right.svg?raw'
import flatpickr from 'flatpickr'
@@ -127,6 +136,7 @@ const props = withDefaults(
showIcon?: boolean
showToday?: boolean
calendarOnly?: boolean
closeOnSelect?: boolean
/**
* Controls where the calendar opens relative to the input. Use `above`
* to force the calendar to open above the input.
@@ -146,7 +156,7 @@ const props = withDefaults(
}>(),
{
disabled: false,
readonly: false,
readonly: true,
enableTime: false,
mode: 'single',
showMonths: 1,
@@ -156,6 +166,7 @@ const props = withDefaults(
showIcon: true,
showToday: false,
calendarOnly: false,
closeOnSelect: false,
position: 'auto',
preserveDay: false,
viewDateAlignment: 'left',
@@ -176,13 +187,18 @@ const intendedViewMonth = ref<CalendarViewMonth | null>(null)
const preserveViewOnNextModelSync = ref(false)
const intendedDay = ref<number | null>(null)
const isPreservingDay = ref(false)
const timeInputDigits = ref(new WeakMap<HTMLInputElement, string>())
const rangeDragState = ref<RangeDragState | null>(null)
const rangeEndpointMoveState = ref<RangeEndpointMoveState | null>(null)
const suppressNextRangeClick = ref(false)
let rangeClickSuppressionTimeout: number | null = null
let monthSelectSyncFrame: number | null = null
let calendarPortal: HTMLElement | null = null
let inputFocusScrollSuppressionTarget: HTMLInputElement | null = null
let originalInputFocus: HTMLInputElement['focus'] | null = null
let suppressNextInputFocusScroll = false
const calendarBaseClass = 'modrinth-date-picker-calendar'
const twoCalendarClass = 'has-two-calendars'
const calendarStateClasses = [
'calendar-only',
'show-today',
@@ -299,6 +315,7 @@ function syncCalendarClasses(instance?: Instance) {
if (!container) return
container.classList.add(calendarBaseClass)
container.classList.toggle(twoCalendarClass, resolvedShowMonths.value === 2)
for (const cls of appliedCalendarClasses) {
container.classList.remove(cls)
@@ -868,7 +885,7 @@ function getTimeInputDigits(value: string) {
function sanitizeTimeInputValue(input: HTMLInputElement) {
const nextValue = getTimeInputDigits(input.value)
if (input.value === nextValue) return
if (input.value === nextValue) return nextValue
const cursorPosition = input.selectionStart ?? nextValue.length
const removedBeforeCursor =
@@ -878,6 +895,7 @@ function sanitizeTimeInputValue(input: HTMLInputElement) {
input.value = nextValue
input.setSelectionRange(nextCursorPosition, nextCursorPosition)
return nextValue
}
function normalizeTimeInputValue(input: HTMLInputElement) {
@@ -917,14 +935,81 @@ function preventNonNumericTimeKeydown(event: KeyboardEvent) {
if (event.key.length === 1 && /\D/.test(event.key)) event.preventDefault()
}
function sanitizeNumericTimeInput(event: Event) {
if (isTimeInput(event.target)) sanitizeTimeInputValue(event.target)
function getTimeInputs(instance: Instance) {
return [instance.hourElement, instance.minuteElement, instance.secondElement].filter(
(input): input is HTMLInputElement => Boolean(input),
)
}
function getTimeInputDraft(input: HTMLInputElement) {
return document.activeElement === input
? (timeInputDigits.value.get(input) ?? getTimeInputDigits(input.value))
: getTimeInputDigits(input.value)
}
function getNormalizedTimeInputDraft(input: HTMLInputElement) {
const draft = getTimeInputDraft(input)
if (!draft) return null
if (draft.length === 2) return draft
const minValue = Number.parseInt(input.min, 10)
const maxValue = Number.parseInt(input.max, 10)
let nextValue = Number.parseInt(draft, 10)
if (Number.isFinite(minValue)) nextValue = Math.max(nextValue, minValue)
if (Number.isFinite(maxValue)) nextValue = Math.min(nextValue, maxValue)
return String(nextValue).padStart(2, '0')
}
function prepareTimeInputValuesForCommit(instance: Instance) {
for (const input of getTimeInputs(instance)) {
const nextValue = getNormalizedTimeInputDraft(input)
if (nextValue === null) return false
input.value = nextValue
}
return true
}
function syncTimeInputDrafts(instance: Instance) {
for (const input of getTimeInputs(instance)) {
timeInputDigits.value.set(input, getTimeInputDigits(input.value))
}
}
function commitTimeInputForInput(event: Event) {
if (!isTimeInput(event.target)) return
const instance = picker.value
if (!instance) return
const previousValue = instance.input.value
const nextDigits = sanitizeTimeInputValue(event.target)
timeInputDigits.value.set(event.target, nextDigits)
if (!prepareTimeInputValuesForCommit(instance)) return
event.target.dispatchEvent(new Event('increment', { bubbles: true }))
if (nextDigits.length === 1 && document.activeElement === event.target) {
event.target.value = nextDigits
event.target.setSelectionRange(nextDigits.length, nextDigits.length)
}
syncTimeInputDrafts(instance)
if (instance.input.value === previousValue) return
const nextValue =
props.mode === 'single'
? instance.input.value || null
: instance.selectedDates.map((date) => instance.formatDate(date, resolvedDateFormat.value))
model.value = nextValue
emit('change', nextValue)
}
function syncTimeInputTypes(instance: Instance) {
const timeInputs = [instance.hourElement, instance.minuteElement, instance.secondElement].filter(
(input): input is HTMLInputElement => Boolean(input),
)
const timeInputs = getTimeInputs(instance)
const activeTimeInput = timeInputs.find((input) => document.activeElement === input)
const activeDraft = activeTimeInput ? timeInputDigits.value.get(activeTimeInput) : undefined
for (const input of timeInputs) {
input.type = 'text'
@@ -932,6 +1017,71 @@ function syncTimeInputTypes(instance: Instance) {
input.pattern = '[0-9]*'
normalizeTimeInputValue(input)
}
if (activeTimeInput && activeDraft !== undefined && activeDraft.length < 2) {
activeTimeInput.value = activeDraft
activeTimeInput.setSelectionRange(activeDraft.length, activeDraft.length)
}
syncTimeInputDrafts(instance)
}
function openPickerWithoutInputFocus(event: PointerEvent) {
if (!props.readonly || props.disabled || props.calendarOnly) return
if (event.button !== 0) return
if (event.pointerType === 'mouse') return
event.preventDefault()
suppressNextInputFocusScroll = true
picker.value?.open()
}
function suppressInputFocusScrollForCalendarPointer(event: PointerEvent) {
if (!props.readonly || props.disabled || props.calendarOnly || !props.closeOnSelect) return
if (event.button !== 0) return
if (event.pointerType === 'mouse') return
const dayElem = getRangeDayElement(event.target)
if (!dayElem || !isSelectableDay(dayElem)) return
suppressNextInputFocusScroll = true
}
function patchInputFocusScrollSuppression(target: HTMLInputElement) {
originalInputFocus = target.focus
target.focus = ((options?: FocusOptions) => {
if (suppressNextInputFocusScroll) {
originalInputFocus?.call(target, { preventScroll: true })
return
}
originalInputFocus?.call(target, options)
}) as HTMLInputElement['focus']
}
function teardownInputFocusScrollSuppression() {
inputFocusScrollSuppressionTarget?.removeEventListener('pointerdown', openPickerWithoutInputFocus)
if (inputFocusScrollSuppressionTarget && originalInputFocus) {
inputFocusScrollSuppressionTarget.focus = originalInputFocus
}
inputFocusScrollSuppressionTarget = null
originalInputFocus = null
suppressNextInputFocusScroll = false
}
function syncInputFocusScrollSuppression() {
const target = picker.value?.altInput ?? inputRef.value ?? null
if (!target || !props.readonly || props.disabled || props.calendarOnly) {
teardownInputFocusScrollSuppression()
return
}
if (inputFocusScrollSuppressionTarget === target) return
teardownInputFocusScrollSuppression()
target.addEventListener('pointerdown', openPickerWithoutInputFocus)
patchInputFocusScrollSuppression(target)
inputFocusScrollSuppressionTarget = target
}
const resolvedDateFormat = computed(
@@ -944,19 +1094,6 @@ const resolvedShowMonths = computed(() =>
Number.isFinite(props.showMonths) ? Math.max(1, Math.floor(props.showMonths)) : 1,
)
const inputClasses = computed(() => [
props.calendarOnly
? 'sr-only pointer-events-none absolute h-0 w-0 opacity-0'
: 'w-full text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow !outline-0',
!props.calendarOnly && props.showIcon ? 'pl-10' : '',
!props.calendarOnly && !props.showIcon ? 'pl-3' : '',
!props.calendarOnly
? 'pr-3 h-9 py-2 text-base outline-none bg-surface-4 border-none rounded-xl'
: '',
props.disabled && !props.calendarOnly ? 'cursor-not-allowed' : '',
props.inputClass,
])
const selectedDates = computed(() => {
const value = model.value
if (Array.isArray(value)) {
@@ -966,6 +1103,28 @@ const selectedDates = computed(() => {
return value ? [value] : []
})
const hasClearButton = computed(
() =>
!props.calendarOnly &&
props.clearable &&
!props.disabled &&
!props.readonly &&
selectedDates.value.length > 0,
)
const inputClasses = computed(() => [
props.calendarOnly
? 'sr-only pointer-events-none absolute h-0 w-0 opacity-0'
: 'w-full touch-manipulation text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow !outline-0',
!props.calendarOnly && props.showIcon ? 'pl-10' : '',
!props.calendarOnly && !props.showIcon ? 'pl-3' : '',
!props.calendarOnly
? `${hasClearButton.value ? 'pr-10' : 'pr-3'} h-9 py-2 text-base outline-none bg-surface-4 border-none rounded-xl`
: '',
props.disabled && !props.calendarOnly ? 'cursor-not-allowed' : '',
props.inputClass,
])
watch(
() => [
model.value,
@@ -982,6 +1141,7 @@ watch(
props.altFormat,
props.time24hr,
props.calendarOnly,
props.closeOnSelect,
props.position,
],
() => {
@@ -1042,6 +1202,11 @@ onMounted(async () => {
onReady: (_selectedDates, _dateStr, instance) => {
syncCalendarView(instance)
instance.calendarContainer.addEventListener(
'pointerdown',
suppressInputFocusScrollForCalendarPointer,
true,
)
instance.calendarContainer.addEventListener('pointerdown', startRangeDrag, true)
instance.calendarContainer.addEventListener('mousedown', stopRangeEndpointMouseEvent, true)
instance.calendarContainer.addEventListener('mouseup', stopRangeEndpointMouseEvent, true)
@@ -1061,7 +1226,7 @@ onMounted(async () => {
)
instance.calendarContainer.addEventListener('beforeinput', preventNonNumericTimeInput, true)
instance.calendarContainer.addEventListener('keydown', preventNonNumericTimeKeydown, true)
instance.calendarContainer.addEventListener('input', sanitizeNumericTimeInput, true)
instance.calendarContainer.addEventListener('input', commitTimeInputForInput, true)
instance.calendarContainer.addEventListener('mousedown', (event) => {
if (props.mode !== 'range') return
@@ -1119,6 +1284,7 @@ onMounted(async () => {
syncCalendarStateClasses(instance)
syncRangeEndpointMoveState(instance)
syncMultiMonthSelects(instance)
syncInputFocusScrollSuppression()
},
onChange: (_selectedDates, dateStr, instance) => {
if (isSyncingFromModel.value) return
@@ -1145,6 +1311,12 @@ onMounted(async () => {
},
onClose: (_selectedDates, dateStr, instance) => {
cancelRangeEndpointMovePreview()
if (suppressNextInputFocusScroll) {
const focusTarget = instance.altInput ?? inputRef.value
focusTarget?.blur()
suppressNextInputFocusScroll = false
}
if (hasCompleteModelRange()) {
syncPickerFromModel()
return
@@ -1192,6 +1364,7 @@ onMounted(async () => {
}
syncAltInputState()
syncInputFocusScrollSuppression()
syncPickerFromModel()
syncHeaderControlState(picker.value)
syncRangeEndpointMoveState(picker.value)
@@ -1204,6 +1377,7 @@ onBeforeUnmount(() => {
document.removeEventListener('pointermove', updateRangeDrag, true)
document.removeEventListener('pointerup', stopRangeDrag, true)
document.removeEventListener('pointercancel', stopRangeDrag, true)
teardownInputFocusScrollSuppression()
picker.value?.destroy()
destroyCalendarPortal()
})
@@ -1215,7 +1389,7 @@ function flatpickrOptions(): Options {
altInputClass: props.calendarOnly ? undefined : inputClasses.value.filter(Boolean).join(' '),
altFormat: resolvedAltFormat.value,
appendTo: ensureCalendarPortal(),
closeOnSelect: false,
closeOnSelect: props.closeOnSelect,
dateFormat: resolvedDateFormat.value,
disableMobile: true,
enableTime: props.enableTime,
@@ -1267,25 +1441,31 @@ function syncPickerFromModel() {
}
function syncAltInputState() {
if (!picker.value?.altInput) return
if (!picker.value?.altInput) {
syncInputFocusScrollSuppression()
return
}
picker.value.altInput.disabled = props.disabled
picker.value.altInput.readOnly = props.readonly
syncInputFocusScrollSuppression()
}
function clearValue() {
const nextValue = props.mode === 'single' ? null : []
model.value = nextValue
picker.value?.clear(false)
setRangeEndpointMoveState(null)
syncMissingRangeEndState()
emit('clear')
emit('change', nextValue)
}
defineExpose({
focus: () => picker.value?.altInput?.focus() ?? inputRef.value?.focus(),
open: () => picker.value?.open(),
close: () => picker.value?.close(),
clear: () => {
const nextValue = props.mode === 'single' ? null : []
model.value = nextValue
picker.value?.clear(false)
setRangeEndpointMoveState(null)
syncMissingRangeEndState()
emit('clear')
emit('change', nextValue)
},
clear: clearValue,
})
</script>
@@ -1295,7 +1475,7 @@ defineExpose({
}
.modrinth-date-picker :deep(.flatpickr-calendar) {
@apply mt-2 rounded-2xl border border-solid border-surface-5 bg-surface-3 shadow-none p-3 text-primary select-none;
@apply mt-2 touch-manipulation rounded-2xl border border-solid border-surface-5 bg-surface-3 shadow-none p-3 text-primary select-none;
box-sizing: content-box;
}
@@ -1333,6 +1513,27 @@ defineExpose({
box-shadow: none;
}
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars) {
width: calc(615.75px + 0.75rem) !important;
}
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars .flatpickr-months),
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars .flatpickr-weekdays),
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars .flatpickr-days) {
@apply gap-3;
}
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars .flatpickr-rContainer),
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars .flatpickr-weekdays),
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars .flatpickr-days) {
width: calc(615.75px + 0.75rem) !important;
}
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars .flatpickr-month),
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars .flatpickr-weekdaycontainer) {
@apply max-w-[307.875px] min-w-[307.875px] flex-none;
}
.modrinth-date-picker :deep(.flatpickr-calendar::before),
.modrinth-date-picker :deep(.flatpickr-calendar::after) {
display: none;
@@ -1356,7 +1557,7 @@ defineExpose({
.modrinth-date-picker :deep(.flatpickr-current-month input.cur-year),
.modrinth-date-picker :deep(.flatpickr-current-month .flatpickr-monthDropdown-months) {
@apply rounded-xl bg-surface-4 py-1 font-semibold text-contrast hover:bg-surface-5 min-h-10;
@apply touch-manipulation rounded-xl bg-surface-4 py-1 font-semibold text-contrast hover:bg-surface-5 min-h-10;
}
.modrinth-date-picker :deep(.flatpickr-current-month .flatpickr-monthDropdown-months) {
@@ -1414,7 +1615,7 @@ defineExpose({
.modrinth-date-picker :deep(.flatpickr-prev-month),
.modrinth-date-picker :deep(.flatpickr-next-month) {
@apply top-2.5 mx-3.5 flex h-10 w-10 items-center justify-center rounded-full p-0 text-secondary hover:bg-surface-4 hover:text-contrast;
@apply top-2.5 mx-3.5 flex h-10 w-10 touch-manipulation items-center justify-center rounded-full p-0 text-secondary hover:bg-surface-4 hover:text-contrast;
}
.modrinth-date-picker :deep(.flatpickr-prev-month.flatpickr-disabled),
@@ -1439,7 +1640,7 @@ defineExpose({
}
.modrinth-date-picker :deep(.flatpickr-day) {
@apply relative z-0 m-0 max-w-none rounded-full border border-solid border-transparent text-primary hover:bg-surface-4 hover:text-contrast font-semibold aspect-square h-auto;
@apply relative z-0 m-0 max-w-none touch-manipulation rounded-full border border-solid border-transparent text-primary hover:bg-surface-4 hover:text-contrast font-semibold aspect-square h-auto;
}
.modrinth-date-picker
:deep(
@@ -1625,7 +1826,7 @@ defineExpose({
}
.modrinth-date-picker :deep(.flatpickr-time) {
@apply mt-2 flex h-11 max-h-none items-center gap-2 border-0 border-t border-solid border-surface-5 px-1 pt-2 leading-none;
@apply mt-2 flex h-11 max-h-none items-center gap-2 border-0 border-t border-solid border-surface-5 px-1 pt-2 overflow-visible leading-none;
}
.modrinth-date-picker :deep(.flatpickr-time .numInputWrapper) {
@@ -1634,7 +1835,7 @@ defineExpose({
.modrinth-date-picker :deep(.flatpickr-time input),
.modrinth-date-picker :deep(.flatpickr-time .flatpickr-am-pm) {
@apply h-full rounded-xl bg-transparent px-2 text-center font-semibold text-primary hover:bg-surface-5 focus:bg-surface-5;
@apply h-full touch-manipulation rounded-xl bg-transparent px-2 text-center font-semibold text-primary hover:bg-surface-5 focus:bg-surface-5;
}
.modrinth-date-picker :deep(.flatpickr-time .flatpickr-time-separator) {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -30,7 +30,7 @@
:autocomplete="autocomplete"
:maxlength="maxlength"
:rows="rows"
class="w-full text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow bg-surface-4 border-none rounded-xl"
class="w-full touch-manipulation text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow bg-surface-4 border-none rounded-xl"
:class="[
inputClass,
'pl-3 pr-3 py-2 text-base',
@@ -60,7 +60,7 @@
:min="min"
:max="max"
:step="step"
class="w-full text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow"
class="w-full touch-manipulation text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow"
:class="[
inputClass,
variant === 'filled' && icon ? 'pl-10' : 'pl-3',
@@ -84,7 +84,7 @@
<button
v-if="!multiline && clearable && model && !disabled && !readonly && variant === 'filled'"
type="button"
class="absolute right-0.5 z-[1] p-2 bg-transparent border-none text-secondary hover:text-contrast transition-colors cursor-pointer select-none"
class="absolute right-0.5 z-[1] p-2 touch-manipulation bg-transparent border-none text-secondary hover:text-contrast transition-colors cursor-pointer select-none"
aria-label="Clear input"
@click="clear"
>
@@ -95,7 +95,7 @@
<button
v-if="!multiline && variant === 'outlined'"
type="button"
class="flex items-center justify-center px-2 bg-transparent border border-solid border-button-bg rounded-r-xl text-secondary hover:text-contrast transition-colors shrink-0"
class="flex touch-manipulation items-center justify-center px-2 bg-transparent border border-solid border-button-bg rounded-r-xl text-secondary hover:text-contrast transition-colors shrink-0"
:aria-label="clearable && model ? 'Clear input' : 'Search'"
:tabindex="clearable && model ? undefined : -1"
@click="clearable && model ? clear() : undefined"
+184 -109
View File
@@ -6,114 +6,123 @@
>
<slot name="header" />
</div>
<table class="w-full table-fixed border-separate border-spacing-0 border-surface-5">
<colgroup>
<col v-if="showSelection" class="w-10" />
<col
v-for="column in columns"
:key="column.key"
:style="column.width ? { width: column.width } : undefined"
/>
</colgroup>
<thead class="">
<tr class="bg-surface-3">
<th v-if="showSelection" class="w-10 pl-4">
<Checkbox
:model-value="allSelected"
:indeterminate="someSelected"
class="shrink-0 py-4"
@update:model-value="toggleSelectAll"
/>
</th>
<th
<div class="overflow-x-auto overflow-y-hidden">
<table
class="w-full table-fixed border-separate border-spacing-0 border-surface-5"
:style="tableMinWidth ? { minWidth: tableMinWidth } : undefined"
>
<colgroup>
<col v-if="showSelection" class="w-12" />
<col
v-for="column in columns"
:key="column.key"
class="h-14 first:pl-4 last:pr-4"
:class="[
`text-${column.align ?? 'left'}`,
column.enableSorting ? 'cursor-pointer select-none' : '',
]"
@click="column.enableSorting ? handleSort(column.key) : undefined"
>
<slot :name="`header-${column.key}`" :column="column">
<span
v-if="column.label || column.enableSorting"
class="inline-flex min-w-0 max-w-full items-center gap-1 font-semibold"
:class="`${sortColumn === column.key ? 'text-contrast' : ''}`"
>
<span class="min-w-0 truncate">{{ column.label ?? '' }}</span>
<template v-if="column.enableSorting">
<ChevronUpIcon
v-if="sortColumn === column.key && sortDirection === 'asc'"
class="size-4 shrink-0"
/>
<ChevronDownIcon
v-else-if="sortColumn === column.key && sortDirection === 'desc'"
class="size-4 shrink-0"
/>
</template>
</span>
</slot>
</th>
</tr>
</thead>
<tbody :ref="setListContainer">
<tr v-if="data.length === 0" class="bg-surface-2">
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-5 p-0">
<slot name="empty-state">
<div class="text-secondary flex h-64 items-center justify-center">
No data available.
</div>
</slot>
</td>
</tr>
<template v-else>
<tr v-if="virtualized && topSpacerHeight > 0" aria-hidden="true">
<td
:colspan="columnSpan"
class="border-0 p-0"
:style="{ height: `${topSpacerHeight}px` }"
></td>
</tr>
<tr
v-for="(row, rowIndex) in renderedRows"
:key="getRowRenderKey(row, getAbsoluteRowIndex(rowIndex))"
:class="getAbsoluteRowIndex(rowIndex) % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
>
<td v-if="showSelection" class="w-10 border-solid border-0 border-t border-surface-5">
:style="column.width ? { width: column.width } : undefined"
/>
</colgroup>
<thead class="">
<tr class="bg-surface-3">
<th v-if="showSelection" class="w-12">
<Checkbox
:model-value="isSelected(row)"
class="shrink-0 p-4"
@update:model-value="toggleSelection(row)"
:model-value="allSelected"
:indeterminate="someSelected"
class="shrink-0 p-4 focus-visible:!outline-none"
@update:model-value="toggleSelectAll"
/>
</td>
<td
</th>
<th
v-for="column in columns"
:key="column.key"
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-5"
:class="`text-${column.align ?? 'left'}`"
class="h-14 first:pl-4 last:pr-4"
:class="[
`text-${column.align ?? 'left'}`,
column.enableSorting ? 'cursor-pointer select-none' : '',
]"
:style="column.width ? { width: column.width } : undefined"
@click="column.enableSorting ? handleSort(column.key) : undefined"
>
<slot
:name="`cell-${column.key}`"
:row="row"
:value="row[column.key]"
:column="column"
:index="getAbsoluteRowIndex(rowIndex)"
>
{{ row[column.key] ?? '' }}
<slot :name="`header-${column.key}`" :column="column">
<span
v-if="column.label || column.enableSorting"
class="inline-flex min-w-0 max-w-full items-center gap-1 font-semibold"
:class="`${sortColumn === column.key ? 'text-contrast' : ''}`"
>
<span class="min-w-0 truncate">{{ column.label ?? '' }}</span>
<template v-if="column.enableSorting">
<ChevronUpIcon
v-if="sortColumn === column.key && sortDirection === 'asc'"
class="size-4 shrink-0"
/>
<ChevronDownIcon
v-else-if="sortColumn === column.key && sortDirection === 'desc'"
class="size-4 shrink-0"
/>
</template>
</span>
</slot>
</th>
</tr>
</thead>
<tbody :ref="setListContainer">
<tr v-if="data.length === 0" class="bg-surface-2">
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-5 p-0">
<slot name="empty-state">
<div class="text-secondary flex h-64 items-center justify-center">
No data available.
</div>
</slot>
</td>
</tr>
<tr v-if="virtualized && bottomSpacerHeight > 0" aria-hidden="true">
<td
:colspan="columnSpan"
class="border-0 p-0"
:style="{ height: `${bottomSpacerHeight}px` }"
></td>
</tr>
</template>
</tbody>
</table>
<template v-else>
<tr v-if="virtualized && topSpacerHeight > 0" aria-hidden="true">
<td
:colspan="columnSpan"
class="border-0 p-0"
:style="{ height: `${topSpacerHeight}px` }"
></td>
</tr>
<tr
v-for="(row, rowIndex) in renderedRows"
:key="getRowRenderKey(row, getAbsoluteRowIndex(rowIndex))"
:class="getAbsoluteRowIndex(rowIndex) % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
>
<td
v-if="showSelection"
class="w-12 border-solid border-0 border-t border-surface-5 focus:outline-none"
>
<Checkbox
:model-value="isSelected(row)"
class="shrink-0 p-4 -outline-offset-[14px] outline rounded-2xl"
@update:model-value="(selectRow, event) => toggleSelection(row, selectRow, event)"
/>
</td>
<td
v-for="column in columns"
:key="column.key"
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-5"
:class="`text-${column.align ?? 'left'}`"
>
<slot
:name="`cell-${column.key}`"
:row="row"
:value="row[column.key]"
:column="column"
:index="getAbsoluteRowIndex(rowIndex)"
>
{{ row[column.key] ?? '' }}
</slot>
</td>
</tr>
<tr v-if="virtualized && bottomSpacerHeight > 0" aria-hidden="true">
<td
:colspan="columnSpan"
class="border-0 p-0"
:style="{ height: `${bottomSpacerHeight}px` }"
></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
@@ -123,7 +132,7 @@
generic="K extends string = string, T extends Record<string, unknown> = Record<K, unknown>"
>
import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
import { computed, toRef, useSlots } from 'vue'
import { computed, ref, toRef, useSlots } from 'vue'
import { useVirtualScroll } from '../../composables/virtual-scroll'
import Checkbox from './Checkbox.vue'
@@ -140,6 +149,7 @@ export interface TableColumn<K extends string = string> {
label?: string
align?: TableColumnAlign
enableSorting?: boolean
defaultSortDirection?: SortDirection
/**
* CSS width value for the column.
* Accepts any valid CSS width (e.g., '200px', '20%', '10rem', 'auto', 'fit-content').
@@ -153,9 +163,16 @@ const props = withDefaults(
data: T[] /* Row data for table */
showSelection?: boolean
rowKey?: keyof T /* The key used to uniquely identify each row */
selectionKey?: keyof T /* The key used to identify selectable rows */
selectionData?: T[] /* The complete selectable data set when data is paginated */
selectionIds?: unknown[] /* Complete selectable IDs when callers do not want to retain row objects */
virtualized?: boolean
virtualRowHeight?: number
virtualBufferSize?: number /* The number of extra rows rendered above and below the visible viewport */
/**
* Sets a minimum width for the table content, allowing horizontal overflow below that width.
*/
tableMinWidth?: string
}>(),
{
showSelection: false,
@@ -170,6 +187,7 @@ const selectedIds = defineModel<unknown[]>('selectedIds', { default: () => [] })
const sortColumn = defineModel<string | undefined>('sortColumn')
const sortDirection = defineModel<SortDirection>('sortDirection', { default: 'asc' })
const slots = useSlots()
const selectionAnchorId = ref<unknown>()
const hasHeaderSlot = computed(() => Boolean(slots.header))
const columnSpan = computed(() => Math.max(props.columns.length + (props.showSelection ? 1 : 0), 1))
@@ -201,17 +219,39 @@ const emit = defineEmits<{
sort: [column: string, direction: SortDirection]
}>()
const selectableRows = computed(() => props.selectionData ?? props.data)
const selectableRowIds = computed(
() => props.selectionIds ?? selectableRows.value.map((row) => getSelectionId(row)),
)
const selectedIdSet = computed(() => new Set(selectedIds.value))
const selectedSelectableIdCount = computed(() => {
let count = 0
for (const id of selectableRowIds.value) {
if (selectedIdSet.value.has(id)) {
count++
}
}
return count
})
const allSelected = computed(
() => props.data.length > 0 && selectedIds.value.length === props.data.length,
() =>
selectableRowIds.value.length > 0 &&
selectedSelectableIdCount.value === selectableRowIds.value.length,
)
const someSelected = computed(
() => selectedIds.value.length > 0 && selectedIds.value.length < props.data.length,
() =>
selectedSelectableIdCount.value > 0 &&
selectedSelectableIdCount.value < selectableRowIds.value.length,
)
function getRowId(row: T): unknown {
return row[props.rowKey as keyof T]
}
function getSelectionId(row: T): unknown {
return row[(props.selectionKey ?? props.rowKey) as keyof T]
}
function setListContainer(element: unknown) {
listContainer.value = props.virtualized ? (element as HTMLElement | null) : null
}
@@ -230,31 +270,66 @@ function getRowRenderKey(row: T, rowIndex: number): PropertyKey {
}
function isSelected(row: T): boolean {
return selectedIds.value.includes(getRowId(row))
return selectedIdSet.value.has(getSelectionId(row))
}
function toggleSelection(row: T) {
const id = getRowId(row)
if (isSelected(row)) {
selectedIds.value = selectedIds.value.filter((selectedId) => selectedId !== id)
function toggleSelection(row: T, selectRow: boolean, event?: MouseEvent) {
const id = getSelectionId(row)
const rowIndex = selectableRowIds.value.findIndex((selectableId) => selectableId === id)
const anchorIndex = selectableRowIds.value.findIndex(
(selectableId) => selectableId === selectionAnchorId.value,
)
if (event?.shiftKey && rowIndex !== -1 && anchorIndex !== -1) {
const startIndex = Math.min(rowIndex, anchorIndex)
const endIndex = Math.max(rowIndex, anchorIndex)
const rangeIds = selectableRowIds.value.slice(startIndex, endIndex + 1)
if (selectRow) {
const nextSelectedIds = [...selectedIds.value]
const nextSelectedIdSet = new Set(nextSelectedIds)
for (const rangeId of rangeIds) {
if (!nextSelectedIdSet.has(rangeId)) {
nextSelectedIds.push(rangeId)
nextSelectedIdSet.add(rangeId)
}
}
selectedIds.value = nextSelectedIds
} else {
const rangeIdSet = new Set(rangeIds)
selectedIds.value = selectedIds.value.filter((selectedId) => !rangeIdSet.has(selectedId))
}
} else {
selectedIds.value = [...selectedIds.value, id]
selectedIds.value = selectRow
? [...selectedIds.value, id]
: selectedIds.value.filter((selectedId) => selectedId !== id)
}
selectionAnchorId.value = id
}
function toggleSelectAll(selectAll: boolean) {
selectionAnchorId.value = undefined
if (selectAll) {
selectedIds.value = props.data.map((row) => getRowId(row))
selectedIds.value = [...selectableRowIds.value]
} else {
selectedIds.value = []
}
}
function handleSort(columnKey: string) {
const column = props.columns.find((column) => column.key === columnKey)
const defaultDirection = column?.defaultSortDirection ?? 'asc'
const newDirection: SortDirection =
sortColumn.value === columnKey && sortDirection.value === 'asc' ? 'desc' : 'asc'
sortColumn.value === columnKey && sortDirection.value === defaultDirection
? getOppositeSortDirection(defaultDirection)
: defaultDirection
sortColumn.value = columnKey
sortDirection.value = newDirection
emit('sort', columnKey, newDirection)
}
function getOppositeSortDirection(direction: SortDirection): SortDirection {
return direction === 'asc' ? 'desc' : 'asc'
}
</script>
+3 -3
View File
@@ -1,7 +1,7 @@
<template>
<div
v-if="tabs.length > 0"
class="inline-flex w-fit items-center overflow-x-auto rounded-xl border border-solid border-surface-5 p-0.5 shadow-sm gap-1"
class="inline-flex w-fit items-center overflow-x-auto rounded-xl border border-solid border-surface-5 p-0.5 shadow-sm gap-1 h-[38px]"
role="tablist"
>
<button
@@ -9,7 +9,7 @@
:key="tab.value"
ref="tabButtons"
type="button"
class="flex min-h-6 shrink-0 cursor-pointer items-center justify-center gap-2 rounded-lg border border-solid px-2.5 py-1 text-sm font-medium outline-none transition-all active:scale-[0.97] focus-visible:ring-4 focus-visible:ring-brand-shadow"
class="flex min-h-6 shrink-0 cursor-pointer items-center justify-center gap-2 rounded-lg border border-solid px-2.5 h-full text-sm font-medium outline-none transition-all active:scale-[0.97] focus-visible:ring-4 focus-visible:ring-brand-shadow"
:class="
tab.value === value
? 'border-green bg-highlight-green text-green'
@@ -27,7 +27,7 @@
class="size-5 shrink-0"
:class="tab.value === value ? 'text-green' : 'text-secondary'"
/>
<span class="text-nowrap">{{ tab.label }}</span>
<span v-if="tab.label" class="text-nowrap">{{ tab.label }}</span>
</button>
</div>
</template>
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -5,7 +5,7 @@
role="switch"
:aria-checked="modelValue"
:disabled="disabled"
class="group inline-flex shrink-0 items-center rounded-full m-0 p-1 transition-all duration-200 cursor-pointer border-none"
class="group inline-flex shrink-0 touch-manipulation items-center rounded-full m-0 p-1 transition-all duration-200 cursor-pointer border-none"
:class="[
small ? 'h-5 !w-[40px]' : 'h-6 !w-[48px]',
modelValue ? 'bg-brand' : 'bg-button-bg',
+14 -2
View File
@@ -50,7 +50,11 @@ export { default as LoadingBar } from './LoadingBar.vue'
export { default as LoadingIndicator } from './LoadingIndicator.vue'
export { default as ManySelect } from './ManySelect.vue'
export { default as MarkdownEditor } from './MarkdownEditor.vue'
export type { MultiSelectOption } from './MultiSelect.vue'
export type {
MultiSelectItem,
MultiSelectOption,
MultiSelectSectionHeader,
} from './MultiSelect.vue'
export { default as MultiSelect } from './MultiSelect.vue'
export type { MaybeCtxFn, StageButtonConfig, StageConfigInput } from './MultiStageModal.vue'
export { default as MultiStageModal, resolveCtxFn } from './MultiStageModal.vue'
@@ -77,12 +81,20 @@ export type { StackedAdmonitionItem, StackedAdmonitionType } from './StackedAdmo
export { default as StackedAdmonitions } from './StackedAdmonitions.vue'
export { default as StatItem } from './StatItem.vue'
export { default as StyledInput } from './StyledInput.vue'
export type { TableColumn } from './Table.vue'
export type { SortDirection, TableColumn } from './Table.vue'
export { default as Table } from './Table.vue'
export type { TabsTab, TabsValue } from './Tabs.vue'
export { default as Tabs } from './Tabs.vue'
export { default as TagItem } from './TagItem.vue'
export { default as TagTagItem } from './TagTagItem.vue'
export type {
TimeFrameLastUnit,
TimeFrameLastUnitOption,
TimeFrameMode,
TimeFramePickerSelection,
TimeFramePreset,
} from './TimeFramePicker.vue'
export { default as TimeFramePicker } from './TimeFramePicker.vue'
export { default as Timeline } from './Timeline.vue'
export { default as Toggle } from './Toggle.vue'
export { default as UnsavedChangesPopup } from './UnsavedChangesPopup.vue'
+26 -7
View File
@@ -60,12 +60,22 @@ export function useScrollViewport(options: ScrollViewportOptions = {}) {
}
function syncScrollState() {
if (!scrollContainer.value) return
scrollTop.value = getScrollTop(scrollContainer.value)
viewportHeight.value = getViewportHeight(scrollContainer.value)
const listEl = listContainer.value
if (!listEl) return
const container = findScrollableAncestor(listEl)
scrollContainer.value = container
scrollTop.value = getScrollTop(container)
viewportHeight.value = getViewportHeight(container)
updateContainerOffset()
}
function resetScrollState() {
scrollTop.value = 0
viewportHeight.value = 0
containerOffset.value = 0
}
function handleScroll() {
if (scrollContainer.value) {
scrollTop.value = getScrollTop(scrollContainer.value)
@@ -109,6 +119,7 @@ export function useScrollViewport(options: ScrollViewportOptions = {}) {
})
return {
resetScrollState,
containerOffset,
listContainer,
relativeScrollTop,
@@ -130,10 +141,16 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
nearEndThreshold = 0.2,
} = options
const { listContainer, relativeScrollTop, scrollContainer, syncScrollState, viewportHeight } =
useScrollViewport({
onScroll: checkNearEnd,
})
const {
listContainer,
relativeScrollTop,
resetScrollState,
scrollContainer,
syncScrollState,
viewportHeight,
} = useScrollViewport({
onScroll: checkNearEnd,
})
const totalHeight = computed(() => items.value.length * itemHeight)
@@ -191,5 +208,7 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
visibleRange,
visibleTop,
visibleItems,
resetScrollState,
syncScrollState,
}
}
+87
View File
@@ -4373,6 +4373,93 @@
"tag.loader.waterfall": {
"defaultMessage": "Waterfall"
},
"time-frame-picker.apply": {
"defaultMessage": "Apply"
},
"time-frame-picker.cancel": {
"defaultMessage": "Cancel"
},
"time-frame-picker.clear-range": {
"defaultMessage": "Clear"
},
"time-frame-picker.custom-range": {
"defaultMessage": "Custom fixed date range..."
},
"time-frame-picker.decrease-amount": {
"defaultMessage": "Decrease timeframe amount"
},
"time-frame-picker.empty-range": {
"defaultMessage": "No date range selected."
},
"time-frame-picker.end-date": {
"defaultMessage": "End date"
},
"time-frame-picker.increase-amount": {
"defaultMessage": "Increase timeframe amount"
},
"time-frame-picker.last-timeframe": {
"defaultMessage": "In the last {amount} {unit, select, hours {{amount, plural, one {hour} other {hours}}} days {{amount, plural, one {day} other {days}}} weeks {{amount, plural, one {week} other {weeks}}} months {{amount, plural, one {month} other {months}}} other {days}}"
},
"time-frame-picker.last-timeframe-prefix": {
"defaultMessage": "In the last"
},
"time-frame-picker.option.all-time": {
"defaultMessage": "All time"
},
"time-frame-picker.option.last-14-days": {
"defaultMessage": "Last 14 days"
},
"time-frame-picker.option.last-180-days": {
"defaultMessage": "Last 180 days"
},
"time-frame-picker.option.last-30-days": {
"defaultMessage": "Last 30 days"
},
"time-frame-picker.option.last-7-days": {
"defaultMessage": "Last 7 days"
},
"time-frame-picker.option.last-90-days": {
"defaultMessage": "Last 90 days"
},
"time-frame-picker.option.today": {
"defaultMessage": "Today"
},
"time-frame-picker.option.year-to-date": {
"defaultMessage": "Year to date"
},
"time-frame-picker.option.yesterday": {
"defaultMessage": "Yesterday"
},
"time-frame-picker.select-timeframe": {
"defaultMessage": "Select timeframe"
},
"time-frame-picker.selected-range": {
"defaultMessage": "Selected"
},
"time-frame-picker.selecting-range": {
"defaultMessage": "Selecting"
},
"time-frame-picker.start-date": {
"defaultMessage": "Start date"
},
"time-frame-picker.timeframe-amount": {
"defaultMessage": "Timeframe amount"
},
"time-frame-picker.timeframe-unit": {
"defaultMessage": "Timeframe unit"
},
"time-frame-picker.unit.days": {
"defaultMessage": "days"
},
"time-frame-picker.unit.hours": {
"defaultMessage": "hours"
},
"time-frame-picker.unit.months": {
"defaultMessage": "months"
},
"time-frame-picker.unit.weeks": {
"defaultMessage": "weeks"
},
"ui.component.unsaved-changes-popup.body": {
"defaultMessage": "You have unsaved changes."
},
+184 -41
View File
@@ -32,6 +32,17 @@ export const Default: Story = {
},
}
export const WithSelectedOption: Story = {
args: {
modelValue: '2',
options: [
{ value: '1', label: 'Option 1' },
{ value: '2', label: 'Option 2' },
{ value: '3', label: 'Option 3' },
],
},
}
export const Searchable: Story = {
args: {
options: [
@@ -64,6 +75,54 @@ export const SearchableEmpty: Story = {
},
}
export const DropdownMinWidth: StoryObj = {
render: () => ({
components: { Combobox },
data: () => ({
selected: undefined,
options: [
{ value: 'fabric', label: 'Fabric', subLabel: 'Lightweight modding toolchain' },
{ value: 'forge', label: 'Forge', subLabel: 'The original Minecraft modding API' },
{ value: 'neoforge', label: 'NeoForge', subLabel: 'Community-driven Forge fork' },
],
}),
template: /*html*/ `
<div style="width: 11rem;">
<Combobox
v-model="selected"
:options="options"
:dropdown-min-width="320"
placeholder="Loader"
/>
</div>
`,
}),
}
export const DropdownClass: StoryObj = {
render: () => ({
components: { Combobox },
data: () => ({
selected: undefined,
options: [
{ value: 'fabric', label: 'Fabric' },
{ value: 'forge', label: 'Forge' },
{ value: 'neoforge', label: 'NeoForge' },
],
}),
template: /*html*/ `
<div style="width: 14rem;">
<Combobox
v-model="selected"
:options="options"
dropdown-class="!border-brand"
placeholder="Loader"
/>
</div>
`,
}),
}
export const Disabled: Story = {
args: {
options: [{ value: '1', label: 'Option 1' }],
@@ -72,23 +131,6 @@ export const Disabled: Story = {
},
}
export const WithSubLabels: Story = {
args: {
options: [
{ value: 'download', label: 'Download', icon: DownloadIcon },
{ value: 'share', label: 'Share', icon: ShareIcon },
{ value: 'favorite', label: 'Add to favorites', icon: HeartIcon },
{ type: 'divider' },
{ value: 'settings', label: 'Settings', icon: SettingsIcon },
{ value: 'profile', label: 'Profile', icon: UserIcon },
{ type: 'divider' },
{ value: 'delete', label: 'Delete', icon: TrashIcon, disabled: true },
],
placeholder: 'Select an action',
listbox: false,
},
}
export const SearchableWithIcons: Story = {
args: {
options: [
@@ -105,7 +147,24 @@ export const SearchableWithIcons: Story = {
},
}
export const WithSelectedOption: Story = {
export const WithDividers: Story = {
args: {
options: [
{ value: 'download', label: 'Download', icon: DownloadIcon },
{ value: 'share', label: 'Share', icon: ShareIcon },
{ value: 'favorite', label: 'Add to favorites', icon: HeartIcon },
{ type: 'divider' },
{ value: 'settings', label: 'Settings', icon: SettingsIcon },
{ value: 'profile', label: 'Profile', icon: UserIcon },
{ type: 'divider' },
{ value: 'delete', label: 'Delete', icon: TrashIcon, disabled: true },
],
placeholder: 'Select an action',
listbox: false,
},
}
export const WithSubLabel: Story = {
args: {
modelValue: '2',
options: [
@@ -117,6 +176,18 @@ export const WithSelectedOption: Story = {
},
}
export const MixedSubLabels: Story = {
args: {
options: [
{ value: '1', label: 'Minecraft', subLabel: 'The base game' },
{ value: '2', label: 'Fabric' },
{ value: '3', label: 'Forge', subLabel: 'Supports most mods' },
{ value: '4', label: 'NeoForge' },
{ value: '5', label: 'Quilt', subLabel: 'Fabric-compatible' },
],
},
}
export const SearchableNoFilter: Story = {
args: {
options: [
@@ -132,20 +203,6 @@ export const SearchableNoFilter: Story = {
},
}
export const SearchableModpacks: Story = {
args: {
options: [
{ value: 'download', label: 'Download', icon: DownloadIcon },
{ value: 'share', label: 'Share', icon: ShareIcon },
{ value: 'favorite', label: 'Add to favorites', icon: HeartIcon },
{ value: 'settings', label: 'Settings', icon: SettingsIcon },
],
searchable: true,
searchPlaceholder: 'Search modpacks...',
noOptionsMessage: 'No modpacks found',
},
}
export const WithDropdownFooter: StoryObj = {
render: () => ({
components: { Combobox, EyeIcon, EyeOffIcon },
@@ -194,15 +251,48 @@ export const WithDropdownFooter: StoryObj = {
}),
}
export const MixedSubLabels: Story = {
args: {
options: [
{ value: '1', label: 'Minecraft', subLabel: 'The base game' },
{ value: '2', label: 'Fabric' },
{ value: '3', label: 'Forge', subLabel: 'Supports most mods' },
{ value: '4', label: 'NeoForge' },
{ value: '5', label: 'Quilt', subLabel: 'Fabric-compatible' },
],
export const DropdownFooterOnly: StoryObj = {
render: () => ({
components: { Combobox },
data: () => ({
selected: undefined,
options: [],
}),
template: /*html*/ `
<div style="width: 240px;">
<Combobox
v-model="selected"
:options="options"
display-value="Custom range"
dropdown-min-width="320"
>
<template #dropdown-footer>
<div style="display: flex; flex-direction: column; gap: 0.75rem; padding: 1rem; color: var(--color-text-primary);">
<div style="font-size: 0.875rem; font-weight: 700;">Dropdown footer content</div>
<div style="font-size: 0.8125rem; color: var(--color-text-secondary);">
This dropdown has no options and stays open because its footer slot is content.
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem;">
<button type="button" style="height: 2rem; border: 1px solid var(--color-surface-5); border-radius: 0.5rem; background: var(--color-surface-3); color: var(--color-text-primary); font-weight: 600;">
Cancel
</button>
<button type="button" style="height: 2rem; border: 0; border-radius: 0.5rem; background: var(--color-brand); color: var(--color-bg); font-weight: 700;">
Apply
</button>
</div>
</div>
</template>
</Combobox>
</div>
`,
}),
parameters: {
docs: {
description: {
story:
'Covers dropdowns whose only rendered content is the footer slot, such as the analytics custom date range picker.',
},
},
},
}
@@ -258,3 +348,56 @@ export const SearchableWithOptionAndSelectionAffix: StoryObj = {
`,
}),
}
export const ManyOptionsOverflow: Story = {
args: {
options: Array.from({ length: 40 }, (_, index) => ({
value: `${index + 1}`,
label: `Option ${index + 1}`,
})),
placeholder: 'Select an option',
maxHeight: 380,
},
parameters: {
docs: {
description: {
story:
'Covers long option lists where the dropdown content should scroll within its max height.',
},
},
},
}
export const ScrollRepositioning: StoryObj = {
render: () => ({
components: { Combobox },
data: () => ({
selected: undefined,
options: Array.from({ length: 16 }, (_, index) => ({
value: `loader-${index + 1}`,
label: `Loader ${index + 1}`,
})),
}),
template: /*html*/ `
<div style="min-height: 150vh; padding-top: 45vh;">
<div style="width: min(100%, 22rem);">
<Combobox
v-model="selected"
:options="options"
searchable
placeholder="Select loader"
search-placeholder="Search loaders..."
/>
</div>
</div>
`,
}),
parameters: {
docs: {
description: {
story:
'Covers fixed dropdown repositioning while the page scrolls with a searchable input open.',
},
},
},
}
@@ -48,6 +48,26 @@ export const WithTime: Story = {
}),
}
export const Clearable: Story = {
render: () => ({
components: { DatePicker },
setup() {
const value = ref('2026-04-27')
return { value }
},
template: /* html */ `
<div class="flex max-w-sm flex-col gap-2">
<DatePicker
v-model="value"
wrapperClass="w-[300px]"
placeholder="Select a date..."
/>
<p class="text-sm text-secondary">Selected value: {{ value || 'None' }}</p>
</div>
`,
}),
}
export const OpenWithTime: Story = {
render: () => ({
components: { DatePicker },
@@ -292,6 +312,59 @@ export const OpenCalendar: Story = {
}),
}
export const CloseOnSelect: Story = {
render: () => ({
components: { DatePicker },
setup() {
const value = ref('2026-06-15')
return { value }
},
template: /* html */ `
<div class="flex max-w-sm flex-col gap-2">
<DatePicker
v-model="value"
wrapperClass="w-[300px]"
close-on-select
default-view-date="2026-06-01"
/>
<p class="text-sm text-secondary">Selected value: {{ value || 'None' }}</p>
</div>
`,
}),
}
export const CloseOnSelectInScrollablePanel: Story = {
render: () => ({
components: { DatePicker },
setup() {
const value = ref('2026-06-15')
return { value }
},
template: /* html */ `
<div class="h-[320px] w-[360px] overflow-y-auto rounded-xl border border-solid border-surface-5 bg-surface-2 p-4">
<div class="h-[360px]"></div>
<div class="flex max-w-sm flex-col gap-2">
<DatePicker
v-model="value"
wrapperClass="w-[300px]"
close-on-select
default-view-date="2026-06-01"
/>
<p class="text-sm text-secondary">Selected value: {{ value || 'None' }}</p>
</div>
</div>
`,
}),
parameters: {
docs: {
description: {
story:
'Readonly touch selection closes without focusing the input in a way that scrolls its container.',
},
},
},
}
export const ForceAbove: Story = {
render: () => ({
components: { DatePicker },
@@ -415,6 +488,26 @@ export const Disabled: Story = {
},
}
export const Editable: Story = {
render: () => ({
components: { DatePicker },
setup() {
const value = ref('2026-04-27')
return { value }
},
template: /* html */ `
<div class="flex max-w-sm flex-col gap-2">
<DatePicker
v-model="value"
wrapperClass="w-[300px]"
:readonly="false"
/>
<p class="text-sm text-secondary">Selected value: {{ value || 'None' }}</p>
</div>
`,
}),
}
export const CalendarClass: Story = {
render: () => ({
components: { DatePicker },
@@ -1,3 +1,4 @@
import { BoxIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
@@ -55,10 +56,75 @@ const searchableCategories = [
searchPlaceholder: 'Search versions...',
submenuClass: 'w-[360px]',
options: [
{ value: '1.21.5', label: '1.21.5' },
{ value: '1.21.4', label: '1.21.4' },
{ value: '1.20.1', label: '1.20.1' },
{ value: '1.19.2', label: '1.19.2' },
{ value: '1.21.5', label: '1.21.5', searchTerms: ['Sodium'] },
{ value: '1.21.4', label: '1.21.4', searchTerms: ['Sodium'] },
{ value: '1.20.1', label: '1.20.1', searchTerms: ['Iris'] },
{ value: '1.19.2', label: '1.19.2', searchTerms: ['Mod Menu'] },
],
},
]
const largeVersionOptions = Array.from({ length: 250 }, (_, index) => {
const version = `1.${Math.floor(index / 10) + 1}.${index % 10}`
const project = `Project ${Math.floor(index / 25) + 1}`
return {
value: `version-${index + 1}`,
label: version,
searchTerms: [project],
}
})
const mixedWidthCategories = [
{
key: 'status',
label: 'Status',
options: [
{ value: 'active', label: 'Active' },
{ value: 'archived', label: 'Archived' },
{ value: 'draft', label: 'Draft' },
],
},
{
key: 'country',
label: 'Country',
searchable: true,
searchPlaceholder: 'Search countries...',
submenuClass: 'w-[324px]',
options: [
{ value: 'US', label: 'United States' },
{ value: 'CA', label: 'Canada' },
{ value: 'DE', label: 'Germany' },
{ value: 'JP', label: 'Japan' },
],
},
{
key: 'version',
label: 'Project version',
searchable: true,
searchPlaceholder: 'Search project versions...',
submenuClass: 'w-[368px]',
options: [
{ value: 'sodium-1.21.5', label: 'Sodium 1.21.5' },
{ value: 'iris-1.21.4', label: 'Iris 1.21.4' },
{ value: 'mod-menu-1.20.1', label: 'Mod Menu 1.20.1' },
],
},
]
const mobileWidthCategories = [
{
key: 'country',
label: 'Country',
searchable: true,
searchPlaceholder: 'Search countries...',
submenuClass: 'w-[360px]',
options: [
{ value: 'US', label: 'United States' },
{ value: 'CA', label: 'Canada' },
{ value: 'DE', label: 'Germany' },
{ value: 'JP', label: 'Japan' },
{ value: 'BR', label: 'Brazil' },
{ value: 'AU', label: 'Australia' },
],
},
]
@@ -90,11 +156,21 @@ export const WithAppliedFilters: Story = {
status: ['active'],
type: ['mod', 'plugin'],
})
return { categories: defaultCategories, selected }
const clearEvents = ref(0)
function handleClear() {
clearEvents.value += 1
}
return { categories: defaultCategories, clearEvents, handleClear, selected }
},
template: /* html */ `
<div class="flex flex-wrap items-center gap-2">
<DropdownFilterBar v-model="selected" :categories="categories" />
<DropdownFilterBar
v-model="selected"
:categories="categories"
@clear="handleClear"
/>
<span class="text-sm font-medium text-secondary">Clear events: {{ clearEvents }}</span>
</div>
`,
}),
@@ -107,6 +183,75 @@ export const WithAppliedFilters: Story = {
},
}
export const WithRightCheckmarks: Story = {
render: () => ({
components: { DropdownFilterBar },
setup() {
const selected = ref<Record<string, string[]>>({
status: ['active'],
type: ['mod', 'plugin'],
})
return { categories: defaultCategories, selected }
},
template: /* html */ `
<div class="flex flex-wrap items-center gap-2">
<DropdownFilterBar
v-model="selected"
:categories="categories"
checkbox-position="right"
/>
</div>
`,
}),
args: {
modelValue: {
status: ['active'],
type: ['mod', 'plugin'],
},
categories: defaultCategories,
checkboxPosition: 'right',
},
parameters: {
docs: {
description: {
story:
'Renders selected options with the same right-side checkmark placement as MultiSelect.',
},
},
},
}
export const WithClearOverride: Story = {
render: () => ({
components: { DropdownFilterBar },
setup() {
const selected = ref<Record<string, string[]>>({})
const clearEvents = ref(0)
function handleClear() {
clearEvents.value += 1
}
return { categories: defaultCategories, clearEvents, handleClear, selected }
},
template: /* html */ `
<div class="flex flex-wrap items-center gap-2">
<DropdownFilterBar
v-model="selected"
:categories="categories"
show-clear
@clear="handleClear"
/>
<span class="text-sm font-medium text-secondary">Clear events: {{ clearEvents }}</span>
</div>
`,
}),
args: {
modelValue: {},
categories: defaultCategories,
showClear: true,
},
}
export const WithFilterIcon: Story = {
render: () => ({
components: { DropdownFilterBar },
@@ -133,14 +278,41 @@ export const WithFilterIcon: Story = {
export const SearchableCategories: Story = {
render: () => ({
components: { DropdownFilterBar },
components: { BoxIcon, DropdownFilterBar },
setup() {
const selected = ref<Record<string, string[]>>({})
return { categories: searchableCategories, selected }
const versionProjects: Record<string, string> = {
'1.21.5': 'Sodium',
'1.21.4': 'Sodium',
'1.20.1': 'Iris',
'1.19.2': 'Mod Menu',
}
function getVersionProject(categoryKey: string, optionValue: string) {
return categoryKey === 'version' ? versionProjects[optionValue] : undefined
}
return { categories: searchableCategories, getVersionProject, selected }
},
template: /* html */ `
<div class="flex flex-wrap items-center gap-2">
<DropdownFilterBar v-model="selected" :categories="categories" />
<DropdownFilterBar v-model="selected" :categories="categories">
<template #option="{ category, option, selected }">
<div class="flex min-w-0 flex-1 items-center gap-2">
<span
v-if="getVersionProject(category.key, option.value)"
v-tooltip="getVersionProject(category.key, option.value)"
class="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded text-primary"
>
<BoxIcon class="size-6" />
</span>
<span
class="min-w-0 truncate font-semibold leading-tight"
:class="selected ? 'text-contrast' : 'text-primary'"
>
{{ option.label }}
</span>
</div>
</template>
</DropdownFilterBar>
</div>
`,
}),
@@ -148,13 +320,218 @@ export const SearchableCategories: Story = {
modelValue: {},
categories: searchableCategories,
},
parameters: {
docs: {
description: {
story:
'On mobile and narrow viewports, tapping a category replaces the add menu with the category submenu; both surfaces should stay anchored while scrolling or when the visual viewport changes.',
},
},
},
}
export const MixedSubmenuWidthsNearEdge: Story = {
render: () => ({
components: { DropdownFilterBar },
setup() {
const selected = ref<Record<string, string[]>>({})
return { categories: mixedWidthCategories, selected }
},
template: /* html */ `
<div class="flex min-h-96 justify-end px-4 py-8">
<DropdownFilterBar v-model="selected" :categories="categories" />
</div>
`,
}),
args: {
modelValue: {},
categories: mixedWidthCategories,
},
parameters: {
docs: {
description: {
story:
'Covers mixed submenu widths near the viewport edge so all add-menu submenus open on the same side.',
},
},
},
}
export const MobileSubmenuWidth: Story = {
render: () => ({
components: { DropdownFilterBar },
setup() {
const selected = ref<Record<string, string[]>>({})
return { categories: mobileWidthCategories, selected }
},
template: /* html */ `
<div class="flex min-h-96 max-w-[390px] items-start px-4 py-8">
<DropdownFilterBar v-model="selected" :categories="categories" />
</div>
`,
}),
args: {
modelValue: {},
categories: mobileWidthCategories,
},
parameters: {
docs: {
description: {
story:
'Covers mobile submenu page swaps where the submenu keeps its assigned width instead of inheriting the add-menu width.',
},
},
},
}
export const VirtualizedPreview: Story = {
render: () => ({
components: { BoxIcon, DropdownFilterBar },
setup() {
const selected = ref<Record<string, string[]>>({
version: ['version-3', 'version-47', 'version-132'],
})
const categories = [
{
key: 'version',
label: 'Version',
searchable: true,
searchPlaceholder: 'Search versions...',
submenuClass: 'w-[360px]',
previewDropdownWidth: '360px',
options: largeVersionOptions,
},
]
function getVersionProject(categoryKey: string, optionValue: string) {
if (categoryKey !== 'version') {
return undefined
}
const optionIndex = Number(optionValue.replace('version-', '')) - 1
return `Project ${Math.floor(optionIndex / 25) + 1}`
}
return { categories, getVersionProject, selected }
},
template: /* html */ `
<div class="flex flex-wrap items-center gap-2">
<DropdownFilterBar v-model="selected" :categories="categories">
<template #option="{ category, option, selected }">
<div class="flex min-w-0 flex-1 items-center gap-2">
<span
v-if="getVersionProject(category.key, option.value)"
v-tooltip="getVersionProject(category.key, option.value)"
class="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded text-primary"
>
<BoxIcon class="size-6" />
</span>
<span
class="min-w-0 truncate font-semibold leading-tight"
:class="selected ? 'text-contrast' : 'text-primary'"
>
{{ option.label }}
</span>
</div>
</template>
</DropdownFilterBar>
</div>
`,
}),
args: {
modelValue: {
version: ['version-3', 'version-47', 'version-132'],
},
categories: [
{
key: 'version',
label: 'Version',
searchable: true,
searchPlaceholder: 'Search versions...',
submenuClass: 'w-[360px]',
previewDropdownWidth: '360px',
options: largeVersionOptions,
},
],
},
}
export const VirtualizedSubmenu: Story = {
render: () => ({
components: { BoxIcon, DropdownFilterBar },
setup() {
const selected = ref<Record<string, string[]>>({})
const categories = [
{
key: 'version',
label: 'Version',
searchable: true,
searchPlaceholder: 'Search versions...',
submenuClass: 'w-[360px]',
options: largeVersionOptions,
},
]
function getVersionProject(categoryKey: string, optionValue: string) {
if (categoryKey !== 'version') {
return undefined
}
const optionIndex = Number(optionValue.replace('version-', '')) - 1
return `Project ${Math.floor(optionIndex / 25) + 1}`
}
return { categories, getVersionProject, selected }
},
template: /* html */ `
<div class="flex flex-wrap items-center gap-2">
<DropdownFilterBar v-model="selected" :categories="categories">
<template #option="{ category, option, selected }">
<div class="flex min-w-0 flex-1 items-center gap-2">
<span
v-if="getVersionProject(category.key, option.value)"
v-tooltip="getVersionProject(category.key, option.value)"
class="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded text-primary"
>
<BoxIcon class="size-6" />
</span>
<span
class="min-w-0 truncate font-semibold leading-tight"
:class="selected ? 'text-contrast' : 'text-primary'"
>
{{ option.label }}
</span>
</div>
</template>
</DropdownFilterBar>
</div>
`,
}),
args: {
modelValue: {},
categories: [
{
key: 'version',
label: 'Version',
searchable: true,
searchPlaceholder: 'Search versions...',
submenuClass: 'w-[360px]',
options: largeVersionOptions,
},
],
},
parameters: {
docs: {
description: {
story:
'Covers the add-menu submenu with a large unselected category, custom option content, flush rows, square hover states, and OverlayScrollbars.',
},
},
},
}
export const CustomControls: Story = {
render: () => ({
components: { DropdownFilterBar },
setup() {
const selected = ref<Record<string, string[]>>({})
const selected = ref<Record<string, string[]>>({
version: ['1.21.5'],
})
const minimumDownloads = ref('1k')
const releaseOnly = ref(true)
const categories = [
{
@@ -171,7 +548,7 @@ export const CustomControls: Story = {
],
},
]
return { categories, releaseOnly, selected }
return { categories, minimumDownloads, releaseOnly, selected }
},
template: /* html */ `
<div class="flex flex-wrap items-center gap-2">
@@ -193,14 +570,43 @@ export const CustomControls: Story = {
</button>
</div>
</template>
<template #preview-footer="{ category, setSelectedValues, closeMenu }">
<div
v-if="category.key === 'version'"
class="flex flex-wrap items-center gap-3 border-0 border-t border-solid border-surface-5 px-6 py-2.5"
>
<span class="shrink-0 whitespace-nowrap text-sm font-semibold text-primary">
Versions above
</span>
<input
v-model="minimumDownloads"
type="text"
inputmode="numeric"
class="h-8 w-16 rounded-lg border border-solid border-surface-5 bg-surface-3 px-2 text-center text-sm font-semibold text-primary outline-none"
aria-label="Version downloads threshold"
@keydown.enter.prevent.stop="setSelectedValues(['1.21.5', '1.21.4']); closeMenu($event)"
/>
<span class="shrink-0 text-sm font-semibold text-primary">downloads</span>
</div>
</template>
</DropdownFilterBar>
</div>
`,
}),
args: {
modelValue: {},
modelValue: {
version: ['1.21.5'],
},
categories: searchableCategories,
},
parameters: {
docs: {
description: {
story:
'Covers category search actions and footer controls in both add-menu and applied-filter preview dropdowns.',
},
},
},
}
export const EmptyCategory: Story = {
@@ -1,4 +1,4 @@
import { CheckIcon } from '@modrinth/assets'
import { BoxIcon, CheckIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { computed, ref } from 'vue'
@@ -43,6 +43,29 @@ export const Default: Story = {
modelValue: ['en', 'es', 'fr', 'zh-CN'],
placeholder: 'Select languages',
},
parameters: {
docs: {
description: {
story:
'Options render flush to the dropdown edges with full-width hover and selected states.',
},
},
},
}
export const DeselectFocusState: Story = {
args: {
...Default.args,
modelValue: ['en'],
},
parameters: {
docs: {
description: {
story:
'Mouse focus after deselecting an option should not keep the selected brightness state applied.',
},
},
},
}
export const WithSearch: Story = {
@@ -51,6 +74,56 @@ export const WithSearch: Story = {
searchable: true,
searchPlaceholder: 'Search versions',
},
parameters: {
docs: {
description: {
story:
'Searchable dropdowns avoid auto-focusing search on mobile so opening the menu does not summon the soft keyboard.',
},
},
},
}
export const WithOptionRightSlot: Story = {
args: {
options: [
{ value: 'sodium-1.21.5', label: '1.21.5', searchTerms: ['Sodium'] },
{ value: 'sodium-1.21.4', label: '1.21.4', searchTerms: ['Sodium'] },
{ value: 'iris-1.20.1', label: '1.20.1', searchTerms: ['Iris'] },
{ value: 'modmenu-1.19.2', label: '1.19.2', searchTerms: ['Mod Menu'] },
],
modelValue: ['sodium-1.21.5'],
placeholder: 'Select versions',
searchable: true,
searchPlaceholder: 'Search versions',
},
render: (args) => ({
components: { BoxIcon, MultiSelect },
setup() {
const selected = ref(args.modelValue)
const projectNames: Record<string, string> = {
'sodium-1.21.5': 'Sodium',
'sodium-1.21.4': 'Sodium',
'iris-1.20.1': 'Iris',
'modmenu-1.19.2': 'Mod Menu',
}
return { args, projectNames, selected }
},
template: /*html*/ `
<div style="width: 400px;">
<MultiSelect v-bind="args" v-model="selected">
<template #option-right="{ item }">
<span
v-tooltip="projectNames[item.value]"
class="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded text-primary"
>
<BoxIcon class="size-6" />
</span>
</template>
</MultiSelect>
</div>
`,
}),
}
export const WithSelectAll: Story = {
@@ -62,12 +135,72 @@ export const WithSelectAll: Story = {
},
}
export const WithSelectionActions: Story = {
export const SingleOptionWithSelectAll: Story = {
args: {
options: [{ value: 'sodium', label: 'Sodium' }],
modelValue: [],
placeholder: 'Select projects',
searchable: true,
includeSelectAllOption: true,
searchPlaceholder: 'Search projects',
},
parameters: {
docs: {
description: {
story: 'Select all is hidden when there is only one enabled option.',
},
},
},
}
export const WithRightCheckbox: Story = {
args: {
...Default.args,
searchable: true,
includeSelectAllOption: true,
checkboxPosition: 'right',
searchPlaceholder: 'Search languages',
},
}
export const WithSelectionActions: Story = {
args: {
...Default.args,
modelValue: [],
searchable: true,
showSelectionActions: true,
searchPlaceholder: 'Search versions',
maxHeight: 180,
},
parameters: {
docs: {
description: {
story:
'Selection actions stay above the scrollable options and compensate the scroll position when they appear.',
},
},
},
}
export const WithSections: Story = {
args: {
options: [
{ value: 'iris', label: 'Iris' },
{ value: 'sodium', label: 'Sodium' },
{ type: 'section-header', label: 'Single project group' },
{ value: 'lithium', label: 'Lithium', searchTerms: ['Single project group'] },
{ type: 'section-header', label: 'LambdAurora' },
{ value: 'lambda-better-grass', label: 'LambdaBetterGrass', searchTerms: ['LambdAurora'] },
{ value: 'auroras-decorations', label: "Aurora's Decorations", searchTerms: ['LambdAurora'] },
{ type: 'section-header', label: 'Terraformers' },
{ value: 'modmenu', label: 'Mod Menu', searchTerms: ['Terraformers'] },
{ value: 'terraform-api', label: 'Terraform API', searchTerms: ['Terraformers'] },
],
modelValue: ['iris', 'modmenu'],
placeholder: 'Select projects',
searchable: true,
showSelectionActions: true,
searchPlaceholder: 'Search projects',
},
}
@@ -249,6 +382,50 @@ export const WithBottomSlot: Story = {
}),
}
export const VirtualizedLargeList: Story = {
args: {
options: Array.from({ length: 250 }, (_, index) => {
const version = `1.${Math.floor(index / 10) + 1}.${index % 10}`
return {
value: `version-${index + 1}`,
label: version,
searchTerms: [`Project ${Math.floor(index / 25) + 1}`],
}
}),
modelValue: ['version-3', 'version-47', 'version-132'],
placeholder: 'Select versions',
searchable: true,
searchPlaceholder: 'Search versions',
showSelectionActions: true,
maxHeight: 320,
},
render: (args) => ({
components: { BoxIcon, MultiSelect },
setup() {
const selected = ref(args.modelValue)
function getProjectName(value: string) {
const optionIndex = Number(value.replace('version-', '')) - 1
return `Project ${Math.floor(optionIndex / 25) + 1}`
}
return { args, getProjectName, selected }
},
template: /*html*/ `
<div style="width: 400px;">
<MultiSelect v-bind="args" v-model="selected">
<template #option-right="{ item }">
<span
v-tooltip="getProjectName(item.value)"
class="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded text-primary"
>
<BoxIcon class="size-6" />
</span>
</template>
</MultiSelect>
</div>
`,
}),
}
export const NoOptions: Story = {
args: {
...Default.args,
@@ -265,3 +442,37 @@ export const Empty: Story = {
modelValue: [],
},
}
export const ScrollRepositioning: Story = {
args: {
options: Array.from({ length: 16 }, (_, index) => ({
value: `version-${index + 1}`,
label: `Version ${index + 1}`,
})),
modelValue: [],
placeholder: 'Select versions',
searchable: true,
searchPlaceholder: 'Search versions',
},
render: (args) => ({
components: { MultiSelect },
setup() {
const selected = ref(args.modelValue)
return { args, selected }
},
template: /*html*/ `
<div style="min-height: 150vh; padding-top: 45vh;">
<div style="width: min(100%, 22rem);">
<MultiSelect v-bind="args" v-model="selected" />
</div>
</div>
`,
}),
parameters: {
docs: {
description: {
story: 'Covers fixed dropdown repositioning while the page scrolls with the menu open.',
},
},
},
}
+111 -1
View File
@@ -28,6 +28,20 @@ const sampleUsers: User[] = [
role: 'Admin',
},
]
const rangeSelectionUsers: User[] = Array.from({ length: 10 }, (_, index): User => {
const id = String(index + 1)
const paddedId = id.padStart(2, '0')
const statuses: User['status'][] = ['active', 'inactive', 'pending']
const roles = ['Admin', 'Editor', 'Maintainer', 'Reviewer', 'User']
return {
id,
name: `Member ${paddedId}`,
email: `member-${paddedId}@example.com`,
status: statuses[index % statuses.length],
role: roles[index % roles.length],
}
})
const meta = {
title: 'Base/Table',
@@ -57,7 +71,7 @@ export const Default: StoryObj = {
}),
}
export const WithSelection: StoryObj = {
export const HorizontalOverflow: StoryObj = {
args: {},
render: () => ({
components: { Table },
@@ -69,6 +83,35 @@ export const WithSelection: StoryObj = {
{ key: 'role', label: 'Role' },
]
const data = sampleUsers
return { columns, data }
},
template: /* html */ `
<div class="max-w-80">
<Table :columns="columns" :data="data" table-min-width="44rem">
<template #header>
<div class="flex items-center justify-between gap-4">
<div class="text-lg font-semibold text-contrast">Members</div>
<div class="text-sm text-secondary">{{ data.length }} rows</div>
</div>
</template>
</Table>
</div>
`,
}),
}
export const WithSelection: StoryObj = {
args: {},
render: () => ({
components: { Table },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'status', label: 'Status' },
{ key: 'role', label: 'Role' },
]
const data = rangeSelectionUsers
const selectedIds = ref<string[]>([])
return { columns, data, selectedIds }
},
@@ -81,6 +124,73 @@ export const WithSelection: StoryObj = {
row-key="id"
v-model:selected-ids="selectedIds"
/>
<p class="text-secondary text-sm">Click a checkbox, then Shift-click another checkbox to select or clear the range.</p>
<p class="text-secondary">Selected IDs: {{ selectedIds.join(', ') || 'None' }}</p>
</div>
`,
}),
}
export const WithSelectionData: StoryObj = {
args: {},
render: () => ({
components: { Table },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'status', label: 'Status' },
{ key: 'role', label: 'Role' },
]
const selectionData = rangeSelectionUsers
const data = selectionData.filter((_, index) => index === 1 || index === 5)
const selectedIds = ref<string[]>([])
return { columns, data, selectionData, selectedIds }
},
template: /* html */ `
<div class="space-y-4">
<Table
:columns="columns"
:data="data"
:selection-data="selectionData"
show-selection
row-key="id"
v-model:selected-ids="selectedIds"
/>
<p class="text-secondary text-sm">Only rows 2 and 6 are visible; Shift-clicking between them selects IDs 2 through 6 from selectionData.</p>
<p class="text-secondary">Selected IDs: {{ selectedIds.join(', ') || 'None' }}</p>
</div>
`,
}),
}
export const WithSelectionIds: StoryObj = {
args: {},
render: () => ({
components: { Table },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'status', label: 'Status' },
{ key: 'role', label: 'Role' },
]
const data = rangeSelectionUsers.filter((_, index) => index === 1 || index === 5)
const selectionIds = rangeSelectionUsers.map((user) => user.id)
const selectedIds = ref<string[]>([])
return { columns, data, selectionIds, selectedIds }
},
template: /* html */ `
<div class="space-y-4">
<Table
:columns="columns"
:data="data"
:selection-ids="selectionIds"
show-selection
row-key="id"
v-model:selected-ids="selectedIds"
/>
<p class="text-secondary text-sm">Only rows 2 and 6 are visible; Shift-clicking between them selects IDs 2 through 6 from selectionIds.</p>
<p class="text-secondary">Selected IDs: {{ selectedIds.join(', ') || 'None' }}</p>
</div>
`,
@@ -0,0 +1,96 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import TimeFramePicker, {
type TimeFrameLastUnit,
type TimeFrameMode,
type TimeFramePreset,
} from '../../components/base/TimeFramePicker.vue'
const meta = {
title: 'Base/TimeFramePicker',
component: TimeFramePicker,
parameters: {
layout: 'padded',
},
decorators: [
(story) => ({
components: { story },
template: '<div style="width: 20rem;"><story /></div>',
}),
],
} satisfies Meta<typeof TimeFramePicker>
export default meta
type Story = StoryObj<typeof meta>
function renderPicker(
initial: {
mode?: TimeFrameMode
preset?: TimeFramePreset
lastAmount?: number
lastUnit?: TimeFrameLastUnit
customStartDate?: string
customEndDate?: string
} = {},
) {
return () => ({
components: { TimeFramePicker },
setup() {
const mode = ref<TimeFrameMode>(initial.mode ?? 'preset')
const preset = ref<TimeFramePreset>(initial.preset ?? 'last_30_days')
const lastAmount = ref(initial.lastAmount ?? 1)
const lastUnit = ref<TimeFrameLastUnit>(initial.lastUnit ?? 'days')
const customStartDate = ref(initial.customStartDate ?? '2026-04-23')
const customEndDate = ref(initial.customEndDate ?? '2026-05-22')
return {
customEndDate,
customStartDate,
lastAmount,
lastUnit,
mode,
preset,
}
},
template: /* html */ `
<TimeFramePicker
v-model:mode="mode"
v-model:preset="preset"
v-model:last-amount="lastAmount"
v-model:last-unit="lastUnit"
v-model:custom-start-date="customStartDate"
v-model:custom-end-date="customEndDate"
min-date="2023-01-01"
/>
`,
})
}
export const Preset: Story = {
render: renderPicker(),
}
export const LastTimeframe: Story = {
render: renderPicker({
mode: 'last',
lastAmount: 12,
lastUnit: 'hours',
}),
}
export const CustomRange: Story = {
render: renderPicker({
mode: 'custom_range',
customStartDate: '2026-04-23',
customEndDate: '2026-05-22',
}),
parameters: {
docs: {
description: {
story:
'On mobile and narrow viewports, the custom range panel uses separate one-month start and end date pickers whose portaled calendars should not close the timeframe dropdown when selecting dates.',
},
},
},
}
@@ -5,6 +5,13 @@ import Toggle from '../../components/base/Toggle.vue'
const meta = {
title: 'Base/Toggle',
component: Toggle,
parameters: {
docs: {
description: {
component: 'Toggle uses touch manipulation so double tapping on mobile does not zoom.',
},
},
},
} satisfies Meta<typeof Toggle>
export default meta
+25 -6
View File
@@ -308,6 +308,9 @@ importers:
ansi-to-html:
specifier: ^0.7.2
version: 0.7.2
chart.js:
specifier: ^4.5.1
version: 4.5.1
dayjs:
specifier: ^1.11.7
version: 1.11.19
@@ -718,6 +721,9 @@ importers:
motion-v:
specifier: ^2.2.1
version: 2.2.1(@vueuse/core@11.3.0(vue@3.5.27(typescript@5.9.3)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vue@3.5.27(typescript@5.9.3))
overlayscrollbars:
specifier: ^2.15.1
version: 2.15.1
postprocessing:
specifier: ^6.37.6
version: 6.38.2(three@0.172.0)
@@ -2502,6 +2508,9 @@ packages:
'@jsdevtools/ono@7.1.3':
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
'@kurkle/color@0.3.4':
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
'@kwsites/file-exists@1.1.1':
resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==}
@@ -5415,6 +5424,10 @@ packages:
character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
chart.js@4.5.1:
resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==}
engines: {pnpm: '>=8'}
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
@@ -11585,6 +11598,8 @@ snapshots:
'@jsdevtools/ono@7.1.3': {}
'@kurkle/color@0.3.4': {}
'@kwsites/file-exists@1.1.1':
dependencies:
debug: 4.4.3
@@ -13302,7 +13317,7 @@ snapshots:
eslint-visitor-keys: 4.2.1
espree: 10.4.0
estraverse: 5.3.0
picomatch: 4.0.3
picomatch: 4.0.4
transitivePeerDependencies:
- supports-color
- typescript
@@ -14835,6 +14850,10 @@ snapshots:
character-reference-invalid@2.0.1: {}
chart.js@4.5.1:
dependencies:
'@kurkle/color': 0.3.4
check-error@2.1.3: {}
chevrotain@7.1.1:
@@ -15507,7 +15526,7 @@ snapshots:
eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
is-glob: 4.0.3
minimatch: 10.1.2
semver: 7.7.3
semver: 7.7.4
stable-hash-x: 0.2.0
unrs-resolver: 1.11.1
optionalDependencies:
@@ -15526,7 +15545,7 @@ snapshots:
espree: 10.4.0
esquery: 1.7.0
parse-imports-exports: 0.2.4
semver: 7.7.3
semver: 7.7.4
spdx-expression-parse: 4.0.0
transitivePeerDependencies:
- supports-color
@@ -15587,7 +15606,7 @@ snapshots:
read-pkg-up: 7.0.1
regexp-tree: 0.1.27
regjsparser: 0.10.0
semver: 7.7.3
semver: 7.7.4
strip-indent: 3.0.0
eslint-plugin-vue@10.7.0(@stylistic/eslint-plugin@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@2.6.1))):
@@ -15612,7 +15631,7 @@ snapshots:
natural-compare: 1.4.0
nth-check: 2.1.1
postcss-selector-parser: 6.1.2
semver: 7.7.3
semver: 7.7.4
vue-eslint-parser: 9.4.3(eslint@9.39.2(jiti@2.6.1))
xml-name-validator: 4.0.0
transitivePeerDependencies:
@@ -20222,7 +20241,7 @@ snapshots:
espree: 9.6.1
esquery: 1.7.0
lodash: 4.17.23
semver: 7.7.3
semver: 7.7.4
transitivePeerDependencies:
- supports-color