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
26 changes: 24 additions & 2 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,11 @@ FGO イベント報告データを集計し、アイテムドロップ率を算
| `period.start` | string (ISO 8601) | イベント開始日時 |
| `period.end` | string (ISO 8601) | イベント終了日時 |
| `quests` | array | 集計対象クエストの一覧 |
| `quests[].questId` | string | Harvest 上のクエスト ID (ページ ID) |
| `quests[].questId` | string | Harvest 上のクエスト ID (ページ ID)。公開画面の URL キーおよび中間 JSON のファイルキーに使われる |
| `quests[].name` | string | クエスト名 |
| `quests[].level` | string | 推奨レベル |
| `quests[].ap` | number | 消費 AP |
| `quests[].sourceQuestIds` | string[] (省略可) | 集計元の Harvest ページ ID リスト。複数ページに分割されているクエストを統合する際に指定する。省略または空配列の場合は `questId` のみを使用する |

### 3.2 除外リスト (`exclusions.json`)

Expand Down Expand Up @@ -386,7 +387,28 @@ report_id: `605fc0f1` — `items` にイベントアイテム (ぐん肥/のび
- しかし添字なしイベントアイテムはやはり集計に含めるべきでない(枠数として解釈できないため)
- → 実数報告かどうかに関わらず、ベース名がイベントアイテムと一致するキーは常に除外する (→ 5.1 (b))

### 7.7 同一報告者の複数報告
### 7.7 Harvest ページ分割 (1:N ソースマッピング)

同一クエストのデータが Harvest 上で複数のページ ID に分割されている場合がある(例: イベント期間途中でページが分割されたケース)。

このような場合は `events.json` の `quests[].sourceQuestIds` に全ページ ID を列挙することで対応する:

```json
{
"questId": "XCtBEoEwgr6R",
"name": "...",
"level": "90+",
"ap": 40,
"sourceQuestIds": ["XCtBEoEwgr6R", "Ab12CdEfGhIj"]
}
```

- 集計 Lambda は `sourceQuestIds` の各 ID から報告を取得し、重複排除後にマージして1つの中間 JSON を生成する
- 出力ファイルパスは `questId` を使用 (`{eventId}/{questId}.json`)
- 公開画面のルーティングや除外リストの管理は変わらない
- `sourceQuestIds` 省略または空配列の場合は `questId` のみを使用する(後方互換)

### 7.8 同一報告者の複数報告

同一の `reporter_id` で複数の報告が存在する (例: 豆ぽ 5件、シェリル 6件)。これらは別々の周回期間の報告であり、それぞれ独立した報告として集計する。

Expand Down
195 changes: 190 additions & 5 deletions admin/src/pages/EventFormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export function EventFormPage() {
const [newItem, setNewItem] = useState("");
const [loading, setLoading] = useState(isEdit);
const [error, setError] = useState("");
const [sourceExpanded, setSourceExpanded] = useState<Record<number, boolean>>({});
const [newSourceId, setNewSourceId] = useState<Record<number, string>>({});
// 候補テーブルの「ソースに追加」select の選択状態: candidateId → questIndex (-1 = 未選択)
const [addAsSourceTarget, setAddAsSourceTarget] = useState<Record<string, number>>({});

// Harvest クエスト候補
const [candidates, setCandidates] = useState<HarvestQuest[]>([]);
Expand Down Expand Up @@ -80,11 +84,75 @@ export function EventFormPage() {
};

const addQuest = () => {
setQuests([...quests, { questId: "", name: "", level: "", ap: 40 }]);
setQuests([...quests, { questId: "", name: "", level: "", ap: 40, sourceQuestIds: [] }]);
};

const toggleSourceExpanded = (index: number) => {
setSourceExpanded((prev) => ({ ...prev, [index]: !prev[index] }));
};

const addSourceId = (index: number) => {
const id = (newSourceId[index] ?? "").trim();
if (!id) return;
const current = quests[index].sourceQuestIds ?? [];
if (current.includes(id)) return;
const updated = quests.map((q, i) =>
i === index ? { ...q, sourceQuestIds: [...current, id] } : q,
);
setQuests(updated);
setNewSourceId((prev) => ({ ...prev, [index]: "" }));
};

const removeSourceId = (questIndex: number, sourceIndex: number) => {
const current = quests[questIndex].sourceQuestIds ?? [];
const updated = quests.map((q, i) =>
i === questIndex
? { ...q, sourceQuestIds: current.filter((_, si) => si !== sourceIndex) }
: q,
);
setQuests(updated);
};

const addAsSource = (questIndex: number, sourceId: string) => {
if (questIndex < 0 || questIndex >= quests.length) return;
const current = quests[questIndex].sourceQuestIds ?? [];
if (current.includes(sourceId)) return;
const updated = quests.map((q, i) =>
i === questIndex ? { ...q, sourceQuestIds: [...current, sourceId] } : q,
);
setQuests(updated);
setAddAsSourceTarget((prev) => ({ ...prev, [sourceId]: -1 }));
};

const removeQuest = (index: number) => {
setQuests(quests.filter((_, i) => i !== index));
setSourceExpanded((prev) => {
const next: Record<number, boolean> = {};
for (const [k, v] of Object.entries(prev)) {
const ki = Number(k);
if (ki < index) next[ki] = v;
else if (ki > index) next[ki - 1] = v;
}
return next;
});
setNewSourceId((prev) => {
const next: Record<number, string> = {};
for (const [k, v] of Object.entries(prev)) {
const ki = Number(k);
if (ki < index) next[ki] = v;
else if (ki > index) next[ki - 1] = v;
}
return next;
});
setAddAsSourceTarget((prev) => {
const next: Record<string, number> = {};
for (const [k, v] of Object.entries(prev)) {
if (v < index) next[k] = v;
else if (v > index) next[k] = v - 1;
// v === index: 削除されたクエストを指していたので除外(未選択に戻す)
}
return next;
});
};

const updateQuest = (index: number, field: keyof Quest, value: string | number) => {
Expand Down Expand Up @@ -124,7 +192,7 @@ export function EventFormPage() {

if (loading) return <p>読み込み中...</p>;

const addedIds = new Set(quests.map((q) => q.questId));
const addedIds = new Set(quests.flatMap((q) => [q.questId, ...(q.sourceQuestIds ?? [])]));

return (
<div style={{ maxWidth: 800, margin: "24px auto", padding: 24 }}>
Expand Down Expand Up @@ -276,9 +344,48 @@ export function EventFormPage() {
{addedIds.has(c.id) ? (
<span style={{ color: "#888" }}>追加済み</span>
) : (
<button type="button" onClick={() => addCandidate(c)}>
追加
</button>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 4,
alignItems: "center",
}}
>
<button type="button" onClick={() => addCandidate(c)}>
追加
</button>
{quests.length > 0 && (
<>
<select
value={addAsSourceTarget[c.id] ?? -1}
onChange={(e) =>
setAddAsSourceTarget((prev) => ({
...prev,
[c.id]: Number(e.target.value),
}))
}
style={{ fontSize: 13 }}
>
<option value={-1}>ソースに追加...</option>
{quests.map((q, qi) => (
<option key={qi} value={qi}>
{q.name || `クエスト${qi + 1}`}
{q.level ? ` (Lv.${q.level})` : ""}
</option>
))}
</select>
<button
type="button"
disabled={(addAsSourceTarget[c.id] ?? -1) < 0}
onClick={() => addAsSource(addAsSourceTarget[c.id], c.id)}
style={{ fontSize: 13 }}
>
+
</button>
</>
)}
</div>
)}
</td>
</tr>
Expand Down Expand Up @@ -345,6 +452,84 @@ export function EventFormPage() {
削除
</button>
</div>

{/* 複数ソース設定 */}
<div style={{ marginTop: 8 }}>
<button
type="button"
onClick={() => toggleSourceExpanded(i)}
style={{
fontSize: 12,
color: "#555",
background: "none",
border: "none",
cursor: "pointer",
padding: 0,
}}
>
{sourceExpanded[i] ? "▲ 複数ソース設定を閉じる" : "▼ 複数ソース設定"}
{q.sourceQuestIds && q.sourceQuestIds.length > 0 && (
<span style={{ marginLeft: 6, color: "#0066cc" }}>
({q.sourceQuestIds.length} 件設定中)
</span>
)}
</button>
{sourceExpanded[i] && (
<div
style={{
marginTop: 6,
padding: "8px 12px",
background: "#f9f9f9",
borderRadius: 4,
border: "1px solid #e0e0e0",
}}
>
<p style={{ fontSize: 12, color: "#666", margin: "0 0 6px" }}>
集計元の Harvest ページ ID を列挙します。未設定の場合はクエスト ID
のみが使われます。
</p>
{(q.sourceQuestIds ?? []).map((sid, si) => (
<div
key={si}
style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 4 }}
>
<span style={{ fontFamily: "monospace", fontSize: 13, flex: 1 }}>{sid}</span>
<button
type="button"
onClick={() => removeSourceId(i, si)}
style={{
border: "none",
background: "none",
cursor: "pointer",
color: "#888",
fontSize: 13,
}}
>
x
</button>
</div>
))}
<div style={{ display: "flex", gap: 6, marginTop: 4 }}>
<input
type="text"
value={newSourceId[i] ?? ""}
onChange={(e) => setNewSourceId((prev) => ({ ...prev, [i]: e.target.value }))}
placeholder="Harvest ページ ID"
style={{ flex: 1, fontFamily: "monospace", fontSize: 13 }}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addSourceId(i);
}
}}
/>
<button type="button" onClick={() => addSourceId(i)} style={{ fontSize: 13 }}>
追加
</button>
</div>
</div>
)}
</div>
</div>
))}

