Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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 <url>
```

## ディレクトリ構成

```
/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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
7 changes: 3 additions & 4 deletions application/client/babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
],
Expand Down
13 changes: 2 additions & 11 deletions application/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,21 @@
"@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",
"core-js": "3.45.1",
"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",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions application/client/postcss.config.js
Original file line number Diff line number Diff line change
@@ -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,
}),
Expand Down
9 changes: 6 additions & 3 deletions application/client/src/components/crok/ChatInput.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import Bluebird from "bluebird";
import kuromoji, { type Tokenizer, type IpadicFeatures } from "kuromoji";
import {
useEffect,
Expand Down Expand Up @@ -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<Tokenizer<IpadicFeatures>>((resolve, reject) => {
kuromoji.builder({ dicPath: "/dicts" }).build((err, tokenizer) => {
if (err) reject(err);
else resolve(tokenizer);
});
});
if (mounted) {
setTokenizer(nextTokenizer);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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)}
</time>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -141,7 +141,7 @@ export const DirectMessagePage = ({
</p>
<div className="flex gap-1 text-xs">
<time dateTime={message.createdAt}>
{moment(message.createdAt).locale("ja").format("HH:mm")}
{formatTime(message.createdAt)}
</time>
{isActiveUserSend && message.isRead && (
<span className="text-cax-text-muted">既読</span>
Expand Down
99 changes: 32 additions & 67 deletions application/client/src/components/foundation/CoveredImage.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDialogElement>) => {
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<RefCallback<HTMLDivElement>>((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 (
<div ref={callbackRef} className="relative h-full w-full overflow-hidden">
<div className="relative h-full w-full overflow-hidden">
<img
alt={alt}
className={classNames(
"absolute left-1/2 top-1/2 max-w-none -translate-x-1/2 -translate-y-1/2",
{
"w-auto h-full": containerRatio > 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"
/>

<button
className="border-cax-border bg-cax-surface-raised/90 text-cax-text-muted hover:bg-cax-surface absolute right-1 bottom-1 rounded-full border px-2 py-1 text-center text-xs"
type="button"
command="show-modal"
commandfor={dialogId}
>
ALT を表示する
</button>

<Modal id={dialogId} closedby="any" onClick={handleDialogClick}>
<div className="grid gap-y-6">
<h1 className="text-center text-2xl font-bold">画像の説明</h1>

<p className="text-sm">{alt}</p>

<Button variant="secondary" command="close" commandfor={dialogId}>
閉じる
</Button>
</div>
</Modal>
{alt && (
<>
<button
className="border-cax-border bg-cax-surface-raised/90 text-cax-text-muted hover:bg-cax-surface absolute right-1 bottom-1 rounded-full border px-2 py-1 text-center text-xs"
type="button"
command="show-modal"
commandfor={dialogId}
>
ALT を表示する
</button>

<Modal id={dialogId} closedby="any" onClick={handleDialogClick}>
<div className="grid gap-y-6">
<h1 className="text-center text-2xl font-bold">画像の説明</h1>

<p className="text-sm">{alt}</p>

<Button variant="secondary" command="close" commandfor={dialogId}>
閉じる
</Button>
</div>
</Modal>
</>
)}
</div>
);
};
23 changes: 16 additions & 7 deletions application/client/src/components/foundation/SoundWaveSVG.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,38 @@
import _ from "lodash";
import { useEffect, useRef, useState } from "react";

interface ParsedData {
max: number;
peaks: number[];
}

function mean(arr: number[]): number {
return arr.reduce((a, b) => a + b, 0) / arr.length;
}

function chunk<T>(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<ParsedData> {
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 };
}
Expand Down
Loading
Loading