626 lines
25 KiB
Dart
626 lines
25 KiB
Dart
import 'package:cloud_firestore/cloud_firestore.dart'
|
|
show QueryDocumentSnapshot, Timestamp;
|
|
|
|
import '../../core/data/backend.dart';
|
|
import '../../core/data/invite_code.dart';
|
|
import '../../core/data/server_clock.dart';
|
|
import '../../core/ui/planet_25d.dart' show planetColors, planetColorsFrom, setPlanetColorMaster;
|
|
import 'package:flutter/painting.dart' show Color;
|
|
import 'home_data.dart';
|
|
import '../inside/inside_repository_firestore.dart' show insideQuestionFrom;
|
|
import '../quest/quest_data.dart' show QuestType;
|
|
import '../quest/quest_repository_firestore.dart' show openTimeAttack, questTitle, questToday;
|
|
import '../../core/i18n/app_locale.dart';
|
|
|
|
/// 홈의 실 서버 구현 — 화면 대조 원장 F1.
|
|
///
|
|
/// [SampleHomeRepository] 와 같은 인터페이스라 화면은 어느 쪽이 붙었는지 모른다.
|
|
/// 값의 출처만 표본에서 Firestore 로 바뀐다.
|
|
///
|
|
/// 성장 조건은 팬덤 공동 상태와 Stage 마스터에서 읽는다.
|
|
/// 개인 멤버 기여량/개인 행성 Stage는 공동 성장으로 대체하지 않는다.
|
|
class FirestoreHomeRepository implements HomeRepository {
|
|
FirestoreHomeRepository(this._backend);
|
|
|
|
final Backend _backend;
|
|
|
|
@override
|
|
Stream<PlanetGrowth> watchGrowth(String fandomId) {
|
|
if (fandomId.isEmpty) return const Stream.empty();
|
|
return _backend.db
|
|
.doc('fandoms/$fandomId')
|
|
.snapshots(includeMetadataChanges: true)
|
|
.where((s) => !s.metadata.isFromCache && !s.metadata.hasPendingWrites)
|
|
.asyncMap((s) => _growth(s.data() ?? const {}, fandomId));
|
|
}
|
|
|
|
@override
|
|
Stream<int> watchPopBalance() => _backend.db
|
|
.doc('users/${_backend.requireUid}/wallet/main')
|
|
.snapshots()
|
|
.map(
|
|
(s) => (s.data() ?? const <String, dynamic>{}).intOr('activityBalance'),
|
|
);
|
|
|
|
@override
|
|
Future<({String fandomId, bool primaryChanged})> createNebula(
|
|
String name,
|
|
) async {
|
|
final data = await _backend.call('fnNebulaCreate', {'name': name});
|
|
final fandomId = data['fandomId'];
|
|
// ID 없이 성공으로 돌아오면 화면이 빈 ID 로 성운 상세를 연다 — 실패로 다룬다.
|
|
if (fandomId is! String || fandomId.isEmpty) {
|
|
throw BackendException('E-SYS-01', appL10n.nebulaCreateFailed);
|
|
}
|
|
return (
|
|
fandomId: fandomId,
|
|
primaryChanged: data['primaryChanged'] != false,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> createPlanet({
|
|
required String fandomId,
|
|
required String planetType,
|
|
required String colorKey,
|
|
}) => _backend.call('fnPlanetCreate', {
|
|
'fandomId': fandomId,
|
|
'planetType': planetType,
|
|
'colorKey': colorKey,
|
|
});
|
|
|
|
@override
|
|
Future<List<(String, Color)>> planetColorOptions() async {
|
|
try {
|
|
final master = planetColorsFrom(
|
|
(await _backend.db.doc('config/planetColors').get()).data(),
|
|
);
|
|
setPlanetColorMaster(master.all);
|
|
return master.visible;
|
|
} catch (_) {
|
|
return planetColors;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<NebulaTargets> nebulaTargets() async {
|
|
try {
|
|
return nebulaTargetsFrom(
|
|
(await _backend.db.doc('config/policy').get()).data(),
|
|
);
|
|
} catch (_) {
|
|
return defaultNebulaTargets;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<HomeSnapshot> load() async {
|
|
final uid = _backend.requireUid;
|
|
// 공유 링크 `?ref=` 용 — 기다리지 않는다.
|
|
InviteCode.ensure(_backend);
|
|
// 컬러 마스터 — HOME · GALAXY 행성이 발주사가 더한 색도 그리도록 먼저 읽어 둔다. 기다리지 않는다.
|
|
planetColorOptions();
|
|
final db = _backend.db;
|
|
|
|
final userSnap = await db.doc('users/$uid').get();
|
|
final user = userSnap.data() ?? const <String, dynamic>{};
|
|
final fandomId = user.strOr('fandomId');
|
|
|
|
// 홈 한 화면에 필요한 것을 병렬로 띄운다. 순차로 기다리면 왕복이 그대로 쌓인다.
|
|
final walletF = db.doc('users/$uid/wallet/main').get();
|
|
final fandomF = fandomId.isEmpty ? null : db.doc('fandoms/$fandomId').get();
|
|
// TODAY'S QUEST — 설명 프레임 `2097:16397` 「관리자 퀘스트 최신순 최대 3개」. 사용자 생성
|
|
// 퀘스트(`authorUid` 있음)는 HOME 에 띄우지 않는다. 정렬 · 거르기는 아래에서 한다.
|
|
// ponytail: 활성 30건 안에서만 고른다. (active, createdAt) 색인을 배포하면 질의로 옮긴다.
|
|
final questsF = db
|
|
.collection('quests')
|
|
.where('active', isEqualTo: true)
|
|
.limit(30)
|
|
.get();
|
|
// 긴급 돌발 타임어택(FR-QST-06) — 내 팬덤의 **열린 창**만. 위 목록과 따로 읽어 맨 앞에
|
|
// 둔다. 위 질의는 정렬이 없어 5 건 안에 든다는 보장이 없다. 색인 `firestore.indexes.json`.
|
|
// 질의는 단말 시계 기준 1시간 여유로 넓게 받고, 마감 판정은 아래에서 서버 시각으로 한다.
|
|
// ponytail: 단말 시계가 1시간 넘게 빠르면 열린 창을 놓친다. 그때는 여유를 늘린다.
|
|
final timeAttacksF = fandomId.isEmpty
|
|
? null
|
|
: db
|
|
.collection('quests')
|
|
.where('timeAttack.fandomId', isEqualTo: fandomId)
|
|
.where(
|
|
'timeAttack.activeTo',
|
|
isGreaterThan: Timestamp.fromDate(
|
|
DateTime.now().subtract(const Duration(hours: 1)),
|
|
),
|
|
)
|
|
.limit(10)
|
|
.get();
|
|
final progressF = db.collection('users/$uid/questProgress').get();
|
|
// 출석 보상은 서버가 `config/policy.attendPop` 으로 지급한다. 같은 곳을 읽는다.
|
|
final policyF = db.doc('config/policy').get();
|
|
// 브리핑은 **게시된 당일분만** 읽는다 — 정책 원장 B-47 「당일 승인된 콘텐츠만
|
|
// 노출 · 미승인 Draft 노출 0건 · 전일 콘텐츠 자동 재사용 없음」. 세 조건을
|
|
// 질의에 두어 화면이 걸러 낼 것이 남지 않게 한다. 앱에서 거르면 초안이 기기에
|
|
// 한 번 내려온 뒤에 지워지는 것이라 「노출 0건」이 아니다.
|
|
final briefingsF = db
|
|
.collection('briefings')
|
|
.where('fandomId', isEqualTo: fandomId)
|
|
.where('status', isEqualTo: 'published')
|
|
.where('dayKey', isEqualTo: kstDayKey())
|
|
// **섹션별로 rank 를 매기므로 정렬 키가 둘이다.** `rank` 만으로 자르면
|
|
// News 3 건 + SNS 1 건일 때 rank 3 두 개가 경쟁해 News 셋째 줄이 잘린다.
|
|
// 상한 6 은 두 구획 각 3 줄 기준이다(Figma 홈의 3 줄 × 구획 2).
|
|
.orderBy('section')
|
|
.orderBy('rank')
|
|
.limit(6)
|
|
.get();
|
|
// HOT ARCHIVE 캐러셀 — Figma 홈 주석 `2097:16415` 「최대 5개 소팅(목표 달성 임박 건 > 실시간
|
|
// 기여 급증 건 > 신규 등록 건 순)」 · 별첨 B 「HOT ARCHIVE」. 정렬은 아래 [hotArchiveOrder].
|
|
// ponytail: 모금 중 30건 안에서 고른다. 모금 중 캠페인이 이보다 많아지면 서버 집계 필드로 옮긴다.
|
|
final campaignF = db
|
|
.collection('campaigns')
|
|
.where('status', isEqualTo: 'funding')
|
|
.limit(30)
|
|
.get();
|
|
// 팬덤행성 인큐베이터 — 명세 §2.114-4 「기여율 높은 순 최대 6개」. 성운(Stage0)만.
|
|
// ponytail: 성운 30건 안에서 고른다. (stage, growthPop) 색인을 배포하면 질의로 옮긴다.
|
|
final nebulasF = db
|
|
.collection('fandoms')
|
|
.where('stage', isEqualTo: 0)
|
|
.limit(30)
|
|
.get();
|
|
final blocksF = db.collection('users/$uid/blocks').get();
|
|
// Hero Gift Catalog(원장 B-85) — 판매 중인 것만. 소속 행성이 없는 사용자는 시트가
|
|
// 결제 창을 열기 전에 막는다(`fn-iap-verify` 도 거부한다).
|
|
final itemsF = db
|
|
.collection('heroGiftProducts')
|
|
.where('active', isEqualTo: true)
|
|
.get();
|
|
// 인기 K-INSIDE — 기능명세서 v1.4 §2.114-7 「최대 3개 소팅(미해결 + 고액 포인트 순)」.
|
|
// 미해결(`open`) 먼저, 그 안에서 현상금 큰 순. 정렬은 아래 [popularInsides].
|
|
// ponytail: 현상금 상위 20건 안에서만 고른다. (status, bountyPop) 색인을 배포하면 질의로 옮긴다.
|
|
final insidesF = db
|
|
.collection('questions')
|
|
.orderBy('bountyPop', descending: true)
|
|
.limit(20)
|
|
.get();
|
|
|
|
// HOME 주간 정보(A-37) — 주간 공식 Snapshot 최신 · 직전 두 개(순위 · 변동 · 성장 기준선).
|
|
final snapshotsF = fandomId.isEmpty
|
|
? null
|
|
: db
|
|
.collection('rankingSnapshots')
|
|
.orderBy('createdAt', descending: true)
|
|
.limit(2)
|
|
.get()
|
|
.then((q) => [for (final d in q.docs) d.data()])
|
|
.catchError((Object _) => <Map<String, dynamic>>[]);
|
|
|
|
final wallet = (await walletF).data() ?? const <String, dynamic>{};
|
|
final fandom = fandomF == null
|
|
? const <String, dynamic>{}
|
|
: ((await fandomF).data() ?? const <String, dynamic>{});
|
|
final timeAttacks = [
|
|
for (final doc in (await timeAttacksF)?.docs ?? const [])
|
|
if (doc.data()['active'] != false) doc,
|
|
];
|
|
if (timeAttacks.isNotEmpty) await ServerClock.ensure(_backend);
|
|
final open = [
|
|
for (final d in timeAttacks)
|
|
if (openTimeAttack(d.data()) != null) d,
|
|
];
|
|
final openIds = {for (final d in open) d.id};
|
|
final quests = [
|
|
...open,
|
|
// 회차가 열린 Quest 는 위에서 맨 앞에 넣었다. 두 번 넣지 않는다.
|
|
...adminQuestsLatest([
|
|
for (final d in (await questsF).docs)
|
|
if (!openIds.contains(d.id)) d,
|
|
], (d) => d.data()),
|
|
].take(3).toList();
|
|
final progress = (await progressF).docs;
|
|
final briefings = (await briefingsF).docs;
|
|
final blocked = {for (final d in (await blocksF).docs) d.id};
|
|
final policy = (await policyF).data() ?? const <String, dynamic>{};
|
|
final campaigns = await _hotArchive(
|
|
(await campaignF).docs
|
|
.where((d) => !blocked.contains(d.data()['creatorUid']))
|
|
.toList(),
|
|
policy,
|
|
);
|
|
final insideDocs = {
|
|
for (final doc in (await insidesF).docs) doc.data(): doc.id,
|
|
};
|
|
final insides = [
|
|
for (final data in popularInsides(insideDocs.keys.toList()))
|
|
(insideDocs[data]!, data),
|
|
];
|
|
|
|
final progressById = {for (final doc in progress) doc.id: doc.data()};
|
|
|
|
HomeWeekly? weekly;
|
|
if (snapshotsF != null) {
|
|
final snapshots = await snapshotsF;
|
|
final since = snapshots.firstOrNull?['createdAt'] is Timestamp
|
|
? (snapshots.first['createdAt'] as Timestamp).toDate()
|
|
: kstWeekStart(DateTime.now());
|
|
weekly = HomeWeekly.from(
|
|
fandomId: fandomId,
|
|
snapshots: snapshots,
|
|
fandom: fandom,
|
|
myWeekPop: await _myWeekGrowth(uid, fandomId, since),
|
|
wallet: wallet,
|
|
);
|
|
}
|
|
|
|
return HomeSnapshot(
|
|
weekly: weekly,
|
|
popBalance: wallet.intOr('activityBalance'),
|
|
fandomId: fandomId,
|
|
liveTicker: fandom.strOr('liveTicker').isEmpty
|
|
? null
|
|
: fandom.strOr('liveTicker'),
|
|
growth: await _growth(fandom, fandomId),
|
|
quests: [
|
|
for (final doc in quests)
|
|
_quest(doc.id, doc.data(), progressById[doc.id], policy),
|
|
],
|
|
briefings: [for (final doc in briefings) ?_briefing(doc.data())],
|
|
campaigns: [for (final d in campaigns) _campaign(d.id, d.data())],
|
|
insides: [
|
|
for (final (id, data) in insides) insideQuestionFrom(id, data, uid),
|
|
],
|
|
nebulas: nebulaPicks([
|
|
for (final d in (await nebulasF).docs) (d.id, d.data()),
|
|
], nebulaTargetsFrom(policy).pop),
|
|
items: [for (final doc in (await itemsF).docs) _item(doc.id, doc.data())],
|
|
);
|
|
}
|
|
|
|
/// 문서 ID 가 스토어 상품 ID 다. 수치는 Admin 저장값 그대로(B-84 · 환율 재계산 없음).
|
|
ItemProduct _item(String productId, Map<String, dynamic> data) => ItemProduct(
|
|
productId: productId,
|
|
name: data.strOr('name', productId),
|
|
description: data.strOr('description'),
|
|
dropPop: data.intOr('dropPop'),
|
|
buyerPop: data.intOr('buyerPop'),
|
|
priceWon: data.intOr('priceWon'),
|
|
// 구매 즉시 Drop 이 열려 보유 재고가 생기지 않는다.
|
|
owned: 0,
|
|
);
|
|
|
|
/// 브리핑 한 줄 — 정책 원장 B-75 5 요소가 전건 있어야 그린다.
|
|
///
|
|
/// **하나라도 비면 그 줄을 버린다.** 서버가 수신 단계에서 이미 거부하지만
|
|
/// (`fn-briefing-ingest`), Admin 수동 편집이나 예전 스키마 문서가 섞일 수 있다.
|
|
/// 여기서 통과시키면 출처가 빈 카드가 화면에 뜨고 그것이 B-75 위반이다.
|
|
/// 섹션이 News·SNS 밖이면 정책이 정한 구획이 아니므로 역시 버린다.
|
|
BriefingItem? _briefing(Map<String, dynamic> data) {
|
|
final section = BriefingSection.of(data.strOr('section'));
|
|
final headline = data.strOr('headline');
|
|
final sourceName = data.strOr('sourceName');
|
|
final sourceUrl = data.strOr('sourceUrl');
|
|
final articleUrl = data.strOr('articleUrl');
|
|
final aiSummary = data.strOr('aiSummary');
|
|
final sourceAt = data['sourceAt'];
|
|
if (section == null ||
|
|
headline.isEmpty ||
|
|
sourceName.isEmpty ||
|
|
sourceUrl.isEmpty ||
|
|
articleUrl.isEmpty ||
|
|
aiSummary.isEmpty ||
|
|
sourceAt == null) {
|
|
return null;
|
|
}
|
|
return BriefingItem(
|
|
rank: data.intOr('rank', 1),
|
|
headline: headline,
|
|
section: section,
|
|
sourceName: sourceName,
|
|
sourceAt: data.dateOr('sourceAt', DateTime.now()),
|
|
sourceUrl: sourceUrl,
|
|
articleUrl: articleUrl,
|
|
aiSummary: aiSummary,
|
|
sourceKind: switch (data.strOr('sourceKind')) {
|
|
final k when k == 'official' || k == 'fandom' => k,
|
|
_ => null,
|
|
},
|
|
);
|
|
}
|
|
|
|
/// 이번 주 내가 [fandomId] 행성 성장에 보탠 Growth POP — 원장과 같은 트랜잭션에서 쓰는
|
|
/// `users/{uid}/growthEvents`(역분개는 음수 행)의 [since] 이후 합. 읽지 못하면 null.
|
|
// ponytail: 한 주치 성장 행을 전부 읽어 합한다. 한 사용자가 주에 수천 건을 넘기면 서버 집계 필드로 옮긴다.
|
|
Future<num?> _myWeekGrowth(String uid, String fandomId, DateTime since) async {
|
|
try {
|
|
final q = await _backend.db
|
|
.collection('users/$uid/growthEvents')
|
|
.where('createdAt', isGreaterThanOrEqualTo: Timestamp.fromDate(since))
|
|
.get();
|
|
num sum = 0;
|
|
for (final d in q.docs) {
|
|
final data = d.data();
|
|
final amount = data['amount'];
|
|
if (data['fandomIdAtPayout'] == fandomId && amount is num) sum += amount;
|
|
}
|
|
return sum;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<PlanetGrowth> _growth(
|
|
Map<String, dynamic> fandom,
|
|
String fandomId,
|
|
) async {
|
|
final stage = switch (fandom['stage']) {
|
|
int n when n >= 0 && n <= 6 => n,
|
|
_ => null,
|
|
};
|
|
// Stage0의 탄생은 별도의 멤버/POP 조건이다. Stage1 마스터의 0으로 승급을 추정하지 않는다.
|
|
final next = stage != null && stage > 0 && stage < 6
|
|
? (await _backend.db.doc('planetStages/${stage + 1}').get()).data()
|
|
: null;
|
|
int? validInt(dynamic value) => value is int && value >= 0 ? value : null;
|
|
num? validNumber(dynamic value) =>
|
|
value is num && value.isFinite && value >= 0 ? value : null;
|
|
// 정식 행성은 서버가 수치표 v1.4 대로 적은 레벨업 POP 진행도(생성 기준점 이후 · ZAP 가중치 반영)와
|
|
// 다음 Stage 진입 누계를 쓴다. 아직 판정 전인 행성만 구 마스터(`planetStages`)로 그린다.
|
|
final leveled = stage != null && stage > 0 && fandom['levelPop'] is num;
|
|
final current = leveled
|
|
? validNumber(fandom['levelPop'])
|
|
: validNumber(fandom['growthPop']);
|
|
final required = leveled
|
|
? validInt(fandom['nextStagePop'])
|
|
: validInt(next?['requiredPop']);
|
|
// 성운(Stage0)의 전환 목표는 서버와 같은 `config/policy` 키로 읽는다.
|
|
final policy = stage == 0
|
|
? (await _backend.db.doc('config/policy').get()).data()
|
|
: null;
|
|
final targets = stage == 0 ? nebulaTargetsFrom(policy) : null;
|
|
final goal = targets?.pop ?? required;
|
|
// Q-PN-02 — 기간이 지난 성운은 운영 설정이 막지 않는 한 조건만 채우면 탄생한다(서버와 같은 판정).
|
|
final deadline = fandom['nebulaDeadline'];
|
|
final goalPassed =
|
|
stage == 0 &&
|
|
deadline is Timestamp &&
|
|
ServerClock.now().isAfter(deadline.toDate()) &&
|
|
policy?['nebulaConversionDeadlineEnforced'] != true;
|
|
return PlanetGrowth(
|
|
stage: stage,
|
|
fandomName: fandom.strOr('name', fandomId),
|
|
// 기존 데이터에 이메일 형태가 남아 있어도 가린다.
|
|
contributorName: fandom.containsKey('lastContributorName')
|
|
? publicName(fandom['lastContributorName'])
|
|
: '',
|
|
contributedPop: fandom.intOr('lastContributedPop'),
|
|
growthPercent: current != null && goal != null && goal > 0
|
|
? ((current / goal) * 100).clamp(0, 100).round()
|
|
: null,
|
|
currentPop: current,
|
|
requiredPop: goal,
|
|
currentSfpi: validNumber(fandom['sfpi']),
|
|
members: validInt(fandom['memberCount']),
|
|
requiredMembers: targets?.members,
|
|
// Q-PN-01 A안 — 탄생은 창립자만.
|
|
isFounder:
|
|
fandom['founderUid'] != null && fandom['founderUid'] == _backend.uid,
|
|
requiredSfpi: leveled
|
|
? validNumber(fandom['nextStageSfpi'])
|
|
: validNumber(next?['requiredSfpi']),
|
|
nebulaGoalPassed: goalPassed,
|
|
);
|
|
}
|
|
|
|
QuestSummary _quest(
|
|
String id,
|
|
Map<String, dynamic> quest,
|
|
Map<String, dynamic>? progress,
|
|
Map<String, dynamic> policy,
|
|
) {
|
|
// 퀘스트 목록 · 상세와 같은 판정(`questToday`) — 오늘(KST) 행만 센다.
|
|
final (:count, :done) = questToday(
|
|
QuestType.parse(quest.strOr('type')),
|
|
quest,
|
|
progress,
|
|
policy,
|
|
);
|
|
final started = count > 0;
|
|
return QuestSummary(
|
|
title: questTitle(quest),
|
|
type: QuestType.parse(quest.strOr('type')),
|
|
category: QuestType.parse(quest.strOr('type')).label,
|
|
rewardPop: openTimeAttack(quest)?.rewardPop ??
|
|
QuestType.parse(quest.strOr('type')).rewardFrom(quest, policy),
|
|
state: done
|
|
? QuestState.done
|
|
: started
|
|
? QuestState.claimable
|
|
: QuestState.inProgress,
|
|
progressLabel: quest.strOr('progressLabel').isEmpty
|
|
? null
|
|
: quest.strOr('progressLabel'),
|
|
id: id,
|
|
endsAt: openTimeAttack(quest)?.endsAt,
|
|
);
|
|
}
|
|
|
|
/// HOT ARCHIVE 5건 — 「목표 달성 임박 > 실시간 기여 급증 > 신규 등록」.
|
|
///
|
|
/// 급증은 최근 [HotArchiveRule.surgeWindow] 안의 후원 합(`campaigns/{id}/donations`)으로 잰다.
|
|
/// 임박 묶음에 든 캠페인은 급증을 셀 필요가 없어 읽지 않는다.
|
|
Future<List<QueryDocumentSnapshot<Map<String, dynamic>>>> _hotArchive(
|
|
List<QueryDocumentSnapshot<Map<String, dynamic>>> docs,
|
|
Map<String, dynamic> policy,
|
|
) async {
|
|
final rule = HotArchiveRule.from(policy);
|
|
final since = Timestamp.fromDate(DateTime.now().subtract(rule.surgeWindow));
|
|
final candidates = await Future.wait([
|
|
for (final d in docs)
|
|
() async {
|
|
final goal = d.data().intOr('targetPop', d.data().intOr('goalPop'));
|
|
final raised = d.data().intOr('raisedPop');
|
|
final percent = goal == 0 ? 0.0 : raised * 100 / goal;
|
|
var recent = 0;
|
|
if (percent < rule.imminentPercent) {
|
|
final snap = await d.reference
|
|
.collection('donations')
|
|
.where('createdAt', isGreaterThanOrEqualTo: since)
|
|
.get();
|
|
for (final x in snap.docs) {
|
|
recent += x.data().intOr('amount');
|
|
}
|
|
}
|
|
return (
|
|
id: d.id,
|
|
percent: percent,
|
|
recentPop: recent,
|
|
createdAt: d.data().dateOr(
|
|
'createdAt',
|
|
DateTime.fromMillisecondsSinceEpoch(0),
|
|
),
|
|
);
|
|
}(),
|
|
]);
|
|
final byId = {for (final d in docs) d.id: d};
|
|
return [for (final id in hotArchiveOrder(candidates, rule)) byId[id]!];
|
|
}
|
|
|
|
CampaignHighlight _campaign(String id, Map<String, dynamic> data) {
|
|
// 규칙의 스키마 이름(`targetPop` · `creatorName` · `summary`)을 먼저 읽는다.
|
|
final goal = data.intOr('targetPop', data.intOr('goalPop'));
|
|
final raised = data.intOr('raisedPop');
|
|
return CampaignHighlight(
|
|
id: id,
|
|
authorName: publicName(data['creatorName'] ?? data['authorName']),
|
|
achievedPercent: goal == 0 ? 0 : ((raised / goal) * 100).round(),
|
|
title: data.strOr('title'),
|
|
description: data.strOr('summary', data.strOr('description')),
|
|
supporters: data.intOr('donorCount'),
|
|
comments: data.intOr('commentCount'),
|
|
likes: data.intOr('likeCount'),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// HOT ARCHIVE 정렬의 기준값 — `config/policy.hotArchive`.
|
|
///
|
|
/// **임시값 — 회신 대기.** 「목표 달성 임박」의 달성률 기준과 「실시간 기여 급증」의 측정 구간은
|
|
/// Figma 주석 `2097:16415` · 별첨 B 어디에도 수치가 없다(해석 확인 요청 영역D). 운영값 자리에 두고
|
|
/// 회신을 받으면 값만 바꾼다. 기본값 80% · 60분은 회신 전 임시값이다.
|
|
class HotArchiveRule {
|
|
const HotArchiveRule({
|
|
this.imminentPercent = 80,
|
|
this.surgeWindow = const Duration(minutes: 60),
|
|
});
|
|
|
|
factory HotArchiveRule.from(Map<String, dynamic>? policy) {
|
|
final raw = policy?['hotArchive'];
|
|
if (raw is! Map) return const HotArchiveRule();
|
|
final percent = raw['imminentPercent'];
|
|
final minutes = raw['surgeWindowMinutes'];
|
|
return HotArchiveRule(
|
|
imminentPercent: percent is num && percent > 0 ? percent.toDouble() : 80,
|
|
surgeWindow: Duration(
|
|
minutes: minutes is int && minutes > 0 ? minutes : 60,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 이 달성률(%) 이상이면 「목표 달성 임박」.
|
|
final double imminentPercent;
|
|
|
|
/// 「실시간 기여 급증」을 재는 최근 구간.
|
|
final Duration surgeWindow;
|
|
}
|
|
|
|
/// HOT ARCHIVE 최대 5건의 순서 — 「목표 달성 임박 건 > 실시간 기여 급증 건 > 신규 등록 건」.
|
|
///
|
|
/// 임박(달성률 ≥ 기준)은 달성률 높은 순, 급증(최근 구간 후원 > 0)은 최근 후원 많은 순,
|
|
/// 나머지는 신규 등록 순이다.
|
|
List<String> hotArchiveOrder(
|
|
List<({String id, double percent, int recentPop, DateTime createdAt})> all,
|
|
HotArchiveRule rule,
|
|
) {
|
|
final imminent = all.where((c) => c.percent >= rule.imminentPercent).toList()
|
|
..sort((a, b) => b.percent.compareTo(a.percent));
|
|
final surging =
|
|
all
|
|
.where((c) => c.percent < rule.imminentPercent && c.recentPop > 0)
|
|
.toList()
|
|
..sort((a, b) => b.recentPop.compareTo(a.recentPop));
|
|
final rest =
|
|
all
|
|
.where((c) => c.percent < rule.imminentPercent && c.recentPop <= 0)
|
|
.toList()
|
|
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
|
return [
|
|
for (final c in [...imminent, ...surging, ...rest].take(5)) c.id,
|
|
];
|
|
}
|
|
|
|
/// 인기 K-INSIDE 3건 — 미해결(`status == open`) 먼저, 같은 묶음 안에서는 현상금 큰 순.
|
|
/// 입력이 현상금 내림차순이라 미해결 묶음만 앞으로 옮기면 된다.
|
|
List<Map<String, dynamic>> popularInsides(
|
|
List<Map<String, dynamic>> byBounty,
|
|
) => [
|
|
...byBounty.where((q) => q['status'] == 'open'),
|
|
...byBounty.where((q) => q['status'] != 'open'),
|
|
].take(3).toList();
|
|
|
|
/// 오늘 날짜(KST `YYYY-MM-DD`) — 서버 `briefings.dayKey` 와 같은 규약이다.
|
|
///
|
|
/// 단말 시간대로 끊으면 해외 사용자의 「오늘」이 서버의 `dayKey` 와 달라 브리핑이
|
|
/// 안 보인다. 브리핑은 한국 시간 기준 하루다.
|
|
String kstDayKey([DateTime? at]) => (at ?? DateTime.now().toUtc())
|
|
.toUtc()
|
|
.add(const Duration(hours: 9))
|
|
.toIso8601String()
|
|
.substring(0, 10);
|
|
|
|
/// 이번 주 시작 — KST 월요일 00:00(서버 `weekKeyOf` 와 같은 주 경계). 주간 Snapshot 이 아직 없을 때만 쓴다.
|
|
DateTime kstWeekStart(DateTime at) {
|
|
final kst = at.toUtc().add(const Duration(hours: 9));
|
|
final monday = DateTime.utc(kst.year, kst.month, kst.day - (kst.weekday - 1));
|
|
return monday.subtract(const Duration(hours: 9));
|
|
}
|
|
|
|
/// HOME TODAY'S QUEST 의 관리자 퀘스트 — `authorUid` 가 없는 것만, `createdAt` 최신순
|
|
/// (설명 프레임 `2097:16397` 「관리자 퀘스트 - 최신순으로 최대 3개 노출」).
|
|
List<T> adminQuestsLatest<T>(
|
|
Iterable<T> docs,
|
|
Map<String, dynamic> Function(T) data,
|
|
) {
|
|
DateTime at(T d) => switch (data(d)['createdAt']) {
|
|
Timestamp t => t.toDate(),
|
|
DateTime t => t,
|
|
_ => DateTime.fromMillisecondsSinceEpoch(0),
|
|
};
|
|
return [
|
|
for (final d in docs)
|
|
if ((data(d)['authorUid'] ?? '').toString().isEmpty) d,
|
|
]..sort((a, b) => at(b).compareTo(at(a)));
|
|
}
|
|
|
|
/// 인큐베이터 카드 — 기여율(누적 POP ÷ 성운 전환 목표 POP) 높은 순 최대 6개.
|
|
List<NebulaPick> nebulaPicks(
|
|
Iterable<(String, Map<String, dynamic>)> docs,
|
|
int goalPop,
|
|
) {
|
|
final picks = [
|
|
for (final (id, d) in docs)
|
|
NebulaPick(
|
|
id: id,
|
|
name: d.strOr('name', id),
|
|
members: d.intOr('memberCount'),
|
|
percent: goalPop <= 0
|
|
? 0
|
|
: ((d['growthPop'] is num ? d['growthPop'] as num : 0) /
|
|
goalPop *
|
|
100)
|
|
.clamp(0, 100)
|
|
.floor(),
|
|
),
|
|
]..sort((a, b) => b.percent.compareTo(a.percent));
|
|
return picks.take(6).toList();
|
|
}
|