🌈

ブレイクダンサーが役立つであろうWebアプリを個人開発してみた

2024/12/27に公開

はじめに

2024年もお疲れ様でした。
今回ブレイクダンサー向けのWebアプリを個人開発してみて、どういうことを考えて開発をしたかなどを自分自身の備忘録を含めてまとめてみました。

作ったアプリ

ブレイクダンサー向けのオンリーワンアプリを目指す「breakin one」というwebアプリです。

https://www.breakin-one.com/

アプリケーションの規模や開発期間等

現時点でおおよそ下記になります。

  • 開発期間(開発時間)
    • 3ヶ月ほど(毎日1日の早朝と夜間の3時間くらいなので、300時間弱くらいの時間)
  • 画面数
    • 27ページ
  • DBのテーブル数
    • 21個
  • api数
    • 59個

技術構成等

ディレクトリ構造

一部削除しているところがあるのですが、大まかなディレクトリ構成は下記のような構成になります。
ディレクトリ設計はプロジェクトの特性で正解もないと思うので参考程度にご確認ください。

src以下
.
├── app
│   ├── (base) # Route Groupsの機能を使っている。
│   │   ├── [nickname]# 一部紹介。ユーザー画面に関わるものを置いている。
│   │   │   ├── components # componentsも依存するものをまとめて置いている。
│   │   │   │   ├── UserShowProfile
│   │   │   │   │   ├── index.tsx
│   │   │   │   │   └── styles.module.css
│   │   │   │   └── UsersModal
│   │   │   │       ├── index.tsx
│   │   │   │       └── styles.module.css
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   ├── styles.module.css
│   │   └── util.ts # base配下に依存するものをまとめて置いている。
│   ├── api # trpc部分を一部紹介
│   │   └── trpc
│   │       └── [trpc]
│   │           └── route.ts # trpcのhandler や createContextなど、
│   ├── error.tsx
│   ├── layout.tsx
│   ├── not-found.tsx
│   ├── styles.module.css
├── assets
│   ├── images
│   │   └── materialIcon.ts
│   └── styles
│       └── globals.css
├── components
│   ├── common # 「ログイン依頼のモーダル」などの特定の画面に依存をしない共通のコンポーネントなど
│   ├── provider
│   ├── template # 画面のレイアウト部分などに関係するもの
│   └── ui-parts # buttonやcardなど、プロジェクトに依存をしないような汎用的なコンポーネント
├── e2e # playwrigtで作られたもの
├── env.ts # envはzodで管理
├── features # 今回featureディレクトリを採用したがあまり使っていない。
│   ├── app-rule
│   │   └── index.ts
│   ├── completencess
│   │   └── index.ts
│   ├── meta-data
│   │   └── index.ts
│   ├── move
│   │   └── index.ts
│   ├── move-post
│   │   └── index.ts
│   ├── sns
│   │   └── index.ts
│   └── storage
│       ├── index.test.ts
│       └── index.ts

├── lib
├── middleware.ts
├── server # サーバー側の処理
│   ├── api
│   │   ├── external # 外部サービス
│   │   │   ├── email
│   │   │   ├── storage
│   │   │   └── stripe
│   │   ├── repository # データアクセス層
│   │   ├── root.ts # trpcでcreateTRPCRouterでappRouterを作っている場所
│   │   ├── routers
│   │   │   ├── move # trpcの各routerの処理を書いている。
│   │   │   │   ├── show # ムーブ詳細
│   │   │   │   │   ├── index.ts
│   │   │   │   │   ├── request.test.ts
│   │   │   │   │   ├── request.ts(リクエストを受ける)
│   │   │   │   │   ├── resolver.test.ts
│   │   │   │   │   ├── resolver.ts(ユースケース的なところ)
│   │   │   │   │   ├── response.test.ts
│   │   │   │   │   └── response.ts(レスポンスの整形)
│   │   ├── trpc.ts
│   │   └── type.ts
│   ├── db.ts
│   ├── libs
│   │   └── error.ts
│   └── services # server actionsので使う、actionとschemaを配置。
│       ├── move
│       │   ├── create
│       │   │   ├── action.ts
│       │   │   └── schema.ts
├── styles
├── test
│   ├── __generated__
│   │   └── fabbrica
│   │       ├── index.d.ts
│   │       └── index.js
│   ├── mock.ts
│   ├── factories.ts
│   └── vitest.setup.ts
└── trpc
    ├── query-client.ts
    ├── react.tsx
    └── server.ts

