LoginSignup
0
0

More than 1 year has passed since last update.

[Node.js][Express]傾斜機能付き割り勘webアプリを作る!

Last updated at Posted at 2022-11-04

はじめに

制作物

warican

現在スマホで操作するとイベント編集ボタンをクリックできないバグが発生しております。現在対応中です

github(一部抜粋)

github
個人情報を含むファイルは削除しています。

こだわりポイント

  • 傾斜を設定できる
  • 結果のテキストを保存できる
  • グループ作成時にはメンバーを指定する必要がない
  • 支払い者が複数のイベントも追加できる

自己紹介

神奈川在住の大学二年生。エンジニア系のインターンに受かることを目標に2022年9月からJavascriptの勉強を開始。独習JavaScript読破後、ハンズオンNode-jsでNode.jsの勉強にうつり、10月中盤にwebアプリの基本を抑えたのでwebアプリ制作を開始。HTMLとCSSはもともとぼちぼち知っていた。

制作の経緯

waricaとか割り勘webアプリめっちゃ便利だけど、傾斜機能(先輩が多く払うなど)がないから、後輩とかとドライブ行く時とかは結局使えないんだよねー。じゃあ作るか!

制作方法

友人と2人で。友人はフロントエンドのデザイン、私はバックエンドとフロントエンドの通信、データ処理部分を担当。僕はデザインが、友人はデータ処理部分が苦手だったので、いい勉強になった。またGitHubでの共同開発についても知見が深まった。

制作準備

機能を考える

機能を考えるためにまずは以下の用語を定義しておく。

用語定義

用語 定義
グループ 割り勘をする集団 メンバーを設定し、イベントを追加していく
メンバー グループに参加する人
イベント お金を立て替えた出来事(居酒屋、カラオケなど)

最低限必要な機能

グループ、メンバー、イベントをそれぞれ追加、編集、削除できる。

欲しい機能

  • みんなで編集できる
  • 結果の共有が簡単
  • 傾斜の詳細設定ができる

使う技術

フロントエンド

HTML,CSS,JavaScriptで制作。友人がデザイン面でJQueryを利用していたので、DOM操作にも多少利用した。データのやりとりはfetchを利用した。

デプロイ方法

本当はAWSで公開したかったが難しそうだったので、githubの連携で簡単に公開できるrender.comを選択。render.comではpostgresqlを無料で利用できるので、データベースとしてpostgreSQLを選択した。またパッケージマネージャーはrender.comと相性の良いyarnを利用。

バックエンド

Expressフレームワークを選択。またその他パッケージとして

  • cookie-parser (レスポンスでクッキーを含める)
  • ejs (エラーページにエラーメッセージをのせるため)
  • pg (PostgreSQLの操作)

を使用。

ページ、URL設計

waricaを参考に制作した。

URL

パス メソッド ページの内容
/home GET ホーム画面 過去の作成したグループの表示
/home/addGroup GET グループ作成ページ
/home/groupHome/:groupId GET グループ、割り勘結果の表示
/home/groupHome/:groupId/addEvent GET イベントの追加
/home/groupHome/:groupId/groupSettings GET 割り勘方法の設定

ページ設計

WebAPiのURL

パス メソッド 処理内容 利用方法
/home/addGroup POST グループ追加 fetch
/home/groupHome/:groupId POST グループ編集 fetch
/home/groupHome/:groupId POST メンバー追加 fetch
/home/groupHome/:groupId POST メンバー削除 fetch
/home/groupHome/:groupId POST イベント削除 fetch
/home/groupHome/:groupId/showGroup GET グループ表示 fetch
/home/groupHome/:groupId/addEvent POST イベント追加 fetch
/home/groupHome/:groupId/addEvent POST イベント編集 fetch
/home/groupHome/:groupId/groupSettings POST グループ編集 fetch

反省

同じパスに複数のメソッドを投げる設計は適切でないかも。

データベース設計

ユーザーのグループを作成時、groupidを作成して、allgroupにデータが追加する。

allgroup

Column Type 補足
groupid PK varchar(255) 長さ8のランダムなアルファベット列
groupname varchar(255)
grouptype json 割り勘方法や最小単位といったグループの情報
lastlogin timestamp with time zone

その後groupidをもとに2つのテーブルを作成。(テーブル名にgroupidを含める)

(groupid)member

