2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

前屈サポートアプリ「まいにち前屈」をリリース【個人開発】

2
Last updated at Posted at 2025-09-17

はじめに

こんにちは、プログラミングスクールにてWebエンジニアを目指して勉強中の初学者アキケロです。初めての個人開発アプリを制作した概要をまとめました。

⚠️ 注意
間違いやご意見などありましたら、コメントで教えていただけますと幸いです:pray:

サービス概要

前屈が苦手で体が硬いけれど、ジム通いや整体は続かない…そんな方のためのストレッチ記録アプリを制作しました。前屈をテーマに、日々の柔軟運動を投稿・記録し、他のユーザーと励まし合いながら継続を目指せます。「自分もやらなきゃ」と思える仕組みで、挫折しがちなストレッチ習慣を楽しくサポートします。
そもそもこのサービスを作ろうと思ったきっかけは、自身の体が硬すぎて前屈で手がつかないことを改善したいという思いがあったからです。しかし、ストレッチを継続する難しさを感じており、誰かに見られているという意識があれば頑張れるのでは?と、SNS機能を取り入れることにしました。結果的に、他のユーザーと応援し合いながらストレッチを続けられています。

また、アプリ制作にあたって一番大事にしたコンセプトは「めんどくさがりな人でも、毎日苦痛にならずに柔軟運動ができる」ことです。そのため、下記の3つを念頭に置いて、サービスを考えました。

  • 人にゆるく見張られたいが、人間関係の煩雑さを増やしたくない
  • 機能はシンプルに分かりやすく、始める/続けるハードルは小さく
  • 達成感や体の不調改善など、アプリから嬉しさを感じてもらいたい

日々の小さな積み重ねが体の変化につながり、孤独に頑張る必要のない自然と習慣になるサービスを目指しています。

▼ サービスURL
https://mainichi-zenkutsu.jp/

▼ GitHub
https://github.com/aki786-teq/app-production1

主な機能紹介

ストレッチ目標設定
goal.png
まずは簡単な目標から設定できます。達成報酬や未達成のペナルティを任意で設定できるため、モチベーションを上げられます。
投稿作成機能
create.png
ストレッチをやった/やっていないの選択だけで投稿できるため、毎日の手間を減らしています。
応援・ブックマーク機能
cheer.gif
応援ボタンをクリックすると、エフェクトが出て楽しく応援しあえます。ブックマーク機能で後で見返したい投稿も確認できます。
前屈測定機能
distance.gif
立位前屈の写真を使って自分の前屈レベルを知ることができます。応援メッセージがランダムでつくため、楽しく成長を感じられます。
リマインダー機能
reminder.png
3日間以上投稿がない場合、LINEでお知らせしてくれます。ストレッチを習慣化させてくれます。

大変だった実装

LINE通知によるリマインダー機能

LINE通知のために何が必要なのかをまず技術記事で調べてAIと相談しながら実装しました。しかし、LINEログインとLINE Messaging APIの両方を使用して実装したところ、LINE Messaging APIの機能だけでLINE通知することができることに気づいたため、大幅に実装変更をしました。そのため、事前調査の時点で一連の流れをもう少し調べておけば良かったと思いました。
また、開発環境では通知が届いていたのに本番環境では通知が届かないなど様々なエラーが出て、その度に調べてエラーを解決していったので想定より時間がかかってしまいました。

前屈測定機能

MediaPipeで立位前屈した際の体のランドマークを検出し、地面から指先までの距離を測定する実装をしました。 MediaPipeでは中指のランドマークが提供されていないことや、写真から解析しようとすると誤差が大きくなるため、正確な距離を測ることが難しかったです。そのため、cmで距離を出すのではなく、範囲で前屈レベルを測定する方法に落ち着きました。正確性や測定の楽しさを向上させるために、まだまだ改善していきたいです。

使用技術

カテゴリ 技術内容
開発環境 Docker
バックエンド Ruby 3.3.2 Rails 7.2.2.1
フロントエンド Hotwire(Turbo / Stimulus),JavaScript
UIフレームワーク Tailwind CSS v4
認証機能 Devise
アニメーション mo.js
インフラ Render
データベース PostgreSQL
バックグラウンドジョブ Sidekiq + Redis
テスト RSpec + SimpleCov
コード品質 RuboCop
CI GitHub Actions
外部API連携 LINE Messaging API, Google OAuth2, YouTube API, 楽天API
その他 sitemap_generator(サイトマップ作成),pagy(ページネーション),meta-tags(メタタグ管理),MediaPipe(前屈測定)

技術選定理由

開発環境

カリキュラムでDockerを使用した経験から、ローカル環境を手軽に構築できると考えました。

バックエンド

学習コストが低く、CRUD機能などWebアプリに必要な機能を素早く構築できると考えました。Railsは「設定より規約」という思想に基づいており、開発者が余計な設定をしなくても動く仕組みが整っています。そのため、他のフレームワークよりも開発の初期構築が速く、アプリの本質的な機能開発に集中できるのが強みです。

フロントエンド

