88
65

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.

NRI OpenStandiaAdvent Calendar 2023

Day 3

健康管理のために Next.js × OpenAI × Vercel で体重管理アプリを作ってみた

Last updated at Posted at 2023-12-02

はじめに

はじめまして。突然ですが、システムエンジニアにとって体は資本ですよね。
システムエンジニアという職業上、デスクワークが多く運動不足になりやすいですし、リリース時期ともなると忙しく睡眠不足に陥ってしまうこともあると思います。
ですがもちろん体調が悪いと良いパフォーマンスを発揮できませんし、普段から健康管理をすることが重要になってきます。

私は最近 Azure やフロントエンドにおける技術支援を主に担当しておりますが、今年私の下に新人が入社してきました。その新人にも同じように業務を教えていくのですが、私の下で働くからには 一日でも長く健康に仕事をしてほしい と思い、興味のある技術(Next.js や Open AI)を用いて「体重管理アプリ」を作ろうと決意しました。

ということで NRI OpenStandia Advent Calendar 2023 の 3 日目はその奮闘記になります。大まかな開発の流れは以下の通りです。

  1. Next.js でアプリを開発し、Vercel にデプロイする
  2. Azure で動作するようアプリを改修し、App Service に移行する

本記事はその中で前半の Vercel にデプロイするまでとなります。Azure に移行する記事は NRI OpenStandia Advent Calendar 2023 の 19 日目に公開予定です。

本記事で記載している実装はポイントを絞っていますので、ソースコードの完成系を閲覧したい場合は m2-sakai/weight-management-app をご参照ください。

完成したアプリ

まず、完成したアプリは以下となります。

app_fix.gif.gif

ソースコードは以下の GitHub に入れてますので、ご興味あればご覧ください。

機能・技術スタック

本アプリでは以下の機能を実装しています。

機能 説明 使用技術 バージョン
カレンダー機能 毎日の体重を数値で入力・管理できる FullCalender 6.1.9
グラフ機能 毎日の体重の増減をグラフで見ることができる react-chartjs-2 5.2.0
チャット機能 優れた AI と対話ができる OpenAI API v1
認証機能 各ユーザーごとにパーソナライズされる NextAuth.js 5.0.0-beta.3

また、その他開発に関わる技術スタックは以下の通りです。

種別 技術スタック バージョン / プラン
フロントエンドの実装 Next.js / TypeScript 14.0.1 / 5
CSS tailwind css / chakra-ui 3.3.0 / 2.8.2
ホスティングサービス Vercel Hobby プラン
DB Vercel Postgres 0.5.1
API 通信 axios 1.6.2
バリデーション zod 3.22.4

準備

ここから実装の説明に移ります。と、いきたいところですが、私は Next.js における開発が未経験で何から始めれば良いか調べるところからのスタートだったため、まずは Next.js の公式チュートリアルに取り組むことで理解を深めました。

このチュートリアルですが、Chapter 1~16 と用意されており、コンテンツも非常に充実しています。Next.js を学ぼうと思っている方、まずはここから始めるのが良いかと思います。

実装

満を持して実装です。まず以下のコマンドで Next.js のプロジェクトの大枠を作成します。

npx create-next-app@latest

コマンド実行後、対話型による各種設定を行います。今回はほとんどデフォルトの設定としましたが、どのような設定があるかは公式ドキュメントのcreate-next-appに記載されているので、そちらをご参照ください。

全ての設定が完了すると Next.js のプロジェクトの大枠が完成したので、一度 GitHub に push しておきます。

Vercel にデプロイ

Next.js のテンプレートが完成したところで、Vercel にデプロイしてホスティングされるか確認してみます。

Vercel は Hobby、Pro、Enterprise と 3 つの料金プランがあります。今回は商用目的でなく個人開発用ですので、Hobby プランでアカウントを作成します。

アカウントの作成は GitHub 連携、GitLab 連携、Bitbucket 連携、Email と様々な手段がありますが、先程 push した GitHub のアカウントと連携して作成すると、push したリポジトリで Vercel のプロジェクトがすぐに作成できるため、おすすめです。

プロジェクトを作成すると、ビルド ⇒ デプロイ と CI/CD が実行され、xxxx.vercel.app でホスティングされます。

image.png

この後は機能を実装し、該当ブランチに push をすれば自動的にデプロイされます。楽チンですね。

DB の設定

Vercel でホスティングできたことを確認できたため、次はユーザー情報や体重を管理するための DB を用意します。
Vercel Hobby プランには 2 種類(Key-Value、PostgreSQL)の DB が用意されています。今回は単純な SQL でデータを操作するため PostgreSQL を使用します。ホスティングも簡単に実現できるのに DB もあるなんて嬉しいですね。

Vercel の DB の使い方に関しては、Next.js のチュートリアルの Chapter 6Chapter 7 をご覧頂くのが一番良いかと思います。

DB を作成すると、接続するための接続情報(URL やホスト、ユーザ、パスワード等)が発行されます。
間違ってもその情報は GitHub に push しないようにしましょう。Vercel で環境変数を設定する画面があるので、そちらに登録するようにしてください。

Next.js から DB を操作するには、@vercel/postgres を利用します。以下のコマンドでライブラリをインストールすることで利用可能です。

npm i @vercel/postgre

SQL を用いたデータフェッチの実装については各機能の実装で後述します。

カレンダー機能の実装

それでは各機能の実装に移ります。まずは毎日の体重を入力、管理できるカレンダー機能の実装です。カレンダーを画面に描画するため、今回は FullCalender を使用します。

FullCalender は JavaScript で開発された、カスタマイズ可能で使いやすいイベントカレンダーのライブラリです。様々なイベントの表示、追加、編集、削除など、カレンダーに関連する機能を提供しており、アプリケーションに動的なカレンダー機能を追加することができます。

