Skip to content

Commit 9069ac5

Browse files
authored
Merge branch 'main' into chore/agent-docs
2 parents 773a328 + a20f07b commit 9069ac5

6 files changed

Lines changed: 184 additions & 113 deletions

File tree

src/channels/telegram.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,9 @@ export const formatSection = (section: BriefingSection): string => {
138138
if (label && value && item.url) {
139139
const escapedLabel = escapeMarkdown(`${label}: `);
140140
const formattedValue = `\`${escapeMarkdownInCode(value)}\``;
141-
const linkContent = sentiment
142-
? `${formattedValue} ${sentiment}`
143-
: formattedValue;
144-
line = `${indent}${bullet} ${timePrefix}${escapedLabel}[${linkContent}](${escapeUrlForMarkdown(item.url)})`;
145-
// Sentiment is already included in the link, don't add it again
141+
const sentimentPrefix = sentiment ? `${sentiment} ` : "";
142+
line = `${indent}${bullet} ${sentimentPrefix}${timePrefix}${escapedLabel}[${formattedValue}](${escapeUrlForMarkdown(item.url)})`;
143+
// Sentiment is already included as a prefix, don't add it again
146144
} else {
147145
// Regular items: link the whole text
148146
if (item.monospace) {
@@ -154,10 +152,13 @@ export const formatSection = (section: BriefingSection): string => {
154152
? `[${formatTextWithMonospace(text)}](${escapeUrlForMarkdown(item.url)})`
155153
: formatTextWithMonospace(text);
156154

157-
line = `${indent}${bullet} ${timePrefix}${formattedText}`;
158-
159-
if (sentiment) {
160-
line += ` ${sentiment}`;
155+
if (item.sentimentPrefix && sentiment) {
156+
line = `${indent}${bullet} ${sentiment} ${timePrefix}${formattedText}`;
157+
} else {
158+
line = `${indent}${bullet} ${timePrefix}${formattedText}`;
159+
if (sentiment) {
160+
line += ` ${sentiment}`;
161+
}
161162
}
162163
}
163164
}

src/orchestrator.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,12 @@ export const runBriefing = async (
5959

6060
if (result.status === "fulfilled") {
6161
console.log(`[orchestrator] ✓ ${source.name} succeeded`);
62-
sections.push(result.value);
62+
const value = result.value;
63+
if (Array.isArray(value)) {
64+
sections.push(...value);
65+
} else {
66+
sections.push(value);
67+
}
6368
} else {
6469
const errorMessage =
6570
result.reason instanceof Error

src/sources/appstore-rankings.ts

Lines changed: 145 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import type {
1616
DataSource,
1717
Sentiment,
1818
} from "../types";
19-
import type { DailyAppRanking, DailySnapshot, RankingsHistory } from "../utils";
19+
import type { DailySnapshot, RankingsHistory } from "../utils";
2020
import {
2121
formatDateKey,
2222
loadRankingsHistory,
@@ -289,73 +289,123 @@ export const getSentiment = (trends: AppTrends): Sentiment | undefined => {
289289
return "neutral";
290290
};
291291

292-
/** Format the position text for a single app (e.g., "#35 overall · #12 Finance"). */
293-
export const formatPositionText = (
292+
/** Format the position text for a Finance-section item (rank only, no "Finance" label). */
293+
export const formatFinancePositionText = (
294294
app: TrackedApp,
295-
ranking: DailyAppRanking,
296-
): string => {
297-
const parts: string[] = [app.name];
298-
299-
if (ranking.overall === null && ranking.finance === null) {
300-
parts.push("unranked");
301-
} else if (ranking.overall !== null && ranking.finance !== null) {
302-
parts.push(
303-
`${formatRank(ranking.overall)} overall · ${formatRank(ranking.finance)} Finance`,
304-
);
305-
} else if (ranking.finance === null) {
306-
parts.push(`${formatRank(ranking.overall)} overall`);
307-
} else {
308-
parts.push(`${formatRank(ranking.finance)} Finance`);
309-
}
295+
rank: number,
296+
): string => `${app.name}: ${formatRank(rank)}`;
310297

311-
return parts.join(": ");
312-
};
298+
/** Format the position text for a Total-section item (rank only, no "overall" label). */
299+
export const formatOverallPositionText = (
300+
app: TrackedApp,
301+
rank: number,
302+
): string => `${app.name}: ${formatRank(rank)}`;
313303

314-
/** Build BriefingItems from current rankings + historical trends, sorted by best position. */
315-
const buildBriefingItems = (
304+
/** Compute daily/weekly/monthly trends for Overall rank of a single app. */
305+
const computeOverallAppTrends = (
306+
history: RankingsHistory,
307+
currentRank: number | null,
308+
bundleId: string,
309+
referenceDate: Date,
310+
): AppTrends => ({
311+
daily: computeTrend(
312+
history,
313+
currentRank,
314+
bundleId,
315+
"overall",
316+
1,
317+
referenceDate,
318+
),
319+
weekly: computeTrend(
320+
history,
321+
currentRank,
322+
bundleId,
323+
"overall",
324+
7,
325+
referenceDate,
326+
),
327+
monthly: computeTrend(
328+
history,
329+
currentRank,
330+
bundleId,
331+
"overall",
332+
30,
333+
referenceDate,
334+
),
335+
});
336+
337+
/** Build Finance-category BriefingItems — only apps ranked in Finance, sorted by rank. */
338+
const buildFinanceItems = (
316339
snapshot: DailySnapshot,
317340
history: RankingsHistory,
318341
referenceDate: Date,
319342
): readonly BriefingItem[] => {
320-
// Create items with app reference for sorting
321-
interface ItemWithRanking {
322-
readonly app: TrackedApp;
323-
readonly ranking: DailyAppRanking;
343+
interface ItemWithRank {
344+
readonly rank: number;
324345
readonly item: BriefingItem;
325346
}
326347

327-
const itemsWithApps: ItemWithRanking[] = TRACKED_APPS.map((app) => {
348+
return TRACKED_APPS.flatMap((app): ItemWithRank[] => {
328349
const ranking = snapshot[app.bundleId] ?? { overall: null, finance: null };
350+
if (ranking.finance === null) return [];
351+
329352
const trends = computeAppTrends(
330353
history,
331354
ranking.finance,
332355
app.bundleId,
333356
referenceDate,
334357
);
358+
const trendLine = formatTrendLine(trends);
359+
const text = trendLine
360+
? `${formatFinancePositionText(app, ranking.finance)} (${trendLine})`
361+
: formatFinancePositionText(app, ranking.finance);
362+
363+
return [
364+
{
365+
rank: ranking.finance,
366+
item: { text, sentiment: getSentiment(trends), sentimentPrefix: true },
367+
},
368+
];
369+
})
370+
.sort((a, b) => a.rank - b.rank)
371+
.map(({ item }) => item);
372+
};
373+
374+
/** Build Overall/Total BriefingItems — only apps ranked in the overall top 100, sorted by rank. */
375+
const buildOverallItems = (
376+
snapshot: DailySnapshot,
377+
history: RankingsHistory,
378+
referenceDate: Date,
379+
): readonly BriefingItem[] => {
380+
interface ItemWithRank {
381+
readonly rank: number;
382+
readonly item: BriefingItem;
383+
}
384+
385+
return TRACKED_APPS.flatMap((app): ItemWithRank[] => {
386+
const ranking = snapshot[app.bundleId] ?? { overall: null, finance: null };
387+
if (ranking.overall === null) return [];
335388

389+
const trends = computeOverallAppTrends(
390+
history,
391+
ranking.overall,
392+
app.bundleId,
393+
referenceDate,
394+
);
336395
const trendLine = formatTrendLine(trends);
337-
return {
338-
app,
339-
ranking,
340-
item: {
341-
text: trendLine
342-
? `${formatPositionText(app, ranking)} (${trendLine})`
343-
: formatPositionText(app, ranking),
344-
sentiment: getSentiment(trends),
396+
const text = trendLine
397+
? `${formatOverallPositionText(app, ranking.overall)} (${trendLine})`
398+
: formatOverallPositionText(app, ranking.overall);
399+
400+
return [
401+
{
402+
rank: ranking.overall,
403+
item: { text, sentiment: getSentiment(trends), sentimentPrefix: true },
345404
},
346-
};
347-
});
348-
349-
// Sort by best (lowest) position first: Overall preferred, then finance category, unranked last
350-
return itemsWithApps
351-
.sort((a: ItemWithRanking, b: ItemWithRanking) => {
352-
const aRank =
353-
a.ranking.overall ?? a.ranking.finance ?? Number.MAX_SAFE_INTEGER;
354-
const bRank =
355-
b.ranking.overall ?? b.ranking.finance ?? Number.MAX_SAFE_INTEGER;
356-
return aRank - bRank;
357-
})
358-
.map(({ item }: ItemWithRanking) => item);
405+
];
406+
})
407+
.sort((a, b) => a.rank - b.rank)
408+
.map(({ item }) => item);
359409
};
360410

361411
// ============================================================================
@@ -367,7 +417,7 @@ export const appStoreRankingsSource: DataSource = {
367417
priority: 7,
368418
timeoutMs: 30_000,
369419

370-
fetch: async (): Promise<BriefingSection> => {
420+
fetch: async (): Promise<BriefingSection[]> => {
371421
console.log("[appstore-rankings] Starting App Store rankings fetch...");
372422

373423
// Fetch current rankings from both APIs
@@ -378,7 +428,8 @@ export const appStoreRankingsSource: DataSource = {
378428

379429
// Build items with trend data from history
380430
const today = new Date();
381-
const items = buildBriefingItems(snapshot, history, today);
431+
const financeItems = buildFinanceItems(snapshot, history, today);
432+
const overallItems = buildOverallItems(snapshot, history, today);
382433

383434
// Save today's snapshot to history (for future trend calculations).
384435
// Failure to save is non-fatal: the current briefing data is still valid,
@@ -396,11 +447,24 @@ export const appStoreRankingsSource: DataSource = {
396447
);
397448
}
398449

399-
return {
400-
title: "App Store Rankings",
401-
icon: "📱",
402-
items,
403-
};
450+
const sections: BriefingSection[] = [
451+
{
452+
title: "App Store · Finance",
453+
icon: "📱",
454+
items: financeItems,
455+
},
456+
];
457+
458+
// Only include Total section if at least one app cracked the overall top 100
459+
if (overallItems.length > 0) {
460+
sections.push({
461+
title: "App Store · Total",
462+
icon: "📱",
463+
items: overallItems,
464+
});
465+
}
466+
467+
return sections;
404468
},
405469
};
406470

@@ -412,24 +476,30 @@ export const mockAppStoreRankingsSource: DataSource = {
412476
name: "App Store Rankings",
413477
priority: 7,
414478

415-
fetch: async (): Promise<BriefingSection> => ({
416-
title: "App Store Rankings",
417-
icon: "📱",
418-
items: [
419-
{
420-
text: "Coinbase: #35 overall · #12 Finance (↑5 daily · ↑12 weekly · ↑25 monthly)",
421-
sentiment: "positive",
422-
},
423-
{
424-
text: "Polymarket: #128 Finance (↓46 daily · ↓42 weekly)",
425-
sentiment: "negative",
426-
},
427-
{
428-
text: "Kraken: unranked",
429-
},
430-
{
431-
text: "Crypto.com: unranked",
432-
},
433-
],
434-
}),
479+
fetch: async (): Promise<BriefingSection[]> => [
480+
{
481+
title: "App Store · Finance",
482+
icon: "📱",
483+
items: [
484+
{
485+
text: "Coinbase: #12 (↑5 daily · ↑12 weekly · ↑25 monthly)",
486+
sentiment: "positive",
487+
},
488+
{
489+
text: "Polymarket: #128 (↓46 daily · ↓42 weekly)",
490+
sentiment: "negative",
491+
},
492+
],
493+
},
494+
{
495+
title: "App Store · Total",
496+
icon: "📱",
497+
items: [
498+
{
499+
text: "Coinbase: #35 (↑2 daily)",
500+
sentiment: "positive",
501+
},
502+
],
503+
},
504+
],
435505
};

src/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export interface DataSource {
1010
readonly name: string;
1111
readonly priority: number; // Lower = higher in briefing
1212
readonly timeoutMs?: number; // Override default timeout for slow sources
13-
fetch(date: Date): Promise<BriefingSection>;
13+
fetch(date: Date): Promise<BriefingSection | BriefingSection[]>;
1414
}
1515

1616
export interface BriefingSection {
@@ -29,6 +29,7 @@ export interface BriefingItem {
2929
readonly calendarUrl?: string; // GCS URL for calendar ICS download
3030
readonly sentiment?: Sentiment;
3131
readonly monospace?: boolean; // Render text in fixed-width font (for alignment)
32+
readonly sentimentPrefix?: boolean; // Place sentiment emoji before the text (default: after)
3233
}
3334

3435
export type Sentiment = "positive" | "negative" | "neutral";

0 commit comments

Comments
 (0)