diff --git a/app/controllers/trips_controller.rb b/app/controllers/trips_controller.rb
new file mode 100644
index 00000000..c576e2e8
--- /dev/null
+++ b/app/controllers/trips_controller.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+class TripsController < ApplicationController
+ before_action :authenticate_user!
+ before_action :set_trip, only: %i[show edit update destroy]
+
+ def index
+ @trips = current_user.trips
+ end
+
+ def show; end
+
+ def new
+ @trip = Trip.new
+ end
+
+ def edit; end
+
+ def create
+ @trip = current_user.trips.build(trip_params)
+
+ if @trip.save
+ redirect_to @trip, notice: 'Trip was successfully created.'
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ def update
+ if @trip.update(trip_params)
+ redirect_to @trip, notice: 'Trip was successfully updated.', status: :see_other
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ @trip.destroy!
+ redirect_to trips_url, notice: 'Trip was successfully destroyed.', status: :see_other
+ end
+
+ private
+
+ def set_trip
+ @trip = current_user.trips.find(params[:id])
+ end
+
+ def trip_params
+ params.require(:trip).permit(:name, :started_at, :ended_at, :notes)
+ end
+end
diff --git a/app/helpers/trips_helper.rb b/app/helpers/trips_helper.rb
new file mode 100644
index 00000000..04f333d4
--- /dev/null
+++ b/app/helpers/trips_helper.rb
@@ -0,0 +1,2 @@
+module TripsHelper
+end
diff --git a/app/models/trip.rb b/app/models/trip.rb
new file mode 100644
index 00000000..572a90a4
--- /dev/null
+++ b/app/models/trip.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class Trip < ApplicationRecord
+ belongs_to :user
+
+ validates :name, :started_at, :ended_at, presence: true
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 806b3b2f..a102d0b5 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -15,6 +15,7 @@ class User < ApplicationRecord
has_many :visits, dependent: :destroy
has_many :points, through: :imports
has_many :places, through: :visits
+ has_many :trips, dependent: :destroy
after_create :create_api_key
diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb
index 6ba5ead7..edd241de 100644
--- a/app/views/shared/_navbar.html.erb
+++ b/app/views/shared/_navbar.html.erb
@@ -10,6 +10,7 @@
<%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %>
<%= link_to 'Visitsβ'.html_safe, visits_url(status: :confirmed), class: "#{active_class?(visits_url)}" %>
<%= link_to 'Placesβ'.html_safe, places_url, class: "#{active_class?(places_url)}" %>
+ <%= link_to 'Tripsβ'.html_safe, trips_url, class: "#{active_class?(trips_url)}" %>
<%= link_to 'Imports', imports_url, class: "#{active_class?(imports_url)}" %>
<%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %>
@@ -46,6 +47,7 @@
<%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %>
<%= link_to 'Visitsβ'.html_safe, visits_url(status: :confirmed), class: "#{active_class?(visits_url)}" %>
<%= link_to 'Placesβ'.html_safe, places_url, class: "#{active_class?(places_url)}" %>
+ <%= link_to 'Trips', trips_url, class: "#{active_class?(trips_url)}" %>
<%= link_to 'Imports', imports_url, class: "#{active_class?(imports_url)}" %>
<%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %>
diff --git a/app/views/trips/_form.html.erb b/app/views/trips/_form.html.erb
new file mode 100644
index 00000000..877c3068
--- /dev/null
+++ b/app/views/trips/_form.html.erb
@@ -0,0 +1,39 @@
+<%= form_with(model: trip, class: "contents") do |form| %>
+ <% if trip.errors.any? %>
+
+
<%= pluralize(trip.errors.count, "error") %> prohibited this trip from being saved:
+
+
+ <% trip.errors.each do |error| %>
+ - <%= error.full_message %>
+ <% end %>
+
+
+ <% end %>
+
+
+
+ <%= form.label :name %>
+ <%= form.text_field :name, class: 'input input-bordered' %>
+
+
+
+ <%= form.label :started_at %>
+ <%= form.datetime_field :started_at, include_seconds: false, class: 'input input-bordered', value: trip.started_at %>
+
+
+ <%= form.label :ended_at %>
+ <%= form.datetime_field :ended_at, include_seconds: false, class: 'input input-bordered', value: trip.ended_at %>
+
+
+
+
+
+ <%= form.label :notes %>
+ <%= form.text_area :notes, class: 'textarea textarea-bordered w-full', rows: 10 %>
+
+
+
+ <%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
+
+<% end %>
diff --git a/app/views/trips/_trip.html.erb b/app/views/trips/_trip.html.erb
new file mode 100644
index 00000000..87b36e7f
--- /dev/null
+++ b/app/views/trips/_trip.html.erb
@@ -0,0 +1,2 @@
+
+
diff --git a/app/views/trips/edit.html.erb b/app/views/trips/edit.html.erb
new file mode 100644
index 00000000..c55fbe26
--- /dev/null
+++ b/app/views/trips/edit.html.erb
@@ -0,0 +1,8 @@
+
+
Editing trip
+
+ <%= render "form", trip: @trip %>
+
+ <%= link_to "Show this trip", @trip, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
+ <%= link_to "Back to trips", trips_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
+
diff --git a/app/views/trips/index.html.erb b/app/views/trips/index.html.erb
new file mode 100644
index 00000000..aea0bb6a
--- /dev/null
+++ b/app/views/trips/index.html.erb
@@ -0,0 +1,48 @@
+<% content_for :title, 'Trips' %>
+
+
+
+ <% if @trips.empty? %>
+
+
+
+
Hello there!
+
+ Here you'll find your trips, but now there are none. Let's <%= link_to 'create one', new_trip_path, class: 'link' %>!
+
+
+
+
+ <% else %>
+
+
+ <%= paginate @trips %>
+
+
+
+
+
+
+ | Name |
+ Started at |
+ Ended at |
+
+
+
+ <% @trips.each do |trip| %>
+
+ | <%= link_to trip.name, trip, class: 'underline hover:no-underline' %> |
+ <%= trip.started_at.strftime("%d.%m.%Y, %H:%M") %> |
+ <%= trip.ended_at.strftime("%d.%m.%Y, %H:%M") %> |
+
+ <% end %>
+
+
+
+ <% end %>
+
+
diff --git a/app/views/trips/new.html.erb b/app/views/trips/new.html.erb
new file mode 100644
index 00000000..cd64bfac
--- /dev/null
+++ b/app/views/trips/new.html.erb
@@ -0,0 +1,9 @@
+<% content_for :title, 'New trip' %>
+
+
+
New trip
+
+ <%= render "form", trip: @trip %>
+
+ <%= link_to "Back to trips", trips_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
+
diff --git a/app/views/trips/show.html.erb b/app/views/trips/show.html.erb
new file mode 100644
index 00000000..2b6414e8
--- /dev/null
+++ b/app/views/trips/show.html.erb
@@ -0,0 +1,64 @@
+<% content_for :title, @trip.name %>
+
+
+
+
+
<%= @trip.name %>
+
Countries visited: [Placeholder]
+
+
+
+
+
+
+
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.
+
+
+
+ At vero eos et accusam et justo duo dolores et ea rebum.
+
+
+
+ Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.
+
+
+
+
+ <% (1..12).each_slice(4) do |slice| %>
+
+ <% slice.each do %>
+
+