以下のコマンドでライブラリをインストールします。

npm i @fullcalendar/core @fullcalendar/daygrid @fullcalendar/interaction @fullcalendar/react @fullcalendar/timegrid

本アプリのカレンダー機能で実現したいことは以下の通りです。

  • カレンダー初期表示に、サインインしているユーザーの 3 ヶ月分(当月 + 前後 1 ヶ月)の体重を DB から取得し、カレンダーにイベントとして表示する
  • 月を移動した場合、取得していない月の体重を DB から取得し、カレンダーにイベントとして表示する
  • カレンダーの日付は選択でき、選択した日付の体重及び BMI がカレンダーの下に表示される
  • 選択した日付を再度クリックするとモーダルが表示され、体重が入力できる ⇒ 入力情報は DB に保存される

上記を実現するために実装したコードが以下となります。ユーザ情報をセッションから取得する部分については認証機能が完成したら修正するので一旦固定値としています。

また、このカレンダーはインタラクティブにイベントを管理、体重を表示しているため Client Components にしており、体重を DB に登録・取得する部分は Server Actions を用いています。

----------------------------------------
   ソースコードを表示(カレンダーページ)
 ------------------------------------------
app/top/calender/page.tsx
'use client';

import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin, { DateClickArg } from '@fullcalendar/interaction';
import jaLocale from '@fullcalendar/core/locales/ja';
import { useCallback, useEffect, useState } from 'react';
import { InputModal } from '@/app/ui/calender/InputModal';
import { fetchWeightsForCalender } from '@/app/lib/weight';
import { getUser } from '@/app/lib/user';
import { User } from '@/app/types/User';
import { redirect } from 'next/navigation';
import { DatesSetArg } from '@fullcalendar/core/index.js';

type AddEventState = {
  date: string;
  calenderApi: any;
};

type Event = {
  title: string;
  date: string;
  allDay: boolean;
  display: string;
};

const dateFormatOption: Intl.DateTimeFormatOptions = {
  year: 'numeric',
  month: '2-digit',
  day: '2-digit',
};

export default function Page() {
  const [email, setEmail] = useState<string>('');
  const [heights, setHeights] = useState<number>(0);
  const [currentWeights, setCurrentWeights] = useState<number>(0);
  const [currentEvent, setCurrentEvent] = useState<Event[]>([]);
  const [isOpenModal, setIsOpenModal] = useState<boolean>(false);
  const [addEvent, setAddEvent] = useState<AddEventState>({ date: '', calenderApi: undefined });
  const [selectedDate, setSelectedDate] = useState<string>('');

  useEffect(() => {
    const data = async () => {
      // TODO: セッションからユーザー情報取得し、email, 身長をセット
      setEmail("xxx@gmail.com");
      setHeights(170);

      // 体重を3ヶ月分取得し、イベントにセット
      const currentDate: Date = new Date();
      const currentMonth: number = currentDate.getMonth() + 1;
      const weightList = await fetchWeightsForCalender("xxx@gmail.com", currentMonth);
      const initialEventList: Event[] = [];
      weightList.forEach((weight) => {
        const event: Event = {
          title: weight.weight.toString() + ' kg',
          date: weight.date,
          allDay: true,
          display: 'list-item',
        };
        initialEventList.push(event);

        const compareDate = new Date(weight.date);
        if (compareDate.toDateString() === currentDate.toDateString()) {
          setCurrentWeights(weight.weight);
        }
      });
      setCurrentEvent(initialEventList);
    };
    data();
  }, []);

  const handleDateClick = useCallback(
    (clickInfo: DateClickArg) => {
      if (selectedDate === clickInfo.dateStr) {
        setAddEvent({
          date: clickInfo.dateStr,
          calenderApi: clickInfo.view.calendar,
        });
        setIsOpenModal(true);
      } else {
        const eventInfo = currentEvent.filter((event) => {
          const compareDate = new Date(event.date)
            .toLocaleDateString('ja-JP', dateFormatOption)
            .split('/')
            .join('-');
          return compareDate === clickInfo.dateStr;
        });
        if (eventInfo.length !== 0) {
          setCurrentWeights(Number(eventInfo[0].title.replace('kg', '').trim()));
        }
      }
      setSelectedDate(clickInfo.dateStr);
    },
    [selectedDate, currentEvent]
  );

  const handleDateSet = useCallback(
    async (dateSetArg: DatesSetArg) => {
      if (email !== '') {
        const startMonth = dateSetArg.start.getMonth() + 1;
        const endMonth = dateSetArg.end.getMonth() + 1;
        let weightList = [];
        if (endMonth - startMonth === 1) {
          weightList = await fetchWeightsForCalender(email, startMonth);
        } else {
          weightList = await fetchWeightsForCalender(email, startMonth + 1);
        }
        const eventList: Event[] = [];
        weightList.forEach((weight) => {
          const event: Event = {
            title: weight.weight.toString() + ' kg',
            date: weight.date,
            allDay: true,
            display: 'list-item',
          };
          eventList.push(event);
        });
        setCurrentEvent(eventList);
      }
    },
    [email]
  );

  return (
    <div>
      <FullCalendar
        plugins={[dayGridPlugin, interactionPlugin]}  // 日で分割される
        locale={jaLocale}                             // 日本語化
        businessHours={true}                          // 土日はグレーに
        contentHeight={'auto'}                        // サイズは合わせる
        selectable={true}                             // 選択可能
        dateClick={(info) => {
          handleDateClick(info);                      // 日付選択時の挙動
        }}
        events={currentEvent}                         // 表示するイベント
        datesSet={(info) => {
          handleDateSet(info);                        // 月移動時の挙動
        }}
      />
      <p className="text-[30px]">体重: {currentWeights} kg</p>
      <p className="text-[20px]">
        BMI:{' '}
        {currentWeights !== 0
          ? (currentWeights / (heights / 100) / (heights / 100)).toFixed(1)
          : 0.0}
      </p>
      {isOpenModal && (                              // 体重入力用モーダル表示
        <InputModal
          email={email}
          date={addEvent.date}
          calenderApi={addEvent.calenderApi}
          setIsOpenModal={setIsOpenModal}
        />
      )}
    </div>
  );
}

