diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..81776f3a58 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,76 @@ +# Web Speed Hackathon 2026 + +## プロジェクト概要 + +CyberAgent主催のWebパフォーマンス改善競技。架空SNS「CaX」のLighthouseスコアを改善する。 + +- **競技期間**: 2026/03/20 10:30 〜 03/21 18:30 JST +- **リーダーボード**: https://web-speed-hackathon-scoring-board-2026.fly.dev/ + +## 採点基準(合計1150点) + +### ページの表示(900点) +9ページ × 100点(FCP / SI / LCP / TBT / CLS) + +### ページの操作(250点) +5シナリオ × 50点(TBT / INP) + +> **注意**: 「ページの表示」300点未満の場合、操作スコアが0点になる + +## 主要コマンド + +```bash +mise trust && mise install # 初期セットアップ +pnpm install # 依存インストール +pnpm run build # ビルド +pnpm run start # サーバー起動 (localhost:3000) +pnpm run test # VRT実行 +pnpm run test:update # スクリーンショット更新 +``` + +採点ツール(`/scoring-tool` ディレクトリから): + +```bash +pnpm start --applicationUrl +``` + +## ディレクトリ構成 + +``` +/application/workspaces/server # サーバー実装 +/application/workspaces/client # クライアント実装 +/application/workspaces/e2e # E2EテストとVRT +/scoring-tool # ローカル採点ツール +/docs # ルール・テストケース +``` + +## レギュレーション + +### 禁止事項(違反=失格) +- `fly.toml` の変更 +- VRTと手動テスト項目の機能落ち +- 初期シードデータのIDを変えること +- 競技終了後のデプロイ更新 +- `GET /api/v1/crok` のSSEプロトコル変更 + +### 必須事項 +- `POST /api/v1/initialize` でDB初期化が機能すること +- 競技終了まで本番URLにアクセス可能であること + +### 自由なこと +- コード・ファイルの変更すべて +- APIレスポンス内容の変更(追加・削除可) +- 外部SaaSの利用(有料費用は自己負担) + +## VRT・手動テスト + +- Playwrightを使ったVRT(スクリーンショット比較) +- `/docs/test_cases.md` の手動テスト項目を遵守 +- 主要機能: タイムライン、投稿詳細、DM、Crok(AIチャット)、検索、認証 + +## 技術スタック + +- **Node.js**: 24.14.0(mise管理) +- **pnpm**: 10.32.1 +- **デプロイ**: fly.io(GitHub Actions経由) +- **テスト**: Playwright + Lighthouse 12.8.2 diff --git a/README.md b/README.md index 854d089d1d..48464d198b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ 今回のテーマは、架空の SNS サイト「CaX」です。 レギュレーションを守った上で、CaX のパフォーマンスを改善してください。 -## 開催日程 +## 開催日程 - 開催日程 | 2026/03/20 10:30 JST - 2026/03/21 18:30 JST - 募集要項 | https://cyberagent.connpass.com/event/371488/ diff --git a/application/client/babel.config.js b/application/client/babel.config.js index c3c574591a..f040fa1667 100644 --- a/application/client/babel.config.js +++ b/application/client/babel.config.js @@ -4,16 +4,15 @@ module.exports = { [ "@babel/preset-env", { - targets: "ie 11", + targets: "Chrome 133", corejs: "3", - modules: "commonjs", - useBuiltIns: false, + modules: false, + useBuiltIns: "usage", }, ], [ "@babel/preset-react", { - development: true, runtime: "automatic", }, ], diff --git a/application/client/package.json b/application/client/package.json index 9f8e80a6a8..82961fceda 100644 --- a/application/client/package.json +++ b/application/client/package.json @@ -15,7 +15,6 @@ "@mlc-ai/web-llm": "0.2.80", "@web-speed-hackathon-2026/client": "workspace:*", "bayesian-bm25": "0.4.0", - "bluebird": "3.7.2", "buffer": "6.0.3", "classnames": "2.5.1", "common-tags": "1.8.2", @@ -23,20 +22,14 @@ "encoding-japanese": "2.2.0", "fast-average-color": "9.5.0", "gifler": "github:themadcreator/gifler#v0.3.0", - "image-size": "2.0.2", - "jquery": "3.7.1", - "jquery-binarytransport": "1.0.0", "json-repair-js": "1.0.0", "katex": "0.16.25", "kuromoji": "0.1.2", "langs": "2.0.0", - "lodash": "4.17.21", - "moment": "2.30.1", "negaposi-analyzer-ja": "1.0.1", "normalize.css": "8.0.1", "omggif": "1.0.10", "pako": "2.1.0", - "piexifjs": "1.0.6", "react": "19.2.0", "react-dom": "19.2.0", "react-helmet": "npm:@dr.pogodin/react-helmet@3.0.4", @@ -57,18 +50,16 @@ "@babel/preset-env": "7.28.3", "@babel/preset-react": "7.27.1", "@babel/preset-typescript": "7.27.1", + "@tailwindcss/postcss": "4.2.1", "@tsconfig/strictest": "2.0.8", - "@types/bluebird": "3.5.42", + "tailwindcss": "4.2.1", "@types/common-tags": "1.8.4", "@types/encoding-japanese": "2.2.1", - "@types/jquery": "3.5.33", "@types/kuromoji": "0.1.3", "@types/langs": "2.0.5", - "@types/lodash": "4.17.20", "@types/node": "22.18.8", "@types/omggif": "1.0.5", "@types/pako": "2.0.4", - "@types/piexifjs": "1.0.0", "@types/react": "19.2.2", "@types/react-dom": "19.2.1", "@types/react-syntax-highlighter": "15.5.13", diff --git a/application/client/postcss.config.js b/application/client/postcss.config.js index d7ee920b94..0c9a35ec92 100644 --- a/application/client/postcss.config.js +++ b/application/client/postcss.config.js @@ -1,9 +1,11 @@ const postcssImport = require("postcss-import"); +const tailwindcss = require("@tailwindcss/postcss"); const postcssPresetEnv = require("postcss-preset-env"); module.exports = { plugins: [ postcssImport(), + tailwindcss(), postcssPresetEnv({ stage: 3, }), diff --git a/application/client/src/components/crok/ChatInput.tsx b/application/client/src/components/crok/ChatInput.tsx index 6f8c17796b..41ecf66c97 100644 --- a/application/client/src/components/crok/ChatInput.tsx +++ b/application/client/src/components/crok/ChatInput.tsx @@ -1,4 +1,3 @@ -import Bluebird from "bluebird"; import kuromoji, { type Tokenizer, type IpadicFeatures } from "kuromoji"; import { useEffect, @@ -97,8 +96,12 @@ export const ChatInput = ({ isStreaming, onSendMessage }: Props) => { let mounted = true; const init = async () => { - const builder = Bluebird.promisifyAll(kuromoji.builder({ dicPath: "/dicts" })); - const nextTokenizer = await builder.buildAsync(); + const nextTokenizer = await new Promise>((resolve, reject) => { + kuromoji.builder({ dicPath: "/dicts" }).build((err, tokenizer) => { + if (err) reject(err); + else resolve(tokenizer); + }); + }); if (mounted) { setTokenizer(nextTokenizer); } diff --git a/application/client/src/components/direct_message/DirectMessageListPage.tsx b/application/client/src/components/direct_message/DirectMessageListPage.tsx index 5a373e918e..6fa790cc0d 100644 --- a/application/client/src/components/direct_message/DirectMessageListPage.tsx +++ b/application/client/src/components/direct_message/DirectMessageListPage.tsx @@ -1,4 +1,4 @@ -import moment from "moment"; +import { fromNow } from "@web-speed-hackathon-2026/client/src/utils/date"; import { useCallback, useEffect, useState } from "react"; import { Button } from "@web-speed-hackathon-2026/client/src/components/foundation/Button"; @@ -100,7 +100,7 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => { className="text-cax-text-subtle text-xs" dateTime={lastMessage.createdAt} > - {moment(lastMessage.createdAt).locale("ja").fromNow()} + {fromNow(lastMessage.createdAt)} )} diff --git a/application/client/src/components/direct_message/DirectMessagePage.tsx b/application/client/src/components/direct_message/DirectMessagePage.tsx index 098c7d2894..d3719c88ae 100644 --- a/application/client/src/components/direct_message/DirectMessagePage.tsx +++ b/application/client/src/components/direct_message/DirectMessagePage.tsx @@ -1,5 +1,5 @@ import classNames from "classnames"; -import moment from "moment"; +import { formatTime } from "@web-speed-hackathon-2026/client/src/utils/date"; import { ChangeEvent, useCallback, @@ -141,7 +141,7 @@ export const DirectMessagePage = ({

