Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions app/controllers/discourse_rewind/rewinds_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,35 @@ class RewindsController < ::ApplicationController

requires_login

def show
def index
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 }
on_success do |reports:, total_available:|
render json: { reports: reports, total_available: total_available }, status: :ok
end
end
end

def show
DiscourseRewind::FetchReport.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(:report_class) do
raise Discourse::NotFound.new(
nil,
custom_message: "discourse_rewind.invalid_report_index",
)
end
on_model_not_found(:report) do
raise Discourse::NotFound.new(nil, custom_message: "discourse_rewind.report_failed")
end
on_success { |report:| render json: { report: report }, status: :ok }
end
end
end
Expand Down
92 changes: 92 additions & 0 deletions app/services/discourse_rewind/fetch_report.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# frozen_string_literal: true

module DiscourseRewind
# Service responsible to fetch a single report by index.
#
# @example
# ::DiscourseRewind::FetchReport.call(
# guardian: guardian,
# index: 3
# )
#
class FetchReport
include Service::Base

# @!method self.call(guardian:, params:)
# @param [Guardian] guardian
# @param [Hash] params
# @option params [Integer] :index the report index
# @return [Service::Base::Context]

CACHE_DURATION = Rails.env.development? ? 10.seconds : 5.minutes

model :year
model :date
model :report_class
model :report

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_report_class(date:, guardian:, year:, params:)
# Use the same cached all_reports list as FetchReports
# If not cached, generate it now
key = "rewind:#{guardian.user.username}:#{year}:all_reports"
cached_list = Discourse.redis.get(key)

all_reports =
if cached_list
MultiJson.load(cached_list, symbolize_keys: true)
else
# Generate all reports and cache them
reports =
FetchReports::REPORTS.filter_map do |report_class|
begin
report_class.call(date:, user: guardian.user, guardian:)
rescue => e
Rails.logger.error("Failed to generate report #{report_class.name}: #{e.message}")
nil
end
end
Discourse.redis.setex(key, CACHE_DURATION, MultiJson.dump(reports))
reports
end

# Access index from params data object (params.index) or hash (params[:index])
index = (params[:index] || params.index).to_i

return false if index < 0 || index >= all_reports.length

all_reports[index]
end

def fetch_report(report_class:)
# Report is already generated and cached in the all_reports list
report_class
end
end
end
43 changes: 33 additions & 10 deletions app/services/discourse_rewind/fetch_reports.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ class FetchReports
# @param [Hash] params
# @option params [Integer] :year of the rewind
# @option params [Integer] :username of the rewind
# @option params [Integer] :count number of reports to fetch (optional, defaults to 3)
# @return [Service::Base::Context]

CACHE_DURATION = Rails.env.development? ? 10.seconds : 5.minutes
INITIAL_REPORT_COUNT = 3

# order matters
REPORTS = [
Expand All @@ -42,7 +44,9 @@ class FetchReports

model :year
model :date
model :enabled_reports
model :reports
model :total_available

private

Expand Down Expand Up @@ -71,19 +75,38 @@ 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)
def fetch_enabled_reports(date:, guardian:, year:)
# Generate all reports and filter out nils (disabled/empty reports)
# Cache the full list to maintain consistent indices across requests
key = "rewind:#{guardian.user.username}:#{year}:all_reports"
cached_list = 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
return MultiJson.load(cached_list, symbolize_keys: true) if cached_list

reports =
REPORTS.filter_map do |report_class|
begin
report_class.call(date:, user: guardian.user, guardian:)
rescue => e
Rails.logger.error("Failed to generate report #{report_class.name}: #{e.message}")
nil
end
end

# Cache the complete enabled reports list
Discourse.redis.setex(key, CACHE_DURATION, MultiJson.dump(reports))
reports
end

def fetch_total_available(enabled_reports:)
enabled_reports.length
end

