Skip to content
Merged
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
29 changes: 29 additions & 0 deletions admin/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import type { EventData, EventsResponse, Exclusion, HarvestQuest } from "../type

const API_URL = import.meta.env.VITE_API_URL as string;

/**
* Cognito の idToken を Bearer トークンとして含む認証ヘッダーを生成する。
* すべての API リクエストの前処理として request() から呼び出される。
*/
async function authHeaders(): Promise<Record<string, string>> {
const session = await fetchAuthSession();
const token = session.tokens?.idToken?.toString() ?? "";
Expand All @@ -12,6 +16,12 @@ async function authHeaders(): Promise<Record<string, string>> {
};
}

/**
* 認証ヘッダーを付けて管理 API へリクエストを送る汎用関数。
* レスポンスが ok でない場合はレスポンスボディを含むエラーをスローする。
* @param path API パス(VITE_API_URL からの相対パス)
* @param init fetch の追加オプション(method, body など)
*/
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const headers = await authHeaders();
const res = await fetch(`${API_URL}${path}`, {
Expand All @@ -25,41 +35,60 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
return res.json() as Promise<T>;
}

/** イベント一覧を取得する。 */
export function getEvents() {
return request<EventsResponse>("/events");
}

/** イベントを新規作成する。作成されたイベント(eventId 付き)を返す。 */
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

この関数は data パラメータを持っていますが、@param タグがありません。一貫性のために @param data イベントデータ(eventId を除く) を追加することを推奨します。

Copilot uses AI. Check for mistakes.
export function createEvent(data: Omit<EventData, "eventId">) {
return request<EventData>("/events", {
method: "POST",
body: JSON.stringify(data),
});
}

/**
* 指定イベントを更新する。更新後のイベントデータを返す。
* @param eventId 更新対象のイベント ID
* @param data イベントデータ(eventId を除く)
*/
export function updateEvent(eventId: string, data: Omit<EventData, "eventId">) {
return request<EventData>(`/events/${eventId}`, {
method: "PUT",
body: JSON.stringify(data),
});
}

/** 指定イベントを削除する。 */
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

この関数は eventId パラメータを持っていますが、@param タグがありません。一貫性のために @param eventId 削除対象のイベント ID を追加することを推奨します。

Suggested change
/** 指定イベントを削除する。 */
/**
* 指定イベントを削除する。
* @param eventId 削除対象のイベント ID
*/

Copilot uses AI. Check for mistakes.
export function deleteEvent(eventId: string) {
return request<{ message: string }>(`/events/${eventId}`, {
method: "DELETE",
});
}

/** 指定クエストの除外リストを取得する。 */
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

この関数は questId パラメータを持っていますが、@param タグがありません。一貫性のために @param questId クエスト ID を追加することを推奨します。

Suggested change
/** 指定クエストの除外リストを取得する。 */
/**
* 指定クエストの除外リストを取得する。
* @param questId クエスト ID
*/

Copilot uses AI. Check for mistakes.
export function getExclusions(questId: string) {
return request<Exclusion[]>(`/exclusions/${questId}`);
}

/**
* 指定クエストの除外リストを更新する(全件置き換え)。
* 更新後の除外リストを返す。
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

この関数は questIdexclusions のパラメータを持っていますが、@param タグがありません。一貫性のために @param questId クエスト ID@param exclusions 除外リスト を追加することを推奨します。

Suggested change
* 更新後の除外リストを返す。
* 更新後の除外リストを返す。
* @param questId クエスト ID
* @param exclusions 除外リスト

Copilot uses AI. Check for mistakes.
* @param questId クエスト ID
* @param exclusions 除外リスト(全件置き換え)
*/
export function updateExclusions(questId: string, exclusions: Exclusion[]) {
return request<Exclusion[]>(`/exclusions/${questId}`, {
method: "PUT",
body: JSON.stringify(exclusions),
});
}

/**
* Harvest のクエスト一覧を取得する。
* EventFormPage でイベントクエスト候補を絞り込む際に使用する。
*/
export function fetchHarvestQuests() {
return request<HarvestQuest[]>("/harvest/quests");
}
6 changes: 6 additions & 0 deletions admin/src/auth/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,16 @@ interface AuthContextValue {

const AuthContext = createContext<AuthContextValue>(null!);

/** 認証コンテキスト(isAuthenticated, username, login, logout など)を取得するカスタムフック。 */
export function useAuth() {
return useContext(AuthContext);
}

/**
* AWS Amplify を使った認証状態を管理し、子コンポーネントに提供するプロバイダー。
* マウント時に getCurrentUser() で既存セッションを確認し、認証状態を初期化する。
* login / logout を AuthContext 経由で子コンポーネントに公開する。
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

この関数は children パラメータを持っていますが、@param タグがありません。一貫性のために @param children プロバイダー内でレンダリングする子要素 を追加することを推奨します。

Suggested change
* login / logout AuthContext 経由で子コンポーネントに公開する。
* login / logout AuthContext 経由で子コンポーネントに公開する。
* @param children プロバイダー内でレンダリングする子要素

Copilot uses AI. Check for mistakes.
*/
export function AuthProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
Expand Down
20 changes: 20 additions & 0 deletions admin/src/pages/EventFormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,11 @@ export function EventFormPage() {

const SUFFIX_ORDER = ["", "+", "++", "+++", "\u2605", "\u2605\u2605", "\u2605\u2605\u2605"];

/**
* クエストレベル文字列を比較用の [数値部分, サフィックス順序] タプルに変換する。
* サフィックスは SUFFIX_ORDER の定義順("", "+", "++", ... , "★★★")で数値化する。
* パターンにマッチしない場合は [0, 0] を返す。
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

この関数は level パラメータを持っていますが、@param タグがありません。一貫性のために @param level クエストレベル文字列 を追加することを推奨します。

Suggested change
* パターンにマッチしない場合は [0, 0] を返す。
* パターンにマッチしない場合は [0, 0] を返す。
* @param level クエストレベル文字列

Copilot uses AI. Check for mistakes.
*/
function parseLevelKey(level: string): [number, number] {
const m = level.match(/^(\d+)(.*)/);
if (!m) return [0, 0];
Expand All @@ -374,20 +379,35 @@ function parseLevelKey(level: string): [number, number] {
return [num, suffixIdx >= 0 ? suffixIdx : SUFFIX_ORDER.length];
}

/**
* クエストをレベル昇順で並び替える比較関数。Array.sort() に渡して使用する。
* 数値が同じ場合はサフィックス("+" < "++" < "★" など)の定義順で比較する。
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

この関数は ab のパラメータを持っていますが、@param タグがありません。一貫性のために @param a 比較対象のクエスト(左辺)@param b 比較対象のクエスト(右辺) を追加することを推奨します。

Suggested change
* 数値が同じ場合はサフィックス("+" < "++" < "★" など)の定義順で比較する。
* 数値が同じ場合はサフィックス("+" < "++" < "★" など)の定義順で比較する。
* @param a 比較対象のクエスト(左辺)
* @param b 比較対象のクエスト(右辺)

Copilot uses AI. Check for mistakes.
* @param a 比較対象のクエスト
* @param b 比較対象のクエスト
*/
function sortByLevel(a: Quest, b: Quest): number {
const [aNum, aSuf] = parseLevelKey(a.level);
const [bNum, bSuf] = parseLevelKey(b.level);
if (aNum !== bNum) return aNum - bNum;
return aSuf - bSuf;
}

/**
* ISO 形式の日時文字列を `<input type="datetime-local">` の値形式(JST, "YYYY-MM-DDTHH:mm")に変換する。
* API から取得した期間データをフォームに初期表示する際に使用する。
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

この関数は iso パラメータを持っていますが、@param タグがありません。一貫性のために @param iso ISO 形式の日時文字列 を追加することを推奨します。

Suggested change
* API から取得した期間データをフォームに初期表示する際に使用する。
* API から取得した期間データをフォームに初期表示する際に使用する。
* @param iso ISO 形式の日時文字列

Copilot uses AI. Check for mistakes.
*/
function toLocalInput(iso: string): string {
const d = new Date(iso);
const offset = 9 * 60;
const local = new Date(d.getTime() + offset * 60 * 1000);
return local.toISOString().slice(0, 16);
}

/**
* `<input type="datetime-local">` の値("YYYY-MM-DDTHH:mm")を
* JST オフセット付きの ISO 形式("YYYY-MM-DDTHH:mm:00+09:00")に変換する。
* フォームの入力値を API へ送信するペイロードに変換する際に使用する。
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

この関数は localInput パラメータを持っていますが、@param タグがありません。一貫性のために @param localInput datetime-local 形式の入力値 を追加することを推奨します。

Suggested change
* フォームの入力値を API へ送信するペイロードに変換する際に使用する。
* フォームの入力値を API へ送信するペイロードに変換する際に使用する。
* @param localInput datetime-local 形式の入力値

Copilot uses AI. Check for mistakes.
*/
function toISO(localInput: string): string {
return `${localInput}:00+09:00`;
}
Expand Down
1 change: 1 addition & 0 deletions admin/src/pages/EventListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export function EventListPage() {
);
}

/** ISO 形式の日時文字列を日本時間のロケール文字列に変換する。 */
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

この関数は iso パラメータを持っていますが、@param タグがありません。一貫性のために @param iso ISO 形式の日時文字列 を追加することを推奨します。

Suggested change
/** ISO 形式の日時文字列を日本時間のロケール文字列に変換する。 */
/**
* ISO 形式の日時文字列を日本時間のロケール文字列に変換する。
* @param iso ISO 形式の日時文字列
*/

Copilot uses AI. Check for mistakes.
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" });
Expand Down
15 changes: 15 additions & 0 deletions lambda/admin_api/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@


def lambda_handler(event, context):
"""管理 API Lambda のエントリーポイント。

HTTP メソッドとパスに基づいてルーティングし、対応するハンドラ関数を呼び出す。
未定義のルートは 404、ハンドラ内の例外は 500 を返す。
"""
method = event["requestContext"]["http"]["method"]
path = event["requestContext"]["http"]["path"]

Expand Down Expand Up @@ -45,6 +50,7 @@ def lambda_handler(event, context):


def read_json(key):
"""S3 から指定キーの JSON を読み込む。キーが存在しない場合は None を返す。"""
try:
obj = s3.get_object(Bucket=BUCKET, Key=key)
return json.loads(obj["Body"].read().decode("utf-8"))
Expand All @@ -55,6 +61,7 @@ def read_json(key):


def write_json(key, data):
"""data を JSON シリアライズして S3 の指定キーに書き込む。"""
s3.put_object(
Bucket=BUCKET,
Key=key,
Expand All @@ -67,13 +74,15 @@ def write_json(key, data):


def get_events():
"""イベント一覧を返す。events.json が存在しない場合は空のイベントリストを返す。"""
data = read_json(EVENTS_KEY)
if data is None:
data = {"events": []}
return response(200, data)


def post_event(body):
"""イベントを新規作成する。eventId が未指定または空の場合は UUID を自動生成する。"""
data = read_json(EVENTS_KEY)
if data is None:
data = {"events": []}
Expand All @@ -87,6 +96,7 @@ def post_event(body):


def put_event(event_id, body):
"""指定 eventId のイベントを更新する。イベントが存在しない場合は 404 を返す。"""
data = read_json(EVENTS_KEY)
if data is None:
return response(404, {"error": "Event not found"})
Expand All @@ -102,6 +112,7 @@ def put_event(event_id, body):


def delete_event(event_id):
"""指定 eventId のイベントを削除する。イベントが存在しない場合は 404 を返す。"""
data = read_json(EVENTS_KEY)
if data is None:
return response(404, {"error": "Event not found"})
Expand All @@ -119,6 +130,7 @@ def delete_event(event_id):


def get_exclusions(quest_id):
"""指定クエストの除外リストを返す。exclusions.json が存在しない場合は空リストを返す。"""
data = read_json(EXCLUSIONS_KEY)
if data is None:
data = {}
Expand All @@ -127,6 +139,7 @@ def get_exclusions(quest_id):


def put_exclusions(quest_id, body):
"""指定クエストの除外リストを更新する(全件置き換え)。"""
data = read_json(EXCLUSIONS_KEY)
if data is None:
data = {}
Expand All @@ -140,6 +153,7 @@ def put_exclusions(quest_id, body):


def get_harvest_quests():
"""Harvest API から全クエスト一覧を取得してそのまま返す(プロキシ)。"""
with urlopen(HARVEST_ALL_URL) as resp:
data = json.loads(resp.read().decode("utf-8"))
return response(200, data)
Expand All @@ -149,6 +163,7 @@ def get_harvest_quests():


def response(status_code, body):
"""API Gateway (HTTP API) 互換のレスポンス辞書を生成する。"""
return {
"statusCode": status_code,
"headers": {"Content-Type": "application/json"},
Expand Down
11 changes: 11 additions & 0 deletions lambda/aggregator/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@


def read_json(key: str) -> dict | list | None:
"""S3 から指定キーの JSON を読み込む。キーが存在しない場合は None を返す。"""
try:
obj = s3.get_object(Bucket=BUCKET, Key=key)
return json.loads(obj["Body"].read().decode("utf-8"))
Expand All @@ -32,6 +33,7 @@ def read_json(key: str) -> dict | list | None:


def write_json(key: str, data: dict | list) -> None:
"""data を JSON シリアライズして S3 の指定キーに書き込む。"""
s3.put_object(
Bucket=BUCKET,
Key=key,
Expand Down Expand Up @@ -204,6 +206,15 @@ def process_quest(event_id: str, quest: dict, event_items: set[str]) -> None:


def lambda_handler(event: Any, context: Any) -> dict[str, int]:
"""集計 Lambda のエントリーポイント。

events.json を読み込み、現在時刻にアクティブなイベントのクエストを順に処理する。
イベント終了直前の報告や Harvest への反映遅延を考慮し、
終了後5時間はグレースピリオドとして集計を継続する。

Returns:
{"processed": <処理したクエスト数>}
"""
data = read_json(EVENTS_KEY)
if data is None:
logger.info("No events.json found, exiting")
Expand Down
44 changes: 44 additions & 0 deletions viewer/src/aggregate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import type { Exclusion, ItemOutlierStats, ItemStats, Report } from "./types";

const Z = 1.96; // 95% confidence

/**
* Wilson スコア法による二項比率の 95% 信頼区間を計算する。
* @param successes ドロップ数(成功回数)
* @param n 試行回数(周回数)
* @returns 信頼区間の下限・上限(0〜1)
*/
function wilsonCI(successes: number, n: number): { lower: number; upper: number } {
if (n === 0) return { lower: 0, upper: 0 };
const p = successes / n;
Expand All @@ -16,10 +22,21 @@ function wilsonCI(successes: number, n: number): { lower: number; upper: number
};
}

/**
* 除外リストから reportId の Set を作成する。
* 集計・外れ値計算の前処理として有効報告のフィルタリングに使用する。
*/
export function createExcludedIdSet(exclusions: Exclusion[]): Set<string> {
return new Set(exclusions.map((e) => e.reportId));
}

/**
* 有効な報告を集計し、アイテムごとのドロップ数・ドロップ率・Wilson 95% 信頼区間を計算する。
* exclusions に含まれる報告は集計から除外される。
* @param reports クエストの全報告リスト
* @param exclusions 除外対象の報告リスト
* @returns アイテムごとの集計結果(ItemStats)の配列
*/
export function aggregate(reports: Report[], exclusions: Exclusion[]): ItemStats[] {
const excludedIds = createExcludedIdSet(exclusions);
const validReports = reports.filter((r) => !excludedIds.has(r.id));
Expand Down Expand Up @@ -57,10 +74,21 @@ export function aggregate(reports: Report[], exclusions: Exclusion[]): ItemStats
return stats;
}

/**
* アイテムが外れ値検出の常時対象かどうかを判定する。
* イベントアイテム・ポイント・QP は低ドロップ率でも外れ値チェックを行うため、true を返す。
*/
function isAlwaysTargetItem(itemName: string): boolean {
return RE_EVENT_ITEM.test(itemName) || RE_POINT.test(itemName) || RE_QP.test(itemName);
}

/**
* アイテムごとに「1周あたりドロップ数」の平均・標準偏差を計算する。
* isOutlier() による外れ値判定の基準値として使用する。
* @param reports クエストの全報告リスト
* @param exclusions 除外対象の報告リスト
* @returns アイテムごとの外れ値統計(ItemOutlierStats)の配列
*/
export function calcOutlierStats(reports: Report[], exclusions: Exclusion[]): ItemOutlierStats[] {
const excludedIds = createExcludedIdSet(exclusions);
const validReports = reports.filter((r) => !excludedIds.has(r.id));
Expand Down Expand Up @@ -102,6 +130,22 @@ const MIN_SAMPLE_COUNT = 5;
const MIN_RUNCOUNT = 20;
const MIN_DROP_RATE_FOR_NORMAL = 0.2;

/**
* 報告の値が外れ値かどうかを z スコアで判定する。
*
* 以下のいずれかに該当する場合は外れ値チェックをスキップして null を返す:
* - value が null(そのアイテムの報告が存在しない)
* - 周回数が少ない(MIN_RUNCOUNT 未満)
* - サンプル数が少ない(MIN_SAMPLE_COUNT 未満)
* - 標準偏差がほぼゼロ(全報告が同一値)
* - 通常アイテムかつドロップ率が低い(MIN_DROP_RATE_FOR_NORMAL 未満)
*
* @param value 報告のアイテムドロップ数
* @param runcount 報告の周回数
* @param outlierStats そのアイテムの全体統計(平均・標準偏差)
* @param dropRate そのアイテムの全体ドロップ率
* @returns 外れ値の場合は z スコア、外れ値でないまたはスキップの場合は null
*/
export function isOutlier(
value: number | null,
runcount: number,
Expand Down
Loading