18
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

入力も集計も自由自在!あらゆる趣味と習慣のための記録アプリ作ってみた

Last updated at Posted at 2025-12-22

はじめに

新卒一年目に個人開発した記録アプリをリメイクとして、入力項目も集計項目を自由自在に決めることができる記録アプリを作ってみました!

この記事では、リメイクしていく中で改善・実現させたかったことや、実装のポイントについて紹介していこうと思います。

  • リメイク前
    スクリーンショット 2025-12-03 11.11.42.png
  • リメイク後
    スクリーンショット 2025-12-22 184608.png

一年目に開発した記録アプリ

まずは一年目に開発した記録アプリについて軽く紹介していきます!
このアプリは、 「自分の好きな本、映画、ドラマの記録と感想を一元管理する」 ことを目的として制作しました。

開発環境

当時はReactやExpressの勉強も目的だったため下記を選定しました。
フロントエンド:React
バックエンド:Node.js, Express
データベース:MySQL(Prisma)
言語:JavaScript

アプリ詳細

シンプルな一覧画面

アクセスすると読書記録の一覧が表示されます
右下の「+」ボタンから記録を追加していくことができる、シンプルなUIになっています
読書Watch データ0.png

カテゴリごとに固定された入力項目

カテゴリごとに、あらかじめ決まった項目を細かく入力するように設計しました
読書
 読了日|タイトル|著者|出版社|ジャンル|ページ数|評価|あらすじ|感想

映画
 鑑賞日|タイトル|監督|ジャンル|映画時間(分)|評価|あらすじ|感想

ドラマ
 鑑賞日|タイトル|シーズン数|エピソード数|評価|あらすじ|感想

スクリーンショット 2025-12-03 11.11.42.png

アコーディオンによる詳細表示

一覧画面では情報をすっきり見せるため、詳細内容はアコーディオン形式にしました
クリックをするとあらすじや感想などが展開されるようになっています
スクリーンショット 2025-12-03 11.31.26.png
スクリーンショット 2025-12-03 11.31.38.png

集計機能

溜まったデータを可視化する集計画面です

単に合計数を出すだけではなく、著者別・出版社別など、多角的に自分の傾向を分析できるようにしました

読書
 ・読書合計数 ・ページ合計数
 ・著者別タイトル ・出版社別タイトル ・ジャンル別タイトル
 ・著者合計数 ・出版社合計数 ・ジャンル合計数
映画
 ・映画合計数 ・映画時間合計
 ・監督別タイトル ・ジャンル別タイトル
 ・監督数 ・ジャンル数
ドラマ
 ・ドラマ合計数 ・シーズン合計数 ・エピソード合計数
 ・ジャンル別タイトル ・ジャンル合計数

を集計してくれます。
スクリーンショット 2025-12-03 12.03.02.png

こちらもアコーディオン形式を採用し、特定の項目(特定の著者など)をクリックすると、その内訳であるタイトルがずらっと表示されるようになっています
スクリーンショット 2025-12-03 12.05.26.png

3つの改善と実装のポイント

1. 記録するカテゴリや入力項目を自由自在に決める

世の中には大変便利で素晴らしい記録アプリが沢山ありますよね
しかし、基本的には「特定のカテゴリ」を記録することに特化しているものが多く、記録したいものが増えるたびに「便利なアプリを探し出しインストール、記録するときにはあっちのアプリを開いて次はこっちのアプリを……」と管理がバラバラになってしまいがちです

また、同じ「読書管理」でもアプリごとに入力項目が異なり、「アプリAには出版社があるけど評価がない」「アプリBには評価はあるけど出版社がない」といった状況も起こります

アプリ自体はとてもとっても便利だけれど、アプリを新しく探したりあっちこっち開いたりは、ほんの少し面倒くさく感じてしまいます…
じゃあ、「一つのアプリにまとめてしまおう」と作ったのがリメイク前のアプリでした
当時は主な趣味が本・映画・ドラマの3つだったので、とりあえずこの3つに絞ってアプリを作りました

リメイク前の「拡張性」の限界

