Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
2a26089
feat:add CLAUDE.md
toshiki31 Mar 20, 2026
f0da8db
Merge pull request #1 from toshiki31/feat/add-claude.md
toshiki31 Mar 20, 2026
292b313
build: webpackをproductionモードに変更しminify・treeshaking・async splitChunksを有効化
toshiki31 Mar 20, 2026
b18d91a
Merge pull request #2 from toshiki31/fix/webpack-2
toshiki31 Mar 20, 2026
24f799c
build: ビルドスクリプトのNODE_ENVをproductionに変更しbabelのdevelopmentフラグを連動
toshiki31 Mar 20, 2026
6769fff
Merge pull request #3 from toshiki31/fix/build-script
toshiki31 Mar 20, 2026
16c9a5f
build: babelターゲットをlast 2 Chrome versionsに変更しmodulesをfalseにしてTree Shak…
toshiki31 Mar 20, 2026
9244691
Merge pull request #4 from toshiki31/fix/babel-target
toshiki31 Mar 20, 2026
9fe12c7
perf: scriptタグにdeferを追加してHTMLパースのブロッキングを解消
toshiki31 Mar 20, 2026
d04c6e5
fix: app.tsの全レスポンスno-cacheヘッダーを削除し静的アセットにmax-age=31536000を設定
toshiki31 Mar 20, 2026
e20e4c1
fix: crokのSSEストリームのsleep(3000)とsleep(10)を削除
toshiki31 Mar 20, 2026
0c4d157
perf: SQLiteインデックスを追加してフルスキャンを解消
toshiki31 Mar 20, 2026
8a0061d
perf: afterSaveフックのN+1を解消しindividualHooksを無効化
toshiki31 Mar 20, 2026
685869c
Merge pull request #5 from toshiki31/fix/html-render
toshiki31 Mar 20, 2026
26ab824
perf: bcrypt.compareSyncをasyncに変更しsignin時のeventloopブロッキングを解消
toshiki31 Mar 20, 2026
4abfcaa
Revert "perf: afterSaveフックのN+1を解消しindividualHooksを無効化"
toshiki31 Mar 20, 2026
be7eee1
chore: searchのサーバーサイドページネーション未実装をTODOコメントで記録
toshiki31 Mar 20, 2026
166c4da
Merge pull request #6 from toshiki31/fix/server-perf
toshiki31 Mar 20, 2026
c48d3f4
fix: InfiniteScrollの2^18回ループをIntersectionObserverに置き換え
toshiki31 Mar 20, 2026
f3e85ed
fix: 検索クエリのReDoS脆弱性がある正規表現を単純化
toshiki31 Mar 20, 2026
4818f41
fix: jQueryのasync:false同期XHRをnative fetchに置き換え
toshiki31 Mar 20, 2026
6d42bf4
perf: CoveredImageをdirect img srcに変更しEXIF取得をALT表示時のみ遅延フェッチ
toshiki31 Mar 20, 2026
e4bbaf0
fix: fetch系関数にHTTPエラー時の例外throwを追加する
toshiki31 Mar 20, 2026
9058757
Merge pull request #7 from toshiki31/fix/frontend-perf
toshiki31 Mar 20, 2026
5714eb8
perf: window.load→DOMContentLoaded・web-llm dynamic import・moment→Intl…
toshiki31 Mar 20, 2026
e54a191
Merge pull request #8 from toshiki31/fix/js-optimize
toshiki31 Mar 20, 2026
4da4332
perf: PausableMovieをimg直接表示・SoundPlayerをdirect srcに変更しバイナリ取得を排除
toshiki31 Mar 20, 2026
b030e9b
fix: DMページのsetInterval(1ms)+getComputedStyleをResizeObserverに置き換え
toshiki31 Mar 20, 2026
309cdff
perf: フォントをOTF→WOFF2に変換(12.6MB→7.3MB)
toshiki31 Mar 20, 2026
53e8b70
perf: lodash削除とReact.lazy化でmain.jsを25MB→942KBに削減
toshiki31 Mar 20, 2026
3ce7b27
fix: test error
toshiki31 Mar 21, 2026
b5ce1d7
Merge remote-tracking branch 'upstream/main' into fix/vrt
toshiki31 Mar 21, 2026
d873ae2
chore: upstream/mainのオリジナルアプリでVRTスナップショットを再生成
toshiki31 Mar 21, 2026
bcfd449
fix: Enterでメッセージを送信・Shift+Enterで改行できること
toshiki31 Mar 21, 2026
c3dc7b8
fix: 検索画面のテスト落ち修正
toshiki31 Mar 21, 2026
781ef25
fix: 投稿テストが落ちるのを修正
toshiki31 Mar 21, 2026
0b272de
fix: closeのみフォームリセット
toshiki31 Mar 21, 2026
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
66 changes: 66 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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` の内容を変更してはならない**
6 changes: 3 additions & 3 deletions application/client/babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
],
Expand Down
2 changes: 1 addition & 1 deletion application/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
17 changes: 14 additions & 3 deletions application/client/src/components/application/SearchPage.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 && (
<span className="text-cax-danger mt-1 text-xs">{meta.error}</span>
)}
</div>
Expand Down Expand Up @@ -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)}`);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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");
})()}
</time>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import classNames from "classnames";
import moment from "moment";
import {
ChangeEvent,
useCallback,
useId,
useRef,
useId,
useState,
KeyboardEvent,
FormEvent,
Expand Down Expand Up @@ -34,7 +33,7 @@ export const DirectMessagePage = ({
onTyping,
onSubmit,
}: Props) => {
const formRef = useRef<HTMLFormElement>(null);
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const textAreaId = useId();

const peer =
Expand All @@ -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<HTMLTextAreaElement>) => {
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<HTMLFormElement>) => {
event.preventDefault();
void submitMessage();
},
[submitMessage],
);

const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
formRef.current?.requestSubmit();
void submitMessage();
}
},
[formRef],
);

const handleSubmit = useCallback(
(event: FormEvent<HTMLFormElement>) => {
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) {
Expand Down Expand Up @@ -141,7 +156,11 @@ export const DirectMessagePage = ({
</p>
<div className="flex gap-1 text-xs">
<time dateTime={message.createdAt}>
{moment(message.createdAt).locale("ja").format("HH:mm")}
{new Intl.DateTimeFormat("ja-JP", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(new Date(message.createdAt))}
</time>
{isActiveUserSend && message.isRead && (
<span className="text-cax-text-muted">既読</span>
Expand All @@ -163,7 +182,6 @@ export const DirectMessagePage = ({
<form
className="border-cax-border bg-cax-surface flex items-end gap-2 border-t p-4"
onSubmit={handleSubmit}
ref={formRef}
>
<div className="flex grow">
<label className="sr-only" htmlFor={textAreaId}>
Expand Down
53 changes: 12 additions & 41 deletions application/client/src/components/foundation/CoveredImage.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
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, useState } 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 {
Expand All @@ -22,56 +19,30 @@ export const CoveredImage = ({ src }: Props) => {
ev.stopPropagation();
}, []);

const { data, isLoading } = useFetch(src, fetchBinary);
const [alt, setAlt] = useState("");

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 handleShowAlt = useCallback(async () => {
const data = await fetchBinary(src);
const exif = load(Buffer.from(data).toString("binary"));
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;
const altText = raw != null ? new TextDecoder().decode(Buffer.from(raw, "binary")) : "";
setAlt(altText);
}, [src]);

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}
/>

<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}
onClick={handleShowAlt}
>
ALT を表示する
</button>
Expand Down
Loading
Loading