Rails7に標準搭載されているHotwire(Turbo/Stimulus)を採用し、手軽に一部分のページ遷移などを実装できるため開発効率が高いと考えました。複雑な実装部分はHotwireと相性の良いJavaScriptを使用しています。

UIフレームワーク

Tailwind CSSはユーティリティクラスによるスタイリングのため、CSSのようにクラス名を考えなくて良い点が使いやすく、手軽にスタイリングができると考えました。

認証機能

gem Deviseを使用することでユーザー認証に必要な機能を素早く実装することができ、技術記事も豊富だったため実装のハードルを下げることができると考えました。

アニメーション

Xのいいねボタンのようなアニメーションをつけようと思い、TailwindやTurboとの相性のいいmo.jsを導入しました。

インフラ

複数のサービスと比較したとき、初心者にも扱いやすく技術記事も豊富な点からRenderを採用しました。

データベース

Renderが推奨していることからPostgreSQLを使用しました。

バックグラウンドジョブ

LINE通知機能の実装でSidekiqとRedisを組み合わせることでRailsとの相性を考慮しつつ通知処理を非同期化でき、高速に大量の通知やリトライ・スケジューリングを安定して実行できると考えました。

テスト

RSpecのルールに則った記述で分かりやすく、SimpleCovで網羅率を測定することで品質の向上につながると思いました。

コード品質

RuboCopによるコード整形で手数が少なくコードを整えられると考えました。

CI

GitHub Actionsでプルリク時に自動でテストとLintが走るようにし、コードの品質向上を目指しました。

外部API連携

それぞれユーザー体験を向上させたいという思いから導入しました。技術記事や公式ドキュメントを活用することができました。

その他

  • sitemap_generator サイトマップを手軽に作成できる点から導入しました。
  • pagy 効率よくページネーションを作成することができました。
  • meta-tags メタタグを管理しやすくすることができました。
  • MediaPipe 前屈測定で姿勢のランドマーク検出に活用することができました。

インフラ構成

infra.png

ER図

ER図はこちら(dbdiagram)

db/schema.rb
# モデルとアップロードしたファイルをつなぐ中間テーブル
  create_table "active_storage_attachments", force: :cascade do |t|
    t.string "name", null: false, comment: "モデル側で定義した添付ファイル名"
    t.string "record_type", null: false, comment: "添付元のモデル名"
    t.bigint "record_id", null: false, comment: "添付元のモデルのID"
    t.bigint "blob_id", null: false, comment: "実際のファイル情報が保存されるactive_storage_blobsテーブルのID"
    t.datetime "created_at", null: false
    t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
    t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
  end

# ファイルそのものの情報を管理するテーブル
  create_table "active_storage_blobs", force: :cascade do |t|
    t.string "key", null: false, comment: "ファイルを一意に識別するためのキー"
    t.string "filename", null: false, comment: "アップロードしたファイル名"
    t.string "content_type", comment: "ファイル形式。例: image"
    t.text "metadata", comment: "追加情報"
    t.string "service_name", null: false, comment: "保存先のサービス名"
    t.bigint "byte_size", null: false, comment: "ファイルのサイズ"
    t.string "checksum", comment: "ファイルの内容が壊れていないかを検証する値"
    t.datetime "created_at", null: false
    t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
  end

# 加工済み画像のキャッシュ情報を保存するテーブル
  create_table "active_storage_variant_records", force: :cascade do |t|
    t.bigint "blob_id", null: false, comment: "元の画像ファイルを指すID"
    t.string "variation_digest", null: false, comment: "どういう加工をしたのかを表すハッシュ値"
    t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
  end

  create_table "boards", force: :cascade do |t|
    t.boolean "did_stretch", null: false
    t.text "content"
    t.integer "flexibility_level"
    t.bigint "user_id", null: false
    t.bigint "goal_id", null: false
    t.text "goal_title", comment: "投稿時点の目標"
    t.text "goal_content"
    t.text "goal_reward"
    t.text "goal_punishment"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.string "youtube_link"
    t.string "item_code"
    t.string "item_name"
    t.integer "item_price"
    t.text "item_url"
    t.text "item_image_url"
    t.index ["goal_id"], name: "index_boards_on_goal_id"
    t.index ["user_id"], name: "index_boards_on_user_id"
  end

  create_table "bookmarks", force: :cascade do |t|
    t.bigint "user_id", null: false
    t.bigint "board_id", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["board_id"], name: "index_bookmarks_on_board_id"
    t.index ["user_id", "board_id"], name: "index_bookmarks_on_user_id_and_board_id", unique: true
    t.index ["user_id"], name: "index_bookmarks_on_user_id"
  end

  create_table "cheers", force: :cascade do |t|
    t.bigint "user_id", null: false
    t.bigint "board_id", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["board_id"], name: "index_cheers_on_board_id"
    t.index ["user_id", "board_id"], name: "index_cheers_on_user_id_and_board_id", unique: true
    t.index ["user_id"], name: "index_cheers_on_user_id"
  end

  create_table "goals", force: :cascade do |t|
    t.bigint "user_id", null: false
    t.text "goal"
    t.text "content"
    t.text "reward"
    t.text "punishment"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["user_id"], name: "index_goals_on_user_id"
  end