Expand Down
1 change: 1 addition & 0 deletions admin/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export interface Quest {
name: string;
level: string;
ap: number;
sourceQuestIds?: string[];
}

export interface EventPeriod {
Expand Down
22 changes: 18 additions & 4 deletions lambda/aggregator/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,24 @@ def fetch_harvest_reports(quest_id: str) -> list[dict]:
def process_quest(event_id: str, quest: dict, event_items: set[str]) -> None:
"""クエスト1件を処理: 取得・変換・中間 JSON 出力。"""
quest_id = quest["questId"]
logger.info("Processing quest %s (%s)", quest_id, quest["name"])

reports = fetch_harvest_reports(quest_id)
logger.info("Fetched %d reports for quest %s", len(reports), quest_id)
source_ids = quest.get("sourceQuestIds") or [quest_id]
logger.info("Processing quest %s (%s), sources: %s", quest_id, quest["name"], source_ids)

all_reports: list[dict] = []
seen_ids: set[str] = set()
for sid in source_ids:
fetched = fetch_harvest_reports(sid)
logger.info("Fetched %d reports from source %s", len(fetched), sid)
for r in fetched:
rid = r.get("id", r.get("report_id", ""))
if rid:
if rid in seen_ids:
continue
seen_ids.add(rid)
all_reports.append(r)

reports = all_reports
logger.info("Total %d unique reports for quest %s", len(reports), quest_id)

if not event_items:
event_items = detect_event_items(reports)
Expand Down
Loading
Loading