diff --git a/app/javascript/application.js b/app/javascript/application.js
index b839039d..1320946e 100644
--- a/app/javascript/application.js
+++ b/app/javascript/application.js
@@ -51,6 +51,7 @@ import ToggleClasses from './elements/toggle_classes'
import AutosizeField from './elements/autosize_field'
import GoogleDriveFilePicker from './elements/google_drive_file_picker'
import OpenModal from './elements/open_modal'
+import BarChart from './elements/bar_chart'
import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -140,6 +141,7 @@ safeRegisterElement('toggle-classes', ToggleClasses)
safeRegisterElement('autosize-field', AutosizeField)
safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker)
safeRegisterElement('open-modal', OpenModal)
+safeRegisterElement('bar-chart', BarChart)
safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () {
diff --git a/app/javascript/elements/bar_chart.js b/app/javascript/elements/bar_chart.js
new file mode 100644
index 00000000..32fdfa0f
--- /dev/null
+++ b/app/javascript/elements/bar_chart.js
@@ -0,0 +1,50 @@
+export default class extends HTMLElement {
+ connectedCallback () {
+ this.chartLabels = JSON.parse(this.dataset.labels || '[]')
+ this.chartDatasets = JSON.parse(this.dataset.datasets || '[]')
+
+ this.initChart()
+ }
+
+ disconnectedCallback () {
+ if (this.chartInstance) {
+ this.chartInstance.destroy()
+ this.chartInstance = null
+ }
+ }
+
+ async initChart () {
+ const { default: Chart } = await import(/* webpackChunkName: "chartjs" */ 'chart.js/auto')
+
+ const canvas = this.querySelector('canvas')
+
+ const ctx = canvas.getContext('2d')
+
+ this.chartInstance = new Chart(ctx, {
+ type: 'bar',
+ data: {
+ labels: this.chartLabels,
+ datasets: this.chartDatasets
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: true,
+ animation: false,
+ scales: {
+ y: {
+ beginAtZero: true,
+ grace: '20%',
+ ticks: {
+ precision: 0
+ }
+ }
+ },
+ plugins: {
+ legend: {
+ display: false
+ }
+ }
+ }
+ })
+ }
+}
diff --git a/app/jobs/process_submitter_completion_job.rb b/app/jobs/process_submitter_completion_job.rb
index cd9888d5..a3cdf6f2 100644
--- a/app/jobs/process_submitter_completion_job.rb
+++ b/app/jobs/process_submitter_completion_job.rb
@@ -47,6 +47,7 @@ class ProcessSubmitterCompletionJob
completed_submitter.assign_attributes(
submission_id: submitter.submission_id,
account_id: submission.account_id,
+ is_first: !CompletedSubmitter.exists?(submission: submitter.submission_id, is_first: true),
template_id: submission.template_id,
source: submission.source,
sms_count: sms_events.sum { |e| e.data['segments'] || 1 },
diff --git a/app/models/account.rb b/app/models/account.rb
index cbf157e5..bc6471bf 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -60,6 +60,10 @@ class Account < ApplicationRecord
linked_account_account&.testing?
end
+ def tz_info
+ @tz_info ||= TZInfo::Timezone.get(ActiveSupport::TimeZone::MAPPING[timezone] || timezone)
+ end
+
def default_template_folder
super || build_default_template_folder(name: TemplateFolder::DEFAULT_NAME,
author_id: users.minimum(:id)).tap(&:save!)
diff --git a/app/models/completed_submitter.rb b/app/models/completed_submitter.rb
index 25c36e3a..8c5f2f64 100644
--- a/app/models/completed_submitter.rb
+++ b/app/models/completed_submitter.rb
@@ -6,6 +6,7 @@
#
# id :bigint not null, primary key
# completed_at :datetime not null
+# is_first :boolean
# sms_count :integer not null
# source :string not null
# verification_method :string
@@ -18,8 +19,10 @@
#
# Indexes
#
-# index_completed_submitters_on_account_id (account_id)
-# index_completed_submitters_on_submitter_id (submitter_id) UNIQUE
+# index_completed_submitters_account_id_completed_at_is_first (account_id,completed_at) WHERE (is_first = true)
+# index_completed_submitters_on_account_id_and_completed_at (account_id,completed_at)
+# index_completed_submitters_on_submission_id (submission_id) UNIQUE WHERE (is_first = true)
+# index_completed_submitters_on_submitter_id (submitter_id) UNIQUE
#
class CompletedSubmitter < ApplicationRecord
belongs_to :submitter
diff --git a/app/models/submission_event.rb b/app/models/submission_event.rb
index d85536b3..11a6e995 100644
--- a/app/models/submission_event.rb
+++ b/app/models/submission_event.rb
@@ -10,17 +10,21 @@
# event_type :string not null
# created_at :datetime not null
# updated_at :datetime not null
+# account_id :bigint
# submission_id :bigint not null
# submitter_id :bigint
#
# Indexes
#
-# index_submission_events_on_created_at (created_at)
-# index_submission_events_on_submission_id (submission_id)
-# index_submission_events_on_submitter_id (submitter_id)
+# index_submission_events_on_account_id (account_id)
+# index_submission_events_on_created_at (created_at)
+# index_submission_events_on_submission_id (submission_id)
+# index_submission_events_on_submitter_id (submitter_id)
+# index_submissions_events_on_sms_event_types (account_id,created_at) WHERE ((event_type)::text = ANY ((ARRAY['send_sms'::character varying, 'send_2fa_sms'::character varying])::text[]))
#
# Foreign Keys
#
+# fk_rails_... (account_id => accounts.id)
# fk_rails_... (submission_id => submissions.id)
# fk_rails_... (submitter_id => submitters.id)
#
@@ -35,6 +39,7 @@ class SubmissionEvent < ApplicationRecord
serialize :data, coder: JSON
before_validation :set_submission_id, on: :create
+ before_validation :set_account_id, on: :create
enum :event_type, {
send_email: 'send_email',
@@ -63,4 +68,8 @@ class SubmissionEvent < ApplicationRecord
def set_submission_id
self.submission_id = submitter&.submission_id
end
+
+ def set_account_id
+ self.account_id = submitter&.account_id
+ end
end
diff --git a/app/views/shared/_settings_nav.html.erb b/app/views/shared/_settings_nav.html.erb
index 1b06a08a..8e26a8fb 100644
--- a/app/views/shared/_settings_nav.html.erb
+++ b/app/views/shared/_settings_nav.html.erb
@@ -66,7 +66,7 @@
<% end %>
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user != true_user || !current_account.linked_account_account) %>
- <%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premises" }.to_query}", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
+ <%= content_for(:pro_link) || link_to(Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premises" }.to_query}", class: 'text-base hover:bg-base-300', data: { turbo: false }) do %>
<%= t('plans') %>
<%= t('pro') %>
<% end %>
@@ -74,13 +74,13 @@
<% end %>
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user == true_user || current_account.testing?) %>
- <%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/api") : "#{Docuseal::CONSOLE_URL}/on_premises", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
+ <%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/api") : "#{Docuseal::CONSOLE_URL}/on_premises", class: 'text-base hover:bg-base-300', data: { turbo: false } do %>
<% if Docuseal.multitenant? %> API <% else %> <%= t('console') %> <% end %>
<% end %>
<% if Docuseal.multitenant? %>
- <%= link_to console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/embedding/form"), class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
+ <%= link_to console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/embedding/form"), class: 'text-base hover:bg-base-300', data: { turbo: false } do %>
<%= t('embedding') %>
<% end %>
diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml
index 3b6d649f..b9784c04 100644
--- a/config/locales/i18n.yml
+++ b/config/locales/i18n.yml
@@ -23,6 +23,7 @@ en: &en
add_from_google_drive: Add from Google Drive
or_add_from: Or add from
upload_a_new_document: Upload a New Document
+ billing: Billing
hi_there: Hi there
pro: Pro
thanks: Thanks
@@ -843,6 +844,23 @@ en: &en
tablet: Tablet
reset_default: Reset default
send_signature_request_email: Send signature request email
+ last_3_months: Last 3 months
+ last_6_months: Last 6 months
+ last_year: Last year
+ all_time: All time
+ everyone: Everyone
+ daily: Daily
+ weekly: Weekly
+ monthly: Monthly
+ api: API
+ embed: Embed
+ bulk: Bulk
+ invite: Invite
+ api_and_embed: API and Embed
+ period: Period
+ reports: Reports
+ completed_submissions: Completed submissions
+ sms: SMS
submission_sources:
api: API
bulk: Bulk Send
@@ -949,10 +967,12 @@ en: &en
range_without_total: "%{from}-%{to} events"
es: &es
+ billing: Facturación
add_from_google_drive: Agregar desde Google Drive
or_add_from: O agregar desde
upload_a_new_document: Subir nuevo documento
use_direct_file_attachment_links_in_the_documents: Usar enlaces directos de archivos adjuntos en los documentos
+ click_here_to_send_a_reset_password_email_html: ' para enviar un correo electrónico de restablecimiento de contraseña.'
enabled: Habilitado
disabled: Deshabilitado
expirable_file_download_links: Enlaces de descarga de archivos con vencimiento
@@ -1594,6 +1614,7 @@ es: &es
image_field: Campo de Imagen
file_field: Campo de Archivo
select_field: Campo de Selección
+ checkbox_field: Campo de Casilla
multiple_field: Campo Múltiple
radio_field: Campo de Grupo Radio
cells_field: Campo de Celdas
@@ -1770,6 +1791,23 @@ es: &es
tablet: Tableta
reset_default: Restablecer por defecto
send_signature_request_email: Enviar correo de solicitud de firma
+ last_3_months: Últimos 3 meses
+ last_6_months: Últimos 6 meses
+ last_year: Último año
+ all_time: Todo el tiempo
+ everyone: Todos
+ daily: Diario
+ weekly: Semanal
+ monthly: Mensual
+ api: API
+ embed: Integrar
+ bulk: Masivo
+ invite: Invitación
+ api_and_embed: API e Integrar
+ period: Período
+ reports: Informes
+ completed_submissions: Envíos completados
+ sms: SMS
submission_sources:
api: API
bulk: Envío masivo
@@ -1876,6 +1914,7 @@ es: &es
range_without_total: "%{from}-%{to} eventos"
it: &it
+ billing: Fatturazione
add_from_google_drive: Aggiungi da Google Drive
or_add_from: Oppure aggiungi da
upload_a_new_document: Carica nuovo documento
@@ -1909,6 +1948,7 @@ it: &it
edit_per_party: Modifica per partito
signed: Firmato
reply_to: Rispondi a
+ partially_completed: Parzialmente completato
pending_by_me: In sospeso da me
add: Aggiungi
adding: Aggiungendo
@@ -2354,6 +2394,7 @@ it: &it
upload_signature: Carica firma
integration: Integrazione
admin: Amministratore
+ tenant_admin: Amministratore tenant
editor: Editor
viewer: Visualizzatore
member: Membro
@@ -2698,6 +2739,23 @@ it: &it
tablet: Tablet
reset_default: Reimposta predefinito
send_signature_request_email: Invia email di richiesta firma
+ last_3_months: Ultimi 3 mesi
+ last_6_months: Ultimi 6 mesi
+ last_year: Ultimo anno
+ all_time: Tutto il tempo
+ everyone: Tutti
+ daily: Giornaliero
+ weekly: Settimanale
+ monthly: Mensile
+ api: API
+ embed: Incorporare
+ bulk: Massivo
+ invite: Invito
+ api_and_embed: API e Incorporare
+ period: Periodo
+ reports: Rapporti
+ completed_submissions: Invii completati
+ sms: SMS
submission_sources:
api: API
bulk: Invio massivo
@@ -2804,6 +2862,7 @@ it: &it
range_without_total: "%{from}-%{to} eventi"
fr: &fr
+ billing: Facturation
add_from_google_drive: Ajouter depuis Google Drive
or_add_from: Ou ajouter depuis
upload_a_new_document: Téléverser un nouveau document
@@ -3623,6 +3682,24 @@ fr: &fr
tablet: Tablette
reset_default: Réinitialiser par défaut
send_signature_request_email: Envoyer un e-mail de demande de signature
+ last_month: Mois dernier
+ last_3_months: 3 derniers mois
+ last_6_months: 6 derniers mois
+ last_year: Année dernière
+ all_time: Tout le temps
+ everyone: Tout le monde
+ daily: Quotidien
+ weekly: Hebdomadaire
+ monthly: Mensuel
+ api: API
+ embed: Intégrer
+ bulk: En masse
+ invite: Invitation
+ api_and_embed: API et Intégrer
+ period: Période
+ reports: Rapports
+ completed_submissions: Soumissions terminées
+ sms: SMS
submission_sources:
api: API
bulk: Envoi en masse
@@ -3729,6 +3806,7 @@ fr: &fr
range_without_total: "%{from}-%{to} événements"
pt: &pt
+ billing: Pagamentos
add_from_google_drive: Adicionar do Google Drive
or_add_from: Ou adicionar de
upload_a_new_document: Enviar novo documento
@@ -4552,6 +4630,23 @@ pt: &pt
tablet: Tablet
reset_default: Redefinir para padrão
send_signature_request_email: Enviar e-mail de solicitação de assinatura
+ last_3_months: Últimos 3 meses
+ last_6_months: Últimos 6 meses
+ last_year: Último ano
+ all_time: Todo o tempo
+ everyone: Todos
+ daily: Diário
+ weekly: Semanal
+ monthly: Mensal
+ api: API
+ embed: Incorporar
+ bulk: Em massa
+ invite: Convite
+ api_and_embed: API e Incorporar
+ period: Período
+ reports: Relatórios
+ completed_submissions: Envios concluídos
+ sms: SMS
submission_sources:
api: API
bulk: Envio em massa
@@ -4658,6 +4753,7 @@ pt: &pt
range_without_total: "%{from}-%{to} eventos"
de: &de
+ billing: Abrechnung
add_from_google_drive: Aus Google Drive hinzufügen
or_add_from: Oder hinzufügen aus
upload_a_new_document: Neues Dokument hochladen
@@ -5481,6 +5577,23 @@ de: &de
tablet: Tablet
reset_default: Standard zurücksetzen
send_signature_request_email: Signaturanfrage-E-Mail senden
+ last_3_months: Letzte 3 Monate
+ last_6_months: Letzte 6 Monate
+ last_year: Letztes Jahr
+ all_time: Gesamte Zeit
+ everyone: Alle
+ daily: Täglich
+ weekly: Wöchentlich
+ monthly: Monatlich
+ api: API
+ embed: Einbetten
+ bulk: Massenversand
+ invite: Einladung
+ api_and_embed: API und Einbetten
+ period: Zeitraum
+ reports: Berichte
+ completed_submissions: Abgeschlossene Übermittlungen
+ sms: SMS
submission_sources:
api: API
bulk: Massenversand
@@ -5951,6 +6064,7 @@ he:
your_email_could_not_be_reached_this_may_happen_if_there_was_a_typo_in_your_address_or_if_your_mailbox_is_not_available_please_contact_support_email_to_log_in: לא ניתן היה לגשת לדוא"ל שלך. ייתכן שזה קרה עקב שגיאת כתיב בכתובת או אם תיבת הדואר אינה זמינה. אנא פנה ל־support@docuseal.com כדי להתחבר.
nl: &nl
+ billing: Facturatie
add_from_google_drive: Toevoegen vanuit Google Drive
or_add_from: Of toevoegen vanuit
upload_a_new_document: Nieuw document uploaden
@@ -6771,6 +6885,23 @@ nl: &nl
tablet: Tablet
reset_default: Standaard herstellen
send_signature_request_email: E-mail met handtekeningaanvraag verzenden
+ last_3_months: Afgelopen 3 maanden
+ last_6_months: Afgelopen 6 maanden
+ last_year: Afgelopen jaar
+ all_time: Altijd
+ everyone: Iedereen
+ daily: Dagelijks
+ weekly: Wekelijks
+ monthly: Maandelijks
+ api: API
+ embed: Insluiten
+ bulk: Bulk
+ invite: Uitnodiging
+ api_and_embed: API en Insluiten
+ period: Periode
+ reports: Rapporten
+ completed_submissions: Voltooide inzendingen
+ sms: SMS
submission_sources:
api: API
bulk: Bulkverzending
diff --git a/db/migrate/20251121090556_add_account_id_to_submission_events.rb b/db/migrate/20251121090556_add_account_id_to_submission_events.rb
new file mode 100644
index 00000000..6e88c16a
--- /dev/null
+++ b/db/migrate/20251121090556_add_account_id_to_submission_events.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AddAccountIdToSubmissionEvents < ActiveRecord::Migration[8.0]
+ def change
+ add_reference :submission_events, :account, null: true, foreign_key: true, index: true
+ end
+end
diff --git a/db/migrate/20251121092044_add_is_first_to_completed_submitters.rb b/db/migrate/20251121092044_add_is_first_to_completed_submitters.rb
new file mode 100644
index 00000000..baa59d33
--- /dev/null
+++ b/db/migrate/20251121092044_add_is_first_to_completed_submitters.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class AddIsFirstToCompletedSubmitters < ActiveRecord::Migration[8.0]
+ def change
+ # rubocop:disable Rails/ThreeStateBooleanColumn
+ add_column :completed_submitters, :is_first, :boolean
+ # rubocop:enable Rails/ThreeStateBooleanColumn
+
+ add_index :completed_submitters, %i[account_id completed_at],
+ where: 'is_first = TRUE',
+ name: 'index_completed_submitters_account_id_completed_at_is_first'
+ add_index :completed_submitters, :submission_id, unique: true, where: 'is_first = TRUE'
+ end
+end
diff --git a/db/migrate/20251121093511_add_completed_at_index_to_completed_submitters.rb b/db/migrate/20251121093511_add_completed_at_index_to_completed_submitters.rb
new file mode 100644
index 00000000..86880645
--- /dev/null
+++ b/db/migrate/20251121093511_add_completed_at_index_to_completed_submitters.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class AddCompletedAtIndexToCompletedSubmitters < ActiveRecord::Migration[8.0]
+ def change
+ add_index :completed_submitters, %i[account_id completed_at],
+ name: 'index_completed_submitters_on_account_id_and_completed_at'
+ remove_index :completed_submitters, :account_id
+ end
+end
diff --git a/db/migrate/20251121113910_add_send_sms_send_2fa_sms_index.rb b/db/migrate/20251121113910_add_send_sms_send_2fa_sms_index.rb
new file mode 100644
index 00000000..f6f666a8
--- /dev/null
+++ b/db/migrate/20251121113910_add_send_sms_send_2fa_sms_index.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class AddSendSmsSend2faSmsIndex < ActiveRecord::Migration[8.0]
+ def change
+ add_index :submission_events, %i[account_id created_at],
+ where: "event_type IN ('send_sms', 'send_2fa_sms')",
+ name: 'index_submissions_events_on_sms_event_types'
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index c14f705b..5ceb1f31 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.0].define(version: 2025_09_22_053744) do
+ActiveRecord::Schema[8.0].define(version: 2025_11_21_113910) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "plpgsql"
@@ -118,7 +118,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_22_053744) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "verification_method"
- t.index ["account_id"], name: "index_completed_submitters_on_account_id"
+ t.boolean "is_first"
+ t.index ["account_id", "completed_at"], name: "index_completed_submitters_account_id_completed_at_is_first", where: "(is_first = true)"
+ t.index ["account_id", "completed_at"], name: "index_completed_submitters_on_account_id_and_completed_at"
+ t.index ["submission_id"], name: "index_completed_submitters_on_submission_id", unique: true, where: "(is_first = true)"
t.index ["submitter_id"], name: "index_completed_submitters_on_submitter_id", unique: true
end
@@ -293,6 +296,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_22_053744) do
t.datetime "event_timestamp", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.bigint "account_id"
+ t.index ["account_id", "created_at"], name: "index_submissions_events_on_sms_event_types", where: "((event_type)::text = ANY ((ARRAY['send_sms'::character varying, 'send_2fa_sms'::character varying])::text[]))"
+ t.index ["account_id"], name: "index_submission_events_on_account_id"
t.index ["created_at"], name: "index_submission_events_on_created_at"
t.index ["submission_id"], name: "index_submission_events_on_submission_id"
t.index ["submitter_id"], name: "index_submission_events_on_submitter_id"
@@ -506,6 +512,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_22_053744) do
add_foreign_key "oauth_access_grants", "users", column: "resource_owner_id"
add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id"
add_foreign_key "oauth_access_tokens", "users", column: "resource_owner_id"
+ add_foreign_key "submission_events", "accounts"
add_foreign_key "submission_events", "submissions"
add_foreign_key "submission_events", "submitters"
add_foreign_key "submissions", "templates"
diff --git a/lib/submission_events.rb b/lib/submission_events.rb
index 1705ba41..570652e1 100644
--- a/lib/submission_events.rb
+++ b/lib/submission_events.rb
@@ -20,4 +20,14 @@ module SubmissionEvents
**data
}.compact_blank)
end
+
+ def populate_account_id
+ Account.find_each do |account|
+ ids = account.submissions.pluck(:id)
+
+ ids.each_slice(10_000).each do |batch|
+ SubmissionEvent.where(submission_id: batch).update_all(account_id: account.id)
+ end
+ end
+ end
end
diff --git a/lib/submissions/timestamp_handler.rb b/lib/submissions/timestamp_handler.rb
index a152a97f..8dd3179c 100644
--- a/lib/submissions/timestamp_handler.rb
+++ b/lib/submissions/timestamp_handler.rb
@@ -3,6 +3,7 @@
module Submissions
class TimestampHandler
HASH_ALGORITHM = 'SHA256'
+ TIMEOUT = 10
TimestampError = Class.new(StandardError)
@@ -32,6 +33,8 @@ module Submissions
uri = Addressable::URI.parse(tsa_url)
conn = Faraday.new(uri.origin) do |c|
+ c.options.read_timeout = TIMEOUT
+ c.options.open_timeout = TIMEOUT
c.basic_auth(uri.user, uri.password) if uri.password.present?
end
diff --git a/lib/submitters.rb b/lib/submitters.rb
index 2738f773..af8e0c69 100644
--- a/lib/submitters.rb
+++ b/lib/submitters.rb
@@ -251,4 +251,20 @@ module Submitters
true
end
+
+ def populate_completed_is_first
+ Account.find_each do |account|
+ submissions_index = {}
+
+ CompletedSubmitter.where(account_id: account.id).order(:account_id, :completed_at).each do |cs|
+ submissions_index[cs.submission_id] ||= cs.submitter_id
+
+ cs.update_columns(is_first: submissions_index[cs.submission_id] == cs.submitter_id)
+ rescue ActiveRecord::RecordNotUnique
+ CompletedSubmitter.where(submission_id: cs.submission_id).update_all(is_first: false)
+
+ cs.update_columns(is_first: submissions_index[cs.submission_id] == cs.submitter_id)
+ end
+ end
+ end
end
diff --git a/lib/time_utils.rb b/lib/time_utils.rb
index c1c0d07e..0af5a928 100644
--- a/lib/time_utils.rb
+++ b/lib/time_utils.rb
@@ -22,7 +22,7 @@ module TimeUtils
DEFAULT_DATE_FORMAT_US = 'MM/DD/YYYY'
DEFAULT_DATE_FORMAT = 'DD/MM/YYYY'
- US_TIMEZONES = %w[EST CST MST PST HST AKDT].freeze
+ US_TIMEZONES = %w[EST EDT CST CDT MST MDT PST PDT HST HDT AKST AKDT].freeze
module_function
diff --git a/package.json b/package.json
index 4b664b19..af04328b 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-macros": "^3.1.0",
"canvas-confetti": "^1.6.0",
+ "chart.js": "^4.5.1",
"codemirror": "^6.0.2",
"compression-webpack-plugin": "10.0.0",
"css-loader": "^6.7.3",
diff --git a/yarn.lock b/yarn.lock
index 2faf9648..4b3b5bf0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1355,6 +1355,11 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
+"@kurkle/color@^0.3.0":
+ version "0.3.4"
+ resolved "https://registry.yarnpkg.com/@kurkle/color/-/color-0.3.4.tgz#4d4ff677e1609214fc71c580125ddddd86abcabf"
+ integrity sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==
+
"@leichtgewicht/ip-codec@^2.0.1":
version "2.0.4"
resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b"
@@ -2341,6 +2346,13 @@ chalk@^4.0, chalk@^4.0.0, chalk@^4.1.0:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
+chart.js@^4.5.1:
+ version "4.5.1"
+ resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.5.1.tgz#19dd1a9a386a3f6397691672231cb5fc9c052c35"
+ integrity sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==
+ dependencies:
+ "@kurkle/color" "^0.3.0"
+
"chokidar@>=3.0.0 <4.0.0", chokidar@^3.5.3:
version "3.5.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"