例えば、読書カテゴリに追加で「値段」を記録したくなったとします

  1. Booksテーブルにpriceカラムを追加
  2. 入力画面に新しくinputを追加
  3. APIのバリデーションや処理の修正

さらに、新しく「ゲーム」カテゴリを追加しようものなら…

  1. 専用のGamesテーブルを作成
  2. 一覧画面と入力画面を新規作成
  3. APIを新規作成

……逆に面倒くさくなってる!!!
何か増やそうとするたびにコードを書き替え、DB構造を変更
これでは、記録したいものが増えるたびに気軽に拡張なんてできませんね
元々ReactやExpressなどの勉強も目的の一つだったので、作り終わった後はほぼ使いませんでした

「気軽に増やす」ために何が必要だったのか

実装方法は一旦横に置き、原因と改善策を整理しました

  • なぜ簡単に増やせないのか?
    • アプリ側で記録できるカテゴリ,入力項目がガチガチに固定されている
    • そのため、変更のたびにコードやDB構造の修正が必要になる
  • どうすれば改善できるのか?
    • カテゴリや入力項目を固定させず、UI上で自由自在に定義できるようにする
    • コードやDB構造を変更しなくても、機能が拡張される設計にする

というわけで、今回のリメイクで一番核となるテーマは 「自由自在に記録できる柔軟なデータ設計」 です

柔軟なデータ設計:実装編

開発環境

リメイク前と基本的には変わりませんが、型の安全と開発のしやすさからTypeScriptに変更しています。
また、一部機能の実装にGeminiやGitHub Copilotを使用しています。
フロントエンド:React
バックエンド:Node.js, Express
データベース:MySQL(Prisma)
言語:TypeScript

Prisma Schema

リメイク前は「1つのカテゴリ=1つのテーブル」でしたが、今回は以下の4つのテーブルで構成するようにしました。

schema.prisma
model categories {
  id        Int      @id @default(autoincrement())
  user_id   Int
  user      users     @relation(fields: [user_id], references: [id])
  name      String         //「読書記録」や「映画鑑賞」などのカテゴリ名
  field_settings field_settings[] // このカテゴリが持つ入力項目
  records   records[]
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
}

model base_fields {
  id        Int      @id @default(autoincrement())
  field_key      String  // 判定で使用するためのkey
  data_type     String   // 'text','number','date'などの型
  field_name    String   // 入力項目設定で表示させるための名前
  field_settings field_settings[]
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
}

model field_settings {
  id        Int      @id @default(autoincrement())
  category_id Int
  base_field_id     Int  // ベースとなったフィールド
  custom_label   String? // 独自のラベル名
  is_required   Boolean   @default(false) // 必須か
  is_filterable  Boolean @default(false) // フィルターに使用するか
  is_unique     Boolean @default(false)   // 一意か
  order_index  Int
  unit        String?  // 'number'などのフィールド設定したとき用の単位設定用
  options     Json?     // 'select'などoptionが必要なフィールド用の設定用
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
}

model records {
  id        Int      @id @default(autoincrement())
  user_id   Int
  category_id Int
  record_datetime DateTime
  record_data Json  // 実際に記録されたレコードがJson型で保存
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
}

categoriesテーブル

「読書記録」や「映画鑑賞」といった、データの大きな器(カテゴリ名)を管理します。

base_fieldsテーブル

「一行テキスト」「整数」「評価」などのベースとなるフィールドを保存しています。
ここを増やすだけで、新しい入力形式を拡張できるようになっています。
現在は下記の20種類のフィールドを登録しています。

一行テキスト、複数行テキスト、URLリンク、整数、小数、5段階評価、日付、時間、スイッチ、画像、選択、タグ、ラジオ、スライダー、カラー選択、チェックリスト、住所と地図、動画(YouTube)、ストップウォッチ、リスト

field_settingsテーブル

「読書カテゴリでは、ベースの『整数』を使って、『ページ数』というラベルで表示する」といった、カテゴリごとの具体的なカスタマイズを保存します。
単位(unit)や必須設定(is_required)などをここで管理することで、どんなカテゴリでも思い通りの入力フォームを組み立てられます。

recordsテーブル

