diff --git a/app/controllers/api/summary_controller.rb b/app/controllers/api/summary_controller.rb new file mode 100644 index 0000000..9dfa7f5 --- /dev/null +++ b/app/controllers/api/summary_controller.rb @@ -0,0 +1,157 @@ +module Api + class SummaryController < ApplicationController + skip_before_action :verify_authenticity_token + + def index + # Parse interval or date range + date_range = determine_date_range(params[:interval], params[:range], params[:from], params[:to]) + return render json: { error: "Invalid date range" }, status: :bad_request unless date_range + + # Create parameters for WakatimeService + start_date = date_range.begin.to_i + end_date = date_range.end.to_i + + # Get user if specified + user = User.find_by(slack_uid: params[:user]) if params[:user].present? + + # Determine which summary elements we want + specific_filters = [ :projects, :languages, :editors, :operating_systems, :machines, :categories, :branches, :entities ] + + # Create service instance with filters applied + service = WakatimeService.new( + user: user, + specific_filters: specific_filters, + allow_cache: true, + limit: nil, # No limit for summary data + start_date: start_date, + end_date: end_date + ) + + # Get the summary data from WakatimeService + wakatime_summary = service.generate_summary + + # Format for API response + summary = { + from: date_range.begin.strftime("%Y-%m-%d %H:%M:%S.000"), + to: date_range.end.strftime("%Y-%m-%d %H:%M:%S.000"), + projects: wakatime_summary[:projects] || [], + languages: wakatime_summary[:languages] || [], + editors: wakatime_summary[:editors] || [], + operating_systems: wakatime_summary[:operating_systems] || [], + machines: wakatime_summary[:machines] || [], + categories: wakatime_summary[:categories] || [], + branches: wakatime_summary[:branches] || [], + entities: wakatime_summary[:entities] || [], + labels: wakatime_summary[:labels] || [] + } + + render json: summary + end + + private + + def determine_date_range(interval, range, from_date, to_date) + timezone = "UTC" + Time.use_zone(timezone) do + now = Time.current + + if from_date.present? && to_date.present? + begin + from = Time.zone.parse(from_date).beginning_of_day + to = Time.zone.parse(to_date).end_of_day + return from..to + rescue + return nil + end + end + + interval ||= range # Allow range parameter as an alias for interval + + case interval + when "today" + now.beginning_of_day..now.end_of_day + when "yesterday" + (now - 1.day).beginning_of_day..(now - 1.day).end_of_day + when "week", "7_days" + now.beginning_of_week..now.end_of_week + when "last_7_days" + (now - 7.days).beginning_of_day..now.end_of_day + when "month", "30_days" + now.beginning_of_month..now.end_of_month + when "last_30_days" + (now - 30.days).beginning_of_day..now.end_of_day + when "6_months" + now.beginning_of_month - 5.months..now.end_of_month + when "last_6_months" + (now - 6.months).beginning_of_day..now.end_of_day + when "year", "12_months" + now.beginning_of_year..now.end_of_year + when "last_12_months", "last_year" + (now - 1.year).beginning_of_day..now.end_of_day + when "any", "all_time", nil + Time.at(0)..now.end_of_day + else + now.beginning_of_day..now.end_of_day # Default to today + end + end + end + + def filter_heartbeats(heartbeats, params) + heartbeats = heartbeats.where(project: params[:project]) if params[:project].present? + heartbeats = heartbeats.where(language: params[:language]) if params[:language].present? + heartbeats = heartbeats.where(editor: params[:editor]) if params[:editor].present? + heartbeats = heartbeats.where(operating_system: params[:operating_system]) if params[:operating_system].present? + heartbeats = heartbeats.where(machine: params[:machine]) if params[:machine].present? + + if params[:user].present? + user = User.find_by(slack_uid: params[:user]) + heartbeats = heartbeats.where(user_id: user.id) if user + end + + heartbeats + end + + def calculate_summary(heartbeats, date_range) + projects = {} + languages = {} + editors = {} + operating_systems = {} + machines = {} + categories = {} + branches = {} + entities = {} + labels = {} + + + # Format summary items + { + from: date_range.begin.strftime("%Y-%m-%d %H:%M:%S.000"), + to: date_range.end.strftime("%Y-%m-%d %H:%M:%S.000"), + projects: format_summary_items(projects), + languages: format_summary_items(languages), + editors: format_summary_items(editors), + operating_systems: format_summary_items(operating_systems), + machines: format_summary_items(machines), + categories: format_summary_items(categories), + branches: format_summary_items(branches), + entities: format_summary_items(entities), + labels: format_summary_items(labels) + } + end + + def format_summary_items(items_hash) + items_hash.map do |key, total_seconds| + next if key.blank? + { + key: key, + total_seconds: total_seconds, + total: total_seconds, + text: ApplicationController.helpers.short_time_simple(total_seconds), + hours: total_seconds / 3600, + minutes: (total_seconds % 3600) / 60, + digital: ApplicationController.helpers.digital_time(total_seconds) + } + end.compact + end + end +end diff --git a/config/routes.rb b/config/routes.rb index bd08d67..55f24a7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -98,6 +98,9 @@ Rails.application.routes.draw do end end + # wakatime compatible summary + get "summary", to: "summary#index" + # Everything in this namespace conforms to wakatime.com's API. namespace :hackatime do namespace :v1 do diff --git a/db/primary_direct_schema.rb b/db/primary_direct_schema.rb new file mode 100644 index 0000000..50efc5b --- /dev/null +++ b/db/primary_direct_schema.rb @@ -0,0 +1,276 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.0].define(version: 2025_04_25_211619) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + + create_table "api_keys", force: :cascade do |t| + t.bigint "user_id", null: false + t.text "name", null: false + t.text "token", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["token"], name: "index_api_keys_on_token", unique: true + t.index ["user_id", "name"], name: "index_api_keys_on_user_id_and_name", unique: true + t.index ["user_id", "token"], name: "index_api_keys_on_user_id_and_token", unique: true + t.index ["user_id"], name: "index_api_keys_on_user_id" + end + + create_table "email_addresses", force: :cascade do |t| + t.string "email" + t.bigint "user_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["email"], name: "index_email_addresses_on_email", unique: true + t.index ["user_id"], name: "index_email_addresses_on_user_id" + end + + create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "description" + t.jsonb "serialized_properties" + t.text "on_finish" + t.text "on_success" + t.text "on_discard" + t.text "callback_queue_name" + t.integer "callback_priority" + t.datetime "enqueued_at" + t.datetime "discarded_at" + t.datetime "finished_at" + t.datetime "jobs_finished_at" + end + + create_table "good_job_executions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "active_job_id", null: false + t.text "job_class" + t.text "queue_name" + t.jsonb "serialized_params" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.text "error" + t.integer "error_event", limit: 2 + t.text "error_backtrace", array: true + t.uuid "process_id" + t.interval "duration" + t.index ["active_job_id", "created_at"], name: "index_good_job_executions_on_active_job_id_and_created_at" + t.index ["process_id", "created_at"], name: "index_good_job_executions_on_process_id_and_created_at" + end + + create_table "good_job_processes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.jsonb "state" + t.integer "lock_type", limit: 2 + end + + create_table "good_job_settings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "key" + t.jsonb "value" + t.index ["key"], name: "index_good_job_settings_on_key", unique: true + end + + create_table "good_jobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.text "queue_name" + t.integer "priority" + t.jsonb "serialized_params" + t.datetime "scheduled_at" + t.datetime "performed_at" + t.datetime "finished_at" + t.text "error" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "active_job_id" + t.text "concurrency_key" + t.text "cron_key" + t.uuid "retried_good_job_id" + t.datetime "cron_at" + t.uuid "batch_id" + t.uuid "batch_callback_id" + t.boolean "is_discrete" + t.integer "executions_count" + t.text "job_class" + t.integer "error_event", limit: 2 + t.text "labels", array: true + t.uuid "locked_by_id" + t.datetime "locked_at" + t.index ["active_job_id", "created_at"], name: "index_good_jobs_on_active_job_id_and_created_at" + t.index ["batch_callback_id"], name: "index_good_jobs_on_batch_callback_id", where: "(batch_callback_id IS NOT NULL)" + t.index ["batch_id"], name: "index_good_jobs_on_batch_id", where: "(batch_id IS NOT NULL)" + t.index ["concurrency_key"], name: "index_good_jobs_on_concurrency_key_when_unfinished", where: "(finished_at IS NULL)" + t.index ["cron_key", "created_at"], name: "index_good_jobs_on_cron_key_and_created_at_cond", where: "(cron_key IS NOT NULL)" + t.index ["cron_key", "cron_at"], name: "index_good_jobs_on_cron_key_and_cron_at_cond", unique: true, where: "(cron_key IS NOT NULL)" + t.index ["finished_at"], name: "index_good_jobs_jobs_on_finished_at", where: "((retried_good_job_id IS NULL) AND (finished_at IS NOT NULL))" + t.index ["labels"], name: "index_good_jobs_on_labels", where: "(labels IS NOT NULL)", using: :gin + t.index ["locked_by_id"], name: "index_good_jobs_on_locked_by_id", where: "(locked_by_id IS NOT NULL)" + t.index ["priority", "created_at"], name: "index_good_job_jobs_for_candidate_lookup", where: "(finished_at IS NULL)" + t.index ["priority", "created_at"], name: "index_good_jobs_jobs_on_priority_created_at_when_unfinished", order: { priority: "DESC NULLS LAST" }, where: "(finished_at IS NULL)" + t.index ["priority", "scheduled_at"], name: "index_good_jobs_on_priority_scheduled_at_unfinished_unlocked", where: "((finished_at IS NULL) AND (locked_by_id IS NULL))" + t.index ["queue_name", "scheduled_at"], name: "index_good_jobs_on_queue_name_and_scheduled_at", where: "(finished_at IS NULL)" + t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)" + end + + create_table "heartbeats", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "branch" + t.string "category" + t.string "dependencies", default: [], array: true + t.string "editor" + t.string "entity" + t.string "language" + t.string "machine" + t.string "operating_system" + t.string "project" + t.string "type" + t.string "user_agent" + t.integer "line_additions" + t.integer "line_deletions" + t.integer "lineno" + t.integer "lines" + t.integer "cursorpos" + t.integer "project_root_count" + t.float "time", null: false + t.boolean "is_write" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "fields_hash" + t.integer "source_type", null: false + t.inet "ip_address" + t.integer "ysws_program", default: 0, null: false + t.index ["category", "time"], name: "index_heartbeats_on_category_and_time" + t.index ["fields_hash"], name: "index_heartbeats_on_fields_hash", unique: true + t.index ["user_id"], name: "index_heartbeats_on_user_id" + end + + create_table "leaderboard_entries", force: :cascade do |t| + t.bigint "leaderboard_id", null: false + t.integer "total_seconds", default: 0, null: false + t.integer "rank" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.integer "streak_count", default: 0 + t.index ["leaderboard_id", "user_id"], name: "idx_leaderboard_entries_on_leaderboard_and_user", unique: true + t.index ["leaderboard_id"], name: "index_leaderboard_entries_on_leaderboard_id" + end + + create_table "leaderboards", force: :cascade do |t| + t.date "start_date", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.datetime "finished_generating_at" + t.datetime "deleted_at" + t.integer "period_type", default: 0, null: false + end + + create_table "project_repo_mappings", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "project_name", null: false + t.string "repo_url", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id", "project_name"], name: "index_project_repo_mappings_on_user_id_and_project_name", unique: true + t.index ["user_id"], name: "index_project_repo_mappings_on_user_id" + end + + create_table "sailors_log_leaderboards", force: :cascade do |t| + t.string "slack_channel_id" + t.string "slack_uid" + t.text "message" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.datetime "deleted_at" + end + + create_table "sailors_log_notification_preferences", force: :cascade do |t| + t.string "slack_uid", null: false + t.string "slack_channel_id", null: false + t.boolean "enabled", default: true, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["slack_uid", "slack_channel_id"], name: "idx_sailors_log_notification_preferences_unique_user_channel", unique: true + end + + create_table "sailors_log_slack_notifications", force: :cascade do |t| + t.string "slack_uid", null: false + t.string "slack_channel_id", null: false + t.string "project_name", null: false + t.integer "project_duration", null: false + t.boolean "sent", default: false, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "sailors_logs", force: :cascade do |t| + t.string "slack_uid", null: false + t.jsonb "projects_summary", default: {}, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "sign_in_tokens", force: :cascade do |t| + t.string "token" + t.bigint "user_id", null: false + t.integer "auth_type" + t.datetime "expires_at" + t.datetime "used_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["token"], name: "index_sign_in_tokens_on_token" + t.index ["user_id"], name: "index_sign_in_tokens_on_user_id" + end + + create_table "users", force: :cascade do |t| + t.string "slack_uid" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "username" + t.string "slack_avatar_url" + t.boolean "is_admin", default: false, null: false + t.boolean "uses_slack_status", default: false, null: false + t.string "slack_scopes", default: [], array: true + t.text "slack_access_token" + t.integer "hackatime_extension_text_type", default: 0, null: false + t.string "timezone", default: "UTC" + t.string "github_uid" + t.string "github_avatar_url" + t.text "github_access_token" + t.string "github_username" + t.string "slack_username" + t.index ["slack_uid"], name: "index_users_on_slack_uid", unique: true + t.index ["timezone"], name: "index_users_on_timezone" + end + + create_table "versions", force: :cascade do |t| + t.string "whodunnit" + t.datetime "created_at" + t.bigint "item_id", null: false + t.string "item_type", null: false + t.string "event", null: false + t.text "object" + t.text "object_changes" + t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id" + end + + add_foreign_key "api_keys", "users" + add_foreign_key "email_addresses", "users" + add_foreign_key "heartbeats", "users" + add_foreign_key "leaderboard_entries", "leaderboards" + add_foreign_key "leaderboard_entries", "users" + add_foreign_key "project_repo_mappings", "users" + add_foreign_key "sign_in_tokens", "users" +end