技術選定の際に考えたこと

  • 出来るだけ多くのライブラリを入れない。
    • バージョンアップの際に他のライブラリとの依存関係でうまく動かないなどを考えるのが手間なた
  • 学習コストが少なくなるように意識する
    • 本業で開発がある、かつ家庭の事情でそこまで時間が取れないため、基本的には慣れている技術を扱う
  • 開発を楽しむために関心のある新しい技術を少しを扱う。
    • 基本的にはあまり学習コストの低い構成にしたいのですが、仕事と同じような構成だと学びも面白みもないので1,2つくらい自分の学習になるようなものを使う

ツール

ドキュメントとしてNotionを使いました。
たまにうまく動かない?ような時もあるのですが、色々なことを雑にまとめられるのでとても重宝しています。主に下記のような使い方です。

  • TODO管理
    • 実装をする必要があることを雑に書く、優先度を考えながら並び替えて上から順番に実装をする
  • 画面設計
    • 特定画面ごとで実装をする機能の詳細をまとめる
  • URL設計
  • etc....

アプリケーション

慣れていることもあり、Next.js(v15)を使っています。
App Routerをこのあたりでちゃんとキャッチアップをした方がいいなという気持ちがあり採用をしました。
実装としては出来るだけバンドルサイズを少なくすることを意識して、Server Componentsを活用して実装をしました。
個人的にはクライアントコンポーネントとサーバーコンポーネントで出来ること、出来ないことを意識しながら開発をするのは少し面倒ではありましたがそれほど難しいと感じる点は少なかったのでよかったです。

RPC

TRPCを使いました。こちらは入れるか迷ったのですが、個人的な学習を目的に導入をしました。
よくある型定義のコードを生成するやり方もあると思うのですが、コード生成をせずにバックエンドのインターフェースがそのままフロントエンドに反映されるというのがとても開発体験がとてもよかったです。
またバックエンド側のミドルウェアも簡単にかける点も個人的に好きなポイントです。

スタイリング

CSS Modulesで一部複数のクラスを適用する部分でclsxを使って実装をしました。

選定の理由としては、

  • ライブラリを使った時のパフォーマンスの問題を考えるのが面倒だった
  • ライブラリのドキュメントを見るのが面倒だった。
  • 例としてTailwind CSSのようにtsx側にスタイリングの情報をあまり書きたくなかった。
  • 今までの個人開発でcssで実装をしてきたコンポーネントが資産としてあるのでそれを使いまわす形で実装をできたから

という理由がありました。

今回チャレンジ的にScssも使わずに対応をしてみましたが、そこまで大きなプロジェクトではないのもあり、特に問題もなく実装もできたのでよかったです。

Linter, Formatter

biomeを使いました。セットアップが楽です。
LinterとFormatterの最近のスタンダードになってきているのではと感じています。
以前はPrettierとESLintを使っていたりしましたが、biomeだけで完結ができるのが素敵です。

認証

bcrypt, jsonwebtokenを使って認証を実装しました。NextAuth.jsを使うことも考えていたのですが、認証周りで複数のプロバイダを使う予定もなかったのと、実装にそこまで大きな時間もかからないと判断をしてNextAuthを使わない実装するようにしました。

ORM

Prismaを使いました。こちらも以前から使っていた経験があり慣れているという理由もあり採用をしました。
個人的な関心としてはdrizzleも関心があるので次回に何かしらを作るときには使ってみたいと思います。