image.png

----------------------------------------
   ソースコードを表示(モーダル)
 ------------------------------------------
app/ui/calender/InputModal.tsx
import { Button } from '../common/Button';
import { PlayIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { useState } from 'react';
import { registerWeight } from '@/app/lib/weight';
import { CalendarApi } from '@fullcalendar/core/index.js';
import clsx from 'clsx';

export const InputModal = ({
  email,
  date,
  calenderApi,
  setIsOpenModal,
}: {
  email: string;
  date: string;
  calenderApi: CalendarApi;
  setIsOpenModal: (state: boolean) => void;
}) => {
  const [weight, setWeight] = useState(0);
  const [isRegister, setIsRegister] = useState(false);

  return (
    <div className="ma fixed left-0 top-0 flex h-full w-full items-center justify-center bg-black bg-opacity-20 z-10">
      <div className="max-w-md rounded-lg bg-white p-8">
        <h2 className="mb-2 text-2xl text-black font-bold">{date}</h2>
        <label className="block text-black text-base mb-2" htmlFor="weight">
          Weight (kg)
        </label>
        <input
          className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
          id="weight"
          autoFocus={true}
          type="number"
          placeholder="00.0"
          onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
            setWeight(Number(e.target.value));
            weight > 1 && weight < 1000 ? setIsRegister(true) : setIsRegister(false);
          }}
        />
        <div className="flex justify-between">
          <Button
            className="mt-4 w-[100px]"
            onClick={() => {
              setIsOpenModal(false);
            }}
          >
            <XMarkIcon className="h-5 w-8 text-gray-50" />
            Cancel
          </Button>
          <Button
            className={clsx('mt-4 w-[100px]', {
              'cursor-not-allowed': !isRegister,
            })}
            onClick={async () => {
              setIsOpenModal(false);
              calenderApi.addEvent({
                title: weight.toString() + ' kg',
                start: date,
                allDay: true,
                display: 'list-item',
              });
              await registerWeight(email, weight, date);
            }}
            disabled={!isRegister}
          >
            <PlayIcon className="h-5 w-8 text-gray-50" />
            登録
          </Button>
        </div>
      </div>
    </div>
  );
};

image.png

----------------------------------------
   ソースコードを表示(データ取得)
 ------------------------------------------
app/lib/weight.ts
'use server';
import { sql } from '@vercel/postgres';
import { unstable_noStore as noStore } from 'next/cache';
import { Weight } from '@/app/types/Weight';

export async function fetchWeightsForCalender(email: string, month: number) {
  noStore();
  try {
    // カレンダーは前後月も少し表示されるため、3カ月分取得する
    const weight = await sql<Weight>`SELECT *
      FROM wm_weights
      WHERE user_id=(SELECT id FROM wm_users WHERE email=${email}) AND EXTRACT(MONTH FROM date) <= ${
      month + 1
    } AND ${month - 1} <= EXTRACT(MONTH FROM date)`;
    return weight.rows;
  } catch (error) {
    throw new Error('Database Error: Failed to fetch Weight list for calender.');
  }
}

export async function registerWeight(email: string, weight: number, date: string) {
  try {
    await sql`
		INSERT INTO wm_weights (user_id, weight, date)
		VALUES ((SELECT id from wm_users WHERE email=${email}), ${weight}, ${date})
    ON CONFLICT (user_id, date)
    DO UPDATE SET weight = ${weight}`;
  } catch (error) {
    throw new Error('Database Error: Failed to Register Weight.');
  }
}

FullCalender では、node_modules 配下にあるグローバル CSS を読み込みますが、Next.js ではそのグローバル CSS が読み込まれない仕様のため(参考)、next-transpile-modules をインストールして next.config.js に設定する必要があります。

npm i next-transpile-modules
next.config.js
const nextConfig = {
  transpilePackages: ['@fullcalendar/common', '@fullcalendar/daygrid', '@fullcalendar/react'],
};

module.exports = nextConfig;

グラフ機能の実装

続いて、毎日の体重の増減を確認できるグラフ機能の実装です。今回は DB から取得した情報をグラフとして表示するために、react-chartjs-2 を使用します。

react-chartjs-2 は React アプリケーションで使用するための Chart.js の React ラッパーライブラリです。Chart.js は JavaScript で描画されるクライアントサイドのグラフ描画ライブラリであり、react-chartjs-2 を使用すると React プロジェクトで簡単にインタラクティブで美しいチャートを組み込むことができます。

以下でライブラリをインストールします。

npm i react-chartjs-2 chart.js chartjs-plugin-annotation

今回グラフ機能で実現したいことは以下となります。

  • グラフの範囲は 1 週間、1 ヶ月、3 ヶ月、1 年の 4 つの内から選択でき、選択した範囲のグラフが表示される
  • 選択したグラフの範囲に対応した体重を DB から取得し、折れ線グラフで表示される
  • ユーザの目標体重を表示し、体重の変遷と目標までの差分を視覚的に把握できる

上記を実現するために実装したコードが以下となります。ユーザー情報をセッションから取得する部分については認証機能が完成したら修正するので一旦固定値としています。

グラフ部分はカレンダー機能と同じく Client Components としており、体重を DB から取得する部分は Server Actions を用いています。

