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]>
This commit is contained in:
Rafael dos Santos Silva
2025-12-02 09:41:30 -05:00
committed by GitHub
co-authored by awesomerobot Martin Brennan
parent 26a77d4c69
commit d9f9e0c461
49 changed files with 2761 additions and 190 deletions
@@ -33,66 +33,45 @@ module DiscourseRewind
return if new_user_ids.empty?
# Count likes given to new users
likes_scope =
UserAction.where(
acting_user_id: user.id,
user_id: new_user_ids,
action_type: UserAction::WAS_LIKED,
).where(created_at: date)
likes_given = likes_scope.count
liked_user_ids = likes_scope.distinct.pluck(:user_id)
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
replies_scope =
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)
replies_to_new_users = replies_scope.count
replied_user_ids = replies_scope.distinct.pluck("parent_posts.user_id")
# Count topics created by user that new users participated in
topics_with_new_users =
Topic
.joins(:posts)
.where(topics: { user_id: user.id, deleted_at: nil })
.where(posts: { user_id: new_user_ids, deleted_at: nil })
.where(topics: { created_at: date })
.distinct
.count
.pluck("parent_posts.user_id")
# Count direct messages/mentions to new users
mentions_scope =
# 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 })
mentions_to_new_users = mentions_scope.distinct.count
mentioned_user_ids = mentions_scope.distinct.pluck("user_actions.user_id")
.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
total_interactions = likes_given + replies_to_new_users + mentions_to_new_users
return if unique_new_users == 0
return if total_interactions == 0
{
data: {
total_interactions: total_interactions,
likes_given: likes_given,
replies_to_new_users: replies_to_new_users,
mentions_to_new_users: mentions_to_new_users,
topics_with_new_users: topics_with_new_users,
unique_new_users: unique_new_users,
new_users_count: new_user_ids.count,
},
identifier: "new-user-interactions",
}
{ data: { unique_new_users: unique_new_users }, identifier: "new-user-interactions" }
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
@@ -25,6 +25,7 @@ module DiscourseRewind
REPORTS = [
Action::TopWords,
Action::ReadingTime,
Action::WritingAnalysis,
Action::Reactions,
Action::Fbff,
Action::MostViewedTags,
@@ -57,18 +57,18 @@ export default class ActivityCalendar extends Component {
@action
computeClass(count) {
if (!count) {
return "-empty";
return "--empty";
} else if (count < 10) {
return "-low";
return "--low";
} else if (count < 20) {
return "-medium";
return "--medium";
} else {
return "-high";
return "--high";
}
}
<template>
<div class="rewind-report-page -activity-calendar">
<div class="rewind-report-page --activity-calendar">
<h2 class="rewind-report-title">{{i18n
"discourse_rewind.reports.activity_calendar.title"
}}</h2>
@@ -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>
}
@@ -0,0 +1,63 @@
import number from "discourse/helpers/number";
import { i18n } from "discourse-i18n";
const Assignments = <template>
<div class="rewind-report-page --assignments">
<div class="sticky-board">
<div class="sticky-note --yellow --rotate-left">
<div class="sticky-note__content">
<div class="sticky-note__title">
{{i18n "discourse_rewind.reports.assignments.completed"}}
</div>
<div class="sticky-note__value">
{{number @report.data.completed}}
</div>
</div>
</div>
<div class="sticky-note --pink --rotate-right">
<div class="sticky-note__content">
<div class="sticky-note__title">
{{i18n "discourse_rewind.reports.assignments.pending"}}
</div>
<div class="sticky-note__value">{{number @report.data.pending}}</div>
</div>
</div>
<div class="sticky-note --blue --rotate-left-small">
<div class="sticky-note__content">
<div class="sticky-note__title">
{{i18n "discourse_rewind.reports.assignments.total_assigned"}}
</div>
<div class="sticky-note__value">
{{number @report.data.total_assigned}}
</div>
</div>
</div>
<div class="sticky-note --green --rotate-right-small">
<div class="sticky-note__content">
<div class="sticky-note__title">
{{i18n "discourse_rewind.reports.assignments.assigned_by_user"}}
</div>
<div class="sticky-note__value">
{{number @report.data.assigned_by_user}}
</div>
</div>
</div>
<div class="sticky-note --orange">
<div class="sticky-note__content">
<div class="sticky-note__title">
{{i18n "discourse_rewind.reports.assignments.completion_rate"}}
</div>
<div
class="sticky-note__value"
>{{@report.data.completion_rate}}%</div>
</div>
</div>
</div>
</div>
</template>;
export default Assignments;
@@ -11,7 +11,7 @@ export default class BestPosts extends Component {
<template>
{{#if @report.data.length}}
<div class="rewind-report-page -best-posts">
<div class="rewind-report-page --best-posts">
<h2 class="rewind-report-title">
{{i18n
"discourse_rewind.reports.best_posts.title"
@@ -21,19 +21,21 @@ export default class BestPosts extends Component {
<div class="rewind-report-container">
{{#each @report.data as |post idx|}}
<div class={{concatClass "rewind-card" (this.rankClass idx)}}>
<span class="best-posts -rank"></span>
<span class="best-posts -rank"></span>
<div class="best-posts__post"><p>{{htmlSafe
post.excerpt
}}</p></div>
<span class="best-posts --rank"></span>
<span class="best-posts --rank"></span>
<div class="best-posts__post">
<p>{{htmlSafe post.excerpt}}</p>
</div>
<div class="best-posts__metadata">
<span class="best-posts__likes">
{{icon "heart"}}{{post.like_count}}</span>
{{icon "heart"}}{{post.like_count}}
</span>
<span class="best-posts__replies">
{{icon "comment"}}{{post.reply_count}}</span>
<a href="/t/{{post.topic_id}}/{{post.post_number}}">{{i18n
"discourse_rewind.reports.best_posts.view_post"
}}</a>
{{icon "comment"}}{{post.reply_count}}
</span>
<a href="/t/{{post.topic_id}}/{{post.post_number}}">
{{i18n "discourse_rewind.reports.best_posts.view_post"}}
</a>
</div>
</div>
{{/each}}
@@ -13,7 +13,7 @@ export default class BestTopics extends Component {
<template>
{{#if @report.data.length}}
<div class="rewind-report-page -best-topics">
<div class="rewind-report-page --best-topics">
<h2 class="rewind-report-title">
{{i18n
"discourse_rewind.reports.best_topics.title"
@@ -27,12 +27,12 @@ export default class BestTopics extends Component {
href={{getURL (concat "/t/-/" topic.topic_id)}}
class={{concatClass "best-topics__topic" (this.rankClass idx)}}
>
<span class="best-topics -rank"></span>
<span class="best-topics -rank"></span>
<span class="best-topics --rank"></span>
<span class="best-topics --rank"></span>
<h2 class="best-topics__header">{{topic.title}}</h2>
<span class="best-topics__excerpt">{{replaceEmoji
(htmlSafe topic.excerpt)
}}</span>
<span class="best-topics__excerpt">
{{replaceEmoji (htmlSafe topic.excerpt)}}
</span>
</a>
{{/each}}
</div>
@@ -0,0 +1,161 @@
import Component from "@glimmer/component";
import { concat } from "@ember/helper";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import avatar from "discourse/helpers/avatar";
import number from "discourse/helpers/number";
import { i18n } from "discourse-i18n";
const BotMessage = <template>
<div class="chat-message__avatar">🤖</div>
<div class="chat-message__bubble">
<div class="chat-message__author">
{{i18n "discourse_rewind.reports.chat_usage.bot_name"}}
</div>
{{#if @message}}
<div class="chat-message__text">
{{htmlSafe @message}}
</div>
{{/if}}
{{yield}}
</div>
</template>;
const UserMessage = <template>
<div class="chat-message__bubble">
<div class="chat-message__author">
{{i18n "discourse_rewind.reports.chat_usage.you"}}
</div>
{{#if @replyKey}}
<div class="chat-message__text">
{{i18n (concat "discourse_rewind.reports.chat_usage." @replyKey)}}
</div>
{{/if}}
{{yield}}
</div>
<div class="chat-message__avatar">
{{avatar @user imageSize="small"}}
</div>
</template>;
export default class ChatUsage extends Component {
@service currentUser;
get favoriteChannels() {
return this.args.report.data.favorite_channels ?? [];
}
<template>
<div class="rewind-report-page --chat-usage">
<h2 class="rewind-report-title">{{i18n
"discourse_rewind.reports.chat_usage.title"
}}</h2>
<div class="chat-window">
<div class="chat-window__header">
<span class="chat-window__title">
{{i18n "discourse_rewind.reports.chat_usage.channel_title"}}
</span>
<span class="chat-window__status">
{{i18n "discourse_rewind.reports.chat_usage.status_online"}}
</span>
</div>
<div class="chat-window__messages">
<div class="chat-message --left">
<BotMessage
@message={{i18n
"discourse_rewind.reports.chat_usage.message_1"
count=(number @report.data.total_messages)
}}
/>
</div>
<div class="chat-message --right">
<UserMessage @user={{this.currentUser}} @replyKey="reply_1" />
</div>
<div class="chat-message --left">
<BotMessage
@message={{htmlSafe
(i18n
"discourse_rewind.reports.chat_usage.message_2"
dm_count=(number @report.data.dm_message_count)
channel_count=(number @report.data.unique_dm_channels)
)
}}
/>
</div>
<div class="chat-message --right">
<UserMessage @user={{this.currentUser}} @replyKey="reply_2" />
</div>
<div class="chat-message --left">
<BotMessage
@message={{htmlSafe
(i18n
"discourse_rewind.reports.chat_usage.message_3"
count=(number @report.data.total_reactions_received)
)
}}
/>
</div>
<div class="chat-message --right">
<UserMessage @user={{this.currentUser}} @replyKey="reply_3" />
</div>
<div class="chat-message --left">
<BotMessage
@message={{htmlSafe
(i18n
"discourse_rewind.reports.chat_usage.message_4"
[email protected]_message_length
)
}}
/>
</div>
{{#if this.favoriteChannels.length}}
<div class="chat-message --left">
<BotMessage
@message={{i18n
"discourse_rewind.reports.chat_usage.message_5"
}}
>
<div class="chat-message__channels">
{{#each this.favoriteChannels as |channel|}}
<a
class="chat-channel-link"
href={{concat "/chat/c/-/" channel.channel_id}}
>
<span
class="chat-channel-link__name"
>#{{channel.channel_name}}</span>
<span class="chat-channel-link__count">
{{number channel.message_count}}
</span>
</a>
{{/each}}
</div>
</BotMessage>
</div>
{{/if}}
<div class="chat-message --right">
<UserMessage @user={{this.currentUser}}>
<img
src="/plugins/discourse-rewind/images/dancing_baby.gif"
alt={{i18n
"discourse_rewind.reports.chat_usage.dancing_baby_alt"
}}
class="chat-message__gif"
/>
</UserMessage>
</div>
</div>
</div>
</div>
</template>
}
@@ -0,0 +1,53 @@
import Component from "@glimmer/component";
import icon from "discourse/helpers/d-icon";
import number from "discourse/helpers/number";
import { i18n } from "discourse-i18n";
export default class FavoriteGifs extends Component {
get favoriteGifs() {
return this.args.report.data.favorite_gifs ?? [];
}
<template>
{{#if this.favoriteGifs.length}}
<div class="rewind-report-page --favorite-gifs">
<h2 class="rewind-report-title">{{i18n
"discourse_rewind.reports.favorite_gifs.title"
count=this.favoriteGifs.length
}}</h2>
<div class="rewind-report-subtitle">{{i18n
"discourse_rewind.reports.favorite_gifs.total_usage"
[email protected]_gif_usage
}}</div>
<div class="rewind-report-container">
{{#each this.favoriteGifs as |gif idx|}}
<div class="rewind-card scale">
<div class="favorite-gifs__gif">
<img
src={{gif.url}}
alt="GIF #{{idx}}"
class="favorite-gifs__image"
loading="lazy"
/>
<div class="favorite-gifs__stats">
<span class="favorite-gifs__stat">
{{icon "repeat"}}
{{number gif.usage_count}}
</span>
<span class="favorite-gifs__stat">
{{icon "heart"}}
{{number gif.likes}}
</span>
<span class="favorite-gifs__stat">
{{icon "smile"}}
{{number gif.reactions}}
</span>
</div>
</div>
</div>
{{/each}}
</div>
</div>
{{/if}}
</template>
}
@@ -3,10 +3,10 @@ import avatar from "discourse/helpers/bound-avatar-template";
import { i18n } from "discourse-i18n";
const FBFF = <template>
<div class="rewind-report-page -fbff">
<h2 class="rewind-report-title">{{i18n
"discourse_rewind.reports.fbff.title"
}}</h2>
<div class="rewind-report-page --fbff">
<h2 class="rewind-report-title">
{{i18n "discourse_rewind.reports.fbff.title"}}
</h2>
<div class="rewind-report-container">
<div class="rewind-card">
<div class="fbff-avatar-container">
@@ -0,0 +1,118 @@
import Component from "@glimmer/component";
import { concat } from "@ember/helper";
import avatar from "discourse/helpers/bound-avatar-template";
import number from "discourse/helpers/number";
import { i18n } from "discourse-i18n";
export default class Invites extends Component {
get mostActiveInvitee() {
return this.args.report.data.most_active_invitee;
}
<template>
<div class="rewind-report-page --invites">
<div class="guest-book">
<div class="guest-book__cover">
<div class="guest-book__title">
{{i18n "discourse_rewind.reports.invites.guest_book_title"}}
</div>
<div class="guest-book__subtitle">
{{i18n "discourse_rewind.reports.invites.guest_book_subtitle"}}
</div>
</div>
<div class="guest-book__page">
<div class="guest-book__entry">
<div class="guest-book__entry-label">
{{i18n "discourse_rewind.reports.invites.label_invitations_sent"}}
</div>
<div class="guest-book__entry-value">
{{number @report.data.total_invites}}
</div>
</div>
<div class="guest-book__entry">
<div class="guest-book__entry-label">
{{i18n "discourse_rewind.reports.invites.label_guests_joined"}}
</div>
<div class="guest-book__entry-value">
{{number @report.data.redeemed_count}}
</div>
</div>
<div class="guest-book__entry">
<div class="guest-book__entry-label">
{{i18n "discourse_rewind.reports.invites.label_acceptance_rate"}}
</div>
<div class="guest-book__entry-value">
{{@report.data.redemption_rate}}%
</div>
</div>
<div class="guest-book__divider"></div>
<div class="guest-book__section-title">
{{i18n "discourse_rewind.reports.invites.section_contributions"}}
</div>
<div class="guest-book__entry --small">
<div class="guest-book__entry-label">
{{i18n "discourse_rewind.reports.invites.label_posts_written"}}
</div>
<div class="guest-book__entry-value">
{{number @report.data.invitee_post_count}}
</div>
</div>
<div class="guest-book__entry --small">
<div class="guest-book__entry-label">
{{i18n "discourse_rewind.reports.invites.label_topics_started"}}
</div>
<div class="guest-book__entry-value">
{{number @report.data.invitee_topic_count}}
</div>
</div>
<div class="guest-book__entry --small">
<div class="guest-book__entry-label">
{{i18n "discourse_rewind.reports.invites.label_likes_given"}}
</div>
<div class="guest-book__entry-value">
{{number @report.data.invitee_like_count}}
</div>
</div>
{{#if this.mostActiveInvitee}}
<div class="guest-book__divider"></div>
<div class="guest-book__section-title">
{{i18n "discourse_rewind.reports.invites.section_most_active"}}
</div>
<a
href={{concat "/u/" this.mostActiveInvitee.username}}
class="guest-book__signature"
>
{{avatar
this.mostActiveInvitee.avatar_template
"large"
username=this.mostActiveInvitee.username
name=this.mostActiveInvitee.name
}}
<div class="guest-book__signature-info">
<span class="guest-book__signature-name">
{{this.mostActiveInvitee.username}}
</span>
{{#if this.mostActiveInvitee.name}}
<span class="guest-book__signature-realname">
{{this.mostActiveInvitee.name}}
</span>
{{/if}}
</div>
</a>
{{/if}}
</div>
</div>
</div>
</template>
}
@@ -3,11 +3,13 @@ import { i18n } from "discourse-i18n";
const MostViewedCategories = <template>
{{#if @report.data.length}}
<div class="rewind-report-page -most-viewed-categories">
<h2 class="rewind-report-title">{{i18n
<div class="rewind-report-page --most-viewed-categories">
<h2 class="rewind-report-title">
{{i18n
"discourse_rewind.reports.most_viewed_categories.title"
[email protected]
}}</h2>
}}
</h2>
<div class="rewind-report-container">
{{#each @report.data as |data|}}
<a class="folder-wrapper" href={{concat "/c/-/" data.category_id}}>
@@ -3,7 +3,7 @@ import { i18n } from "discourse-i18n";
const MostViewedTags = <template>
{{#if @report.data.length}}
<div class="rewind-report-page -most-viewed-tags">
<div class="rewind-report-page --most-viewed-tags">
<h2 class="rewind-report-title">{{i18n
"discourse_rewind.reports.most_viewed_tags.title"
[email protected]
@@ -0,0 +1,30 @@
import Component from "@glimmer/component";
import { i18n } from "discourse-i18n";
export default class NewUserInteractions extends Component {
get wavyWords() {
const num = this.args.report.data.unique_new_users;
const memberText = i18n(
"discourse_rewind.reports.new_user_interactions.new_member",
{ count: num }
);
return memberText.split(" ").map((word) => word.split(""));
}
<template>
<div class="rewind-report-page --new-user-interactions">
<div class="wordart-container">
<div class="wordart-text">your contributions helped</div>
<div class="wordart-3d">
{{#each this.wavyWords as |word|}}
<span class="wordart-word">
{{#each word as |char|}}
<span class="wordart-letter">{{char}}</span>
{{/each}}
</span>
{{/each}}
</div>
</div>
</div>
</template>
}
@@ -32,33 +32,33 @@ export default class Reactions extends Component {
}
<template>
<div class="rewind-report-page -post-received-reactions">
<h2 class="rewind-report-title">{{i18n
"discourse_rewind.reports.post_received_reactions.title"
}}</h2>
<div class="rewind-report-page --post-received-reactions">
<h2 class="rewind-report-title">
{{i18n "discourse_rewind.reports.post_received_reactions.title"}}
</h2>
<div class="rewind-report-container">
{{#each-in this.receivedReactions as |emojiName count|}}
<div class="rewind-card scale">
<span class="rewind-card__emoji">{{replaceEmoji
(concat ":" emojiName ":")
}}</span>
<span class="rewind-card__emoji">
{{replaceEmoji (concat ":" emojiName ":")}}
</span>
<span class="rewind-card__data">{{count}}</span>
</div>
{{/each-in}}
</div>
</div>
<div class="rewind-report-page -post-used-reactions">
<h2 class="rewind-report-title">{{i18n
"discourse_rewind.reports.post_used_reactions.title"
}}</h2>
<div class="rewind-report-page --post-used-reactions">
<h2 class="rewind-report-title">
{{i18n "discourse_rewind.reports.post_used_reactions.title"}}
</h2>
<div class="rewind-card">
<div class="rewind-reactions-chart">
{{#each-in @report.data.post_used_reactions as |emojiName count|}}
<div class="rewind-reactions-row">
<span class="emoji">{{replaceEmoji
(concat ":" emojiName ":")
}}</span>
<span class="emoji">
{{replaceEmoji (concat ":" emojiName ":")}}
</span>
<span class="percentage">{{this.computePercentage count}}</span>
<div
class="rewind-reactions-bar"
@@ -19,10 +19,10 @@ export default class ReadingTime extends Component {
<template>
{{#if @report.data}}
<div class="rewind-report-page -reading-time">
<h2 class="rewind-report-title">{{i18n
"discourse_rewind.reports.reading_time.title"
}}</h2>
<div class="rewind-report-page --reading-time">
<h2 class="rewind-report-title">
{{i18n "discourse_rewind.reports.reading_time.title"}}
</h2>
<div class="rewind-card">
<p class="reading-time__text">
{{htmlSafe
@@ -0,0 +1,457 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import DButton from "discourse/components/d-button";
import { i18n } from "discourse-i18n";
export default class TimeOfDayActivity extends Component {
@service currentUser;
@tracked isPlaying = false;
@tracked playbackProgress = 0;
@tracked isGlitching = false;
audioContext = null;
audioSource = null;
playbackTimeout = null;
stereoPanner = null;
animationFrame = null;
hasGlitched = false;
SVG_WIDTH = 1200;
SVG_HEIGHT = 200;
SVG_PADDING = 40;
BIT_CRUSH_STEPS = 5;
get activityByHour() {
return this.args.report?.data?.activity_by_hour ?? {};
}
get mostActiveHour() {
return this.args.report?.data?.most_active_hour ?? 0;
}
get maxActivity() {
const counts = Object.values(this.activityByHour);
return Math.max(...counts, 1);
}
get plotDimensions() {
return {
width: this.SVG_WIDTH,
height: this.SVG_HEIGHT,
padding: this.SVG_PADDING,
plotWidth: this.SVG_WIDTH - this.SVG_PADDING * 2,
plotHeight: this.SVG_HEIGHT - this.SVG_PADDING * 2,
};
}
calculatePoint(hour) {
const { height, padding, plotWidth, plotHeight } = this.plotDimensions;
const count = this.activityByHour[hour] || 0;
const x = padding + (hour / 23) * plotWidth;
const y = height - padding - (count / this.maxActivity) * plotHeight;
return { x, y };
}
get personalizedAudioParams() {
const username = this.currentUser?.username || "default";
// convert username to seed
const hash = (str) => {
let h = 0;
for (let i = 0; i < str.length; i++) {
h = (h * 31 + str.charCodeAt(i)) | 0; // eslint-disable-line no-bitwise
}
return Math.abs(h);
};
const seed = hash(username);
// Three distinct scales for variety
const scales = [
// C minor pentatonic
[
130.81, 155.56, 174.61, 196.0, 233.08, 261.63, 311.13, 349.23, 392.0,
466.16, 523.25,
],
// C major pentatonic
[
130.81, 146.83, 164.81, 196.0, 220.0, 261.63, 293.66, 329.63, 392.0,
440.0, 523.25,
],
// Blues scale
[
130.81, 155.56, 164.81, 174.61, 196.0, 233.08, 261.63, 311.13, 329.63,
349.23, 392.0,
],
];
const harmonyRatios = [1.5, 1.25, 2.0]; // Perfect fifth, major third, octave
return {
scale: scales[seed % scales.length],
harmonyRatio: harmonyRatios[(seed >> 4) % harmonyRatios.length], // eslint-disable-line no-bitwise
};
}
get waveformPath() {
const points = Array.from({ length: 24 }, (_, hour) =>
this.calculatePoint(hour)
);
const tension = 0.3;
let path = `M ${points[0].x} ${points[0].y}`;
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[Math.max(i - 1, 0)];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[Math.min(i + 2, points.length - 1)];
const cp1x = p1.x + ((p2.x - p0.x) / 6) * tension;
const cp1y = p1.y + ((p2.y - p0.y) / 6) * tension;
const cp2x = p2.x - ((p3.x - p1.x) / 6) * tension;
const cp2y = p2.y - ((p3.y - p1.y) / 6) * tension;
path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`;
}
return path;
}
get waveformPoints() {
return Array.from({ length: 24 }, (_, hour) => {
const { x, y } = this.calculatePoint(hour);
const isActive = hour === this.mostActiveHour;
return {
x,
y,
hour,
radius: isActive ? 6 : 2.5,
class: isActive ? "oscilloscope__dot active" : "oscilloscope__dot",
showLabel: hour % 3 === 0,
};
});
}
get gridLines() {
return Array.from({ length: 5 }, (_, i) => ({
y: this.SVG_PADDING + (i * (this.SVG_HEIGHT - 2 * this.SVG_PADDING)) / 4,
style: htmlSafe(`opacity: ${i === 0 || i === 4 ? 0.3 : 0.15}`),
}));
}
get playbackPosition() {
if (!this.isPlaying) {
return null;
}
const { height, padding, plotWidth, plotHeight } = this.plotDimensions;
const currentHour = this.playbackProgress * 23;
const hourIndex = Math.floor(currentHour);
const nextHourIndex = Math.min(hourIndex + 1, 23);
const t = currentHour - hourIndex;
const currentActivity = this.activityByHour[hourIndex] || 0;
const nextActivity = this.activityByHour[nextHourIndex] || 0;
const activity = currentActivity + (nextActivity - currentActivity) * t;
const x = padding + (currentHour / 23) * plotWidth;
const y = height - padding - (activity / this.maxActivity) * plotHeight;
return { x, y };
}
formatHour(hour) {
const hourNum = parseInt(hour, 10);
const period = hourNum >= 12 ? "PM" : "AM";
const displayHour =
hourNum === 0 ? 12 : hourNum > 12 ? hourNum - 12 : hourNum;
return `${displayHour}${period}`;
}
@action
stopWaveform() {
if (this.audioSource) {
this.audioSource.stop();
}
if (this.audioContext) {
this.audioContext.close();
}
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
}
if (this.playbackTimeout) {
clearTimeout(this.playbackTimeout);
}
this.isPlaying = false;
this.playbackProgress = 0;
this.isGlitching = false;
this.hasGlitched = false;
this.audioContext = null;
this.audioSource = null;
this.playbackTimeout = null;
this.stereoPanner = null;
}
@action
async playWaveform() {
if (this.isPlaying) {
this.stopWaveform();
return;
}
this.isPlaying = true;
this.playbackProgress = 0;
try {
this.audioContext = new (window.AudioContext ||
window.webkitAudioContext)();
// Musical timing: 24 hours at 200 BPM
const duration = 7.2; // (24 / 200) * 60 seconds
const sampleRate = this.audioContext.sampleRate;
const numSamples = duration * sampleRate;
const buffer = this.audioContext.createBuffer(1, numSamples, sampleRate);
const channelData = buffer.getChannelData(0);
const params = this.personalizedAudioParams;
const hours = Array.from({ length: 24 }, (_, i) => i);
const samplesPerHour = numSamples / 24;
for (let i = 0; i < numSamples; i++) {
const hourIndex = Math.floor(i / samplesPerHour);
const nextHourIndex = Math.min(hourIndex + 1, 23);
const t = (i % samplesPerHour) / samplesPerHour;
// Get activity for current and next hour
const currentActivity = this.activityByHour[hours[hourIndex]] || 0;
const nextActivity = this.activityByHour[hours[nextHourIndex]] || 0;
// Interpolate between hours
const activity = currentActivity + (nextActivity - currentActivity) * t;
// Map activity to personalized musical scale
const scale = params.scale;
const normalizedActivity = activity / this.maxActivity;
// Map to scale index with smooth interpolation
const scalePosition = normalizedActivity * (scale.length - 1);
const lowerIndex = Math.floor(scalePosition);
const upperIndex = Math.min(lowerIndex + 1, scale.length - 1);
const blend = scalePosition - lowerIndex;
let frequency =
scale[lowerIndex] + (scale[upperIndex] - scale[lowerIndex]) * blend;
const time = i / sampleRate;
// Generate triangle waveform for retro feel
const generateWave = (freq) => {
const sine = Math.sin(2 * Math.PI * freq * time);
return (2 / Math.PI) * Math.asin(sine);
};
// Generate main voice and harmony
const mainWave = generateWave(frequency);
const harmonyWave = generateWave(frequency * params.harmonyRatio);
// Mix voices (70/30 split)
const mixedWave = mainWave * 0.7 + harmonyWave * 0.3;
// Add bit crushing effect for retro feel
const crushed =
Math.round(mixedWave * this.BIT_CRUSH_STEPS) / this.BIT_CRUSH_STEPS;
// Add minimal noise (sparkle at peak hour)
const peakHourTime = (this.mostActiveHour / 23) * duration;
const peakWindow = 0.2;
const isPeakMoment =
Math.abs(time - peakHourTime) < peakWindow && time >= peakHourTime;
const noise = (Math.random() - 0.5) * (isPeakMoment ? 0.08 : 0.02);
// Reduce volume for zero/very low activity sections
const lowActivityVolume = normalizedActivity < 0.05 ? 0.3 : 1;
// Output with all effects applied
channelData[i] = (crushed * 0.15 + noise) * lowActivityVolume;
}
// Create source and play
this.audioSource = this.audioContext.createBufferSource();
this.audioSource.buffer = buffer;
// Add stereo panner (pan from left to right as time progresses)
this.stereoPanner = this.audioContext.createStereoPanner();
this.stereoPanner.pan.value = -1; // Start at left
// Add filter for retro feel
const filter = this.audioContext.createBiquadFilter();
filter.type = "lowpass";
filter.frequency.value = 2500; // Tame the highs
// Simple delay for subtle depth
const delay = this.audioContext.createDelay();
delay.delayTime.value = 0.15; // 150ms delay
const delayGain = this.audioContext.createGain();
delayGain.gain.value = 0.2; // Subtle echo
// Connect audio graph: source -> filter -> panner + delay feedback
this.audioSource.connect(filter);
filter.connect(this.stereoPanner);
// Add subtle delay feedback
filter.connect(delay);
delay.connect(delayGain);
delayGain.connect(this.stereoPanner);
this.stereoPanner.connect(this.audioContext.destination);
this.audioSource.start();
// Animate playback progress and stereo panning
const startTime = Date.now();
const animate = () => {
const elapsed = (Date.now() - startTime) / 1000;
this.playbackProgress = Math.min(elapsed / duration, 1);
// Pan from left (-1) to right (1) as we progress
if (this.stereoPanner) {
this.stereoPanner.pan.value = -1 + this.playbackProgress * 2;
}
// Trigger visual glitch when hitting peak hour
const peakHourProgress = this.mostActiveHour / 23;
if (
!this.hasGlitched &&
this.playbackProgress >= peakHourProgress &&
this.playbackProgress < peakHourProgress + 0.05
) {
this.isGlitching = true;
this.hasGlitched = true;
setTimeout(() => {
this.isGlitching = false;
}, 200);
}
if (this.playbackProgress < 1) {
this.animationFrame = requestAnimationFrame(animate);
}
};
this.animationFrame = requestAnimationFrame(animate);
// Reset playing state when done
this.playbackTimeout = setTimeout(() => {
this.stopWaveform();
}, duration * 1000);
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error playing waveform:", error);
this.stopWaveform();
}
}
<template>
<div class="rewind-report-page --time-of-day-activity">
<h2 class="rewind-report-title">{{i18n
"discourse_rewind.reports.time_of_day_activity.title"
}}
</h2>
<div class="rewind-card">
<div
class="time-of-day__oscilloscope
{{if this.isGlitching '--glitching'}}"
>
<DButton
@action={{this.playWaveform}}
@icon={{if this.isPlaying "volume-xmark" "volume-high"}}
class="oscilloscope__play-btn {{if this.isPlaying '--playing'}}"
@title={{if
this.isPlaying
"discourse_rewind.reports.time_of_day_activity.stop_button"
"discourse_rewind.reports.time_of_day_activity.play_button"
}}
/>
<svg viewBox="0 0 1200 200" class="oscilloscope__svg">
<defs>
<filter id="glow">
<feGaussianBlur stdDeviation="2" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="glow-strong">
<feGaussianBlur stdDeviation="4" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{{#each this.gridLines as |gridLine|}}
<line
x1="40"
y1={{gridLine.y}}
x2="1160"
y2={{gridLine.y}}
class="oscilloscope__grid-line"
style={{gridLine.style}}
/>
{{/each}}
{{#each this.waveformPoints as |point|}}
{{#if point.showLabel}}
<line
x1={{point.x}}
y1="40"
x2={{point.x}}
y2="160"
class="oscilloscope__grid-line --vertical"
/>
<text
x={{point.x}}
y="180"
class="oscilloscope__time-label"
>{{this.formatHour point.hour}}</text>
{{/if}}
{{/each}}
<path d={{this.waveformPath}} class="oscilloscope__waveform" />
{{#each this.waveformPoints as |point|}}
<circle
cx={{point.x}}
cy={{point.y}}
r={{point.radius}}
class={{point.class}}
/>
{{/each}}
{{#if this.playbackPosition}}
<circle
cx={{this.playbackPosition.x}}
cy={{this.playbackPosition.y}}
r="12"
class="oscilloscope__playback-dot"
/>
{{/if}}
</svg>
</div>
</div>
</div>
</template>
}
@@ -1,23 +1,28 @@
import Component from "@glimmer/component";
import { i18n } from "discourse-i18n";
import WordCard from "discourse/plugins/discourse-rewind/discourse/components/reports/top-words/word-card";
const WordCards = <template>
<div class="rewind-report-page -top-words">
<div class="rewind-report-container">
<h2 class="rewind-report-title">{{i18n
"discourse_rewind.reports.top_words.title"
}}</h2>
<div class="cards-container">
{{#each @report.data as |entry index|}}
<WordCard
@word={{entry.word}}
@count={{entry.score}}
@index={{index}}
/>
{{/each}}
export default class WordCards extends Component {
get topWords() {
return this.args.report.data.sort((a, b) => b.score - a.score).slice(0, 5);
}
<template>
<div class="rewind-report-page --top-words">
<div class="rewind-report-container">
<h2 class="rewind-report-title">{{i18n
"discourse_rewind.reports.top_words.title"
}}</h2>
<div class="cards-container">
{{#each this.topWords as |entry index|}}
<WordCard
@word={{entry.word}}
@count={{entry.score}}
@index={{index}}
/>
{{/each}}
</div>
</div>
</div>
</div>
</template>;
export default WordCards;
</template>
}
@@ -79,25 +79,25 @@ export default class WordCard extends Component {
{{on "mouseleave" this.handleLeave}}
class={{concatClass
"rewind-card__wrapper"
(if this.longWord "-long-word")
(if this.longWord "--long-word")
}}
style={{this.cardStyle}}
{{didInsert this.registerCardContainer}}
role="button"
>
<div class="rewind-card__inner">
<div class="rewind-card -front">
<span class="rewind-card__image tl">{{emoji
this.mysteryData.emoji
}}</span>
<span class="rewind-card__image cr">{{emoji
this.mysteryData.emoji
}}</span>
<span class="rewind-card__image br">{{emoji
this.mysteryData.emoji
}}</span>
<div class="rewind-card --front">
<span class="rewind-card__image tl">
{{emoji this.mysteryData.emoji}}
</span>
<span class="rewind-card__image cr">
{{emoji this.mysteryData.emoji}}
</span>
<span class="rewind-card__image br">
{{emoji this.mysteryData.emoji}}
</span>
</div>
<div class="rewind-card -back">
<div class="rewind-card --back">
<span class="rewind-card__title">{{@word}}</span>
<span class="rewind-card__data">{{@count}}x</span>
</div>
@@ -0,0 +1,172 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { i18n } from "discourse-i18n";
export default class WritingAnalysis extends Component {
@tracked currentColorIndex = 0;
terminalColors = ["#0f0", "#ffbf00", "#00ffff", "#ff00ff"];
constructor() {
super(...arguments);
this.handleKeyDown = this.handleKeyDown.bind(this);
}
@action
setupKeyListener(element) {
document.addEventListener("keydown", this.handleKeyDown);
this.element = element;
}
@action
teardownKeyListener() {
document.removeEventListener("keydown", this.handleKeyDown);
}
handleKeyDown(event) {
if (event.key === "F1") {
event.preventDefault();
this.cycleColor();
}
}
@action
cycleColor() {
this.currentColorIndex =
(this.currentColorIndex + 1) % this.terminalColors.length;
const newColor = this.terminalColors[this.currentColorIndex];
document.documentElement.style.setProperty("--rewind-green", newColor);
}
get scoreLabel() {
const score = this.args.report.data.readability_score;
const randomNum = Math.floor(Math.random() * 4) + 1;
switch (true) {
case score >= 80 && score <= 100:
return i18n(
`discourse_rewind.reports.writing_analysis.readability_score.over_80.${randomNum}`
);
case score >= 60 && score < 80:
return i18n(
`discourse_rewind.reports.writing_analysis.readability_score.over_60.${randomNum}`
);
case score >= 40 && score < 60:
return i18n(
`discourse_rewind.reports.writing_analysis.readability_score.over_40.${randomNum}`
);
case score >= 20 && score < 40:
return i18n(
`discourse_rewind.reports.writing_analysis.readability_score.over_20.${randomNum}`
);
default:
return i18n(
`discourse_rewind.reports.writing_analysis.readability_score.over_0.${randomNum}`
);
}
}
<template>
<div
class="rewind-report-page --writing-analysis"
{{didInsert this.setupKeyListener}}
{{willDestroy this.teardownKeyListener}}
>
<h2 class="rewind-report-title">
{{i18n "discourse_rewind.reports.writing_analysis.title"}}
</h2>
<div class="writing-analysis">
<div class="writing-analysis__menubar">
<span class="writing-analysis__menu-item">{{i18n
"discourse_rewind.reports.writing_analysis.menu_file"
}}</span>
<span class="writing-analysis__menu-item">{{i18n
"discourse_rewind.reports.writing_analysis.menu_other"
}}</span>
<span class="writing-analysis__menu-item">{{i18n
"discourse_rewind.reports.writing_analysis.menu_additional"
}}</span>
<span
class="writing-analysis__menu-item writing-analysis__menu-item--right"
>{{i18n
"discourse_rewind.reports.writing_analysis.menu_opening"
}}</span>
</div>
<div class="writing-analysis__frame">
<div class="writing-analysis__header-row">
<div class="writing-analysis__helpbox">
{{i18n "discourse_rewind.reports.writing_analysis.help_text"}}
</div>
<div class="writing-analysis__release">
<div class="writing-analysis__release-name">{{i18n
"discourse_rewind.reports.writing_analysis.app_name"
}}</div>
<div class="writing-analysis__release-meta">
{{i18n
"discourse_rewind.reports.writing_analysis.release_info"
}}
<span>&lt;3</span>
</div>
</div>
</div>
<div class="writing-analysis__stats">
<div class="writing-analysis__stats-col">
<div class="writing-analysis__stats-label">{{i18n
"discourse_rewind.reports.writing_analysis.total_words"
}}</div>
<div
class="writing-analysis__stats-value"
>{{@report.data.total_words}}</div>
<div class="writing-analysis__stats-label">{{i18n
"discourse_rewind.reports.writing_analysis.total_posts"
}}</div>
<div
class="writing-analysis__stats-value"
>{{@report.data.total_posts}}</div>
</div>
<div class="writing-analysis__stats-col">
<div class="writing-analysis__stats-label">{{i18n
"discourse_rewind.reports.writing_analysis.avg_post_length"
}}</div>
<div
class="writing-analysis__stats-value"
>{{@report.data.average_post_length}}</div>
<div class="writing-analysis__stats-label">{{i18n
"discourse_rewind.reports.writing_analysis.readability_score_label"
}}</div>
<div
class="writing-analysis__stats-value"
>{{@report.data.readability_score}}</div>
</div>
<div class="writing-analysis__stats-col">
<div class="writing-analysis__stats-label">{{i18n
"discourse_rewind.reports.writing_analysis.readability_level"
}}</div>
<div
class="writing-analysis__stats-value"
>{{this.scoreLabel}}</div>
</div>
</div>
</div>
</div>
</div>
</template>
}
@@ -7,18 +7,25 @@ import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { eq } from "discourse/truth-helpers";
import { i18n } from "discourse-i18n";
import ActivityCalendar from "discourse/plugins/discourse-rewind/discourse/components/reports/activity-calendar";
import AiUsage from "discourse/plugins/discourse-rewind/discourse/components/reports/ai-usage";
import Assignments from "discourse/plugins/discourse-rewind/discourse/components/reports/assignments";
import BestPosts from "discourse/plugins/discourse-rewind/discourse/components/reports/best-posts";
import BestTopics from "discourse/plugins/discourse-rewind/discourse/components/reports/best-topics";
import ChatUsage from "discourse/plugins/discourse-rewind/discourse/components/reports/chat-usage";
// import FavoriteGifs from "discourse/plugins/discourse-rewind/discourse/components/reports/favorite-gifs";
import FBFF from "discourse/plugins/discourse-rewind/discourse/components/reports/fbff";
import RewindHeader from "discourse/plugins/discourse-rewind/discourse/components/reports/header";
import Invites from "discourse/plugins/discourse-rewind/discourse/components/reports/invites";
import MostViewedCategories from "discourse/plugins/discourse-rewind/discourse/components/reports/most-viewed-categories";
import MostViewedTags from "discourse/plugins/discourse-rewind/discourse/components/reports/most-viewed-tags";
import NewUserInteractions from "discourse/plugins/discourse-rewind/discourse/components/reports/new-user-interactions";
import Reactions from "discourse/plugins/discourse-rewind/discourse/components/reports/reactions";
import ReadingTime from "discourse/plugins/discourse-rewind/discourse/components/reports/reading-time";
import TimeOfDayActivity from "discourse/plugins/discourse-rewind/discourse/components/reports/time-of-day-activity";
import TopWords from "discourse/plugins/discourse-rewind/discourse/components/reports/top-words";
import WritingAnalysis from "discourse/plugins/discourse-rewind/discourse/components/reports/writing-analysis";
export default class Rewind extends Component {
@tracked rewind = [];
@@ -66,11 +73,52 @@ export default class Rewind extends Component {
this.rewindContainer = element;
}
getReportComponent(identifier) {
switch (identifier) {
case "fbff":
return FBFF;
case "reactions":
return Reactions;
case "top-words":
return TopWords;
case "best-posts":
return BestPosts;
case "best-topics":
return BestTopics;
case "activity-calendar":
return ActivityCalendar;
case "most-viewed-tags":
return MostViewedTags;
case "reading-time":
return ReadingTime;
case "most-viewed-categories":
return MostViewedCategories;
case "ai-usage":
return AiUsage;
case "assignments":
return Assignments;
case "chat-usage":
return ChatUsage;
// case "favorite-gifs":
// return FavoriteGifs;
case "invites":
return Invites;
case "new-user-interactions":
return NewUserInteractions;
case "time-of-day-activity":
return TimeOfDayActivity;
case "writing-analysis":
return WritingAnalysis;
default:
return null;
}
}
<template>
<div
class={{concatClass
"rewind-container"
(if this.fullScreen "-fullscreen")
(if this.fullScreen "--fullscreen")
}}
{{didInsert this.loadRewind}}
{{on "keydown" this.handleEscape}}
@@ -99,27 +147,16 @@ export default class Rewind extends Component {
>
{{#each this.rewind as |report|}}
<div class={{concatClass "rewind-report" report.identifier}}>
{{#if (eq report.identifier "fbff")}}
<FBFF @report={{report}} />
{{else if (eq report.identifier "reactions")}}
<Reactions @report={{report}} />
{{else if (eq report.identifier "top-words")}}
<TopWords @report={{report}} />
{{else if (eq report.identifier "best-posts")}}
<BestPosts @report={{report}} />
{{else if (eq report.identifier "best-topics")}}
<BestTopics @report={{report}} />
{{else if (eq report.identifier "activity-calendar")}}
<ActivityCalendar @report={{report}} />
{{else if (eq report.identifier "most-viewed-tags")}}
<MostViewedTags @report={{report}} />
{{else if (eq report.identifier "reading-time")}}
<ReadingTime @report={{report}} />
{{else if (eq report.identifier "most-viewed-categories")}}
<MostViewedCategories @report={{report}} />
{{#let
(this.getReportComponent report.identifier)
as |ReportComponent|
}}
{{#if ReportComponent}}
<div class={{concatClass "rewind-report" report.identifier}}>
<ReportComponent @report={{report}} />
</div>
{{/if}}
</div>
{{/let}}
{{/each}}
</div>
+7
View File
@@ -13,6 +13,13 @@
@import "fonts";
@import "reading-time";
@import "fbff";
@import "writing-analysis";
@import "rewind-header";
@import "rewind-callout";
@import "folder-styles";
@import "time-of-day-activity";
@import "chat-usage";
@import "new-user-interactions";
@import "ai-usage";
@import "assignments";
@import "invites";
@@ -1,17 +1,21 @@
.-activity-calendar {
margin-bottom: 5em;
.--activity-calendar {
margin-bottom: 2em;
.rewind-report-title {
border: none;
width: 100%;
text-align: center;
box-sizing: border-box;
}
.rewind-card {
@include rewind-border;
@media screen and (width <= 475px) {
padding: 0.5em;
}
padding: 0;
}
.rewind-calendar {
border-collapse: unset;
border-spacing: 3px;
width: 100%;
@media screen and (width <= 475px) {
border-spacing: 1px;
@@ -38,20 +42,32 @@
width: 5px;
}
&.-empty {
&.--empty {
background: var(--primary-low);
}
&.-low {
background: var(--success);
&.--low {
background: color-mix(in srgb, var(--rewind-green) 40%, transparent);
}
&.-medium {
background: var(--success-medium);
&.--medium {
background: color-mix(in srgb, var(--rewind-green) 70%, transparent);
}
&.-high {
background: var(--success-low);
&.--high {
background: var(--rewind-green);
animation: pulse-high 2s ease-in-out infinite;
}
}
}
@keyframes pulse-high {
0%,
100% {
box-shadow: 0 0 0 rgb(0, 255, 0, 0);
}
50% {
box-shadow: 0 0 8px 2px var(--rewind-green);
}
}
+112
View File
@@ -0,0 +1,112 @@
.--ai-usage {
.rewind-report-title {
display: none;
}
.matrix-container {
position: relative;
min-height: 600px;
background: var(--rewind-black);
overflow: hidden;
border-radius: 0;
outline: inset 1px solid var(--rewind-green);
outline-offset: 5px;
border: 2px solid var(--rewind-green);
box-shadow: 0 0 20px 10px rgb(0 255 0 / 0.2);
}
.matrix-rain {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.3;
}
.matrix-content {
position: relative;
z-index: 1;
padding: 3em 1em 1em;
color: var(--rewind-green);
font-family: "Courier New", monospace;
}
.matrix-title {
font-size: 32px;
font-weight: bold;
text-align: center;
margin-bottom: 2em;
letter-spacing: 4px;
text-shadow: 0 0 5px var(--rewind-green);
.matrix-subhead {
font-size: var(--font-down-5);
font-weight: normal;
}
}
.matrix-stats {
display: flex;
justify-content: space-around;
margin-bottom: 3em;
flex-wrap: wrap;
gap: 2em;
}
.matrix-stat {
text-align: center;
flex: 1;
min-width: 150px;
}
.matrix-stat__label {
font-size: 12px;
letter-spacing: 2px;
margin-bottom: 0.5em;
opacity: 0.8;
}
.matrix-stat__value {
font-size: 36px;
font-weight: bold;
text-shadow: 0 0 5px var(--rewind-green);
}
.matrix-section {
margin-bottom: 2em;
}
.matrix-section__title {
font-size: 18px;
font-weight: bold;
margin-bottom: 1em;
letter-spacing: 2px;
text-shadow: 0 0 5px var(--rewind-green);
}
.matrix-list {
display: flex;
flex-direction: column;
gap: 0.75em;
}
.matrix-list__item {
display: flex;
justify-content: space-between;
padding: 0.75em 1em;
background: rgb(0, 255, 0, 0.05);
border: 1px solid rgb(0, 255, 0, 0.3);
border-radius: 4px;
}
.matrix-list__name {
text-transform: uppercase;
letter-spacing: 1px;
}
.matrix-list__count {
font-weight: bold;
text-shadow: 0 0 5px var(--rewind-green);
}
}
+107
View File
@@ -0,0 +1,107 @@
.--assignments {
.sticky-board {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
padding: 0;
margin: 1em 0;
z-index: 9; // above the scanlines
}
.sticky-note {
width: 180px;
height: 180px;
box-shadow: 3px 3px 8px rgb(0 0 0 / 0.2);
position: relative;
font-family: "Brush Script MT", cursive, sans-serif;
transition: transform 0.2s ease;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 30px;
background: linear-gradient(to bottom, rgb(0 0 0 / 0.05), transparent);
z-index: 1;
}
&.--yellow {
background: linear-gradient(to bottom, #fef9c3 0%, #fde68a 100%);
}
&.--pink {
background: linear-gradient(to bottom, #fecaca 0%, #fca5a5 100%);
&::after {
content: "";
position: absolute;
bottom: 0;
right: 0;
width: 0;
height: 0;
border-style: solid;
border-width: 0 0 20px 20px;
border-color: transparent transparent #000 transparent;
box-shadow: -2px 2px 4px rgb(0 0 0 / 0.2);
}
}
&.--blue {
background: linear-gradient(to bottom, #bfdbfe 0%, #93c5fd 100%);
}
&.--green {
background: linear-gradient(to bottom, #bbf7d0 0%, #86efac 100%);
}
&.--orange {
background: linear-gradient(to bottom, #fed7aa 0%, #fdba74 100%);
}
&.--rotate-left {
transform: rotate(-4deg);
}
&.--rotate-right {
transform: rotate(3deg);
}
&.--rotate-left-small {
transform: rotate(-2deg);
}
&.--rotate-right-small {
transform: rotate(6deg);
}
}
.sticky-note__content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
text-align: center;
}
.sticky-note__title {
font-weight: bold;
color: #333;
margin-bottom: 12px;
line-height: 1;
padding: 0 5em;
}
.sticky-note__value {
font-size: 36px;
font-weight: bold;
color: #1a1a1a;
.number {
font-family: inherit;
}
}
}
+9 -11
View File
@@ -1,4 +1,6 @@
.-best-posts {
.--best-posts {
margin-bottom: 1em;
.rewind-report-container {
display: grid;
grid-template-columns: repeat(3, calc(32% - (1em / 3)));
@@ -16,10 +18,15 @@
text-align: center;
box-sizing: border-box;
border: none;
margin-bottom: 1em;
@media screen and (width >= 550px) {
margin-bottom: 1em;
}
}
.rewind-card {
border-radius: 0;
min-width: 0;
box-sizing: border-box;
padding: 0.5em;
position: relative;
@@ -77,15 +84,6 @@
@media screen and (width <= 475px) {
max-height: 300px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
p {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
.-best-topics {
.--best-topics {
padding-bottom: 0;
margin-top: 2em;
.rewind-report-container {
flex-direction: column;
+167
View File
@@ -0,0 +1,167 @@
.chat-usage {
margin-inline: 2em;
}
.--chat-usage {
box-shadow:
inset -1px -1px #0a0a0a,
inset 1px 1px #dfdfdf,
inset -2px -2px var(--rewind-grey),
inset 2px 2px var(--rewind-white);
padding: 2px;
max-width: 660px;
transform: rotate(0.25deg);
margin-bottom: 2em;
.rewind-report-title {
display: none;
}
.chat-window {
overflow: hidden;
width: 100%;
margin: 0 auto;
overflow-y: auto;
max-height: 400px;
background: var(--rewind-light-grey);
}
.chat-window__header {
background: var(--rewind-blue);
color: var(--rewind-white);
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
font-weight: bold;
position: sticky;
top: 0;
z-index: 1;
}
.chat-window__title {
font-size: 16px;
}
.chat-window__status {
font-size: 12px;
opacity: 0.9;
}
.chat-window__messages {
padding: 1em 0.5em;
display: flex;
flex-direction: column;
gap: 16px;
min-height: 400px;
}
.chat-message {
display: flex;
gap: 8px;
min-width: 100%;
&.--left {
flex-direction: row;
align-self: flex-start;
}
&.--right {
align-self: flex-end;
justify-content: end;
.chat-message__author {
text-align: right;
}
}
}
.chat-message__avatar {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.chat-message__bubble {
max-width: 70%;
padding: 10px 14px;
box-shadow:
inset -1px -1px #0a0a0a,
inset 1px 1px #dfdfdf,
inset -2px -2px var(--rewind-grey),
inset 2px 2px var(--rewind-white);
.chat-message.--left & {
background: var(--rewind-grey);
border-bottom-left-radius: 4px;
}
.chat-message.--right & {
background: var(--rewind-magenta);
border-bottom-right-radius: 4px;
}
}
.chat-message__author {
font-size: 11px;
font-weight: bold;
margin-bottom: 4px;
opacity: 0.7;
color: var(--rewind-grey);
}
.chat-message__text {
font-size: 14px;
line-height: 1.4;
color: var(--rewind-black);
strong {
color: var(--rewind-blue);
font-weight: bold;
}
}
.chat-message__gif {
max-width: 200px;
display: block;
}
.chat-message__channels {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chat-channel-link {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border-radius: 8px;
text-decoration: none;
transition: all 0.2s ease;
border: 1px solid var(--rewind-blue);
gap: 0.5em;
font-size: var(--font-down-2);
&:hover {
background: var(--rewind-white);
border-color: var(--rewind-blue);
}
}
.chat-channel-link__name {
font-weight: bold;
color: var(--rewind-blue);
}
.chat-channel-link__count {
font-size: 12px;
color: var(--rewind-blue);
}
}
+3 -1
View File
@@ -1,5 +1,6 @@
.-fbff {
.--fbff {
--border-size: 6px;
margin-block: 1em;
@media screen and (width <= 768px) {
--border-size: 4px;
@@ -68,6 +69,7 @@
justify-self: center;
width: 75px;
height: 75px;
border-radius: 0;
@media screen and (width <= 768px) {
width: 50px;
+2 -2
View File
@@ -1,5 +1,5 @@
.-most-viewed-tags,
.-most-viewed-categories {
.--most-viewed-tags,
.--most-viewed-categories {
.rewind-card {
@include rewind-border;
flex-grow: 1;
+221
View File
@@ -0,0 +1,221 @@
.--invites {
margin-bottom: 10em;
.guest-book {
max-width: 700px;
width: 100%;
margin: 2em auto;
background: #c0c0c0;
border: 4px outset #dfdfdf;
box-shadow: 5px 5px 0 rgb(0 0 0 / 0.2);
}
.guest-book__cover {
padding: 2em;
text-align: center;
background: linear-gradient(135deg, #f0f 0%, #0ff 100%);
border-bottom: 4px ridge #fff;
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image: repeating-linear-gradient(
45deg,
transparent,
transparent 10px,
rgb(255 255 255 / 0.1) 10px,
rgb(255 255 255 / 0.1) 20px
);
pointer-events: none;
}
}
.guest-book__title {
font-family: "Comic Sans MS", "Comic Sans", cursive;
font-size: 48px;
color: #ff0;
margin-bottom: 0.25em;
text-shadow:
3px 3px 0 #f0f,
-1px -1px 0 #000,
1px -1px 0 #000,
-1px 1px 0 #000,
1px 1px 0 #000;
font-weight: bold;
animation: rainbow-text 3s linear infinite;
}
@keyframes rainbow-text {
0% {
color: #ff0;
}
16% {
color: #0f0;
}
33% {
color: #0ff;
}
50% {
color: #f0f;
}
66% {
color: #f00;
}
83% {
color: #ff0;
}
100% {
color: #0f0;
}
}
.guest-book__subtitle {
font-family: Arial, sans-serif;
font-size: 16px;
font-weight: bold;
color: #fff;
letter-spacing: 3px;
text-shadow: 2px 2px 0 #000;
text-transform: uppercase;
}
.guest-book__page {
padding: 2em;
background: #fff;
position: relative;
}
.guest-book__entry {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1em;
padding: 0.75em;
background: linear-gradient(to right, #ffd700 0%, #ff69b4 100%);
border: 3px ridge #fff;
font-family: Arial, sans-serif;
font-size: 14px;
font-weight: bold;
&.--small {
background: linear-gradient(to right, #87ceeb 0%, #98fb98 100%);
font-size: 13px;
padding: 0.5em 0.75em;
}
}
.guest-book__entry-label {
color: #000;
text-shadow: 1px 1px 0 rgb(255 255 255 / 0.5);
}
.guest-book__entry-value {
color: #000;
font-size: 1.2em;
background: #fff;
padding: 0.25em 0.5em;
border: 2px inset #c0c0c0;
.number {
font-family: inherit;
}
}
.guest-book__divider {
height: 3px;
background: repeating-linear-gradient(
to right,
#f00 0,
#f00 10px,
#ff0 10px,
#ff0 20px,
#0f0 20px,
#0f0 30px,
#0ff 30px,
#0ff 40px,
#00f 40px,
#00f 50px,
#f0f 50px,
#f0f 60px
);
margin: 1.5em 0;
border: 1px solid #000;
}
.guest-book__section-title {
font-family: "Comic Sans MS", "Comic Sans", cursive;
font-size: 24px;
font-weight: bold;
color: #f0f;
margin-bottom: 1em;
text-align: center;
text-shadow: 2px 2px 0 #0ff;
text-decoration: underline;
}
.guest-book__signature {
display: flex;
align-items: center;
gap: 1em;
padding: 1em;
background: #ff0;
border: 4px double #000;
text-decoration: none;
box-shadow:
inset 0 0 0 2px #f0f,
inset 0 0 0 4px #0ff;
&:hover {
background: #ff9;
animation: blink 0.5s infinite;
}
.avatar {
flex-shrink: 0;
border: 3px solid #f0f;
}
}
@keyframes blink {
0%,
100% {
background: #ff0;
}
50% {
background: #f0f;
}
}
.guest-book__signature-info {
display: flex;
flex-direction: column;
gap: 0.25em;
}
.guest-book__signature-name {
font-family: "Comic Sans MS", "Comic Sans", cursive;
font-size: 22px;
color: #f0f;
font-weight: bold;
text-shadow: 1px 1px 0 #0ff;
}
.guest-book__signature-realname {
font-family: Arial, sans-serif;
font-size: 14px;
color: #000;
font-style: italic;
}
}
@@ -1,4 +1,4 @@
.-most-viewed-categories {
.--most-viewed-categories {
.rewind-report-container {
display: flex;
gap: 0.5em;
@@ -1,4 +1,4 @@
.-most-viewed-tags {
.--most-viewed-tags {
.rewind-report-title {
box-sizing: border-box;
border: none;
@@ -0,0 +1,77 @@
.--new-user-interactions {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1em;
.wordart-container {
text-align: center;
margin: 2em 0 2.5em;
}
.wordart-text {
font-size: 32px;
color: var(--rewind-white);
font-family: "Comic Sans MS", cursive, sans-serif;
}
.wordart-3d {
font-size: 64px;
font-weight: 900;
font-family: Impact, Arial, sans-serif;
text-transform: uppercase;
display: inline-block;
transform: rotate(-3deg);
line-height: 1;
}
.wordart-word {
display: inline-block;
white-space: nowrap;
margin-right: 0.75em;
}
.wordart-letter {
font-family: impact, arial, sans-serif;
font-weight: bold;
display: inline-block;
color: #0ff;
letter-spacing: -0.4em;
text-shadow:
1px 1px 0 #00d,
2px 2px 0 #00d,
3px 3px 0 #00b,
4px 4px 0 #00b,
5px 5px 0 #009,
6px 6px 0 #009,
7px 7px 0 #007,
8px 8px 0 #007,
9px 9px 0 #005,
10px 10px 0 #005,
11px 11px 0 #003,
12px 12px 0 #003,
13px 13px 0 #001,
14px 14px 0 #001,
15px 15px 20px rgb(0 0 0 / 0.5);
animation: wordart-wave 3s ease-in-out infinite;
}
// Wave pattern using nth-child
@for $i from 1 through 30 {
.wordart-letter:nth-child(#{$i}) {
animation-delay: #{$i * 0.05}s;
transform: translateY(#{sin($i * 0.5) * 25}px);
}
}
}
@keyframes wordart-wave {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-35px);
}
}
@@ -1,4 +1,6 @@
.-post-received-reactions {
.--post-received-reactions {
margin-block: 1em;
.rewind-report-container {
align-items: center;
justify-content: space-between;
@@ -1,6 +1,8 @@
.-post-used-reactions {
.--post-used-reactions {
.rewind-card {
@include rewind-border;
border-radius: 0 var(--rewind-border-radius) var(--rewind-border-radius)
var(--rewind-border-radius);
}
.rewind-reactions-chart {
@@ -125,6 +125,8 @@ p.reading-time__text {
--book-color: var(--primary-very-high);
@include rewind-border;
border-radius: 0 var(--rewind-border-radius) var(--rewind-border-radius)
var(--rewind-border-radius);
@media screen and (width <= 475px) {
padding: 0.75em;
+1 -1
View File
@@ -28,7 +28,7 @@
align-self: start;
padding: 0.25em 1em 0.25em;
margin-bottom: -2px;
background: #111;
background: var(--rewind-black);
margin-left: 0;
position: relative;
font-size: var(--font-down-1);
@@ -8,6 +8,10 @@
.rewind-callout__container {
height: 47px;
border-bottom: 1px solid var(--primary-low);
@media screen and (width <= 639px) {
height: 40px;
}
}
#rewind-vhs {
@@ -46,6 +50,10 @@
top: 0.75em;
right: 1em;
font-size: var(--font-down-3);
@media screen and (width <= 639px) {
top: 0.45em;
}
}
}
+6 -6
View File
@@ -1,30 +1,30 @@
.rewind-logo {
height: 40px;
&.-light {
&.--light {
display: none;
}
&.-dark {
&.--dark {
display: block;
}
@media (prefers-color-scheme: dark) {
&.-light {
&.--light {
display: block;
}
&.-dark {
&.--dark {
display: none;
}
}
@if is-dark-color-scheme() {
&.-dark {
&.--dark {
display: none;
}
&.-light {
&.--light {
display: block;
}
}
+5 -1
View File
@@ -11,7 +11,11 @@
font-size: 24px;
}
&.-fullscreen {
&:not(.--fullscreen) {
height: 65vh;
}
&.--fullscreen {
top: 0;
left: 0;
right: 0;
@@ -0,0 +1,247 @@
@import "variables";
.time-of-day__oscilloscope {
background: var(--rewind-black);
position: relative;
overflow: hidden;
width: 100%;
margin-bottom: 1em;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(
ellipse at center,
rgb(0, 255, 0, 0.02) 0%,
transparent 70%
);
pointer-events: none;
}
&.--glitching {
svg {
animation: peak-glitch 0.2s step-end;
}
}
}
.oscilloscope__play-btn {
position: absolute;
top: 10px;
right: 10px;
z-index: 10;
background: rgb(0, 0, 0, 0.7);
border: 1px solid var(--rewind-green);
color: var(--rewind-green);
padding: 0.5em;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
.discourse-no-touch & {
&:hover {
color: var(--rewind-white);
background: rgb(0, 255, 0, 0.2) !important;
box-shadow: 0 0 10px var(--rewind-green);
.d-icon {
color: var(--rewind-white);
}
}
}
&.--playing {
background: rgb(0, 255, 0, 0.3);
animation: audio-pulse 0.5s ease-in-out infinite;
.d-icon {
color: var(--rewind-magenta);
}
}
.d-icon {
color: var(--rewind-green);
margin: 0;
}
}
@keyframes audio-pulse {
0%,
100% {
box-shadow: 0 0 5px var(--rewind-green);
}
50% {
box-shadow: 0 0 10px var(--rewind-green);
}
}
.oscilloscope__svg {
width: 100%;
height: auto;
display: block;
}
.oscilloscope__grid-line {
stroke: var(--rewind-green);
stroke-width: 0.5;
opacity: 0.2;
&.--vertical {
opacity: 0.15;
}
}
.oscilloscope__time-label {
fill: var(--rewind-green);
font-size: 16px;
text-anchor: middle;
font-family: monospace;
opacity: 0.6;
}
.oscilloscope__waveform {
fill: none;
stroke: var(--rewind-green);
stroke-width: 2;
filter: url("#glow");
animation: pulse-waveform 3s ease-in-out infinite;
}
.oscilloscope__dot {
fill: var(--rewind-green);
filter: url("#glow");
opacity: 0.8;
transform-origin: center;
transform-box: fill-box;
&.active {
fill: var(--rewind-yellow);
filter: url("#glow-strong");
opacity: 1;
animation: pulse-dot 2s ease-in-out infinite;
}
}
.oscilloscope__playback-dot {
fill: var(--rewind-magenta);
filter: url("#glow-strong");
opacity: 1;
pointer-events: none;
}
@keyframes pulse-waveform {
0%,
100% {
opacity: 0.9;
}
50% {
opacity: 1;
}
}
@keyframes peak-glitch {
0% {
transform: translate(0, 0);
}
10% {
transform: translate(-10px, 5px);
filter: blur(2px);
opacity: 0.5;
}
20% {
transform: translate(12px, -8px);
filter: blur(1px);
opacity: 0.8;
}
30% {
transform: translate(-8px, 3px);
filter: blur(3px);
opacity: 0.4;
}
40% {
transform: translate(15px, -5px);
filter: blur(0.5px);
opacity: 1;
}
50% {
transform: translate(-12px, 7px);
filter: blur(2px);
opacity: 0.6;
}
60% {
transform: translate(10px, -4px);
filter: blur(1.5px);
opacity: 0.9;
}
70% {
transform: translate(-6px, 6px);
filter: blur(1px);
opacity: 0.7;
}
80% {
transform: translate(8px, -3px);
filter: blur(0.5px);
opacity: 0.85;
}
90% {
transform: translate(-4px, 2px);
filter: blur(0.3px);
opacity: 0.95;
}
100% {
transform: translate(0, 0);
filter: none;
opacity: 1;
}
}
@keyframes pulse-dot {
0%,
100% {
opacity: 0.7;
}
50% {
opacity: 1;
}
}
.--time-of-day-activity {
padding: 0;
@media screen and (width >= 500px) {
margin-block: 1em;
}
.rewind-card {
padding: 0;
}
.rewind-report-title {
border: none;
width: 100%;
text-align: center;
box-sizing: border-box;
}
}
+7 -7
View File
@@ -1,4 +1,4 @@
.-top-words .rewind-report-container {
.--top-words .rewind-report-container {
border: none;
background-color: transparent;
width: 100%;
@@ -129,7 +129,7 @@
}
}
.rewind-card.-front {
.rewind-card.--front {
padding: 0.5em;
display: grid;
grid-template:
@@ -150,25 +150,25 @@
var(--rewind-white);
}
.rewind-card.-back {
.rewind-card.--back {
transform: rotateY(180deg);
background-color: var(--rewind-white);
color: var(--rewind-black);
}
.rewind-card.-front,
.rewind-card.-back {
.rewind-card.--front,
.rewind-card.--back {
position: absolute;
width: 100%;
height: 100%; /* Safari */
backface-visibility: hidden;
}
&.-long-word .rewind-card__title {
&.--long-word .rewind-card__title {
font-size: var(--font-0);
}
&.-long-word .rewind-card {
&.--long-word .rewind-card {
padding: 2px;
}
@@ -0,0 +1,94 @@
.--writing-analysis {
.writing-analysis {
border: 2px solid var(--rewind-green);
width: 100%;
color: var(--rewind-green);
font-size: 0.6em;
font-family: "Pixelify Sans", sans-serif;
}
.rewind-report-title {
margin-bottom: 0;
}
.writing-analysis__titlebar {
background: var(--rewind-green);
color: #000;
padding: 6px 10px;
font-weight: bold;
display: flex;
justify-content: space-between;
}
.writing-analysis__menubar {
background: var(--rewind-green);
color: #000;
padding: 4px 10px 6px;
display: flex;
gap: 30px;
}
.writing-analysis__menu-item--right {
margin-left: auto;
}
.writing-analysis__frame {
padding: 0;
}
.writing-analysis__header-row {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
}
.writing-analysis__helpbox {
border: 2px solid var(--rewind-green);
margin-left: 10px;
margin-top: 10px;
padding: 10px;
width: 180px;
text-align: center;
height: 20px;
}
.writing-analysis__release {
text-align: center;
margin-top: 10px;
width: 250px;
}
.writing-analysis__release-name {
font-weight: bold;
margin-bottom: 6px;
}
.writing-analysis__release-meta {
font-size: 14px;
}
.writing-analysis__stats {
border-top: 14px solid var(--rewind-green);
padding: 0 20px;
display: flex;
gap: 20px;
}
.writing-analysis__stats-col {
padding-top: 10px;
width: 140px;
border-right: 10px solid var(--rewind-green);
&:last-of-type {
border-right: none;
}
}
.writing-analysis__stats-label {
font-weight: bold;
}
.writing-analysis__stats-value {
margin-bottom: 15px;
}
}
+126
View File
@@ -48,3 +48,129 @@ en:
title:
one: Your best post
other: Your %{count} best posts
ai_usage:
title: AI Usage
total_requests: Total Requests
total_tokens: Total Tokens
success_rate: Success Rate
favorite_features: Most Used Features
favorite_models: Most Used AI Models
wake_up: "Wake up, %{username}..."
system_title: "AI USAGE DETECTED"
section_features: "TOP FEATURES"
section_models: "TOP MODELS"
assignments:
title: Assignments
total_assigned: Assigned to me
completed: Assignments completed
pending: Assignments pending
assigned_by_user: Assignments given
completion_rate: Completion rate
chat_usage:
title: Chat Activity
total_messages: Total Messages
dm_messages: DM Messages
unique_dm_channels: DM Conversations
reactions_received: Reactions Received
avg_message_length: Avg Message Length
favorite_channels: Favorite Channels
messages: messages
bot_name: XxRewindBotxX
you: You
channel_title: "#chat-activity"
status_online: "● online"
message_1: "You sent <strong>%{count}</strong> messages this year!"
reply_1: "lol awesome B-)"
message_2: "You had <strong>%{dm_count}</strong> DM conversations with <strong>%{channel_count}</strong> different people"
reply_2: "cOo0oL :D"
message_3: "People loved your messages! You got <strong>%{count}</strong> reactions ❤️"
reply_3: "r0fl rAwr 8-)"
message_4: "Your average message length was <strong>%{length}</strong> characters"
message_5: "Your favorite channels:"
dancing_baby_alt: "Dancing baby"
favorite_gifs:
title:
one: Your favorite GIF
other: Your %{count} favorite GIFs
total_usage:
one: Used %{count} time
other: Used %{count} times total
invites:
title: Invites
total_invites: Total Invites Sent
redeemed: Accepted Invites
redemption_rate: Acceptance Rate
avg_trust_level: Avg Trust Level
invitee_impact: Impact of Your Invitees
invitee_posts: Posts Created
invitee_topics: Topics Started
invitee_likes: Likes Given
most_active_invitee: Most Active Invitee
guest_book_title: Guest Book
guest_book_subtitle: Your Community Invitations
label_invitations_sent: "Invitations sent:"
label_guests_joined: "Invites accepted:"
label_acceptance_rate: "Acceptance rate:"
section_contributions: Invitee Contributions
label_posts_written: "Posts written:"
label_topics_started: "Topics started:"
label_likes_given: "Likes given:"
section_most_active: Most Active Guest
new_user_interactions:
title: Mentoring New Users
subtitle:
one: You helped %{count} new user this year
other: You helped %{count} new users this year
new_member:
one: "%{count} new member"
other: "%{count} new members"
total_interactions: Total Interactions
unique_new_users: Unique New Users
likes_given: Likes Given
replies: Replies to Posts
mentions: Mentions
topics_with_new_users: Topics with New Users
time_of_day_activity:
title: Daily Activity Rhythm
play_button: Play your activity as sound
stop_button: Stop audio playback
writing_analysis:
title: Writing Analysis
menu_file: File
menu_other: Other
menu_additional: Additional
menu_opening: OPENING
help_text: Press F1 for help
app_name: DiscoStar Professional
release_info: Release 2025 from CDCK
total_words: Total Words
total_posts: Total Posts
avg_post_length: Avg Post Length
readability_score_label: Readability Score
readability_level: Readability Level
readability_score:
over_80:
1: "Ernest Hemingway"
2: "Raymond Carver"
3: "Michael Crichton"
4: "Roald Dahl"
over_60:
1: "J.K. Rowling"
2: "Stephen King"
3: "Agatha Christie"
4: "George R.R. Martin"
over_40:
1: "George Orwell"
2: "F. Scott Fitzgerald"
3: "Aldous Huxley"
4: "Joan Didion"
over_20:
1: "Cormac McCarthy"
2: "William Faulkner"
3: "Virgina Woolf"
4: "Gabriel Garcia Marquez"
over_0:
1: "James Joyce"
2: "Thomas Pynchon"
3: "David Foster Wallace"
4: "Fyodor Dostoevsky"
+2
View File
@@ -11,6 +11,8 @@
enabled_site_setting :discourse_rewind_enabled
register_svg_icon "repeat"
register_svg_icon "volume-high"
register_svg_icon "volume-xmark"
register_asset "stylesheets/common/_index.scss"
register_asset "stylesheets/mobile/_index.scss", :mobile
Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

+8 -3
View File
@@ -13,6 +13,11 @@ RSpec.describe DiscourseRewind::Action::BestTopics do
describe ".call" do
it "returns top 3 topics ordered by yearly_score" do
[topic_1, topic_2, topic_3, topic_4, topic_5].each do |topic|
topic.update!(created_at: random_datetime)
end
TopTopic.refresh!
TopTopic.find_by(topic_id: topic_1.id).update!(yearly_score: 15)
TopTopic.find_by(topic_id: topic_2.id).update!(yearly_score: 10)
TopTopic.find_by(topic_id: topic_3.id).update!(yearly_score: 6)
@@ -24,19 +29,19 @@ RSpec.describe DiscourseRewind::Action::BestTopics do
topic_id: topic_1.id,
title: topic_1.title,
excerpt: topic_1.excerpt,
yearly_score: 15,
yearly_score: 15.0,
},
{
topic_id: topic_5.id,
title: topic_5.title,
excerpt: topic_5.excerpt,
yearly_score: 13,
yearly_score: 13.0,
},
{
topic_id: topic_4.id,
title: topic_4.title,
excerpt: topic_4.excerpt,
yearly_score: 11,
yearly_score: 11.0,
},
],
)