【未経験2週間】感謝で幸福度を上げる日記アプリをリリースしました【個人開発】

# はじめに
React学習の集大成として、AIから返信がもらえる感謝日記アプリを個人開発しました。

0から1を生み出す過程は挑戦の連続でしたが、同時に多くの気づきと学びをもたらしてくれました。今回の開発経験で得た苦労や喜び、そして学びを共有したいと思います。

どうしてこのアプリを作成しようと思ったかの理由はこちらの記事で詳しく説明をしています。
よかったら見てみてください！

https://qiita.com/acu8/items/f13ff54bf9f0263a877d


# 完成したアプリ
### デモ
<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3825671/3239ee77-8079-f1fe-11ba-6a4f302e7d7c.gif">

### 概要
その日に感謝できることを3つ入力するとAIから返信をもらえて、続けていくうちに幸福度を上げていくアプリ

### 使用している技術スタック
```
- 環境
vite: 5.3.4
firebase: 10.12.5

- 言語
typescript: 5.2.2

- ライブラリ
react: 18.3.1
react-hook-form: 7.52.2
react-router-dom: 6.26.0
axios: 1.7.3

- DB
supabase/supabase-js: 2.45.0

- CSS
tailwindcss: 3.4.8
daisyui: 4.12.10

- Test
testing-library/jest-dom: 6.4.8
testing-library/react: 16.0.0
testing-library/user-event: 14.5.2
types/jest: 29.5.12

```
# 実装機能
### 新規ユーザー登録
ユーザーの登録を行えます。

<img　width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3825671/fa31180c-d3a6-7afb-c514-b9a0bc352a75.png" width=60% height=200rem　>　
<br>
フォームには入力チェックをつけています。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3825671/f6ed536a-d63b-025e-1256-170d95ba8076.png" width=60% height=200rem> 
*****
### ログイン機能
emailとpasswordでログインを行います。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3825671/56bcc353-204b-6764-150f-15419981f1d6.png" width=60% height=200rem> 

*****
### ジャーナル登録機能
その日に感謝したいことを3つ入力していきます。
1つ入力をしたらプラスボタンを押します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3825671/7e801ac8-baef-5f16-7368-9ca3ee20cdad.png" width=60% height=200rem> 
<br>
<br>
3つの入力が完了したら、submitボタンが表示されます。<br>
submitボタンを押すことでAIからの返信を受け取ることができます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3825671/1a92bce7-5b70-938d-c059-8e1680a956aa.png" width=60% height=200rem> 
<br>
<br>
ジャーナルは1日1回の投稿としています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3825671/d0ad15fa-0e6d-c5c1-eab1-56f07136f3af.png" width=60% height=200rem> 

### AIからのレスポンス受信機能
入力したジャーナルに対してAIからポジティブなレスポンスを受け取ることができます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3825671/42e878b8-8c33-9076-c879-87fb115f5bb7.png" width=60% height=200rem> 

******
### カレンダー機能
ジャーナルを入れた日がハイライトされて表示されます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3825671/7307f39d-8833-e384-e266-39e30372cec7.png" width=60% height=200rem> 

ハイライトされた日付を押すとカレンダー下にジャーナルの内容と受け取ったAIからの返信を見ることができます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3825671/9ca59b1d-f7ec-2bd4-e633-01705a0ab3b3.png" width=60% height=200rem> 


# 大変だったこと
### AIからのレスポンスを得るところ
このアプリではGemini APIを利用してレスポンスを画面に表示させています。
普通にAPIを使用してレスポンスを受けとりデータを取り出すだけなのですが、データのある階層が深いのかログを出力しても表示されずどこにあるのか特定するのに何度かトライする必要がありました。
最終的には`result.data.candidates[0].content`でAIからのレスポンスを得ることができました。

### fetched日付のカレンダーテスト

アプリの中にカレンダーをおいてジャーナルを書いた日をハイライトしています。
カレンダー実装詳細は以下に説明をしましたので興味があれば見てみてください。

https://qiita.com/acu8/items/ef162ef4191dd9015b25

今回はこの部分のテストをしようとしたら沼ってしまいました。

最初のテストコード
```diff_Typescript
test("ハイライト部分の日付をクリックすると下にジャーナル内容が表示される", async () => {
    const mockHighlightedDates = [new Date("2024-08-01")];
    const mockJournalContent = ["Journal Entry 1", "Journal Entry 2"];

    (fetchHighlightedDates as jest.Mock).mockResolvedValue(
      mockHighlightedDates.map((date) => ({ created_at: date.toISOString() }))
    );

    (handleDateSelect as jest.Mock).mockResolvedValue({
      id: 1,
      content: mockJournalContent,
    });

    render(
      <MemoryRouter>
        <UserProvider>
          <CalendarPage />
        </UserProvider>
      </MemoryRouter>
    );

    const calendar = await screen.findByTestId("calendar");
    expect(calendar).toBeInTheDocument();

    const dayElement = screen.getByText(mockHighlightedDates[0].getDate());
    fireEvent.click(dayElement);

    await waitFor(() => {
+     const journalContainer = screen.queryByTestId("journal-container");
      expect(journalContainer).toBeInTheDocument();
    });

    mockJournalContent.forEach((content) => {
      const element = screen.queryByText((_, element) => {
        return element
          ? element.textContent?.includes(content) ?? false
          : false;
      });
      console.log(element);
      expect(element).toBeInTheDocument();
    });
  });


```