+
+ <% end %>
+
+ <% end %>
+
+
+ <%= link_to "More photos on Immich", "#", class: "btn btn-primary" %>
+
+
+
+
+
+
+
+
+ <%= link_to "Edit this trip", edit_trip_path(@trip), class: "btn" %>
+ <%= link_to "Destroy this trip",
+ trip_path(@trip),
+ data: {
+ turbo_confirm: "Are you sure? This action will delete all points imported with this file",
+ turbo_method: :delete
+ },
+ class: "btn" %>
+ <%= link_to "Back to trips", trips_path, class: "btn" %>
+
+
+
diff --git a/config/routes.rb b/config/routes.rb
index 430ba885..78474586 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -3,6 +3,7 @@
require 'sidekiq/web'
Rails.application.routes.draw do
+ resources :trips
mount ActionCable.server => '/cable'
mount Rswag::Api::Engine => '/api-docs'
mount Rswag::Ui::Engine => '/api-docs'
diff --git a/db/migrate/20241127161621_create_trips.rb b/db/migrate/20241127161621_create_trips.rb
new file mode 100644
index 00000000..500b32bb
--- /dev/null
+++ b/db/migrate/20241127161621_create_trips.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class CreateTrips < ActiveRecord::Migration[7.2]
+ def change
+ create_table :trips do |t|
+ t.string :name, null: false
+ t.datetime :started_at, null: false
+ t.datetime :ended_at, null: false
+ t.text :notes
+ t.references :user, null: false, foreign_key: true
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 46ceb3d4..a48f37ac 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[7.2].define(version: 2024_10_30_152025) do
+ActiveRecord::Schema[7.2].define(version: 2024_11_27_161621) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -175,6 +175,32 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_30_152025) do
t.index ["year"], name: "index_stats_on_year"
end
+ create_table "trips", force: :cascade do |t|
+ t.string "name", null: false
+ t.datetime "started_at", null: false
+ t.datetime "ended_at", null: false
+ t.text "notes"
+ t.bigint "user_id", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["user_id"], name: "index_trips_on_user_id"
+ end
+
+ create_table "user_digests", force: :cascade do |t|
+ t.bigint "user_id", null: false
+ t.integer "kind", default: 0, null: false
+ t.datetime "start_at", null: false
+ t.datetime "end_at"
+ t.integer "distance", default: 0, null: false
+ t.text "countries", default: [], array: true
+ t.text "cities", default: [], array: true
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["distance"], name: "index_user_digests_on_distance"
+ t.index ["kind"], name: "index_user_digests_on_kind"
+ t.index ["user_id"], name: "index_user_digests_on_user_id"
+ end
+
create_table "users", force: :cascade do |t|
t.string "email", default: "", null: false
t.string "encrypted_password", default: "", null: false
@@ -216,6 +242,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_30_152025) do
add_foreign_key "points", "users"
add_foreign_key "points", "visits"
add_foreign_key "stats", "users"
+ add_foreign_key "trips", "users"
+ add_foreign_key "user_digests", "users"
add_foreign_key "visits", "areas"
add_foreign_key "visits", "places"
add_foreign_key "visits", "users"
diff --git a/spec/factories/trips.rb b/spec/factories/trips.rb
new file mode 100644
index 00000000..dbecdc41
--- /dev/null
+++ b/spec/factories/trips.rb
@@ -0,0 +1,9 @@
+FactoryBot.define do
+ factory :trip do
+ name { "MyString" }
+ started_at { "2024-11-27 17:16:21" }
+ ended_at { "2024-11-27 17:16:21" }
+ notes { "MyText" }
+ user { nil }
+ end
+end
diff --git a/spec/models/trip_spec.rb b/spec/models/trip_spec.rb
new file mode 100644
index 00000000..55660a6a
--- /dev/null
+++ b/spec/models/trip_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Trip, type: :model do
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:name) }
+ it { is_expected.to validate_presence_of(:started_at) }
+ it { is_expected.to validate_presence_of(:ended_at) }
+ end
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:user) }
+ end
+end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 25421848..c42d969f 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -13,6 +13,7 @@ RSpec.describe User, type: :model do
it { is_expected.to have_many(:areas).dependent(:destroy) }
it { is_expected.to have_many(:visits).dependent(:destroy) }
it { is_expected.to have_many(:places).through(:visits) }
+ it { is_expected.to have_many(:trips).dependent(:destroy) }
end
describe 'callbacks' do
diff --git a/spec/requests/trips_spec.rb b/spec/requests/trips_spec.rb
new file mode 100644
index 00000000..e0409075
--- /dev/null
+++ b/spec/requests/trips_spec.rb
@@ -0,0 +1,131 @@
+require 'rails_helper'
+
+# This spec was generated by rspec-rails when you ran the scaffold generator.
+# It demonstrates how one might use RSpec to test the controller code that
+# was generated by Rails when you ran the scaffold generator.
+#
+# It assumes that the implementation code is generated by the rails scaffold
+# generator. If you are using any extension libraries to generate different
+# controller code, this generated spec may or may not pass.
+#
+# It only uses APIs available in rails and/or rspec-rails. There are a number
+# of tools you can use to make these specs even more expressive, but we're
+# sticking to rails and rspec-rails APIs to keep things simple and stable.
+
+RSpec.describe "/trips", type: :request do
+
+ # This should return the minimal set of attributes required to create a valid
+ # Trip. As you add validations to Trip, be sure to
+ # adjust the attributes here as well.
+ let(:valid_attributes) {
+ skip("Add a hash of attributes valid for your model")
+ }
+
+ let(:invalid_attributes) {
+ skip("Add a hash of attributes invalid for your model")
+ }
+
+ describe "GET /index" do
+ it "renders a successful response" do
+ Trip.create! valid_attributes
+ get trips_url
+ expect(response).to be_successful
+ end
+ end
+
+ describe "GET /show" do
+ it "renders a successful response" do
+ trip = Trip.create! valid_attributes
+ get trip_url(trip)
+ expect(response).to be_successful
+ end
+ end
+
+ describe "GET /new" do
+ it "renders a successful response" do
+ get new_trip_url
+ expect(response).to be_successful
+ end
+ end
+
+ describe "GET /edit" do
+ it "renders a successful response" do
+ trip = Trip.create! valid_attributes
+ get edit_trip_url(trip)
+ expect(response).to be_successful
+ end
+ end
+
+ describe "POST /create" do
+ context "with valid parameters" do
+ it "creates a new Trip" do
+ expect {
+ post trips_url, params: { trip: valid_attributes }
+ }.to change(Trip, :count).by(1)
+ end
+
+ it "redirects to the created trip" do
+ post trips_url, params: { trip: valid_attributes }
+ expect(response).to redirect_to(trip_url(Trip.last))
+ end
+ end
+
+ context "with invalid parameters" do
+ it "does not create a new Trip" do
+ expect {
+ post trips_url, params: { trip: invalid_attributes }
+ }.to change(Trip, :count).by(0)
+ end
+
+ it "renders a response with 422 status (i.e. to display the 'new' template)" do
+ post trips_url, params: { trip: invalid_attributes }
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+ end
+
+ describe "PATCH /update" do
+ context "with valid parameters" do
+ let(:new_attributes) {
+ skip("Add a hash of attributes valid for your model")
+ }
+
+ it "updates the requested trip" do
+ trip = Trip.create! valid_attributes
+ patch trip_url(trip), params: { trip: new_attributes }
+ trip.reload
+ skip("Add assertions for updated state")
+ end
+
+ it "redirects to the trip" do
+ trip = Trip.create! valid_attributes
+ patch trip_url(trip), params: { trip: new_attributes }
+ trip.reload
+ expect(response).to redirect_to(trip_url(trip))
+ end
+ end
+
+ context "with invalid parameters" do
+ it "renders a response with 422 status (i.e. to display the 'edit' template)" do
+ trip = Trip.create! valid_attributes
+ patch trip_url(trip), params: { trip: invalid_attributes }
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+ end
+
+ describe "DELETE /destroy" do
+ it "destroys the requested trip" do
+ trip = Trip.create! valid_attributes
+ expect {
+ delete trip_url(trip)
+ }.to change(Trip, :count).by(-1)
+ end
+
+ it "redirects to the trips list" do
+ trip = Trip.create! valid_attributes
+ delete trip_url(trip)
+ expect(response).to redirect_to(trips_url)
+ end
+ end
+end