Column Type 補足
memberid serial integer
name varchar(255) メンバー名
membertype varchar(255) 男、女などメンバーの属性

(groupid)event

Column Type 補足
eventid serial integer
name varchar(255) イベント名
payerlist json[] 支払い者のmemberidと支払い金額情報
receiverlist interger[] 未支払い者のmemberidの配列

グループで追加したメンバーやイベントはこれらのテーブルに保存。

反省

問題点

  • ログインのないグループのデータとテーブルは自動で削除するようにしているが、テーブルがグループ数だけ増えていく仕様になっている。
  • 対策用のミドルウェアを実装しているが、table名が動的に変化するためSQLインジェクションの可能性がある。
    allmember、alleventというテーブルを制作し、Columnにgroupidを追加する方法で制作した方が良いのかな??データベース設計についてよくわからないことが多いので、体系的に勉強したい。

バックエンド制作

はじめに

ディレクトリ構造

├── app.js
├── db.js
├── node_modules
├── package.json
|   ├──...
├── public
│   ├── debug
│   │   ├──...
│   ├── docs
│   │   ├── css
|   |   |   ├──...
│   │   ├── html
|   |   |   ├──...
│   │   └── js
|   |       ├──...
│   └── img
|       ├──...
|
├── yarn.lock

必要モジュールインストール

app.js
const express = require("express");
const cookie = require("cookie-parser");
const pool = require("./db");
const app = express();

app.use(express.json());
app.use(cookie());
app.use(express.static("public"));
app.set("view engine", "ejs");

リクエストからレスポンスまでの流れ例

groupidをURLに含む場合

groupidチェックミドルウェア

Router

app.js
//groupIdをチェックするミドルウェア
app.use("/home/groupHome/:groupId", (req, res, next) => {
  checkGroupId(req.params.groupId, next);
});

groupIDをチェックする関数

app.js
function checkGroupId(groupId, next) {
  //groupIdのインジェクションを防ぐ
  if (!isGroupIdSafe(groupId)) {
    next(new Error("invalidGropuId")); //errorへ
  }
  //groupIdの存在を確認する
  else {
    pool
      .query("select * from allgroup where groupid = $1", [groupId])
      .then((results) => {
        if (!results.rows.length) {
          next(new Error("noGroup")); //errorへ
        } else {
          next();
        }
      });
  }
}

function isGroupIdSafe(groupId) {
  //groupIdのインジェクションを防ぐ
  const alphabet = "abcdefghijklmnopqrstuvwxyz";
  if (groupId.length != 8) {
    return false;
  }
  for (const word of groupId) {
    if (!alphabet.includes(word)) {
      return false;
    }
  }
  return true;
}

リクエストハンドラ

groupHomeリクエストハンドラ

app.js
app
  .route("/home/groupHome/:groupId")
  .get((req, res, next) => {
    //静的ファイル送信
    res.sendFile(__dirname + "/public/docs/html/groupHome.html");
  })
  .post((req, res, next) => {
    const obj = req.body;
    //SQL操作関数へ
    if (obj.method === "editGroup") {
      editGroup(req.params.groupId, obj.arguments, res, next);
    }
    if (obj.method === "addMember") {
      addMember(req.params.groupId, obj.arguments, res, next);
    }
    if (obj.method === "deleteMember") {
      deleteMember(req.params.groupId, obj.arguments, res, next);
    }
    if (obj.method === "deleteEvent") {
      deleteEvent(req.params.groupId, obj.arguments, res, next);
    }
  });

その他のURLについても同様のため省略

反省

先にも述べた通り、ひとつのURLで複数のリクエストを処理するのは良くない設計の可能性がある。

SQL関連

SQLとの接続

db.js
const Pool = require("pg").Pool;

const connectionString = process.env.PORT
  ? "Internal Database URL"
  : "External Database URL";

const pool = new Pool({
  connectionString: connectionString,
});

module.exports = pool;

反省

render.comは外部からデータベースにアクセスする時とrender.comでデブロイしたアプリ内部でデータベースにアクセスするときでURLが違うが、それを区別する方法としてより適切な方法がありそう。

SQL操作関数

グループ追加

app.js
function addGroup({ name }, res, next) {
  //ランダムな8桁の文字列を生成してgroupIdとする
  const alphabet = "abcdefghijklmnopqrstuvwxyz";
  let groupId = "";
  for (let i = 0; i < 8; i++) {
    groupId += alphabet[Math.floor(Math.random() * 26)];
  }

  //alldata table に nameを追加する
  //table (groupId)member,(groupId)event を作成する
  const createMemberTable =
    "create table " +
    groupId +
    "member (memberid serial,name varchar(255),membertype varchar(255))";
  const createEventTable =
    "create table " +
    groupId +
    `event (eventid serial,name varchar(255),payerlist json[],receiverlist integer[])`;
  Promise.all([
    pool.query(
      "insert into allgroup (groupid,groupname,grouptype,lastlogin) values ($1,$2,$3,$4)",
      [
        groupId,
        name,
        JSON.stringify({
          balance: "equal",
          ratelist: [1, 1.5, 2.0],
          forXYen: 10,
        }),
        new Date(),
      ]
    ),
    pool.query(createMemberTable),
    pool.query(createEventTable),
  ])
    .then(() => {
      //適切に作成されたら、groupIdとクッキーを返す
      res
        .status(200)
        .cookie(groupId, name, { maxAge: 1000 * 3600 * 24 * 4 })
        .json(groupId);
    })
    .catch((err) => {
      //errorハンドリングミドルウェアへ
      next(err);
    });
}

反省

  • groupidをテーブル名に使用している以上、PostgreSQLのuuid型で作成したものは利用できなかった(table名の最初はalphabetのみのため)
    groupidの長さは揃えた方がよいためこの形に落ち着いた

  • transactionを利用して、一連の操作でエラーが出た場合は操作を取り消す仕組みを追加するべきだった。

  • res,nextを引数に入れないほうがシンプルだったかも?

その他のeditGroup,deleteGroup,addMember....関数に関しては同様のため省略。

errorハンドリング

app.js
//errorハンドリングミドルウェア
app.use((err, req, res, next) => {
  if (err == "Error: noGroup") {
    errMessage = "お探しのグループは存在しません";
    res.status(404);
  } else if ((err = "Error: invalidGroupuId")) {
    errMessage = "グループIDが無効です";
    res.status(406);
  } else {
    errMessage = "サーバー上で問題が発生しました";
    res.status(500);
  }
  res.render(__dirname + "/public/docs/html/error.ejs", {
    err: errMessage,
  });
});

反省

errorの分類の仕方が流石にダサすぎる。うまい方法があるんだろうけど思いつかなかった。

その他の機能

更新のないグループのデータの自動削除

app.js
function deleteOldData() {
  pool.query("select * from allgroup").then((result) => {
    const now = new Date().getTime();
    result.rows.forEach(({ groupid, lastlogin }) => {
      if (now - lastlogin.getTime() > 1000 * 3600 * 24 * 4) {
        //4日間編集なしなら
        deleteGroup(
          groupid,
          {
            status: () => {
              return { json: () => {} };
            },
          },
          console.log
        ); //いなし方雑
      }
    });
  });
}
deleteGroup
app.js
function deleteGroup(groupId, res, next) {
  //alldata table のgroupId を削除する

  const dropMemberTable = "drop table " + groupId + "member";
  const dropEventTable = "drop table " + groupId + "event";
  Promise.all([
    pool.query("delete from allgroup where groupid = $1", [groupId]),
    pool.query(dropMemberTable),
    pool.query(dropEventTable),
  ])
    .then(() => {
      res.status(200).json("group has deleted");
    })
    .catch((err) => {
      next(err);
    });
}

反省

SQL操作関数のdeleteGroupを使用しようとしたら、res,nextを組み込んだ設計だったため、簡単には流用できず、結果的にerrorにならないような適当な関数を用意していなした。雑い...

フロントエンド制作

以後紹介するのは各ページのJSファイルの一部抜粋です。

ページ設計(再掲)

home

Cookieから過去に編集したグループのデータを取得し表示

index.js
//cokkieの情報をゲットする
const oldData = document.cookie.split("; ");
//cookie空確認
if (Boolean(oldData[0])) {
  oldData.forEach((str) => {
    const [groupid, name] = str.split("=");
    const cloneItem = groupListItem.cloneNode(true);
    cloneItem.id = groupid;
    //日本語の文字化けを直す
    cloneItem.querySelector(".group-title").textContent = decodeURI(name);
    groupListContainer.append(cloneItem);
  });
}
groupListItem.remove();

//過去データクリックしたらグループホームへ
$(".group-list-item").on("click", function () {
  location.href = location.origin + "/home/groupHome/" + $(this)[0].id;
});

反省

この制作ではページ遷移の方法としてlocation.hrefを変える方法を使用しているが、適切であるかわからない。

addGroup

入力した名前をグループ名に保存しグループ作成

createGroup.js
groupButton.addEventListener("click", addGroup);

async function addGroup(event) {
  event.preventDefault();
  const name = groupName.value;
  if (name && name.length < 10) {
    groupButton.removeEventListener("click", addGroup);
    const obj = {
      method: "addGroup",
      arguments: { groupname: name },
    };
    //POST
    fetch(location.pathname, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(obj),
    }).then((result) => {
      result.json().then((groupId) => {
        location.href = location.origin + "/home/groupHome/" + groupId;
      });
    });
    load();
  } else {
    alert("グループ名が不適切です");
  }
}

//ロード画面を表示=>ページ遷移まで
function load() {
  const loaderWrapper = document.querySelector(".loader-wrapper");
  loaderWrapper.classList.add("active");
}

groupHome

groupHomeのロード順

groupHomeは機能が多く、処理順序が重要であるため以下にまとめる。

不適切なイベントチェック

既に削除されたメンバーを含みイベントをエラー表示する

showGroup.js
function checkalertEvent() {
  //全てのイベントのpayerlist,receiverlistに削除されたメンバーがいないか確認
  const memberidList = JSON.parse(sessionStorage.getItem("member")).map(
    ({ memberid }) => memberid
  );
  const eventList = JSON.parse(sessionStorage.getItem("event"));
  eventList.forEach(({ eventid, payerlist, receiverlist }) => {
    //payerlist,receiverlistに削除されたメンバーがいないか確認
    if (
      receiverlist.find((memberid) => !memberidList.includes(memberid)) ||
      payerlist.find(({ memberid }) => !memberidList.includes(Number(memberid)))
    ) {
      //eventエレメントの見た目を変更
      const str = "#event-" + eventid;
      const item = document.querySelector(str);
      if (!item.classList.contains("alert")) {
        item.classList.toggle("alert");
        //メッセージ表示
        item.querySelector(".alert-message").hidden = false;
      }
    }
  });
}

計算の流れ

イベントごとに男子と女子の支払い金額を計算しているのは、イベントごとにメンバーが変化する可能性があり、まとめて計算すると不利なひとが生じ得るから。また最後のお金の受け渡しのアルゴリズムによって支払い者がお金を渡す必要がある人数を少なく抑えている。

実際のコード(全文)
calculation.js
window.addEventListener("allLoaded", tryCalculation);
window.addEventListener("calculate", tryCalculation);
//クリップボードにコピー用
let results = "";