----------------------------------------
   ソースコードを表示(グラフページ)
 ------------------------------------------
app/top/graph/page.tsx
'use client';

import { getSession } from '@/app/lib/actions';
import { fetchWeightsForGraph } from '@/app/lib/weight';
import { UserSession } from '@/app/types/UserSession';
import { GraphTabs } from '@/app/ui/graph/GraphTabs';
import {
  Chart as ChartJS,
  CategoryScale,
  LinearScale,
  PointElement,
  LineElement,
  Title,
  Tooltip,
  Legend,
} from 'chart.js';
import { useEffect, useState } from 'react';
import { Line } from 'react-chartjs-2';
import annotationPlugin from 'chartjs-plugin-annotation';
import { getUser } from '@/app/lib/user';
import { redirect } from 'next/navigation';
import { User } from '@/app/types/User';

ChartJS.register(
  CategoryScale,
  LinearScale,
  PointElement,
  LineElement,
  Title,
  Tooltip,
  Legend,
  annotationPlugin
);

type GraphWeight = {
  date: string;
  weight: number | null;
};

const dateFormatOption: Intl.DateTimeFormatOptions = { // 日付表示フォーマット(yyyy-mm-dd)
  year: 'numeric',
  month: '2-digit',
  day: '2-digit',
};

export default function Page() {
  const [dayRange, setDayRange] = useState<number>(7);
  const [goal, setGoal] = useState<number>(0.0);
  const [graphWeights, setGraphWeights] = useState<GraphWeight[]>([]);

  useEffect(() => {
    const data = async (labelDateArray: string[]) => {
      // TODO: セッションからユーザー情報取得し、email, 目標をセット
      setEmail("xxx@gmail.com");
      setGoal(64.0);

      const weightList = await fetchWeightsForGraph("xxx@gmail.com", dayRange);
      const graphList: GraphWeight[] = [];
      labelDateArray.forEach((labelDate, index) => {
        weightList.forEach((weight) => {
          const compareDate = new Date(weight.date)
            .toLocaleDateString('ja-JP', dateFormatOption)
            .split('/')
            .join('-');
          if (labelDate === compareDate) {
            graphList.push({
              date: labelDate,
              weight: weight.weight,
            });
          }
        });
        if (graphList[index] === undefined) {
          graphList.push({
            date: labelDate,
            weight: null,
          });
        }
      });

      // DB から取得したデータをソートする
      graphList.sort((a, b) => {
        const x = new Date(a.date);
        const y = new Date(b.date);
        return x.getTime() - y.getTime();
      });
      setGraphWeights(graphList);
    };

    // グラフの横軸(label)を作成
    const currentDate = new Date(
      Date.now() + (new Date().getTimezoneOffset() + 9 * 60) * 60 * 1000
    );
    const startDate = new Date(Date.now() + (new Date().getTimezoneOffset() + 9 * 60) * 60 * 1000);
    startDate.setDate(currentDate.getDate() - dayRange + 1);
    let labelDateArray: string[] = [];
    while (startDate <= currentDate) {
      labelDateArray.push(
        startDate.toLocaleDateString('ja-JP', dateFormatOption).split('/').join('-')
      );
      startDate.setDate(startDate.getDate() + 1);
    }

    // グラフの縦軸(dataset)を取得
    data(labelDateArray);
  }, [dayRange]);

  const data = {
    labels: graphWeights.map((weight) => weight['date']),
    datasets: [
      {
        label: '体重 kg',
        data: graphWeights.map((weight) => weight['weight']),
        backgroundColor: 'rgba(255, 99, 132, 0.5)',
      },
    ],
  };

  // グラフのオプション
  const graphOptions = {
    maintainAspectRatio: false,             // 縦横比率を開発者が設定できるように
    responsive: true,                       // レスポンシブ対応
    spanGaps: true,                         // データにnullがあったとしても線を保管表示する
    scales: {
      y: {
        min: goal !== 0 ? goal - 1 : 0,     // 目標体重の -1 kg までグラフを表示
      },
    },
    plugins: {
      legend: {
        position: 'top' as const,
      },
      title: {
        display: true,
        text: '体重グラフ',
      },
      annotation: {
        annotations: {
          goalLine: {                       // 目標体重の線
            yMin: goal !== 0 ? goal : 0,
            yMax: goal !== 0 ? goal : 0,
            borderColor: 'red',
            borderWidth: 2,
            borderDash: [2, 2],
            label: {
              display: true,
              content: '目標',
              backgroundColor: 'lightpink',
            },
          },
        },
      },
    },
  };

  return (
    <div>
      <GraphTabs setDayRange={setDayRange} />
      <div className="h-screen-60 md:h-screen-3/4 w-auto">
        <Line options={graphOptions} data={data} />
      </div>
    </div>
  );
}
----------------------------------------
   ソースコードを表示(タブ)
 ------------------------------------------
app/ui/graph/GraphTabs.tsx
import React from 'react';

