[offers][feat] Add multiple company analysis

This commit is contained in:
Bryann Yeap Kok Keong
2022-10-31 11:41:36 +08:00
committed by Bryann Yeap Kok Keong
parent 68f3c72945
commit 91696571fe
17 changed files with 754 additions and 572 deletions

View File

@ -0,0 +1,113 @@
/*
Warnings:
- You are about to drop the column `companyPercentile` on the `OffersAnalysis` table. All the data in the column will be lost.
- You are about to drop the column `noOfSimilarCompanyOffers` on the `OffersAnalysis` table. All the data in the column will be lost.
- You are about to drop the column `noOfSimilarOffers` on the `OffersAnalysis` table. All the data in the column will be lost.
- You are about to drop the column `overallPercentile` on the `OffersAnalysis` table. All the data in the column will be lost.
- You are about to drop the column `userId` on the `OffersProfile` table. All the data in the column will be lost.
- You are about to drop the `_TopCompanyOffers` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `_TopOverallOffers` table. If the table is not empty, all the data it contains will be lost.
- Added the required column `overallAnalysisUnitId` to the `OffersAnalysis` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `OffersAnalysis` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "OffersProfile" DROP CONSTRAINT "OffersProfile_userId_fkey";
-- DropForeignKey
ALTER TABLE "_TopCompanyOffers" DROP CONSTRAINT "_TopCompanyOffers_A_fkey";
-- DropForeignKey
ALTER TABLE "_TopCompanyOffers" DROP CONSTRAINT "_TopCompanyOffers_B_fkey";
-- DropForeignKey
ALTER TABLE "_TopOverallOffers" DROP CONSTRAINT "_TopOverallOffers_A_fkey";
-- DropForeignKey
ALTER TABLE "_TopOverallOffers" DROP CONSTRAINT "_TopOverallOffers_B_fkey";
-- AlterTable
ALTER TABLE "OffersAnalysis" DROP COLUMN "companyPercentile",
DROP COLUMN "noOfSimilarCompanyOffers",
DROP COLUMN "noOfSimilarOffers",
DROP COLUMN "overallPercentile",
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "overallAnalysisUnitId" TEXT NOT NULL,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
-- AlterTable
ALTER TABLE "OffersProfile" DROP COLUMN "userId";
-- DropTable
DROP TABLE "_TopCompanyOffers";
-- DropTable
DROP TABLE "_TopOverallOffers";
-- CreateTable
CREATE TABLE "OffersAnalysisUnit" (
"id" TEXT NOT NULL,
"companyName" TEXT NOT NULL,
"percentile" DOUBLE PRECISION NOT NULL,
"noOfSimilarOffers" INTEGER NOT NULL,
CONSTRAINT "OffersAnalysisUnit_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_OffersProfileToUser" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "_CompanyAnalysis" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "_OffersAnalysisUnitToOffersOffer" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "_OffersProfileToUser_AB_unique" ON "_OffersProfileToUser"("A", "B");
-- CreateIndex
CREATE INDEX "_OffersProfileToUser_B_index" ON "_OffersProfileToUser"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_CompanyAnalysis_AB_unique" ON "_CompanyAnalysis"("A", "B");
-- CreateIndex
CREATE INDEX "_CompanyAnalysis_B_index" ON "_CompanyAnalysis"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_OffersAnalysisUnitToOffersOffer_AB_unique" ON "_OffersAnalysisUnitToOffersOffer"("A", "B");
-- CreateIndex
CREATE INDEX "_OffersAnalysisUnitToOffersOffer_B_index" ON "_OffersAnalysisUnitToOffersOffer"("B");
-- AddForeignKey
ALTER TABLE "OffersAnalysis" ADD CONSTRAINT "OffersAnalysis_overallAnalysisUnitId_fkey" FOREIGN KEY ("overallAnalysisUnitId") REFERENCES "OffersAnalysisUnit"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_OffersProfileToUser" ADD CONSTRAINT "_OffersProfileToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "OffersProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_OffersProfileToUser" ADD CONSTRAINT "_OffersProfileToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_CompanyAnalysis" ADD CONSTRAINT "_CompanyAnalysis_A_fkey" FOREIGN KEY ("A") REFERENCES "OffersAnalysis"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_CompanyAnalysis" ADD CONSTRAINT "_CompanyAnalysis_B_fkey" FOREIGN KEY ("B") REFERENCES "OffersAnalysisUnit"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_OffersAnalysisUnitToOffersOffer" ADD CONSTRAINT "_OffersAnalysisUnitToOffersOffer_A_fkey" FOREIGN KEY ("A") REFERENCES "OffersAnalysisUnit"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_OffersAnalysisUnitToOffersOffer" ADD CONSTRAINT "_OffersAnalysisUnitToOffersOffer_B_fkey" FOREIGN KEY ("B") REFERENCES "OffersOffer"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -225,8 +225,7 @@ model OffersProfile {
offers OffersOffer[] offers OffersOffer[]
user User? @relation(fields: [userId], references: [id]) users User[]
userId String?
analysis OffersAnalysis? analysis OffersAnalysis?
} }
@ -362,9 +361,8 @@ model OffersOffer {
offersFullTime OffersFullTime? @relation(fields: [offersFullTimeId], references: [id], onDelete: Cascade) offersFullTime OffersFullTime? @relation(fields: [offersFullTimeId], references: [id], onDelete: Cascade)
offersFullTimeId String? @unique offersFullTimeId String? @unique
OffersAnalysis OffersAnalysis? @relation("HighestOverallOffer") offersAnalysis OffersAnalysis? @relation("HighestOverallOffer")
OffersAnalysisTopOverallOffers OffersAnalysis[] @relation("TopOverallOffers") offersAnalysisUnit OffersAnalysisUnit[]
OffersAnalysisTopCompanyOffers OffersAnalysis[] @relation("TopCompanyOffers")
} }
model OffersIntern { model OffersIntern {
@ -396,7 +394,9 @@ model OffersFullTime {
} }
model OffersAnalysis { model OffersAnalysis {
id String @id @default(cuid()) id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
profile OffersProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) profile OffersProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
profileId String @unique profileId String @unique
@ -405,14 +405,22 @@ model OffersAnalysis {
offerId String @unique offerId String @unique
// OVERALL // OVERALL
overallPercentile Float overallAnalysis OffersAnalysisUnit @relation("OverallAnalysis", fields: [overallAnalysisUnitId], references: [id])
noOfSimilarOffers Int overallAnalysisUnitId String
topOverallOffers OffersOffer[] @relation("TopOverallOffers")
// Company companyAnalysis OffersAnalysisUnit[] @relation("CompanyAnalysis")
companyPercentile Float }
noOfSimilarCompanyOffers Int
topCompanyOffers OffersOffer[] @relation("TopCompanyOffers") model OffersAnalysisUnit {
id String @id @default(cuid())
companyName String
percentile Float
noOfSimilarOffers Int
topSimilarOffers OffersOffer[]
offersAnalysisOverall OffersAnalysis[] @relation("OverallAnalysis")
offersAnalysisCompany OffersAnalysis[] @relation("CompanyAnalysis")
} }
// End of Offers project models. // End of Offers project models.

View File

@ -7,7 +7,7 @@ const navigation: ProductNavigationItems = [
const navigationAuthenticated: ProductNavigationItems = [ const navigationAuthenticated: ProductNavigationItems = [
{ href: '/offers/submit', name: 'Analyze your offers' }, { href: '/offers/submit', name: 'Analyze your offers' },
{ href: '/offers/dashboard', name: 'Your repository' }, { href: '/offers/dashboard', name: 'Your dashboard' },
{ href: '/offers/features', name: 'Features' }, { href: '/offers/features', name: 'Features' },
]; ];

View File

@ -6,29 +6,20 @@ import OfferPercentileAnalysisText from './OfferPercentileAnalysisText';
import OfferProfileCard from './OfferProfileCard'; import OfferProfileCard from './OfferProfileCard';
import { OVERALL_TAB } from '../constants'; import { OVERALL_TAB } from '../constants';
import type { import type { AnalysisUnit, ProfileAnalysis } from '~/types/offers';
Analysis,
AnalysisHighestOffer,
ProfileAnalysis,
} from '~/types/offers';
type OfferAnalysisData = {
offer?: AnalysisHighestOffer;
offerAnalysis?: Analysis;
};
type OfferAnalysisContentProps = Readonly<{ type OfferAnalysisContentProps = Readonly<{
analysis: OfferAnalysisData; analysis: AnalysisUnit;
isSubmission: boolean; isSubmission: boolean;
tab: string; tab: string;
}>; }>;
function OfferAnalysisContent({ function OfferAnalysisContent({
analysis: { offer, offerAnalysis }, analysis,
tab, tab,
isSubmission, isSubmission,
}: OfferAnalysisContentProps) { }: OfferAnalysisContentProps) {
if (!offerAnalysis || !offer || offerAnalysis.noOfOffers === 0) { if (!analysis || analysis.noOfOffers === 0) {
if (tab === OVERALL_TAB) { if (tab === OVERALL_TAB) {
return ( return (
<p className="m-10"> <p className="m-10">
@ -47,9 +38,8 @@ function OfferAnalysisContent({
return ( return (
<> <>
<OfferPercentileAnalysisText <OfferPercentileAnalysisText
companyName={offer.company.name} analysis={analysis}
isSubmission={isSubmission} isSubmission={isSubmission}
offerAnalysis={offerAnalysis}
tab={tab} tab={tab}
/> />
<p className="mt-5"> <p className="mt-5">
@ -57,7 +47,7 @@ function OfferAnalysisContent({
? 'Here are some of the top offers relevant to you:' ? 'Here are some of the top offers relevant to you:'
: 'Relevant top offers:'} : 'Relevant top offers:'}
</p> </p>
{offerAnalysis.topPercentileOffers.map((topPercentileOffer) => ( {analysis.topPercentileOffers.map((topPercentileOffer) => (
<OfferProfileCard <OfferProfileCard
key={topPercentileOffer.id} key={topPercentileOffer.id}
offerProfile={topPercentileOffer} offerProfile={topPercentileOffer}
@ -77,7 +67,7 @@ function OfferAnalysisContent({
} }
type OfferAnalysisProps = Readonly<{ type OfferAnalysisProps = Readonly<{
allAnalysis?: ProfileAnalysis | null; allAnalysis: ProfileAnalysis;
isError: boolean; isError: boolean;
isLoading: boolean; isLoading: boolean;
isSubmission?: boolean; isSubmission?: boolean;
@ -90,61 +80,55 @@ export default function OfferAnalysis({
isSubmission = false, isSubmission = false,
}: OfferAnalysisProps) { }: OfferAnalysisProps) {
const [tab, setTab] = useState(OVERALL_TAB); const [tab, setTab] = useState(OVERALL_TAB);
const [analysis, setAnalysis] = useState<OfferAnalysisData | null>(null); const [analysis, setAnalysis] = useState<AnalysisUnit>(
allAnalysis.overallAnalysis,
);
useEffect(() => { useEffect(() => {
if (tab === OVERALL_TAB) { if (tab === OVERALL_TAB) {
setAnalysis({ setAnalysis(allAnalysis.overallAnalysis);
offer: allAnalysis?.overallHighestOffer,
offerAnalysis: allAnalysis?.overallAnalysis,
});
} else { } else {
setAnalysis({ setAnalysis(allAnalysis.companyAnalysis[parseInt(tab, 10)]);
offer: allAnalysis?.overallHighestOffer,
offerAnalysis: allAnalysis?.companyAnalysis[0],
});
} }
}, [tab, allAnalysis]); }, [tab, allAnalysis]);
const tabOptions = [ const companyTabs = allAnalysis.companyAnalysis.map((value, index) => ({
label: value.companyName,
value: `${index}`,
}));
let tabOptions = [
{ {
label: OVERALL_TAB, label: OVERALL_TAB,
value: OVERALL_TAB, value: OVERALL_TAB,
}, },
{
label: allAnalysis?.overallHighestOffer.company.name || '',
value: allAnalysis?.overallHighestOffer.company.id || '',
},
]; ];
tabOptions = tabOptions.concat(companyTabs);
return ( return (
<> <div>
{isError && (
<p className="m-10 text-center">
An error occurred while generating profile analysis.
</p>
)}
{isLoading && <Spinner className="m-10" display="block" size="lg" />} {isLoading && <Spinner className="m-10" display="block" size="lg" />}
{analysis && ( {!isError && !isLoading && (
<div> <div>
{isError && ( <Tabs
<p className="m-10 text-center"> label="Result Navigation"
An error occurred while generating profile analysis. tabs={tabOptions}
</p> value={tab}
)} onChange={setTab}
{!isError && !isLoading && ( />
<div> <HorizontalDivider className="mb-5" />
<Tabs <OfferAnalysisContent
label="Result Navigation" analysis={analysis}
tabs={tabOptions} isSubmission={isSubmission}
value={tab} tab={tab}
onChange={setTab} />
/>
<HorizontalDivider className="mb-5" />
<OfferAnalysisContent
analysis={analysis}
isSubmission={isSubmission}
tab={tab}
/>
</div>
)}
</div> </div>
)} )}
</> </div>
); );
} }

View File

@ -1,18 +1,16 @@
import { OVERALL_TAB } from '../constants'; import { OVERALL_TAB } from '../constants';
import type { Analysis } from '~/types/offers'; import type { AnalysisUnit } from '~/types/offers';
type OfferPercentileAnalysisTextProps = Readonly<{ type OfferPercentileAnalysisTextProps = Readonly<{
companyName: string; analysis: AnalysisUnit;
isSubmission: boolean; isSubmission: boolean;
offerAnalysis: Analysis;
tab: string; tab: string;
}>; }>;
export default function OfferPercentileAnalysisText({ export default function OfferPercentileAnalysisText({
tab, tab,
companyName, analysis: { noOfOffers, percentile, companyName },
offerAnalysis: { noOfOffers, percentile },
isSubmission, isSubmission,
}: OfferPercentileAnalysisTextProps) { }: OfferPercentileAnalysisTextProps) {
return tab === OVERALL_TAB ? ( return tab === OVERALL_TAB ? (

View File

@ -47,11 +47,13 @@ export default function OfferProfileCard({
</div> </div>
<div className="col-span-10"> <div className="col-span-10">
<p className="font-bold">{profileName}</p> <p className="font-bold">{profileName}</p>
<div className="flex flex-row"> {previousCompanies.length > 0 && (
<BuildingOffice2Icon className="mr-2 h-5" /> <div className="flex flex-row">
<span className="mr-2 font-bold">Current:</span> <BuildingOffice2Icon className="mr-2 h-5" />
<span>{previousCompanies[0]}</span> <span className="mr-2 font-bold">Current:</span>
</div> <span>{previousCompanies[0]}</span>
</div>
)}
<div className="flex flex-row"> <div className="flex flex-row">
<CalendarDaysIcon className="mr-2 h-5" /> <CalendarDaysIcon className="mr-2 h-5" />
<span className="mr-2 font-bold">YOE:</span> <span className="mr-2 font-bold">YOE:</span>

View File

@ -34,7 +34,7 @@ export default function OffersProfileSave({
}, },
onSuccess: () => { onSuccess: () => {
showToast({ showToast({
title: `Saved to your repository!`, title: `Saved to your dashboard!`,
variant: 'success', variant: 'success',
}); });
}, },
@ -95,8 +95,8 @@ export default function OffersProfileSave({
</div> </div>
<p className="mb-5 text-slate-900"> <p className="mb-5 text-slate-900">
If you do not want to keep the edit link, you can opt to save this If you do not want to keep the edit link, you can opt to save this
profile under your account's respository. It will still only be profile under your account's dashboard. It will still only be editable
editable by you. by you.
</p> </p>
<div className="mb-20"> <div className="mb-20">
<Button <Button

View File

@ -17,13 +17,17 @@ export default function OffersSubmissionAnalysis({
<h5 className="mb-8 text-center text-4xl font-bold text-slate-900"> <h5 className="mb-8 text-center text-4xl font-bold text-slate-900">
Result Result
</h5> </h5>
<OfferAnalysis {!analysis && (
key={3} <p className="mb-8 text-center">Error generating analysis.</p>
allAnalysis={analysis} )}
isError={isError} {analysis && (
isLoading={isLoading} <OfferAnalysis
isSubmission={true} key={3}
/> allAnalysis={analysis}
isError={isError}
isLoading={isLoading}
/>
)}
</div> </div>
); );
} }

View File

@ -115,7 +115,15 @@ function ProfileAnalysis({
return ( return (
<div className="mx-8 my-4"> <div className="mx-8 my-4">
<OfferAnalysis allAnalysis={analysis} isError={false} isLoading={false} /> {!analysis ? (
<p>No analysis available.</p>
) : (
<OfferAnalysis
allAnalysis={analysis}
isError={false}
isLoading={false}
/>
)}
{isEditable && ( {isEditable && (
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button

View File

@ -41,7 +41,7 @@ export default function ProfileHeader({
setSelectedTab, setSelectedTab,
}: ProfileHeaderProps) { }: ProfileHeaderProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
// Const [saved, setSaved] = useState(isSaved); const [saved, setSaved] = useState(isSaved);
const router = useRouter(); const router = useRouter();
const trpcContext = trpc.useContext(); const trpcContext = trpc.useContext();
const { offerProfileId = '', token = '' } = router.query; const { offerProfileId = '', token = '' } = router.query;
@ -60,7 +60,7 @@ export default function ProfileHeader({
}); });
}, },
onSuccess: () => { onSuccess: () => {
// SetSaved(true); setSaved(true);
showToast({ showToast({
title: `Saved to dashboard!`, title: `Saved to dashboard!`,
variant: 'success', variant: 'success',
@ -79,7 +79,7 @@ export default function ProfileHeader({
}); });
}, },
onSuccess: () => { onSuccess: () => {
// SetSaved(false); setSaved(false);
showToast({ showToast({
title: `Removed from dashboard!`, title: `Removed from dashboard!`,
variant: 'success', variant: 'success',
@ -90,7 +90,7 @@ export default function ProfileHeader({
); );
const toggleSaved = () => { const toggleSaved = () => {
if (isSaved) { if (saved) {
unsaveMutation.mutate({ profileId: offerProfileId as string }); unsaveMutation.mutate({ profileId: offerProfileId as string });
} else { } else {
saveMutation.mutate({ saveMutation.mutate({
@ -111,10 +111,10 @@ export default function ProfileHeader({
disabled={ disabled={
isLoading || saveMutation.isLoading || unsaveMutation.isLoading isLoading || saveMutation.isLoading || unsaveMutation.isLoading
} }
icon={isSaved ? BookmarkIconSolid : BookmarkIconOutline} icon={saved ? BookmarkIconSolid : BookmarkIconOutline}
isLabelHidden={true} isLabelHidden={true}
isLoading={saveMutation.isLoading || unsaveMutation.isLoading} isLoading={saveMutation.isLoading || unsaveMutation.isLoading}
label={isSaved ? 'Remove from account' : 'Save to your account'} label={saved ? 'Remove from account' : 'Save to your account'}
size="md" size="md"
variant="tertiary" variant="tertiary"
onClick={toggleSaved} onClick={toggleSaved}

View File

@ -1,6 +1,7 @@
import type { import type {
Company, Company,
OffersAnalysis, OffersAnalysis,
OffersAnalysisUnit,
OffersBackground, OffersBackground,
OffersCurrency, OffersCurrency,
OffersEducation, OffersEducation,
@ -18,9 +19,9 @@ import { TRPCError } from '@trpc/server';
import type { import type {
AddToProfileResponse, AddToProfileResponse,
Analysis,
AnalysisHighestOffer, AnalysisHighestOffer,
AnalysisOffer, AnalysisOffer,
AnalysisUnit,
Background, Background,
CreateOfferProfileResponse, CreateOfferProfileResponse,
DashboardOffer, DashboardOffer,
@ -111,32 +112,33 @@ const analysisOfferDtoMapper = (
return analysisOfferDto; return analysisOfferDto;
}; };
const analysisDtoMapper = ( const analysisUnitDtoMapper = (
noOfOffers: number, analysisUnit: OffersAnalysisUnit & {
percentile: number, topSimilarOffers: Array<
topPercentileOffers: Array< OffersOffer & {
OffersOffer & { company: Company;
company: Company; offersFullTime:
offersFullTime: | (OffersFullTime & { totalCompensation: OffersCurrency })
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & { company: Company | null }
>;
})
| null; | null;
}; offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
} profile: OffersProfile & {
>, background:
| (OffersBackground & {
experiences: Array<
OffersExperience & { company: Company | null }
>;
})
| null;
};
}
>;
},
) => { ) => {
const analysisDto: Analysis = { const analysisDto: AnalysisUnit = {
noOfOffers, companyName: analysisUnit.companyName,
percentile, noOfOffers: analysisUnit.noOfSimilarOffers,
topPercentileOffers: topPercentileOffers.map((offer) => percentile: analysisUnit.percentile,
topPercentileOffers: analysisUnit.topSimilarOffers.map((offer) =>
analysisOfferDtoMapper(offer), analysisOfferDtoMapper(offer),
), ),
}; };
@ -166,6 +168,52 @@ const analysisHighestOfferDtoMapper = (
export const profileAnalysisDtoMapper = ( export const profileAnalysisDtoMapper = (
analysis: analysis:
| (OffersAnalysis & { | (OffersAnalysis & {
companyAnalysis: Array<
OffersAnalysisUnit & {
topSimilarOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & { company: Company | null }
>;
})
| null;
};
}
>;
}
>;
overallAnalysis: OffersAnalysisUnit & {
topSimilarOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & { company: Company | null }
>;
})
| null;
};
}
>;
};
overallHighestOffer: OffersOffer & { overallHighestOffer: OffersOffer & {
company: Company; company: Company;
offersFullTime: offersFullTime:
@ -176,46 +224,6 @@ export const profileAnalysisDtoMapper = (
| null; | null;
profile: OffersProfile & { background: OffersBackground | null }; profile: OffersProfile & { background: OffersBackground | null };
}; };
topCompanyOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & { company: Company | null }
>;
})
| null;
};
}
>;
topOverallOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & { company: Company | null }
>;
})
| null;
};
}
>;
}) })
| null, | null,
) => { ) => {
@ -224,23 +232,17 @@ export const profileAnalysisDtoMapper = (
} }
const profileAnalysisDto: ProfileAnalysis = { const profileAnalysisDto: ProfileAnalysis = {
companyAnalysis: [ companyAnalysis: analysis.companyAnalysis.map((analysisUnit) =>
analysisDtoMapper( analysisUnitDtoMapper(analysisUnit),
analysis.noOfSimilarCompanyOffers,
analysis.companyPercentile,
analysis.topCompanyOffers,
),
],
id: analysis.id,
overallAnalysis: analysisDtoMapper(
analysis.noOfSimilarOffers,
analysis.overallPercentile,
analysis.topOverallOffers,
), ),
createdAt: analysis.createdAt,
id: analysis.id,
overallAnalysis: analysisUnitDtoMapper(analysis.overallAnalysis),
overallHighestOffer: analysisHighestOfferDtoMapper( overallHighestOffer: analysisHighestOfferDtoMapper(
analysis.overallHighestOffer, analysis.overallHighestOffer,
), ),
profileId: analysis.profileId, profileId: analysis.profileId,
updatedAt: analysis.updatedAt,
}; };
return profileAnalysisDto; return profileAnalysisDto;
}; };
@ -442,6 +444,52 @@ export const profileDtoMapper = (
profile: OffersProfile & { profile: OffersProfile & {
analysis: analysis:
| (OffersAnalysis & { | (OffersAnalysis & {
companyAnalysis: Array<
OffersAnalysisUnit & {
topSimilarOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & { company: Company | null }
>;
})
| null;
};
}
>;
}
>;
overallAnalysis: OffersAnalysisUnit & {
topSimilarOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & { company: Company | null }
>;
})
| null;
};
}
>;
};
overallHighestOffer: OffersOffer & { overallHighestOffer: OffersOffer & {
company: Company; company: Company;
offersFullTime: offersFullTime:
@ -452,46 +500,6 @@ export const profileDtoMapper = (
| null; | null;
profile: OffersProfile & { background: OffersBackground | null }; profile: OffersProfile & { background: OffersBackground | null };
}; };
topCompanyOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & { company: Company | null }
>;
})
| null;
};
}
>;
topOverallOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & { company: Company | null }
>;
})
| null;
};
}
>;
}) })
| null; | null;
background: background:
@ -528,7 +536,7 @@ export const profileDtoMapper = (
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null; offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
} }
>; >;
user: User | null; users: Array<User>;
}, },
inputToken: string | undefined, inputToken: string | undefined,
inputUserId: string | null | undefined, inputUserId: string | null | undefined,
@ -548,18 +556,12 @@ export const profileDtoMapper = (
profileDto.editToken = profile.editToken ?? null; profileDto.editToken = profile.editToken ?? null;
profileDto.isEditable = true; profileDto.isEditable = true;
const users = profile.user; const { users } = profile;
// TODO: BRYANN UNCOMMENT THIS ONCE U CHANGE THE SCHEMA for (let i = 0; i < users.length; i++) {
// for (let i = 0; i < users.length; i++) { if (users[i].id === inputUserId) {
// if (users[i].id === inputUserId) { profileDto.isSaved = true;
// profileDto.isSaved = true }
// }
// }
// TODO: REMOVE THIS ONCE U CHANGE THE SCHEMA
if (users?.id === inputUserId) {
profileDto.isSaved = true;
} }
} }

View File

@ -71,11 +71,11 @@ export default function ProfilesDashboard() {
{!userProfilesQuery.isLoading && ( {!userProfilesQuery.isLoading && (
<div className="mt-8 overflow-y-auto"> <div className="mt-8 overflow-y-auto">
<h1 className="mx-auto mb-4 w-3/4 text-start text-4xl font-bold text-slate-900"> <h1 className="mx-auto mb-4 w-3/4 text-start text-4xl font-bold text-slate-900">
Your repository Your dashboard
</h1> </h1>
<p className="mx-auto w-3/4 text-start text-xl text-slate-900"> <p className="mx-auto w-3/4 text-start text-xl text-slate-900">
Save your offer profiles to respository to easily access and edit Save your offer profiles to dashboard to easily access and edit them
them later. later.
</p> </p>
<div className="justfy-center mt-8 flex w-screen"> <div className="justfy-center mt-8 flex w-screen">
<ul className="mx-auto w-3/4 space-y-3" role="list"> <ul className="mx-auto w-3/4 space-y-3" role="list">

View File

@ -1,5 +1,6 @@
import Error from 'next/error'; import Error from 'next/error';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react';
import { useState } from 'react'; import { useState } from 'react';
import { Spinner, useToast } from '@tih/ui'; import { Spinner, useToast } from '@tih/ui';
@ -34,11 +35,16 @@ export default function OfferProfile() {
ProfileDetailTab.OFFERS, ProfileDetailTab.OFFERS,
); );
const [analysis, setAnalysis] = useState<ProfileAnalysis>(); const [analysis, setAnalysis] = useState<ProfileAnalysis>();
const { data: session } = useSession();
const getProfileQuery = trpc.useQuery( const getProfileQuery = trpc.useQuery(
[ [
'offers.profile.listOne', 'offers.profile.listOne',
{ profileId: offerProfileId as string, token: token as string }, {
profileId: offerProfileId as string,
token: token as string,
userId: session?.user?.id,
},
], ],
{ {
enabled: typeof offerProfileId === 'string', enabled: typeof offerProfileId === 'string',

View File

@ -14,20 +14,17 @@ import { profileAnalysisDtoMapper } from '~/mappers/offers-mappers';
import { createRouter } from '../context'; import { createRouter } from '../context';
type Offer = OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
profile: OffersProfile & { background: OffersBackground | null };
};
const searchOfferPercentile = ( const searchOfferPercentile = (
offer: OffersOffer & { offer: Offer,
company: Company;
offersFullTime:
| (OffersFullTime & {
baseSalary: OffersCurrency | null;
bonus: OffersCurrency | null;
stocks: OffersCurrency | null;
totalCompensation: OffersCurrency;
})
| null;
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
profile: OffersProfile & { background: OffersBackground | null };
},
similarOffers: Array< similarOffers: Array<
OffersOffer & { OffersOffer & {
company: Company; company: Company;
@ -58,6 +55,70 @@ export const offersAnalysisRouter = createRouter()
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const analysis = await ctx.prisma.offersAnalysis.findFirst({ const analysis = await ctx.prisma.offersAnalysis.findFirst({
include: { include: {
companyAnalysis: {
include: {
topSimilarOffers: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
},
},
},
},
},
overallAnalysis: {
include: {
topSimilarOffers: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
},
},
},
},
},
overallHighestOffer: { overallHighestOffer: {
include: { include: {
company: true, company: true,
@ -78,62 +139,6 @@ export const offersAnalysisRouter = createRouter()
}, },
}, },
}, },
topCompanyOffers: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
},
},
},
topOverallOffers: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
},
},
},
}, },
where: { where: {
profileId: input.profileId, profileId: input.profileId,
@ -310,11 +315,57 @@ export const offersAnalysisRouter = createRouter()
}, },
}); });
let similarCompanyOffers = similarOffers.filter( // COMPANY ANALYSIS
(offer) => offer.companyId === overallHighestOffer.companyId, const companyMap = new Map<string, Offer>();
offers.forEach((offer) => {
if (companyMap.get(offer.companyId) == null) {
companyMap.set(offer.companyId, offer);
}
});
const companyAnalysis = Array.from(companyMap.values()).map(
(companyOffer) => {
// TODO: Refactor calculating analysis into a function
let similarCompanyOffers = similarOffers.filter(
(offer) => offer.companyId === companyOffer.companyId,
);
const companyIndex = searchOfferPercentile(
companyOffer,
similarCompanyOffers,
);
const companyPercentile =
similarCompanyOffers.length <= 1
? 100
: 100 - (100 * companyIndex) / (similarCompanyOffers.length - 1);
// Get top offers (excluding user's offer)
similarCompanyOffers = similarCompanyOffers.filter(
(offer) => offer.id !== companyOffer.id,
);
const noOfSimilarCompanyOffers = similarCompanyOffers.length;
const similarCompanyOffers90PercentileIndex = Math.ceil(
noOfSimilarCompanyOffers * 0.1,
);
const topPercentileCompanyOffers =
noOfSimilarCompanyOffers > 2
? similarCompanyOffers.slice(
similarCompanyOffers90PercentileIndex,
similarCompanyOffers90PercentileIndex + 2,
)
: similarCompanyOffers;
return {
companyName: companyOffer.company.name,
noOfSimilarOffers: noOfSimilarCompanyOffers,
percentile: companyPercentile,
topSimilarOffers: topPercentileCompanyOffers,
};
},
); );
// CALCULATE PERCENTILES // OVERALL ANALYSIS
const overallIndex = searchOfferPercentile( const overallIndex = searchOfferPercentile(
overallHighestOffer, overallHighestOffer,
similarOffers, similarOffers,
@ -324,23 +375,9 @@ export const offersAnalysisRouter = createRouter()
? 100 ? 100
: 100 - (100 * overallIndex) / (similarOffers.length - 1); : 100 - (100 * overallIndex) / (similarOffers.length - 1);
const companyIndex = searchOfferPercentile(
overallHighestOffer,
similarCompanyOffers,
);
const companyPercentile =
similarCompanyOffers.length <= 1
? 100
: 100 - (100 * companyIndex) / (similarCompanyOffers.length - 1);
// FIND TOP >=90 PERCENTILE OFFERS, DOESN'T GIVE 100th PERCENTILE
// e.g. If there only 4 offers, it gives the 2nd and 3rd offer
similarOffers = similarOffers.filter( similarOffers = similarOffers.filter(
(offer) => offer.id !== overallHighestOffer.id, (offer) => offer.id !== overallHighestOffer.id,
); );
similarCompanyOffers = similarCompanyOffers.filter(
(offer) => offer.id !== overallHighestOffer.id,
);
const noOfSimilarOffers = similarOffers.length; const noOfSimilarOffers = similarOffers.length;
const similarOffers90PercentileIndex = Math.ceil(noOfSimilarOffers * 0.1); const similarOffers90PercentileIndex = Math.ceil(noOfSimilarOffers * 0.1);
@ -352,46 +389,110 @@ export const offersAnalysisRouter = createRouter()
) )
: similarOffers; : similarOffers;
const noOfSimilarCompanyOffers = similarCompanyOffers.length;
const similarCompanyOffers90PercentileIndex = Math.ceil(
noOfSimilarCompanyOffers * 0.1,
);
const topPercentileCompanyOffers =
noOfSimilarCompanyOffers > 2
? similarCompanyOffers.slice(
similarCompanyOffers90PercentileIndex,
similarCompanyOffers90PercentileIndex + 2,
)
: similarCompanyOffers;
const analysis = await ctx.prisma.offersAnalysis.create({ const analysis = await ctx.prisma.offersAnalysis.create({
data: { data: {
companyPercentile, companyAnalysis: {
noOfSimilarCompanyOffers, create: companyAnalysis.map((analysisUnit) => {
noOfSimilarOffers, return {
companyName: analysisUnit.companyName,
noOfSimilarOffers: analysisUnit.noOfSimilarOffers,
percentile: analysisUnit.percentile,
topSimilarOffers: {
connect: analysisUnit.topSimilarOffers.map((offer) => {
return { id: offer.id };
}),
},
};
}),
},
overallAnalysis: {
create: {
companyName: overallHighestOffer.company.name,
noOfSimilarOffers,
percentile: overallPercentile,
topSimilarOffers: {
connect: topPercentileOffers.map((offer) => {
return { id: offer.id };
}),
},
},
},
overallHighestOffer: { overallHighestOffer: {
connect: { connect: {
id: overallHighestOffer.id, id: overallHighestOffer.id,
}, },
}, },
overallPercentile,
profile: { profile: {
connect: { connect: {
id: input.profileId, id: input.profileId,
}, },
}, },
topCompanyOffers: {
connect: topPercentileCompanyOffers.map((offer) => {
return { id: offer.id };
}),
},
topOverallOffers: {
connect: topPercentileOffers.map((offer) => {
return { id: offer.id };
}),
},
}, },
include: { include: {
companyAnalysis: {
include: {
topSimilarOffers: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
},
},
},
},
},
overallAnalysis: {
include: {
topSimilarOffers: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
},
},
},
},
},
overallHighestOffer: { overallHighestOffer: {
include: { include: {
company: true, company: true,
@ -412,62 +513,6 @@ export const offersAnalysisRouter = createRouter()
}, },
}, },
}, },
topCompanyOffers: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
},
},
},
topOverallOffers: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
},
},
},
}, },
}); });

View File

@ -128,6 +128,70 @@ export const offersProfileRouter = createRouter()
include: { include: {
analysis: { analysis: {
include: { include: {
companyAnalysis: {
include: {
topSimilarOffers: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
},
},
},
},
},
overallAnalysis: {
include: {
topSimilarOffers: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
},
},
},
},
},
overallHighestOffer: { overallHighestOffer: {
include: { include: {
company: true, company: true,
@ -148,62 +212,6 @@ export const offersProfileRouter = createRouter()
}, },
}, },
}, },
topCompanyOffers: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
},
},
},
topOverallOffers: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
},
},
},
}, },
}, },
background: { background: {
@ -244,7 +252,7 @@ export const offersProfileRouter = createRouter()
}, },
}, },
}, },
user: true, users: true,
}, },
where: { where: {
id: input.profileId, id: input.profileId,
@ -409,7 +417,7 @@ export const offersProfileRouter = createRouter()
message: 'Missing fields in background experiences.', message: 'Missing fields in background experiences.',
}); });
}), }),
) ),
}, },
specificYoes: { specificYoes: {
create: input.background.specificYoes.map((x) => { create: input.background.specificYoes.map((x) => {

View File

@ -3,129 +3,130 @@ import * as trpc from '@trpc/server';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { import {
addToProfileResponseMapper, getUserProfileResponseMapper, addToProfileResponseMapper,
getUserProfileResponseMapper,
} from '~/mappers/offers-mappers'; } from '~/mappers/offers-mappers';
import { createProtectedRouter } from '../context'; import { createProtectedRouter } from '../context';
export const offersUserProfileRouter = createProtectedRouter() export const offersUserProfileRouter = createProtectedRouter()
.mutation('addToUserProfile', { .mutation('addToUserProfile', {
input: z.object({ input: z.object({
profileId: z.string(), profileId: z.string(),
token: z.string(), token: z.string(),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const profile = await ctx.prisma.offersProfile.findFirst({ const profile = await ctx.prisma.offersProfile.findFirst({
where: { where: {
id: input.profileId, id: input.profileId,
},
});
const profileEditToken = profile?.editToken;
if (profileEditToken === input.token) {
const userId = ctx.session.user.id
const updated = await ctx.prisma.offersProfile.update({
data: {
user: {
connect: {
id: userId,
},
},
},
where: {
id: input.profileId,
},
});
return addToProfileResponseMapper(updated);
}
throw new trpc.TRPCError({
code: 'UNAUTHORIZED',
message: 'Invalid token.',
});
}, },
}) });
.query('getUserProfiles', {
async resolve({ ctx }) { const profileEditToken = profile?.editToken;
const userId = ctx.session.user.id if (profileEditToken === input.token) {
const result = await ctx.prisma.user.findFirst({ const userId = ctx.session.user.id;
const updated = await ctx.prisma.offersProfile.update({
data: {
users: {
connect: {
id: userId,
},
},
},
where: {
id: input.profileId,
},
});
return addToProfileResponseMapper(updated);
}
throw new trpc.TRPCError({
code: 'UNAUTHORIZED',
message: 'Invalid token.',
});
},
})
.query('getUserProfiles', {
async resolve({ ctx }) {
const userId = ctx.session.user.id;
const result = await ctx.prisma.user.findFirst({
include: {
OffersProfile: {
include: {
offers: {
include: { include: {
OffersProfile: { company: true,
include: { offersFullTime: {
offers: { include: {
include: { totalCompensation: true,
company: true, },
offersFullTime: { },
include: { offersIntern: {
totalCompensation: true include: {
} monthlySalary: true,
}, },
offersIntern: { },
include: {
monthlySalary: true
}
}
}
}
}
}
}, },
where: { },
id: userId },
} },
}) },
where: {
id: userId,
},
});
return getUserProfileResponseMapper(result) return getUserProfileResponseMapper(result);
},
})
.mutation('removeFromUserProfile', {
input: z.object({
profileId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session.user.id;
const profiles = await ctx.prisma.user.findFirst({
include: {
OffersProfile: true,
},
where: {
id: userId,
},
});
// Validation
let doesProfileExist = false;
if (profiles?.OffersProfile) {
for (let i = 0; i < profiles.OffersProfile.length; i++) {
if (profiles.OffersProfile[i].id === input.profileId) {
doesProfileExist = true;
}
} }
}) }
.mutation('removeFromUserProfile', {
input: z.object({
profileId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session.user.id
const profiles = await ctx.prisma.user.findFirst({ if (!doesProfileExist) {
include: { throw new TRPCError({
OffersProfile: true code: 'NOT_FOUND',
}, message: 'No such profile id saved.',
where: { });
id: userId }
}
})
// Validation await ctx.prisma.user.update({
let doesProfileExist = false; data: {
OffersProfile: {
if (profiles?.OffersProfile) { disconnect: [
for (let i = 0; i < profiles.OffersProfile.length; i++) { {
if (profiles.OffersProfile[i].id === input.profileId) { id: input.profileId,
doesProfileExist = true },
} ],
} },
} },
where: {
if (!doesProfileExist) { id: userId,
throw new TRPCError({ },
code: 'NOT_FOUND', });
message: 'No such profile id saved.' },
}) });
}
await ctx.prisma.user.update({
data: {
OffersProfile: {
disconnect: [{
id: input.profileId
}]
}
},
where: {
id: userId
}
})
}
})

View File

@ -143,14 +143,17 @@ export type OffersDiscussion = {
}; };
export type ProfileAnalysis = { export type ProfileAnalysis = {
companyAnalysis: Array<Analysis>; companyAnalysis: Array<AnalysisUnit>;
createdAt: Date;
id: string; id: string;
overallAnalysis: Analysis; overallAnalysis: AnalysisUnit;
overallHighestOffer: AnalysisHighestOffer; overallHighestOffer: AnalysisHighestOffer;
profileId: string; profileId: string;
updatedAt: Date;
}; };
export type Analysis = { export type AnalysisUnit = {
companyName: string;
noOfOffers: number; noOfOffers: number;
percentile: number; percentile: number;
topPercentileOffers: Array<AnalysisOffer>; topPercentileOffers: Array<AnalysisOffer>;