Repository: isucon/isucon11-qualify (language: go)

Issue:
[frontend] trendページの実装
とりあえず仮置きなものを実装する


Discussion / hints:

Fix patch:
diff --git a/bench/scenario/assets.go b/bench/scenario/assets.go
index d84d9c11c..65c9b7c2b 100644
--- a/bench/scenario/assets.go
+++ b/bench/scenario/assets.go
@@ -4,16 +4,16 @@ package scenario
 var (
 	resourcesHash = map[string]string{ 
 		"/favicon.d0f5f504.svg": "3697a1b6c6d164ec4a5e4db6f7789f05",
-		"/index.0d3741e5.css": "754e54ce0a55e20c8380c24ee4cc9665",
-		"/index.485ed10e.js": "fb04b774306ddd2aaa326bcf21719ff3",
+		"/index.37b78a5d.js": "eeca944065e47cb34f89ea94a64cd8bb",
+		"/index.a118b0cc.css": "5edfd36f6e3dd7df74b1df3a6dec9d63",
 		"/logo_orange.974bf3e6.svg": "10e2dfa0ff27ec4325b781973c646737",
 		"/logo_white.98008342.svg": "f81e35a6821af44ea5b3a77346bcf6ad",
 		"/vendor.60bb2f38.js": "4c5af65cb072cd3acb3198b2c17d54a5",
 	}
 	resourcesMap = map[string]string{ 
 		"/favicon.svg": "/favicon.d0f5f504.svg",
-		"/index.css": "/index.0d3741e5.css",
-		"/index.js": "/index.485ed10e.js",
+		"/index.css": "/index.a118b0cc.css",
+		"/index.js": "/index.37b78a5d.js",
 		"/logo_orange.svg": "/logo_orange.974bf3e6.svg",
 		"/logo_white.svg": "/logo_white.98008342.svg",
 		"/vendor.js": "/vendor.60bb2f38.js",
diff --git a/webapp/frontend/src/App.tsx b/webapp/frontend/src/App.tsx
index 5f8645cce..88bad9031 100644
--- a/webapp/frontend/src/App.tsx
+++ b/webapp/frontend/src/App.tsx
@@ -1,6 +1,5 @@
-import { BrowserRouter, Switch, Route } from 'react-router-dom'
+import { BrowserRouter, Switch } from 'react-router-dom'
 import PageHeader from './components/PageHeader/PageHeader'
-import Auth from './pages/Auth'
 import Home from './pages/Home'
 import IsuRoot from './pages/IsuRoot'
 import Register from './pages/Register'
@@ -22,9 +21,6 @@ const App = () => {
             <GuardedRoute path="/register" exact>
               <Register />
             </GuardedRoute>
-            <Route path="/login">
-              <Auth />
-            </Route>
           </Switch>
         </div>
       </BrowserRouter>
diff --git a/webapp/frontend/src/components/Condition/ConditionDetail.tsx b/webapp/frontend/src/components/Condition/ConditionDetail.tsx
index b3e65b388..ab4e0545c 100644
--- a/webapp/frontend/src/components/Condition/ConditionDetail.tsx
+++ b/webapp/frontend/src/components/Condition/ConditionDetail.tsx
@@ -20,7 +20,7 @@ const ConditionDetail = ({ condition }: Props) => {
         <Tip variant={condition.condition_level} />
       </div>
       <div>
-        <div>{getConditionTime(condition)}</div>
+        <div>{getConditionTime(condition.date)}</div>
       </div>
     </div>
   )
diff --git a/webapp/frontend/src/components/Home/Auth.tsx b/webapp/frontend/src/components/Home/Auth.tsx
new file mode 100644
index 000000000..ae17c9f12
--- /dev/null
+++ b/webapp/frontend/src/components/Home/Auth.tsx
@@ -0,0 +1,27 @@
+import Card from '../UI/Card'
+import logo_orange from '/@/assets/logo_orange.svg'
+
+const Auth = () => {
+  const click = async () => {
+    // TODO: 本番どうするか考える
+    location.href = `http://localhost:5000`
+  }
+
+  return (
+    <Card>
+      <div className="flex flex-col items-center w-full">
+        <img src={logo_orange} alt="isucondition" className="my-2 max-w-sm" />
+        <div className="mt-4 text-lg">ISUとつくる新しい明日</div>
+        <div className="mt-16 w-full border-b border-outline" />
+        <button
+          className="mt-10 px-5 py-2 h-12 text-white-primary font-bold bg-button rounded-3xl"
+          onClick={click}
+        >
+          JIAのアカウントでログイン
+        </button>
+      </div>
+    </Card>
+  )
+}
+
+export default Auth
diff --git a/webapp/frontend/src/components/Home/Trend.tsx b/webapp/frontend/src/components/Home/Trend.tsx
new file mode 100644
index 000000000..6e8ffc659
--- /dev/null
+++ b/webapp/frontend/src/components/Home/Trend.tsx
@@ -0,0 +1,40 @@
+import { Trend } from '../../lib/apis'
+import { getConditionTime } from '../../lib/date'
+import TrendGraph from './TrendGraph'
+
+interface Props {
+  trend: Trend
+  maxConditionCount: number
+}
+
+const getLatestStringTime = (trend: Trend) => {
+  let latest: Date | null = null
+  for (const c of trend.conditions) {
+    if (!latest || latest < c.date) {
+      latest = c.date
+    }
+  }
+  if (!latest) {
+    return 'no data'
+  }
+  return getConditionTime(latest)
+}
+
+const TrendElement = ({ trend, maxConditionCount }: Props) => {
+  return (
+    <div className="grid grid-cols-trend p-2">
+      <div className="flex flex-col">
+        <div>{trend.character}</div>
+        <div className="text-secondary">{getLatestStringTime(trend)}</div>
+      </div>
+      <div className="flex items-center">
+        <TrendGraph
+          conditions={trend.conditions}
+          maxConditionCount={maxConditionCount}
+        />
+      </div>
+    </div>
+  )
+}
+
+export default TrendElement
diff --git a/webapp/frontend/src/components/Home/TrendGraph.tsx b/webapp/frontend/src/components/Home/TrendGraph.tsx
new file mode 100644
index 000000000..64ab6e161
--- /dev/null
+++ b/webapp/frontend/src/components/Home/TrendGraph.tsx
@@ -0,0 +1,48 @@
+import { TrendCondition } from '../../lib/apis'
+
+interface Props {
+  conditions: TrendCondition[]
+  maxConditionCount: number
+}
+
+const calcWidthPercentage = (
+  conditionLength: number,
+  maxConditionCount: number
+) => {
+  return `${Math.round((conditionLength / maxConditionCount) * 100)}%`
+}
+
+const divideConditions = (conditions: TrendCondition[]) => {
+  const info = conditions.filter(v => v.condition_level === 'info')
+  const warning = conditions.filter(v => v.condition_level === 'warning')
+  const critical = conditions.filter(v => v.condition_level === 'critical')
+  return { info, warning, critical }
+}
+
+const TrendGraph = ({ conditions, maxConditionCount }: Props) => {
+  const divided = divideConditions(conditions)
+  return (
+    <div className="flex items-center w-full h-4">
+      <div
+        className="h-full bg-status-info"
+        style={{
+          width: calcWidthPercentage(divided.info.length, maxConditionCount)
+        }}
+      />
+      <div
+        className="h-full bg-status-warning"
+        style={{
+          width: calcWidthPercentage(divided.warning.length, maxConditionCount)
+        }}
+      />
+      <div
+        className="h-full bg-status-critical"
+        style={{
+          width: calcWidthPercentage(divided.critical.length, maxConditionCount)
+        }}
+      />
+    </div>
+  )
+}
+
+export default TrendGraph
diff --git a/webapp/frontend/src/components/Home/TrendHeader.tsx b/webapp/frontend/src/components/Home/TrendHeader.tsx
new file mode 100644
index 000000000..bb4d95417
--- /dev/null
+++ b/webapp/frontend/src/components/Home/TrendHeader.tsx
@@ -0,0 +1,18 @@
+import Tip from '../UI/Tip'
+
+const TrendHeadeer = () => {
+  return (
+    <div className="grid grid-cols-trend p-2">
+      <div className="flex justify-center">
+        <div>せいかく</div>
+      </div>
+      <div className="flex gap-4 items-center justify-center">
+        <Tip variant="info" label="バッチリ" />
+        <Tip variant="warning" label="ぼちぼち" />
+        <Tip variant="critical" label="こわれた" />
+      </div>
+    </div>
+  )
+}
+
+export default TrendHeadeer
diff --git a/webapp/frontend/src/components/Home/TrendList.tsx b/webapp/frontend/src/components/Home/TrendList.tsx
new file mode 100644
index 000000000..43c8715df
--- /dev/null
+++ b/webapp/frontend/src/components/Home/TrendList.tsx
@@ -0,0 +1,46 @@
+import { useEffect } from 'react'
+import { useState } from 'react'
+import apis, { TrendResponse } from '../../lib/apis'
+import NowLoading from '../UI/NowLoading'
+import TrendElement from './Trend'
+import TrendHeadeer from './TrendHeader'
+
+const TrendList = () => {
+  const [trends, setTrends] = useState<TrendResponse>([])
+  const [maxConditionCount, setMaxConditionCount] = useState(0)
+  useEffect(() => {
+    const update = async () => {
+      const newTrends = await apis.getTrend()
+      newTrends.sort((a, b) => b.conditions.length - a.conditions.length)
+      setTrends(newTrends)
+
+      let max = 0
+      newTrends.forEach(v => {
+        if (v.conditions.length > max) {
+          max = v.conditions.length
+        }
+      })
+      setMaxConditionCount(max)
+    }
+    update()
+  }, [])
+
+  if (trends.length === 0) {
+    return <NowLoading />
+  }
+  return (
+    <div>
+      <h2 className="mb-6 text-xl font-bold">みんなのISU</h2>
+      <TrendHeadeer />
+      {trends.map(trend => (
+        <TrendElement
+          key={trend.character}
+          trend={trend}
+          maxConditionCount={maxConditionCount}
+        />
+      ))}
+    </div>
+  )
+}
+
+export default TrendList
diff --git a/webapp/frontend/src/components/PageHeader/Controls.tsx b/webapp/frontend/src/components/PageHeader/Controls.tsx
index 21201aa32..20a675101 100644
--- a/webapp/frontend/src/components/PageHeader/Controls.tsx
+++ b/webapp/frontend/src/components/PageHeader/Controls.tsx
@@ -19,7 +19,7 @@ const Controls = () => {
   }
 
   return (
-    <div className="w-110 flex items-center justify-between ml-auto">
+    <div className="w-80 flex items-center justify-between ml-auto">
       <ControlLinkItem to="/register" label="ISUの登録" icon={<TiPlus />} />
       <ControlLinkItem
         to="/condition"
diff --git a/webapp/frontend/src/components/UI/IsuList.tsx b/webapp/frontend/src/components/UI/IsuList.tsx
index 709a18bba..ab54083eb 100644
--- a/webapp/frontend/src/components/UI/IsuList.tsx
+++ b/webapp/frontend/src/components/UI/IsuList.tsx
@@ -14,7 +14,7 @@ const IsuList = ({ isus }: Props) => {
         <div key={isu.jia_isu_uuid} className="flex flex-col items-center">
           <Isu isu={isu} />
           {isu.latest_isu_condition ? (
-            <div>{getConditionTime(isu.latest_isu_condition)}</div>
+            <div>{getConditionTime(isu.latest_isu_condition.date)}</div>
           ) : null}
           {isu.latest_isu_condition ? (
             <Tip variant={isu.latest_isu_condition.condition_level} />
diff --git a/webapp/frontend/src/components/UI/Tip.tsx b/webapp/frontend/src/components/UI/Tip.tsx
index 3e6841bd4..50bcb9741 100644
--- a/webapp/frontend/src/components/UI/Tip.tsx
+++ b/webapp/frontend/src/components/UI/Tip.tsx
@@ -1,8 +1,9 @@
 interface Props {
   variant: 'info' | 'warning' | 'sitting' | 'critical'
+  label?: string
 }
 
-const Tip = ({ variant }: Props) => {
+const Tip = ({ variant, label }: Props) => {
   const color = (() => {
     switch (variant) {
       case 'info':
@@ -16,7 +17,7 @@ const Tip = ({ variant }: Props) => {
     }
   })()
   const className = `h-6 rounded-2xl px-4 font-medium text-center ${color}`
-  return <div className={className}>{variant}</div>
+  return <div className={className}>{label ?? variant}</div>
 }
 
 export default Tip
diff --git a/webapp/frontend/src/lib/apis.ts b/webapp/frontend/src/lib/apis.ts
index 82f3d249a..420df7a43 100644
--- a/webapp/frontend/src/lib/apis.ts
+++ b/webapp/frontend/src/lib/apis.ts
@@ -116,6 +116,25 @@ class Apis {
 
     return conditions
   }
+
+  async getTrend(axiosConfig?: AxiosRequestConfig) {
+    const { data } = await axios.get<ApiTrendResponse>(`/api/trend`, {
+      ...axiosConfig
+    })
+
+    const trends: TrendResponse = []
+    data.forEach(trend => {
+      trends.push({
+        ...trend,
+        conditions: trend.conditions.map(v => ({
+          ...v,
+          date: timestampToDate(v.timestamp)
+        }))
+      })
+    })
+
+    return trends
+  }
 }
 
 const apis = new Apis()
@@ -220,3 +239,27 @@ export interface GraphRequest {
 }
 
 export const DEFAULT_CONDITION_LIMIT = 20
+
+interface ApiTrendResponseElement {
+  character: string
+  conditions: {
+    isu_id: number
+    condition_level: 'critical' | 'warning' | 'info'
+    timestamp: number
+  }[]
+}
+
+type ApiTrendResponse = ApiTrendResponseElement[]
+
+export interface Trend {
+  character: string
+  conditions: TrendCondition[]
+}
+
+export interface TrendCondition {
+  isu_id: number
+  condition_level: 'critical' | 'warning' | 'info'
+  date: Date
+}
+
+export type TrendResponse = Trend[]
diff --git a/webapp/frontend/src/lib/date.ts b/webapp/frontend/src/lib/date.ts
index 252021b67..99ac5e959 100644
--- a/webapp/frontend/src/lib/date.ts
+++ b/webapp/frontend/src/lib/date.ts
@@ -1,5 +1,3 @@
-import { Condition } from './apis'
-
 export const getNowDate = () => {
   return new Date()
 }
@@ -18,8 +16,7 @@ export const timestampToDate = (timestamp: number) => {
   return new Date(timestamp * 1000)
 }
 
-export const getConditionTime = (condition: Condition) => {
-  const date = condition.date
+export const getConditionTime = (date: Date) => {
   // 2020/01/01 01:01:01
   return `${date.getFullYear()}/${pad0(date.getMonth() + 1)}/${pad0(
     date.getDate()
diff --git a/webapp/frontend/src/pages/Auth.tsx b/webapp/frontend/src/pages/Auth.tsx
deleted file mode 100644
index 74f06a94b..000000000
--- a/webapp/frontend/src/pages/Auth.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import Card from '../components/UI/Card'
-import logo_orange from '/@/assets/logo_orange.svg'
-
-const Auth = () => {
-  const click = async () => {
-    // TODO: 本番どうするか考える
-    location.href = `http://localhost:5000`
-  }
-
-  return (
-    <div className="flex justify-center p-10">
-      <div className="flex justify-center w-full max-w-lg">
-        <Card>
-          <div className="flex flex-col items-center w-full">
-            <img src={logo_orange} alt="isucondition" className="my-2" />
-            <div className="mt-4 text-lg">ISUとつくる新しい明日</div>
-            <div className="mt-6 w-full border-b border-outline" />
-            <button
-              className="mt-10 px-5 py-2 h-12 text-white-primary font-bold bg-button rounded-3xl"
-              onClick={click}
-            >
-              JIAのアカウントでログイン
-            </button>
-          </div>
-        </Card>
-      </div>
-    </div>
-  )
-}
-
-export default Auth
diff --git a/webapp/frontend/src/pages/Home.tsx b/webapp/frontend/src/pages/Home.tsx
index 60d06dbf3..fb78cc8d4 100644
--- a/webapp/frontend/src/pages/Home.tsx
+++ b/webapp/frontend/src/pages/Home.tsx
@@ -1,7 +1,31 @@
+import Auth from '../components/Home/Auth'
 import Isus from '../components/Home/Isus'
+import TrendList from '../components/Home/TrendList'
 import Card from '../components/UI/Card'
+import { useStateContext } from '../context/state'
 
 const Home = () => {
+  const state = useStateContext()
+
+  if (state.me) {
+    return <LoginPage />
+  } else {
+    return <LandingPage />
+  }
+}
+
+const LandingPage = () => {
+  return (
+    <div className="flex flex-col gap-10 items-center p-10">
+      <Auth />
+      <Card>
+        <TrendList />
+      </Card>
+    </div>
+  )
+}
+
+const LoginPage = () => {
   return (
     <div className="flex flex-col gap-10 items-center p-10">
       <Card>
diff --git a/webapp/frontend/src/router/GuardedRoute.tsx b/webapp/frontend/src/router/GuardedRoute.tsx
index 0f0d5b721..4b796e1fe 100644
--- a/webapp/frontend/src/router/GuardedRoute.tsx
+++ b/webapp/frontend/src/router/GuardedRoute.tsx
@@ -20,7 +20,13 @@ const GuardedRoute = <T extends { path: string; children?: JSX.Element }>(
   }
 
   if (!state.me) {
-    return <Redirect to={`/login`} />
+    if (props.path === '/login') {
+      return <Redirect to={`/login`} />
+    }
+    if (props.path === '/') {
+      return <Route {...props} />
+    }
+    return <Redirect to={`/`} />
   }
 
   return <Route {...props} />
diff --git a/webapp/frontend/tailwind.config.js b/webapp/frontend/tailwind.config.js
index cdf2f5b53..136f996e1 100644
--- a/webapp/frontend/tailwind.config.js
+++ b/webapp/frontend/tailwind.config.js
@@ -34,7 +34,7 @@ module.exports = {
     extend: {
       gridTemplateColumns: {
         isus: 'repeat(auto-fill,minmax(10rem,1fr))',
-        search: '9rem 1fr'
+        trend: '10rem 1fr'
       }
     }
   }


Test patch: