Compare commits

...

1 Commits

Author SHA1 Message Date
Clément Pasteau
f7ff6fee1b Wip earnings in gamedashboard.
Data is not accurate, needs modification in the services side
2024-12-18 11:20:45 +01:00
10 changed files with 637 additions and 226 deletions

View File

@@ -57,10 +57,12 @@ const CustomTooltip = ({
payload, payload,
label, label,
customStyle, customStyle,
decimals = 2,
}: {| }: {|
payload: ?Array<any>, payload: ?Array<any>,
label: string, label: string,
customStyle: Object, customStyle: Object,
decimals?: number,
|}) => |}) =>
payload ? ( payload ? (
<Paper style={customStyle} background="light"> <Paper style={customStyle} background="light">
@@ -79,7 +81,9 @@ const CustomTooltip = ({
index index
) => ( ) => (
<Text noMargin key={index}>{`${name}: ${ <Text noMargin key={index}>{`${name}: ${
Number.isInteger(value) ? value.toString() : value.toFixed(2) Number.isInteger(value)
? value.toString()
: value.toFixed(decimals)
}${unit ? ` ${unit}` : ''}`}</Text> }${unit ? ` ${unit}` : ''}`}</Text>
) )
)} )}
@@ -397,3 +401,53 @@ export const PlayersDurationPerDayChart = ({
</ResponsiveContainer> </ResponsiveContainer>
); );
}; };
export const GameAdEarningsChart = ({
i18n,
chartData,
height,
fontSize,
}: ChartProps) => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const styles = getChartsStyleFromTheme(gdevelopTheme);
return (
<ResponsiveContainer width={chartWidth} height={height} debounce={1}>
<AreaChart data={chartData.adsEarnings} margin={chartMargins}>
<Area
name={i18n._(t`USD`)}
type="monotone"
dataKey="accumulatedEarningsInUSDs"
stroke={gdevelopTheme.chart.dataColor1}
fill={gdevelopTheme.chart.dataColor1}
fillOpacity={0.25}
yAxisId={0}
/>
<CartesianGrid
stroke={gdevelopTheme.chart.gridColor}
strokeDasharray="3 3"
/>
<XAxis
dataKey="date"
stroke={gdevelopTheme.chart.textColor}
style={styles.tickLabel}
tick={{ fontSize: fontSize === 'small' ? 12 : 16 }}
/>
<YAxis
dataKey="accumulatedEarningsInUSDs"
stroke={gdevelopTheme.chart.textColor}
style={styles.tickLabel}
tick={{ fontSize: fontSize === 'small' ? 12 : 16 }}
/>
<Tooltip
content={props =>
CustomTooltip({
...props,
customStyle: styles.tooltipContent,
})
}
/>
</AreaChart>
</ResponsiveContainer>
);
};

View File

