Skip to content

Commit 7955a47

Browse files
FEATURE: Store rewind dismiss state in database instead of localStorage (#36625)
Users were seeing repeated rewind notifications across different devices and browsers because the dismiss state was stored in localStorage, which is per-browser and easily cleared. Move the dismiss state to a new `discourse_rewind_dismissed_at` column in the `user_options` table. This allows the dismiss to sync across all devices and persist through browser data clears. The timestamp is compared using "rewind year" logic - dismissing in January 2025 (for rewind 2024) won't block the notification for rewind 2025 in December 2025. - Add `discourse_rewind_dismissed_at` datetime column to user_options - Add POST /rewinds/dismiss endpoint - Add `discourse_rewind_dismissed` to user_option serializers - Extract `DiscourseRewind.rewind_year` helper for year calculation - Update frontend service to read from server state instead of localStorage --------- Co-authored-by: Martin Brennan <[email protected]>
1 parent 9abdc35 commit 7955a47

File tree

9 files changed

+210
-40
lines changed

9 files changed

+210
-40
lines changed

plugins/discourse-rewind/app/controllers/discourse_rewind/rewinds_controller.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ class RewindsController < ::ApplicationController
66

77
requires_login
88

9+
def dismiss
10+
DiscourseRewind::Dismiss.call(service_params) do
11+
on_success { head :no_content }
12+
on_failure { render(json: failed_json, status: :unprocessable_entity) }
13+
end
14+
end
15+
916
def index
1017
DiscourseRewind::FetchReports.call(service_params) do
1118
on_model_not_found(:year) do
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseRewind
4+
# Service responsible for dismissing Rewind for the user.
5+
#
6+
# @example
7+
# ::DiscourseRewind::Dismiss.call(guardian: guardian)
8+
#
9+
class Dismiss
10+
include Service::Base
11+
12+
# @!method self.call(guardian:)
13+
# @param [Guardian] guardian
14+
# @return [Service::Base::Context]
15+
16+
step :dismiss
17+
18+
private
19+
20+
def dismiss(guardian:)
21+
guardian.user.user_option.update!(discourse_rewind_dismissed_at: Time.zone.now)
22+
end
23+
end
24+
end

plugins/discourse-rewind/assets/javascripts/discourse/services/rewind.js

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { tracked } from "@glimmer/tracking";
22
import Service, { service } from "@ember/service";
3-
import KeyValueStore from "discourse/lib/key-value-store";
3+
import { ajax } from "discourse/lib/ajax";
44

55
export default class Rewind extends Service {
66
@service currentUser;
77

8-
@tracked dismissed = this.store.getObject("_dismissed") ?? false;
9-
10-
store = new KeyValueStore("discourse_rewind_" + this.fetchRewindYear);
8+
@tracked
9+
_isDismissed = this.currentUser?.user_option?.discourse_rewind_dismissed;
1110

1211
@tracked
1312
_isDisabled = this.currentUser?.user_option?.discourse_rewind_disabled;
@@ -16,6 +15,10 @@ export default class Rewind extends Service {
1615
return this.currentUser?.is_rewind_active;
1716
}
1817

18+
get dismissed() {
19+
return this._isDismissed ?? false;
20+
}
21+
1922
get disabled() {
2023
return this._isDisabled ?? false;
2124
}
@@ -24,9 +27,11 @@ export default class Rewind extends Service {
2427
this._isDisabled = value;
2528
}
2629

27-
// We want to show the previous year's rewind in January
28-
// but the current year's rewind in any other month (in
29-
// reality, only December).
30+
/**
31+
* We want to show the previous year's rewind in January
32+
* but the current year's rewind in any other month (in
33+
* reality, only December).
34+
*/
3035
get fetchRewindYear() {
3136
const currentDate = new Date();
3237
const currentMonth = currentDate.getMonth();
@@ -52,7 +57,7 @@ export default class Rewind extends Service {
5257
}
5358

5459
dismiss() {
55-
this.dismissed = true;
56-
this.store.setObject({ key: "_dismissed", value: true });
60+
this._isDismissed = true;
61+
ajax("/rewinds/dismiss", { type: "POST" });
5762
}
5863
}

plugins/discourse-rewind/config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
DiscourseRewind::Engine.routes.draw do
44
get "/rewinds" => "rewinds#index"
55
get "/rewinds/:index" => "rewinds#show"
6+
post "/rewinds/dismiss" => "rewinds#dismiss"
67
end
78

89
Discourse::Application.routes.draw { mount ::DiscourseRewind::Engine, at: "/" }
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# frozen_string_literal: true
2+
3+
class AddDiscourseRewindDismissedAtToUserOptions < ActiveRecord::Migration[7.2]
4+
def change
5+
add_column :user_options, :discourse_rewind_dismissed_at, :datetime, null: true
6+
end
7+
end

plugins/discourse-rewind/plugin.rb

Lines changed: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -24,32 +24,19 @@ def self.public_asset_path(name)
2424
File.expand_path(File.join(__dir__, "public", name))
2525
end
2626

27+
def self.rewind_year(date = nil)
28+
date ||= Time.zone.now
29+
date.month == 1 ? date.year - 1 : date.year
30+
end
31+
2732
def self.year_date_range(date_override = nil)
28-
if date_override.present?
29-
current_date = date_override
30-
else
31-
current_date = Time.zone.now
32-
end
33+
current_date = date_override.presence || Time.zone.now
3334

34-
current_month = current_date.month
35-
current_year = current_date.year
35+
# Outside December/January, only available in development
36+
is_rewind_period = current_date.month == 1 || current_date.month == 12
37+
return false if !is_rewind_period && !Rails.env.development?
3638

37-
case current_month
38-
when 1
39-
current_year - 1
40-
when 12
41-
current_year
42-
else
43-
# Otherwise it's impossible to test in browser locally unless you're
44-
# in December or January
45-
if Rails.env.development?
46-
current_year
47-
else
48-
false
49-
end
50-
end
51-
52-
Date.new(current_year).all_year
39+
Date.new(current_date.year).all_year
5340
end
5441
end
5542

@@ -58,10 +45,13 @@ def self.year_date_range(date_override = nil)
5845
after_initialize do
5946
UserUpdater::OPTION_ATTR.push(:discourse_rewind_disabled)
6047

61-
add_to_serializer(:user_option, :discourse_rewind_disabled) { object.discourse_rewind_disabled }
62-
63-
add_to_serializer(:current_user_option, :discourse_rewind_disabled) do
64-
object.discourse_rewind_disabled
48+
%i[user_option current_user_option].each do |serializer|
49+
add_to_serializer(serializer, :discourse_rewind_disabled) { object.discourse_rewind_disabled }
50+
add_to_serializer(serializer, :discourse_rewind_dismissed) do
51+
dismissed_at = object.discourse_rewind_dismissed_at
52+
dismissed_at.present? &&
53+
DiscourseRewind.rewind_year(dismissed_at) >= DiscourseRewind.rewind_year
54+
end
6555
end
6656

6757
add_to_serializer(:current_user, :is_rewind_active) do

plugins/discourse-rewind/spec/requests/rewinds_controller_spec.rb

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,42 @@
33
RSpec.describe DiscourseRewind::RewindsController do
44
before { SiteSetting.discourse_rewind_enabled = true }
55

6+
describe "#dismiss" do
7+
it "requires login" do
8+
post "/rewinds/dismiss.json"
9+
expect(response.status).to eq(403)
10+
end
11+
12+
context "when logged in" do
13+
fab!(:user)
14+
before { sign_in(user) }
15+
16+
it "sets dismissed_at on user_option" do
17+
freeze_time DateTime.parse("2022-12-24 10:00:00")
18+
19+
post "/rewinds/dismiss.json"
20+
21+
expect(response.status).to eq(204)
22+
expect(user.user_option.reload.discourse_rewind_dismissed_at).to eq_time(Time.current)
23+
end
24+
25+
it "returns dismissed state via session/current endpoint" do
26+
freeze_time DateTime.parse("2022-12-24")
27+
user.user_option.update!(discourse_rewind_dismissed_at: Time.current)
28+
29+
get "/session/current.json"
30+
31+
expect(
32+
response.parsed_body.dig("current_user", "user_option", "discourse_rewind_dismissed"),
33+
).to eq(true)
34+
end
35+
end
36+
end
37+
638
describe "#index" do
7-
fab!(:current_user, :user)
39+
fab!(:user)
840

9-
before { sign_in(current_user) }
41+
before { sign_in(user) }
1042

1143
context "when out of valid month" do
1244
before { freeze_time DateTime.parse("2022-11-24") }
@@ -51,9 +83,9 @@
5183
end
5284

5385
describe "#show" do
54-
fab!(:current_user, :user)
86+
fab!(:user)
5587

56-
before { sign_in(current_user) }
88+
before { sign_in(user) }
5789

5890
context "when out of valid month" do
5991
before { freeze_time DateTime.parse("2022-11-24") }
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe UserOptionSerializer do
4+
before { SiteSetting.discourse_rewind_enabled = true }
5+
6+
fab!(:user)
7+
8+
let(:serializer) do
9+
UserOptionSerializer.new(user.user_option, scope: Guardian.new(user), root: false)
10+
end
11+
12+
def set_dismissed_at(date_string)
13+
user.user_option.update!(discourse_rewind_dismissed_at: DateTime.parse(date_string))
14+
end
15+
16+
describe "#discourse_rewind_dismissed" do
17+
it "returns false when dismissed_at is nil" do
18+
expect(serializer.as_json[:discourse_rewind_dismissed]).to eq(false)
19+
end
20+
21+
context "when in December 2024 (showing rewind 2024)" do
22+
before { freeze_time DateTime.parse("2024-12-15") }
23+
24+
it "returns true when dismissed in Dec 2024" do
25+
set_dismissed_at("2024-12-01")
26+
expect(serializer.as_json[:discourse_rewind_dismissed]).to eq(true)
27+
end
28+
29+
it "returns false when dismissed in Jan 2024 (was for rewind 2023)" do
30+
set_dismissed_at("2024-01-10")
31+
expect(serializer.as_json[:discourse_rewind_dismissed]).to eq(false)
32+
end
33+
34+
it "returns false when dismissed in Dec 2023" do
35+
set_dismissed_at("2023-12-01")
36+
expect(serializer.as_json[:discourse_rewind_dismissed]).to eq(false)
37+
end
38+
end
39+
40+
context "when in January 2025 (still showing rewind 2024)" do
41+
before { freeze_time DateTime.parse("2025-01-15") }
42+
43+
it "returns true when dismissed in Dec 2024" do
44+
set_dismissed_at("2024-12-20")
45+
expect(serializer.as_json[:discourse_rewind_dismissed]).to eq(true)
46+
end
47+
48+
it "returns true when dismissed in Jan 2025" do
49+
set_dismissed_at("2025-01-05")
50+
expect(serializer.as_json[:discourse_rewind_dismissed]).to eq(true)
51+
end
52+
53+
it "returns false when dismissed in Jan 2024 (was for rewind 2023)" do
54+
set_dismissed_at("2024-01-20")
55+
expect(serializer.as_json[:discourse_rewind_dismissed]).to eq(false)
56+
end
57+
end
58+
end
59+
end
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# frozen_string_literal: true
2+
3+
describe "DiscourseRewind | dismiss", type: :system do
4+
fab!(:user)
5+
let(:rewind_page) { PageObjects::Pages::Rewind.new }
6+
7+
before do
8+
SiteSetting.discourse_rewind_enabled = true
9+
sign_in(user)
10+
freeze_time DateTime.parse("2022-12-05")
11+
end
12+
13+
it "persists dismiss across page refreshes and saves to database" do
14+
rewind_page.visit_my_activity
15+
expect(rewind_page).to have_rewind_notification_active
16+
17+
rewind_page.open_user_menu
18+
expect(rewind_page).to have_callout
19+
rewind_page.click_callout
20+
21+
expect(rewind_page).to have_no_rewind_notification_active
22+
expect(user.user_option.reload.discourse_rewind_dismissed_at).to be_present
23+
24+
visit("/")
25+
rewind_page.visit_my_activity
26+
expect(rewind_page).to have_no_rewind_notification_active
27+
end
28+
29+
it "hides notification and callout when already dismissed" do
30+
user.user_option.update!(discourse_rewind_dismissed_at: Time.current)
31+
32+
rewind_page.visit_my_activity
33+
expect(rewind_page).to have_no_rewind_notification_active
34+
35+
rewind_page.open_user_menu
36+
expect(rewind_page).to have_no_callout
37+
end
38+
39+
it "shows notification for new year even if previous year was dismissed" do
40+
user.user_option.update!(discourse_rewind_dismissed_at: DateTime.parse("2021-12-15"))
41+
42+
rewind_page.visit_my_activity
43+
expect(rewind_page).to have_rewind_notification_active
44+
end
45+
end

0 commit comments

Comments
 (0)