127 Commits
Author SHA1 Message Date
Martin Brennan 5effd054ec DEV: Mark plugin as deprecated (#35)
We are moving it into core with
https://github.com/discourse/discourse/pull/36409/
2025-12-05 16:01:41 +10:00
d9f9e0c461 UX: New visualizations for new reports (#34)
This adds some new visualizations and styles and reports and cleans up
some existing styles and class names.

Daily activity rhythm 

<img width="1588" height="412" alt="image"
src="https://github.com/user-attachments/assets/0c7980e4-7bb6-4e08-9957-24c3c5718572"
/>


Helping new members

<img width="1456" height="310" alt="image"
src="https://github.com/user-attachments/assets/096a13da-ab63-45d4-9b68-53a01df62a78"
/>

Chat activity 

<img width="1442" height="926" alt="image"
src="https://github.com/user-attachments/assets/07ce2cab-1963-4918-a9ff-805c251a1377"
/>

Assigns 

<img width="1388" height="844" alt="image"
src="https://github.com/user-attachments/assets/ee49c6ee-2da6-4bfa-b500-715ed7ceb13a"
/>

AI usage 

<img width="1442" height="1254" alt="image"
src="https://github.com/user-attachments/assets/b382bffb-010d-4072-9144-cb7e6a8680e3"
/>

Invitations 

<img width="1486" height="1346" alt="image"
src="https://github.com/user-attachments/assets/020f68a9-69d2-46ed-9f38-b0d5e98d02de"
/>

Writing analysis

<img width="917" height="409" alt="image"
src="https://github.com/user-attachments/assets/1147427c-513f-4737-8f28-99bfc6f4aff9"
/>

---------

Co-authored-by: awesomerobot <[email protected]>
Co-authored-by: Martin Brennan <[email protected]>
2025-12-02 09:41:30 -05:00
Martin Brennan 26a77d4c69 DEV: Adding specs and refactors (#21)
Add specs for the following reports:

* Reading time
* Activity calendar
* Best posts
* Best topics
* Top words
* Most viewed tags
* Most viewed categories

Did some minor UI and ruby refactors for related components.

Also made a minor change to the Activity calendar, to show a title based
on the number of posts or if the user was active on hover.

Still missing specs for:

* Reactions
* FBFF

And the newly added reports that don't yet have UI components.
2025-12-01 10:08:30 +10:00
Rafael dos Santos Silva 09ee91ad67 DEV: FakeData for all reports (#32) 2025-11-26 15:50:18 -03:00
Rafael dos Santos Silva f4560c2292 PERF: Migrate AI usage report to use AiApiRequestStat for better perf (#31)
Changes the AiUsage report to query AiApiRequestStat instead of
AiApiAuditLog for significantly improved query performance.
2025-11-26 12:24:07 -03:00
Kris bdbac80063 UX: revert to emoji card backs (#30)
didn't get permission to use the classic solitaire images, so I switched
to some retro emoji!

<img width="717" height="219" alt="image"
src="https://github.com/user-attachments/assets/4c95da4f-dbbb-4bcb-8f4b-2e13ed287320"
/>
2025-11-24 09:38:52 -05:00
Martin Brennan 1cdd3fd708 FIX: Various bugs from 2024 rewind (#29)
* Use post excerpt for best posts to avoid giant posts
  ruining it
* Make sure short emojis (e.g. `:joy:`) render in best posts
  and best topics
* Make images show in best post excerpts (requires core change)
* Fix invite action error, using column that doesn't exist
* Add missing # for top category slugs
* Refactor report service + controller a little to make it easier
  to figure out what went wrong if it fails, and to use the guardian
  user

Copies a couple of fixes from
https://github.com/discourse/discourse-rewind/pull/28 to make
things work locally, the conflicts will be easy to resolve.

**Best topics before**

<img width="977" height="712" alt="image"
src="https://github.com/user-attachments/assets/e120507f-d0d8-44f0-b242-e1056084ef03"
/>


**Best topics after**

<img width="969" height="648" alt="image"
src="https://github.com/user-attachments/assets/dd13f6ac-35df-4e9a-907e-9ed5102843b6"
/>

**Categories before**

<img width="904" height="465" alt="image"
src="https://github.com/user-attachments/assets/0d15e73d-3534-4836-b776-2c97381e58ef"
/>


**Categories after**

<img width="925" height="466" alt="image"
src="https://github.com/user-attachments/assets/3d6e4eae-32ed-4f11-a4f4-090e4473ebe9"
/>
2025-11-19 09:36:08 +10:00
Kris 972cc6e5d9 UX: restyling for 2025 (#28)
This generally updates the styles for the 2025 version. I also made more
features work in development environments (so it's easier to test) and
fixed an issue with invites that was causing the report to not show.


<img width="400" alt="image"
src="https://github.com/user-attachments/assets/69a62b79-12e3-4539-a82c-9bbf9673abe2"
/>

<img width="1554" height="896" alt="image"
src="https://github.com/user-attachments/assets/60fabac5-6f20-4e01-9679-fd228c6850c2"
/>

<img width="754" height="459" alt="image"
src="https://github.com/user-attachments/assets/6725c6e2-1bac-417e-b4ee-190841ab656b"
/>

<img width="1472" height="490" alt="image"
src="https://github.com/user-attachments/assets/87c95e6a-5d78-4fb6-9f2d-dad13bcc9788"
/>

<img width="1480" height="702" alt="image"
src="https://github.com/user-attachments/assets/67bec8f9-dce5-4077-a07d-fe9e13696958"
/>
2025-11-18 09:03:41 -05:00
Martin Brennan a600db4d06 FIX: Rename Favorite Tags/Categories to Most Viewed Tags/Categories and isRewindActive fixes (#27)
This was confusing people last year, most people assumed it meant
the tags they used the most / categories they posted in the most.
This should clear things up.

Also removes the `isRewindActive` function in favour of
`is_rewind_active`
on the current user, since then we only have one place to check this,
and
it makes testing easier since we don't have to mock browser time.

Finally it moves the route for rewind in the UI to the new route path
format.
2025-11-07 10:20:25 +10:00
Rafael dos Santos SilvaandMartin Brennan 93b37e4069 FEATURE: Add seven new metrics to Discourse Rewind reports (#26)
## Summary
Adds seven new metrics to the Discourse Rewind plugin, significantly expanding the types of insights users can get about their annual
activity. These new reports cover temporal patterns, community engagement, plugin integrations, and content analysis.

## New Reports

### 1. Time of Day Activity (`time-of-day-activity`)
- Analyzes user activity by hour in their timezone
- Determines personality type: "early bird" (6-9am), "night owl" (10pm-2am), or "balanced"
- Aggregates posts, chat messages, and page views

### 2. New User Interactions (`new-user-interactions`)
- Tracks veteran mentorship and community building behavior
- Measures likes, replies, and mentions to users who joined this year
- Shows unique new users interacted with and total engagement

### 3. Chat Usage (`chat-usage`)
- Total messages and average message length
- Top 5 favorite channels with message counts
- DM statistics (message count, unique conversations)
- Reactions received on messages

### 4. AI Usage (`ai-usage`)
- Integrates with `discourse-ai` plugin
- Total requests, tokens consumed (request/response breakdown)
- Top 5 most used features and AI models
- Success rate calculation

### 5. Favorite GIFs (`favorite-gifs`)
- Extracts GIFs from posts and chat messages
- Ranks by engagement score (usage × 10 + likes + reactions)
- Supports Giphy, Tenor, and direct GIF URLs
- Shows top 5 GIFs with usage statistics

### 6. Assignments (`assignments`)
- Integrates with `discourse-assign` plugin
- Tracks assignments received and given
- Shows completion rate and pending assignments

### 7. Invites (`invites`)
- Total invites sent and redemption rate
- Impact metrics: invitee posts, topics, and likes created
- Most active invitee identification
- Average trust level of invitees

## Technical Details
- All reports extend `BaseReport` class
- Include proper enablement checks for plugin dependencies
- Use efficient database queries with proper joins and aggregations
- Return `nil` when no relevant data exists

Co-authored-by: Martin Brennan <[email protected]>
2025-10-30 11:18:35 -03:00
dependabot[bot] ea11f40e61 Bump rack from 3.2.2 to 3.2.3 (#24)
Bumps [rack](https://github.com/rack/rack) from 3.2.2 to 3.2.3.
- [Release notes](https://github.com/rack/rack/releases)
- [Changelog](https://github.com/rack/rack/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rack/rack/compare/v3.2.2...v3.2.3)

---
updated-dependencies:
- dependency-name: rack
  dependency-version: 3.2.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-11 01:04:16 +02:00
Jarek Radosz cdf6f0d0e7 DEV: Update linting (#23) 2025-10-09 13:53:37 +01:00
Alan Guo Xiang Tan 09ac87205c DEV: Pin version for Discourse < 3.6.0.beta1-dev (#20) 2025-08-27 12:47:41 +08:00
dependabot[bot] ca316389b5 Bump rack from 3.1.12 to 3.1.16 (#19)
Bumps [rack](https://github.com/rack/rack) from 3.1.12 to 3.1.16.
- [Release notes](https://github.com/rack/rack/releases)
- [Changelog](https://github.com/rack/rack/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rack/rack/compare/v3.1.12...v3.1.16)

---
updated-dependencies:
- dependency-name: rack
  dependency-version: 3.1.16
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-21 17:52:10 +02:00
Jarek Radosz 4b621412ba DEV: Update linting config and run gjs-codemod (#18) 2025-06-05 11:39:18 +01:00
Loïc Guitaut 139688358b DEV: Add system spec to check core features are working fine (#16) 2025-04-09 14:26:53 +02:00
Jarek Radosz 5e2acd9c2d DEV: Update linting (#15) 2025-03-17 11:03:35 +00:00
Joffrey JAFFEUX ffdd50cc65 DEV: fix spec failure (#13)
Reports are only returned in december and january, so since we move into february the spec was failing.
2025-02-26 16:25:10 +01:00
dependabot[bot] 0b7de95705 Bump rack from 3.0.11 to 3.0.12 (#12)
Bumps [rack](https://github.com/rack/rack) from 3.0.11 to 3.0.12.
- [Release notes](https://github.com/rack/rack/releases)
- [Changelog](https://github.com/rack/rack/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rack/rack/commits/v3.0.12)

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

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-13 23:32:18 +00:00
David Taylor e090efa809 DEV: Bump dependencies and fix linting (#11) 2025-02-06 19:43:34 +01:00
David Taylor 6da3c9388d DEV: Pin version for Discourse <3.5.0.beta1-dev (#10) 2025-02-06 17:35:01 +01:00
Jordan Vidrine b3f205375a UX: Text & width adjustments (#9) 2025-01-22 12:23:40 -03:00
Rafael Silva 824d4a426b Restrict tags and categories to user visibility level 2025-01-21 12:45:57 -03:00
Joffrey JAFFEUX 6a7efb7f0c disable favorite tags for now 2025-01-20 18:49:08 +01:00
Joffrey JAFFEUX 8b1943fb9a fbff font 2025-01-20 18:24:09 +01:00
Joffrey JAFFEUX c7a8c3d30b basic specs 2025-01-20 17:17:18 +01:00
Joffrey JAFFEUX a59d6c7931 caching and load order 2025-01-20 16:50:51 +01:00
Joffrey JAFFEUX 141140a410 make fonts local to the project 2025-01-20 15:59:12 +01:00
Rafael dos Santos Silva 19af3cb107 Merge pull request #7 from jjaffeux/category-links
UX: Match category cards with tag cards
2025-01-15 09:45:32 -03:00
Jordan Vidrine 7cc39605f5 linting 2025-01-14 19:02:34 -06:00
Jordan Vidrine fed8205a64 DEV: Add fake category data 2025-01-14 15:57:23 -06:00
Jordan Vidrine d669a39a00 UX: Make card clickable link 2025-01-14 15:55:14 -06:00
Jordan Vidrine 6a55f56b91 Merge pull request #6 from jjaffeux/reading-series 2025-01-14 15:37:39 -06:00
Rafael Silva 17e30c3933 lint 2025-01-14 18:30:10 -03:00
Jordan Vidrine ef21ee6b35 DEV: Format 2025-01-14 15:27:23 -06:00
Jordan Vidrine b358456b65 more 2025-01-14 15:19:16 -06:00
Jordan Vidrine 472783d1c1 DEV: Add correct data to method 2025-01-14 15:17:10 -06:00
Jordan Vidrine 2bb1072d29 Merge pull request #5 from jjaffeux/long-words 2025-01-14 11:38:43 -06:00
Jordan Vidrine ff72e3ed37 UX: Adjust styles for long words 2025-01-14 11:30:13 -06:00
Jordan Vidrine 2398ab4fe6 Merge branch 'main' of https://github.com/jjaffeux/discourse-rewind into main 2025-01-14 11:02:12 -06:00
Jordan Vidrine 951a405c4c UX: Simplify hours and minutes shown 2025-01-14 11:02:03 -06:00
Jordan VidrineandJoffrey JAFFEUX 2e8e292b01 UX: More books styling (#4)
Allows to style books as series

---------

Co-authored-by: Joffrey JAFFEUX <[email protected]>
2025-01-14 17:49:57 +01:00
Rafael dos Santos Silva b045b23e04 Merge pull request #3 from jjaffeux/more-books
More books suggested by sam
2025-01-14 12:45:12 -03:00
Jordan Vidrine 444085caeb Titles 2025-01-14 09:05:45 -06:00
Jordan Vidrine 6982e76294 more book images 2025-01-14 09:04:12 -06:00
Rafael Silva 33476eafb4 More books suggested by sam 2025-01-14 11:17:55 -03:00
Jordan Vidrine 68097b3120 increase trigger for media query 2025-01-13 20:00:12 -06:00
Jordan Vidrine 0d25800241 Merge branch 'main' of https://github.com/jjaffeux/discourse-rewind into main 2025-01-13 17:30:57 -06:00
Jordan Vidrine db3a7e67a4 fix 2025-01-13 17:30:47 -06:00
Joffrey JAFFEUX 797924b018 bottom padding on mobile 2025-01-13 23:51:05 +01:00
Rafael Silva b6b36f9e4a Fix reaction ordering 2025-01-13 18:56:40 -03:00
Rafael Silva 5fa624a0e7 Fix reaction counting 2025-01-13 18:38:52 -03:00
Joffrey JAFFEUX ac95880bdb disabling cache for now 2025-01-13 22:29:42 +01:00
Joffrey JAFFEUX 566c605cca absolute image url 2025-01-13 22:17:28 +01:00
Rafael Silva 266e77dfed Fix logo case 2025-01-13 17:25:55 -03:00
Joffrey JAFFEUX 9a566a706a Merge branch 'main' of https://github.com/jjaffeux/discourse-rewind 2025-01-13 20:54:33 +01:00
Jordan Vidrine a42428442f more 2025-01-13 13:49:26 -06:00
Jordan Vidrine 135af147fa Merge branch 'main' of https://github.com/jjaffeux/discourse-rewind into main 2025-01-13 13:19:31 -06:00
Jordan Vidrine 3aa0610252 shimmer 2025-01-13 13:14:33 -06:00
Jordan Vidrine b92ceee74a more 2025-01-13 12:58:59 -06:00
Jordan Vidrine 98c08d19b3 color scheme issue 2025-01-13 11:52:21 -06:00
Joffrey JAFFEUX 193f92c89b requires_login 2025-01-13 18:17:02 +01:00
Joffrey JAFFEUX f88ab32434 linting 2025-01-13 18:13:24 +01:00
Joffrey JAFFEUX 8586610ee5 rewind border for categories 2025-01-13 18:08:42 +01:00
Jordan Vidrine 823f908cff more 2025-01-13 10:56:52 -06:00
Jordan Vidrine 9aef7c285a more 2025-01-13 10:55:59 -06:00
Jordan Vidrine 535aeba9fa more 2025-01-13 10:48:23 -06:00
Joffrey JAFFEUX d02e4bd5d2 removes log 2025-01-13 17:01:02 +01:00
Joffrey JAFFEUX 6bd637b7b0 fixes 2025-01-13 17:00:32 +01:00
Joffrey JAFFEUX 3049646b65 favorite categories/tags 2025-01-13 14:40:07 +01:00
Joffrey JAFFEUX 29a54cf075 rewind title 2025-01-13 13:46:45 +01:00
Joffrey JAFFEUX 935a290407 mobile stylesheet 2025-01-13 13:46:41 +01:00
Joffrey JAFFEUX b985dc8d25 i18n 2025-01-13 13:12:12 +01:00
Jordan Vidrine 4fef35cd3f final touches 2025-01-12 15:32:46 -06:00
Jordan Vidrine 73c20cb267 more 2025-01-12 15:24:56 -06:00
Jordan Vidrine ee4590d9d7 last bit 2025-01-10 21:10:14 -06:00
Jordan Vidrine 27b3547bbe hand off to joffrey 2025-01-10 21:08:47 -06:00
Jordan Vidrine 57ebde0836 more 2025-01-10 17:03:18 -06:00
Jordan Vidrine 7dc6b55b85 fbff 2025-01-10 17:02:27 -06:00
Jordan Vidrine 412a126e22 more solid styling choices 2025-01-10 14:26:35 -06:00
Jordan Vidrine 5427b90a93 Cards 2025-01-10 08:38:17 -06:00
Jordan Vidrine 1aa62c5760 more testing 2025-01-10 08:23:31 -06:00
Joffrey JAFFEUX 2f7cd88bf2 more reports and fake data 2025-01-09 17:55:58 +01:00
Joffrey JAFFEUX 88d928c315 fake data 2025-01-09 16:52:32 +01:00
Jordan Vidrine d970dbba68 edits 2025-01-09 09:27:58 -06:00
Jordan Vidrine 2e4343fff4 latest 2025-01-09 09:18:46 -06:00
Jordan Vidrine d32072e974 books 2025-01-08 15:11:42 -06:00
Joffrey JAFFEUX 90a03dec31 create kitchen sink spec for fake data 2025-01-07 23:26:17 +01:00
Jordan Vidrine 80210f11d2 styles and animation aditions 2025-01-06 15:04:58 -06:00
Jordan Vidrine 5e7daba027 styling: 2025-01-06 11:53:18 -06:00
Jordan Vidrine 4d7aad782d fonts 2025-01-03 20:20:20 -06:00
Jordan Vidrine 6c4142b4d4 better card flip 2025-01-03 20:03:03 -06:00
Jordan Vidrine 4f381a5da7 loading kind of works 2025-01-03 17:04:03 -06:00
Jordan Vidrine 4a996e6c80 animation, need to figure out how to preload images 2025-01-03 16:56:49 -06:00
Jordan Vidrine c04ca33c1d animation 2025-01-03 16:51:46 -06:00
Jordan Vidrine a36c61c92c animations 2025-01-03 11:44:06 -06:00
Jordan Vidrine 72b5ab50c5 merge 2025-01-03 10:50:18 -06:00
Jordan Vidrine 5e2fce3fde class names 2025-01-03 10:49:43 -06:00
Joffrey JAFFEUX 39dcfc159d Merge branch 'main' of https://github.com/jjaffeux/discourse-rewind 2025-01-03 17:43:09 +01:00
Joffrey JAFFEUX f28f894b97 favorite tags/categories 2025-01-03 17:43:02 +01:00
Jordan Vidrine d0f813207b Merge branch 'main' of https://github.com/jjaffeux/discourse-rewind into main 2025-01-03 10:40:24 -06:00
Jordan Vidrine bf79dc2512 parallax works : ) 2025-01-03 10:40:06 -06:00
Joffrey JAFFEUX 9847ecfe6e bests posts/topics 2025-01-03 17:25:18 +01:00
Joffrey JAFFEUX 0ef77503de clean 2025-01-03 17:12:19 +01:00
Joffrey JAFFEUX e9e6d80eac simple word cloud 2025-01-03 17:11:20 +01:00
Joffrey JAFFEUX 5b916dcf1c parallax 2025-01-03 16:52:21 +01:00
Jordan Vidrine 1ce828080f more 2025-01-03 09:42:42 -06:00
Jordan Vidrine 1fd84fff3b parallax 2025-01-03 09:35:18 -06:00
Jordan Vidrine 35628ccc33 more 2025-01-03 09:17:00 -06:00
Jordan Vidrine d31b2495e2 testing out parralax 2025-01-03 09:15:32 -06:00
Joffrey JAFFEUX bbab3a8f6f Merge branch 'main' of https://github.com/jjaffeux/discourse-rewind 2025-01-02 22:17:04 +01:00
Joffrey JAFFEUX 27b843f2c5 various tweaks 2025-01-02 22:15:10 +01:00
Jordan Vidrine a2a21c12de images 2025-01-02 15:15:05 -06:00
Jordan Vidrine 42e31a7f24 more 2024-12-20 15:50:01 -06:00
Jordan Vidrine 87b7de3ad3 more 2024-12-20 15:41:12 -06:00
Jordan Vidrine c62efa4819 more 2024-12-20 15:39:07 -06:00
Jordan Vidrine 409367588a styling 2024-12-20 14:55:55 -06:00
Joffrey JAFFEUX 3af6a12a55 start implementing reports 2024-12-20 18:56:02 +01:00
Rafael Silva bc80558e2d More reports 2024-12-19 15:31:12 -03:00
Joffrey JAFFEUX 1ce447e37f correct class name 2024-12-17 21:21:22 +01:00
Rafael Silva bf0f068938 Word Cloud report 2024-12-13 14:14:13 -03:00
Joffrey JAFFEUX baf19cbe9f base design 2024-12-13 17:51:06 +01:00
Joffrey JAFFEUX 66ec74bf2b DEV: move to an ember component 2024-12-13 17:00:17 +01:00
Joffrey JAFFEUX df21bc6e73 Fix: visible_post_types expects a user 2024-12-13 16:57:40 +01:00
Rafael Silva 589d2e9761 FBFF report 2024-12-13 10:48:20 -03:00
Rafael dos Santos Silva bc6ad108ea Reaction report (#2) 2024-12-12 21:45:22 +01:00
Rafael dos Santos Silva 906f9d6e9e Add reading_time and posting calendar reports (#1) 2024-12-12 15:12:04 +01:00
139 changed files with 9734 additions and 2389 deletions
+3
View File
@@ -0,0 +1,3 @@
< 3.6.0.beta1-dev: ca316389b51afeb17a697efaff041e425a0deac6
< 3.5.0.beta5-dev: cbcbbd2e3316b06c91998c7a0fd493d89ab6d2a5
< 3.5.0.beta1-dev: b3f205375a2f12287aa3338d8adc1afa98c4bfbb
+67 -52
View File
@@ -1,80 +1,95 @@
GEM
remote: https://rubygems.org/
specs:
activesupport (7.1.3.3)
activesupport (8.0.3)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
mutex_m
tzinfo (~> 2.0)
ast (2.4.2)
base64 (0.2.0)
bigdecimal (3.1.8)
concurrent-ruby (1.2.3)
connection_pool (2.4.1)
drb (2.2.1)
i18n (1.14.5)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
ast (2.4.3)
base64 (0.3.0)
benchmark (0.4.1)
bigdecimal (3.3.0)
concurrent-ruby (1.3.5)
connection_pool (2.5.4)
drb (2.2.3)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
json (2.7.2)
language_server-protocol (3.17.0.3)
minitest (5.23.1)
mutex_m (0.2.0)
parallel (1.24.0)
parser (3.3.1.0)
json (2.15.1)
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.7.0)
minitest (5.26.0)
parallel (1.27.0)
parser (3.3.9.0)
ast (~> 2.4.1)
racc
prettier_print (1.2.1)
racc (1.8.0)
rack (3.0.11)
prism (1.5.1)
racc (1.8.1)
rack (3.2.3)
rainbow (3.1.1)
regexp_parser (2.9.2)
rexml (3.3.9)
rubocop (1.64.0)
regexp_parser (2.11.3)
rubocop (1.81.1)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.31.1, < 2.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.47.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.31.3)
parser (>= 3.3.1.0)
rubocop-capybara (2.20.0)
rubocop (~> 1.41)
rubocop-discourse (3.8.0)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.47.1)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-capybara (2.22.1)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-discourse (3.13.3)
activesupport (>= 6.1)
rubocop (>= 1.59.0)
rubocop-capybara (>= 2.0.0)
rubocop-factory_bot (>= 2.0.0)
rubocop-rails (>= 2.25.0)
rubocop-rspec (>= 2.25.0)
rubocop-factory_bot (2.25.1)
rubocop (~> 1.41)
rubocop-rails (2.25.0)
lint_roller (>= 1.1.0)
rubocop (>= 1.73.2)
rubocop-capybara (>= 2.22.0)
rubocop-factory_bot (>= 2.27.0)
rubocop-rails (>= 2.30.3)
rubocop-rspec (>= 3.0.1)
rubocop-rspec_rails (>= 2.31.0)
rubocop-factory_bot (2.27.1)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-rails (2.33.4)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rspec (2.29.2)
rubocop (~> 1.40)
rubocop-capybara (~> 2.17)
rubocop-factory_bot (~> 2.22)
rubocop-rspec_rails (~> 2.28)
rubocop-rspec_rails (2.28.3)
rubocop (~> 1.40)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rspec (3.7.0)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-rspec_rails (2.31.0)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-rspec (~> 3.5)
ruby-progressbar (1.13.0)
syntax_tree (6.2.0)
securerandom (0.4.1)
syntax_tree (6.3.0)
prettier_print (>= 1.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (2.5.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
uri (1.0.4)
PLATFORMS
ruby
@@ -84,4 +99,4 @@ DEPENDENCIES
syntax_tree
BUNDLED WITH
2.5.10
2.7.2
+2 -9
View File
@@ -1,12 +1,5 @@
# DiscourseRewind
Display stats on your last year of Discourse usage.
## DEPRECATED
## Usage
- add `http://127.0.0.1:4200/rewinds?` in `SiteSetting.allowed_iframes`
- example embed of a rewind in a post:
```
<iframe src="http://127.0.0.1:4200/rewinds?year=2024&username=j.jaffeux" width="320" height="500" frameborder="0" style="border:0;" allowfullscreen="" aria-hidden="false" tabindex="0" />
```
This plugin has been moved into Discourse core as at https://github.com/discourse/discourse/pull/36409/
@@ -1,32 +0,0 @@
# frozen_string_literal: true
module ::DiscourseRewind
class RewindsAssetsController < ::ApplicationController
requires_plugin PLUGIN_NAME
skip_before_action :preload_json, :check_xhr, only: %i[show]
skip_before_action :verify_authenticity_token, only: %i[show]
def show
no_cookies
name = params[:name]
path, content_type =
if name == "rewind"
%w[rewind.css text/css]
else
raise Discourse::NotFound
end
content = File.read(DiscourseRewind.public_asset_path("css/#{path}"))
# note, path contains a ":version" which automatically busts the cache
# based on file content, so this is safe
response.headers["Last-Modified"] = 10.years.ago.httpdate
response.headers["Content-Length"] = content.bytesize.to_s
immutable_for 1.year
render plain: content, disposition: :nil, content_type: content_type
end
end
end
@@ -4,17 +4,17 @@ module ::DiscourseRewind
class RewindsController < ::ApplicationController
requires_plugin PLUGIN_NAME
skip_before_action :preload_json, :check_xhr, :redirect_to_login_if_required, only: %i[show]
requires_login
def show
# expires_in 1.minute, public: false
response.headers["X-Robots-Tag"] = "noindex"
DiscourseRewind::Rewind::Fetch.call(service_params) do
on_success do |reports:|
@reports = reports
render "show", layout: false
DiscourseRewind::FetchReports.call(service_params) do
on_model_not_found(:year) do
raise Discourse::NotFound.new(nil, custom_message: "discourse_rewind.invalid_year")
end
on_model_not_found(:reports) do
raise Discourse::NotFound.new(nil, custom_message: "discourse_rewind.report_failed")
end
on_success { |reports:| render json: MultiJson.dump(reports), status: :ok }
end
end
end
@@ -1,25 +0,0 @@
# frozen_string_literal: true
module DiscourseRewind
module RewindsHelper
# keeping it here for caching
def self.rewind_asset_url(asset_name)
if !%w[rewind.css].include?(asset_name)
raise StandardError, "unknown asset type #{asset_name}"
end
@urls ||= {}
url = @urls[asset_name]
return url if url
content = File.read(DiscourseRewind.public_asset_path("css/#{asset_name}"))
sha1 = Digest::SHA1.hexdigest(content)
url = "/rewinds/assets/#{sha1}/#{asset_name}"
@urls[asset_name] = GlobalPath.cdn_path(url)
end
def rewind_asset_url(asset_name)
DiscourseRewind::RewindsHelper.rewind_asset_url(asset_name)
end
end
end
@@ -0,0 +1,43 @@
# frozen_string_literal: true
# For a GitHub like calendar
# https://docs.github.com/assets/cb-35216/mw-1440/images/help/profile/contributions-graph.webp
module DiscourseRewind
module Action
class ActivityCalendar < BaseReport
FakeData = {
data:
(Date.new(2024, 1, 1)..Date.new(2024, 12, 31)).map do |date|
{ date:, post_count: rand(0..20), visited: false }
end,
identifier: "activity-calendar",
}
def call
return FakeData if Rails.env.development?
calendar =
Post
.unscoped
.joins(<<~SQL)
RIGHT JOIN
generate_series('#{date.first}', '#{date.last}', '1 day'::interval) ON
posts.created_at::date = generate_series::date AND
posts.user_id = #{user.id} AND
posts.deleted_at IS NULL
SQL
.joins(
"LEFT JOIN user_visits ON generate_series::date = visited_at AND user_visits.user_id = #{user.id}",
)
.select(
"generate_series::date as date, count(posts.id) as post_count, COUNT(visited_at) > 0 as visited",
)
.group("generate_series, user_visits.id")
.order("generate_series")
.map { |row| { date: row.date, post_count: row.post_count, visited: row.visited } }
{ data: calendar, identifier: "activity-calendar" }
end
end
end
end
@@ -0,0 +1,97 @@
# frozen_string_literal: true
# AI usage statistics from discourse-ai plugin
# Shows total usage, favorite features, token consumption, etc.
# Uses AiApiRequestStat for efficient aggregation queries
module DiscourseRewind
module Action
class AiUsage < BaseReport
FakeData = {
data: {
total_requests: 247,
total_tokens: 156_890,
request_tokens: 45_230,
response_tokens: 111_660,
feature_usage: {
"chat_composer_helper" => 89,
"post_summarizer" => 56,
"semantic_search" => 42,
"topic_gist" => 38,
"similar_topics" => 22,
},
model_usage: {
"gpt-4" => 123,
"claude-3-5-sonnet" => 89,
"gpt-3.5-turbo" => 35,
},
success_rate: 94.7,
},
identifier: "ai-usage",
}
def call
return FakeData if Rails.env.development?
return if !enabled?
base_query = AiApiRequestStat.where(user_id: user.id).where(bucket_date: date)
# Get aggregated stats in a single query
stats =
base_query.select(
"COALESCE(SUM(usage_count), 0) as total_requests",
"COALESCE(SUM(request_tokens), 0) as total_request_tokens",
"COALESCE(SUM(response_tokens), 0) as total_response_tokens",
"COALESCE(SUM(CASE WHEN response_tokens > 0 THEN usage_count ELSE 0 END), 0) as successful_requests",
).take
return if stats.total_requests == 0
total_tokens = stats.total_request_tokens + stats.total_response_tokens
success_rate =
(
if stats.total_requests > 0
(stats.successful_requests.to_f / stats.total_requests * 100).round(1)
else
0
end
)
# Most used features (top 5)
feature_usage =
base_query
.group(:feature_name)
.order("SUM(usage_count) DESC")
.limit(5)
.pluck(:feature_name, Arel.sql("SUM(usage_count)"))
.to_h
# Most used AI model (top 5)
model_usage =
base_query
.where.not(language_model: nil)
.group(:language_model)
.order("SUM(usage_count) DESC")
.limit(5)
.pluck(:language_model, Arel.sql("SUM(usage_count)"))
.to_h
{
data: {
total_requests: stats.total_requests,
total_tokens: total_tokens,
request_tokens: stats.total_request_tokens,
response_tokens: stats.total_response_tokens,
feature_usage: feature_usage,
model_usage: model_usage,
success_rate: success_rate,
},
identifier: "ai-usage",
}
end
def enabled?
defined?(AiApiRequestStat) && SiteSetting.discourse_ai_enabled
end
end
end
end
@@ -0,0 +1,74 @@
# frozen_string_literal: true
# Assignment statistics using discourse-assign plugin data
# Shows how many assignments, completed, pending, etc.
module DiscourseRewind
module Action
class Assignments < BaseReport
FakeData = {
data: {
total_assigned: 24,
completed: 18,
pending: 6,
assigned_by_user: 15,
completion_rate: 75.0,
},
identifier: "assignments",
}
def call
return FakeData if Rails.env.development?
return if !enabled?
# Assignments made to the user
assignments_scope =
Assignment
.where(assigned_to_id: user.id, assigned_to_type: "User")
.where(created_at: date)
total_assigned = assignments_scope.count
# Completed assignments (topics that were assigned and then closed or unassigned)
completed_count =
assignments_scope
.joins(:topic)
.where(
"topics.closed = true OR assignments.active = false OR assignments.updated_at > assignments.created_at",
)
.distinct
.count
# Currently pending (still open and assigned)
pending_count =
Assignment
.where(assigned_to_id: user.id, assigned_to_type: "User", active: true)
.joins(:topic)
.where(topics: { closed: false })
.count
# Assignments made by the user to others
assigned_by_user =
Assignment
.where(assigned_by_user_id: user.id)
.where(created_at: date)
.count
{
data: {
total_assigned: total_assigned,
completed: completed_count,
pending: pending_count,
assigned_by_user: assigned_by_user,
completion_rate:
total_assigned > 0 ? (completed_count.to_f / total_assigned * 100).round(1) : 0,
},
identifier: "assignments",
}
end
def enabled?
defined?(Assignment) && SiteSetting.assign_enabled
end
end
end
end
@@ -0,0 +1,18 @@
# frozen_string_literal: true
module DiscourseRewind
module Action
class BaseReport < Service::ActionBase
option :user
option :date
def call
raise NotImplementedError
end
def self.enabled?
true
end
end
end
end
@@ -0,0 +1,61 @@
# frozen_string_literal: true
module DiscourseRewind
module Action
class BestPosts < BaseReport
FakeData = {
data: [
{
post_number: 5,
topic_id: 42,
like_count: 23,
reply_count: 8,
excerpt: "This is a great explanation of how ActiveRecord works under the hood...",
},
{
post_number: 12,
topic_id: 89,
like_count: 19,
reply_count: 5,
excerpt:
"Here's a comprehensive guide to testing Rails applications with RSpec and system tests...",
},
{
post_number: 3,
topic_id: 156,
like_count: 15,
reply_count: 12,
excerpt:
"The key to understanding PostgreSQL performance is looking at your query plans...",
},
],
identifier: "best-posts",
}
def call
return FakeData if Rails.env.development?
best_posts =
Post
.where(user_id: user.id)
.where(created_at: date)
.where(deleted_at: nil)
.where("post_number > 1")
.order("like_count DESC NULLS LAST, created_at ASC")
.limit(3)
.select(:post_number, :topic_id, :like_count, :reply_count, :raw, :cooked)
.map do |post|
{
post_number: post.post_number,
topic_id: post.topic_id,
like_count: post.like_count,
reply_count: post.reply_count,
excerpt:
post.excerpt(200, { strip_links: true, remap_emoji: true, keep_images: true }),
}
end
{ data: best_posts, identifier: "best-posts" }
end
end
end
end
@@ -0,0 +1,48 @@
# frozen_string_literal: true
module DiscourseRewind
module Action
class BestTopics < BaseReport
FakeData = {
data: [
{
topic_id: 1,
title: "How to get started with Rails",
excerpt: "A comprehensive guide to getting started with Ruby on Rails...",
yearly_score: 42.5,
},
{
topic_id: 2,
title: "Best practices for database optimization",
excerpt: "Learn how to optimize your database queries for better performance...",
yearly_score: 38.2,
},
{
topic_id: 3,
title: "Understanding ActiveRecord associations",
excerpt: "Deep dive into has_many, belongs_to, and other associations...",
yearly_score: 35.7,
},
],
identifier: "best-topics",
}
def call
return FakeData if Rails.env.development?
best_topics =
TopTopic
.includes(:topic)
.references(:topic)
.where(topic: { deleted_at: nil, created_at: date, user_id: user.id })
.order("yearly_score DESC NULLS LAST")
.limit(3)
.pluck(:topic_id, :title, :excerpt, :yearly_score)
.map do |topic_id, title, excerpt, yearly_score|
{ topic_id: topic_id, title: title, excerpt: excerpt, yearly_score: yearly_score }
end
{ data: best_topics, identifier: "best-topics" }
end
end
end
end
@@ -0,0 +1,102 @@
# frozen_string_literal: true
# Chat usage statistics
# Shows message counts, favorite channels, DM activity, etc.
module DiscourseRewind
module Action
class ChatUsage < BaseReport
FakeData = {
data: {
total_messages: 342,
favorite_channels: [
{ channel_id: 1, channel_name: "general", message_count: 156 },
{ channel_id: 2, channel_name: "tech-talk", message_count: 89 },
{ channel_id: 3, channel_name: "random", message_count: 45 },
{ channel_id: 4, channel_name: "dev", message_count: 32 },
{ channel_id: 5, channel_name: "announcements", message_count: 12 },
],
dm_message_count: 87,
unique_dm_channels: 12,
messages_with_reactions: 42,
total_reactions_received: 156,
avg_message_length: 78.5,
},
identifier: "chat-usage",
}
def call
return FakeData if Rails.env.development?
return if !enabled?
messages =
Chat::Message.where(user_id: user.id).where(created_at: date).where(deleted_at: nil)
total_messages = messages.count
return if total_messages == 0
# Get favorite channels (public channels)
channel_usage =
messages
.joins(:chat_channel)
.where(chat_channels: { type: "CategoryChannel" })
.group("chat_channels.id", "chat_channels.name")
.count
.sort_by { |_, count| -count }
.first(5)
.map do |(id, name), count|
{ channel_id: id, channel_name: name, message_count: count }
end
# DM statistics
dm_message_count =
messages.joins(:chat_channel).where(chat_channels: { type: "DirectMessageChannel" }).count
# Unique DM conversations
unique_dm_channels =
messages
.joins(:chat_channel)
.where(chat_channels: { type: "DirectMessageChannel" })
.distinct
.count(:chat_channel_id)
# Messages with reactions received
messages_with_reactions =
Chat::MessageReaction
.joins(:chat_message)
.where(chat_messages: { user_id: user.id })
.where(chat_messages: { created_at: date })
.distinct
.count(:chat_message_id)
# Total reactions received
total_reactions_received =
Chat::MessageReaction
.joins(:chat_message)
.where(chat_messages: { user_id: user.id })
.where(chat_messages: { created_at: date })
.count
# Average message length
avg_message_length =
messages.where("LENGTH(message) > 0").average("LENGTH(message)")&.to_f&.round(1) || 0
{
data: {
total_messages: total_messages,
favorite_channels: channel_usage,
dm_message_count: dm_message_count,
unique_dm_channels: unique_dm_channels,
messages_with_reactions: messages_with_reactions,
total_reactions_received: total_reactions_received,
avg_message_length: avg_message_length,
},
identifier: "chat-usage",
}
end
def enabled?
SiteSetting.chat_enabled
end
end
end
end
@@ -0,0 +1,162 @@
# frozen_string_literal: true
# Find the user's most used GIFs in posts and chat
# Ranks by usage count and engagement (likes/reactions)
module DiscourseRewind
module Action
class FavoriteGifs < BaseReport
GIF_URL_PATTERN =
%r{
https?://[^\s]+\.(?:gif|gifv)
|
https?://(?!(?:developers|support|blog)\.) (?:[^/\s]+\.)?giphy\.com/(?!dashboard\b)[^\s]+
|
https?://(?!(?:support)\.) (?:[^/\s]+\.)?tenor\.com/(?!gifapi\b)[^\s]+
}ix
MAX_RESULTS = 5
FakeData = {
data: {
favorite_gifs: [
{
url: "https://media.giphy.com/media/111ebonMs90YLu/giphy.gif",
usage_count: 12,
likes: 45,
reactions: 23,
},
{
url: "https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.gif",
usage_count: 8,
likes: 32,
reactions: 18,
},
{
url: "https://media1.tenor.com/m/XnODae53zvYAAAAd/joke-stickfigure.gif",
usage_count: 7,
likes: 28,
reactions: 15,
},
{
url: "https://c.tenor.com/tX_T48A14BwAAAAd/khaby-really.gif",
usage_count: 5,
likes: 20,
reactions: 12,
},
{
url: "https://media.giphy.com/media/3oriO0OEd9QIDdllqo/giphy.gif",
usage_count: 4,
likes: 15,
reactions: 8,
},
],
total_gif_usage: 36,
},
identifier: "favorite-gifs",
}
def call
return FakeData if Rails.env.development?
gif_data = {}
# Get GIFs from posts
post_gifs = extract_gifs_from_posts
post_gifs.each do |url, data|
gif_data[url] ||= { url: url, usage_count: 0, likes: 0, reactions: 0 }
gif_data[url][:usage_count] += data[:count]
gif_data[url][:likes] += data[:likes]
end
# Get GIFs from chat messages if chat is enabled
if SiteSetting.chat_enabled
chat_gifs = extract_gifs_from_chat
chat_gifs.each do |url, data|
gif_data[url] ||= { url: url, usage_count: 0, likes: 0, reactions: 0 }
gif_data[url][:usage_count] += data[:count]
gif_data[url][:reactions] += data[:reactions]
end
end
return if gif_data.empty?
# Sort by engagement score (usage * 10 + likes + reactions)
sorted_gifs =
gif_data
.values
.sort_by { |gif| -(gif[:usage_count] * 10 + gif[:likes] + gif[:reactions]) }
.first(MAX_RESULTS)
{
data: {
favorite_gifs: sorted_gifs,
total_gif_usage: gif_data.values.sum { |g| g[:usage_count] },
},
identifier: "favorite-gifs",
}
end
private
def extract_gifs_from_posts
gif_usage = {}
posts =
Post
.where(user_id: user.id)
.where(created_at: date)
.where(deleted_at: nil)
.where("raw ~* ?", gif_sql_pattern)
.select(:id, :raw, :like_count)
posts.each do |post|
gif_urls = post.raw.scan(GIF_URL_PATTERN).uniq.select { |url| content_gif_url?(url) }
gif_urls.each do |url|
gif_usage[url] ||= { count: 0, likes: 0 }
gif_usage[url][:count] += 1
gif_usage[url][:likes] += post.like_count || 0
end
end
gif_usage
end
def extract_gifs_from_chat
gif_usage = {}
messages =
Chat::Message
.where(user_id: user.id)
.where(created_at: date)
.where(deleted_at: nil)
.where("message ~* ?", gif_sql_pattern)
.select(:id, :message)
messages.each do |message|
gif_urls =
message.message.scan(GIF_URL_PATTERN).uniq.select { |url| content_gif_url?(url) }
gif_urls.each do |url|
gif_usage[url] ||= { count: 0, reactions: 0 }
gif_usage[url][:count] += 1
# Count reactions on this message
reaction_count = Chat::MessageReaction.where(chat_message_id: message.id).count
gif_usage[url][:reactions] += reaction_count
end
end
gif_usage
end
def gif_sql_pattern
@gif_sql_pattern ||= GIF_URL_PATTERN.source.gsub(/\s+/, "")
end
def content_gif_url?(url)
return true if url.match?(/\.(gif|gifv)(?:\?|$)/i)
return true if url.match?(%r{giphy\.com/(?:gifs?|media|embed|stickers|clips)}i)
return true if url.match?(%r{tenor\.com/(?:view|watch|embed|gif)}i)
false
end
end
end
end
@@ -0,0 +1,133 @@
# frozen_string_literal: true
# Forum Best Friend Forever ranking
# Score is informative only, do not show in UI
module DiscourseRewind
module Action
class Fbff < BaseReport
MAX_SUMMARY_RESULTS = 50
LIKE_SCORE = 1
REPLY_SCORE = 10
FakeData = {
data: {
fbff: {
id: 2,
username: "codingpal",
name: "Coding Pal",
avatar_template: "/letter_avatar_proxy/v4/letter/c/3be4f8/{size}.png",
},
yourself: {
id: 1,
username: "you",
name: "You",
avatar_template: "/letter_avatar_proxy/v4/letter/y/f05b48/{size}.png",
},
},
identifier: "fbff",
}
def call
return FakeData if Rails.env.development?
most_liked_users =
like_query(date)
.where(acting_user_id: user.id)
.group(:user_id)
.order("COUNT(*) DESC")
.limit(MAX_SUMMARY_RESULTS)
.pluck("user_actions.user_id, COUNT(*)")
.map { |user_id, count| { user_id => count } }
.reduce({}, :merge)
most_liked_by_users =
like_query(date)
.where(user: user)
.group(:acting_user_id)
.order("COUNT(*) DESC")
.limit(MAX_SUMMARY_RESULTS)
.pluck("acting_user_id, COUNT(*)")
.map { |acting_user_id, count| { acting_user_id => count } }
.reduce({}, :merge)
users_who_most_replied_me =
post_query(user, date)
.where(posts: { user_id: user.id })
.group("replies.user_id")
.order("COUNT(*) DESC")
.limit(MAX_SUMMARY_RESULTS)
.pluck("replies.user_id, COUNT(*)")
.map { |user_id, count| { user_id => count } }
.reduce({}, :merge)
users_i_most_replied =
post_query(user, date)
.where("replies.user_id = ?", user.id)
.group("posts.user_id")
.order("COUNT(*) DESC")
.limit(MAX_SUMMARY_RESULTS)
.pluck("posts.user_id, COUNT(*)")
.map { |user_id, count| { user_id => count } }
.reduce({}, :merge)
#TODO include chat?
fbffs = [
apply_score(most_liked_users, LIKE_SCORE),
apply_score(most_liked_by_users, LIKE_SCORE),
apply_score(users_who_most_replied_me, REPLY_SCORE),
apply_score(users_i_most_replied, REPLY_SCORE),
]
fbff_id =
fbffs
.flatten
.inject { |h1, h2| h1.merge(h2) { |_, v1, v2| v1 + v2 } }
&.sort_by { |_, v| -v }
&.first
&.first
return if !fbff_id
{
data: {
fbff: BasicUserSerializer.new(User.find(fbff_id), root: false).as_json,
yourself: BasicUserSerializer.new(user, root: false).as_json,
},
identifier: "fbff",
}
end
def post_query(user, date)
Post
.joins(:topic)
.includes(:topic)
.where(
"posts.post_type IN (?)",
Topic.visible_post_types(user, include_moderator_actions: false),
)
.joins(
"INNER JOIN posts replies ON posts.topic_id = replies.topic_id AND posts.reply_to_post_number = replies.post_number",
)
.joins(
"INNER JOIN topics ON replies.topic_id = topics.id AND topics.archetype <> 'private_message'",
)
.joins(
"AND replies.post_type IN (#{Topic.visible_post_types(user, include_moderator_actions: false).join(",")})",
)
.where("replies.created_at BETWEEN ? AND ?", date.first, date.last)
.where("replies.user_id <> posts.user_id")
end
def like_query(date)
UserAction
.joins(:target_topic, :target_post)
.where(created_at: date)
.where(action_type: UserAction::WAS_LIKED)
end
def apply_score(users, score)
users.map { |user_id, count| { user_id => count * score } }
end
end
end
end
@@ -0,0 +1,101 @@
# frozen_string_literal: true
# Invite statistics
# Shows how many users this user invited and the impact of those invitees
module DiscourseRewind
module Action
class Invites < BaseReport
FakeData = {
data: {
total_invites: 18,
redeemed_count: 12,
redemption_rate: 66.7,
invitee_post_count: 145,
invitee_topic_count: 23,
invitee_like_count: 89,
avg_trust_level: 1.8,
most_active_invitee: {
id: 42,
username: "newbie_123",
name: "New User",
avatar_template: "/letter_avatar_proxy/v4/letter/n/8c91d9/{size}.png",
},
},
identifier: "invites",
}
def call
return FakeData if Rails.env.development?
# Get all invites created by this user in the date range
invites = Invite.where(invited_by_id: user.id).where(created_at: date)
total_invites = invites.count
return if total_invites == 0
# Redeemed invites (users who actually joined)
redeemed_count = invites.where("redemption_count > 0").count
# Get the users who were invited (via InvitedUser or redeemed invites)
invited_user_ids = InvitedUser.where(invite: invites).pluck(:user_id).compact
invited_users = User.where(id: invited_user_ids)
# Calculate impact of invitees
invitee_post_count =
Post.where(user_id: invited_user_ids).where(created_at: date).where(deleted_at: nil).count
invitee_topic_count =
Topic
.where(user_id: invited_user_ids)
.where(created_at: date)
.where(deleted_at: nil)
.count
invitee_like_count =
UserAction
.where(user_id: invited_user_ids)
.where(action_type: UserAction::LIKE)
.where(created_at: date)
.count
# Calculate average trust level of invitees
avg_trust_level = invited_users.average(:trust_level)&.to_f&.round(1) || 0
# Most active invitee
most_active_invitee = nil
if invited_user_ids.any?
most_active_id =
Post
.where(user_id: invited_user_ids)
.where(created_at: date)
.where(deleted_at: nil)
.group(:user_id)
.count
.max_by { |_, count| count }
&.first
if most_active_id
most_active_user = User.find_by(id: most_active_id)
most_active_invitee =
BasicUserSerializer.new(most_active_user, root: false).as_json if most_active_user
end
end
{
data: {
total_invites: total_invites,
redeemed_count: redeemed_count,
redemption_rate:
total_invites > 0 ? (redeemed_count.to_f / total_invites * 100).round(1) : 0,
invitee_post_count: invitee_post_count,
invitee_topic_count: invitee_topic_count,
invitee_like_count: invitee_like_count,
avg_trust_level: avg_trust_level,
most_active_invitee: most_active_invitee,
},
identifier: "invites",
}
end
end
end
end
@@ -0,0 +1,40 @@
# frozen_string_literal: true
# Topics visited grouped by category
module DiscourseRewind
module Action
class MostViewedCategories < BaseReport
FakeData = {
data: [
{ category_id: 1, name: "cats" },
{ category_id: 2, name: "dogs" },
{ category_id: 3, name: "countries" },
{ category_id: 4, name: "management" },
],
identifier: "most-viewed-categories",
}
def call
return FakeData if Rails.env.development?
most_viewed_categories =
TopicViewItem
.joins(:topic)
.joins("INNER JOIN categories ON categories.id = topics.category_id")
.where(
user: user,
viewed_at: date,
categories: {
id: user.guardian.allowed_category_ids,
},
)
.group("categories.id, categories.name")
.order("COUNT(*) DESC")
.limit(4)
.pluck("categories.id, categories.name")
.map { |category_id, name| { category_id: category_id, name: name } }
{ data: most_viewed_categories, identifier: "most-viewed-categories" }
end
end
end
end
@@ -0,0 +1,36 @@
# frozen_string_literal: true
# Topics visited grouped by tag
module DiscourseRewind
module Action
class MostViewedTags < BaseReport
FakeData = {
data: [
{ tag_id: 1, name: "cats" },
{ tag_id: 2, name: "dogs" },
{ tag_id: 3, name: "countries" },
{ tag_id: 4, name: "management" },
],
identifier: "most-viewed-tags",
}
def call
return FakeData if Rails.env.development?
most_viewed_tags =
TopicViewItem
.joins(:topic)
.joins("INNER JOIN topic_tags ON topic_tags.topic_id = topics.id")
.joins("INNER JOIN tags ON tags.id = topic_tags.tag_id")
.where(user: user, viewed_at: date, tags: { id: Tag.visible(user.guardian).pluck(:id) })
.group("tags.id, tags.name")
.order("COUNT(DISTINCT topic_views.topic_id) DESC")
.limit(4)
.pluck("tags.id, tags.name")
.map { |tag_id, name| { tag_id: tag_id, name: name } }
{ data: most_viewed_tags, identifier: "most-viewed-tags" }
end
end
end
end
@@ -0,0 +1,78 @@
# frozen_string_literal: true
# Tracks how much this user interacted with new users (created this year)
# Shows veteran mentorship and community building behavior
module DiscourseRewind
module Action
class NewUserInteractions < BaseReport
FakeData = {
data: {
total_interactions: 127,
likes_given: 45,
replies_to_new_users: 62,
mentions_to_new_users: 20,
topics_with_new_users: 8,
unique_new_users: 24,
new_users_count: 156,
},
identifier: "new-user-interactions",
}
def call
return FakeData if Rails.env.development?
year_start = Date.new(date.first.year, 1, 1)
# Find users who created accounts this year
new_user_ids =
User
.real
.where("created_at >= ? AND created_at <= ?", year_start, date.last)
.where("id != ?", user.id)
.pluck(:id)
return if new_user_ids.empty?
# Count likes given to new users
liked_user_ids =
UserAction
.where(
acting_user_id: user.id,
user_id: new_user_ids,
action_type: UserAction::WAS_LIKED,
)
.where(created_at: date)
.distinct
.pluck(:user_id)
# Count replies to new users' posts
replied_user_ids =
Post
.joins(
"INNER JOIN posts AS parent_posts ON posts.reply_to_post_number = parent_posts.post_number AND posts.topic_id = parent_posts.topic_id",
)
.where(posts: { user_id: user.id, deleted_at: nil, created_at: date })
.where("parent_posts.user_id": new_user_ids)
.distinct
.pluck("parent_posts.user_id")
# Count direct mentions to new users
mentioned_user_ids =
Post
.joins(
"INNER JOIN user_actions ON user_actions.target_post_id = posts.id AND user_actions.action_type = #{UserAction::MENTION}",
)
.where(posts: { user_id: user.id, deleted_at: nil, created_at: date })
.where(user_actions: { user_id: new_user_ids })
.distinct
.pluck("user_actions.user_id")
# Unique new users interacted with
unique_new_users = (liked_user_ids + replied_user_ids + mentioned_user_ids).uniq.count
return if unique_new_users == 0
{ data: { unique_new_users: unique_new_users }, identifier: "new-user-interactions" }
end
end
end
end
@@ -0,0 +1,92 @@
# frozen_string_literal: true
# For a most user / received reactions cards
module DiscourseRewind
module Action
class Reactions < BaseReport
FakeData = {
data: {
post_received_reactions: {
"open_mouth" => 2,
"cat" => 32,
"dog" => 34,
"heart" => 45,
"grinning" => 82,
},
post_used_reactions: {
"open_mouth" => 2,
"cat" => 32,
"dog" => 34,
"heart" => 45,
"grinning" => 82,
},
chat_used_reactions: {
"open_mouth" => 2,
"cat" => 32,
"dog" => 34,
"heart" => 45,
"grinning" => 82,
},
chat_received_reactions: {
"open_mouth" => 2,
"cat" => 32,
"dog" => 34,
"heart" => 45,
"grinning" => 82,
},
},
identifier: "reactions",
}
def call
return FakeData if Rails.env.development?
data = {}
if defined?(DiscourseReactions::Reaction)
# This is missing heart reactions (default like)
data[:post_used_reactions] = sort_and_limit(
DiscourseReactions::Reaction
.by_user(user)
.where(created_at: date)
.group(:reaction_value)
.count,
)
data[:post_received_reactions] = sort_and_limit(
DiscourseReactions::Reaction
.includes(:post)
.where(posts: { user_id: user.id })
.where(created_at: date)
.group(:reaction_value)
.count,
)
end
if SiteSetting.chat_enabled
data[:chat_used_reactions] = sort_and_limit(
Chat::MessageReaction.where(user: user).where(created_at: date).group(:emoji).count,
)
data[:chat_received_reactions] = sort_and_limit(
Chat::MessageReaction
.includes(:chat_message)
.where(chat_message: { user_id: user.id })
.where(created_at: date)
.group(:emoji)
.count,
)
end
{ data:, identifier: "reactions" }
end
def enabled?
SiteSetting.discourse_reactions_enabled || SiteSetting.chat_enabled
end
def sort_and_limit(reactions)
reactions.sort_by { |_, value| -value }.take(5).reverse.to_h
end
end
end
end
@@ -0,0 +1,187 @@
# frozen_string_literal: true
# For showcasing the reading time of a user
# Should we show book covers or just the names?
module DiscourseRewind
module Action
class ReadingTime < BaseReport
POPULAR_BOOKS = {
"The Metamorphosis" => {
reading_time: 3120,
isbn: "978-0553213690",
series: false,
},
"The Little Prince" => {
reading_time: 5400,
isbn: "978-0156012195",
series: false,
},
"Animal Farm" => {
reading_time: 7200,
isbn: "978-0451526342",
series: false,
},
"The Alchemist" => {
reading_time: 10_800,
isbn: "978-0061122415",
series: false,
},
"The Great Gatsby" => {
reading_time: 12_600,
isbn: "978-0743273565",
series: false,
},
"The Hitchhiker's Guide to the Galaxy" => {
reading_time: 12_600,
isbn: "978-0345391803",
series: false,
},
"Fahrenheit 451" => {
reading_time: 15_000,
isbn: "978-1451673319",
series: false,
},
"And Then There Were None" => {
reading_time: 16_200,
isbn: "978-0062073488",
series: false,
},
"1984" => {
reading_time: 16_800,
isbn: "978-0451524935",
series: false,
},
"The Catcher in the Rye" => {
reading_time: 18_000,
isbn: "978-0316769488",
series: false,
},
"The Hunger Games" => {
reading_time: 19_740,
isbn: "978-0439023481",
series: false,
},
"To Kill a Mockingbird" => {
reading_time: 22_800,
isbn: "978-0061120084",
series: false,
},
"Harry Potter and the Sorcerer's Stone" => {
reading_time: 24_600,
isbn: "978-0590353427",
series: true,
},
"Pride and Prejudice" => {
reading_time: 25_200,
isbn: "978-1503290563",
series: false,
},
"The Hobbit" => {
reading_time: 27_000,
isbn: "978-0547928227",
series: false,
},
"Little Women" => {
reading_time: 30_000,
isbn: "978-0147514011",
series: false,
},
"Jane Eyre" => {
reading_time: 34_200,
isbn: "978-0141441146",
series: false,
},
"The Da Vinci Code" => {
reading_time: 37_800,
isbn: "978-0307474278",
series: false,
},
"One Hundred Years of Solitude" => {
reading_time: 46_800,
isbn: "978-0060883287",
series: false,
},
"The Lord of the Rings" => {
reading_time: 108_000,
isbn: "978-0544003415",
series: true,
},
"The Complete works of Shakespeare" => {
reading_time: 180_000,
isbn: "978-1853268953",
series: true,
},
"The Game of Thrones Series" => {
reading_time: 360_000,
isbn: "978-0007477159",
series: true,
},
"Malazan Book of the Fallen" => {
reading_time: 720_000,
isbn: "978-0765348821",
series: true,
},
"Terry Pratchett's Discworld series" => {
reading_time: 1_440_000,
isbn: "978-9123684458",
series: true,
},
"The Wandering Inn web series" => {
reading_time: 2_160_000,
isbn: "the-wandering-inn",
series: true,
},
"The Combined Cosmere works + Wheel of Time" => {
reading_time: 2_880_000,
isbn: "978-0812511819",
series: true,
},
"The Star Trek novels" => {
reading_time: 3_600_000,
isbn: "978-1852860691",
series: true,
},
}.symbolize_keys
FakeData = {
data: {
reading_time: 2_880_000,
book: "The Combined Cosmere works + Wheel of Time",
isbn: "978-0812511819",
series: true,
},
identifier: "reading-time",
}
def call
return FakeData if Rails.env.development?
reading_time = UserVisit.where(user_id: user.id).where(visited_at: date).sum(:time_read)
book = best_book_fit(reading_time)
return if book.nil?
{
data: {
reading_time: reading_time,
book: book[:title].to_s,
isbn: book[:isbn],
series: book[:series],
},
identifier: "reading-time",
}
end
def best_book_fit(reading_time)
best_fit =
POPULAR_BOOKS
.select { |_, v| v[:reading_time] > reading_time }
.min_by { |_, v| v[:reading_time] }
return if best_fit.nil?
{ title: best_fit.first, isbn: best_fit.last[:isbn], series: best_fit.last[:series] }
end
end
end
end
@@ -0,0 +1,145 @@
# frozen_string_literal: true
# Time of day activity analysis
# Shows when a user is most active (considering their timezone)
# Determines if they are a night owl or early bird
module DiscourseRewind
module Action
class TimeOfDayActivity < BaseReport
EARLY_BIRD_THRESHOLD = 6..9
NIGHT_OWL_THRESHOLD_PM = 22..23
NIGHT_OWL_THRESHOLD_AM = 0..2
FakeData = {
data: {
activity_by_hour: {
0 => 12,
1 => 8,
2 => 5,
3 => 2,
4 => 1,
5 => 3,
6 => 8,
7 => 15,
8 => 25,
9 => 32,
10 => 28,
11 => 24,
12 => 22,
13 => 20,
14 => 26,
15 => 30,
16 => 28,
17 => 22,
18 => 18,
19 => 16,
20 => 14,
21 => 18,
22 => 22,
23 => 15,
},
most_active_hour: 9,
personality: {
type: "early_bird",
percentage: 28.5,
},
total_activities: 414,
},
identifier: "time-of-day-activity",
}
def call
return FakeData if Rails.env.development?
# Get activity by hour of day (in user's timezone)
activity_by_hour = get_activity_by_hour
return if activity_by_hour.empty?
total_activities = activity_by_hour.values.sum
most_active_hour = activity_by_hour.max_by { |_, count| count }&.first
personality = determine_personality(activity_by_hour, total_activities)
{
data: {
activity_by_hour: activity_by_hour,
most_active_hour: most_active_hour,
personality: personality,
total_activities: total_activities,
},
identifier: "time-of-day-activity",
}
end
private
def get_activity_by_hour
# Get user timezone offset
user_timezone = user.user_option&.timezone || "UTC"
quoted_timezone = ActiveRecord::Base.connection.quote(user_timezone)
hour_extract_sql =
Arel.sql(
"EXTRACT(HOUR FROM created_at AT TIME ZONE 'UTC' AT TIME ZONE #{quoted_timezone})::integer",
)
# Initialize hash with all hours
activity = (0..23).to_h { |hour| [hour, 0] }
# Posts created
post_hours =
Post
.where(user_id: user.id)
.where(created_at: date)
.where(deleted_at: nil)
.pluck(hour_extract_sql)
.tally
# User visits (page views)
visit_hours =
UserHistory
.where(acting_user_id: user.id)
.where(created_at: date)
.where(action: UserHistory.actions[:page_view])
.pluck(hour_extract_sql)
.tally
# Chat messages if chat is enabled
if SiteSetting.chat_enabled
chat_hours =
Chat::Message
.where(user_id: user.id)
.where(created_at: date)
.where(deleted_at: nil)
.pluck(hour_extract_sql)
.tally
chat_hours.each { |hour, count| activity[hour] += count }
end
post_hours.each { |hour, count| activity[hour] += count }
visit_hours.each { |hour, count| activity[hour] += count }
activity
end
def determine_personality(activity_by_hour, total_activities)
return nil if total_activities == 0
early_bird_activity = EARLY_BIRD_THRESHOLD.sum { |hour| activity_by_hour[hour] || 0 }
night_owl_activity =
NIGHT_OWL_THRESHOLD_PM.sum { |hour| activity_by_hour[hour] || 0 } +
NIGHT_OWL_THRESHOLD_AM.sum { |hour| activity_by_hour[hour] || 0 }
early_bird_percentage = (early_bird_activity.to_f / total_activities * 100).round(1)
night_owl_percentage = (night_owl_activity.to_f / total_activities * 100).round(1)
if early_bird_percentage > 20
{ type: "early_bird", percentage: early_bird_percentage }
elsif night_owl_percentage > 20
{ type: "night_owl", percentage: night_owl_percentage }
else
{ type: "balanced", percentage: 0 }
end
end
end
end
end
@@ -0,0 +1,93 @@
# frozen_string_literal: true
module DiscourseRewind
module Action
class TopWords < BaseReport
FakeData = {
data: [
{ word: "seven", score: 100 },
{ word: "longest", score: 90 },
{ word: "you", score: 80 },
{ word: "overachieved", score: 70 },
{ word: "assume", score: 60 },
],
identifier: "top-words",
}
def call
return FakeData if Rails.env.development?
words = DB.query(<<~SQL, user_id: user.id, date_start: date.first, date_end: date.last)
WITH popular_words AS (
SELECT
*
FROM
ts_stat(
$INNERSQL$
SELECT
search_data
FROM
post_search_data
INNER JOIN
posts ON posts.id = post_search_data.post_id
WHERE
posts.user_id = :user_id
AND posts.created_at BETWEEN :date_start AND :date_end
$INNERSQL$
) AS search_data
ORDER BY
nentry DESC,
ndoc DESC,
word
LIMIT
100
), lex AS (
SELECT
DISTINCT ON (lexeme) to_tsvector('english', word) as lexeme,
word as original_word
FROM
ts_stat ($INNERSQL$
SELECT
to_tsvector('simple', raw)
FROM
posts AS p
WHERE
p.created_at BETWEEN :date_start AND :date_end
AND p.user_id = :user_id
$INNERSQL$)
), ranked_words AS (
SELECT
popular_words.*, lex.original_word,
ROW_NUMBER() OVER (PARTITION BY word ORDER BY LENGTH(original_word)) AS rn
FROM
popular_words
INNER JOIN
lex ON lex.lexeme @@ to_tsquery('english', popular_words.word)
)
SELECT
word,
ndoc,
nentry,
original_word
FROM
ranked_words
WHERE
rn = 1
ORDER BY
ndoc + nentry DESC
LIMIT 100
SQL
word_score =
words
.map do |word_data|
{ word: word_data.original_word, score: word_data.ndoc + word_data.nentry }
end
.sort_by! { |w| -w[:score] }
.take(5)
{ data: word_score, identifier: "top-words" }
end
end
end
end
@@ -0,0 +1,115 @@
# frozen_string_literal: true
module DiscourseRewind
module Action
class WritingAnalysis < BaseReport
FakeData = {
data: {
total_words: 45_230,
total_posts: 197,
average_post_length: 230,
readability_score: 65.4,
},
identifier: "writing-analysis",
}
def call
return FakeData if Rails.env.development?
total_words =
DB.query_single(<<~SQL, user_id: user.id, date_start: date.first, date_end: date.last)
SELECT SUM(word_count) FROM posts
WHERE user_id = :user_id
AND created_at BETWEEN :date_start AND :date_end
AND deleted_at IS NULL
SQL
post_count =
DB.query_single(<<~SQL, user_id: user.id, date_start: date.first, date_end: date.last)
SELECT COUNT(*) FROM posts
WHERE user_id = :user_id
AND created_at BETWEEN :date_start AND :date_end
AND deleted_at IS NULL
SQL
average_post_length =
post_count.first > 0 ? (total_words.first.to_f / post_count.first).round(2) : 0
# Calculated using the Flesch Reading Ease formula,
# with an approximation for syllables since this can
# be tricky to get right in SQL.
#
# Tries to handle short sentences or ones without delmiters
# and ending with emojis by treating them as a single sentence.
readability_score =
DB.query_single(<<~SQL, user_id: user.id, start: date.first, end: date.last)
WITH cleaned AS (
SELECT
p.id AS post_id,
p.user_id,
p.created_at,
p.word_count,
regexp_replace(p.cooked, '<[^>]+>', ' ', 'g') AS plain
FROM posts p
WHERE p.user_id = :user_id
AND p.created_at BETWEEN :start AND :end
),
metrics AS (
SELECT
post_id,
user_id,
created_at,
plain,
word_count AS words,
regexp_count(plain, '[.!?;:](\s|$)') AS sentences_raw,
regexp_count(lower(plain), '[aeiouy]+') AS syllables
FROM cleaned
),
scores AS (
SELECT
post_id,
user_id,
created_at,
words,
syllables,
plain,
CASE
WHEN sentences_raw = 0 AND words > 5 THEN 1
ELSE sentences_raw
END AS sentences_fixed,
-- Flesch Reading Ease formula
CASE
WHEN words = 0 THEN NULL
WHEN (CASE WHEN sentences_raw = 0 AND words > 5 THEN 1 ELSE sentences_raw END) = 0 THEN NULL
ELSE (
206.835
- 1.015 * (
words::float /
(CASE WHEN sentences_raw = 0 AND words > 5 THEN 1 ELSE sentences_raw END)
)
- 84.6 * (syllables::float / words)
)
END AS readability_score
FROM metrics
)
SELECT AVG(readability_score) AS avg_readability_score
FROM scores
GROUP BY user_id;
SQL
{
data: {
total_words: total_words.first,
total_posts: post_count.first,
average_post_length: average_post_length,
readability_score: readability_score.first,
},
identifier: "writing-analysis",
}
end
end
end
end
@@ -0,0 +1,91 @@
# frozen_string_literal: true
module DiscourseRewind
# Service responsible to fetch a rewind for a username/year.
#
# @example
# ::DiscourseRewind::Rewind::Fetch.call(
# guardian: guardian,
# params: { year: 2023, username: 'codinghorror' }
# )
#
class FetchReports
include Service::Base
# @!method self.call(guardian:, params:)
# @param [Guardian] guardian
# @param [Hash] params
# @option params [Integer] :year of the rewind
# @option params [Integer] :username of the rewind
# @return [Service::Base::Context]
CACHE_DURATION = Rails.env.development? ? 10.seconds : 5.minutes
# order matters
REPORTS = [
Action::TopWords,
Action::ReadingTime,
Action::WritingAnalysis,
Action::Reactions,
Action::Fbff,
Action::MostViewedTags,
Action::MostViewedCategories,
Action::BestTopics,
Action::BestPosts,
Action::ActivityCalendar,
Action::TimeOfDayActivity,
Action::NewUserInteractions,
Action::ChatUsage,
Action::AiUsage,
Action::FavoriteGifs,
Action::Assignments,
Action::Invites,
]
model :year
model :date
model :reports
private
def fetch_year
current_date = Time.zone.now
current_month = current_date.month
current_year = current_date.year
case current_month
when 1
current_year - 1
when 12
current_year
else
# Otherwise it's impossible to test in browser unless you're
# in December or January
if Rails.env.development?
current_year
else
false
end
end
end
def fetch_date(params:, year:)
Date.new(year).all_year
end
def fetch_reports(date:, guardian:, year:)
key = "rewind:#{guardian.user.username}:#{year}"
reports = Discourse.redis.get(key)
if !reports
reports =
REPORTS.map { |report| report.call(date:, user: guardian.user, guardian:) }.compact
Discourse.redis.setex(key, CACHE_DURATION, MultiJson.dump(reports))
else
reports = MultiJson.load(reports, symbolize_keys: true)
end
reports
end
end
end
@@ -1,23 +0,0 @@
# frozen_string_literal: true
module DiscourseRewind
class Rewind::Action::CreatePostsCountReport < Service::ActionBase
option :params
option :guardian
option :date
option :user
delegate :username, :year, to: :params, private: true
def call
p date
p user
p guardian
p username
p year
p params
{ data: { count: 5 }, identifier: "posts-count-report" }
end
end
end
@@ -1,66 +0,0 @@
# frozen_string_literal: true
module DiscourseRewind
# Service responsible to fetch a rewind for a username/year.
#
# @example
# ::DiscourseRewind::Rewind::Fetch.call(
# guardian: guardian,
# params: {
# username: "falco",
# year: 2024,
# }
# )
#
class Rewind::Fetch
include Service::Base
# @!method self.call(guardian:, params:)
# @param [Guardian] guardian
# @param [Hash] params
# @option params [Integer] :year of the rewind
# @option params [Integer] :username of the rewind
# @return [Service::Base::Context]
# order matters, rewinds are displayed in the order they are defined
REPORTS = [DiscourseRewind::Rewind::Action::CreatePostsCountReport]
CACHE_DURATION = 5.minutes
params do
attribute :year, :integer
attribute :username, :string
validates :year, presence: true
validates :username, presence: true
end
model :user
model :date
model :reports
private
def fetch_user(params:)
User.find_by_username(params.username)
end
def fetch_date(params:)
Date.new(params.year).all_year
end
def fetch_reports(params:, date:, user:, guardian:)
key = "rewind:#{params.username}:#{params.year}"
reports = Discourse.redis.get(key)
if Rails.env.development? || !reports
reports = REPORTS.map { |report| report.call(params:, date:, user:, guardian:) }
Discourse.redis.setex(key, CACHE_DURATION, MultiJson.dump(reports))
else
reports = MultiJson.load(reports, symbolize_keys: true)
end
reports
end
end
end
@@ -1,31 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title><%= I18n.t("discourse_rewind.title", title: "RETRO TITLE", site_name: SiteSetting.title) %></title>
<meta property="og:title" content="<%= I18n.t("discourse_rewind.title", title: "RETRO TITLE", site_name: SiteSetting.title) %>">
<meta property="og:description" content="DESC">
<meta property="og:type" content="website">
<meta property="og:url" content="<%= request.original_url %>">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="<%= I18n.t("discourse_rewind.title", title: "RETRO TITLE", site_name: SiteSetting.title) %>">
<meta name="twitter:description" content="DESC">
<meta name="viewport" content="width=device-width">
<link rel="stylesheet" href="<%= rewind_asset_url("rewind.css") %>">
</head>
<body>
<div class="rewind__wrapper">
<div class="rewind">
<%- @reports.each do |report| %>
<div class="rewind__report">
<div class="rewind__report__header">
<%= report[:identifier] %>
</div>
<div class="rewind__report__body">
<%= report[:data][:count] %>
</div>
</div>
<%- end %>
</div>
</div>
</body>
</html>
@@ -0,0 +1,150 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import concatClass from "discourse/helpers/concat-class";
import { i18n } from "discourse-i18n";
const ROWS = 7;
const COLS = 53;
export default class ActivityCalendar extends Component {
get rowsArray() {
const data = this.args.report.data;
let rowsArray = [];
for (let r = 0; r < ROWS; r++) {
let rowData = [];
for (let c = 0; c < COLS; c++) {
const index = c * ROWS + r;
rowData.push(data[index] ? data[index] : "");
}
rowsArray.push(rowData);
}
return rowsArray;
}
@action
getAbbreviatedMonth(monthIndex) {
return moment().month(monthIndex).format("MMM");
}
@action
computeCellTitle(cell) {
if (!cell || !cell.date) {
return "";
}
const date = moment(cell.date).format("LL");
if (cell.visited && cell.post_count === 0) {
return i18n(
"discourse_rewind.reports.activity_calendar.cell_title.visited_no_posts",
{ date }
);
} else if (cell.post_count > 0) {
return i18n(
"discourse_rewind.reports.activity_calendar.cell_title.visited_with_posts",
{ date, count: cell.post_count }
);
}
return i18n(
"discourse_rewind.reports.activity_calendar.cell_title.no_activity",
{ date }
);
}
@action
computeClass(count) {
if (!count) {
return "--empty";
} else if (count < 10) {
return "--low";
} else if (count < 20) {
return "--medium";
} else {
return "--high";
}
}
<template>
<div class="rewind-report-page --activity-calendar">
<h2 class="rewind-report-title">{{i18n
"discourse_rewind.reports.activity_calendar.title"
}}</h2>
<div class="rewind-card">
<table class="rewind-calendar">
<thead>
<tr>
<td
colspan="5"
class="activity-header-cell"
>{{this.getAbbreviatedMonth 0}}</td>
<td
colspan="4"
class="activity-header-cell"
>{{this.getAbbreviatedMonth 1}}</td>
<td
colspan="4"
class="activity-header-cell"
>{{this.getAbbreviatedMonth 2}}</td>
<td
colspan="5"
class="activity-header-cell"
>{{this.getAbbreviatedMonth 3}}</td>
<td
colspan="4"
class="activity-header-cell"
>{{this.getAbbreviatedMonth 4}}</td>
<td
colspan="4"
class="activity-header-cell"
>{{this.getAbbreviatedMonth 5}}</td>
<td
colspan="5"
class="activity-header-cell"
>{{this.getAbbreviatedMonth 6}}</td>
<td
colspan="4"
class="activity-header-cell"
>{{this.getAbbreviatedMonth 7}}</td>
<td
colspan="5"
class="activity-header-cell"
>{{this.getAbbreviatedMonth 8}}</td>
<td
colspan="4"
class="activity-header-cell"
>{{this.getAbbreviatedMonth 9}}</td>
<td
colspan="4"
class="activity-header-cell"
>{{this.getAbbreviatedMonth 10}}</td>
<td
colspan="4"
class="activity-header-cell"
>{{this.getAbbreviatedMonth 11}}</td>
</tr>
</thead>
<tbody>
{{#each this.rowsArray as |row|}}
<tr>
{{#each row as |cell|}}
<td
data-date={{cell.date}}
title={{this.computeCellTitle cell}}
class={{concatClass
"rewind-calendar-cell"
(this.computeClass cell.post_count)
}}
></td>
{{/each}}
</tr>
{{/each}}
</tbody>
</table>
</div>
</div>
</template>
}
@@ -0,0 +1,176 @@
import Component from "@glimmer/component";
import { get } from "@ember/helper";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { service } from "@ember/service";
import number from "discourse/helpers/number";
import { i18n } from "discourse-i18n";
export default class AiUsage extends Component {
@service currentUser;
matrixInterval = null;
get totalRequests() {
return this.args.report.data.total_requests ?? 0;
}
get totalTokens() {
return this.args.report.data.total_tokens ?? 0;
}
get successRate() {
return this.args.report.data.success_rate ?? 0;
}
get featureUsage() {
return Object.entries(this.args.report.data.feature_usage ?? {})
.filter(([name]) => name && name.trim().length > 0)
.slice(0, 3);
}
get modelUsage() {
return Object.entries(this.args.report.data.model_usage ?? {})
.filter(([name]) => name && name.trim().length > 0)
.slice(0, 3);
}
@action
formatFeatureName(featureName) {
return featureName.replace(/_/g, " ");
}
@action
setupMatrix(element) {
const canvas = element;
const ctx = canvas.getContext("2d");
canvas.width = element.offsetWidth;
canvas.height = element.offsetHeight;
const characters =
"01アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン";
const fontSize = 14;
const columns = Math.floor(canvas.width / fontSize);
const drops = Array(columns).fill(1);
const draw = () => {
ctx.fillStyle = "rgba(0, 0, 0, 0.05)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#0f0";
ctx.font = `${fontSize}px monospace`;
for (let i = 0; i < drops.length; i++) {
const char = characters[Math.floor(Math.random() * characters.length)];
const x = i * fontSize;
const y = drops[i] * fontSize;
ctx.fillText(char, x, y);
if (y > canvas.height && Math.random() > 0.975) {
drops[i] = 0;
}
drops[i]++;
}
};
this.matrixInterval = setInterval(draw, 33);
}
@action
cleanupMatrix() {
if (this.matrixInterval) {
clearInterval(this.matrixInterval);
}
}
<template>
<div class="rewind-report-page --ai-usage">
<div class="matrix-container">
<canvas
class="matrix-rain"
{{didInsert this.setupMatrix}}
{{willDestroy this.cleanupMatrix}}
></canvas>
<div class="matrix-content">
<h2 class="matrix-title">
<div class="matrix-subhead">
{{i18n
"discourse_rewind.reports.ai_usage.wake_up"
username=this.currentUser.username
}}
</div>
{{i18n "discourse_rewind.reports.ai_usage.system_title"}}
</h2>
<div class="matrix-stats">
<div class="matrix-stat">
<div class="matrix-stat__label">
{{i18n "discourse_rewind.reports.ai_usage.total_requests"}}
</div>
<div class="matrix-stat__value">
{{number this.totalRequests}}
</div>
</div>
<div class="matrix-stat">
<div class="matrix-stat__label">
{{i18n "discourse_rewind.reports.ai_usage.total_tokens"}}
</div>
<div class="matrix-stat__value">{{number this.totalTokens}}</div>
</div>
<div class="matrix-stat">
<div class="matrix-stat__label">
{{i18n "discourse_rewind.reports.ai_usage.success_rate"}}
</div>
<div class="matrix-stat__value">
<span class="number">
{{this.successRate}}%
</span>
</div>
</div>
</div>
{{#if this.featureUsage.length}}
<div class="matrix-section">
<div class="matrix-section__title">&gt;
{{i18n "discourse_rewind.reports.ai_usage.section_features"}}
</div>
<div class="matrix-list">
{{#each this.featureUsage as |entry|}}
<div class="matrix-list__item">
<span class="matrix-list__name">
{{this.formatFeatureName (get entry "0")}}
</span>
<span class="matrix-list__count">{{get entry "1"}}</span>
</div>
{{/each}}
</div>
</div>
{{/if}}
{{#if this.modelUsage.length}}
<div class="matrix-section">
<div class="matrix-section__title">&gt;
{{i18n "discourse_rewind.reports.ai_usage.section_models"}}
</div>
<div class="matrix-list">
{{#each this.modelUsage as |entry|}}
<div class="matrix-list__item">
<span class="matrix-list__name">{{get entry "0"}}</span>
<span class="matrix-list__count">{{get entry "1"}}</span>
</div>
{{/each}}
</div>
</div>
{{/if}}
</div>
</div>
</div>
</template>
}

Some files were not shown because too many files have changed in this diff Show More