{isActiveUserSend && message.isRead && ( 既読 diff --git a/application/client/src/components/foundation/CoveredImage.tsx b/application/client/src/components/foundation/CoveredImage.tsx index 8ad9cc1f7d..eac2e02670 100644 --- a/application/client/src/components/foundation/CoveredImage.tsx +++ b/application/client/src/components/foundation/CoveredImage.tsx @@ -1,92 +1,57 @@ -import classNames from "classnames"; -import sizeOf from "image-size"; -import { load, ImageIFD } from "piexifjs"; -import { MouseEvent, RefCallback, useCallback, useId, useMemo, useState } from "react"; +import { MouseEvent, useCallback, useId } from "react"; import { Button } from "@web-speed-hackathon-2026/client/src/components/foundation/Button"; import { Modal } from "@web-speed-hackathon-2026/client/src/components/modal/Modal"; -import { useFetch } from "@web-speed-hackathon-2026/client/src/hooks/use_fetch"; -import { fetchBinary } from "@web-speed-hackathon-2026/client/src/utils/fetchers"; interface Props { src: string; + alt?: string; } /** * アスペクト比を維持したまま、要素のコンテンツボックス全体を埋めるように画像を拡大縮小します */ -export const CoveredImage = ({ src }: Props) => { +export const CoveredImage = ({ src, alt = "" }: Props) => { const dialogId = useId(); // ダイアログの背景をクリックしたときに投稿詳細ページに遷移しないようにする const handleDialogClick = useCallback((ev: MouseEvent) => { ev.stopPropagation(); }, []); - const { data, isLoading } = useFetch(src, fetchBinary); - - const imageSize = useMemo(() => { - return data != null ? sizeOf(Buffer.from(data)) : { height: 0, width: 0 }; - }, [data]); - - const alt = useMemo(() => { - const exif = data != null ? load(Buffer.from(data).toString("binary")) : null; - const raw = exif?.["0th"]?.[ImageIFD.ImageDescription]; - return raw != null ? new TextDecoder().decode(Buffer.from(raw, "binary")) : ""; - }, [data]); - - const blobUrl = useMemo(() => { - return data != null ? URL.createObjectURL(new Blob([data])) : null; - }, [data]); - - const [containerSize, setContainerSize] = useState({ height: 0, width: 0 }); - const callbackRef = useCallback>((el) => { - setContainerSize({ - height: el?.clientHeight ?? 0, - width: el?.clientWidth ?? 0, - }); - }, []); - - if (isLoading || data === null || blobUrl === null) { - return null; - } - - const containerRatio = containerSize.height / containerSize.width; - const imageRatio = imageSize?.height / imageSize?.width; - return ( -
+
{alt} imageRatio, - "w-full h-auto": containerRatio <= imageRatio, - }, - )} - src={blobUrl} + className="absolute inset-0 h-full w-full object-cover" + src={src} + loading="lazy" + decoding="async" /> - - - -
-

画像の説明

- -

{alt}

- - -
-
+ {alt && ( + <> + + + +
+

画像の説明

+ +

{alt}

+ + +
+
+ + )}
); }; diff --git a/application/client/src/components/foundation/SoundWaveSVG.tsx b/application/client/src/components/foundation/SoundWaveSVG.tsx index d95e63164c..f66907a018 100644 --- a/application/client/src/components/foundation/SoundWaveSVG.tsx +++ b/application/client/src/components/foundation/SoundWaveSVG.tsx @@ -1,4 +1,3 @@ -import _ from "lodash"; import { useEffect, useRef, useState } from "react"; interface ParsedData { @@ -6,24 +5,34 @@ interface ParsedData { peaks: number[]; } +function mean(arr: number[]): number { + return arr.reduce((a, b) => a + b, 0) / arr.length; +} + +function chunk(arr: T[], size: number): T[][] { + return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) => + arr.slice(i * size, i * size + size), + ); +} + async function calculate(data: ArrayBuffer): Promise { const audioCtx = new AudioContext(); // 音声をデコードする const buffer = await audioCtx.decodeAudioData(data.slice(0)); // 左の音声データの絶対値を取る - const leftData = _.map(buffer.getChannelData(0), Math.abs); + const leftData = Array.from(buffer.getChannelData(0), Math.abs); // 右の音声データの絶対値を取る - const rightData = _.map(buffer.getChannelData(1), Math.abs); + const rightData = Array.from(buffer.getChannelData(1), Math.abs); // 左右の音声データの平均を取る - const normalized = _.map(_.zip(leftData, rightData), _.mean); + const normalized = leftData.map((l, i) => (l + rightData[i]) / 2); // 100 個の chunk に分ける - const chunks = _.chunk(normalized, Math.ceil(normalized.length / 100)); + const chunks = chunk(normalized, Math.ceil(normalized.length / 100)); // chunk ごとに平均を取る - const peaks = _.map(chunks, _.mean); + const peaks = chunks.map(mean); // chunk の平均の中から最大値を取る - const max = _.max(peaks) ?? 0; + const max = Math.max(...peaks); return { max, peaks }; } diff --git a/application/client/src/components/new_post_modal/NewPostModalPage.tsx b/application/client/src/components/new_post_modal/NewPostModalPage.tsx index e337c46b74..f6cd84ff6c 100644 --- a/application/client/src/components/new_post_modal/NewPostModalPage.tsx +++ b/application/client/src/components/new_post_modal/NewPostModalPage.tsx @@ -1,13 +1,9 @@ -import { MagickFormat } from "@imagemagick/magick-wasm"; import { ChangeEventHandler, FormEventHandler, useCallback, useState } from "react"; import { FontAwesomeIcon } from "@web-speed-hackathon-2026/client/src/components/foundation/FontAwesomeIcon"; import { ModalErrorMessage } from "@web-speed-hackathon-2026/client/src/components/modal/ModalErrorMessage"; import { ModalSubmitButton } from "@web-speed-hackathon-2026/client/src/components/modal/ModalSubmitButton"; import { AttachFileInputButton } from "@web-speed-hackathon-2026/client/src/components/new_post_modal/AttachFileInputButton"; -import { convertImage } from "@web-speed-hackathon-2026/client/src/utils/convert_image"; -import { convertMovie } from "@web-speed-hackathon-2026/client/src/utils/convert_movie"; -import { convertSound } from "@web-speed-hackathon-2026/client/src/utils/convert_sound"; const MAX_UPLOAD_BYTES_LIMIT = 10 * 1024 * 1024; @@ -53,13 +49,16 @@ export const NewPostModalPage = ({ id, hasError, isLoading, onResetError, onSubm if (isValid) { setIsConverting(true); - Promise.all( - files.map((file) => - convertImage(file, { extension: MagickFormat.Jpg }).then( - (blob) => new File([blob], "converted.jpg", { type: "image/jpeg" }), + import("@web-speed-hackathon-2026/client/src/utils/convert_image") + .then(({ convertImage }) => + Promise.all( + files.map((file) => + convertImage(file, { extension: "JPG" }).then( + (blob) => new File([blob], "converted.jpg", { type: "image/jpeg" }), + ), + ), ), - ), - ) + ) .then((convertedFiles) => { setParams((params) => ({ ...params, @@ -82,16 +81,18 @@ export const NewPostModalPage = ({ id, hasError, isLoading, onResetError, onSubm if (isValid) { setIsConverting(true); - convertSound(file, { extension: "mp3" }).then((converted) => { - setParams((params) => ({ - ...params, - images: [], - movie: undefined, - sound: new File([converted], "converted.mp3", { type: "audio/mpeg" }), - })); + import("@web-speed-hackathon-2026/client/src/utils/convert_sound") + .then(({ convertSound }) => convertSound(file, { extension: "mp3" })) + .then((converted) => { + setParams((params) => ({ + ...params, + images: [], + movie: undefined, + sound: new File([converted], "converted.mp3", { type: "audio/mpeg" }), + })); - setIsConverting(false); - }); + setIsConverting(false); + }); } }, []); @@ -103,7 +104,8 @@ export const NewPostModalPage = ({ id, hasError, isLoading, onResetError, onSubm if (isValid) { setIsConverting(true); - convertMovie(file, { extension: "gif", size: undefined }) + import("@web-speed-hackathon-2026/client/src/utils/convert_movie") + .then(({ convertMovie }) => convertMovie(file, { extension: "gif", size: undefined })) .then((converted) => { setParams((params) => ({ ...params, diff --git a/application/client/src/components/post/CommentItem.tsx b/application/client/src/components/post/CommentItem.tsx index cb5bd38bda..f6071a0edd 100644 --- a/application/client/src/components/post/CommentItem.tsx +++ b/application/client/src/components/post/CommentItem.tsx @@ -1,7 +1,6 @@ -import moment from "moment"; - import { Link } from "@web-speed-hackathon-2026/client/src/components/foundation/Link"; import { TranslatableText } from "@web-speed-hackathon-2026/client/src/components/post/TranslatableText"; +import { formatLongDate, toISOString } from "@web-speed-hackathon-2026/client/src/utils/date"; import { getProfileImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path"; interface Props { @@ -42,8 +41,8 @@ export const CommentItem = ({ comment }: Props) => {

-

diff --git a/application/client/src/components/post/ImageArea.tsx b/application/client/src/components/post/ImageArea.tsx index 27fe9c018c..e1a4057bdf 100644 --- a/application/client/src/components/post/ImageArea.tsx +++ b/application/client/src/components/post/ImageArea.tsx @@ -24,7 +24,7 @@ export const ImageArea = ({ images }: Props) => { "row-span-2": images.length <= 2 || (images.length === 3 && idx === 0), })} > - + ); })} diff --git a/application/client/src/components/post/PostItem.tsx b/application/client/src/components/post/PostItem.tsx index 5fa904c91a..fbd6ecc794 100644 --- a/application/client/src/components/post/PostItem.tsx +++ b/application/client/src/components/post/PostItem.tsx @@ -1,10 +1,9 @@ -import moment from "moment"; - import { Link } from "@web-speed-hackathon-2026/client/src/components/foundation/Link"; import { ImageArea } from "@web-speed-hackathon-2026/client/src/components/post/ImageArea"; import { MovieArea } from "@web-speed-hackathon-2026/client/src/components/post/MovieArea"; import { SoundArea } from "@web-speed-hackathon-2026/client/src/components/post/SoundArea"; import { TranslatableText } from "@web-speed-hackathon-2026/client/src/components/post/TranslatableText"; +import { formatLongDate, toISOString } from "@web-speed-hackathon-2026/client/src/utils/date"; import { getProfileImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path"; interface Props { @@ -67,8 +66,8 @@ export const PostItem = ({ post }: Props) => { ) : null}

-

diff --git a/application/client/src/components/post/TranslatableText.tsx b/application/client/src/components/post/TranslatableText.tsx index d772529d92..b099174f0a 100644 --- a/application/client/src/components/post/TranslatableText.tsx +++ b/application/client/src/components/post/TranslatableText.tsx @@ -1,7 +1,5 @@ import { useCallback, useState } from "react"; -import { createTranslator } from "@web-speed-hackathon-2026/client/src/utils/create_translator"; - type State = | { type: "idle"; text: string } | { type: "loading" } @@ -20,6 +18,9 @@ export const TranslatableText = ({ text }: Props) => { (async () => { updateState({ type: "loading" }); try { + const { createTranslator } = await import( + "@web-speed-hackathon-2026/client/src/utils/create_translator" + ); using translator = await createTranslator({ sourceLanguage: "ja", targetLanguage: "en", diff --git a/application/client/src/components/timeline/TimelineItem.tsx b/application/client/src/components/timeline/TimelineItem.tsx index 21b88980f8..51bb56a6bf 100644 --- a/application/client/src/components/timeline/TimelineItem.tsx +++ b/application/client/src/components/timeline/TimelineItem.tsx @@ -1,4 +1,4 @@ -import moment from "moment"; +import { formatLongDate, toISOString } from "@web-speed-hackathon-2026/client/src/utils/date"; import { MouseEventHandler, useCallback } from "react"; import { Link, useNavigate } from "react-router"; @@ -76,8 +76,8 @@ export const TimelineItem = ({ post }: Props) => { - -