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,
label,
customStyle,
decimals = 2,
}: {|
payload: ?Array<any>,
label: string,
customStyle: Object,
decimals?: number,
|}) =>
payload ? (
<Paper style={customStyle} background="light">
@@ -79,7 +81,9 @@ const CustomTooltip = ({
index
) => (
<Text noMargin key={index}>{`${name}: ${
Number.isInteger(value) ? value.toString() : value.toFixed(2)
Number.isInteger(value)
? value.toString()
: value.toFixed(decimals)
}${unit ? ` ${unit}` : ''}`}</Text>
)
)}
@@ -397,3 +401,53 @@ export const PlayersDurationPerDayChart = ({
</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,
} from 'date-fns';
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.
*/
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.
@@ -46,6 +64,8 @@ export type ChartData = {|
playersPercent: number,
durationInMinutes: number,
|},
totalEarningsInUSDs: number,
|},
/**
* 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.
*/
overPlayedDuration: {| duration: number, playersCount: number |}[],
/**
* Accumulated earnings from ads each day.
*/
adsEarnings: {|
date: string,
accumulatedEarningsInCredits: number,
accumulatedEarningsInUSDs: number,
|}[],
|};
const emptyChartData: ChartData = {
@@ -110,9 +138,11 @@ const emptyChartData: ChartData = {
playersPercent: 0,
durationInMinutes: 0,
},
totalEarningsInUSDs: 0,
},
overTime: [],
overPlayedDuration: [],
adsEarnings: [],
};
const durationIndexes: { [string]: number } = {
@@ -124,9 +154,9 @@ const durationIndexes: { [string]: number } = {
};
export const durationValues = [1, 3, 5, 10, 15];
const createZeroesMetric = (date: Date): GameMetrics => {
const createZeroesGameMetric = (date: Date): GameMetrics => {
return {
date: formatISO(date),
date: formatISO(date, { representation: 'date' }),
sessions: {
d0Sessions: 0,
@@ -150,10 +180,15 @@ const createZeroesMetric = (date: Date): GameMetrics => {
* @param gameMetrics concise game metrics from the backend (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>,
todayDate: Date
): Array<GameMetrics> => {
todayDate: Date,
totalDays: number,
}): Array<GameMetrics> => {
const filledGameMetrics = [];
// TODO In some timezones, it might start the wrong day.
let previousMetricDate = addDays(todayDate, 1);
@@ -164,21 +199,77 @@ const fillMissingDays = (
differenceInCalendarDays(parseISO(metric.date), previousMetricDate) < -1
) {
const addedMetricDate = subDays(previousMetricDate, 1);
filledGameMetrics.push(createZeroesMetric(addedMetricDate));
filledGameMetrics.push(createZeroesGameMetric(addedMetricDate));
previousMetricDate = addedMetricDate;
}
filledGameMetrics.push(metric);
previousMetricDate = metricDate;
}
// Fill to one year
while (filledGameMetrics.length < daysShownForYear) {
// Fill to total days
while (filledGameMetrics.length < totalDays) {
const addedMetricDate = subDays(previousMetricDate, 1);
filledGameMetrics.push(createZeroesMetric(addedMetricDate));
filledGameMetrics.push(createZeroesGameMetric(addedMetricDate));
previousMetricDate = addedMetricDate;
}
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`.
* 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`
*/
const mergeGameMetrics = (
a: GameMetrics,
b: GameMetrics
a: GameMetrics | MergedGameMetrics,
b: GameMetrics | MergedGameMetrics
): MergedGameMetrics => {
return {
date: a.date,
@@ -269,6 +360,44 @@ const mergeGameMetricsByWeek = (
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 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
* (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 playersSum = 0;
let onlyFullyDefinedPlayersSum = 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 =
metric.sessions && metric.sessions.d0SessionsDurationTotal !== null
? metric.sessions.d0SessionsDurationTotal
@@ -412,9 +564,9 @@ const evaluateChartData = (metrics: MergedGameMetrics[]): ChartData => {
const dateFormatOptions = { month: 'short', day: 'numeric' };
const noMonthDateFormatOptions = { day: 'numeric' };
const formatDate = (metric: MergedGameMetrics) => {
const startIsoDate = metric.startDate;
const endDate = parseISO(metric.date);
const formatDate = (chartItem: MergedGameMetrics | MergedGameAdEarnings) => {
const startIsoDate = chartItem.startDate;
const endDate = parseISO(chartItem.date);
const formattedDate = endDate.toLocaleDateString(
undefined,
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 {
overview: {
// Players from before the migration are shown as viewers
@@ -470,8 +672,10 @@ const evaluateChartData = (metrics: MergedGameMetrics[]): ChartData => {
: 0,
durationInMinutes: durationValues[greaterDurationPlayerIndex],
},
totalEarningsInUSDs:
Math.floor(accumulatedEarningsInMilliUSDs / 10) / 100,
},
overTime: metrics
overTime: gameMetricsForPeriod
.map(metric => {
const d0SessionsDurationTotal =
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
* (today at last).
*/
export const buildChartData = (
export const buildChartData = ({
gameId,
gameMetrics,
gameAdEarnings,
usages,
todayDate = new Date(),
}: {
gameId: string,
gameMetrics: ?Array<GameMetrics>,
todayDate: Date = new Date()
): { yearChartData: ChartData, monthChartData: ChartData } => {
if (!gameMetrics) {
gameAdEarnings: ?Array<GameAdEarning>,
usages: ?Array<Usage>,
todayDate?: Date,
}): {|
yearChartData: ChartData,
monthChartData: ChartData,
weekChartData: ChartData,
|} => {
if (!gameMetrics || !gameAdEarnings || !usages) {
return {
yearChartData: 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()
),
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 {
yearChartData: evaluateChartData(
mergeGameMetricsByWeek(filledGameRollingMetrics)
),
monthChartData: evaluateChartData(
filledGameRollingMetrics
.slice(0, 30)
.map(metric => ({ ...metric, startDate: null }: MergedGameMetrics))
),
yearChartData: evaluateChartData({
allMergedGameMetrics: gameMetricsMergedByWeek,
allMergedGameAdEarnings: gameAdEarningsMergedByWeek,
cashOuts,
period: 'year',
}),
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,
PlayersDurationPerDayChart,
SessionsChart,
GameAdEarningsChart,
} from './GameAnalyticsCharts';
import MarketingPlanSingleDisplay from '../MarketingPlans/MarketingPlanSingleDisplay';
import {
getGameAdEarnings,
type GameAdEarning,
} from '../Utils/GDevelopServices/Usage';
const chartHeight = 300;
@@ -46,62 +51,79 @@ export const GameAnalyticsPanel = ({
gameFeaturings,
fetchGameFeaturings,
}: Props) => {
const { getAuthorizationHeader, profile } = React.useContext(
const { getAuthorizationHeader, profile, usages } = React.useContext(
AuthenticatedUserContext
);
const [gameRollingMetrics, setGameMetrics] = React.useState<?(GameMetrics[])>(
null
);
const [gameMetrics, setGameMetrics] = React.useState<?(GameMetrics[])>(null);
const [
gameAdEarnings,
setGameAdEarnings,
] = React.useState<?(GameAdEarning[])>(null);
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 chartData = dataPeriod === 'year' ? yearChartData : monthChartData;
const [gameRollingMetricsError, setGameMetricsError] = React.useState<?Error>(
null
);
const [isGameMetricsLoading, setIsGameMetricsLoading] = React.useState(false);
const [error, setError] = React.useState<?Error>(null);
const [isLoading, setIsLoading] = React.useState(false);
// TODO In some timezones, it might ask one less or extra day.
const lastYearIsoDate = formatISO(subDays(new Date(), daysShownForYear), {
representation: 'date',
});
const loadGameMetrics = React.useCallback(
const loadGameAnalytics = React.useCallback(
async () => {
if (!profile) return;
const { id } = profile;
setIsGameMetricsLoading(true);
setGameMetricsError(null);
setIsLoading(true);
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 {
const gameRollingMetrics = await getGameMetricsFrom(
getAuthorizationHeader,
id,
game.id,
lastYearIsoDate
);
const [gameRollingMetrics, gameAdEarnings] = await Promise.all([
getGameMetricsFrom(
getAuthorizationHeader,
id,
game.id,
lastYearIsoDate
),
getGameAdEarnings(getAuthorizationHeader, id, {
gameId: game.id,
startIsoDate: gameCreatioDateIsoDate,
endIsoDate: todayIsoDate,
}),
]);
setGameMetrics(gameRollingMetrics);
setGameAdEarnings(gameAdEarnings);
} catch (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(
() => {
loadGameMetrics();
loadGameAnalytics();
},
[loadGameMetrics]
[loadGameAnalytics]
);
if (isGameMetricsLoading) return <PlaceholderLoader />;
if (isLoading) return <PlaceholderLoader />;
const displaySuggestedMarketingPlan =
recommendedMarketingPlan && gameFeaturings && fetchGameFeaturings;
@@ -109,10 +131,10 @@ export const GameAnalyticsPanel = ({
return (
<I18n>
{({ i18n }) =>
gameRollingMetricsError ? (
error ? (
<PlaceholderError
onRetry={() => {
loadGameMetrics();
loadGameAnalytics();
}}
>
<Trans>There was an issue getting the game analytics.</Trans>{' '}
@@ -136,8 +158,28 @@ export const GameAnalyticsPanel = ({
<Grid
item
xs={12}
sm={displaySuggestedMarketingPlan ? 7 : 12}
md={displaySuggestedMarketingPlan ? 8 : 12}
sm={displaySuggestedMarketingPlan ? 4 : 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>
<Text size="block-title" align="center">
@@ -150,10 +192,11 @@ export const GameAnalyticsPanel = ({
/>
</Column>
</Grid>
{recommendedMarketingPlan &&
gameFeaturings &&
fetchGameFeaturings && (
<Grid item xs={12} sm={5} md={4}>
<Grid item xs={12} sm={4} md={4}>
<MarketingPlanSingleDisplay
fetchGameFeaturings={fetchGameFeaturings}
gameFeaturings={gameFeaturings}

View File

@@ -11,7 +11,6 @@ import Window from '../../Utils/Window';
import RaisedButton from '../../UI/RaisedButton';
import Coin from '../../Credits/Icons/Coin';
import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext';
import PlaceholderError from '../../UI/PlaceholderError';
import Tooltip from '@material-ui/core/Tooltip';
import CreditOutDialog from './CashOutDialog';
import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext';
@@ -49,7 +48,6 @@ const UserEarningsWidget = ({ size }: Props) => {
const [earningsInMilliUsd, setEarningsInMilliUsd] = React.useState(0);
const [earningsInCredits, setEarningsInCredits] = React.useState(0);
const [error, setError] = React.useState(null);
const intervalValuesUpdate = React.useRef(null);
const [selectedCashOutType, setSelectedCashOutType] = React.useState<
@@ -58,43 +56,43 @@ const UserEarningsWidget = ({ size }: Props) => {
const animateEarnings = React.useCallback(
async () => {
if (!userEarningsBalance) return;
try {
// 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);
} catch (error) {
console.error('Unable to get user earnings balance:', error);
setError(error);
if (!userEarningsBalance) {
// In case the user logs out, reset the earnings.
setEarningsInMilliUsd(0);
setEarningsInCredits(0);
return;
}
// 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]
);
@@ -127,16 +125,7 @@ const UserEarningsWidget = ({ size }: Props) => {
userEarningsBalance &&
earningsInMilliUsd >= userEarningsBalance.minAmountToCashoutInMilliUSDs;
const content = error ? (
<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>
) : (
const content = (
<ResponsiveLineStackLayout
noMargin
alignItems="center"

View File

@@ -10,17 +10,19 @@ import { ColumnStackLayout, ResponsiveLineStackLayout } from '../../UI/Layout';
import { Column, Line, Spacer } from '../../UI/Grid';
import Text from '../../UI/Text';
import { type Game } from '../../Utils/GDevelopServices/Game';
import { SessionsChart } from '../GameAnalyticsCharts';
import { GameAdEarningsChart, SessionsChart } from '../GameAnalyticsCharts';
import { type GameMetrics } from '../../Utils/GDevelopServices/Analytics';
import { buildLastWeekChartData } from '../GameAnalyticsEvaluator';
import { buildChartData } from '../GameAnalyticsEvaluator';
import RaisedButton from '../../UI/RaisedButton';
import Coin from '../../Credits/Icons/Coin';
import MarketingPlansDialog from '../../MarketingPlans/MarketingPlansDialog';
import GameLinkAndShareIcons from '../GameLinkAndShareIcons';
import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer';
import { type GameAdEarning } from '../../Utils/GDevelopServices/Usage';
import { getHelpLink } from '../../Utils/HelpLink';
import Window from '../../Utils/Window';
import Link from '../../UI/Link';
import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext';
const publishingHelpLink = getHelpLink('/publishing', 'publish-your-game');
@@ -30,29 +32,29 @@ type Props = {|
game: Game,
onSeeAll: () => void,
gameMetrics: ?Array<GameMetrics>,
gameAdEarnings: ?(GameAdEarning[]),
gameUrl: ?string,
|};
const AnalyticsWidget = ({ game, onSeeAll, gameMetrics, gameUrl }: Props) => {
const AnalyticsWidget = ({
game,
onSeeAll,
gameMetrics,
gameAdEarnings,
gameUrl,
}: Props) => {
const hasNoSession = gameMetrics && gameMetrics.length === 0;
const { isMobile } = useResponsiveWindowSize();
const oneWeekAgoIsoDate = new Date(
new Date().setHours(0, 0, 0, 0) - 7 * 24 * 3600 * 1000
).toISOString();
const [
marketingPlansDialogOpen,
setMarketingPlansDialogOpen,
] = React.useState<boolean>(false);
const { usages } = React.useContext(AuthenticatedUserContext);
const chartData = React.useMemo(
() => {
const lastWeekGameMetrics = gameMetrics
? gameMetrics.filter(metrics => metrics.date > oneWeekAgoIsoDate)
: null;
return buildLastWeekChartData(lastWeekGameMetrics);
},
[gameMetrics, oneWeekAgoIsoDate]
const { weekChartData } = React.useMemo(
() =>
buildChartData({ gameMetrics, gameAdEarnings, usages, gameId: game.id }),
[gameMetrics, gameAdEarnings, usages, game.id]
);
return (
@@ -74,69 +76,89 @@ const AnalyticsWidget = ({ game, onSeeAll, gameMetrics, gameUrl }: Props) => {
widgetName="analytics"
>
<ResponsiveLineStackLayout expand noColumnMargin noMargin>
{!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>
<Column expand noMargin>
<Line alignItems="center">
<Text size="sub-title">
<Trans>Ads earnings</Trans>
</Text>
</Line>
{!gameAdEarnings ? (
<div style={styles.loadingSpace} />
) : (
<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>
)
) : (
<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)}
<Column noMargin>
<GameAdEarningsChart
i18n={i18n}
height={200}
chartData={weekChartData}
fontSize="small"
/>
</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
i18n={i18n}
height={200}
chartData={chartData}
chartData={weekChartData}
fontSize="small"
/>
</Column>
)}
)}
</Column>
</ResponsiveLineStackLayout>
</DashboardWidget>
)}

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
import axios from 'axios';
import { GDevelopAnalyticsApi } from './ApiConfigs';
export type GameMetrics = {
export type GameMetrics = {|
date: string,
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). */
d7RetainedPlayers: number,
},
};
|};
export const client = axios.create({
baseURL: GDevelopAnalyticsApi.baseUrl,

View File

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

View File

@@ -11,6 +11,8 @@ export type Usage = {
userId: string,
type: string,
createdAt: number,
description?: string,
creditsPaid?: number,
};
export type Usages = Array<Usage>;
@@ -192,6 +194,14 @@ export type SubscriptionPlanWithPricingSystems = {|
pricingSystems: SubscriptionPlanPricingSystem[],
|};
export type GameAdEarning = {|
gameId: string,
date: string,
adEarningsInMilliUSDs: number,
adEarningsInCredits: number,
updatedAt: number,
|};
export interface UserEarningsBalance {
userId: string;
amountInMilliUSDs: number;
@@ -313,6 +323,40 @@ export const getUserUsages = async (
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 (
getAuthorizationHeader: () => Promise<string>,
userId: string