def fetch_reports(enabled_reports:, params:)
count = params[:count]&.to_i || INITIAL_REPORT_COUNT
count = [[count, 1].max, enabled_reports.length].min

enabled_reports.first(count)
end
end
end
70 changes: 69 additions & 1 deletion assets/javascripts/discourse/components/rewind.gjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { tracked } from "@glimmer/tracking";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class";
import { ajax } from "discourse/lib/ajax";
Expand All @@ -24,24 +25,83 @@ export default class Rewind extends Component {
@tracked rewind = [];
@tracked fullScreen = true;
@tracked loadingRewind = false;
@tracked totalAvailable = 0;
@tracked loadingNextReport = false;

BUFFER_SIZE = 3;

// Arrow function for event listener - maintains 'this' binding
handleScroll = () => {
if (!this.scrollWrapper || this.loadingRewind) {
return;
}

const scrollTop = this.scrollWrapper.scrollTop;
const scrollHeight = this.scrollWrapper.scrollHeight;
const clientHeight = this.scrollWrapper.clientHeight;

// Trigger preload when scrolled 70% through content
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
if (scrollPercentage > 0.7) {
this.preloadNextReports();
}
};

@action
registerScrollWrapper(element) {
this.scrollWrapper = element;
this.scrollWrapper.addEventListener("scroll", this.handleScroll);
}

@action
async loadRewind() {
try {
this.loadingRewind = true;
this.rewind = await ajax("/rewinds");
const response = await ajax("/rewinds");
this.rewind = response.reports;
this.totalAvailable = response.total_available;

// Preload next report to maintain buffer
this.preloadNextReports();
} catch (e) {
popupAjaxError(e);
} finally {
this.loadingRewind = false;
}
}

@action
async preloadNextReports() {
// Load reports to maintain BUFFER_SIZE ahead of current position
const currentIndex = this.rewind.length;
const targetIndex = currentIndex + this.BUFFER_SIZE;

if (
this.loadingNextReport ||
currentIndex >= this.totalAvailable ||
targetIndex > this.totalAvailable
) {
return;
}

try {
this.loadingNextReport = true;
const response = await ajax(`/rewinds/${currentIndex}`);
this.rewind = [...this.rewind, response.report];

// Continue preloading if we haven't reached buffer size yet
if (this.rewind.length < targetIndex) {
this.preloadNextReports();
}
} catch (e) {
// Silently fail for preloading - user already has content to view
// eslint-disable-next-line no-console
console.error("Failed to preload report:", e);
} finally {
this.loadingNextReport = false;
}
}

@action
toggleFullScreen() {
this.fullScreen = !this.fullScreen;
Expand All @@ -66,13 +126,21 @@ export default class Rewind extends Component {
this.rewindContainer = element;
}

@action
cleanup() {
if (this.scrollWrapper) {
this.scrollWrapper.removeEventListener("scroll", this.handleScroll);
}
}

<template>
<div
class={{concatClass
"rewind-container"
(if this.fullScreen "-fullscreen")
}}
{{didInsert this.loadRewind}}
{{willDestroy this.cleanup}}
{{on "keydown" this.handleEscape}}
{{on "click" this.handleBackdropClick}}
{{didInsert this.registerRewindContainer}}
Expand Down
1 change: 1 addition & 0 deletions config/locales/server.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ en:
discourse_rewind:
report_failed: "Failed to generate Discourse Rewind report."
invalid_year: "Rewind can only be generated in January or December."
invalid_report_index: "Invalid report index."
5 changes: 4 additions & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# frozen_string_literal: true

DiscourseRewind::Engine.routes.draw { get "/rewinds" => "rewinds#show" }
DiscourseRewind::Engine.routes.draw do
get "/rewinds" => "rewinds#index", :constraints => { format: :json }
get "/rewinds/:index" => "rewinds#show", :constraints => { index: /\d+/ }
end

Discourse::Application.routes.draw { mount ::DiscourseRewind::Engine, at: "/" }
Loading