export const GraphTabs = ({ setDayRange }: { setDayRange: (range: number) => void }) => {
  const [openTab, setOpenTab] = React.useState(1);
  return (
    <>
      <div className="flex flex-wrap">
        <div className="w-full">
          <ul className="flex mb-0 list-none flex-wrap pt-3 pb-4 flex-row" role="tablist">
            <li className="-mb-px mr-2 last:mr-0 flex-auto text-center">
              <button
                className={
                  'text-xs w-full font-bold uppercase px-5 py-3 shadow-lg rounded block leading-normal ' +
                  (openTab === 1 ? 'text-white bg-green-600' : 'text-green-600 bg-white')
                }
                onClick={(e) => {
                  e.preventDefault();
                  setOpenTab(1);
                  setDayRange(7);
                }}
                data-toggle="tab"
                role="tablist"
              >
                1週間
              </button>
            </li>
            <li className="-mb-px mr-2 last:mr-0 flex-auto text-center">
              <button
                className={
                  'text-xs w-full font-bold uppercase px-5 py-3 shadow-lg rounded block leading-normal ' +
                  (openTab === 2 ? 'text-white bg-green-600' : 'text-green-600 bg-white')
                }
                onClick={(e) => {
                  e.preventDefault();
                  setOpenTab(2);
                  setDayRange(31);
                }}
                data-toggle="tab"
                role="tablist"
              >
                1ヶ月
              </button>
            </li>
            <li className="-mb-px mr-2 last:mr-0 flex-auto text-center">
              <button
                className={
                  'text-xs w-full font-bold uppercase px-5 py-3 shadow-lg rounded block leading-normal ' +
                  (openTab === 3 ? 'text-white bg-green-600' : 'text-green-600 bg-white')
                }
                onClick={(e) => {
                  e.preventDefault();
                  setOpenTab(3);
                  setDayRange(92);
                }}
                data-toggle="tab"
                role="tablist"
              >
                3ヶ月
              </button>
            </li>
            <li className="-mb-px mr-2 last:mr-0 flex-auto text-center">
              <button
                className={
                  'text-xs w-full font-bold uppercase px-5 py-3 shadow-lg rounded block leading-normal ' +
                  (openTab === 4 ? 'text-white bg-green-600' : 'text-green-600 bg-white')
                }
                onClick={(e) => {
                  e.preventDefault();
                  setOpenTab(4);
                  setDayRange(365);
                }}
                data-toggle="tab"
                role="tablist"
              >
                1年
              </button>
            </li>
          </ul>
        </div>
      </div>
    </>
  );
};

image.png

----------------------------------------
   ソースコードを表示(データ取得)
 ------------------------------------------
app/lib/weight.ts
'use server';
import { sql } from '@vercel/postgres';
import { unstable_noStore as noStore } from 'next/cache';
import { Weight } from '@/app/types/Weight';

export async function fetchWeightsForGraph(email: string, range: number) {
  noStore();
  try {
    const day = new Date();
    day.setDate(day.getDate() - range);
    const weight = await sql<Weight>`SELECT *
      FROM wm_weights
      WHERE user_id=(SELECT id FROM wm_users WHERE email=${email}) AND date > ${
      day.toISOString().split('T')[0]
    };`;
    return weight.rows;
  } catch (error) {
    throw new Error('Database Error: Failed to fetch Weight list for graph.');
  }
}

react-chart-js2 では、option のmaintainAspectRatioがデフォルトでtrueになっているため、何も option を設定しない場合は表示するキャンバスのアスペクト比を維持しようとします。

もしブラウザのサイズ変更をしても表示幅を固定したい場合は option にmaintainAspectRatio: falseに設定した上でグラフコンポーネントにheightwidthを設定することで表示幅を固定にできます。

const options = {
  maintainAspectRatio: false,
};
return (
  <Line options="{graphOptions}" data="{data}" width="{200}" height="{200}" />
);

また、表示をレスポンシブにしたくない場合は option にresponsive: false(デフォルトはtrue)を加えることでレスポンシブ設定を無効にすることができます。

チャット機能の実装

続いて、優れた AI と対話ができるチャット機能です。今回はチャット機能を実現するにあたり、OpenAI API を使用します。

OpenAI API は、OpenAI が提供するプログラムやサービスと他のソフトウェアを連携させるためのインターフェースです。具体的には、OpenAI API は自然言語処理タスクにおいて高度な言語モデルである GPT を利用するための手段を提供しています。

まず、OpenAI API を使用するために、API キーを取得します(Open AI へのアカウント作成は省略します)。
公式ドキュメント - API keysから、「Create new secret key」を押下することで簡単に作成ができます。

image.png

DB の接続情報と同様、API Key は間違っても GitHub に push しないようにしましょう。

ただ、API キーを取得しただけでは API は実行できません。OpenAI API は実行毎に料金が発生するためです。従って事前に Settings > Billingから、クレジットカードの登録とチャージ(最低 $5)をしておきます。

image.png

これで API を実行する準備は整いました。今回チャット機能で実現したいことは以下となります。

  • ユーザーがチャットを入力し、送信すると AI(今回は「体重管理マン」と命名)がレスポンスを返す
  • レスポンスを生成している時間は「・・・」の文字が表示される
  • レスポンスは 1 文字 1 文字コマ送りに表示される

上記を実現するための実装したコードが以下となります。GPT のモデルは gpt-3.5-turbo-0613 を使用しています。

----------------------------------------
   ソースコードを表示(チャットページ)
 ------------------------------------------
app/top/chat/page.tsx
'use client';

import { AnimatePresence } from 'framer-motion';
import { useState } from 'react';
import { Message } from '@/app/types/Message';
import { Chat } from '@/app/ui/chat/Chat';
import InputForm from '@/app/ui/chat/InputForm';
import { chat } from '@/app/lib/chat';
import { Flex } from '@chakra-ui/react';