実際の記録を管理します。
Json型での保存にすることで、どんなに項目が増えてもテーブルのカラムを増やす必要がなく、柔軟性の高い設計にすることができました!

2. 自由自在な記録アプリで実現させる 「集計機能」

一年目のアプリで、一番気に入っていたのが 「集計機能」 でした
コツコツと記録を積み重ねた結果が、数値で可視化されるということが、記録アプリの醍醐味であり、次に記録をつけるためのモチベーションの一つだと考えています

溜まったデータを可視化する楽しさをリメイク版でもなくしたくないという、譲れないポイントでした

自由自在な設計だからこその悩み

先ほど紹介した通り、リメイク版の肝は 「カテゴリや入力項目を自由自在に決められる」 です。

一年目のアプリでは、項目が「著者」や「ページ数」と決まっていたので、専用の集計プログラムを書くだけで済みました。対して、リメイク版では入力する項目が決めておくことができません。

今後は趣味が増えて筋トレを記録したくなるかもしれないし、料理のレシピを記録したくなるかもしれない。

生活に合わせて入力項目が変わっていく中で 何を・どのように集計し数値化していくのか?

どんな項目を作っても、いい感じに可視化してくれる仕組み

自由度が高いから諦めるのではなく、アプリ側がいい感じに可視化してくれる仕組みを目指しました。
自分の作った様々な入力項目が、そのまま集計リストや数値化されていく、「自由度」と「分析の楽しさ」を両立させた、集計機能の実現に挑戦しました。

集計ロジック:実装編

「項目が自由」ということは、プログラムを書く時点では「何をどのように集計するのか」が不明な状態になっています。
その課題を解決するために、集計ルール自体はある程度決めておくが、どの入力項目にどの集計ルールを適用させるかは自由に選ぶことが出来るようにしました。

① 集計用の設定やルールを管理するDB構造

まずは、「何を・どのように集計するのか」という設定を保存するテーブルを用意します

schema.prisma
model analysis_views {
  id          Int      @id @default(autoincrement())
  category_id Int
  name        String  // 集計ビューの名前(著者別タイトル一覧 など)
  column_settings view_column_settings[]
  createdAt   DateTime @default(now()) @map("created_at")
  updatedAt   DateTime @default(now()) @updatedAt @map("updated_at")
}

model view_column_settings {
  id               Int            @id @default(autoincrement())
  analysis_view_id Int
  field_settings_id Int  // どのカスタム項目を使用するか
  role             String // 'GROUP(親)','LIST(子)','SUM(合計)'など集計ルール
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
}

ここでポイントとなるのが role です。

  • GROUP:集計の主役(著者 など)
  • LIST:グループに紐づいて集計される(タイトル など)
  • SUM:数値として合計したい項目(ページ数や値段など)

このように「役割」を与えることで、プログラムが具体的な項目名(著者、タイトルなど)を知らなくても、柔軟に集計ができるようになります。

② 集計ロジック

リメイク版の集計機能の一部には、「どの項目を親(グループ)にして、どの項目を子(リスト)にするか」を自由に決められるようになっています。
例えば、著者を親にしてタイトルを子に設定すれば、著者別タイトルという集計レポートを作ることができます。

スクリーンショット 2025-12-22 094245.png

親子集計のロジックを紹介していきます。
※わかりやすくするため、合計値の計算や複雑な例外処理を省いたシンプル版のコードにしてます

aggregator.ts
/**
 * ユーザーが定義した設定に基づいて、レコードを集計
 * @param allRecords 全ての記録データ
 * @param analysisView ユーザーが作成した集計ルールの設定
 */
