diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..a06b9d7c6e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,66 @@ +# WSH2026 作業指示 + +## 採点方式 +- GitHub Actions で Lighthouse を実行 +- 満点: \ +1. [Lighthouse](https://github.com/GoogleChrome/lighthouse) を用いて、次の2つを検査します + - ページの表示(900点満点) + - ページの操作(250点満点) +2. 検査したそれぞれのスコアを合算し、得点とします(1150点満点) + +## レギュレーション(絶対に守ること) +- ## レギュレーション + +次に挙げる「明確に禁じていること」と「明確に参加者へ要求すること」を守れなかった場合、順位対象外になります。 + +### 明確に許可されていること + +- **課題のレポジトリにあるコード・その他ファイルは、すべて変更してよい** + - API が返却する内容を変更して、新しい項目を追加したり既存の項目を削除したりしてよい + - ただし、運営が用意する fly.io 環境にデプロイする場合、fly.toml を変更してはならない +- **外部のサービス(SaaS など)を自由に利用してよい** + - 無料で使えるサービスは https://free-for.dev/ などで調べられます + - 有料のサービスを使った場合の費用は自己負担となり、運営は負担しません + +### 明確に禁じていること + +- **Google Chrome 最新版において、著しい機能落ちやデザイン差異を発生させてはならない** + - 提供された VRT (Visual Regression Tests) が失敗しないこと + - [./test_cases.md](./test_cases.md) に記載された手動テスト項目が失敗しないこと +- **シードに何らかの変更をしたとき、初期データのシードにある各種 ID を変更してはならない** +- **明らかな悪意を持って VRT と手動テスト項目を通過させるためのコードを書いてはならない** +- **競技終了後にデプロイしたアプリケーションを更新してはならない** +- **`GET /api/v1/crok{?prompt}` のストリーミングプロトコル (Server-Sent Events) を変更してはならない** +- **初期仕様の `crok-response.md` と同等の画面を構成するために必要な情報を Server-Sent Events 以外の方法で伝達してはならない** +- **運営が用意する fly.io 環境にデプロイする場合、`fly.toml` の内容を変更してはならない** + +### 明確に参加者へ要求すること + +- **順位が確定するまでは、アプリケーションにアクセスできる状態であること** + - 競技終了後にレギュレーションチェックを行います + - レギュレーションチェックのときにアプリケーションにアクセスできない場合は、順位対象外になります +- **API `POST /api/v1/initialize` にリクエストを送ると、データベースの内容が初期値にリセットされること** + - 採点サーバーは、データベースの内容が初期値であることを前提に計測をします + - 同等のデータを提供できるのであれば、機能落ちが発生しない範囲においてデータベースの内容は自由に書きかえて構いません + +- Google Chrome最新版で著しいUI差異・機能落ちを発生させない +- VRTと手動テストを不正に通過させるコードを書かない + +## 変更のルール +1. 変更は1施策ずつ /wsh-commit でコミットする +2. UIに触る変更の後は必ずVRTが自動実行される +3. ビルドが通らない状態でコミットしない + +## 優先施策(/wsh-analyze 完了後に記入) +- [ ] 未記入 + +## 禁止操作(レギュレーションを読んで追記) +- **Google Chrome 最新版において、著しい機能落ちやデザイン差異を発生させてはならない** + - 提供された VRT (Visual Regression Tests) が失敗しないこと + - [./test_cases.md](./test_cases.md) に記載された手動テスト項目が失敗しないこと +- **シードに何らかの変更をしたとき、初期データのシードにある各種 ID を変更してはならない** +- **明らかな悪意を持って VRT と手動テスト項目を通過させるためのコードを書いてはならない** +- **競技終了後にデプロイしたアプリケーションを更新してはならない** +- **`GET /api/v1/crok{?prompt}` のストリーミングプロトコル (Server-Sent Events) を変更してはならない** +- **初期仕様の `crok-response.md` と同等の画面を構成するために必要な情報を Server-Sent Events 以外の方法で伝達してはならない** +- **運営が用意する fly.io 環境にデプロイする場合、`fly.toml` の内容を変更してはならない** diff --git a/application/client/babel.config.js b/application/client/babel.config.js index c3c574591a..7c6373189b 100644 --- a/application/client/babel.config.js +++ b/application/client/babel.config.js @@ -4,16 +4,16 @@ module.exports = { [ "@babel/preset-env", { - targets: "ie 11", + targets: "last 2 Chrome versions", corejs: "3", - modules: "commonjs", + modules: false, useBuiltIns: false, }, ], [ "@babel/preset-react", { - development: true, + development: process.env.NODE_ENV !== "production", runtime: "automatic", }, ], diff --git a/application/client/package.json b/application/client/package.json index 9f8e80a6a8..af2d3a5156 100644 --- a/application/client/package.json +++ b/application/client/package.json @@ -5,7 +5,7 @@ "license": "MPL-2.0", "author": "CyberAgent, Inc.", "scripts": { - "build": "NODE_ENV=development webpack", + "build": "NODE_ENV=production webpack", "typecheck": "tsc" }, "dependencies": { diff --git a/application/client/src/components/application/SearchPage.tsx b/application/client/src/components/application/SearchPage.tsx index e99045de45..9d9a97478b 100644 --- a/application/client/src/components/application/SearchPage.tsx +++ b/application/client/src/components/application/SearchPage.tsx @@ -1,6 +1,12 @@ import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router"; -import { Field, InjectedFormProps, reduxForm, WrappedFieldProps } from "redux-form"; +import { + Field, + InjectedFormProps, + reduxForm, + SubmissionError, + WrappedFieldProps, +} from "redux-form"; import { Timeline } from "@web-speed-hackathon-2026/client/src/components/timeline/Timeline"; import { @@ -30,7 +36,7 @@ const SearchInput = ({ input, meta }: WrappedFieldProps) => ( placeholder="検索 (例: キーワード since:2025-01-01 until:2025-12-31)" type="text" /> - {meta.touched && meta.error && ( + {(meta.touched || meta.submitFailed) && meta.error && ( {meta.error} )} @@ -85,7 +91,12 @@ const SearchPageComponent = ({ }, [parsed]); const onSubmit = (values: SearchFormData) => { - const sanitizedText = sanitizeSearchText(values.searchText.trim()); + const rawSearchText = values.searchText?.trim() ?? ""; + if (rawSearchText.length === 0) { + throw new SubmissionError({ searchText: "検索キーワードを入力してください" }); + } + + const sanitizedText = sanitizeSearchText(rawSearchText); navigate(`/search?q=${encodeURIComponent(sanitizedText)}`); }; diff --git a/application/client/src/components/direct_message/DirectMessageListPage.tsx b/application/client/src/components/direct_message/DirectMessageListPage.tsx index 5a373e918e..7169440ab1 100644 --- a/application/client/src/components/direct_message/DirectMessageListPage.tsx +++ b/application/client/src/components/direct_message/DirectMessageListPage.tsx @@ -1,4 +1,3 @@ -import moment from "moment"; import { useCallback, useEffect, useState } from "react"; import { Button } from "@web-speed-hackathon-2026/client/src/components/foundation/Button"; @@ -100,7 +99,15 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => { className="text-cax-text-subtle text-xs" dateTime={lastMessage.createdAt} > - {moment(lastMessage.createdAt).locale("ja").fromNow()} + {(() => { + const diff = new Date(lastMessage.createdAt).getTime() - Date.now(); + const rtf = new Intl.RelativeTimeFormat("ja", { numeric: "auto" }); + const abs = Math.abs(diff); + if (abs < 60_000) return rtf.format(Math.round(diff / 1000), "second"); + if (abs < 3_600_000) return rtf.format(Math.round(diff / 60_000), "minute"); + if (abs < 86_400_000) return rtf.format(Math.round(diff / 3_600_000), "hour"); + return rtf.format(Math.round(diff / 86_400_000), "day"); + })()} )} diff --git a/application/client/src/components/direct_message/DirectMessagePage.tsx b/application/client/src/components/direct_message/DirectMessagePage.tsx index 098c7d2894..da678b478c 100644 --- a/application/client/src/components/direct_message/DirectMessagePage.tsx +++ b/application/client/src/components/direct_message/DirectMessagePage.tsx @@ -1,10 +1,9 @@ import classNames from "classnames"; -import moment from "moment"; import { ChangeEvent, useCallback, - useId, useRef, + useId, useState, KeyboardEvent, FormEvent, @@ -34,7 +33,7 @@ export const DirectMessagePage = ({ onTyping, onSubmit, }: Props) => { - const formRef = useRef(null); + const typingTimeoutRef = useRef | null>(null); const textAreaId = useId(); const peer = @@ -43,46 +42,62 @@ export const DirectMessagePage = ({ const [text, setText] = useState(""); const textAreaRows = Math.min((text || "").split("\n").length, 5); const isInvalid = text.trim().length === 0; - const scrollHeightRef = useRef(0); - const handleChange = useCallback( (event: ChangeEvent) => { setText(event.target.value); - onTyping(); + if (typingTimeoutRef.current !== null) { + clearTimeout(typingTimeoutRef.current); + } + typingTimeoutRef.current = setTimeout(() => { + onTyping(); + }, 300); }, [onTyping], ); + const submitMessage = useCallback(async () => { + const body = text.trim(); + if (body.length === 0 || isSubmitting) { + return; + } + + try { + await onSubmit({ body }); + setText(""); + } catch (error) { + console.error(error); + } + }, [isSubmitting, onSubmit, text]); + + const handleSubmit = useCallback( + (event: FormEvent) => { + event.preventDefault(); + void submitMessage(); + }, + [submitMessage], + ); + const handleKeyDown = useCallback( (event: KeyboardEvent) => { if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); - formRef.current?.requestSubmit(); + void submitMessage(); } }, - [formRef], - ); - - const handleSubmit = useCallback( - (event: FormEvent) => { - event.preventDefault(); - void onSubmit({ body: text.trim() }).then(() => { - setText(""); - }); - }, - [onSubmit, text], + [submitMessage], ); useEffect(() => { - const id = setInterval(() => { - const height = Number(window.getComputedStyle(document.body).height.replace("px", "")); - if (height !== scrollHeightRef.current) { - scrollHeightRef.current = height; - window.scrollTo(0, height); + const observer = new ResizeObserver(() => { + window.scrollTo(0, document.body.scrollHeight); + }); + observer.observe(document.body); + return () => { + observer.disconnect(); + if (typingTimeoutRef.current !== null) { + clearTimeout(typingTimeoutRef.current); } - }, 1); - - return () => clearInterval(id); + }; }, []); if (conversationError != null) { @@ -141,7 +156,11 @@ export const DirectMessagePage = ({

{isActiveUserSend && message.isRead && ( 既読 @@ -163,7 +182,6 @@ export const DirectMessagePage = ({