export default function Page() {
  const [chats, setChats] = useState<Message[]>([
    {
      role: 'system',
      content: '',
    },
  ]);
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleSubmit = async (message: Message) => {
    try {
      setIsSubmitting(true);
      setChats((prev) => [...prev, message]);

      const res = await chat(chats, message);

      setChats((prev) => [...prev, res]);
    } catch (error) {
      console.log(error);
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <div className="w-auto bg-white md:rounded-lg md:shadow-md p-4 md:p-10 my-10">
      <div className="mb-10">
        <AnimatePresence>
          {chats.slice(1, chats.length).map((chat, index) => {
            return <Chat role={chat.role} content={chat.content} key={index} />;
          })}
        </AnimatePresence>
        {isSubmitting && (
          <Flex alignSelf="flex-start" px="2rem" py="0.5rem">
            ・・・
          </Flex>
        )}
      </div>
      <InputForm onSubmit={handleSubmit} />
    </div>
  );
}
----------------------------------------
   ソースコードを表示(チャット)
 ------------------------------------------
app/ui/chat/Chat.tsx
import { Message } from '@/app/types/Message';
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { Flex } from '@chakra-ui/react';
import { GoPerson } from "react-icons/go";
import { MdOutlineEngineering } from "react-icons/md";
import { IconContext } from 'react-icons'

export const Chat = ({ content, role }: Message) => {
  const [chatMessage, setChatMessage] = useState('');
  const [currentIndex, setCurrentIndex] = useState(0);

  useEffect(() => {
    if (currentIndex < content.length) {
      const timeoutId = setTimeout(() => {
        setChatMessage((prevText) => prevText + content[currentIndex]);
        setCurrentIndex((prevIndex) => prevIndex + 1);
      }, 80);

      return () => {
        clearTimeout(timeoutId);
      };
    }
  }, [content, currentIndex]);

  return (
    <motion.div
      style={{
        alignSelf: role === 'assistant' ? 'flex-start' : 'flex-end',
        width: 'auto',
      }}
      initial={{
        opacity: 0,
        translateY: '100%',
      }}
      animate={{ opacity: 1, translateY: 0, transition: { duration: 0.3 } }}
      exit={{ opacity: 0, translateY: 0 }}
    >
      <Flex gap="5px" w="full" flexDir={role === 'assistant' ? 'row' : 'row-reverse'} mt="10">
        <IconContext.Provider value={{ size: '50px' }}>
          {role === 'user' ? (<GoPerson />) : (<MdOutlineEngineering />)}
        </IconContext.Provider>
        <Flex
          borderWidth={1}
          borderColor="lightgreen"
          bg="main-bg"
          p="0.5rem 1rem"
          w="auto"
          mt="16"
          rounded={role === 'assistant' ? '0 20px 20px 20px' : '20px 0 20px 20px'}
          flexDir="column"
        >
          {role === 'assistant' && (
            <Flex alignSelf="flex-end" opacity={0.5} fontSize="15px">
              体重管理マン
            </Flex>
          )}
          {role === 'user' && (
            <Flex alignSelf="flex-start" opacity={0.5} fontSize="15px"
            >
              あなた
            </Flex>
          )}
          {role === 'assistant' ? chatMessage || '' : content || ''}
        </Flex>
      </Flex>
    </motion.div>
  );
};
----------------------------------------
   ソースコードを表示(送信フォーム)
 ------------------------------------------
app/ui/chat/InputForm.tsx
import React, { useRef } from 'react';
import { Message } from '@/app/types/Message';

type InputFormProps = {
  onSubmit: (message: Message) => Promise<void>;
};

const InputForm = ({ onSubmit }: InputFormProps) => {
  const inputRef = useRef<HTMLInputElement>(null);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    const inputValue = inputRef.current?.value;

    if (inputValue) {
      onSubmit({
        role: 'user',
        content: inputValue,
      });
      inputRef.current.value = '';
    }
  };

  return (
    <form onSubmit={handleSubmit} className="flex items-center p-4 border-t border-gray-200">
      <input
        type="text"
        ref={inputRef}
        className="flex-grow px-4 py-2 border rounded-lg focus:outline-none focus:ring"
        placeholder="メッセージを入力..."
      />
      <button
        type="submit"
        className="ml-4 px-4 py-2 bg-green-500 text-white rounded-lg focus:outline-none"
      >
        送信
      </button>
    </form>
  );
};

export default InputForm;

image.png

----------------------------------------
   ソースコードを表示(API実行)
 ------------------------------------------
app/lib/chat.ts
import { Message } from '@/app/types/Message';
import axios from 'axios';