@@ -7,13 +7,31 @@ import {
addDays, addDays,
} from 'date-fns'; } from 'date-fns';
import { type GameMetrics } from '../Utils/GDevelopServices/Analytics'; import { type GameMetrics } from '../Utils/GDevelopServices/Analytics';
import {
type GameAdEarning,
type Usage,
} from '../Utils/GDevelopServices/Usage';
export type MergedGameMetrics = GameMetrics & { export type MergedGameMetrics = {|
...GameMetrics,
/** /**
* The start date is not defined when only one day is merged. * The start date is not defined when only one day is merged.
*/ */
startDate: string | null, startDate: string | null,
}; |};
export type MergedGameAdEarnings = {|
...GameAdEarning,
/**
* The start date is not defined when only one day is merged.
*/
startDate: string | null,
|};
export type CashOuts = {|
date: string,
amountInCredits: number,
|};
/** /**
* It's divisible by 7. * It's divisible by 7.
@@ -46,6 +64,8 @@ export type ChartData = {|
playersPercent: number, playersPercent: number,
durationInMinutes: number, durationInMinutes: number,
|}, |},
totalEarningsInUSDs: number,
|}, |},
/** /**
* Metrics for each day of a month or each week of a year. * Metrics for each day of a month or each week of a year.
@@ -92,6 +112,14 @@ export type ChartData = {|
* A funnel of the remaining players after a given played duration. * A funnel of the remaining players after a given played duration.
*/ */
overPlayedDuration: {| duration: number, playersCount: number |}[], overPlayedDuration: {| duration: number, playersCount: number |}[],
/**
* Accumulated earnings from ads each day.
*/
adsEarnings: {|
date: string,
accumulatedEarningsInCredits: number,
accumulatedEarningsInUSDs: number,
|}[],
|}; |};
const emptyChartData: ChartData = { const emptyChartData: ChartData = {
@@ -110,9 +138,11 @@ const emptyChartData: ChartData = {
playersPercent: 0, playersPercent: 0,
durationInMinutes: 0, durationInMinutes: 0,
}, },
totalEarningsInUSDs: 0,
}, },
overTime: [], overTime: [],
overPlayedDuration: [], overPlayedDuration: [],
adsEarnings: [],
}; };
const durationIndexes: { [string]: number } = { const durationIndexes: { [string]: number } = {
@@ -124,9 +154,9 @@ const durationIndexes: { [string]: number } = {
}; };
export const durationValues = [1, 3, 5, 10, 15]; export const durationValues = [1, 3, 5, 10, 15];
const createZeroesMetric = (date: Date): GameMetrics => { const createZeroesGameMetric = (date: Date): GameMetrics => {
return { return {
date: formatISO(date), date: formatISO(date, { representation: 'date' }),
sessions: { sessions: {
d0Sessions: 0, d0Sessions: 0,
@@ -150,10 +180,15 @@ const createZeroesMetric = (date: Date): GameMetrics => {
* @param gameMetrics concise game metrics from the backend (today first) * @param gameMetrics concise game metrics from the backend (today first)
* @returns game metrics with a metric for each 364 past days (today first). * @returns game metrics with a metric for each 364 past days (today first).
*/ */
const fillMissingDays = ( const fillMissingGameMetricsDays = ({
gameMetrics,
todayDate,
totalDays,
}: {
gameMetrics: Array<GameMetrics>, gameMetrics: Array<GameMetrics>,
todayDate: Date todayDate: Date,
): Array<GameMetrics> => { totalDays: number,
}): Array<GameMetrics> => {
const filledGameMetrics = []; const filledGameMetrics = [];
// TODO In some timezones, it might start the wrong day. // TODO In some timezones, it might start the wrong day.
let previousMetricDate = addDays(todayDate, 1); let previousMetricDate = addDays(todayDate, 1);
@@ -164,21 +199,77 @@ const fillMissingDays = (
differenceInCalendarDays(parseISO(metric.date), previousMetricDate) < -1 differenceInCalendarDays(parseISO(metric.date), previousMetricDate) < -1
) { ) {
const addedMetricDate = subDays(previousMetricDate, 1); const addedMetricDate = subDays(previousMetricDate, 1);
filledGameMetrics.push(createZeroesMetric(addedMetricDate)); filledGameMetrics.push(createZeroesGameMetric(addedMetricDate));
previousMetricDate = addedMetricDate; previousMetricDate = addedMetricDate;
} }
filledGameMetrics.push(metric); filledGameMetrics.push(metric);
previousMetricDate = metricDate; previousMetricDate = metricDate;
} }
// Fill to one year // Fill to total days
while (filledGameMetrics.length < daysShownForYear) { while (filledGameMetrics.length < totalDays) {
const addedMetricDate = subDays(previousMetricDate, 1); const addedMetricDate = subDays(previousMetricDate, 1);
filledGameMetrics.push(createZeroesMetric(addedMetricDate)); filledGameMetrics.push(createZeroesGameMetric(addedMetricDate));
previousMetricDate = addedMetricDate; previousMetricDate = addedMetricDate;
} }
return filledGameMetrics; return filledGameMetrics;
}; };
const createZeroesGameAdEarning = (
gameId: string,
date: Date
): GameAdEarning => {
return {
gameId,
date: formatISO(date, { representation: 'date' }),
adEarningsInCredits: 0,
adEarningsInMilliUSDs: 0,
updatedAt: date.getTime(),
};
};
const fillMissingGameAdEarningsDays = ({
gameAdEarnings,
todayDate,
totalDays,
gameId,
}: {
gameAdEarnings: Array<GameAdEarning>,
todayDate: Date,
totalDays: number,
gameId: string,
}): Array<GameAdEarning> => {
const filledGameAdEarnings = [];
let previousEarningDate = addDays(todayDate, 1);
for (const earning of gameAdEarnings) {
const earningDate = parseISO(earning.date);
// Fill holes
while (
differenceInCalendarDays(parseISO(earning.date), previousEarningDate) < -1
) {
const addedEarningDate = subDays(previousEarningDate, 1);
filledGameAdEarnings.push(
createZeroesGameAdEarning(gameId, addedEarningDate)
);
previousEarningDate = addedEarningDate;
}
filledGameAdEarnings.push(earning);
previousEarningDate = earningDate;
}
// Fill to total days
while (filledGameAdEarnings.length < totalDays) {
const addedEarningDate = subDays(previousEarningDate, 1);
filledGameAdEarnings.push(
createZeroesGameAdEarning(gameId, addedEarningDate)
);
previousEarningDate = addedEarningDate;
}
console.log('filledGameAdEarnings', filledGameAdEarnings);
return filledGameAdEarnings;
};
/** /**
* Sum each metric or `undefined` when one side is `undefined`. * Sum each metric or `undefined` when one side is `undefined`.
* When one metric is `undefined`, the value of the other is not used because * When one metric is `undefined`, the value of the other is not used because
@@ -192,8 +283,8 @@ const fillMissingDays = (
* @returns the sum for each metric or `undefined` when one side is `undefined` * @returns the sum for each metric or `undefined` when one side is `undefined`
*/ */
const mergeGameMetrics = ( const mergeGameMetrics = (
a: GameMetrics, a: GameMetrics | MergedGameMetrics,
b: GameMetrics b: GameMetrics | MergedGameMetrics
): MergedGameMetrics => { ): MergedGameMetrics => {
return { return {
date: a.date, date: a.date,
@@ -269,6 +360,44 @@ const mergeGameMetricsByWeek = (
return mergedGameMetrics; return mergedGameMetrics;
}; };
const mergeGameAdEarnings = (
a: GameAdEarning | MergedGameAdEarnings,
b: GameAdEarning | MergedGameAdEarnings
): MergedGameAdEarnings => {
return {
date: a.date,
startDate: b.date,
gameId: a.gameId,
adEarningsInCredits: a.adEarningsInCredits + b.adEarningsInCredits,
adEarningsInMilliUSDs: a.adEarningsInMilliUSDs + b.adEarningsInMilliUSDs,
updatedAt: a.updatedAt,
};
};
const mergeGameAdEarningsByWeek = (
gameAdEarnings: GameAdEarning[]
): MergedGameAdEarnings[] => {
const mergedGameAdEarnings: Array<MergedGameAdEarnings> = [];
for (let weekIndex = 0; weekIndex < gameAdEarnings.length; weekIndex += 7) {
let mergedGameAdEarning = gameAdEarnings[weekIndex];
for (
let index = weekIndex + 1;
index < weekIndex + 7 && index < gameAdEarnings.length;
index++
) {
mergedGameAdEarning = mergeGameAdEarnings(
mergedGameAdEarning,
gameAdEarnings[index]
);
}
mergedGameAdEarnings.push(
((mergedGameAdEarning: any): MergedGameAdEarnings)
);
}
return mergedGameAdEarnings;
};
/** /**
* @param playersBelowSums * @param playersBelowSums
* @param playersCount * @param playersCount
@@ -350,13 +479,36 @@ const subtract = (a: ?number, b: ?number): number => {
* @returns enriched game metrics that are ready to be used in a chart * @returns enriched game metrics that are ready to be used in a chart
* (today first). * (today first).
*/ */
const evaluateChartData = (metrics: MergedGameMetrics[]): ChartData => { const evaluateChartData = ({
allMergedGameMetrics,
allMergedGameAdEarnings,
cashOuts,
period,
}: {
allMergedGameMetrics: MergedGameMetrics[],
allMergedGameAdEarnings: MergedGameAdEarnings[],
cashOuts: CashOuts[],
period: 'week' | 'month' | 'year',
}): ChartData => {
let playersBelowSums = [0, 0, 0, 0, 0]; let playersBelowSums = [0, 0, 0, 0, 0];
let playersSum = 0; let playersSum = 0;
let onlyFullyDefinedPlayersSum = 0; let onlyFullyDefinedPlayersSum = 0;
let playedDurationSumInMinutes = 0; let playedDurationSumInMinutes = 0;
metrics.forEach(metric => { const numberOfItemsInPeriod =
period === 'week'
? 7
: period === 'month'
? 30
: // merged by week
52;
const gameMetricsForPeriod = allMergedGameMetrics.slice(
0,
numberOfItemsInPeriod
);
gameMetricsForPeriod.forEach(metric => {
const d0SessionsDurationTotal = const d0SessionsDurationTotal =
metric.sessions && metric.sessions.d0SessionsDurationTotal !== null metric.sessions && metric.sessions.d0SessionsDurationTotal !== null
? metric.sessions.d0SessionsDurationTotal ? metric.sessions.d0SessionsDurationTotal
@@ -412,9 +564,9 @@ const evaluateChartData = (metrics: MergedGameMetrics[]): ChartData => {
const dateFormatOptions = { month: 'short', day: 'numeric' }; const dateFormatOptions = { month: 'short', day: 'numeric' };
const noMonthDateFormatOptions = { day: 'numeric' }; const noMonthDateFormatOptions = { day: 'numeric' };
const formatDate = (metric: MergedGameMetrics) => { const formatDate = (chartItem: MergedGameMetrics | MergedGameAdEarnings) => {
const startIsoDate = metric.startDate; const startIsoDate = chartItem.startDate;
const endDate = parseISO(metric.date); const endDate = parseISO(chartItem.date);
const formattedDate = endDate.toLocaleDateString( const formattedDate = endDate.toLocaleDateString(
undefined, undefined,
dateFormatOptions dateFormatOptions
@@ -435,6 +587,56 @@ const evaluateChartData = (metrics: MergedGameMetrics[]): ChartData => {
); );
}; };
const sortedGameAdEarnings = allMergedGameAdEarnings.sort(
(a, b) => parseISO(a.date).getTime() - parseISO(b.date).getTime()
);
console.log('sortedGameAdEarnings', sortedGameAdEarnings);
let accumulatedEarningsInCredits = 0;
let accumulatedEarningsInMilliUSDs = 0;
const sortedAndAccumulatedGameAdEarnings = sortedGameAdEarnings
.map((earning, index) => {
let accumulatedEarningsInCreditsToThatDay =
earning.adEarningsInCredits + accumulatedEarningsInCredits;
let accumulatedEarningsInMilliUSDsToThatDay =
earning.adEarningsInMilliUSDs + accumulatedEarningsInMilliUSDs;
const cashOutsOnThatPeriod = cashOuts.filter(
cashOut =>
cashOut.date === earning.date ||
(!!earning.startDate &&
cashOut.date >= earning.startDate &&
cashOut.date <= earning.date)
);
if (cashOutsOnThatPeriod.length) {
cashOutsOnThatPeriod.forEach(cashOut => {
console.log(accumulatedEarningsInCreditsToThatDay);
const estimatedCreditToMilliUSDsRatio =
accumulatedEarningsInMilliUSDsToThatDay /
accumulatedEarningsInCreditsToThatDay;
const cashOutInMilliUSDs = Math.floor(
cashOut.amountInCredits * estimatedCreditToMilliUSDsRatio
);
accumulatedEarningsInCreditsToThatDay -= cashOut.amountInCredits;
accumulatedEarningsInMilliUSDsToThatDay -= cashOutInMilliUSDs;
console.log(
cashOut,
estimatedCreditToMilliUSDsRatio,
cashOutInMilliUSDs
);
});
}
accumulatedEarningsInCredits = accumulatedEarningsInCreditsToThatDay;
accumulatedEarningsInMilliUSDs = accumulatedEarningsInMilliUSDsToThatDay;
return {
date: formatDate(earning),
accumulatedEarningsInCredits: accumulatedEarningsInCreditsToThatDay,
accumulatedEarningsInUSDs:
Math.floor(accumulatedEarningsInMilliUSDsToThatDay / 10) / 100,
};
})
.slice(allMergedGameAdEarnings.length - numberOfItemsInPeriod);
return { return {
overview: { overview: {
// Players from before the migration are shown as viewers // Players from before the migration are shown as viewers
@@ -470,8 +672,10 @@ const evaluateChartData = (metrics: MergedGameMetrics[]): ChartData => {
: 0, : 0,
durationInMinutes: durationValues[greaterDurationPlayerIndex], durationInMinutes: durationValues[greaterDurationPlayerIndex],
}, },
totalEarningsInUSDs:
Math.floor(accumulatedEarningsInMilliUSDs / 10) / 100,
}, },
overTime: metrics overTime: gameMetricsForPeriod
.map(metric => { .map(metric => {
const d0SessionsDurationTotal = const d0SessionsDurationTotal =
metric.sessions && metric.sessions.d0SessionsDurationTotal !== null metric.sessions && metric.sessions.d0SessionsDurationTotal !== null
@@ -609,6 +813,7 @@ const evaluateChartData = (metrics: MergedGameMetrics[]): ChartData => {
}; };
}) })
), ),
adsEarnings: sortedAndAccumulatedGameAdEarnings,
}; };
}; };
@@ -617,55 +822,87 @@ const evaluateChartData = (metrics: MergedGameMetrics[]): ChartData => {
* @returns enriched game metrics that are ready to be used in a chart * @returns enriched game metrics that are ready to be used in a chart
* (today at last). * (today at last).
*/ */
export const buildChartData = ( export const buildChartData = ({
gameId,
gameMetrics,
gameAdEarnings,
usages,
todayDate = new Date(),
}: {
gameId: string,
gameMetrics: ?Array<GameMetrics>, gameMetrics: ?Array<GameMetrics>,
todayDate: Date = new Date() gameAdEarnings: ?Array<GameAdEarning>,
): { yearChartData: ChartData, monthChartData: ChartData } => { usages: ?Array<Usage>,
if (!gameMetrics) { todayDate?: Date,
}): {|
yearChartData: ChartData,
monthChartData: ChartData,
weekChartData: ChartData,
|} => {
if (!gameMetrics || !gameAdEarnings || !usages) {
return { return {
yearChartData: emptyChartData, yearChartData: emptyChartData,
monthChartData: emptyChartData, monthChartData: emptyChartData,
weekChartData: emptyChartData,
}; };
} }
const filledGameRollingMetrics = fillMissingDays(
gameMetrics.sort( const filledGameMetrics = fillMissingGameMetricsDays({
gameMetrics: gameMetrics.sort(
(a, b) => parseISO(b.date).getTime() - parseISO(a.date).getTime() (a, b) => parseISO(b.date).getTime() - parseISO(a.date).getTime()
), ),
todayDate todayDate,
totalDays: daysShownForYear,
});
const filledGameAdEarnings = fillMissingGameAdEarningsDays({
gameAdEarnings: gameAdEarnings.sort(
(a, b) => parseISO(b.date).getTime() - parseISO(a.date).getTime()
),
todayDate,
totalDays: daysShownForYear,
gameId,
});
const gameMetricsMergedByWeek = mergeGameMetricsByWeek(filledGameMetrics);
const gameAdEarningsMergedByWeek = mergeGameAdEarningsByWeek(
filledGameAdEarnings
); );
const gameMetricsMergedByDay: MergedGameMetrics[] = filledGameMetrics.map(
metric => ({ ...metric, startDate: null })
);
const gameAdEarningsMergedByDay: MergedGameAdEarnings[] = filledGameAdEarnings.map(
earning => ({ ...earning, startDate: null })
);
const cashOuts = usages
.filter(
usage =>
usage.type === 'change-balance' &&
usage.description === 'Cash out of game earnings'
)
.map(usage => ({
date: formatISO(usage.createdAt, { representation: 'date' }),
amountInCredits: -(usage.creditsPaid || 0),
}));
return { return {
yearChartData: evaluateChartData( yearChartData: evaluateChartData({
mergeGameMetricsByWeek(filledGameRollingMetrics) allMergedGameMetrics: gameMetricsMergedByWeek,
), allMergedGameAdEarnings: gameAdEarningsMergedByWeek,
monthChartData: evaluateChartData( cashOuts,
filledGameRollingMetrics period: 'year',
.slice(0, 30) }),
.map(metric => ({ ...metric, startDate: null }: MergedGameMetrics)) monthChartData: evaluateChartData({
), allMergedGameMetrics: gameMetricsMergedByDay,
allMergedGameAdEarnings: gameAdEarningsMergedByDay,
cashOuts,
period: 'month',
}),
weekChartData: evaluateChartData({
allMergedGameMetrics: gameMetricsMergedByDay,
allMergedGameAdEarnings: gameAdEarningsMergedByDay,
cashOuts,
period: 'week',
}),
}; };
}; };
/**
* @param gameMetrics concise game metrics from the backend (today first)
* @returns enriched game metrics that are ready to be used in a chart
* (today at last).
*/
export const buildLastWeekChartData = (
gameMetrics: ?Array<GameMetrics>,
todayDate: Date = new Date()
): ChartData => {
if (!gameMetrics) {
return emptyChartData;
}
const filledGameRollingMetrics = fillMissingDays(
gameMetrics.sort(
(a, b) => parseISO(b.date).getTime() - parseISO(a.date).getTime()
),
todayDate
);
return evaluateChartData(
filledGameRollingMetrics
.slice(0, 7)
.map(metric => ({ ...metric, startDate: null }: MergedGameMetrics))
);
};

View File

@@ -28,8 +28,13 @@ import {
PlayersRepartitionPerDurationChart, PlayersRepartitionPerDurationChart,
PlayersDurationPerDayChart, PlayersDurationPerDayChart,
SessionsChart, SessionsChart,
GameAdEarningsChart,
} from './GameAnalyticsCharts'; } from './GameAnalyticsCharts';
import MarketingPlanSingleDisplay from '../MarketingPlans/MarketingPlanSingleDisplay'; import MarketingPlanSingleDisplay from '../MarketingPlans/MarketingPlanSingleDisplay';
import {
getGameAdEarnings,
type GameAdEarning,
} from '../Utils/GDevelopServices/Usage';
const chartHeight = 300; const chartHeight = 300;
@@ -46,62 +51,79 @@ export const GameAnalyticsPanel = ({
gameFeaturings, gameFeaturings,
fetchGameFeaturings, fetchGameFeaturings,
}: Props) => { }: Props) => {
const { getAuthorizationHeader, profile } = React.useContext( const { getAuthorizationHeader, profile, usages } = React.useContext(
AuthenticatedUserContext AuthenticatedUserContext
); );
const [gameRollingMetrics, setGameMetrics] = React.useState<?(GameMetrics[])>( const [gameMetrics, setGameMetrics] = React.useState<?(GameMetrics[])>(null);
null const [
); gameAdEarnings,
setGameAdEarnings,
] = React.useState<?(GameAdEarning[])>(null);
const { yearChartData, monthChartData } = React.useMemo( const { yearChartData, monthChartData } = React.useMemo(
() => buildChartData(gameRollingMetrics), () =>
[gameRollingMetrics] buildChartData({ gameMetrics, gameAdEarnings, usages, gameId: game.id }),
[gameMetrics, gameAdEarnings, usages, game.id]
); );
const [dataPeriod, setDataPeriod] = React.useState('month'); const [dataPeriod, setDataPeriod] = React.useState('month');
const chartData = dataPeriod === 'year' ? yearChartData : monthChartData; const chartData = dataPeriod === 'year' ? yearChartData : monthChartData;
const [gameRollingMetricsError, setGameMetricsError] = React.useState<?Error>( const [error, setError] = React.useState<?Error>(null);
null const [isLoading, setIsLoading] = React.useState(false);
);
const [isGameMetricsLoading, setIsGameMetricsLoading] = React.useState(false);
// TODO In some timezones, it might ask one less or extra day. // TODO In some timezones, it might ask one less or extra day.
const lastYearIsoDate = formatISO(subDays(new Date(), daysShownForYear), { const loadGameAnalytics = React.useCallback(
representation: 'date',
});
const loadGameMetrics = React.useCallback(
async () => { async () => {
if (!profile) return; if (!profile) return;
const { id } = profile; const { id } = profile;
setIsGameMetricsLoading(true); setIsLoading(true);
setGameMetricsError(null); setError(null);
const lastYearIsoDate = formatISO(subDays(new Date(), daysShownForYear), {
representation: 'date',
});
const gameCreatioDateIsoDate = formatISO(new Date(game.createdAt), {
representation: 'date',
});
const todayIsoDate = formatISO(new Date(), {
representation: 'date',
});
try { try {
const gameRollingMetrics = await getGameMetricsFrom( const [gameRollingMetrics, gameAdEarnings] = await Promise.all([
getAuthorizationHeader, getGameMetricsFrom(
id, getAuthorizationHeader,
game.id, id,
lastYearIsoDate game.id,
); lastYearIsoDate
),
getGameAdEarnings(getAuthorizationHeader, id, {
gameId: game.id,
startIsoDate: gameCreatioDateIsoDate,
endIsoDate: todayIsoDate,
}),
]);
setGameMetrics(gameRollingMetrics); setGameMetrics(gameRollingMetrics);
setGameAdEarnings(gameAdEarnings);
} catch (err) { } catch (err) {
console.error(`Unable to load game rolling metrics:`, err); console.error(`Unable to load game rolling metrics:`, err);
setGameMetricsError(err); setError(err);
} }
setIsGameMetricsLoading(false); setIsLoading(false);
}, },
[getAuthorizationHeader, profile, game, lastYearIsoDate] [getAuthorizationHeader, profile, game]
); );
React.useEffect( React.useEffect(
() => { () => {
loadGameMetrics(); loadGameAnalytics();
}, },
[loadGameMetrics] [loadGameAnalytics]
); );
if (isGameMetricsLoading) return <PlaceholderLoader />; if (isLoading) return <PlaceholderLoader />;
const displaySuggestedMarketingPlan = const displaySuggestedMarketingPlan =
recommendedMarketingPlan && gameFeaturings && fetchGameFeaturings; recommendedMarketingPlan && gameFeaturings && fetchGameFeaturings;
@@ -109,10 +131,10 @@ export const GameAnalyticsPanel = ({
return ( return (
<I18n> <I18n>
{({ i18n }) => {({ i18n }) =>
gameRollingMetricsError ? ( error ? (
<PlaceholderError <PlaceholderError
onRetry={() => { onRetry={() => {
loadGameMetrics(); loadGameAnalytics();
}} }}
> >
<Trans>There was an issue getting the game analytics.</Trans>{' '} <Trans>There was an issue getting the game analytics.</Trans>{' '}
@@ -136,8 +158,28 @@ export const GameAnalyticsPanel = ({
<Grid <Grid
item item
xs={12} xs={12}
sm={displaySuggestedMarketingPlan ? 7 : 12} sm={displaySuggestedMarketingPlan ? 4 : 12}
md={displaySuggestedMarketingPlan ? 8 : 12} md={displaySuggestedMarketingPlan ? 4 : 12}
>
<Column noMargin alignItems="center" expand>
<Text size="block-title" align="center">
<Trans>
USD {chartData.overview.totalEarningsInUSDs} in Ads
earnings
</Trans>
</Text>
<GameAdEarningsChart
chartData={chartData}
height={chartHeight}
i18n={i18n}
/>
</Column>
</Grid>
<Grid
item
xs={12}
sm={displaySuggestedMarketingPlan ? 4 : 12}
md={displaySuggestedMarketingPlan ? 4 : 12}
> >
<Column noMargin alignItems="center" expand> <Column noMargin alignItems="center" expand>
<Text size="block-title" align="center"> <Text size="block-title" align="center">
@@ -150,10 +192,11 @@ export const GameAnalyticsPanel = ({
/> />
</Column> </Column>
</Grid> </Grid>
{recommendedMarketingPlan && {recommendedMarketingPlan &&
gameFeaturings && gameFeaturings &&
fetchGameFeaturings && ( fetchGameFeaturings && (
<Grid item xs={12} sm={5} md={4}> <Grid item xs={12} sm={4} md={4}>
<MarketingPlanSingleDisplay <MarketingPlanSingleDisplay
fetchGameFeaturings={fetchGameFeaturings} fetchGameFeaturings={fetchGameFeaturings}
gameFeaturings={gameFeaturings} gameFeaturings={gameFeaturings}

View File

@@ -11,7 +11,6 @@ import Window from '../../Utils/Window';
import RaisedButton from '../../UI/RaisedButton'; import RaisedButton from '../../UI/RaisedButton';
import Coin from '../../Credits/Icons/Coin'; import Coin from '../../Credits/Icons/Coin';
import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext'; import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext';
import PlaceholderError from '../../UI/PlaceholderError';
import Tooltip from '@material-ui/core/Tooltip'; import Tooltip from '@material-ui/core/Tooltip';
import CreditOutDialog from './CashOutDialog'; import CreditOutDialog from './CashOutDialog';
import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext'; import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext';
@@ -49,7 +48,6 @@ const UserEarningsWidget = ({ size }: Props) => {
const [earningsInMilliUsd, setEarningsInMilliUsd] = React.useState(0); const [earningsInMilliUsd, setEarningsInMilliUsd] = React.useState(0);
const [earningsInCredits, setEarningsInCredits] = React.useState(0); const [earningsInCredits, setEarningsInCredits] = React.useState(0);
const [error, setError] = React.useState(null);
const intervalValuesUpdate = React.useRef(null); const intervalValuesUpdate = React.useRef(null);
const [selectedCashOutType, setSelectedCashOutType] = React.useState< const [selectedCashOutType, setSelectedCashOutType] = React.useState<
@@ -58,43 +56,43 @@ const UserEarningsWidget = ({ size }: Props) => {
const animateEarnings = React.useCallback( const animateEarnings = React.useCallback(
async () => { async () => {
if (!userEarningsBalance) return; if (!userEarningsBalance) {
// In case the user logs out, reset the earnings.
try { setEarningsInMilliUsd(0);
// Create an animation to show the earnings increasing. setEarningsInCredits(0);
const targetMilliUsd = userEarningsBalance.amountInMilliUSDs; return;
const targetCredits = userEarningsBalance.amountInCredits;
const duration = 500;
const steps = 30;
const intervalTime = duration / steps;
const milliUsdIncrement = targetMilliUsd / steps;
const creditsIncrement = targetCredits / steps;
let currentMilliUsd = 0;
let currentCredits = 0;
let step = 0;
intervalValuesUpdate.current = setInterval(() => {
step++;
currentMilliUsd += milliUsdIncrement;
currentCredits += creditsIncrement;
setEarningsInMilliUsd(currentMilliUsd);
setEarningsInCredits(currentCredits);
if (step >= steps) {
clearInterval(intervalValuesUpdate.current);
// Ensure final values are exactly the target values
setEarningsInMilliUsd(targetMilliUsd);
setEarningsInCredits(targetCredits);
}
}, intervalTime);
} catch (error) {
console.error('Unable to get user earnings balance:', error);
setError(error);
} }
// Create an animation to show the earnings increasing.
const targetMilliUsd = userEarningsBalance.amountInMilliUSDs;
const targetCredits = userEarningsBalance.amountInCredits;
const duration = 500;
const steps = 30;
const intervalTime = duration / steps;
const milliUsdIncrement = targetMilliUsd / steps;
const creditsIncrement = targetCredits / steps;
let currentMilliUsd = 0;
let currentCredits = 0;
let step = 0;
intervalValuesUpdate.current = setInterval(() => {
step++;
currentMilliUsd += milliUsdIncrement;
currentCredits += creditsIncrement;
setEarningsInMilliUsd(currentMilliUsd);
setEarningsInCredits(currentCredits);
if (step >= steps) {
clearInterval(intervalValuesUpdate.current);
// Ensure final values are exactly the target values
setEarningsInMilliUsd(targetMilliUsd);
setEarningsInCredits(targetCredits);
}
}, intervalTime);
}, },
[userEarningsBalance] [userEarningsBalance]
); );
@@ -127,16 +125,7 @@ const UserEarningsWidget = ({ size }: Props) => {
userEarningsBalance && userEarningsBalance &&
earningsInMilliUsd >= userEarningsBalance.minAmountToCashoutInMilliUSDs; earningsInMilliUsd >= userEarningsBalance.minAmountToCashoutInMilliUSDs;
const content = error ? ( const content = (
<LineStackLayout noMargin alignItems="center">
<PlaceholderError onRetry={onRefreshEarningsBalance}>
<Trans>
Can't load your game earnings. Verify your internet connection or try
again later.
</Trans>
</PlaceholderError>
</LineStackLayout>
) : (
<ResponsiveLineStackLayout <ResponsiveLineStackLayout
noMargin noMargin
alignItems="center" alignItems="center"

View File

@@ -10,17 +10,19 @@ import { ColumnStackLayout, ResponsiveLineStackLayout } from '../../UI/Layout';
import { Column, Line, Spacer } from '../../UI/Grid'; import { Column, Line, Spacer } from '../../UI/Grid';
import Text from '../../UI/Text'; import Text from '../../UI/Text';
import { type Game } from '../../Utils/GDevelopServices/Game'; import { type Game } from '../../Utils/GDevelopServices/Game';
import { SessionsChart } from '../GameAnalyticsCharts'; import { GameAdEarningsChart, SessionsChart } from '../GameAnalyticsCharts';
import { type GameMetrics } from '../../Utils/GDevelopServices/Analytics'; import { type GameMetrics } from '../../Utils/GDevelopServices/Analytics';
import { buildLastWeekChartData } from '../GameAnalyticsEvaluator'; import { buildChartData } from '../GameAnalyticsEvaluator';
import RaisedButton from '../../UI/RaisedButton'; import RaisedButton from '../../UI/RaisedButton';
import Coin from '../../Credits/Icons/Coin'; import Coin from '../../Credits/Icons/Coin';
import MarketingPlansDialog from '../../MarketingPlans/MarketingPlansDialog'; import MarketingPlansDialog from '../../MarketingPlans/MarketingPlansDialog';
import GameLinkAndShareIcons from '../GameLinkAndShareIcons'; import GameLinkAndShareIcons from '../GameLinkAndShareIcons';
import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer'; import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer';
import { type GameAdEarning } from '../../Utils/GDevelopServices/Usage';
import { getHelpLink } from '../../Utils/HelpLink'; import { getHelpLink } from '../../Utils/HelpLink';
import Window from '../../Utils/Window'; import Window from '../../Utils/Window';
import Link from '../../UI/Link'; import Link from '../../UI/Link';
import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext';
const publishingHelpLink = getHelpLink('/publishing', 'publish-your-game'); const publishingHelpLink = getHelpLink('/publishing', 'publish-your-game');
@@ -30,29 +32,29 @@ type Props = {|
game: Game, game: Game,
onSeeAll: () => void, onSeeAll: () => void,
gameMetrics: ?Array<GameMetrics>, gameMetrics: ?Array<GameMetrics>,
gameAdEarnings: ?(GameAdEarning[]),
gameUrl: ?string, gameUrl: ?string,
|}; |};
const AnalyticsWidget = ({ game, onSeeAll, gameMetrics, gameUrl }: Props) => { const AnalyticsWidget = ({
game,
onSeeAll,
gameMetrics,
gameAdEarnings,
gameUrl,
}: Props) => {
const hasNoSession = gameMetrics && gameMetrics.length === 0; const hasNoSession = gameMetrics && gameMetrics.length === 0;
const { isMobile } = useResponsiveWindowSize(); const { isMobile } = useResponsiveWindowSize();
const oneWeekAgoIsoDate = new Date(
new Date().setHours(0, 0, 0, 0) - 7 * 24 * 3600 * 1000
).toISOString();
const [ const [
marketingPlansDialogOpen, marketingPlansDialogOpen,
setMarketingPlansDialogOpen, setMarketingPlansDialogOpen,
] = React.useState<boolean>(false); ] = React.useState<boolean>(false);
const { usages } = React.useContext(AuthenticatedUserContext);
const chartData = React.useMemo( const { weekChartData } = React.useMemo(
() => { () =>
const lastWeekGameMetrics = gameMetrics buildChartData({ gameMetrics, gameAdEarnings, usages, gameId: game.id }),
? gameMetrics.filter(metrics => metrics.date > oneWeekAgoIsoDate) [gameMetrics, gameAdEarnings, usages, game.id]
: null;
return buildLastWeekChartData(lastWeekGameMetrics);
},
[gameMetrics, oneWeekAgoIsoDate]
); );
return ( return (
@@ -74,69 +76,89 @@ const AnalyticsWidget = ({ game, onSeeAll, gameMetrics, gameUrl }: Props) => {
widgetName="analytics" widgetName="analytics"
> >
<ResponsiveLineStackLayout expand noColumnMargin noMargin> <ResponsiveLineStackLayout expand noColumnMargin noMargin>
{!gameMetrics ? ( <Column expand noMargin>
<div style={styles.loadingSpace} /> <Line alignItems="center">
) : hasNoSession ? ( <Text size="sub-title">
gameUrl ? ( <Trans>Ads earnings</Trans>
<ColumnStackLayout </Text>
noMargin </Line>
alignItems={isMobile ? 'stretch' : 'flex-start'} {!gameAdEarnings ? (
noOverflowParent <div style={styles.loadingSpace} />
>
<Spacer />
<Text noMargin color="secondary">
<Trans>
No data to show yet. Share your game creator profile
with more people to get more players!
</Trans>
</Text>
<GameLinkAndShareIcons display="column" url={gameUrl} />
</ColumnStackLayout>
) : ( ) : (
<ColumnStackLayout <Column noMargin>
noMargin <GameAdEarningsChart
expand i18n={i18n}
justifyContent="center" height={200}
alignItems="center" chartData={weekChartData}
> fontSize="small"
<Spacer />
<Text color="secondary" noMargin>
<Trans>
<Link
href={publishingHelpLink}
onClick={() =>
Window.openExternalURL(publishingHelpLink)
}
>
Share your game
</Link>{' '}
and start collecting data from your players to better
understand them.
</Trans>
</Text>
</ColumnStackLayout>
)
) : (
<Column expand noMargin>
<Line alignItems="center" justifyContent="space-between">
<Text size="block-title" noMargin>
<Trans>Sessions</Trans>
</Text>
<RaisedButton
primary
icon={<Coin fontSize="small" />}
label={<Trans>Get more players</Trans>}
onClick={() => setMarketingPlansDialogOpen(true)}
/> />
</Line> </Column>
)}
</Column>
<Column expand noMargin>
<Line alignItems="center" justifyContent="space-between">
<Text size="sub-title">
<Trans>Sessions</Trans>
</Text>
<RaisedButton
primary
icon={<Coin fontSize="small" />}
label={<Trans>Get more players</Trans>}
onClick={() => setMarketingPlansDialogOpen(true)}
disabled={!gameMetrics}
/>
</Line>
{!gameMetrics ? (
<div style={styles.loadingSpace} />
) : hasNoSession ? (
gameUrl ? (
<ColumnStackLayout
noMargin
alignItems={isMobile ? 'stretch' : 'flex-start'}
noOverflowParent
>
<Spacer />
<Text noMargin color="secondary">
<Trans>
No data to show yet. Share your game creator profile
with more people to get more players!
</Trans>
</Text>
<GameLinkAndShareIcons display="column" url={gameUrl} />
</ColumnStackLayout>
) : (
<ColumnStackLayout
noMargin
expand
justifyContent="center"
alignItems="center"
>
<Spacer />
<Text color="secondary" noMargin>
<Trans>
<Link
href={publishingHelpLink}
onClick={() =>
Window.openExternalURL(publishingHelpLink)
}
>
Share your game
</Link>{' '}
and start collecting data from your players to better
understand them.
</Trans>
</Text>
</ColumnStackLayout>
)
) : (
<SessionsChart <SessionsChart
i18n={i18n} i18n={i18n}
height={200} height={200}
chartData={chartData} chartData={weekChartData}
fontSize="small" fontSize="small"
/> />
</Column> )}
)} </Column>
</ResponsiveLineStackLayout> </ResponsiveLineStackLayout>
</DashboardWidget> </DashboardWidget>
)} )}

View File

@@ -39,7 +39,7 @@ const ServicesWidget = ({
); );
return ( return (
<DashboardWidget <DashboardWidget
widgetSize={'full'} widgetSize="full"
title={<Trans>Player services</Trans>} title={<Trans>Player services</Trans>}
widgetName="services" widgetName="services"
> >

View File

@@ -60,6 +60,10 @@ import ProjectsWidget from './Widgets/ProjectsWidget';
import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer'; import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer';
import { formatISO, subDays } from 'date-fns'; import { formatISO, subDays } from 'date-fns';
import { daysShownForYear } from './GameAnalyticsEvaluator'; import { daysShownForYear } from './GameAnalyticsEvaluator';
import {
getGameAdEarnings,
type GameAdEarning,
} from '../Utils/GDevelopServices/Usage';
const styles = { const styles = {
mobileFooter: { mobileFooter: {
@@ -140,9 +144,7 @@ const GameDashboard = ({
const [feedbacks, setFeedbacks] = React.useState<?Array<Comment>>(null); const [feedbacks, setFeedbacks] = React.useState<?Array<Comment>>(null);
const [builds, setBuilds] = React.useState<?Array<Build>>(null); const [builds, setBuilds] = React.useState<?Array<Build>>(null);
const [publicGame, setPublicGame] = React.useState<?PublicGame>(null); const [publicGame, setPublicGame] = React.useState<?PublicGame>(null);
const [gameRollingMetrics, setGameMetrics] = React.useState<?(GameMetrics[])>( const [gameMetrics, setGameMetrics] = React.useState<?(GameMetrics[])>(null);
null
);
const [ const [
recommendedMarketingPlan, recommendedMarketingPlan,
setRecommendedMarketingPlan, setRecommendedMarketingPlan,
@@ -154,9 +156,10 @@ const GameDashboard = ({
const [leaderboards, setLeaderboards] = React.useState<?Array<Leaderboard>>( const [leaderboards, setLeaderboards] = React.useState<?Array<Leaderboard>>(
null null
); );
const lastYearIsoDate = formatISO(subDays(new Date(), daysShownForYear), { const [
representation: 'date', gameAdEarnings,
}); setGameAdEarnings,
] = React.useState<?Array<GameAdEarning>>(null);
const webBuilds = builds const webBuilds = builds
? builds.filter(build => build.type === 'web-build') ? builds.filter(build => build.type === 'web-build')
@@ -457,13 +460,25 @@ const GameDashboard = ({
setLeaderboards(null); setLeaderboards(null);
setGameFeaturings(null); setGameFeaturings(null);
setRecommendedMarketingPlan(null); setRecommendedMarketingPlan(null);
setGameAdEarnings(null);
return; return;
} }
const lastYearIsoDate = formatISO(subDays(new Date(), daysShownForYear), {
representation: 'date',
});
const gameCreatioDateIsoDate = formatISO(new Date(game.createdAt), {
representation: 'date',
});
const todayIsoDate = formatISO(new Date(), {
representation: 'date',
});
const [ const [
feedbacks, feedbacks,
builds, builds,
gameRollingMetrics, gameMetrics,
gameAdEarnings,
leaderboards, leaderboards,
recommendedMarketingPlan, recommendedMarketingPlan,
] = await Promise.all([ ] = await Promise.all([
@@ -478,6 +493,11 @@ const GameDashboard = ({
game.id, game.id,
lastYearIsoDate lastYearIsoDate
), ),
getGameAdEarnings(getAuthorizationHeader, profile.id, {
gameId: game.id,
startIsoDate: gameCreatioDateIsoDate,
endIsoDate: todayIsoDate,
}),
listGameActiveLeaderboards(getAuthorizationHeader, profile.id, game.id), listGameActiveLeaderboards(getAuthorizationHeader, profile.id, game.id),
getRecommendedMarketingPlan(getAuthorizationHeader, { getRecommendedMarketingPlan(getAuthorizationHeader, {
gameId: game.id, gameId: game.id,
@@ -487,16 +507,17 @@ const GameDashboard = ({
]); ]);
setFeedbacks(feedbacks); setFeedbacks(feedbacks);
setBuilds(builds); setBuilds(builds);
setGameMetrics(gameRollingMetrics); setGameMetrics(gameMetrics);
setLeaderboards(leaderboards); setLeaderboards(leaderboards);
setRecommendedMarketingPlan(recommendedMarketingPlan); setRecommendedMarketingPlan(recommendedMarketingPlan);
setGameAdEarnings(gameAdEarnings);
}, },
[ [
fetchGameFeaturings, fetchGameFeaturings,
game.id, game.id,
getAuthorizationHeader, getAuthorizationHeader,
profile, profile,
lastYearIsoDate, game.createdAt,
] ]
); );
@@ -582,7 +603,8 @@ const GameDashboard = ({
<Grid container spacing={2} ref={grid}> <Grid container spacing={2} ref={grid}>
<AnalyticsWidget <AnalyticsWidget
onSeeAll={() => setCurrentView('analytics')} onSeeAll={() => setCurrentView('analytics')}
gameMetrics={gameRollingMetrics} gameMetrics={gameMetrics}
gameAdEarnings={gameAdEarnings}
game={game} game={game}
gameUrl={gameUrl} gameUrl={gameUrl}
/> />

View File

@@ -2,7 +2,7 @@
import axios from 'axios'; import axios from 'axios';
import { GDevelopAnalyticsApi } from './ApiConfigs'; import { GDevelopAnalyticsApi } from './ApiConfigs';
export type GameMetrics = { export type GameMetrics = {|
date: string, date: string,
sessions: ?{ sessions: ?{
@@ -43,7 +43,7 @@ export type GameMetrics = {
/** Day 7 retained players (number of players who played this day, and were new players 7 days earlier). */ /** Day 7 retained players (number of players who played this day, and were new players 7 days earlier). */
d7RetainedPlayers: number, d7RetainedPlayers: number,
}, },
}; |};
export const client = axios.create({ export const client = axios.create({
baseURL: GDevelopAnalyticsApi.baseUrl, baseURL: GDevelopAnalyticsApi.baseUrl,

View File

@@ -1,7 +1,7 @@
// @flow // @flow
import Window from '../Window'; import Window from '../Window';
const isDev = Window.isDev(); const isDev = false;
export const GDevelopGamePreviews = { export const GDevelopGamePreviews = {
baseUrl: `https://game-previews.gdevelop.io/`, baseUrl: `https://game-previews.gdevelop.io/`,

View File

@@ -11,6 +11,8 @@ export type Usage = {
userId: string, userId: string,
type: string, type: string,
createdAt: number, createdAt: number,
description?: string,
creditsPaid?: number,
}; };
export type Usages = Array<Usage>; export type Usages = Array<Usage>;
@@ -192,6 +194,14 @@ export type SubscriptionPlanWithPricingSystems = {|
pricingSystems: SubscriptionPlanPricingSystem[], pricingSystems: SubscriptionPlanPricingSystem[],
|}; |};
export type GameAdEarning = {|
gameId: string,
date: string,
adEarningsInMilliUSDs: number,
adEarningsInCredits: number,
updatedAt: number,
|};
export interface UserEarningsBalance { export interface UserEarningsBalance {
userId: string; userId: string;
amountInMilliUSDs: number; amountInMilliUSDs: number;
@@ -313,6 +323,40 @@ export const getUserUsages = async (
return response.data; return response.data;
}; };
export const getGameAdEarnings = async (
getAuthorizationHeader: () => Promise<string>,
userId: string,
{
gameId,
startIsoDate,
endIsoDate,
}: {|
gameId: string,
startIsoDate: string,
endIsoDate: string,
|}
): Promise<GameAdEarning[]> => {
const authorizationHeader = await getAuthorizationHeader();
const response = await apiClient.get(`/game-ad-earning`, {
params: {
userId,
gameId,
startIsoDate,
endIsoDate,
},
headers: {
Authorization: authorizationHeader,
},
});
const gameAdEarnings = response.data;
if (!Array.isArray(gameAdEarnings)) {
throw new Error('Invalid response from the game ad earnings API');
}
return gameAdEarnings;
};
export const getUserEarningsBalance = async ( export const getUserEarningsBalance = async (
getAuthorizationHeader: () => Promise<string>, getAuthorizationHeader: () => Promise<string>,
userId: string userId: string