export const aggregateRecords = (allRecords: ApiRecord[],analysisView: ApiAnalysisView,): AggregatedRow[] => {
  // 1. 「親(GROUP)」と「子(LIST)」の役割を持つ項目を仕分ける
  const groupSettings = analysisView.column_settings.filter(column => column.role === 'GROUP');
  const listSettings = analysisView.column_settings.filter(column => column.role === 'LIST');

  // 集計結果を一時的に保存するための「Map(連想配列)」を用意
  const aggregationMap = new Map<string, AggregatedRow>();

  // すべての記録を1つずつチェックしていく
  allRecords.forEach(record => {
    const recordData = record.record_data || {};

    // 2. このレコードの「親の値」を取得する
    const firstGroup = groupSettings[0];
    if (!firstGroup) return;

    // parentValueには記録された著者名が一つ入る(例:夏目漱石 など)
    const parentValue = recordData[String(firstGroup.field_settings_id)];
    // 親の値が空(未入力)の場合は、集計できないのでスキップ
    if (parentValue === null || parentValue === undefined || parentValue === '') return;
    const rowKey = String(parentValue);

    // 3. まだMapの中に対応する親の値がなければ、新しく集計用の箱を作る
    if (!aggregationMap.has(rowKey)) {
      aggregationMap.set(rowKey, {
        key: rowKey,
        count: 0,
        lists: {}
      });
    }

    // 4. 該当するフォルダを取り出して、中身を更新する
    const currentRow = aggregationMap.get(rowKey)!;
    currentRow.count++; // データの件数を1つ増やす

    // 5. 子項目(リスト)の集計:Setを使って「内訳リスト」を作る
    listSettings.forEach(listSetting => {
      const childValue = recordData[String(listSetting.field_settings_id)];
      const fieldIdKey = String(listSetting.field_settings_id);
      
      // 初めて登場する項目の場合は、重複排除用の「Set」を作成
      if (!currentRow.lists[fieldIdKey]) {
        currentRow.lists[fieldIdKey] = new Set();
      }
      
      // 子項目の値があれば追加(Setなので、同じタイトルは自動的に1つにまとまる)
      if (childValue) {
        currentRow.lists[fieldIdKey].add(String(childValue));
      }
    });
  });

  // 最後にMapを配列に変換して、画面に表示できる形にする
  return Array.from(aggregationMap.values());
};
  • 実装のポイント:柔軟性
    プログラム側では、あくまで親と子という役割だけで処理をしているので、他の映画やドラマなどでも同じプログラムで集計が可能になっています。
    これにより、 どんな項目を作っても、いい感じに可視化してくれる仕組み を実現することができました。

3. 使いやすさを重視したプレビュー付きUI

一年目に制作したアプリでは、機能を動かすことに必死で、UIについてはあまり深く考えていませんでした。
しかし、今回のリメイク版ではUIが分かりにくいと、せっかくの自由度も使いづらくなってしまいます。

そこで、リメイク版では 直感的に楽しく設定できるUI を目指しました。

設定しながら完成形が見えるリアルタイムプレビュー

一番のこだわりは、入力項目の設定画面です。
「項目を一つ追加するたびに保存して、一覧画面で確認…」という手間を省くため、左側に設定パネル、右側にリアルタイムで更新されるプレビューという分割レイアウトにしました。

左側で項目名などを変更すると、右側で即座に反映されます。
実際にどのような表示になるのかを確認しながら、パズルを組み立てるような感覚でカテゴリ専用のフォームを作れるようになりました。

タブ切り替えで3つのプレビューを切り替え

さらに、プレビュー画面ではタブで表示を切り替えられるようにしました。

  • 入力画面プレビュー
    • 記録をつけるときのフォームが使いやすいかを確認
  • 記録一覧プレビュー
    • 記録した後にデータがどのように表示されるのかを確認
  • 集計一覧プレビュー
    • 集計の設定と結果を同時に確認

リアルタイムプレビュー:実装編

ソースコードをすべて紹介すると膨大な量になってしまうため、ここでは「なぜ入力した瞬間にプレビューが更新されるのか」という仕組みを中心に複雑なコードは省いたものを紹介します。

