From dabc2c9e7b53965a6537520a0fd488b3963f1a34 Mon Sep 17 00:00:00 2001 From: Safwan Shaheer Date: Sat, 26 Oct 2024 14:20:00 +0600 Subject: [PATCH] Builder gems 7 contribution viz on card (#4898) * Update builder card activity script * Gems activity viz * Fixed gems viz circle * Fixed issue with mobile screen * Added script to prefixx builder card activities * Added points map tooltip * Show builder activity map tooltip and dialog * Resolved reviews * Fixed update builder card activity script * Update circiles to fit on mobile screen * Fixed heading for mobile * Prefilled builder card activities --- .../common/Card/BuilderCard/BuilderCard.tsx | 3 +- .../BuilderCardActivity.tsx | 84 +++++++++++ .../BuilderCardActivityTooltip.tsx | 65 +++++++++ .../BuilderCard/BuilderCardNftDisplay.tsx | 22 +-- .../Card/BuilderCard/BuilderCardStats.tsx | 135 ++++++++++++++---- .../lib/builders/getSortedBuilders.ts | 31 +++- .../lib/builders/getTodaysHotBuilders.ts | 22 ++- apps/scoutgame/lib/builders/interfaces.ts | 1 + .../lib/scouts/getScoutedBuilders.ts | 11 +- apps/scoutgamecron/cron.yml | 5 + .../scripts/prefillBuilderCardActivities.ts | 4 + .../src/scripts/seeder/generateBuilder.ts | 2 +- .../scripts/seeder/generateBuilderEvents.ts | 2 +- .../src/scripts/seeder/generateSeedData.ts | 3 + .../tasks/updateBuilderCardActivity/index.ts | 14 ++ .../updateBuilderCardActivity.ts | 76 ++++++++++ apps/scoutgamecron/src/worker.ts | 3 + package-lock.json | 2 +- package.json | 2 +- 19 files changed, 429 insertions(+), 58 deletions(-) create mode 100644 apps/scoutgame/components/common/Card/BuilderCard/BuilderCardActivity/BuilderCardActivity.tsx create mode 100644 apps/scoutgame/components/common/Card/BuilderCard/BuilderCardActivity/BuilderCardActivityTooltip.tsx create mode 100644 apps/scoutgamecron/src/scripts/prefillBuilderCardActivities.ts create mode 100644 apps/scoutgamecron/src/tasks/updateBuilderCardActivity/index.ts create mode 100644 apps/scoutgamecron/src/tasks/updateBuilderCardActivity/updateBuilderCardActivity.ts diff --git a/apps/scoutgame/components/common/Card/BuilderCard/BuilderCard.tsx b/apps/scoutgame/components/common/Card/BuilderCard/BuilderCard.tsx index 50ba2dab77..d888117ae5 100644 --- a/apps/scoutgame/components/common/Card/BuilderCard/BuilderCard.tsx +++ b/apps/scoutgame/components/common/Card/BuilderCard/BuilderCard.tsx @@ -39,11 +39,12 @@ export function BuilderCard({ username={builder.username} showHotIcon={showHotIcon} size={size} + hideDetails={hideDetails} > {builder.builderStatus === 'banned' ? ( SUSPENDED ) : hideDetails ? null : ( - + )} {typeof builder.price !== 'undefined' && showPurchaseButton && ( diff --git a/apps/scoutgame/components/common/Card/BuilderCard/BuilderCardActivity/BuilderCardActivity.tsx b/apps/scoutgame/components/common/Card/BuilderCard/BuilderCardActivity/BuilderCardActivity.tsx new file mode 100644 index 0000000000..a2fc67a08e --- /dev/null +++ b/apps/scoutgame/components/common/Card/BuilderCard/BuilderCardActivity/BuilderCardActivity.tsx @@ -0,0 +1,84 @@ +'use client'; + +import type { Theme } from '@mui/material'; +import { Stack, Tooltip, useMediaQuery } from '@mui/material'; +import { useState } from 'react'; + +import { Dialog } from 'components/common/Dialog'; + +import { BuilderCardActivityTooltip } from './BuilderCardActivityTooltip'; + +export function BuilderCardActivity({ + size, + last7DaysGems +}: { + size: 'x-small' | 'small' | 'medium' | 'large'; + last7DaysGems: number[]; +}) { + const [isDialogOpen, setIsDialogOpen] = useState(false); + const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm'), { noSsr: true }); + let gemHeight = size === 'x-small' ? 12 : size === 'small' ? 13.5 : size === 'medium' ? 14.5 : 16; + if (isMobile) { + gemHeight *= 0.75; + } + return ( + <> + }> + { + if (isMobile) { + e.preventDefault(); + setIsDialogOpen(true); + } + }} + > + {last7DaysGems?.map((gem, index) => { + const height = gem === 0 ? gemHeight * 0.35 : gem <= 29 ? gemHeight * 0.65 : gemHeight; + + return ( + + + + ); + })} + + + { + e.preventDefault(); + }} + onClose={() => setIsDialogOpen(false)} + title='Builder Activity Map' + > + + + + ); +} diff --git a/apps/scoutgame/components/common/Card/BuilderCard/BuilderCardActivity/BuilderCardActivityTooltip.tsx b/apps/scoutgame/components/common/Card/BuilderCard/BuilderCardActivity/BuilderCardActivityTooltip.tsx new file mode 100644 index 0000000000..1f29aaa9f0 --- /dev/null +++ b/apps/scoutgame/components/common/Card/BuilderCard/BuilderCardActivity/BuilderCardActivityTooltip.tsx @@ -0,0 +1,65 @@ +import { Stack, Typography } from '@mui/material'; + +import { GemsIcon } from 'components/common/Icons'; + +export function BuilderCardActivityTooltip() { + return ( + + + + + + + = Scored 30+ + + + + + + + + + = Scored 1 to 29 + + + + + + + + + = No activity + + + + + Empty + + + = No data + + + + ); +} diff --git a/apps/scoutgame/components/common/Card/BuilderCard/BuilderCardNftDisplay.tsx b/apps/scoutgame/components/common/Card/BuilderCard/BuilderCardNftDisplay.tsx index 4b715c4050..fd5e161bc1 100644 --- a/apps/scoutgame/components/common/Card/BuilderCard/BuilderCardNftDisplay.tsx +++ b/apps/scoutgame/components/common/Card/BuilderCard/BuilderCardNftDisplay.tsx @@ -26,13 +26,15 @@ export function BuilderCardNftDisplay({ children, username, showHotIcon = false, - size = 'medium' + size = 'medium', + hideDetails = false }: { username: string; nftImageUrl?: string | null; showHotIcon?: boolean; children?: React.ReactNode; size?: 'x-small' | 'small' | 'medium' | 'large'; + hideDetails?: boolean; }) { const width = nftDisplaySize[size].width; const height = nftDisplaySize[size].height; @@ -80,25 +82,13 @@ export function BuilderCardNftDisplay({ {nftImageUrl ? null : ( diff --git a/apps/scoutgame/components/common/Card/BuilderCard/BuilderCardStats.tsx b/apps/scoutgame/components/common/Card/BuilderCard/BuilderCardStats.tsx index 4ffe988361..430afaf508 100644 --- a/apps/scoutgame/components/common/Card/BuilderCard/BuilderCardStats.tsx +++ b/apps/scoutgame/components/common/Card/BuilderCard/BuilderCardStats.tsx @@ -3,46 +3,119 @@ import Image from 'next/image'; import { PointsIcon } from 'components/common/Icons'; +import { BuilderCardActivity } from './BuilderCardActivity/BuilderCardActivity'; + export function BuilderCardStats({ + username, builderPoints, nftsSold, - rank + rank, + last7DaysGems, + size }: { + username: string; builderPoints?: number; nftsSold?: number; rank?: number; + last7DaysGems?: number[]; + size: 'x-small' | 'small' | 'medium' | 'large'; }) { return ( - - {typeof builderPoints === 'number' && ( - - - - {builderPoints} - - - - - )} - {typeof rank === 'number' && ( - - - - #{rank} - - - - )} - {typeof nftsSold === 'number' && ( - - - - {nftsSold} - - Nfts - - - )} + + + @{username} + + + {typeof builderPoints === 'number' && ( + + + + {builderPoints} + + + + + )} + {typeof rank === 'number' && ( + + + + #{rank} + + + + )} + {typeof nftsSold === 'number' && ( + + + + {nftsSold} + + Nfts + + + )} + + + + + 7 DAY ACTIVITY + + + + ); } diff --git a/apps/scoutgame/lib/builders/getSortedBuilders.ts b/apps/scoutgame/lib/builders/getSortedBuilders.ts index 2a84f0b160..ac06fcd8b0 100644 --- a/apps/scoutgame/lib/builders/getSortedBuilders.ts +++ b/apps/scoutgame/lib/builders/getSortedBuilders.ts @@ -1,6 +1,7 @@ import { prisma } from '@charmverse/core/prisma-client'; import { getPreviousWeek } from '@packages/scoutgame/dates'; +import type { Last7DaysGems } from './getTodaysHotBuilders'; import type { BuilderInfo } from './interfaces'; export type BuildersSort = 'top' | 'hot' | 'new'; @@ -58,6 +59,11 @@ export async function getSortedBuilders({ } } }, + builderCardActivities: { + select: { + last7Days: true + } + }, userWeeklyStats: { where: { week @@ -91,7 +97,10 @@ export async function getSortedBuilders({ scoutedBy: scout.builderNfts?.[0]?.nftSoldEvents?.length ?? 0, rank: scout.userWeeklyStats[0]?.rank ?? -1, nftsSold: scout.userSeasonStats[0]?.nftsSold ?? 0, - builderStatus: scout.builderStatus! + builderStatus: scout.builderStatus!, + last7DaysGems: ((scout.builderCardActivities[0]?.last7Days as unknown as Last7DaysGems) || []) + .map((gem) => gem.gemsCount) + .slice(-7) })); }); const userId = builders[builders.length - 1]?.id; @@ -145,6 +154,11 @@ export async function getSortedBuilders({ } } }, + builderCardActivities: { + select: { + last7Days: true + } + }, userAllTimeStats: { select: { pointsEarnedAsBuilder: true @@ -172,7 +186,10 @@ export async function getSortedBuilders({ price: stat.user.builderNfts?.[0]?.currentPrice ?? 0, scoutedBy: stat.user.builderNfts?.[0]?.nftSoldEvents?.length ?? 0, nftsSold: stat.user.userSeasonStats[0]?.nftsSold ?? 0, - builderStatus: stat.user.builderStatus! + builderStatus: stat.user.builderStatus!, + last7DaysGems: ((stat.user.builderCardActivities[0]?.last7Days as unknown as Last7DaysGems) || []) + .map((gem) => gem.gemsCount) + .slice(-7) })) ); const userId = builders[builders.length - 1]?.id; @@ -221,6 +238,11 @@ export async function getSortedBuilders({ pointsEarnedAsBuilder: true } }, + builderCardActivities: { + select: { + last7Days: true + } + }, userSeasonStats: { where: { season @@ -251,7 +273,10 @@ export async function getSortedBuilders({ builderPoints: stat.user.userAllTimeStats[0]?.pointsEarnedAsBuilder ?? 0, price: stat.user.builderNfts?.[0]?.currentPrice ?? 0, nftsSold: stat.user.userSeasonStats[0]?.nftsSold ?? 0, - builderStatus: stat.user.builderStatus! + builderStatus: stat.user.builderStatus!, + last7DaysGems: ((stat.user.builderCardActivities[0]?.last7Days as unknown as Last7DaysGems) || []) + .map((gem) => gem.gemsCount) + .slice(-7) })) ); const userId = builders[builders.length - 1]?.id; diff --git a/apps/scoutgame/lib/builders/getTodaysHotBuilders.ts b/apps/scoutgame/lib/builders/getTodaysHotBuilders.ts index 386f3f77dd..a071193106 100644 --- a/apps/scoutgame/lib/builders/getTodaysHotBuilders.ts +++ b/apps/scoutgame/lib/builders/getTodaysHotBuilders.ts @@ -6,6 +6,8 @@ import { BasicUserInfoSelect } from 'lib/users/queries'; import type { BuilderInfo } from './interfaces'; +export type Last7DaysGems = { date: string; gemsCount: number }[]; + const preselectedBuilderUsernames = [ 'samkuhlmann', 'earth2travis', @@ -39,6 +41,11 @@ export async function getTodaysHotBuilders(): Promise { nftsSold: true } }, + builderCardActivities: { + select: { + last7Days: true + } + }, userWeeklyStats: { where: { week: getCurrentWeek() @@ -69,7 +76,10 @@ export async function getTodaysHotBuilders(): Promise { nftImageUrl: builder.builderNfts[0]?.imageUrl, nftsSold: builder.userSeasonStats[0]?.nftsSold || 0, builderStatus: builder.builderStatus!, - rank: builder.userWeeklyStats[0]?.rank || -1 + rank: builder.userWeeklyStats[0]?.rank || -1, + last7DaysGems: ((builder.builderCardActivities[0]?.last7Days as unknown as Last7DaysGems) || []) + .map((gem) => gem.gemsCount) + .slice(-7) }; }) .sort((a, b) => preselectedBuilderUsernames.indexOf(a.id) - preselectedBuilderUsernames.indexOf(b.id)); @@ -96,6 +106,11 @@ export async function getTodaysHotBuilders(): Promise { user: { select: { ...BasicUserInfoSelect, + builderCardActivities: { + select: { + last7Days: true + } + }, userSeasonStats: { where: { season: currentSeason @@ -149,7 +164,10 @@ export async function getTodaysHotBuilders(): Promise { nftsSold: user.userSeasonStats[0]?.nftsSold || 0, rank: user.userWeeklyStats[0]?.rank || -1, scoutedBy: user.nftPurchaseEvents.length, - builderStatus: user.builderStatus! + builderStatus: user.builderStatus!, + last7DaysGems: ((user.builderCardActivities[0]?.last7Days as unknown as Last7DaysGems) || []) + .map((gem) => gem.gemsCount) + .slice(-7) }; }); diff --git a/apps/scoutgame/lib/builders/interfaces.ts b/apps/scoutgame/lib/builders/interfaces.ts index 7dd42cc052..cbb9b3a8d4 100644 --- a/apps/scoutgame/lib/builders/interfaces.ts +++ b/apps/scoutgame/lib/builders/interfaces.ts @@ -17,6 +17,7 @@ export type BuilderMetrics = { price: bigint; rank: number; builderPoints: number; + last7DaysGems: number[]; }; export type BuilderInfo = BasicUserInfo & diff --git a/apps/scoutgame/lib/scouts/getScoutedBuilders.ts b/apps/scoutgame/lib/scouts/getScoutedBuilders.ts index a064161018..a4e884b35b 100644 --- a/apps/scoutgame/lib/scouts/getScoutedBuilders.ts +++ b/apps/scoutgame/lib/scouts/getScoutedBuilders.ts @@ -1,6 +1,7 @@ import { prisma } from '@charmverse/core/prisma-client'; import { currentSeason, getCurrentWeek } from '@packages/scoutgame/dates'; +import type { Last7DaysGems } from 'lib/builders/getTodaysHotBuilders'; import { BasicUserInfoSelect } from 'lib/users/queries'; import type { BuilderInfo } from '../builders/interfaces'; @@ -51,6 +52,11 @@ export async function getScoutedBuilders({ scoutId }: { scoutId: string }): Prom currentPrice: true } }, + builderCardActivities: { + select: { + last7Days: true + } + }, builderStatus: true, userWeeklyStats: { where: { @@ -76,7 +82,10 @@ export async function getScoutedBuilders({ scoutId }: { scoutId: string }): Prom nftsSold: builder.userSeasonStats[0]?.nftsSold ?? 0, nftsSoldToScout, rank: builder.userWeeklyStats[0]?.rank ?? -1, - price: builder.builderNfts[0]?.currentPrice ?? 0 + price: builder.builderNfts[0]?.currentPrice ?? 0, + last7DaysGems: ((builder.builderCardActivities[0]?.last7Days as unknown as Last7DaysGems) || []) + .map((gem) => gem.gemsCount) + .slice(-7) }; }); } diff --git a/apps/scoutgamecron/cron.yml b/apps/scoutgamecron/cron.yml index 667c9ae62e..8735e059c4 100644 --- a/apps/scoutgamecron/cron.yml +++ b/apps/scoutgamecron/cron.yml @@ -24,3 +24,8 @@ cron: url: '/alert-low-wallet-gas-balance' # Every 5 minutes schedule: '*/5 * * * *' + + - name: 'update-builder-card-activity' + url: '/update-builder-card-activity' + # Every hour + schedule: '0 * * * *' diff --git a/apps/scoutgamecron/src/scripts/prefillBuilderCardActivities.ts b/apps/scoutgamecron/src/scripts/prefillBuilderCardActivities.ts new file mode 100644 index 0000000000..a9332f18ca --- /dev/null +++ b/apps/scoutgamecron/src/scripts/prefillBuilderCardActivities.ts @@ -0,0 +1,4 @@ +import { DateTime } from 'luxon'; +import { updateBuilderCardActivity } from '../tasks/updateBuilderCardActivity/updateBuilderCardActivity'; + +updateBuilderCardActivity(DateTime.utc()) \ No newline at end of file diff --git a/apps/scoutgamecron/src/scripts/seeder/generateBuilder.ts b/apps/scoutgamecron/src/scripts/seeder/generateBuilder.ts index cf873b8932..50b1b19b55 100644 --- a/apps/scoutgamecron/src/scripts/seeder/generateBuilder.ts +++ b/apps/scoutgamecron/src/scripts/seeder/generateBuilder.ts @@ -101,7 +101,7 @@ export async function generateBuilder({ index }: { index: number }) { agreedToTermsAt: new Date(), onboardedAt: new Date(), walletAddress: faker.finance.ethereumAddress(), - farcasterId: faker.number.int({ min: 1, max: 5000 }) + index, + farcasterId: faker.number.int({ min: 1, max: 5000000 }) + index, farcasterName: displayName, builderStatus, githubUser: { diff --git a/apps/scoutgamecron/src/scripts/seeder/generateBuilderEvents.ts b/apps/scoutgamecron/src/scripts/seeder/generateBuilderEvents.ts index 770402b21a..4cbe1bf5d6 100644 --- a/apps/scoutgamecron/src/scripts/seeder/generateBuilderEvents.ts +++ b/apps/scoutgamecron/src/scripts/seeder/generateBuilderEvents.ts @@ -34,7 +34,7 @@ async function processBuilderActivity({ if (repo) { try { if (pullRequest.state === 'CLOSED') { - await recordClosedPullRequest({ pullRequest, repo, season, prClosedBy: v4() }); + await recordClosedPullRequest({ pullRequest, repo, season, prClosedBy: v4(), skipSendingComment: true }); } else { await recordMergedPullRequest({ pullRequest, repo, season, skipFirstMergedPullRequestCheck: true }); } diff --git a/apps/scoutgamecron/src/scripts/seeder/generateSeedData.ts b/apps/scoutgamecron/src/scripts/seeder/generateSeedData.ts index 01cf4c0fef..4c7d88bd68 100644 --- a/apps/scoutgamecron/src/scripts/seeder/generateSeedData.ts +++ b/apps/scoutgamecron/src/scripts/seeder/generateSeedData.ts @@ -14,6 +14,7 @@ import { generateBuilderEvents } from './generateBuilderEvents'; import { generateGithubRepos } from './generateGithubRepos'; import { generateNftPurchaseEvents } from './generateNftPurchaseEvents'; import { generateScout } from './generateScout'; +import { updateBuilderCardActivity } from '../../tasks/updateBuilderCardActivity/updateBuilderCardActivity'; export type BuilderInfo = { id: string; @@ -134,6 +135,8 @@ export async function generateSeedData() { totalNftsPurchasedEvents += dailyNftsPurchased; } + await updateBuilderCardActivity(date.minus({ days: 1 })); + // Check if we are at the end of the week if (date.weekday === 7) { const topWeeklyBuilders = await getBuildersLeaderboard({ quantity: 100, week }); diff --git a/apps/scoutgamecron/src/tasks/updateBuilderCardActivity/index.ts b/apps/scoutgamecron/src/tasks/updateBuilderCardActivity/index.ts new file mode 100644 index 0000000000..1516ada50b --- /dev/null +++ b/apps/scoutgamecron/src/tasks/updateBuilderCardActivity/index.ts @@ -0,0 +1,14 @@ +import { log } from '@charmverse/core/log'; +import type Koa from 'koa'; +import { DateTime } from 'luxon'; + +import { updateBuilderCardActivity } from './updateBuilderCardActivity'; + +export async function updateAllBuilderCardActivities( + ctx: Koa.Context, + { date = DateTime.now() }: { date?: DateTime } = {} +) { + log.info('Updating builder card activities'); + const updatedBuilders = await updateBuilderCardActivity(date.minus({ days: 1 })); + log.info(`Updated ${updatedBuilders} builder card activities`); +} diff --git a/apps/scoutgamecron/src/tasks/updateBuilderCardActivity/updateBuilderCardActivity.ts b/apps/scoutgamecron/src/tasks/updateBuilderCardActivity/updateBuilderCardActivity.ts new file mode 100644 index 0000000000..b4371cf51b --- /dev/null +++ b/apps/scoutgamecron/src/tasks/updateBuilderCardActivity/updateBuilderCardActivity.ts @@ -0,0 +1,76 @@ +import { log } from '@charmverse/core/log'; +import { prisma } from '@charmverse/core/prisma-client'; +import { DateTime } from 'luxon'; + +export type Last7DaysGems = { date: string; gemsCount: number }[]; + +export async function updateBuilderCardActivity(date: DateTime) { + const builders = await prisma.scout.findMany({ + where: { + builderStatus: { + in: ['approved', 'banned'] + } + }, + select: { + id: true, + builderCardActivities: true, + events: { + where: { + gemsReceipt: { + isNot: null + }, + createdAt: { + gte: date.minus({ days: 7 }).startOf('day').toISO(), + lte: date.minus({ days: 1 }).endOf('day').toISO() + } + }, + select: { + createdAt: true, + gemsReceipt: { + select: { + value: true + } + } + } + } + } + }); + + let updatedBuilders = 0; + + for (const builder of builders) { + try { + const dayGemsRecord: Record = {}; + builder.events.forEach((event) => { + const formattedDate = DateTime.fromJSDate(event.createdAt).toFormat('yyyy-MM-dd'); + dayGemsRecord[formattedDate] = (dayGemsRecord[formattedDate] ?? 0) + (event.gemsReceipt?.value ?? 0); + }); + + const last7Days = Array.from({ length: 7 }, (_, i) => date.minus({ days: 7 - i }).toFormat('yyyy-MM-dd')); + + const last7DaysGems = last7Days.map((day) => ({ + date: day, + gemsCount: dayGemsRecord[day] ?? 0 + })); + + await prisma.builderCardActivity.upsert({ + where: { builderId: builder.id }, + update: { last7Days: last7DaysGems }, + create: { builderId: builder.id, last7Days: last7DaysGems } + }); + updatedBuilders += 1; + log.info(`Updated builder card activity for builder`, { + builderId: builder.id, + date + }); + } catch (error) { + log.error(`Error updating builder card activity for builder`, { + builderId: builder.id, + date, + error + }); + } + } + + return updatedBuilders; +} diff --git a/apps/scoutgamecron/src/worker.ts b/apps/scoutgamecron/src/worker.ts index a3c24e4b6c..814c56072e 100644 --- a/apps/scoutgamecron/src/worker.ts +++ b/apps/scoutgamecron/src/worker.ts @@ -9,6 +9,7 @@ import { processAllBuilderActivity } from './tasks/processBuilderActivity'; import { processGemsPayout } from './tasks/processGemsPayout'; import { processNftMints } from './tasks/processNftMints'; import { sendNotifications } from './tasks/pushNotifications/sendNotifications'; +import { updateAllBuilderCardActivities } from './tasks/updateBuilderCardActivity'; import { updateMixpanelUserProfilesTask } from './tasks/updateMixpanelProfilesTask'; const app = new Koa(); @@ -57,6 +58,8 @@ addTask('/update-mixpanel-user-profiles', updateMixpanelUserProfilesTask); addTask('/alert-low-wallet-gas-balance', alertLowWalletGasBalance); +addTask('/update-builder-card-activity', updateAllBuilderCardActivities); + // Standard health check used by Beanstalk router.get('/api/health', middleware.healthCheck); diff --git a/package-lock.json b/package-lock.json index 0518668c88..ca93e96216 100644 --- a/package-lock.json +++ b/package-lock.json @@ -118693,4 +118693,4 @@ } } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index b4e2010904..0ab57cfc9e 100644 --- a/package.json +++ b/package.json @@ -468,4 +468,4 @@ "msw": { "workerDirectory": "public" } -} +} \ No newline at end of file