diff --git a/app/controllers/admin/timeline_controller.rb b/app/controllers/admin/timeline_controller.rb
index c1389f3..0d51ae7 100644
--- a/app/controllers/admin/timeline_controller.rb
+++ b/app/controllers/admin/timeline_controller.rb
@@ -12,29 +12,31 @@ class Admin::TimelineController < Admin::BaseController
@next_date = @date + 1.day
@prev_date = @date - 1.day
- # Step 1: Consolidate User Loading
- # Note: current_user in an admin controller is the admin user.
- # The original list of user_ids_to_fetch includes some hardcoded IDs.
- # This logic is preserved but might need review for an admin-specific timeline.
- user_ids_to_fetch = [
- current_user&.id, # Admin's own data (if they are also a tracked user)
- 1, # Example: User.find(1) if it's relevant
- 10, 1792, 69, 1476, 805, 2003, 2011 # Original hardcoded IDs
- ].compact.uniq
+ # User selection logic
+ raw_user_ids = params[:user_ids].present? ? params[:user_ids].split(',').map(&:to_i).uniq : []
+
+ # Always include current_user (admin)
+ @selected_user_ids = [current_user.id] + raw_user_ids
+ @selected_user_ids.uniq!
+
+ user_ids_to_fetch = @selected_user_ids
+ # Fetch all valid users in one go
users_by_id = User.where(id: user_ids_to_fetch).index_by(&:id)
+ # Ensure we only use IDs of users that actually exist
+ valid_user_ids_to_fetch = users_by_id.keys
- mappings_by_user_project = ProjectRepoMapping.where(user_id: users_by_id.keys)
+ mappings_by_user_project = ProjectRepoMapping.where(user_id: valid_user_ids_to_fetch)
.group_by(&:user_id)
.transform_values { |mappings| mappings.index_by(&:project_name) }
- users_to_process = user_ids_to_fetch.map { |id| users_by_id[id] }.compact
+ users_to_process = valid_user_ids_to_fetch.map { |id| users_by_id[id] }.compact
start_of_day_timestamp = @date.beginning_of_day.to_f
end_of_day_timestamp = @date.end_of_day.to_f
all_heartbeats = Heartbeat
- .where(user_id: user_ids_to_fetch, deleted_at: nil)
+ .where(user_id: valid_user_ids_to_fetch, deleted_at: nil)
.where('time >= ? AND time <= ?', start_of_day_timestamp, end_of_day_timestamp)
.select(:id, :user_id, :time, :entity, :project, :editor, :language)
.order(:user_id, :time)
@@ -42,7 +44,7 @@ class Admin::TimelineController < Admin::BaseController
heartbeats_by_user_id = all_heartbeats.group_by(&:user_id)
- @users_with_timeline_data = []
+ @users_with_timeline_data_unordered = []
users_to_process.each do |user|
user_daily_heartbeats_relation = Heartbeat.where(user_id: user.id, deleted_at: nil)
@@ -96,17 +98,85 @@ class Admin::TimelineController < Admin::BaseController
end
end
- if calculated_spans_with_details.any? || total_coded_time_seconds > 0
- @users_with_timeline_data << {
- user: user,
- spans: calculated_spans_with_details,
- total_coded_time: total_coded_time_seconds # Actual coded time for the user for the day
- }
- end
+ # Add user data, even if no spans/time, if they were explicitly selected
+ @users_with_timeline_data_unordered << {
+ user: user,
+ spans: calculated_spans_with_details,
+ total_coded_time: total_coded_time_seconds # Actual coded time for the user for the day
+ }
end
+
+ # Order @users_with_timeline_data according to @selected_user_ids
+ # This ensures that if a user was explicitly selected they appear in the timeline
+ # even if they have no heartbeats for the day.
+ data_map_for_ordering = @users_with_timeline_data_unordered.index_by { |data| data[:user].id }
+ @users_with_timeline_data = @selected_user_ids.map do |id|
+ data_map_for_ordering[id] || (users_by_id[id] ? { user: users_by_id[id], spans: [], total_coded_time: 0 } : nil)
+ end.compact
- @primary_user = users_to_process.first || current_user # current_user is the admin
+ # For Stimulus: provide initial selected users with details
+ @initial_selected_user_objects = User.where(id: @selected_user_ids)
+ .select(:id, :username, :slack_username, :github_username, :slack_avatar_url, :github_avatar_url)
+ .map { |u| { id: u.id, display_name: u.display_name, avatar_url: u.avatar_url } }
+ .sort_by { |u_obj| @selected_user_ids.index(u_obj[:id]) || Float::INFINITY } # Preserve order
+
+ @primary_user = @users_with_timeline_data.first&.[](:user) || current_user
render :show # Renders app/views/admin/timeline/show.html.erb
end
+
+ def search_users
+ query_term = params[:query].to_s.downcase
+ users = User.where("LOWER(username) LIKE :query OR LOWER(slack_username) LIKE :query OR EXISTS (SELECT 1 FROM email_addresses WHERE email_addresses.user_id = users.id AND LOWER(email_addresses.email) LIKE :query)", query: "%#{query_term}%")
+ .order(Arel.sql("CASE WHEN LOWER(username) = #{ActiveRecord::Base.connection.quote(query_term)} THEN 0 ELSE 1 END, username ASC")) # Prioritize exact match
+ .limit(20)
+ .select(:id, :username, :slack_username, :github_username, :slack_avatar_url, :github_avatar_url)
+
+ results = users.map do |user|
+ {
+ id: user.id,
+ display_name: user.display_name,
+ avatar_url: user.avatar_url
+ }
+ end
+ render json: results
+ end
+
+ def leaderboard_users
+ period = params[:period]
+ limit = 25
+
+ leaderboard_period_type = (period == 'last_7_days') ? :last_7_days : :daily
+ start_date = Date.current # Leaderboard job for :last_7_days uses Date.current as start_date
+
+ leaderboard = Leaderboard.where.not(finished_generating_at: nil)
+ .find_by(start_date: start_date, period_type: leaderboard_period_type, deleted_at: nil)
+
+ user_ids_from_leaderboard = leaderboard ? leaderboard.entries.order(total_seconds: :desc).limit(limit).pluck(:user_id) : []
+
+ all_ids_to_fetch = user_ids_from_leaderboard.dup
+ all_ids_to_fetch.unshift(current_user.id).uniq!
+
+ users_data = User.where(id: all_ids_to_fetch)
+ .select(:id, :username, :slack_username, :github_username, :slack_avatar_url, :github_avatar_url)
+ .index_by(&:id)
+
+ final_user_objects = []
+ # Add admin first
+ if admin_data = users_data[current_user.id]
+ final_user_objects << { id: admin_data.id, display_name: admin_data.display_name, avatar_url: admin_data.avatar_url }
+ end
+
+ # Add leaderboard users, ensuring no duplicates and respecting limit
+ user_ids_from_leaderboard.each do |uid|
+ break if final_user_objects.size >= limit
+ next if uid == current_user.id
+
+ if user_data = users_data[uid]
+ final_user_objects << { id: user_data.id, display_name: user_data.display_name, avatar_url: user_data.avatar_url }
+ end
+ end
+
+ render json: { users: final_user_objects }
+ end
end
\ No newline at end of file
diff --git a/app/javascript/controllers/admin_timeline_user_selector_controller.js b/app/javascript/controllers/admin_timeline_user_selector_controller.js
new file mode 100644
index 0000000..12fd020
--- /dev/null
+++ b/app/javascript/controllers/admin_timeline_user_selector_controller.js
@@ -0,0 +1,304 @@
+import { Controller } from "@hotwired/stimulus"
+import { FetchRequest } from '@rails/request.js'
+
+// Helper for debouncing
+function debounce(func, wait) {
+ let timeout;
+ return function executedFunction(...args) {
+ const later = () => {
+ clearTimeout(timeout);
+ func(...args);
+ };
+ clearTimeout(timeout);
+ timeout = setTimeout(later, wait);
+ };
+}
+
+export default class extends Controller {
+ static targets = ["searchInput", "searchResults", "selectedUsersContainer", "userIdsInput", "dateInput"]
+
+ static values = {
+ currentUserJson: Object,
+ initialSelectedUsersJson: Array,
+ searchUrl: String,
+ leaderboardUsersUrl: String
+ }
+
+ connect() {
+ this.selectedUsers = new Map(); // Stores selected users {id: userObject}
+ this._debouncedSearchImpl = debounce(this.search.bind(this), 300);
+
+ try {
+ console.log("Initializing timeline user selector");
+
+ // Parse the JSON if it's a string
+ let adminUser = this.currentUserJsonValue;
+ if (typeof adminUser === 'string') {
+ adminUser = JSON.parse(adminUser);
+ console.log("Parsed admin user:", adminUser);
+ }
+
+ if (adminUser && adminUser.id) {
+ // Ensure ID is a number
+ adminUser.id = parseInt(adminUser.id, 10);
+ this.addUserToSelection(adminUser, true); // true = isPillForAdmin
+ }
+
+ // Parse the JSON if it's a string
+ let initialUsers = this.initialSelectedUsersJsonValue;
+ if (typeof initialUsers === 'string') {
+ initialUsers = JSON.parse(initialUsers);
+ console.log("Parsed initial users:", initialUsers);
+ }
+
+ // Make sure it's an array
+ if (Array.isArray(initialUsers)) {
+ initialUsers.forEach(user => {
+ // Ensure ID is a number
+ if (user && user.id) {
+ user.id = parseInt(user.id, 10);
+ if (!adminUser || user.id !== adminUser.id) {
+ this.addUserToSelection(user, false);
+ }
+ }
+ });
+ }
+
+ this.updateHiddenInput();
+ this.updateDateLinks();
+ } catch (error) {
+ console.error("Error initializing user selector:", error);
+ }
+ }
+
+ async search() {
+ const query = this.searchInputTarget.value;
+ if (query.length < 2) {
+ this.searchResultsTarget.innerHTML = "";
+ this.searchResultsTarget.classList.remove('active');
+ return;
+ }
+
+ const request = new FetchRequest('get', `${this.searchUrlValue}?query=${encodeURIComponent(query)}`, { responseKind: 'json' })
+ const response = await request.perform()
+
+ if (response.ok) {
+ const users = await response.json;
+ console.log(`Found ${users.length} users in search results`);
+ this.renderSearchResults(users);
+ } else {
+ this.searchResultsTarget.innerHTML = "
Error searching users";
+ this.searchResultsTarget.classList.add('active');
+ }
+ }
+
+ renderSearchResults(users) {
+ if (users.length === 0) {
+ this.searchResultsTarget.innerHTML = "No users found";
+ } else {
+ this.searchResultsTarget.innerHTML = users.map(user => `
+
+
${this.escapeHTML(user.display_name)}
+
+ `).join("");
+ }
+ // Make sure the result list is shown
+ this.searchResultsTarget.style.display = 'block';
+ this.searchResultsTarget.classList.add('active');
+ console.log("Search results rendered and active class added");
+ }
+
+ selectUser(event) {
+ event.preventDefault();
+ // Get values directly from data attributes
+ const element = event.currentTarget;
+ const userId = parseInt(element.getAttribute(`data-${this.identifier}-user-id-value`), 10);
+ const displayName = element.getAttribute(`data-${this.identifier}-user-display-name-value`);
+ const avatarUrl = element.getAttribute(`data-${this.identifier}-user-avatar-url-value`);
+
+ console.log("Selected user data:", { userId, displayName, avatarUrl });
+
+ if (isNaN(userId)) {
+ console.error("Invalid user ID");
+ this.clearSearch();
+ return;
+ }
+
+ if (this.selectedUsers.has(userId)) {
+ this.clearSearch();
+ return;
+ }
+
+ const user = {
+ id: userId,
+ display_name: displayName,
+ avatar_url: avatarUrl
+ };
+
+ this.addUserToSelection(user, false);
+
+ this.clearSearch();
+ this.updateHiddenInput();
+ this.updateDateLinks();
+ }
+
+ addUserToSelection(user, isPillForAdmin = false) {
+ // Make sure we have a valid user ID
+ if (!user || !user.id || isNaN(parseInt(user.id, 10))) {
+ console.error("Invalid user object or ID:", user);
+ return;
+ }
+
+ // Convert ID to number just to be safe
+ const userId = parseInt(user.id, 10);
+ user.id = userId;
+
+ if (this.selectedUsers.has(userId)) return;
+
+ console.log("Adding user to selection:", user);
+ this.selectedUsers.set(userId, user);
+
+ const pill = document.createElement("span");
+ // Using Pico.css friendly classes if available, or simple badges
+ pill.classList.add("badge", "secondary", "me-1", "mb-1", "d-inline-flex", "align-items-center", "user-pill");
+ pill.style.backgroundColor = isPillForAdmin ? 'var(--pico-primary-focus)' : 'var(--pico-muted-border-color)';
+ pill.style.color = isPillForAdmin ? 'var(--pico-primary-inverse)' : 'var(--pico-color)';
+ pill.style.padding = '0.3em 0.6em';
+ pill.style.borderRadius = 'var(--pico-border-radius)';
+
+ pill.dataset.userId = userId;
+
+ let avatarImg = '';
+ if (user.avatar_url) {
+ avatarImg = `
`;
+ }
+
+ pill.innerHTML = `${avatarImg} ${this.escapeHTML(user.display_name)}`;
+
+ if (!isPillForAdmin) {
+ const removeButton = document.createElement("button");
+ removeButton.type = "button";
+ removeButton.classList.add("btn-close-custom", "ms-1"); // Custom class for styling
+ removeButton.innerHTML = "×"; // Simple 'x'
+ removeButton.dataset.action = `click->${this.identifier}#removeUser`;
+ removeButton.setAttribute("aria-label", "Remove");
+ pill.appendChild(removeButton);
+ }
+
+ this.selectedUsersContainerTarget.appendChild(pill);
+ }
+
+ removeUser(event) {
+ const pill = event.target.closest(".user-pill");
+ const userId = parseInt(pill.dataset.userId, 10);
+
+ console.log("Removing user with ID:", userId);
+
+ // Don't allow removing if it's the current user or an invalid ID
+ if (isNaN(userId) || (this.currentUserJsonValue && userId === this.currentUserJsonValue.id)) return;
+
+ this.selectedUsers.delete(userId);
+ pill.remove();
+ this.updateHiddenInput();
+ this.updateDateLinks();
+ }
+
+ async applyPreset(event) {
+ event.preventDefault();
+ const period = event.currentTarget.dataset.period;
+
+ const request = new FetchRequest('get', `${this.leaderboardUsersUrlValue}?period=${period}`, { responseKind: 'json' })
+ const response = await request.perform()
+
+ if (response.ok) {
+ const data = await response.json;
+ const presetUsers = data.users;
+
+ this.selectedUsersContainerTarget.querySelectorAll('.user-pill').forEach(pill => {
+ const uid = parseInt(pill.dataset.userId);
+ if (uid !== this.currentUserJsonValue.id) {
+ pill.remove();
+ this.selectedUsers.delete(uid);
+ }
+ });
+
+ presetUsers.forEach(user => {
+ if (user.id !== this.currentUserJsonValue.id) {
+ this.addUserToSelection(user, false);
+ }
+ });
+
+ this.updateHiddenInput();
+ this.updateDateLinks();
+ } else {
+ console.error("Failed to load preset users");
+ alert("Could not load preset users. Please try again.");
+ }
+ }
+
+ updateHiddenInput() {
+ this.userIdsInputTarget.value = Array.from(this.selectedUsers.keys()).join(',');
+ }
+
+ updateDateLinks() {
+ const selectedIds = Array.from(this.selectedUsers.keys()).join(',');
+
+ document.querySelectorAll('a[data-date-nav-link="true"]').forEach(link => {
+ const url = new URL(link.href, window.location.origin);
+ if (selectedIds) {
+ url.searchParams.set('user_ids', selectedIds);
+ } else {
+ url.searchParams.delete('user_ids');
+ }
+ link.href = url.toString();
+ });
+ }
+
+ submitForm() {
+ const form = this.element.querySelector('form#timeline-filter-form');
+ if (form) {
+ if(this.hasDateInputTarget && form.elements.date) {
+ form.elements.date.value = this.dateInputTarget.value;
+ }
+ form.requestSubmit();
+ }
+ }
+
+ handleKeydown(event) {
+ if (event.key === "Escape") {
+ this.clearSearch();
+ }
+ }
+
+ clearSearch() {
+ this.searchInputTarget.value = "";
+ this.searchResultsTarget.innerHTML = "";
+ this.searchResultsTarget.classList.remove('active');
+ this.searchResultsTarget.style.display = 'none';
+ }
+
+ hideResultsDelayed() {
+ setTimeout(() => {
+ if (!this.searchResultsTarget.matches(':hover')) {
+ this.clearSearch();
+ }
+ }, 200);
+ }
+
+ escapeHTML(str) {
+ if (str === null || str === undefined) return '';
+ return str.toString().replace(/[&<>"']/g, function (match) {
+ return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[match];
+ });
+ }
+
+ // This is just a wrapper to satisfy Stimulus data-action format
+ // The actual debounced search implementation is stored in _debouncedSearchImpl
+ debouncedSearch() {
+ this._debouncedSearchImpl();
+ }
+}
\ No newline at end of file
diff --git a/app/views/admin/timeline/show.html.erb b/app/views/admin/timeline/show.html.erb
index 30d9d00..b99f590 100644
--- a/app/views/admin/timeline/show.html.erb
+++ b/app/views/admin/timeline/show.html.erb
@@ -43,21 +43,77 @@
# Add fixed spacers for the header:
total_min_scroll_width_px = (activity_col_area_start_rem * 16) + min_header_content_width_px + (activity_col_area_end_rem * 16)
- # No longer using flex-based width calculation for headers
- # css_header_item_width = "calc((100% - #{(num_users > 1 ? (num_users - 1) * gutter_rem : 0)}rem) / #{num_users})"
+ # Current admin user and selected users for Stimulus
+ current_admin_user = {
+ id: current_user.id,
+ display_name: current_user.display_name,
+ avatar_url: current_user.avatar_url
+ }
+ current_admin_user_json = current_admin_user.to_json
+
+ initial_selected_users_json = @initial_selected_user_objects.to_json
+ current_date_for_form = @date.to_s
%>
+
+
+
<%= @date.in_time_zone(primary_user_tz).strftime("%A, %B %-d, %Y") %>
- <%= link_to "← Prev", admin_timeline_path(date: @prev_date.to_s), class: "button button-outline" %>
- <%= link_to "Today", admin_timeline_path(date: Time.current.to_date.to_s), class: "button button-outline" %>
- <%= link_to "Next →", admin_timeline_path(date: @next_date.to_s), class: "button button-outline" %>
+ <%= link_to "← Prev", admin_timeline_path(date: @prev_date.to_s), class: "button button-outline", data: { "date-nav-link": "true" } %>
+ <%= link_to "Today", admin_timeline_path(date: Time.current.to_date.to_s), class: "button button-outline", data: { "date-nav-link": "true" } %>
+ <%= link_to "Next →", admin_timeline_path(date: @next_date.to_s), class: "button button-outline", data: { "date-nav-link": "true" } %>
@@ -237,4 +293,53 @@
-
\ No newline at end of file
+
+
+<% content_for :head do %>
+
+<% end %>
\ No newline at end of file
diff --git a/config/importmap.rb b/config/importmap.rb
index 909dfc5..b3cb44f 100644
--- a/config/importmap.rb
+++ b/config/importmap.rb
@@ -5,3 +5,4 @@ pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
+pin "@rails/request.js", to: "https://ga.jspm.io/npm:@rails/request.js@0.0.8/src/index.js"
diff --git a/config/routes.rb b/config/routes.rb
index b383c58..8e7464d 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -19,6 +19,8 @@ Rails.application.routes.draw do
namespace :admin do
get 'timeline', to: 'timeline#show', as: :timeline
+ get 'timeline/search_users', to: 'timeline#search_users'
+ get 'timeline/leaderboard_users', to: 'timeline#leaderboard_users'
end
if Rails.env.development?
diff --git a/vendor/javascript/@rails--request.js.js b/vendor/javascript/@rails--request.js.js
new file mode 100644
index 0000000..4c5dfd1
--- /dev/null
+++ b/vendor/javascript/@rails--request.js.js
@@ -0,0 +1,4 @@
+// @rails/request.js@0.0.12 downloaded from https://ga.jspm.io/npm:@rails/request.js@0.0.12/src/index.js
+
+class FetchResponse{constructor(t){this.response=t}get statusCode(){return this.response.status}get redirected(){return this.response.redirected}get ok(){return this.response.ok}get unauthenticated(){return this.statusCode===401}get unprocessableEntity(){return this.statusCode===422}get authenticationURL(){return this.response.headers.get("WWW-Authenticate")}get contentType(){const t=this.response.headers.get("Content-Type")||"";return t.replace(/;.*$/,"")}get headers(){return this.response.headers}get html(){return this.contentType.match(/^(application|text)\/(html|xhtml\+xml)$/)?this.text:Promise.reject(new Error(`Expected an HTML response but got "${this.contentType}" instead`))}get json(){return this.contentType.match(/^application\/.*json$/)?this.responseJson||(this.responseJson=this.response.json()):Promise.reject(new Error(`Expected a JSON response but got "${this.contentType}" instead`))}get text(){return this.responseText||(this.responseText=this.response.text())}get isTurboStream(){return this.contentType.match(/^text\/vnd\.turbo-stream\.html/)}get isScript(){return this.contentType.match(/\b(?:java|ecma)script\b/)}async renderTurboStream(){if(!this.isTurboStream)return Promise.reject(new Error(`Expected a Turbo Stream response but got "${this.contentType}" instead`));window.Turbo?await window.Turbo.renderStreamMessage(await this.text):console.warn("You must set `window.Turbo = Turbo` to automatically process Turbo Stream events with request.js")}async activeScript(){if(!this.isScript)return Promise.reject(new Error(`Expected a Script response but got "${this.contentType}" instead`));{const t=document.createElement("script");const e=document.querySelector("meta[name=csp-nonce]");if(e){const n=e.nonce===""?e.content:e.nonce;n&&t.setAttribute("nonce",n)}t.innerHTML=await this.text;document.body.appendChild(t)}}}class RequestInterceptor{static register(t){this.interceptor=t}static get(){return this.interceptor}static reset(){this.interceptor=void 0}}function t(t){const e=document.cookie?document.cookie.split("; "):[];const n=`${encodeURIComponent(t)}=`;const s=e.find((t=>t.startsWith(n)));if(s){const t=s.split("=").slice(1).join("=");if(t)return decodeURIComponent(t)}}function e(t){const e={};for(const n in t){const s=t[n];s!==void 0&&(e[n]=s)}return e}function n(t){const e=document.head.querySelector(`meta[name="${t}"]`);return e&&e.content}function s(t){return[...t].reduce(((t,[e,n])=>t.concat(typeof n==="string"?[[e,n]]:[])),[])}function r(t,e){for(const[n,s]of e)if(!(s instanceof window.File))if(t.has(n)&&!n.includes("[]")){t.delete(n);t.set(n,s)}else t.append(n,s)}class FetchRequest{constructor(t,e,n={}){this.method=t;this.options=n;this.originalUrl=e.toString()}async perform(){try{const t=RequestInterceptor.get();t&&await t(this)}catch(t){console.error(t)}const t=this.responseKind==="turbo-stream"&&window.Turbo?window.Turbo.fetch:window.fetch;const e=new FetchResponse(await t(this.url,this.fetchOptions));if(e.unauthenticated&&e.authenticationURL)return Promise.reject(window.location.href=e.authenticationURL);e.isScript&&await e.activeScript();const n=e.ok||e.unprocessableEntity;n&&e.isTurboStream&&await e.renderTurboStream();return e}addHeader(t,e){const n=this.additionalHeaders;n[t]=e;this.options.headers=n}sameHostname(){if(!this.originalUrl.startsWith("http:")&&!this.originalUrl.startsWith("https:"))return true;try{return new URL(this.originalUrl).hostname===window.location.hostname}catch(t){return true}}get fetchOptions(){return{method:this.method.toUpperCase(),headers:this.headers,body:this.formattedBody,signal:this.signal,credentials:this.credentials,redirect:this.redirect,keepalive:this.keepalive}}get headers(){const t={"X-Requested-With":"XMLHttpRequest","Content-Type":this.contentType,Accept:this.accept};this.sameHostname()&&(t["X-CSRF-Token"]=this.csrfToken);return e(Object.assign(t,this.additionalHeaders))}get csrfToken(){return t(n("csrf-param"))||n("csrf-token")}get contentType(){return this.options.contentType?this.options.contentType:this.body==null||this.body instanceof window.FormData?void 0:this.body instanceof window.File?this.body.type:"application/json"}get accept(){switch(this.responseKind){case"html":return"text/html, application/xhtml+xml";case"turbo-stream":return"text/vnd.turbo-stream.html, text/html, application/xhtml+xml";case"json":return"application/json, application/vnd.api+json";case"script":return"text/javascript, application/javascript";default:return"*/*"}}get body(){return this.options.body}get query(){const t=(this.originalUrl.split("?")[1]||"").split("#")[0];const e=new URLSearchParams(t);let n=this.options.query;n=n instanceof window.FormData?s(n):n instanceof window.URLSearchParams?n.entries():Object.entries(n||{});r(e,n);const o=e.toString();return o.length>0?`?${o}`:""}get url(){return this.originalUrl.split("?")[0].split("#")[0]+this.query}get responseKind(){return this.options.responseKind||"html"}get signal(){return this.options.signal}get redirect(){return this.options.redirect||"follow"}get credentials(){return this.options.credentials||"same-origin"}get keepalive(){return this.options.keepalive||false}get additionalHeaders(){return this.options.headers||{}}get formattedBody(){const t=Object.prototype.toString.call(this.body)==="[object String]";const e=this.headers["Content-Type"]==="application/json";return e&&!t?JSON.stringify(this.body):this.body}}async function o(t,e){const n=new FetchRequest("get",t,e);return n.perform()}async function i(t,e){const n=new FetchRequest("post",t,e);return n.perform()}async function c(t,e){const n=new FetchRequest("put",t,e);return n.perform()}async function a(t,e){const n=new FetchRequest("patch",t,e);return n.perform()}async function h(t,e){const n=new FetchRequest("delete",t,e);return n.perform()}export{FetchRequest,FetchResponse,RequestInterceptor,h as destroy,o as get,a as patch,i as post,c as put};
+