Storage(動画、画像の保存)

Cloudflare R2を使っています。最初はVercel Storageを使って対応をしていたのですが、動画を扱う上で、Vercel Storageだと料金に不安を感じてリリース直前にCloudflare R2に乗り換えました。詳細はこちらの「CloudflareのR2って何ですか?」な私がR2(カスタムドメイン)とNext.jsを使ってファイルをアップロードするに書いているのですが、興味がありましたら是非ご覧ください。

Form

Conformを利用しています。以前にこちらの akfm_sato(あっきー)さんの記事でServer Actions時代のformライブラリconformという記事やその他の記事でserver actionsを扱うのであればConformがいいぞ、という噂を聞き利用をしてみました。公式のドキュメントも見やすく、とても扱いやすかったです。

バリデーション

zodを利用しています。私があまりバリデーションライブラリに明るくないのですが、おそらく今のデファクトスタンダードなのではないかなと思っています。用途としては下記で主に利用をしています。

  • TRPCのサーバー側でのバリデーション
  • クライアント側のフォームでのバリデーション

Vercel関係

こちらの選定理由は楽である以外は特にないです。
個人開発という特性上でインフラ技術を複数のサービスを跨いで管理するのが手間なのでvercelをメインで利用しています。

ホスティング

vercelを使っています。圧倒的に楽な点で重宝しております。

データベース

Vercel Postgresを使っています。
他のサービスを使うかも迷ったのですが、そこまで大きなサービスになることを考えていなかったので採用をしました。

Cron Jobs

Vercel Cron Jobsを使っています。
アップロードしたがDBに保存をしていない画像、動画ファイル等のゴミファイルを定期的にお掃除するために使っています。
こちらもvercelのドキュメントが見やすく簡単に扱えてよかったです。

ライブラリの自動アップデート

renovatebotを利用しています。
dependabotでもよかったのですが、以前から使っていて慣れているという理由で使っています。
ライブラリのアップデートは面倒ですが、新しい機能でどういうものが出たのかなどのキャッチアップも含めて更新をするようにしています。

テスト

基本的にはVitestを使ってバックエンドをメインにユニットテストをベースに行なっています。一部でplaywrightを使ってE2Eのテストを書いているのですが、あまり書けていないです。
DBに関してはモックを使わずに、prisma-fabbrica, vitest-environment-vprisma を使ってテスト用のDBをdockerで準備して実装をしました。

実装とテスト

サンプルとして下記のような実装とテストを行なっています。
trpcの各routerの実装は細かく分けて関心ごとにテストをかけるように分割をしています。下記のようなイメージです。

src/server/api/routers/move/show
├── show
│   ├── index.ts
│   ├── request.test.ts
│   ├── request.ts
│   ├── resolver.test.ts
│   ├── resolver.ts
│   ├── response.test.ts
│   └── response.ts
src/server/api/routers/move/show/request.ts

import { z } from 'zod'

export const moveShowServerSchema = z.object({
  id: z.number({
    required_error: 'idは必須項目です。',
    invalid_type_error: 'idは数値である必要があります。',
  }),
})

export type MoveShowServerSchema = z.infer<typeof moveShowServerSchema>

src/server/api/routers/move/show/request.test.ts
import { describe, expect, it } from 'vitest'
import { moveShowServerSchema } from './request'

describe('moveShowServerSchema', () => {
  it('正しいidの場合、バリデーションが成功すること', () => {
    const validData = { id: 1 }
    const result = moveShowServerSchema.safeParse(validData)
    expect(result.success).toBe(true)
  })

  it('idが存在しない場合、エラーメッセージが返されること', () => {
    const invalidData = {}
    const result = moveShowServerSchema.safeParse(invalidData)
    expect(result.success).toBe(false)
    if (!result.success) {
      expect(result.error.errors[0].message).toBe('idは必須項目です。')
    }
  })

  it('idが数値でない場合、エラーメッセージが返されること', () => {
    const invalidData = { id: 'not-a-number' }
    const result = moveShowServerSchema.safeParse(invalidData)
    expect(result.success).toBe(false)
    if (!result.success) {
      expect(result.error.errors[0].message).toBe('idは数値である必要があります。')
    }
  })
})