function tryCalculation() {
  try {
    caluculate();
  } catch (err) {
    console.log(err);
  }
}
function caluculate() {
  //pay初期化
  const memberList = JSON.parse(sessionStorage.getItem("member")).map(
    (memberObj) => {
      return { ...memberObj, pay: 0 };
    }
  );
  const eventList = JSON.parse(sessionStorage.getItem("event"));
  const { groupname, grouptype } = JSON.parse(sessionStorage.getItem("group"));

  //results初期化
  results =
    location.href +
    "\n" +
    (groupname || "グループ") +
    " " +
    (grouptype.balance == "boy+"
      ? "男子少し多め\n"
      : grouptype.balance == "boy++"
      ? "男子かなり多め\n"
      : "") +
    memberList.length +
    "人で計算";
  eventList.forEach(({ payerlist, receiverlist }) => {
    const totalCost = payerlist.reduce(
      (total, { cost }) => total + Number(cost),
      0
    );
    let girlPay = 0;
    let boyPay = 0;
    //girlPay,boyPayを求める
    if (grouptype.balance == "equal") {
      girlPay = totalCost / receiverlist.length;
      boyPay = girlPay;
    } else {
      const rate = grouptype.ratelist[grouptype.balance == "boy++" ? 2 : 1];
      let boyGirl = [0, 0];
      receiverlist.forEach((receiverid) =>
        memberList.find(({ memberid }) => memberid == receiverid).membertype ==
        "male"
          ? boyGirl[0]++
          : boyGirl[1]++
      );
      girlPay = totalCost / (boyGirl[0] * rate + boyGirl[1]);
      boyPay = girlPay * rate;
    }
    //memberのイベントの収支を計算
    memberList.forEach(({ memberid, membertype }, index) => {
      if (receiverlist.includes(memberid)) {
        memberList[index].pay += membertype == "male" ? boyPay : girlPay;
      }
      memberList[index].pay -= Number(
        (
          payerlist.find((payerData) => payerData.memberid == memberid) || {
            cost: 0,
          }
        ).cost
      );
    });
  });

  //memberListをcost順に並び替える
  memberList.sort((a, b) => b.pay - a.pay);
  //割り勘計算
  //ここでのpayerは精算時のpayer(逆に注意)
  let payerIndex = 0;
  let receiverIndex = memberList.length - 1;

  //showCalculationで必要
  const paymentItemContainer = document.querySelector(
    ".payment-item-container"
  );
  const paymentItem = paymentItemContainer.querySelector(".payment-item");
  paymentItemContainer.innerHTML = "";
  const forXYen = grouptype.forXYen;

  while (payerIndex < receiverIndex) {
    if ((memberList[receiverIndex].pay += memberList[payerIndex].pay) >= 0) {
      showCalculation(
        memberList[payerIndex].name,
        memberList[receiverIndex].name,
        Number(memberList[payerIndex].pay - memberList[receiverIndex].pay)
      );
      memberList[payerIndex].pay = memberList[receiverIndex].pay;
      if (memberList[payerIndex].pay == 0) {
        payerIndex++;
      }
      memberList[receiverIndex].pay = 0;
      receiverIndex--;
    } else {
      showCalculation(
        memberList[payerIndex].name,
        memberList[receiverIndex].name,
        memberList[payerIndex].pay
      );
      memberList[payerIndex].pay = 0;
      payerIndex++;
    }
  }
  function showCalculation(payerName, receiverName, money) {
    //何円単位で割り勘を行うかを加味した実際払うお金
    const fixedMoney =
      money - Math.floor(money / forXYen) * forXYen >
      forXYen - (money - Math.floor(money / forXYen) * forXYen)
        ? (1 + Math.floor(money / forXYen)) * forXYen
        : Math.floor(money / forXYen) * forXYen;
    if (fixedMoney) {
      //domでの処理
      const cloneItem = paymentItem.cloneNode(true);
      cloneItem.querySelector(".payer").textContent = payerName;
      cloneItem.querySelector(".receiver").textContent = receiverName;
      cloneItem.querySelector(".payment-cost").textContent = fixedMoney + "";
      paymentItemContainer.append(cloneItem);
      //resultsに対応
      results +=
        "\n" + payerName + "\n=>" + receiverName + "   " + fixedMoney + "";
    }
  }
  paymentItem.remove();
}

//クリップボードにコピー
$(".copy-button").on("click", function () {
  if (results[results.length - 1] == "") {
    //空じゃない
    navigator.clipboard.writeText(results);
    $(this)[0].textContent = "コピーしました!!";
  }
});

グループ編集処理

postServer

groupHome.js
function postServer(obj, callback) {
  //ロード開始
  if (obj.method.includes("Member")) {
    memberLoad();
  } else if (obj.method === "editGroup") {
    groupTitleLoad();
  }
  fetch(location.pathname, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(obj),
  })
    .then((res) => {
      res.json().then((x) => {
        console.log(obj);
        console.log(x);
        //ロード終了
        if (obj.method.includes("Member")) {
          memberLoad();
        } else if (obj.method === "editGroup") {
          groupTitleLoad();
        }
        //addMember deleteMember の場合
        const memberObj = { memberid: x[0].memberid, ...obj.arguments }; //add=>alldata,delete=>memberid
        callback(memberObj);
      });
    })
    .catch((err) => {
      console.log("通信に失敗", err);
    });
}

callbackの引数が必要なのがaddmemberのみなので(memberidを受け取る必要がある)、memberObjを渡している。(deletememberの時も便利なので使っている)

addMember