export const chat = async (chats: Message[], message: Message): Promise<Message> => {
  const response = await axios.post(
    'https://api.openai.com/v1/chat/completions',
    {
      model: 'gpt-3.5-turbo',
      messages: [...chats, message].map((d) => ({
        role: d.role,
        content: d.content,
      })),
    },
    {
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${process.env.NEXT_PUBLIC_OPENAI_API_KEY}`,
      },
    }
  );

  const data = await response.data;
  if (response.status !== 200) {
    throw data.error || new Error(`Request failed with status ${response.status}`);
  }
  return data.choices[0].message as Message;
};

今回は Create chat completion の API のみ使用しましたが、OpenAI API の他 API の使用方法については以下のリファレンスをご参照ください。

https://platform.openai.com/docs/api-reference

認証機能の実装

最後に認証機能の実装です。認証機能は Next.js のチュートリアルの Chapter 15 にあるように NextAuth.js を用います。

NextAuth.js はセッションの管理、サインインとサインアウト、および他の認証に関する多くの複雑な部分を抽象化し、Next.js アプリケーションでの認証に対する統一されたソリューションを提供することで実装プロセスを簡素化してくれます。

まず、NextAuth.js の設定オプションを定義する必要があるため、以下のようにauth.config.tsを実装します。ここでは以下のような設定を入れています。

  • pagesオプションにsignIn: '/signin'とすることで、ユーザーは NextAuth.js のデフォルトページではなく、カスタムサインインページ(/signin)にリダイレクトされます。
  • サインインしていないユーザーはコンテンツにアクセスできず、サインインした後はトップページ(/top)にリダイレクトされます。
auth.config.ts
import type { NextAuthConfig } from 'next-auth';

export const authConfig = {
  providers: [],
  pages: {
    signIn: '/signin',
  },
  callbacks: {
    authorized({
      auth,
      request: { nextUrl },
    }: {
      auth: any;
      request: {
        nextUrl: any;
      };
    }) {
      const isSignedIn = !!auth?.user;
      const isOnTop = nextUrl.pathname.startsWith('/top');
      if (isOnTop) {
        if (isSignedIn) return true;
        return false;
      } else if (isSignedIn) {
        return Response.redirect(new URL('/top', nextUrl));
      }
      return true;
    },
  },
} satisfies NextAuthConfig;

続いて、上記の設定オプションを適用するために middleware.ts ファイルにインポートします。

middleware.ts
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';

export default NextAuth(authConfig).auth;

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|.png).*)'],
};

続いて、auth.tsファイルを作成し、Credential Provider を追加します。今回はユーザー名及びパスワードによる認証を行います。
ユーザー情報は DB で管理されているため、入力された情報を元に DB から情報を取得し検証を行います。

auth.ts
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
import Credentials from 'next-auth/providers/credentials';
import { z } from 'zod';
import { sql } from '@vercel/postgres';
import { User } from './app/types/User';
import bcrypt from 'bcrypt';

async function getUser(email: string): Promise<User | undefined> {
  try {
    const user = await sql<User>`SELECT * from wm_users where email=${email}`;
    return user.rows[0];
  } catch (error) {
    console.error('Failed to fetch user:', error);
    throw new Error('Failed to fetch user.');
  }
}

export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
  providers: [
    Credentials({
      async authorize(credentials) {
        const parsedCredentials = z
          .object({ email: z.string().email(), password: z.string().min(6) })
          .safeParse(credentials);

        if (parsedCredentials.success) {
          const { email, password } = parsedCredentials.data;
          const user = await getUser(email);
          if (!user) return null;
          const passwordsMatch = await bcrypt.compare(password, user.password);
          if (passwordsMatch) {
            return user;
          }
        }
        console.log('Invalid credentials');
        return null;
      },
    }),
  ],
});

最後にこの認証機能を呼び出すためのサインインフォームや、サインアップフォームを作成します。作ったフォームは以下となります。

----------------------------------------
   ソースコードを表示(サインインフォーム)
 ------------------------------------------
app/ui/signin/SignInForm.tsx
'use client';

import { lusitana } from '@/app/ui/fonts';
import {
  ArrowRightIcon,
  AtSymbolIcon,
  ExclamationCircleIcon,
  KeyIcon,
} from '@heroicons/react/24/outline';
import { Button } from '../common/Button';
import { useFormState, useFormStatus } from 'react-dom';
import { authenticate } from '@/app/lib/actions';
import Link from 'next/link';

export default function SignInForm() {
  const [code, action] = useFormState(authenticate, undefined);
  return (
    <form action={action} className="space-y-3">
      <div className="flex-1 rounded-lg bg-gray-50 px-10 pb-4 pt-8">
        <h1 className={`${lusitana.className} mb-5 text-1xl`}>
          以下のフォームからサインインしてください。
        </h1>
        <div className="w-full">
          <label className="mb-3 mt-5 block text-xs font-medium text-gray-900" htmlFor="email">
            Email
          </label>
          <div className="relative">
            <input
              className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
              id="email"
              type="email"
              name="email"
              placeholder="Enter your email address"
              required
            />
            <AtSymbolIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
          </div>
        </div>
        <div className="mt-4">
          <label className="mb-3 mt-5 block text-xs font-medium text-gray-900" htmlFor="password">
            Password
          </label>
          <div className="relative">
            <input
              className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
              id="password"
              type="password"
              name="password"
              placeholder="Enter password"
              required
              minLength={6}
            />
            <KeyIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
          </div>
          <SignInButton />
          <div className="flex h-8 items-end space-x-1">
            {code === 'CredentialSignin' && (
              <>
                <ExclamationCircleIcon className="h-5 w-5 text-red-500" />
                <p aria-live="polite" className="text-sm text-red-500">
                  EmailかPasswordが不正です。
                </p>
              </>
            )}
          </div>
          <br />
          <div className="text-right">
            アカウントを作成する場合は<Link href={'/signup'}>こちら</Link>
          </div>
        </div>
      </div>
    </form>
  );
}

function SignInButton() {
  const { pending } = useFormStatus();

  return (
    <Button className="mt-4 w-[130px]" aria-disabled={pending}>
      サインイン <ArrowRightIcon className="ml-auto h-5 w-5 text-gray-50" />
    </Button>
  );
}

image.png

----------------------------------------
   ソースコードを表示(サインアップフォーム)
 ------------------------------------------
app/ui/signup/SignUpForm.tsx
'use client';

import { lusitana } from '@/app/ui/fonts';
import {
  ArrowRightIcon,
  AtSymbolIcon,
  ExclamationCircleIcon,
  KeyIcon,
  UserIcon,
} from '@heroicons/react/24/outline';
import { Button } from '../common/Button';
import { useFormState, useFormStatus } from 'react-dom';
import { createAccount } from '@/app/lib/actions';
import Link from 'next/link';
import { MdOutlineHeight, MdOutlineMonitorWeight } from 'react-icons/md';

export default function SignInForm() {
  const [code, action] = useFormState(createAccount, undefined);
  return (
    <form action={action} className="space-y-3">
      <div className="flex-1 rounded-lg bg-gray-50 px-10 pb-4 pt-8">
        <h1 className={`${lusitana.className} mb-5 text-1xl`}>
          以下のフォームからサインアップしてください。
        </h1>
        <div className="w-full">
          <label className="mb-3 mt-5 block text-xs font-medium text-gray-900" htmlFor="email">
            Email
          </label>
          <div className="relative">
            <input
              className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
              id="email"
              type="email"
              name="email"
              placeholder="Enter your email address"
              required
            />
            <AtSymbolIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
          </div>
        </div>
        <div className="w-full">
          <label className="mb-3 mt-5 block text-xs font-medium text-gray-900" htmlFor="name">
            User名
          </label>
          <div className="relative">
            <input
              className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
              id="name"
              name="name"
              type="string"
              placeholder="Enter your user name"
              required
            />
            <UserIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
          </div>
        </div>
        <div className="mt-4">
          <label className="mb-3 mt-5 block text-xs font-medium text-gray-900" htmlFor="password">
            Password
          </label>
          <div className="relative">
            <input
              className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
              id="password"
              type="password"
              name="password"
              placeholder="Enter password"
              required
              minLength={6}
            />
            <KeyIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
          </div>
          <div className="w-full">
            <label className="mb-3 mt-5 block text-xs font-medium text-gray-900" htmlFor="height">
              身長
            </label>
            <div className="relative">
              <input
                className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
                id="height"
                name="height"
                type="number"
                placeholder="Enter your user height"
                required
              />
              <MdOutlineHeight className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
            </div>
          </div>
          <div className="w-full">
            <label className="mb-3 mt-5 block text-xs font-medium text-gray-900" htmlFor="goal">
              目標体重 (kg)
            </label>
            <div className="relative">
              <input
                className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
                id="goal"
                name="goal"
                type="number"
                placeholder="Enter your user goal weights"
                required
              />
              <MdOutlineMonitorWeight className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
            </div>
          </div>
          <SignInButton />
          <div className="flex h-8 items-end space-x-1">
            {code === 'FailedSignIn' && (
              <>
                <ExclamationCircleIcon className="h-5 w-5 text-red-500" />
                <p aria-live="polite" className="text-sm text-red-500">
                  サインアップに失敗しました。
                </p>
              </>
            )}
          </div>
          <br />
          <div className="text-right">
            <Link href={'/signin'}>サインインに戻る</Link>
          </div>
        </div>
      </div>
    </form>
  );
}

function SignInButton() {
  const { pending } = useFormStatus();

  return (
    <Button className="mt-4 w-[150px]" aria-disabled={pending}>
      サインアップ <ArrowRightIcon className="ml-auto h-5 w-5 text-gray-50" />
    </Button>
  );
}

image.png

----------------------------------------
   ソースコードを表示(認証関連のDB処理)
 ------------------------------------------
app/lib/actions.ts
'use server';

import { auth, signIn, signOut } from '@/auth';
import { sql } from '@vercel/postgres';
import bcrypt from 'bcrypt';
import { redirect } from 'next/navigation';
import { v4 as uuidv4 } from 'uuid';
import { z } from 'zod';
import { UserSession } from '@/app/types/UserSession';

const CreateUser = z.object({
  name: z.string(),
  email: z.string(),
  password: z.string(),
  height: z.string(),
  goal: z.string(),
});

export async function authenticate(prevState: string | undefined, formData: FormData) {
  try {
    await signIn('credentials', Object.fromEntries(formData));
  } catch (error) {
    if ((error as Error).message.includes('CredentialsSignin')) {
      return 'CredentialSignin';
    }
    throw error;
  }
}

export async function createAccount(prevState: string | undefined, formData: FormData) {
  const validatedFields = CreateUser.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    password: formData.get('password'),
    height: formData.get('height'),
    goal: formData.get('goal'),
  });

  if (!validatedFields.success) {
    console.error(validatedFields.error);
    return 'FailedSignIn';
  }

  const id = uuidv4();
  const { name, email, password, height, goal } = validatedFields.data;
  const hashedPassword = await bcrypt.hash(password, 10);
  const heightNum = Number(height);
  const goalNum = Number(goal);
  try {
    await sql`
		INSERT INTO wm_users (id, name, email, password, height, goal)
		VALUES (${id}, ${name}, ${email}, ${hashedPassword}, ${heightNum}, ${goalNum})`;
  } catch (error) {
    console.log(error);
    return 'FailedSignIn';
  }

  redirect('/top');
}

以上で認証機能ができました。最後に、各機能でユーザー情報をセッションから取得する部分がありましたので、以下のような関数を実装して各機能を修正します。

----------------------------------------
   ソースコードを表示(ユーザー情報取得)
 ------------------------------------------
app/lib/actions.ts
'use server';

import { auth, signIn, signOut } from '@/auth';
import { UserSession } from '@/app/types/UserSession';

export const getSession = async (): Promise<UserSession> => {
  const session = await auth();
  let userSession: UserSession = {
    expired: '',
    userName: '',
    email: '',
  };

  if (session && session.user) {
    userSession.expired = session.expires;
    if (session.user.name) userSession.userName = session.user.name;
    if (session.user.email) userSession.email = session.user.email;

    return userSession;
  } else {
    await signOut();
  }
  return userSession;
};

以上で、全ての機能の実装が完了し、体重管理アプリが完成しました!

おわりに

いかがでしたでしょうか。この記事では Next.js を用いて 0 からアプリケーションを作った奮闘記を紹介しました。
正直 Next.js を適切に使いこなせたかは微妙なところですが、1つ動くものができたことは良かったと思います。現在はライブラリが充実しているため、以前は 0 から実装しなければいけなかった機能もライブラリを用いて比較的容易に開発することができます。嬉しい世の中です。今回使用しなかった機能も多数あるため、今後より一層開発経験を積んでいきたいと思います。

次回は今回作ったアプリを Azure にデプロイしようと思います。このアドベントカレンダーの 19 日目に投稿予定ですので、楽しみにお待ちいただけたら幸いです。

参考文献

今回も非常に多くの文献を参考にさせていただきました。心より感謝申し上げます。

88
65
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
88
65

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?