レスポンスはprismaで返したDBのものをそのまま返すのではなく、フロントで使うものだけを返すようにしています。逆にprismaから返す時にはselectなどは行なっていないです。
あと、秘匿情報なども返さないようにここで実装とテストを書きながら確認をしています。

src/server/api/routers/move/show/response.ts

import type { FindByIdMoveDBResponse } from '@/server/api/repository/move'
import type { Completencess, ReferenceLink } from '@prisma/client'

export type ShowResponse = {
  id: number
  name: string
  content: string
  isSignature: boolean
  videoUrl: string
  completencess: Completencess
  moveCategoryIds: number[]
  referenceLinks: Pick<ReferenceLink, 'title' | 'url'>[]
}

export const toResponse = (move: FindByIdMoveDBResponse): ShowResponse => {
  return {
    ...move,
    moveCategoryIds: move.moveMoveCategories.map((mmc) => mmc.moveCategory.id),
    referenceLinks: move.referenceLinks.map((rl) => ({ title: rl.title, url: rl.url })),
  }
}

src/server/api/routers/move/show/response.test.ts
import { describe, it } from 'vitest'
import { type ShowResponse, toResponse } from './response'
import type { FindByIdMoveDBResponse } from '@/server/api/repository/move'
import { moveMock } from '@/test/mock'

describe('toResponse', () => {
  it('レスポンスの整形が正しく行われること', ({ expect }) => {
    const move: FindByIdMoveDBResponse = {
      ...moveMock,
      referenceLinks: [
        { id: 1, title: 'hogehoge', url: 'http://example.com/link1', moveId: 1, createdAt: new Date() },
        { id: 2, title: 'fugafuga', url: 'http://example.com/link12', moveId: 2, createdAt: new Date() },
      ],
      moveMoveCategories: [
        { moveCategory: { id: 1, type: 'BACK_ROCK', createdAt: new Date() } },
        { moveCategory: { id: 2, type: 'BASIC', createdAt: new Date() } },
      ],
    }

    const expected: ShowResponse = {
      ...move,
      moveCategoryIds: [1, 2],
      referenceLinks: [
        { title: 'hogehoge', url: 'http://example.com/link1' },
        { title: 'fugafuga', url: 'http://example.com/link12' },
      ],
    }
    // 実行と検証
    expect(toResponse(move)).toEqual(expected)
  })
})


src/server/api/routers/move/show/resolver.ts
import type { FindByIdMoveResponse } from '@/server/api/repository/move'
import type { AuthProcedureArg } from '@/server/api/type'
import type { MoveShowServerSchema } from './request'
import { toResponse } from './response'

export const resolver = async ({ ctx, input }: AuthProcedureArg<MoveShowServerSchema>) => {
  const move: FindByIdMoveResponse | null = await ctx.repository.move.findById(input.id)
  if (move === null) {
    throw new Error('Not Found Move')
  }
  return toResponse(move)
}

src/server/api/routers/move/show/resolver.test.ts
import { describe, it } from 'vitest'
import { db } from '@/server/db'
import { initializeRepository } from '@/server/api/repository'
import type { MoveShowServerSchema } from './request'
import { resolver } from './resolver'
import type { AuthProcedureArg } from '@/server/api/type'
import { moveFactory } from '@/test/factories' // fabbricaで各modelに合わせてdefine~~でデータを準備している。