# LINE連携のトークン管理テーブル
  create_table "line_link_tokens", force: :cascade do |t|
    t.string "token", null: false, comment: "連携URLで使用する一意トークン"
    t.string "messaging_user_id", null: false, comment: "LINE Messaging APIのユーザーID"
    t.bigint "user_id"
    t.datetime "expires_at", null: false, comment: "トークンの有効期限"
    t.datetime "consumed_at", comment: "トークンを使用した日時"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["messaging_user_id"], name: "index_line_link_tokens_on_messaging_user_id"
    t.index ["token"], name: "index_line_link_tokens_on_token", unique: true
    t.index ["user_id"], name: "index_line_link_tokens_on_user_id"
  end

# LINE通知状態の管理テーブル
  create_table "line_notifications", force: :cascade do |t|
    t.bigint "user_id", null: false
    t.datetime "last_notified_at"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["last_notified_at"], name: "index_line_notifications_on_last_notified_at"
    t.index ["user_id"], name: "index_line_notifications_on_user_id", unique: true
  end

  create_table "notifications", force: :cascade do |t|
    t.string "subject_type", comment: "どのモデルか"
    t.bigint "subject_id", comment: "通知対象モデルのID"
    t.bigint "user_id"
    t.integer "action_type", null: false, comment: "何の通知か"
    t.boolean "checked", default: false, null: false, comment: "既読したか"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["subject_type", "subject_id"], name: "index_notifications_on_subject"
    t.index ["user_id"], name: "index_notifications_on_user_id"
  end

# 複数の外部認証アカウントを管理するテーブル
  create_table "oauth_accounts", force: :cascade do |t|
    t.bigint "user_id", null: false
    t.string "provider", null: false, comment: "どの外部サービスか"
    t.string "uid", null: false, comment: "プロバイダ側で一意のユーザーID"
    t.json "auth_data", comment: "認証時に外部サービスから返ってきた追加情報"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["provider", "uid"], name: "index_oauth_accounts_on_provider_and_uid", unique: true
    t.index ["user_id", "provider"], name: "index_oauth_accounts_on_user_id_and_provider", unique: true
    t.index ["user_id"], name: "index_oauth_accounts_on_user_id"
  end

  create_table "stretch_distances", force: :cascade do |t|
    t.bigint "user_id", null: false
    t.bigint "board_id"
    t.text "comment_template"
    t.string "flexibility_level"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["board_id"], name: "index_stretch_distances_on_board_id"
    t.index ["user_id", "created_at"], name: "index_stretch_distances_on_user_id_and_created_at"
    t.index ["user_id"], name: "index_stretch_distances_on_user_id"
  end

  create_table "users", force: :cascade do |t|
    t.string "email", default: "", null: false
    t.string "encrypted_password", default: "", null: false
    t.string "name", default: "", null: false
    t.string "reset_password_token"
    t.datetime "reset_password_sent_at"
    t.datetime "remember_created_at", comment: "ログイン状態保持の開始時刻"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.string "confirmation_token", comment: "確認用リンクに埋め込まれるトークン"
    t.datetime "confirmed_at", comment: "メール認証が完了した日時"
    t.datetime "confirmation_sent_at", comment: "確認メールを送った日時"
    t.string "unconfirmed_email", comment: "変更後のメールアドレスが一時的に保存される(再認証のときに使用)"
    t.text "introduce"
    t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
    t.index ["email"], name: "index_users_on_email", unique: true
    t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
  end

  add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
  add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
  add_foreign_key "boards", "goals"
  add_foreign_key "boards", "users"
  add_foreign_key "bookmarks", "boards"
  add_foreign_key "bookmarks", "users"
  add_foreign_key "cheers", "boards"
  add_foreign_key "cheers", "users"
  add_foreign_key "goals", "users"
  add_foreign_key "line_link_tokens", "users"
  add_foreign_key "line_notifications", "users"
  add_foreign_key "notifications", "users"
  add_foreign_key "oauth_accounts", "users"
  add_foreign_key "stretch_distances", "boards"
  add_foreign_key "stretch_distances", "users"
end

画面遷移図

画面遷移図はこちら(figma)
ターゲットが若い世代からご年配までと幅広いため、シンプルで分かりやすいデザインを心がけました。

今後の開発予定

  • リファクタリング
    コードの保守性を向上させたいです。

  • 継続達成お祝い機能
    継続した日数に応じて、お祝い機能を追加したいです。

  • UI/UXの向上
    柔軟性をテーマに猫をモチーフにした癒し要素を取り入れたいです。また、すでにある機能をもっと面白い表現にし、ユーザー体験を向上させたいです。

終わりに

初めてアプリを作ってみて、分からないことだらけで不安でしたが、周りの力を借りながらリリースすることができて嬉しかったです。
反省点として、最初に計画していた期限に間に合わせることはできたのですが、コードの理解と工夫が力不足だと思いました。そのため、今後もアウトプットしながら学習を続けて、よりいいコード・より楽しさを提供できるアプリにしていきたいです。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?