From 22cac3321968833796320ace078d653f31d72834 Mon Sep 17 00:00:00 2001 From: Raygen Rupe Date: Tue, 30 Jun 2026 11:24:08 -0600 Subject: [PATCH 1/5] feat(lookout): add session tracking columns, unassigned scope, and assignment endpoints --- .../projects/lookout_sessions_controller.rb | 24 +++++- .../controllers/lookout_capture_controller.js | 47 ++++++++++++ app/models/lookout_session.rb | 28 ++++--- .../forward_heartbeats.turbo_stream.erb | 1 + .../lookout_sessions/skip.turbo_stream.erb | 1 + config/routes.rb | 1 + ..._hackatime_tracking_to_lookout_sessions.rb | 7 ++ db/schema.rb | 5 +- .../lookout_sessions_controller_test.rb | 74 ++++++++++++++++++- test/models/lookout_session_test.rb | 47 +++++++++--- 10 files changed, 207 insertions(+), 28 deletions(-) create mode 100644 app/views/projects/lookout_sessions/forward_heartbeats.turbo_stream.erb create mode 100644 app/views/projects/lookout_sessions/skip.turbo_stream.erb create mode 100644 db/migrate/20260630182848_add_hackatime_tracking_to_lookout_sessions.rb diff --git a/app/controllers/projects/lookout_sessions_controller.rb b/app/controllers/projects/lookout_sessions_controller.rb index a4114f4cf..72d8f4132 100644 --- a/app/controllers/projects/lookout_sessions_controller.rb +++ b/app/controllers/projects/lookout_sessions_controller.rb @@ -1,7 +1,7 @@ class Projects::LookoutSessionsController < ApplicationController before_action -> { head :not_found unless Flipper.enabled?(:hardware_flow, current_user) || Flipper.enabled?(:lookout, current_user) } before_action :set_project - before_action :set_lookout_session, only: %i[show record stop set_mode forward_heartbeats] + before_action :set_lookout_session, only: %i[show record stop set_mode forward_heartbeats skip] def create authorize @project, :create_devlog? @@ -110,12 +110,32 @@ def forward_heartbeats result = LookoutHeartbeatForwarder.call(@lookout_session, project_name: project_name) if result.ok? - render json: { ok: true, project: project_name, heartbeats: result.count } + @lookout_session.update!( + hackatime_project_name: project_name, + hackatime_forwarded_at: Time.current + ) + respond_to do |format| + format.turbo_stream + format.json { render json: { ok: true, project: project_name, heartbeats: result.count } } + format.html { redirect_to project_path(@project) } + end else render json: { error: result.error }, status: :unprocessable_entity end end + def skip + authorize @project, :create_devlog? + + @lookout_session.update!(hackatime_skipped: true) + + respond_to do |format| + format.turbo_stream + format.json { render json: { ok: true } } + format.html { redirect_to project_path(@project) } + end + end + def status authorize @project, :create_devlog? diff --git a/app/javascript/controllers/lookout_capture_controller.js b/app/javascript/controllers/lookout_capture_controller.js index 85909c7df..c75b12216 100644 --- a/app/javascript/controllers/lookout_capture_controller.js +++ b/app/javascript/controllers/lookout_capture_controller.js @@ -56,6 +56,53 @@ export default class extends Controller { this.mode = null; this.baseSeconds = 0; this.lastSyncMs = Date.now(); + this.checkExistingSession(); + } + + async checkExistingSession() { + let data; + try { + data = await this.getJson(`/api/sessions/${this.tokenValue}/status`); + } catch (_) { + return; // Can't reach Lookout — stay on the chooser + } + + const { status } = data; + if (!status || status === "pending") return; + + if (status === "complete") { + this.stopped = true; + this.showStage("done"); + this.chooseDestination(); + this.setText(this.doneStatusTarget, "Your timelapse is ready!"); + await this.revealVideo(); + } else if (status === "failed") { + this.showError( + "This recording failed to process — you can try again or start a new session.", + ); + } else if (status === "stopped" || status === "compiling") { + this.stopped = true; + this.showStage("done"); + this.chooseDestination(); + this.setText(this.doneStatusTarget, "Saving your recording…"); + this.pollStatus(this.doneStatusTarget, { showVideo: true }); + } else if (status === "active" || status === "paused") { + // Desktop app is recording — skip the chooser and resume polling + this.mode = "desktop"; + this.showStage("desktop"); + this.deepLinkTextTarget.textContent = this.deepLinkValue; + this.deepLinkTarget.href = this.deepLinkValue; + this.setText( + this.desktopStatusTarget, + status === "paused" + ? "Recording is paused in the Lookout app." + : "Recording is in progress in the Lookout app…", + ); + this.pollStatus(this.desktopStatusTarget, { + showVideo: true, + doneOnComplete: true, + }); + } } disconnect() { diff --git a/app/models/lookout_session.rb b/app/models/lookout_session.rb index beb5725f7..a644ab884 100644 --- a/app/models/lookout_session.rb +++ b/app/models/lookout_session.rb @@ -2,18 +2,21 @@ # # Table name: lookout_sessions # -# id :bigint not null, primary key -# duration_seconds :integer default(0) -# mode :string -# recording_url :string -# started_at :datetime -# status :string default("pending") -# stopped_at :datetime -# token :string not null -# created_at :datetime not null -# updated_at :datetime not null -# project_id :bigint not null -# user_id :bigint not null +# id :bigint not null, primary key +# duration_seconds :integer default(0) +# hackatime_forwarded_at :datetime +# hackatime_project_name :string +# hackatime_skipped :boolean default(FALSE), not null +# mode :string +# recording_url :string +# started_at :datetime +# status :string default("pending") +# stopped_at :datetime +# token :string not null +# created_at :datetime not null +# updated_at :datetime not null +# project_id :bigint not null +# user_id :bigint not null # # Indexes # @@ -49,6 +52,7 @@ class LookoutSession < ApplicationRecord # SyncPendingLookoutSessionsJob re-polls these so a recording can finalize even # when the builder closed the recorder tab before Lookout finished compiling. scope :syncable, -> { where.not(status: TERMINAL_STATUSES) } + scope :unassigned, -> { where(status: "complete", hackatime_forwarded_at: nil, hackatime_skipped: false) } def terminal? TERMINAL_STATUSES.include?(status) diff --git a/app/views/projects/lookout_sessions/forward_heartbeats.turbo_stream.erb b/app/views/projects/lookout_sessions/forward_heartbeats.turbo_stream.erb new file mode 100644 index 000000000..f9cfaf392 --- /dev/null +++ b/app/views/projects/lookout_sessions/forward_heartbeats.turbo_stream.erb @@ -0,0 +1 @@ +<%= turbo_stream.remove dom_id(@lookout_session) %> diff --git a/app/views/projects/lookout_sessions/skip.turbo_stream.erb b/app/views/projects/lookout_sessions/skip.turbo_stream.erb new file mode 100644 index 000000000..f9cfaf392 --- /dev/null +++ b/app/views/projects/lookout_sessions/skip.turbo_stream.erb @@ -0,0 +1 @@ +<%= turbo_stream.remove dom_id(@lookout_session) %> diff --git a/config/routes.rb b/config/routes.rb index 25eca4695..2c09d00cb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -882,6 +882,7 @@ post :stop, on: :member post :set_mode, on: :member post :forward_heartbeats, on: :member + post :skip, on: :member get :status, on: :collection end resource :og_image, only: [ :show ], module: :projects, defaults: { format: :png } diff --git a/db/migrate/20260630182848_add_hackatime_tracking_to_lookout_sessions.rb b/db/migrate/20260630182848_add_hackatime_tracking_to_lookout_sessions.rb new file mode 100644 index 000000000..b189ed64d --- /dev/null +++ b/db/migrate/20260630182848_add_hackatime_tracking_to_lookout_sessions.rb @@ -0,0 +1,7 @@ +class AddHackatimeTrackingToLookoutSessions < ActiveRecord::Migration[8.1] + def change + add_column :lookout_sessions, :hackatime_project_name, :string + add_column :lookout_sessions, :hackatime_forwarded_at, :datetime + add_column :lookout_sessions, :hackatime_skipped, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 6d6e67c48..b3eccc524 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.1].define(version: 2026_06_30_165531) do +ActiveRecord::Schema[8.1].define(version: 2026_06_30_182848) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "vector" @@ -386,6 +386,9 @@ create_table "lookout_sessions", force: :cascade do |t| t.datetime "created_at", null: false t.integer "duration_seconds", default: 0 + t.datetime "hackatime_forwarded_at" + t.string "hackatime_project_name" + t.boolean "hackatime_skipped", default: false, null: false t.string "mode" t.bigint "project_id", null: false t.string "recording_url" diff --git a/test/controllers/projects/lookout_sessions_controller_test.rb b/test/controllers/projects/lookout_sessions_controller_test.rb index 32e54f5a5..0a73c970c 100644 --- a/test/controllers/projects/lookout_sessions_controller_test.rb +++ b/test/controllers/projects/lookout_sessions_controller_test.rb @@ -128,7 +128,9 @@ class Projects::LookoutSessionsControllerTest < ActionDispatch::IntegrationTest end LookoutHeartbeatForwarder.stub(:call, forward) do - post forward_heartbeats_project_lookout_session_path(@project, session), params: { project_name: "My HT Project" } + post forward_heartbeats_project_lookout_session_path(@project, session), + params: { project_name: "My HT Project" }, + headers: { "Accept" => "application/json" } end assert_response :success @@ -184,6 +186,53 @@ class Projects::LookoutSessionsControllerTest < ActionDispatch::IntegrationTest assert_not called end + test "forward_heartbeats sets hackatime_forwarded_at and hackatime_project_name on success" do + session = @project.lookout_sessions.create!(user: @owner, token: "tok-fwd1", status: "complete", + duration_seconds: 600) + sign_in @owner + + ok_result = LookoutHeartbeatForwarder::Result.new(ok: true, error: nil, count: 5) + LookoutHeartbeatForwarder.stub(:call, ok_result) do + post forward_heartbeats_project_lookout_session_path(@project, session), + params: { project_name: "my-proj" } + end + + assert_not_nil session.reload.hackatime_forwarded_at + assert_equal "my-proj", session.reload.hackatime_project_name + end + + test "forward_heartbeats does not set tracking fields on failure" do + session = @project.lookout_sessions.create!(user: @owner, token: "tok-fwd2", status: "complete", + duration_seconds: 600) + sign_in @owner + + failure = LookoutHeartbeatForwarder::Result.new(ok: false, error: "Something went wrong", count: 0) + LookoutHeartbeatForwarder.stub(:call, failure) do + post forward_heartbeats_project_lookout_session_path(@project, session), + params: { project_name: "my-proj" } + end + + assert_nil session.reload.hackatime_forwarded_at + assert_nil session.reload.hackatime_project_name + end + + test "forward_heartbeats returns turbo stream removing the row when accepted" do + session = @project.lookout_sessions.create!(user: @owner, token: "tok-fwd3", status: "complete", + duration_seconds: 600) + sign_in @owner + + ok_result = LookoutHeartbeatForwarder::Result.new(ok: true, error: nil, count: 5) + LookoutHeartbeatForwarder.stub(:call, ok_result) do + post forward_heartbeats_project_lookout_session_path(@project, session), + params: { project_name: "my-proj" }, + headers: { "Accept" => "text/vnd.turbo-stream.html" } + end + + assert_response :ok + assert_equal "text/vnd.turbo-stream.html", response.media_type + assert_match "lookout_session_#{session.id}", response.body + end + test "set_mode stores a valid recording mode" do session = @project.lookout_sessions.create!(user: @owner, token: "tok-mode", status: "pending") sign_in @owner @@ -267,4 +316,27 @@ class Projects::LookoutSessionsControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_equal "stopped", session.reload.status end + + test "skip marks session as skipped and returns turbo stream removing the row" do + session = @project.lookout_sessions.create!(user: @owner, token: "tok-skip1", status: "complete") + sign_in @owner + + post skip_project_lookout_session_path(@project, session), + headers: { "Accept" => "text/vnd.turbo-stream.html" } + + assert_response :ok + assert_equal "text/vnd.turbo-stream.html", response.media_type + assert session.reload.hackatime_skipped + assert_match "lookout_session_#{session.id}", response.body + end + + test "skip is rejected for a session the user doesn't own" do + session = @project.lookout_sessions.create!(user: @owner, token: "tok-skip2", status: "complete") + sign_in @stranger + + post skip_project_lookout_session_path(@project, session) + + assert_response :not_found + assert_not session.reload.hackatime_skipped + end end diff --git a/test/models/lookout_session_test.rb b/test/models/lookout_session_test.rb index 736bd6c84..b2a226fab 100644 --- a/test/models/lookout_session_test.rb +++ b/test/models/lookout_session_test.rb @@ -2,18 +2,21 @@ # # Table name: lookout_sessions # -# id :bigint not null, primary key -# duration_seconds :integer default(0) -# mode :string -# recording_url :string -# started_at :datetime -# status :string default("pending") -# stopped_at :datetime -# token :string not null -# created_at :datetime not null -# updated_at :datetime not null -# project_id :bigint not null -# user_id :bigint not null +# id :bigint not null, primary key +# duration_seconds :integer default(0) +# hackatime_forwarded_at :datetime +# hackatime_project_name :string +# hackatime_skipped :boolean default(FALSE), not null +# mode :string +# recording_url :string +# started_at :datetime +# status :string default("pending") +# stopped_at :datetime +# token :string not null +# created_at :datetime not null +# updated_at :datetime not null +# project_id :bigint not null +# user_id :bigint not null # # Indexes # @@ -79,6 +82,26 @@ class LookoutSessionTest < ActiveSupport::TestCase assert_equal [ pending.id, stopped.id ].sort, LookoutSession.syncable.pluck(:id).sort end + test "unassigned scope returns only complete sessions with no forwarded_at and not skipped" do + # should appear + unassigned = LookoutSession.create!(user: @user, project: @project, token: "ua1", status: "complete") + # should NOT appear — forwarded + LookoutSession.create!(user: @user, project: @project, token: "ua2", status: "complete", + hackatime_forwarded_at: Time.current, hackatime_project_name: "my-proj") + # should NOT appear — skipped + LookoutSession.create!(user: @user, project: @project, token: "ua3", status: "complete", + hackatime_skipped: true) + # should NOT appear — not complete + LookoutSession.create!(user: @user, project: @project, token: "ua4", status: "compiling") + + assert_equal [unassigned.id], LookoutSession.unassigned.pluck(:id) + end + + test "unassigned scope excludes failed sessions" do + LookoutSession.create!(user: @user, project: @project, token: "ua5", status: "failed") + assert_empty LookoutSession.unassigned + end + test "terminal? is true only for complete and failed" do assert LookoutSession.new(status: "complete").terminal? assert LookoutSession.new(status: "failed").terminal? From 690ad2cf35117f38c777c47dc411a2b160e9b7ea Mon Sep 17 00:00:00 2001 From: Raygen Rupe Date: Tue, 30 Jun 2026 12:46:11 -0600 Subject: [PATCH 2/5] feat(lookout): add LookoutManager controller, routes, and tests --- .../projects/lookout_manager_controller.rb | 26 ++++++ app/helpers/lookout_manager_helper.rb | 13 +++ .../lookout_manager/_session_row.html.erb | 82 +++++++++++++++++++ .../projects/lookout_manager/index.html.erb | 23 ++++++ config/routes.rb | 1 + .../lookout_manager_controller_test.rb | 60 ++++++++++++++ 6 files changed, 205 insertions(+) create mode 100644 app/controllers/projects/lookout_manager_controller.rb create mode 100644 app/helpers/lookout_manager_helper.rb create mode 100644 app/views/projects/lookout_manager/_session_row.html.erb create mode 100644 app/views/projects/lookout_manager/index.html.erb create mode 100644 test/controllers/projects/lookout_manager_controller_test.rb diff --git a/app/controllers/projects/lookout_manager_controller.rb b/app/controllers/projects/lookout_manager_controller.rb new file mode 100644 index 000000000..6d12f8646 --- /dev/null +++ b/app/controllers/projects/lookout_manager_controller.rb @@ -0,0 +1,26 @@ +class Projects::LookoutManagerController < ApplicationController + before_action -> { head :not_found unless Flipper.enabled?(:lookout_manager, current_user) } + before_action :set_project + + def index + authorize @project, :create_devlog? + + @sessions = @project.lookout_sessions + .unassigned + .where(user: current_user) + .order(created_at: :desc) + + @hackatime_project_names = current_user.hackatime_projects + .where.not(name: User::HackatimeProject::EXCLUDED_NAMES) + .order(:name) + .pluck(:name) + @linked_hackatime_names = @hackatime_project_names & @project.hackatime_keys + @default_hackatime_name = @project.hackatime_recorder_name + end + + private + + def set_project + @project = Project.find(params[:project_id]) + end +end diff --git a/app/helpers/lookout_manager_helper.rb b/app/helpers/lookout_manager_helper.rb new file mode 100644 index 000000000..181214610 --- /dev/null +++ b/app/helpers/lookout_manager_helper.rb @@ -0,0 +1,13 @@ +module LookoutManagerHelper + # Returns a human-readable duration string, e.g. "1h 23m" or "45m" or "< 1m". + def format_recording_duration(seconds) + return "< 1m" if seconds < 60 + + hours = seconds / 3600 + minutes = (seconds % 3600) / 60 + parts = [] + parts << "#{hours}h" if hours > 0 + parts << "#{minutes}m" if minutes > 0 + parts.join(" ") + end +end diff --git a/app/views/projects/lookout_manager/_session_row.html.erb b/app/views/projects/lookout_manager/_session_row.html.erb new file mode 100644 index 000000000..94f8aae1e --- /dev/null +++ b/app/views/projects/lookout_manager/_session_row.html.erb @@ -0,0 +1,82 @@ +
+
+ <%= format_recording_duration(session.duration_seconds) %> + <%= session.created_at.strftime("%b %-d") %> +
+
+ +
+ <%= form_with url: forward_heartbeats_project_lookout_session_path(@project, session), method: :post do |f| %> + + + + + + +
+ <%= render ActionButtonComponent.new(text: "Assign", variant: :primary, size: :small, type: :submit) %> + + <%= button_to "Skip", + skip_project_lookout_session_path(@project, session), + method: :post, + class: "lookout-manager__skip-btn", + data: { turbo_stream: true } %> +
+ <% end %> +
diff --git a/app/views/projects/lookout_manager/index.html.erb b/app/views/projects/lookout_manager/index.html.erb new file mode 100644 index 000000000..defeb9ace --- /dev/null +++ b/app/views/projects/lookout_manager/index.html.erb @@ -0,0 +1,23 @@ + +
+
+

Assign your recordings

+
+ + <% if @sessions.empty? %> +

All recordings have been assigned.

+ <% else %> +
+ <% @sessions.each do |session| %> +
+ <%= render "session_row", + session: session, + hackatime_project_names: @hackatime_project_names, + linked_hackatime_names: @linked_hackatime_names, + default_hackatime_name: @default_hackatime_name %> +
+ <% end %> +
+ <% end %> +
+
diff --git a/config/routes.rb b/config/routes.rb index 2c09d00cb..9e4400831 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -885,6 +885,7 @@ post :skip, on: :member get :status, on: :collection end + resources :lookout_manager, only: [:index], module: :projects resource :og_image, only: [ :show ], module: :projects, defaults: { format: :png } resource :ships, only: [ :create ], module: :projects resource :recertification, only: [ :create ], module: :projects diff --git a/test/controllers/projects/lookout_manager_controller_test.rb b/test/controllers/projects/lookout_manager_controller_test.rb new file mode 100644 index 000000000..4f61dcf4a --- /dev/null +++ b/test/controllers/projects/lookout_manager_controller_test.rb @@ -0,0 +1,60 @@ +require "test_helper" + +class Projects::LookoutManagerControllerTest < ActionDispatch::IntegrationTest + setup do + Flipper.enable(:lookout_manager) + @owner = create_user(slack_id: "U_LM_OWNER", display_name: "lm_owner") + @project = Project.create!(title: "Robot arm", hardware_stage: "build") + @project.memberships.create!(user: @owner, role: :owner) + end + + test "index returns 404 when flag is off" do + Flipper.disable(:lookout_manager) + sign_in @owner + + get project_lookout_manager_index_path(@project) + + assert_response :not_found + end + + test "index is rejected for a non-member" do + stranger = create_user(slack_id: "U_LM_STRANGER", display_name: "lm_stranger") + sign_in stranger + + get project_lookout_manager_index_path(@project) + + assert_response :forbidden + end + + test "index renders with no unassigned sessions" do + @project.lookout_sessions.create!( + user: @owner, + token: "tok-forwarded", + status: "complete", + duration_seconds: 600, + hackatime_forwarded_at: Time.current + ) + sign_in @owner + + get project_lookout_manager_index_path(@project) + + assert_response :success + assert_select ".lookout-manager__empty" + assert_select ".lookout-manager__row", count: 0 + end + + test "index lists unassigned complete sessions for the current user" do + session = @project.lookout_sessions.create!( + user: @owner, + token: "tok-unassigned", + status: "complete", + duration_seconds: 900 + ) + sign_in @owner + + get project_lookout_manager_index_path(@project) + + assert_response :success + assert_select "##{dom_id(session)}" + end +end From 95d7177d13c9fe8016d712c0286216feec7cafca Mon Sep 17 00:00:00 2001 From: Raygen Rupe Date: Tue, 30 Jun 2026 12:56:53 -0600 Subject: [PATCH 3/5] feat(lookout): build assignment dialog with video preview, tab navigation, and Hackatime integration --- app/assets/stylesheets/application.scss | 1 + .../pages/projects/_lookout_manager.scss | 336 ++++++++++++++++++ app/controllers/projects_controller.rb | 19 + app/javascript/controllers/index.js | 3 + .../controllers/lookout_capture_controller.js | 31 +- .../controllers/lookout_manager_controller.js | 84 +++++ .../lookout_manager/_session_row.html.erb | 126 ++++--- .../forward_heartbeats.turbo_stream.erb | 1 + .../projects/lookout_sessions/record.html.erb | 3 +- .../lookout_sessions/skip.turbo_stream.erb | 1 + app/views/projects/show.html.erb | 48 +++ 11 files changed, 594 insertions(+), 59 deletions(-) create mode 100644 app/assets/stylesheets/pages/projects/_lookout_manager.scss create mode 100644 app/javascript/controllers/lookout_manager_controller.js diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index e8d22e727..0874df03d 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -59,6 +59,7 @@ @use "pages/projects/show"; @use "pages/projects/readme"; @use "pages/projects/lookout_recorder"; +@use "pages/projects/lookout_manager"; @use "pages/hardware_funding"; @use "pages/votes"; @use "pages/achievements/index" as achievements_index; diff --git a/app/assets/stylesheets/pages/projects/_lookout_manager.scss b/app/assets/stylesheets/pages/projects/_lookout_manager.scss new file mode 100644 index 000000000..096602efa --- /dev/null +++ b/app/assets/stylesheets/pages/projects/_lookout_manager.scss @@ -0,0 +1,336 @@ +// Lookout Manager: popup for assigning recorded sessions to Hackatime. +// Follows the same structure as .ship-warning-modal in _show.scss. + +.lookout-manager-dialog { + border: none; + padding: 0; + background: transparent; + max-width: min(640px, 94vw); + width: 100%; + position: fixed; + top: 50%; + left: 50%; + translate: -50% -50%; + margin: 0; + max-height: 90vh; + overflow-y: auto; + color: var(--color-space-text); + + &::backdrop { + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(2px); + } +} + +.lookout-manager-dialog__panel { + position: relative; + background: var(--color-space-bg-2, #0d0b2e); + border: 1px solid var(--color-space-border); + border-radius: var(--border-radius); + padding: var(--space-l); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4); + display: flex; + flex-direction: column; + gap: var(--space-m); +} + +.lookout-manager-dialog__close { + position: absolute; + top: var(--space-m); + right: var(--space-m); + width: 28px; + height: 28px; + padding: 0; + border: none; + background: none; + color: var(--color-space-text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: color 150ms ease; + + svg { + width: 18px; + height: 18px; + } + + &:hover, + &:focus-visible { + color: var(--color-space-text); + outline: none; + } +} + +// ── Main content wrapper ─────────────────────────────────────────────────── + +.lookout-manager { + display: flex; + flex-direction: column; + gap: var(--space-l); +} + +.lookout-manager__header { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding-right: var(--space-xl); +} + +.lookout-manager__title { + margin: 0; + font-family: var(--font-family-text, "Exo 2", sans-serif); + font-size: var(--font-size-xl); + font-weight: 700; + color: var(--color-brand-cream); +} + +.lookout-manager__subtitle { + margin: 0; + font-size: var(--font-size-base); + color: var(--color-space-text-muted); +} + +.lookout-manager__empty { + margin: 0; + color: var(--color-space-text-muted); + font-size: var(--font-size-s); +} + +// ── Tab strip — hidden when only one recording ──────────────────────────── + +.lookout-manager__tabs { + display: flex; + gap: var(--space-xs); + flex-wrap: wrap; + + &:has(.lookout-manager__tab:only-child) { + display: none; + } +} + +.lookout-manager__tab { + display: flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-s); + border: 1px solid var(--color-space-border); + border-radius: 99px; + background: none; + color: var(--color-space-text-muted); + font: inherit; + font-size: var(--font-size-s); + cursor: pointer; + transition: + border-color 150ms ease, + color 150ms ease, + background 150ms ease; + + &:hover { + border-color: var(--color-space-text); + color: var(--color-space-text); + } + + &--active { + border-color: var(--color-brand-mint); + color: var(--color-brand-mint); + background: color-mix(in srgb, var(--color-brand-mint) 10%, transparent); + + &:hover { + border-color: var(--color-brand-mint); + color: var(--color-brand-mint); + } + } +} + +.lookout-manager__tab-duration { + font-weight: 700; +} + +.lookout-manager__tab-date { + opacity: 0.7; +} + +// ── List of sessions ─────────────────────────────────────────────────────── + +.lookout-manager__list { + display: flex; + flex-direction: column; + gap: var(--space-m); +} + +// ── Individual session row ───────────────────────────────────────────────── + +.lookout-manager__row { + display: flex; + flex-direction: column; + gap: var(--space-m); + + &[hidden] { display: none; } +} + +.lookout-manager__preview { + width: 100%; + max-height: 220px; + border-radius: var(--profile-radius); + background: #000; + object-fit: contain; +} + +.lookout-manager__row-meta { + display: flex; + flex-wrap: wrap; + gap: var(--space-m); + font-size: var(--font-size-s); +} + +.lookout-manager__row-duration { + font-weight: 700; + color: var(--color-brand-mint); +} + +.lookout-manager__row-date { + color: var(--color-space-text-muted); +} + +// ── Assignment form section ──────────────────────────────────────────────── + +.lookout-manager__row-form { + display: flex; + flex-direction: column; + gap: var(--space-m); +} + +// ── Destination chooser — mirrors lookout-rec__dest card style ──────────── + +.lookout-manager__destination { + display: flex; + flex-direction: column; + gap: var(--space-m); +} + +.lookout-manager__dest { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + column-gap: var(--space-m); + padding: var(--space-m); + border: 2px solid var(--color-space-border); + border-radius: var(--profile-radius); + background: var(--color-set-2-bg, #343651); + cursor: pointer; + transition: + border-color 150ms ease, + background 150ms ease; + + &:hover, + &:focus-within { + border-color: var(--color-space-text); + background: var(--color-set-3-bg, #606684); + } + + &:has(.lookout-manager__dest-radio:checked) { + border-color: var(--color-brand-mint); + background: color-mix( + in srgb, + var(--color-brand-mint) 12%, + var(--color-set-2-bg, #343651) + ); + } + + &:has(.lookout-manager__dest-radio:disabled) { + opacity: 0.5; + cursor: not-allowed; + + &:hover { + border-color: var(--color-space-border); + background: var(--color-set-2-bg, #343651); + } + } +} + +.lookout-manager__dest-radio { + margin-top: 2px; + width: 18px; + height: 18px; + accent-color: var(--color-brand-mint); + cursor: inherit; +} + +.lookout-manager__dest-text { + display: flex; + flex-direction: column; + gap: var(--space-xs); + min-width: 0; +} + +.lookout-manager__dest-name { + font-weight: 700; + color: var(--color-space-text); +} + +.lookout-manager__dest-sub { + font-size: var(--font-size-s); + color: var(--color-space-text-muted); +} + +.lookout-manager__dest-select, +.lookout-manager__dest-input { + width: 100%; + margin-top: var(--space-xxs); + padding: var(--space-xs) var(--space-s); + border: 2px solid var(--color-space-border); + border-radius: var(--profile-radius); + background: var(--color-set-1-bg, #08061e); + color: var(--color-space-text); + font: inherit; + + &:focus-visible { + outline: none; + border-color: var(--color-brand-mint); + } + + &:disabled { + opacity: 0.45; + } +} + +.lookout-manager__dest-input { + cursor: text; +} + +// ── Actions row — assign left, delete right ──────────────────────────────── + +.lookout-manager__row-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-m); + margin-top: var(--space-l); + padding-top: var(--space-s); + border-top: 1px solid var(--color-space-border); +} + +@media (max-width: 640px) { + .lookout-manager-dialog__panel { + padding: var(--space-m); + } + + .lookout-manager__title { + font-size: var(--font-size-xl); + } + + .lookout-manager__dest-group { + grid-template-columns: 1fr; + grid-template-rows: auto auto auto; + } + + .lookout-manager__dest-divider { + padding: var(--space-xs) 0; + } + + .lookout-manager__row-actions { + flex-direction: column; + align-items: stretch; + } +} diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index ab631ad71..2cb1e387f 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -75,6 +75,25 @@ def prepare_project_show_context } end end + + if Flipper.enabled?(:lookout_manager, current_user) + # Kick off background sync rather than blocking page render with HTTP + # calls to LookoutService. SyncPendingLookoutSessionsJob handles this. + SyncPendingLookoutSessionsJob.perform_later + + @lookout_unassigned = @project.lookout_sessions + .unassigned + .where(user: current_user) + .order(created_at: :desc) + @lookout_hackatime_names = current_user.hackatime_projects + .where.not(name: User::HackatimeProject::EXCLUDED_NAMES) + .order(:name) + .pluck(:name) + linked_keys = @linked_hackatime_projects&.map(&:name) || [] + @lookout_linked_names = @lookout_hackatime_names & linked_keys + @lookout_default_name = @project.hackatime_recorder_name + @lookout_hackatime_times = @hackatime_times || {} + end end diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index be23142d5..cb1137ddc 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -208,6 +208,9 @@ application.register("json-highlight", JsonHighlightController); import LookoutCaptureController from "./lookout_capture_controller"; application.register("lookout-capture", LookoutCaptureController); +import LookoutManagerController from "./lookout_manager_controller"; +application.register("lookout-manager", LookoutManagerController); + import LookoutRecorderController from "./lookout_recorder_controller"; application.register("lookout-recorder", LookoutRecorderController); diff --git a/app/javascript/controllers/lookout_capture_controller.js b/app/javascript/controllers/lookout_capture_controller.js index c75b12216..1b98a4f1f 100644 --- a/app/javascript/controllers/lookout_capture_controller.js +++ b/app/javascript/controllers/lookout_capture_controller.js @@ -44,6 +44,7 @@ export default class extends Controller { deepLink: String, modeUrl: String, forwardUrl: String, + skipUrl: String, }; connect() { @@ -319,9 +320,20 @@ export default class extends Controller { } // Confirm the destination: forward the captured time to the chosen Hackatime - // project (existing or new), or send nothing when the user opted out. + // project (existing or new), or mark as skipped when the user opted out. async finish() { const choice = this.selectedDestination(); + + // "Don't Send" — mark the session as skipped server-side so it doesn't + // reappear in the project page's assignment popup. + if (choice === "skip") { + await this.skipSession(); + this.setText(this.doneStatusTarget, "All done — your recording was saved without sending time."); + if (this.hasDestinationTarget) this.destinationTarget.hidden = true; + if (this.hasDoneCloseTarget) this.doneCloseTarget.hidden = false; + return; + } + let projectName = null; if (choice === "existing") projectName = this.hasExistingSelectTarget @@ -349,7 +361,7 @@ export default class extends Controller { await this.forwardHeartbeats(projectName); this.setText( this.doneStatusTarget, - `Time sent to “${projectName}” — it'll show up in Hackatime shortly.`, + `Time sent to "${projectName}" — it'll show up in Hackatime shortly.`, ); } catch (error) { // Surface the real reason next to the button and let the user retry, @@ -413,6 +425,21 @@ export default class extends Controller { return `We couldn't send your time to Hackatime (error ${res.status}).`; } + // Mark the session as skipped server-side so it doesn't reappear in the + // project page's assignment popup. Best-effort. + async skipSession() { + if (!this.skipUrlValue) return; + await fetch(this.skipUrlValue, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": this.csrfToken(), + Accept: "application/json", + }, + body: "{}", + }).catch(() => {}); + } + csrfToken() { return document.querySelector('meta[name="csrf-token"]')?.content || ""; } diff --git a/app/javascript/controllers/lookout_manager_controller.js b/app/javascript/controllers/lookout_manager_controller.js new file mode 100644 index 000000000..864766e52 --- /dev/null +++ b/app/javascript/controllers/lookout_manager_controller.js @@ -0,0 +1,84 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["row", "tab", "existingSelect", "newName", "projectNameField"] + + connect() { + this._activeId = null + } + + rowTargetConnected(row) { + this._syncRow(row) + if (!this._activeId) this._activeId = row.id + this._syncDisplay() + if (!this.element.open) this.element.showModal() + } + + rowTargetDisconnected(row) { + if (this.rowTargets.length === 0) { + this.element.close() + return + } + if (this._activeId === row.id) { + this._activeId = this.rowTargets[0].id + } + this._syncDisplay() + } + + switchTab(event) { + this._activeId = event.currentTarget.dataset.rowId + this._syncDisplay() + } + + destinationChanged(event) { + const row = event.target.closest(".lookout-manager__row") + if (row) this._syncRow(row) + } + + selectExisting(event) { + const row = event.target.closest(".lookout-manager__row") + if (!row) return + const radio = row.querySelector("input[name^='lookout-dest'][value='existing']") + if (radio && !radio.disabled) { radio.checked = true; this._syncRow(row) } + } + + selectNew(event) { + const row = event.target.closest(".lookout-manager__row") + if (!row) return + const radio = row.querySelector("input[name^='lookout-dest'][value='new']") + if (radio) { radio.checked = true; this._syncRow(row) } + } + + newNameChanged(event) { + this.selectNew(event) + const row = event.target.closest(".lookout-manager__row") + if (!row) return + const field = row.querySelector("[data-lookout-manager-target='projectNameField']") + if (field) field.value = event.target.value.trim() + } + + _syncDisplay() { + this.rowTargets.forEach(row => { + row.hidden = row.id !== this._activeId + }) + this.tabTargets.forEach(tab => { + tab.classList.toggle("lookout-manager__tab--active", tab.dataset.rowId === this._activeId) + }) + } + + _syncRow(row) { + const radio = row.querySelector("input[name^='lookout-dest']:checked") + const existingSelect = row.querySelector("[data-lookout-manager-target='existingSelect']") + const newInput = row.querySelector("[data-lookout-manager-target='newName']") + const field = row.querySelector("[data-lookout-manager-target='projectNameField']") + if (!radio || !field) return + + const isNew = radio.value === "new" + if (existingSelect) existingSelect.disabled = isNew + if (newInput) newInput.disabled = !isNew + + field.value = isNew + ? (newInput?.value.trim() || "") + : (existingSelect?.value || "") + } +} diff --git a/app/views/projects/lookout_manager/_session_row.html.erb b/app/views/projects/lookout_manager/_session_row.html.erb index 94f8aae1e..4247af58f 100644 --- a/app/views/projects/lookout_manager/_session_row.html.erb +++ b/app/views/projects/lookout_manager/_session_row.html.erb @@ -1,3 +1,12 @@ +<% if session.recording_url.present? %> + +<% end %> +
<%= format_recording_duration(session.duration_seconds) %> @@ -11,72 +20,77 @@ name="project_name" data-lookout-manager-target="projectNameField"> - + + <% else %> + You don't have any Hackatime projects yet. + <% end %> + + - + +
<%= render ActionButtonComponent.new(text: "Assign", variant: :primary, size: :small, type: :submit) %> - - <%= button_to "Skip", + <%= button_to "Delete permanently", skip_project_lookout_session_path(@project, session), method: :post, - class: "lookout-manager__skip-btn", - data: { turbo_stream: true } %> + class: "action-btn action-btn--small action-btn--destructive", + form: { data: { turbo_confirm: "Remove this recording without sending time to Hackatime? This can't be undone.", turbo_stream: true } } %>
<% end %>
diff --git a/app/views/projects/lookout_sessions/forward_heartbeats.turbo_stream.erb b/app/views/projects/lookout_sessions/forward_heartbeats.turbo_stream.erb index f9cfaf392..8a10396bb 100644 --- a/app/views/projects/lookout_sessions/forward_heartbeats.turbo_stream.erb +++ b/app/views/projects/lookout_sessions/forward_heartbeats.turbo_stream.erb @@ -1 +1,2 @@ <%= turbo_stream.remove dom_id(@lookout_session) %> +<%= turbo_stream.remove "tab_#{dom_id(@lookout_session)}" %> diff --git a/app/views/projects/lookout_sessions/record.html.erb b/app/views/projects/lookout_sessions/record.html.erb index 590dacd50..799f26db4 100644 --- a/app/views/projects/lookout_sessions/record.html.erb +++ b/app/views/projects/lookout_sessions/record.html.erb @@ -6,7 +6,8 @@ data-lookout-capture-api-base-value="<%= @lookout_api_base %>" data-lookout-capture-deep-link-value="<%= @deep_link %>" data-lookout-capture-mode-url-value="<%= set_mode_project_lookout_session_path(@project, @lookout_session) %>" - data-lookout-capture-forward-url-value="<%= forward_heartbeats_project_lookout_session_path(@project, @lookout_session) %>"> + data-lookout-capture-forward-url-value="<%= forward_heartbeats_project_lookout_session_path(@project, @lookout_session) %>" + data-lookout-capture-skip-url-value="<%= skip_project_lookout_session_path(@project, @lookout_session) %>">

Lookout Recording

diff --git a/app/views/projects/lookout_sessions/skip.turbo_stream.erb b/app/views/projects/lookout_sessions/skip.turbo_stream.erb index f9cfaf392..8a10396bb 100644 --- a/app/views/projects/lookout_sessions/skip.turbo_stream.erb +++ b/app/views/projects/lookout_sessions/skip.turbo_stream.erb @@ -1 +1,2 @@ <%= turbo_stream.remove dom_id(@lookout_session) %> +<%= turbo_stream.remove "tab_#{dom_id(@lookout_session)}" %> diff --git a/app/views/projects/show.html.erb b/app/views/projects/show.html.erb index e3c8bb0a8..8fc9347a9 100644 --- a/app/views/projects/show.html.erb +++ b/app/views/projects/show.html.erb @@ -1266,4 +1266,52 @@

Loading…

+ + <% if @lookout_unassigned&.any? %> + +
+ +
+
+

Unassigned Lookout recordings

+

We detected recordings that haven't been sent to Hackatime yet. Where should this time go?

+
+
+ <% @lookout_unassigned.each do |session| %> + + <% end %> +
+
+ <% @lookout_unassigned.each do |session| %> +
+ <%= render "projects/lookout_manager/session_row", + session: session, + hackatime_project_names: @lookout_hackatime_names, + linked_hackatime_names: @lookout_linked_names, + default_hackatime_name: @lookout_default_name, + hackatime_times: @lookout_hackatime_times %> +
+ <% end %> +
+
+
+
+ <% end %> From 9bd279043cbc1e773a7f5980c498ee146c95b444 Mon Sep 17 00:00:00 2001 From: Raygen Rupe Date: Tue, 30 Jun 2026 15:55:53 -0600 Subject: [PATCH 4/5] fix(lookout-manager): fix CSRF bug, add background sync, and apply linters --- .../pages/projects/_lookout_manager.scss | 4 +- .../controllers/lookout_capture_controller.js | 5 +- .../controllers/lookout_manager_controller.js | 113 +++++++++++------- .../lookout_manager/_session_row.html.erb | 21 ++-- app/views/projects/show.html.erb | 2 +- config/routes.rb | 2 +- test/models/lookout_session_test.rb | 2 +- 7 files changed, 91 insertions(+), 58 deletions(-) diff --git a/app/assets/stylesheets/pages/projects/_lookout_manager.scss b/app/assets/stylesheets/pages/projects/_lookout_manager.scss index 096602efa..d48320945 100644 --- a/app/assets/stylesheets/pages/projects/_lookout_manager.scss +++ b/app/assets/stylesheets/pages/projects/_lookout_manager.scss @@ -166,7 +166,9 @@ flex-direction: column; gap: var(--space-m); - &[hidden] { display: none; } + &[hidden] { + display: none; + } } .lookout-manager__preview { diff --git a/app/javascript/controllers/lookout_capture_controller.js b/app/javascript/controllers/lookout_capture_controller.js index 1b98a4f1f..fd31b8c74 100644 --- a/app/javascript/controllers/lookout_capture_controller.js +++ b/app/javascript/controllers/lookout_capture_controller.js @@ -328,7 +328,10 @@ export default class extends Controller { // reappear in the project page's assignment popup. if (choice === "skip") { await this.skipSession(); - this.setText(this.doneStatusTarget, "All done — your recording was saved without sending time."); + this.setText( + this.doneStatusTarget, + "All done — your recording was saved without sending time.", + ); if (this.hasDestinationTarget) this.destinationTarget.hidden = true; if (this.hasDoneCloseTarget) this.doneCloseTarget.hidden = false; return; diff --git a/app/javascript/controllers/lookout_manager_controller.js b/app/javascript/controllers/lookout_manager_controller.js index 864766e52..9cd445b14 100644 --- a/app/javascript/controllers/lookout_manager_controller.js +++ b/app/javascript/controllers/lookout_manager_controller.js @@ -1,84 +1,109 @@ -import { Controller } from "@hotwired/stimulus" +import { Controller } from "@hotwired/stimulus"; export default class extends Controller { - static targets = ["row", "tab", "existingSelect", "newName", "projectNameField"] + static targets = [ + "row", + "tab", + "existingSelect", + "newName", + "projectNameField", + ]; connect() { - this._activeId = null + this._activeId = null; } rowTargetConnected(row) { - this._syncRow(row) - if (!this._activeId) this._activeId = row.id - this._syncDisplay() - if (!this.element.open) this.element.showModal() + this._syncRow(row); + if (!this._activeId) this._activeId = row.id; + this._syncDisplay(); + if (!this.element.open) this.element.showModal(); } rowTargetDisconnected(row) { if (this.rowTargets.length === 0) { - this.element.close() - return + this.element.close(); + return; } if (this._activeId === row.id) { - this._activeId = this.rowTargets[0].id + this._activeId = this.rowTargets[0].id; } - this._syncDisplay() + this._syncDisplay(); } switchTab(event) { - this._activeId = event.currentTarget.dataset.rowId - this._syncDisplay() + this._activeId = event.currentTarget.dataset.rowId; + this._syncDisplay(); } destinationChanged(event) { - const row = event.target.closest(".lookout-manager__row") - if (row) this._syncRow(row) + const row = event.target.closest(".lookout-manager__row"); + if (row) this._syncRow(row); } selectExisting(event) { - const row = event.target.closest(".lookout-manager__row") - if (!row) return - const radio = row.querySelector("input[name^='lookout-dest'][value='existing']") - if (radio && !radio.disabled) { radio.checked = true; this._syncRow(row) } + const row = event.target.closest(".lookout-manager__row"); + if (!row) return; + const radio = row.querySelector( + "input[name^='lookout-dest'][value='existing']", + ); + if (radio && !radio.disabled) { + radio.checked = true; + this._syncRow(row); + } } selectNew(event) { - const row = event.target.closest(".lookout-manager__row") - if (!row) return - const radio = row.querySelector("input[name^='lookout-dest'][value='new']") - if (radio) { radio.checked = true; this._syncRow(row) } + const row = event.target.closest(".lookout-manager__row"); + if (!row) return; + const radio = row.querySelector("input[name^='lookout-dest'][value='new']"); + if (radio) { + radio.checked = true; + this._syncRow(row); + } } newNameChanged(event) { - this.selectNew(event) - const row = event.target.closest(".lookout-manager__row") - if (!row) return - const field = row.querySelector("[data-lookout-manager-target='projectNameField']") - if (field) field.value = event.target.value.trim() + this.selectNew(event); + const row = event.target.closest(".lookout-manager__row"); + if (!row) return; + const field = row.querySelector( + "[data-lookout-manager-target='projectNameField']", + ); + if (field) field.value = event.target.value.trim(); } _syncDisplay() { - this.rowTargets.forEach(row => { - row.hidden = row.id !== this._activeId - }) - this.tabTargets.forEach(tab => { - tab.classList.toggle("lookout-manager__tab--active", tab.dataset.rowId === this._activeId) - }) + this.rowTargets.forEach((row) => { + row.hidden = row.id !== this._activeId; + }); + this.tabTargets.forEach((tab) => { + tab.classList.toggle( + "lookout-manager__tab--active", + tab.dataset.rowId === this._activeId, + ); + }); } _syncRow(row) { - const radio = row.querySelector("input[name^='lookout-dest']:checked") - const existingSelect = row.querySelector("[data-lookout-manager-target='existingSelect']") - const newInput = row.querySelector("[data-lookout-manager-target='newName']") - const field = row.querySelector("[data-lookout-manager-target='projectNameField']") - if (!radio || !field) return + const radio = row.querySelector("input[name^='lookout-dest']:checked"); + const existingSelect = row.querySelector( + "[data-lookout-manager-target='existingSelect']", + ); + const newInput = row.querySelector( + "[data-lookout-manager-target='newName']", + ); + const field = row.querySelector( + "[data-lookout-manager-target='projectNameField']", + ); + if (!radio || !field) return; - const isNew = radio.value === "new" - if (existingSelect) existingSelect.disabled = isNew - if (newInput) newInput.disabled = !isNew + const isNew = radio.value === "new"; + if (existingSelect) existingSelect.disabled = isNew; + if (newInput) newInput.disabled = !isNew; field.value = isNew - ? (newInput?.value.trim() || "") - : (existingSelect?.value || "") + ? newInput?.value.trim() || "" + : existingSelect?.value || ""; } } diff --git a/app/views/projects/lookout_manager/_session_row.html.erb b/app/views/projects/lookout_manager/_session_row.html.erb index 4247af58f..221bd305c 100644 --- a/app/views/projects/lookout_manager/_session_row.html.erb +++ b/app/views/projects/lookout_manager/_session_row.html.erb @@ -15,7 +15,9 @@
- <%= form_with url: forward_heartbeats_project_lookout_session_path(@project, session), method: :post do |f| %> + <%= form_with url: forward_heartbeats_project_lookout_session_path(@project, session), + method: :post, + id: "assign-form-#{session.id}" do |f| %> @@ -84,13 +86,14 @@
-
- <%= render ActionButtonComponent.new(text: "Assign", variant: :primary, size: :small, type: :submit) %> - <%= button_to "Delete permanently", - skip_project_lookout_session_path(@project, session), - method: :post, - class: "action-btn action-btn--small action-btn--destructive", - form: { data: { turbo_confirm: "Remove this recording without sending time to Hackatime? This can't be undone.", turbo_stream: true } } %> -
<% end %> + +
+ <%= render ActionButtonComponent.new(text: "Assign", variant: :primary, size: :small, type: :submit, form: "assign-form-#{session.id}") %> + <%= button_to "Delete permanently", + skip_project_lookout_session_path(@project, session), + method: :post, + class: "action-btn action-btn--small action-btn--destructive", + form: { data: { turbo_confirm: "Remove this recording without sending time to Hackatime? This can't be undone.", turbo_stream: true } } %> +
diff --git a/app/views/projects/show.html.erb b/app/views/projects/show.html.erb index 8fc9347a9..c2064ebc2 100644 --- a/app/views/projects/show.html.erb +++ b/app/views/projects/show.html.erb @@ -1277,7 +1277,7 @@ data-action="click->modal#close" aria-label="Close">
diff --git a/config/routes.rb b/config/routes.rb index 9e4400831..73415c430 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -885,7 +885,7 @@ post :skip, on: :member get :status, on: :collection end - resources :lookout_manager, only: [:index], module: :projects + resources :lookout_manager, only: [ :index ], module: :projects resource :og_image, only: [ :show ], module: :projects, defaults: { format: :png } resource :ships, only: [ :create ], module: :projects resource :recertification, only: [ :create ], module: :projects diff --git a/test/models/lookout_session_test.rb b/test/models/lookout_session_test.rb index b2a226fab..e5d3d27a2 100644 --- a/test/models/lookout_session_test.rb +++ b/test/models/lookout_session_test.rb @@ -94,7 +94,7 @@ class LookoutSessionTest < ActiveSupport::TestCase # should NOT appear — not complete LookoutSession.create!(user: @user, project: @project, token: "ua4", status: "compiling") - assert_equal [unassigned.id], LookoutSession.unassigned.pluck(:id) + assert_equal [ unassigned.id ], LookoutSession.unassigned.pluck(:id) end test "unassigned scope excludes failed sessions" do From 034ab63e5823aa5ed66e312fb0269a50fc4826ee Mon Sep 17 00:00:00 2001 From: lordbagel Date: Tue, 7 Jul 2026 00:42:14 -0600 Subject: [PATCH 5/5] fix: remove extra > --- app/views/projects/lookout_sessions/record.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/projects/lookout_sessions/record.html.erb b/app/views/projects/lookout_sessions/record.html.erb index 69dc8578a..9672a1ba9 100644 --- a/app/views/projects/lookout_sessions/record.html.erb +++ b/app/views/projects/lookout_sessions/record.html.erb @@ -7,7 +7,7 @@ data-lookout-capture-deep-link-value="<%= @deep_link %>" data-lookout-capture-mode-url-value="<%= set_mode_project_lookout_session_path(@project, @lookout_session) %>" data-lookout-capture-forward-url-value="<%= forward_heartbeats_project_lookout_session_path(@project, @lookout_session) %>" - data-lookout-capture-skip-url-value="<%= skip_project_lookout_session_path(@project, @lookout_session) %>"> + data-lookout-capture-skip-url-value="<%= skip_project_lookout_session_path(@project, @lookout_session) %>" data-lookout-capture-stop-url-value="<%= stop_project_lookout_session_path(@project, @lookout_session) %>" data-lookout-capture-sync-url-value="<%= project_lookout_session_path(@project, @lookout_session) %>">