CreateCategory.tsx
export const CreateCategory = () => {
  // 1. 設定情報を用意する
  // カテゴリ名や、ユーザーが選んだ項目のリストをここで一括管理
  const [categoryName, setCategoryName] = useState('無題のカテゴリ');
  const [selectedFields, setSelectedFields] = useState([]); // 選択された項目のリスト
  const [previewMode, setPreviewMode] = useState('form'); // プレビューの切り替え状態

  // 2. 項目を追加する処理
  // 左側のボタンを押すと、この関数が動いて「選択された項目のリスト」が更新される
  const addField = (baseField) => {
    const newField = {
      id: Math.random(), 
      label: baseField.name,
      type: baseField.type
    };
    setSelectedFields([...selectedFields, newField]);
  };

  return (
    <div>
      {/* 左側:入力項目設定エリア */}
      <div>
        {/* カテゴリ名を入力すると、即座に categoryName が更新される */}
        <input 
          value={categoryName} 
          onChange={(event) => setCategoryName(event.target.value)} 
        />

        {/* ベースとなるフィールドをクリックして追加するボタン */}
        {baseFields.map((baseField) => (
          <button onClick={() => addField(baseField)}>
            {baseField.name}
          </button>
        ))}
      </div>

      {/* 右側:プレビューエリア */}
      <div>
        {/* タブを切り替えて、プレビューを変える */}
        <div>
          <button onClick={() => setPreviewMode('form')}>Form</button>
          <button onClick={() => setPreviewMode('record')}>Record</button>
        </div>

        {/* 
           「設定情報(state)」をそのままプレビュー用コンポーネントに渡すことで、
           左側を書き換えた瞬間に、右側の表示も自動で再描画される
        */}
        {previewMode === 'form' && (
          <CategoryPreview 
            name={categoryName} 
            fields={selectedFields} 
          />
        )}
        {previewMode === 'record' && (
          <RecordDisplayPreview 
            categoryName={categoryName}
            fields={selectedFields} 
          />
        )}
      </div>
    </div>
  );
};
  • 実装のポイント:Stateの共有
    左側の入力欄に文字を打ち込むと、Reactの機能によって categoryName という変数が書き換わります。
    すると、categoryNameやselectedFieldsを共有している右側のプレビュー画面は、中身が変わったら、検知をして自動で見た目を更新してくれるという仕組みです。
    これにより、「項目を一つ追加するたびに保存して、一覧画面で確認…」という手間を省くことができました!

実際にデータを入れてみた

開発がひと段落し、実際に自分のいろんな趣味の記録を入れてみました。
すでにデータが入ってるカテゴリもあとから入力項目の追加や削除、ラベル名の変更、集計項目の変更など編集できるので、「やっぱこの項目はいらないな」や「あれ記録してみよう」「これも集計しよう」など思いつきのものまで、瞬時にカスタムして実現できるのが便利でした。

  • 実際のカテゴリ作成画面
    入力画面プレビュー
    スクリーンショット 2025-12-22 184608.png
    記録一覧プレビュー
    スクリーンショット 2025-12-22 184612.png
    集計一覧プレビュー
    スクリーンショット 2025-12-22 184616.png

  • 次に実際のデータを記録したあとの記録一覧画面と集計一覧画面
    ドラマ
    スクリーンショット 2025-12-22 184344.png
    スクリーンショット 2025-12-22 183249.png
    映画
    スクリーンショット 2025-12-22 182832.png
    スクリーンショット 2025-12-22 183335.png
    ゲーム
    スクリーンショット 2025-12-22 183059.png
    スクリーンショット 2025-12-22 183406.png
    写真
    スクリーンショット 2025-12-22 195304.png
    毛糸
    特に便利だったのはこのカテゴリです。外出先で毛糸を気まぐれで買うものの、すでに家には5つあったり…
    かぎ針号数が書かれてる紙を捨てちゃって、なんの号数で編めばいいのかわかんなくなったりがかなり改善されました。
    記録してない毛糸がまだまだ沢山ありますが、すべての記録が終われば個人的にとても便利なものになりそうです
    スクリーンショット 2025-12-22 185009.png
    スクリーンショット 2025-12-22 194215.png

リメイクを終えて

当時はとりあえず、動くアプリを作るのに精一杯で、拡張性や使いやすいUIを意識せずに制作していました。

そうした過去の反省を、ひとつひとつ技術で解決していくことは、自分にとって成長を実感できるものでした。
自分が実現させたいものをどこまでも追求し、日常の小さな不便を解決することができるのはとても楽しかったです。
まだまだ実装したい機能や、改善させたいこともあるので、少しずつ自分好みに進化させていこうと思います!

最後まで読んでいただきありがとうございました!

18
0
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
18
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?