diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 185b02b5b..9b9101a48 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 "components/hardware_outpost_modal"; @use "pages/votes"; 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..d48320945 --- /dev/null +++ b/app/assets/stylesheets/pages/projects/_lookout_manager.scss @@ -0,0 +1,338 @@ +// 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/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/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/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index dd26e00d0..6e207bd1c 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -78,6 +78,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/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/javascript/controllers/index.js b/app/javascript/controllers/index.js index 43e27b2a8..b2143e590 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 9d32665a7..18e798a12 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, stopUrl: String, syncUrl: String, }; @@ -58,6 +59,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() { @@ -306,9 +354,23 @@ 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 @@ -336,7 +398,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, @@ -400,6 +462,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..9cd445b14 --- /dev/null +++ b/app/javascript/controllers/lookout_manager_controller.js @@ -0,0 +1,109 @@ +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/models/lookout_session.rb b/app/models/lookout_session.rb index c21c8864d..8c7629d31 100644 --- a/app/models/lookout_session.rb +++ b/app/models/lookout_session.rb @@ -2,19 +2,22 @@ # # 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 -# devlog_id :bigint -# 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 +# devlog_id :bigint +# project_id :bigint not null +# user_id :bigint not null # # Indexes # @@ -52,6 +55,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_manager/_session_row.html.erb b/app/views/projects/lookout_manager/_session_row.html.erb new file mode 100644 index 000000000..221bd305c --- /dev/null +++ b/app/views/projects/lookout_manager/_session_row.html.erb @@ -0,0 +1,99 @@ +<% if session.recording_url.present? %> + +<% end %> + +
All recordings have been assigned.
+ <% else %> +Loading…
+ + <% if @lookout_unassigned&.any? %> + + <% end %> diff --git a/config/routes.rb b/config/routes.rb index 6e1db9df9..e55e76307 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -893,8 +893,10 @@ post :stop, on: :member post :set_mode, on: :member post :forward_heartbeats, on: :member + 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/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 768c664cc..61eaa8fd9 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" @@ -401,6 +401,9 @@ t.datetime "created_at", null: false t.bigint "devlog_id" 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_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 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 98479de37..8652c2b9b 100644 --- a/test/models/lookout_session_test.rb +++ b/test/models/lookout_session_test.rb @@ -2,19 +2,22 @@ # # 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 -# devlog_id :bigint -# 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 +# devlog_id :bigint +# project_id :bigint not null +# user_id :bigint not null # # Indexes # @@ -82,6 +85,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?