groupHome.js
//member追加したときの動作
$("#member-add").on("click", function () {
  const memberNameInput = document.querySelector("#member-name-input");
  const memberSex = document
    .querySelector("#sex-button")
    .className.split(" ")[1];

  if (memberNameInput.value) {
    const obj = {
      method: "addMember",
      arguments: {
        name: memberNameInput.value,
        membertype: memberSex,
      },
    };
    //showMemberし、sessionストレージにデータ追加
    postServer(obj, (obj) => {
      sessionStorage.setItem(
        "member",
        JSON.stringify([...JSON.parse(sessionStorage.getItem("member")), obj])
      );
      //メンバーElement追加
      showMember(obj);
    });
  }
  //更新終わった後にinputを空にする
  memberNameInput.value = "";
});

deleteMember,editGroupも同様のため省略

addEvent

かなりごちゃついた

addEventロード順

イベント編集なら編集前情報を画面に反映

addEvent.js
function importEvent() {
  //編集ボタンをクリックするとeditEventにeventidが追加される
  //単純なイベント追加だとeditEventは""![スクリーンショット 2022-11-05 23.29.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2952141/475fa78b-f5e3-a421-fbc7-9195c0ad647e.png)

  const editeventid = sessionStorage.getItem("editEvent");
  try {
    //編集するイベントのデータを取得
    const { name, payerlist, receiverlist } = JSON.parse(
      sessionStorage.getItem("event")
    ).find(({ eventid }) => eventid == editeventid);
    //nameを代入
    for (const input of document.querySelectorAll(".event-name-input")) {
      input.value = name || "イベント" + editeventid;
    }
    //payerを代入
    if (payerlist.length == 1) {
      //oneのページを表示
      showOneorSeveral("one");
      //one payerに代入
      selectOnePayer(payerlist[0].memberid);
      //one costに代入
      document.querySelector(".cost-input").value = payerlist[0].cost;
    } else {
      //severalのページを表示
      showOneorSeveral("several");
      //several payerとcostを代入
      payerlist.forEach(({ cost, memberid, name }) => {
        selectSeveralPayer(memberid);
        addSeveralPayerCostItem({
          memberid: memberid,
          name: name,
          cost: cost,
        });
      });
    }
    //receiverを代入
    receiverlist.forEach((memberid) => {
      selectReciever(Number(payerlist.length != 1), memberid);
    });
    //最後に追加ボタンを更新ボタンに変更
    for (const button of addEventButtons) {
      button.firstElementChild.textContent = "更新";
    }
  } catch (err) {
    console.log(err);
  }
}

支払い者人数を一人から複数に切り替えたとき、入力していた情報を反映する機能や、受け取り手を一度に選択できるボタンを追加したり、使い勝手を意識して制作した。(故にごちゃついたのだが)

groupSettings

特に難しい処理はないので省略

error

アクセスしたグループが存在しなかった時にcookieのデータを消す

error.js
const errorMassage = document.querySelector(".error-massage");
//urlのgroupidを取得
const hrefList = location.href.split("/");
const invalidgroupid = hrefList[hrefList.indexOf("groupHome") + 1];
if (errorMassage.textContent == "お探しのグループは存在しません") {
  const cookie = document.cookie.split("; ").map((str) => str.split("="));
  cookie.forEach(([groupid, groupname]) => {
    if (groupid == invalidgroupid) {
      document.cookie =
        groupid +
        "=" +
        groupname +
        "; expires=Mon, 31 Aug 2020 15:00:00 GMT;path=/";
    }
  });
}

expireを過去に書き換えることで削除する

おわりに

制作をおえて

このQiitaを編集していて気がついたのは、このアプリを制作する上で一番大変だったところは、コードを書くところじゃなくて設計すところだったということ。あとコーディングに関しては、(コードぐちゃぐちゃな自分が言うのもなんですが)、やりたいように動かすこと事態はそんなに難しくなくて、どれほどスマートにわかりやすくかけるかが一番大事で一番難しい部分なんだとも感じた。今後は実践経験を積んで、その部分についても勉強しつつ、セキュリティー面やバージョン管理といった難しい部分についても挑戦していきたい。

今後やりたいこと

  • Reactを使用したSPAでの実装
  • AWSを利用したデブロイ
  • websocketを利用したリアルタイムで編集が反映されるgroupHome(制作途中でトライしたが、errorハンドリング等共有が難しく、断念した。)
  • c#での経験を活かしてバックエンドTypeScriptでの書き直し。

質問があったらどしどし送ってください!!それでは!

0
0
3

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