describe('move/show/resolver', () => {
  it('moveを取得できる。', async ({ expect }) => {
    // 準備
    const createdMove = await moveFactory.create()
    const repository = initializeRepository(db)
    const input: MoveShowServerSchema = { id: createdMove.id }
    const arg = { ctx: { repository }, input } as AuthProcedureArg<MoveShowServerSchema> // TODO refactor
    // 実行
    const result = await resolver(arg)
    // 検証
    expect(result).toMatchObject(createdMove)
  })
  it('idが誤っている場合はエラーを返す。', async ({ expect }) => {
    const repository = initializeRepository(db)
    const input: MoveShowServerSchema = { id: 11111111 }
    const arg = { ctx: { repository }, input } as AuthProcedureArg<MoveShowServerSchema>
    await expect(resolver(arg)).rejects.toThrow('Not Found Move')
  })
})

技術的な点の課題や良かった点

Server Actionsについて

Next.jsでserver actionsを推奨していることも考慮して client => server action => router(trpc)のような実装にしました。
ログイン画面など、プログレッシブエンハンスを意識してserver actionsを活用することでJavaScriptをオフにしてもログインが可能になりました。
ただ、今回は自分の関心で使ってみたもの実装としては複雑性を増してしまったので無理をしてserver actionsを使わなくてもよかったなと感じました。

featuresについて

src/featuresのディレクトリに機能別で実装を置こうと当初は考えていたのですが、現状だと色々なところで使う共通関数置き場なだけになっていました。
機能的なものが基本特定の画面に依存をしてapp/のディレクトリに配置をしたことによってfeaturesがそこまで機能をしていないのではないかと思います。
今後このアプリケーションではfeatures廃止をしてmodels的な命名に変更をしようと思います。

テストについて

こちらで記載をしましたが、ファイル、関数を細かく分割をしてテストを書いたのでテストがしやすくて良かったです。
今テストケースを確認すると300個ほどの行なっていたので個人開発にしては頑張った方かなと思います。

アプリを開発したきっかけ

自分がブレイクダンスを以前からやっていて最近は仕事や家庭の事情もありダンスをほとんどやっていませんでした。ただ、今年の8月にオリンピックでブレイクダンスを見て、久しぶりにダンス熱が上がり、いい歳ではあるのですが、久しぶりに真面目にやってみようとなりました。
ただ、練習をする上で自分のダンスの動きを管理するためのツールで自分が欲しいと思うものがなかったので自分の課題を解決することを目的に練習ができない朝や夜に開発を始めました。

開発の進め方

友人のダンサーへの聞き取り

自分も長くダンスをやってきましたが、最近は全くダンスをしていなかったので最近のダンス事情を知らない可能性もありました。そこで知人のダンサーに話を聞くところから始めました。自分の考えをそのまま実装するのもいいかと思ったのですが、下記を確認しました。

  • 実装をしようとしている機能が実際に課題を解決するか第三者に確認する。そこで認識齟齬がないかを確認する
  • ダンサーの業界で困っていることを聞き取りする。どういう課題があるのかを聞いてみる。

画面一覧をまとめる

どういう画面が必要なのかをまとめました。

大きな機能説明(課題、実装理由等)

ムーブ機能

作る際の課題

  1. 自分の考えた動きや、練習をしたい動きを忘れてしまうことがある。
  2. ダンススキルの中でカテゴリ(トップロック、フットワーク、パワームーブ、フリーズ、etc.......l)ごとに自身がどれくらいのムーブ(ネタ)があるのかを検索できるようにしたい
  3. 動きの完成度も含めて管理したい

解決

自分の考えた動きや、練習をしたい動きを忘れてしまうことがある。

アプリで自分の考えた動きや練習をしたい動きを登録することができます。
登録をするときは自身で動画を撮って保存をすることも可能ですし、youtubeやtwitterなどで参考にする動きがあればそちらの参考リンクとして保存をすることが可能です。

ダンススキルの中でカテゴリごとに動きをまとめて整理したい
動きの完成度も含めて管理したい

ムーブ画面で下記の赤枠の検索フィールドを活用して「カテゴリ検索」、「完成度」を用いた検索ができるようにしました。

