Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/backend/src/data-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { DataSource } from 'typeorm';
import { Donation } from './donations/donation.entity';
import { User } from './users/user.entity';
import * as dotenv from 'dotenv';
import { Goal } from './donations/goal.entity';

dotenv.config();

Expand All @@ -12,7 +13,7 @@ const AppDataSource = new DataSource({
username: process.env.NX_DB_USERNAME,
password: process.env.NX_DB_PASSWORD,
database: process.env.NX_DB_DATABASE,
entities: [User, Donation],
entities: [User, Donation, Goal],
migrations: [],
// Setting synchronize: true shouldn't be used in production - otherwise you can lose production data
synchronize: true,
Expand Down
49 changes: 49 additions & 0 deletions apps/backend/src/donations/donations.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,55 @@ export class DonationsController {
return stats;
}

@Get('goal/active')
@ApiOperation({
summary: 'get active growing goal summary',
description:
'retrieve the active goal, the amount raised during that goal period, and progress percentage',
})
@ApiResponse({
status: 200,
description: 'active goal summary',
schema: {
type: 'object',
properties: {
goal: {
nullable: true,
type: 'object',
properties: {
id: { type: 'number', example: 1 },
targetAmount: { type: 'number', example: 50000 },
startDate: { type: 'string', example: '2026-01-01' },
endDate: { type: 'string', example: '2026-06-30' },
dateRangeLabel: {
type: 'string',
example: 'January - June 2026',
},
},
},
amountRaised: { type: 'number', example: 31336 },
progressPercent: { type: 'number', example: 62.67 },
},
},
})
@ApiResponse({
status: 401,
description: 'unauthorized',
})
async getActiveGoalSummary(@Req() req: any): Promise<{
goal: {
id: number;
targetAmount: number;
startDate: string;
endDate: string;
dateRangeLabel: string;
} | null;
amountRaised: number;
progressPercent: number;
}> {
return this.donationsService.getActiveGoalSummary();
}

@Get('lapsed')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
Expand Down
3 changes: 2 additions & 1 deletion apps/backend/src/donations/donations.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import { User } from '../users/user.entity';
import { AuthService } from '../auth/auth.service';
import { UsersService } from '../users/users.service';
import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor';
import { Goal } from './goal.entity';

@Module({
imports: [TypeOrmModule.forFeature([Donation, User])],
imports: [TypeOrmModule.forFeature([Donation, Goal, User])],
controllers: [DonationsController],
providers: [
DonationsService,
Expand Down
84 changes: 84 additions & 0 deletions apps/backend/src/donations/donations.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Repository } from 'typeorm';
import { CreateDonationRequest, Donation as DomainDonation } from './mappers';
import { Readable } from 'stream';
import { DonationsRepository } from './donations.repository';
import { Goal } from './goal.entity';

interface PaymentIntentSyncPayload {
donationId?: number;
Expand All @@ -32,6 +33,10 @@ export class DonationsService {
constructor(
@InjectRepository(Donation)
private donationRepository: Repository<Donation>,

@InjectRepository(Goal)
private goalRepository: Repository<Goal>,

private readonly donationsRepository: DonationsRepository,
) {}

Expand Down Expand Up @@ -342,4 +347,83 @@ export class DonationsService {

return stream;
}

async getActiveGoalSummary() {
// --- TEMPORARY MOCK FOR TESTING ---
return {
goal: {
id: 999,
targetAmount: 50000,
startDate: '2026-01-01',
endDate: '2026-06-30',
dateRangeLabel: 'January - June 2026',
},
amountRaised: 31336,
progressPercent: 62.67,
};
/*
const today = new Date().toISOString().split('T')[0];

// 1. find active goal
const goal = await this.goalRepository
.createQueryBuilder('goal')
.where(':today BETWEEN goal.startDate AND goal.endDate', { today })
.orderBy('goal.startDate', 'DESC')
.getOne();

if (!goal) {
return {
goal: null,
amountRaised: 0,
progressPercent: 0,
};
}

// 2. sum donations in goal period
const result = await this.donationRepository
.createQueryBuilder('donation')
.select('COALESCE(SUM(donation.amount), 0)', 'amount')
.where('donation.status = :status', { status: DonationStatus.SUCCEEDED })
.andWhere('donation.createdAt >= :startDate', {
startDate: goal.startDate,
})
.andWhere('donation.createdAt <= :endDate', {
endDate: `${goal.endDate} 23:59:59`,
})
.getRawOne<{ amount: string }>();

const amountRaised = Number(result?.amount ?? 0);

const progressPercent =
goal.targetAmount > 0
? Math.min((amountRaised / goal.targetAmount) * 100, 100)
: 0;

return {
goal: {
id: goal.id,
targetAmount: goal.targetAmount,
startDate: goal.startDate,
endDate: goal.endDate,
dateRangeLabel: this.formatDateRange(goal.startDate, goal.endDate),
},
amountRaised,
progressPercent,
};
*/
}

private formatDateRange(start: string, end: string): string {
const startDate = new Date(start);
const endDate = new Date(end);

const startMonth = startDate.toLocaleString('en-US', { month: 'long' });
const endMonth = endDate.toLocaleString('en-US', { month: 'long' });

if (startDate.getFullYear() === endDate.getFullYear()) {
return `${startMonth} - ${endMonth} ${startDate.getFullYear()}`;
}

return `${startMonth} ${startDate.getFullYear()} - ${endMonth} ${endDate.getFullYear()}`;
}
}
30 changes: 30 additions & 0 deletions apps/backend/src/donations/goal.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';

@Entity('goals')
export class Goal {
@PrimaryGeneratedColumn('identity', {
generatedIdentity: 'ALWAYS',
})
id: number;

@Column({ type: 'int' })
targetAmount: number;

@Column({ type: 'date' })
startDate: string;

@Column({ type: 'date' })
endDate: string;

@CreateDateColumn({ type: 'timestamp', default: () => 'now()' })
createdAt: Date;

@UpdateDateColumn({ type: 'timestamp', default: () => 'now()' })
updatedAt: Date;
}
21 changes: 21 additions & 0 deletions apps/frontend/src/api/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ export type DonationStatsResponse = {
monthToDate: number;
};

export type ActiveGoalResponse = {
goal: {
id: number;
targetAmount: number;
startDate: string;
endDate: string;
dateRangeLabel: string;
} | null;
amountRaised: number;
progressPercent: number;
};

export type SignInRequest = { email: string; password: string };
export type SignUpRequest = {
firstName: string;
Expand Down Expand Up @@ -87,6 +99,15 @@ export class ApiClient {
}
}

public async getActiveGoalSummary(): Promise<ActiveGoalResponse> {
try {
const res = await this.axiosInstance.get('/api/donations/goal/active');
return res.data;
} catch (err: unknown) {
this.handleAxiosError(err, 'Failed to fetch active goal');
}
}

private handleAxiosError(err: unknown, defaultMsg: string): never {
if (axios.isAxiosError<ApiError>(err)) {
const data = err.response?.data;
Expand Down
5 changes: 5 additions & 0 deletions apps/frontend/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { ConfirmRegisteredPage } from '@containers/auth/ConfirmRegisteredPage';
import { DashboardPage } from '@containers/dashboard/DashboardPage';
import { DonorStatsChart } from '@components/DonorStatsChart';
import SidebarTester from '@containers/dashboard/sidebar/SidebarTester';
import { AdminGrowingGoalTester } from '@containers/dashboard/AdminGrowingGoalTester';

const router = createBrowserRouter([
{
Expand Down Expand Up @@ -57,6 +58,10 @@ const router = createBrowserRouter([
path: '/shadcn-example',
element: <ShadcnExample />,
},
{
path: '/admin-growing-goal-test',
element: <AdminGrowingGoalTester />,
},
{
path: '/chart',
element: <AdminRoute />,
Expand Down
17 changes: 12 additions & 5 deletions apps/frontend/src/components/GrowingGoal/GrowingGoal.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
display: flex;
flex-direction: column;
border-radius: 3%;
border: 2% solid #cecece;
border: 1px solid #cecece;
background: #f2f2f2;
text-align: center;
justify-content: start;
container-type: inline-size;
gap: 4%;
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1);
font-family: 'Source Sans Pro', sans-serif;
}

.description-label {
Expand All @@ -21,12 +23,12 @@
justify-content: center;
flex-shrink: 0;
color: #fff;
font-family: Helvetica;
font-family: 'Source Sans Pro', sans-serif;
font-weight: 700;
white-space: nowrap;
height: 12%;
padding: 2%;
background-color: #650D77;
background-color: #3d3e6e;
font-size: 5cqw;
}

Expand All @@ -47,12 +49,13 @@
justify-content: center;
align-items: center;
background: #cecece;
box-shadow: inset 0px 2px 4px rgba(0, 0, 0, 0.1), 0px 4px 8px rgba(0, 0, 0, 0.1);
}

.total-donation-label {
color: #000;
text-align: center;
font-family: Helvetica;
font-family: 'Source Sans Pro', sans-serif;
font-size: 6cqw;
font-weight: 400;
}
Expand All @@ -65,6 +68,7 @@
align-items: center;
justify-content: center;
align-self: center;
font-family: 'Source Sans Pro', sans-serif;
}

.sample-donor-profile {
Expand Down Expand Up @@ -92,7 +96,7 @@

.sample-donor-amount {
color: #fff;
font-family: Helvetica;
font-family: 'Source Sans Pro', sans-serif;
font-size: 4cqw;
overflow: hidden;
}
Expand All @@ -106,6 +110,7 @@
aspect-ratio: 1 / 1;
border-radius: 50%;
background: #ffffff;
box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.2);
}

.growth-container-solid-grey-inner {
Expand All @@ -117,6 +122,7 @@
aspect-ratio: 1 / 1;
border-radius: 50%;
background: #55565a;
box-shadow: inset 0px 2px 5px rgba(0, 0, 0, 0.3);
}

.growth-container-solid-teal {
Expand All @@ -135,6 +141,7 @@
width: 100%;
height: 100%;
border-radius: 50%;
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.2);
background: conic-gradient(
from 180deg at 50% 50%,
#c6be3b 0deg,
Expand Down
Loading
Loading