ハイライト部分でエラーが出てしまっています。実際のコード部分は以下のようになっておりうまくtest-idを見つけることができないようでした。
```diff_Typescript
 {selectedContent && (
          <div
            className="mt-4 p-4 bg-gray-100/60 rounded-md"
+            data-testid="journal-container"
          >
            <h3 className="font-bold">選択された日付のジャーナル:</h3>
            <ul className="list-disc pl-3 space-y-1">
              {selectedContent.map((item, index) => (
                <li key={index}>{item}</li>
              ))}
            </ul>
          </div>
        )}

```

このエラーを解消させるために観点を変えてテストコードを書き換えました。
```diff_Typescript
test("カレンダーが表示され、日付選択をするとジャーナル内容と削除ボタンが表示される", async () => {
    const mockHighlightedDates = [new Date("2024-08-01")];
    const mockJournalContent = ["Journal Entry 1"];
    const mockAiResponse = "AI Response";

    (supabaseFunction.fetchHighlightedDates as jest.Mock).mockResolvedValue(
      mockHighlightedDates.map((date) => ({ created_at: date.toISOString() }))
    );
    (supabaseFunction.handleDateSelect as jest.Mock).mockResolvedValue({
      id: 1,
      content: mockJournalContent,
    });
    (supabaseFunction.getAiResponse as jest.Mock).mockResolvedValue([
      { response: mockAiResponse },
    ]);

    render(<CalendarPage />);

    expect(screen.getByTestId("calendar")).toBeInTheDocument();

    await waitFor(() => {
+      const highlightedDate = screen.getByText("1");
+      expect(highlightedDate).toHaveStyle(
+        "background-color: rgb(185, 123, 252)"
      );
    });

+    const dayElement = screen.getByText("1");
    fireEvent.click(dayElement);

    await waitFor(() => {
+      expect(screen.getByText("Journal Entry 1")).toBeInTheDocument();
+      expect(screen.getByText("AI Response")).toBeInTheDocument();
    });

    expect(screen.getByText("投稿を削除する")).toBeInTheDocument();

    const deleteButton = screen.getByText("投稿を削除する");
    fireEvent.click(deleteButton);

    await waitFor(() => {
      expect(screen.queryByText("Journal Entry 1")).not.toBeInTheDocument();
      expect(screen.queryByText("AI Response")).not.toBeInTheDocument();
      const deleteButton = screen.queryByText("投稿を削除する");
      expect(deleteButton).toHaveClass("hidden");
    });
  });

```
このテストコードではコードの内部実装に依存するのではなく外部からアプリを見た時の動作に焦点を当てています。エラーが出ていたidを探しに行くのではなく、代わりに画面上に表示されている要素から確認を進めるようにしました。


# 反省点
### 事前にAPIをきちんと設計したほうがよかった

DBのテーブル設計はあまり問題がなかったのですが、データベースとのやり取りを行う関数が各コンポーネントで個別に作成することが多くなり予想以上に数が増えてしまいました。

こういう部分を事前に考えておいてコードの重複を減らし、共通化ができるとより最適化されたアプリが作れるのかなと思いました。

設計についてはインプットもしつつ今後のアプリ作りに活かせるようにしたいです。

### UIはまだまだ学ぶことが多い

今回初めてTailwindを使用してUIを作成しました。

今まではChakra UIで細かい調整をすることがあまりなかったのですが、Tailwindだと自由度が高いのとライブラリコンポーネントが多いので細かい部分までこだわることができます。

そのためユーザーが利用しやすいUIにするためにはデザインについても学び実践を重ねる必要があるなと思いました。

# 総括
個人でゼロからアプリを作りきったのはこれが初めてでした。(今まではJISOUのカリキュラムがあったためアプリのアイデアや仕様をゼロから考えることはありませんでした)

<font color="OrangeRed">2ヶ月前の6月はアプリをつくり始めることさえできなかったのに</font>、このアプリを2週間くらいで作りきることができるようになったことは、7月から今まで手を動かし続けていたことが少し成長につながったのかなと思います。

特に嬉しかったことは、事前に思い描いていた通りのアプリをある程度作り上げることができたことです。これは私にとって一つの自信となりました。

とはいえ、手を動かせば動かすほど足りていない知識やスキルが見えてきます。
スキルを向上させるには、実践を続けることが最も効果的だと考えています。そのため、今後もアプリ開発に取り組み続ける予定です。

1ヶ月後、再び振り返ったときに、さらに成長を実感できることを目標に頑張ります！

# JISOUのメンバー募集中！
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
実践的なカリキュラムで、あなたのエンジニアとしてのキャリアを最短で飛躍させましょう！
興味のある方は、ぜひホームページからお気軽にカウンセリングをお申し込みください！
▼▼▼

https://projisou.jp/