セット機能

作る際の課題

  1. 先ほど作ったムーブを組み合わせて自分のムーブがどれくらいセットで組んでバトルで出せるのかを確認したい
  2. 練習をするときに何を練習するか忘れて適当に行なってしまう時があるので、練習をするムーブを組み合わせをしてわかるようにしたい。
  3. ムーブをグルーピングをして確認ができるようにしたい。

解決

先ほど作ったムーブを組み合わせて自分のムーブがどれくらいセットで組んでバトルで出せるのかを確認したい
練習をするときに何を練習するか忘れて適当に行なってしまう時があるので、練習をするムーブを組み合わせをしてわかるようにしたい。
ムーブをグルーピングをして確認ができるようにしたい。

先ほど説明をしたムーブを組み合わせをして保存をできるようにした。
ムーブ単体だと情報を見ずらいものを整理して確認することができるようになった。

SNS機能(投稿、いいね、コメント)

こちらは正直に当初は実装ををするつもりはありませんでした。
ただ、こちらの機能を実装した理由としては、

  • ブレイクダンサーが扱うSNSを知らないので使ってくれる人がいるのでは?と感じた。
  • 一人で使うツールとしてよりも双方向のコミュニケーションが取れる機能があった方がユーザーの投稿のモチベーションが上がるのではないかと考えて実装をしました。

イベント機能

課題

ダンスイベントは東京や大阪などの地域では活発に開催をされているのですが、地方ではあまり開催がされていないのが現状です。
オンラインでの開催を通してたくさんの方が自分のダンスを表現を「評価」までできる場所があると良いと感じた。

解決方法

オンラインで定期的にイベントが作成できるようにしました。
運営側(私)でジャッジを用意させていただき、賞金(アマゾンギフト券)もつけたイベントになります。
ジャッジをお願いした人にはジャッジ用の権限を与えて特別なページで各ユーザーに対してどれだけの金額を割り振れるのかを決定するようにしてみました。

下記のような形でイベントが終了をするとジャッジの評価が反映をされ、評価をされたユーザーに対して通知がされる仕組みになっています。

今回開発をした後の振り返り

開発をしての気持ち

自分が今回ダンス関係のアプリを作っていて開発をしている時が、今までのどの個人開発に比べても作っていて楽しかったです。そして自分自身も使うアプリを作って満足しております。
収益をとっていくことは考えていないしてもまだまだ使ってもらえるユーザーが少ないことは課題としてあるので、こちらは少しずつ広めていければなと考えております。

モバイルアプリの方がよかったのか?

今回Webで開発をしましたが、モバイルアプリで開発をしてもよかったかもしれません。ただし、モバイルアプリだとWebよりも確実に使ってもらえるのか?と考えると正直わからない部分もあるので個人開発で今回趣味開発であることを考えるとwebでの開発で間違いではなかったかなと考えています。

パフォーマンス等

パフォーマンスは意識をしながら開発を行なっていましたが、計測しながら開発を行なっていませんでした。下記は現時点でのスマートフォンでの計測でそこまで悪いというわけではないのですが、改善の余地がありそうなので時間がある時に一つずつ確認をして対応をしていきたいと思います。

今後の開発について

開発としては自分がこのアプリを使う中でもっとこういう機能があったら自分のダンスをする中で楽になるな!ということがあるのでそれを形にしたいと考えています。
また、小規模ではあるのですが、ある一定のアプリケーションとしては成長をして色々な技術が試しやすい環境になりました。
仕事では技術を目的にしないように意識をしているのですが、個人開発で自分の遊び場として活用ができる部分もあるため、PlayWright周りのテストの充実をさせて自身の学習に活用したいと思います。
おそらく

最後に

最高に楽しい個人開発でした!
来年は10年ぶりくらいにダンスイベントに出場をしようと考えているので、このアプリの成長も含めて共に頑張りたいと思います😀

immedioテックブログ

Discussion