diff --git a/app/builders/year_in_review_builder.rb b/app/builders/year_in_review_builder.rb new file mode 100644 index 000000000..545fe8029 --- /dev/null +++ b/app/builders/year_in_review_builder.rb @@ -0,0 +1,74 @@ +class YearInReviewBuilder + attr_reader :account, :user_id, :year + + def initialize(account:, user_id:, year:) + @account = account + @user_id = user_id + @year = year + end + + def build + { + year: year, + total_conversations: total_conversations_count, + busiest_day: busiest_day_data, + support_personality: support_personality_data + } + end + + private + + def year_range + @year_range ||= begin + start_time = Time.zone.local(year, 1, 1).beginning_of_day + end_time = Time.zone.local(year, 12, 31).end_of_day + start_time..end_time + end + end + + def total_conversations_count + account.conversations + .where(assignee_id: user_id, created_at: year_range) + .count + end + + def busiest_day_data + daily_counts = account.conversations + .where(assignee_id: user_id, created_at: year_range) + .group_by_day(:created_at, range: year_range, time_zone: Time.zone) + .count + + return nil if daily_counts.empty? + + busiest_date, count = daily_counts.max_by { |_date, cnt| cnt } + + return nil if count.zero? + + { + date: busiest_date.strftime('%b %d'), + count: count + } + end + + def support_personality_data + response_time = average_response_time + + return { avg_response_time_seconds: 0 } if response_time.nil? + + { + avg_response_time_seconds: response_time.to_i + } + end + + def average_response_time + avg_time = account.reporting_events + .where( + name: 'first_response', + user_id: user_id, + created_at: year_range + ) + .average(:value) + + avg_time&.to_f + end +end diff --git a/app/controllers/api/v2/accounts/year_in_reviews_controller.rb b/app/controllers/api/v2/accounts/year_in_reviews_controller.rb new file mode 100644 index 000000000..7946614bb --- /dev/null +++ b/app/controllers/api/v2/accounts/year_in_reviews_controller.rb @@ -0,0 +1,26 @@ +class Api::V2::Accounts::YearInReviewsController < Api::V1::Accounts::BaseController + def show + year = params[:year] || 2025 + cache_key = "year_in_review_#{Current.account.id}_#{year}" + + cached_data = Current.user.ui_settings&.dig(cache_key) + + if cached_data.present? + render json: cached_data + else + builder = YearInReviewBuilder.new( + account: Current.account, + user_id: Current.user.id, + year: year + ) + + data = builder.build + + ui_settings = Current.user.ui_settings || {} + ui_settings[cache_key] = data + Current.user.update(ui_settings: ui_settings) + + render json: data + end + end +end diff --git a/app/javascript/dashboard/api/yearInReview.js b/app/javascript/dashboard/api/yearInReview.js new file mode 100644 index 000000000..fb0661804 --- /dev/null +++ b/app/javascript/dashboard/api/yearInReview.js @@ -0,0 +1,16 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class YearInReviewAPI extends ApiClient { + constructor() { + super('year_in_review', { accountScoped: true, apiVersion: 'v2' }); + } + + get(year) { + return axios.get(`${this.url}`, { + params: { year }, + }); + } +} + +export default new YearInReviewAPI(); diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index cd4309e6e..1f569060d 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -14,6 +14,7 @@ import Button from 'dashboard/components-next/button/Button.vue'; import SidebarGroup from './SidebarGroup.vue'; import SidebarProfileMenu from './SidebarProfileMenu.vue'; import SidebarChangelogCard from './SidebarChangelogCard.vue'; +import YearInReviewBanner from '../year-in-review/YearInReviewBanner.vue'; import ChannelLeaf from './ChannelLeaf.vue'; import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue'; import Logo from 'next/icon/Logo.vue'; @@ -651,6 +652,7 @@ const menuItems = computed(() => {
+ diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenu.vue b/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenu.vue index 36aad75d0..d006c31cb 100644 --- a/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenu.vue +++ b/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenu.vue @@ -1,11 +1,14 @@ + + diff --git a/app/javascript/dashboard/components-next/year-in-review/YearInReviewBanner.vue b/app/javascript/dashboard/components-next/year-in-review/YearInReviewBanner.vue new file mode 100644 index 000000000..7c262f3b2 --- /dev/null +++ b/app/javascript/dashboard/components-next/year-in-review/YearInReviewBanner.vue @@ -0,0 +1,93 @@ + + + diff --git a/app/javascript/dashboard/components-next/year-in-review/YearInReviewModal.vue b/app/javascript/dashboard/components-next/year-in-review/YearInReviewModal.vue new file mode 100644 index 000000000..b6cf23975 --- /dev/null +++ b/app/javascript/dashboard/components-next/year-in-review/YearInReviewModal.vue @@ -0,0 +1,384 @@ + + + diff --git a/app/javascript/dashboard/components-next/year-in-review/slides/BusiestDaySlide.vue b/app/javascript/dashboard/components-next/year-in-review/slides/BusiestDaySlide.vue new file mode 100644 index 000000000..3f76bfbd4 --- /dev/null +++ b/app/javascript/dashboard/components-next/year-in-review/slides/BusiestDaySlide.vue @@ -0,0 +1,76 @@ + + + diff --git a/app/javascript/dashboard/components-next/year-in-review/slides/ConversationsSlide.vue b/app/javascript/dashboard/components-next/year-in-review/slides/ConversationsSlide.vue new file mode 100644 index 000000000..7f92fe05a --- /dev/null +++ b/app/javascript/dashboard/components-next/year-in-review/slides/ConversationsSlide.vue @@ -0,0 +1,94 @@ + + + diff --git a/app/javascript/dashboard/components-next/year-in-review/slides/IntroSlide.vue b/app/javascript/dashboard/components-next/year-in-review/slides/IntroSlide.vue new file mode 100644 index 000000000..8f47746fe --- /dev/null +++ b/app/javascript/dashboard/components-next/year-in-review/slides/IntroSlide.vue @@ -0,0 +1,43 @@ + + + diff --git a/app/javascript/dashboard/components-next/year-in-review/slides/PersonalitySlide.vue b/app/javascript/dashboard/components-next/year-in-review/slides/PersonalitySlide.vue new file mode 100644 index 000000000..016151c12 --- /dev/null +++ b/app/javascript/dashboard/components-next/year-in-review/slides/PersonalitySlide.vue @@ -0,0 +1,99 @@ + + + diff --git a/app/javascript/dashboard/components-next/year-in-review/slides/ThankYouSlide.vue b/app/javascript/dashboard/components-next/year-in-review/slides/ThankYouSlide.vue new file mode 100644 index 000000000..79c6293b9 --- /dev/null +++ b/app/javascript/dashboard/components-next/year-in-review/slides/ThankYouSlide.vue @@ -0,0 +1,43 @@ + + + diff --git a/app/javascript/dashboard/i18n/locale/en/index.js b/app/javascript/dashboard/i18n/locale/en/index.js index 17121fc61..a55f54165 100644 --- a/app/javascript/dashboard/i18n/locale/en/index.js +++ b/app/javascript/dashboard/i18n/locale/en/index.js @@ -38,6 +38,7 @@ import teamsSettings from './teamsSettings.json'; import whatsappTemplates from './whatsappTemplates.json'; import contentTemplates from './contentTemplates.json'; import mfa from './mfa.json'; +import yearInReview from './yearInReview.json'; export default { ...advancedFilters, @@ -80,4 +81,5 @@ export default { ...whatsappTemplates, ...contentTemplates, ...mfa, + ...yearInReview, }; diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 4e51ef37a..3017e9464 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -234,6 +234,7 @@ "CONTACT_SUPPORT": "Contact support", "SELECTOR_SUBTITLE": "Select an account from the following list", "PROFILE_SETTINGS": "Profile settings", + "YEAR_IN_REVIEW": "Year in Review", "KEYBOARD_SHORTCUTS": "Keyboard shortcuts", "APPEARANCE": "Change appearance", "SUPER_ADMIN_CONSOLE": "SuperAdmin console", diff --git a/app/javascript/dashboard/i18n/locale/en/yearInReview.json b/app/javascript/dashboard/i18n/locale/en/yearInReview.json new file mode 100644 index 000000000..d72e0c679 --- /dev/null +++ b/app/javascript/dashboard/i18n/locale/en/yearInReview.json @@ -0,0 +1,64 @@ +{ + "YEAR_IN_REVIEW": { + "TITLE": "Year in Review", + "LOADING": "Loading your year in review...", + "ERROR": "Failed to load year in review", + "CLOSE": "Close", + "CONVERSATIONS": { + "TITLE": "You have handled", + "SUBTITLE": "conversations", + "FALLBACK": "This year wasn't about the numbers. It was about showing up.", + "COMPARISON": { + "0_50": "You showed up, and that's how every good inbox begins.", + "50_100": "You kept the replies flowing and the conversations alive.", + "100_500": "You handled serious volume and kept everything on track.", + "500_2000": "You kept things moving while the volume kept climbing.", + "2000_10000": "You ran high traffic through your inbox without breaking a sweat.", + "10000_PLUS": "That's a full city of customers knocking on your door. You made it look effortless." + } + }, + "BUSIEST_DAY": { + "TITLE": "Your busiest day was", + "MESSAGE": "{count} conversations that day.", + "COMPARISON": { + "0_5": "A warm-up lap that barely woke the inbox.", + "5_10": "Enough action to justify a second cup of coffee.", + "10_25": "Things got busy and the inbox stayed on its toes.", + "25_50": "A proper rush that barely broke a sweat.", + "50_100": "Controlled chaos, handled like a normal Tuesday.", + "100_500": "Absolute dumpster fire, somehow still shipping replies.", + "500_PLUS": "The inbox lost all chill and never slowed down." + } + }, + "PERSONALITY": { + "TITLE": "Your support personality is", + "MESSAGES": { + "SWIFT_HELPER": "You replied in {time} on average. Faster than most notifications.", + "QUICK_RESPONDER": "You replied in {time} on average. The inbox barely waited.", + "STEADY_SUPPORT": "You replied in {time} on average. Calm pace, solid replies.", + "THOUGHTFUL_ADVISOR": "You replied in {time} on average. Took the time to get it right." + } + }, + "THANK_YOU": { + "TITLE": "Congratulations on surviving the inbox of {year}.", + "MESSAGE": "Thank you for your incredible dedication to supporting customers throughout this year. Your hard work has made a real difference, and we're grateful to have you on this journey. Here's to making {nextYear} even better together!" + }, + "SHARE_MODAL": { + "TITLE": "Share Your Year in Review", + "PREPARING": "Preparing your image...", + "DOWNLOAD": "Download", + "SHARE_TITLE": "My {year} Year in Review", + "SHARE_TEXT": "Check out my {year} Year in Review with Chatwoot!", + "BRANDING": "Made with Chatwoot" + }, + "BANNER": { + "TITLE": "Your {year} Year in Review is here", + "BUTTON": "See your impact" + }, + "NAVIGATION": { + "PREVIOUS": "Previous", + "NEXT": "Next", + "SHARE": "Share" + } + } +} diff --git a/config/routes.rb b/config/routes.rb index f2f392157..15ab79822 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -425,6 +425,7 @@ Rails.application.routes.draw do get :bot_metrics end end + resource :year_in_review, only: [:show] resources :live_reports, only: [] do collection do get :conversation_metrics diff --git a/package.json b/package.json index 84c266880..1abe4f375 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,8 @@ "flag-icons": "^7.2.3", "floating-vue": "^5.2.2", "highlight.js": "^11.10.0", + "html-to-image": "^1.11.13", + "html2canvas": "^1.4.1", "idb": "^8.0.0", "js-cookie": "^3.0.5", "json-logic-js": "^2.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 602a3c5c7..28b82df6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,6 +127,12 @@ importers: highlight.js: specifier: ^11.10.0 version: 11.10.0 + html-to-image: + specifier: ^1.11.13 + version: 1.11.13 + html2canvas: + specifier: ^1.4.1 + version: 1.4.1 idb: specifier: ^8.0.0 version: 8.0.0 @@ -1672,6 +1678,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} @@ -1918,6 +1928,9 @@ packages: peerDependencies: postcss: ^8.4 + css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + css-prefers-color-scheme@8.0.2: resolution: {integrity: sha512-OvFghizHJ45x7nsJJUSYLyQNTzsCU8yWjxAc/nhPQg1pbs18LMoET8N3kOweFDPy0JV0OSXN2iqRFhPBHYOeMA==} engines: {node: ^14 || ^16 || >=18} @@ -2686,6 +2699,13 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-to-image@1.11.13: + resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} + + html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} + htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} @@ -4217,6 +4237,9 @@ packages: resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} engines: {node: '>=18'} + text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -4413,6 +4436,9 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + video.js@7.18.1: resolution: {integrity: sha512-mnXdmkVcD5qQdKMZafDjqdhrnKGettZaGSVkExjACiylSB4r2Yt5W1bchsKmjFpfuNfszsMjTUnnoIWSSqoe/Q==} @@ -6248,6 +6274,8 @@ snapshots: balanced-match@1.0.2: {} + base64-arraybuffer@1.0.2: {} + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 @@ -6530,6 +6558,10 @@ snapshots: postcss-selector-parser: 6.1.1 postcss-value-parser: 4.2.0 + css-line-break@2.1.0: + dependencies: + utrie: 1.0.2 + css-prefers-color-scheme@8.0.2(postcss@8.4.47): dependencies: postcss: 8.4.47 @@ -7464,6 +7496,13 @@ snapshots: html-escaper@2.0.2: {} + html-to-image@1.11.13: {} + + html2canvas@1.4.1: + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + htmlparser2@8.0.2: dependencies: domelementtype: 2.3.0 @@ -9158,6 +9197,10 @@ snapshots: glob: 10.4.5 minimatch: 9.0.5 + text-segmentation@1.0.3: + dependencies: + utrie: 1.0.2 + text-table@0.2.0: {} thenify-all@1.6.0: @@ -9358,6 +9401,10 @@ snapshots: utils-merge@1.0.1: {} + utrie@1.0.2: + dependencies: + base64-arraybuffer: 1.0.2 + video.js@7.18.1: dependencies: '@babel/runtime': 7.25.6 diff --git a/public/assets/images/dashboard/year-in-review/double-quotes.png b/public/assets/images/dashboard/year-in-review/double-quotes.png new file mode 100644 index 000000000..252eb7b28 Binary files /dev/null and b/public/assets/images/dashboard/year-in-review/double-quotes.png differ diff --git a/public/assets/images/dashboard/year-in-review/fifth-frame-signature.png b/public/assets/images/dashboard/year-in-review/fifth-frame-signature.png new file mode 100644 index 000000000..4febb551e Binary files /dev/null and b/public/assets/images/dashboard/year-in-review/fifth-frame-signature.png differ diff --git a/public/assets/images/dashboard/year-in-review/first-frame-bg.png b/public/assets/images/dashboard/year-in-review/first-frame-bg.png new file mode 100644 index 000000000..515a8cf90 Binary files /dev/null and b/public/assets/images/dashboard/year-in-review/first-frame-bg.png differ diff --git a/public/assets/images/dashboard/year-in-review/first-frame-candles.png b/public/assets/images/dashboard/year-in-review/first-frame-candles.png new file mode 100644 index 000000000..9495a80b5 Binary files /dev/null and b/public/assets/images/dashboard/year-in-review/first-frame-candles.png differ diff --git a/public/assets/images/dashboard/year-in-review/fourth-frame-clock.png b/public/assets/images/dashboard/year-in-review/fourth-frame-clock.png new file mode 100644 index 000000000..b81f754b5 Binary files /dev/null and b/public/assets/images/dashboard/year-in-review/fourth-frame-clock.png differ diff --git a/public/assets/images/dashboard/year-in-review/second-frame-cloud-icon.png b/public/assets/images/dashboard/year-in-review/second-frame-cloud-icon.png new file mode 100644 index 000000000..a422a8c00 Binary files /dev/null and b/public/assets/images/dashboard/year-in-review/second-frame-cloud-icon.png differ diff --git a/public/assets/images/dashboard/year-in-review/third-frame-coffee.png b/public/assets/images/dashboard/year-in-review/third-frame-coffee.png new file mode 100644 index 000000000..c54fb302f Binary files /dev/null and b/public/assets/images/dashboard/year-in-review/third-frame-coffee.png differ diff --git a/public/assets/images/dashboard/year-in-review/year-in-review-sidebar.png b/public/assets/images/dashboard/year-in-review/year-in-review-sidebar.png new file mode 100644 index 000000000..e087e15d9 Binary files /dev/null and b/public/assets/images/dashboard/year-in-review/year-in-review-sidebar.png differ diff --git a/public/audio/dashboard/drumroll.mp3 b/public/audio/dashboard/drumroll.mp3 new file mode 100644 index 000000000..5284a9d57 Binary files /dev/null and b/public/audio/dashboard/drumroll.mp3 differ diff --git a/spec/builders/year_in_review_builder_spec.rb b/spec/builders/year_in_review_builder_spec.rb new file mode 100644 index 000000000..526b684b5 --- /dev/null +++ b/spec/builders/year_in_review_builder_spec.rb @@ -0,0 +1,64 @@ +require 'rails_helper' + +RSpec.describe YearInReviewBuilder, type: :model do + subject(:builder) { described_class.new(account: account, user_id: user.id, year: year) } + + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + let(:year) { 2025 } + + describe '#build' do + context 'when there is no data for the year' do + it 'returns empty aggregates' do + result = builder.build + + expect(result[:year]).to eq(year) + expect(result[:total_conversations]).to eq(0) + expect(result[:busiest_day]).to be_nil + expect(result[:support_personality]).to eq({ avg_response_time_seconds: 0 }) + end + end + + context 'when there is data for the year' do + let(:busiest_date) { Time.zone.local(year, 3, 10, 10, 0, 0) } + let(:other_date) { Time.zone.local(year, 3, 11, 10, 0, 0) } + + before do + create(:conversation, account: account, assignee: user, created_at: busiest_date) + create(:conversation, account: account, assignee: user, created_at: busiest_date + 1.hour) + create(:conversation, account: account, assignee: user, created_at: other_date) + + create( + :reporting_event, + account: account, + user: user, + name: 'first_response', + value: 12.7, + created_at: busiest_date + ) + end + + it 'returns total conversations count' do + expect(builder.build[:total_conversations]).to eq(3) + end + + it 'returns busiest day data' do + expect(builder.build[:busiest_day]).to eq({ date: busiest_date.strftime('%b %d'), count: 2 }) + end + + it 'returns support personality data' do + expect(builder.build[:support_personality]).to eq({ avg_response_time_seconds: 12 }) + end + + it 'scopes data to the provided year' do + create(:conversation, account: account, assignee: user, created_at: Time.zone.local(year - 1, 6, 1)) + create(:reporting_event, account: account, user: user, name: 'first_response', value: 99, created_at: Time.zone.local(year - 1, 6, 1)) + + result = builder.build + + expect(result[:total_conversations]).to eq(3) + expect(result[:support_personality]).to eq({ avg_response_time_seconds: 12 }) + end + end + end +end diff --git a/tailwind.config.js b/tailwind.config.js index 18bd54948..5ac5c5836 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -41,7 +41,7 @@ const tailwindConfig = { fontFamily: { sans: defaultSansFonts, inter: ['Inter', ...defaultSansFonts], - interDisplay: ['Inter Display', ...defaultSansFonts], + interDisplay: ['InterDisplay', ...defaultSansFonts], }, typography: { bubble: {