diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..188fa99 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,39 @@ +name: Deploy +run-name: Deploy to AWS + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup Node + uses: actions/setup-node@v2 + with: + node-version: 16 + + - name: Install dependencies + run: npm install + + - name: Build + run: ng run user-interface:ngsscbuild:production + + - name: Deploy + run: + aws s3 sync ./dist/user-interface ${{ secrets.S3_BUCKET }} --region ${{ secrets.AWS_DEFAULT_REGION }} --delete + echo "Invalidating Old Distribution" + aws cloudfront create-invalidation --distribution-id ${{ secrets.DISTRIBUTION_ID }} --paths '/*' + echo "Deployed Successfully" \ No newline at end of file diff --git a/README.md b/README.md index b3cd84f..7f042fb 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,10 @@ While the official website states a general waiting time of 8-12 weeks, individu As of the time of writing this, the main sources of information about ACS skills assessments are the [ACS Skills Assessment for PR Facebook group](https://www.facebook.com/groups/acs4pr) and the [AusVisa subreddit](https://www.reddit.com/r/AusVisa/). -There is no single source of truth that collects timelines from the applicants, with the most active updates being user posts on the Facebook group where applicants often tend to post their outcomes along with the time taken. - However, there is no consistent format to this data, as some may choose to omit crucial details such as the ANZSCO code they applied under, or the chosen stream. It is also not convenient to always scroll through a large number of posts seeking help or discussing other aspects of the process just to find the ones that shed light on waiting times. +Other options include [ImmiTracker](https://myimmitracker.com/) or [TrackItt](https://www.trackitt.com/australia-immigration-trackers/skills-assessment), both of which necessitate signing up and creating a user profile. ImmiTracker's data on ACS skills assessments is also not as exhaustive as its base of users who post about General Skilled Migration (GSM) visa applications. + ### Solution The idea for this application was conceived during the interminable wait between submitting my own skills assessment application and receiving a response. With this tracker, I wanted to create a web app that would convey critical information on current wait times based on aggregated user responses from those who have already received a result - either positive or negative. diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 9a0c819..9c0001a 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -14,6 +14,7 @@ import { ApplicationsTableComponent } from "./components/applications-table/appl import { AddEntryDialogComponent } from "./components/add-entry-dialog/add-entry-dialog.component"; import { LayoutModule } from "@angular/cdk/layout"; import { GraphQLModule } from "./graphql.module"; +import { StatisticsComponent } from "./components/statistics/statistics.component"; @NgModule({ declarations: [ @@ -23,6 +24,7 @@ import { GraphQLModule } from "./graphql.module"; DialogComponent, ApplicationsTableComponent, AddEntryDialogComponent, + StatisticsComponent, ], imports: [ BrowserModule, diff --git a/src/app/components/add-entry-dialog/add-entry-dialog.component.ts b/src/app/components/add-entry-dialog/add-entry-dialog.component.ts index 6ec3045..ee7d8b0 100644 --- a/src/app/components/add-entry-dialog/add-entry-dialog.component.ts +++ b/src/app/components/add-entry-dialog/add-entry-dialog.component.ts @@ -2,11 +2,11 @@ import { Component } from "@angular/core"; import { FormBuilder, FormGroup, Validators } from "@angular/forms"; import { MatDialogRef } from "@angular/material/dialog"; import { MatSnackBar } from "@angular/material/snack-bar"; -import { DateTime } from "luxon"; import { AddEntryGQL, AddEntryMutationVariables, } from "src/app/graphql/graphql-codegen-generated"; +import { ClockService } from "src/app/services/clock/clock.service"; export const anzscoCodes: Map = new Map([ [261311, "Analyst Programmer"], @@ -100,7 +100,7 @@ export class AddEntryDialogComponent { this.form.controls["anzsco"].value.value, submitted_on: this.form.controls["dateSubmitted"].value, received_on: this.form.controls["dateReceived"].value, - days: this.findDateDiff( + days: ClockService.findDateDiff( this.form.controls["dateSubmitted"].value, this.form.controls["dateReceived"].value, ), @@ -140,19 +140,4 @@ export class AddEntryDialogComponent { public close(): void { this.dialogRef.close(); } - - /** - * Finds difference in days given two dates - * @param - * startDate: initial date of application in ISOString format - * endDate: date of receiving result in ISOString format - * @returns - * Days between the two given dates as a number - */ - private findDateDiff(startDate: string, endDate: string): number { - const start: DateTime = DateTime.fromISO(startDate); - const end: DateTime = DateTime.fromISO(endDate); - - return end.diff(start, "days").toObject().days!; - } } diff --git a/src/app/components/applications-table/applications-table.component.html b/src/app/components/applications-table/applications-table.component.html index ce3ec85..6209590 100644 --- a/src/app/components/applications-table/applications-table.component.html +++ b/src/app/components/applications-table/applications-table.component.html @@ -1,15 +1,15 @@
-
; + @Input() + get dataSource(): MatTableDataSource | undefined { + return this.tableData; + } + set dataSource(value: MatTableDataSource) { + this.tableData = value; + this.setTableEntries(); + } + // Table controls @ViewChild(MatSort, { static: false }) sort: MatSort; @ViewChild(MatPaginator, { static: false }) paginator: MatPaginator; // Table data public displayedColumns: Array = []; - public dataSource: MatTableDataSource = new MatTableDataSource(); // Track screen size public resizeTable = false; constructor( public breakpointObserver: BreakpointObserver, - private getAllEntriesQuery: GetAllEntriesGQL, private dialog: MatDialog, ) {} @@ -40,8 +46,6 @@ export class ApplicationsTableComponent implements OnInit { this.resizeTable = true; } }); - - this.fetchTableEntries(); } /** Opens dialog to log a new entry in the table */ @@ -60,27 +64,20 @@ export class ApplicationsTableComponent implements OnInit { } /** - * Runs GraphQL query to fetch all table entries from the MongoDB Atlas Collection + * Initialise table with data */ - private fetchTableEntries(): void { - this.getAllEntriesQuery - .fetch() - .pipe(map((response) => response.data.applications)) - .subscribe((data) => { - this.dataSource = new MatTableDataSource(data); - this.dataSource.sort = this.sort; - this.dataSource.paginator = this.paginator; - - this.displayedColumns = [ - "anzsco_code", - "submitted_on", - "received_on", - "days", - "outcome", - "stream", - "location", - "comment", - ]; - }); + private setTableEntries(): void { + this.tableData.sort = this.sort; + this.tableData.paginator = this.paginator; + this.displayedColumns = [ + "anzsco_code", + "submitted_on", + "received_on", + "days", + "outcome", + "stream", + "location", + "comment", + ]; } } diff --git a/src/app/components/statistics/statistics.component.html b/src/app/components/statistics/statistics.component.html new file mode 100644 index 0000000..c73b5e0 --- /dev/null +++ b/src/app/components/statistics/statistics.component.html @@ -0,0 +1,6 @@ +
+ +

{{ card.header }}

+

{{ card.text }}

+
+
diff --git a/src/app/components/statistics/statistics.component.scss b/src/app/components/statistics/statistics.component.scss new file mode 100644 index 0000000..ffe2978 --- /dev/null +++ b/src/app/components/statistics/statistics.component.scss @@ -0,0 +1,49 @@ +.stats-card { + border-radius: 20px; + background-image: linear-gradient( + to top, + #ecf4d7 0%, + #ecf4d7 60%, + #daec37 83% + ); + width: 300px; + height: 200px; + padding: 10px; + text-align: center; +} + +.card-text { + font-size: 20px; + margin-top: 15%; +} + +.statistics-container { + margin-top: 20px; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} + +@media only screen and (max-width: 1100px) { + .statistics-container { + flex-direction: column; + } + + .stats-card { + width: 250px; + height: 150px; + margin: 20px; + background-image: linear-gradient( + to top, + #ecf4d7 0%, + #ecf4d7 50%, + #daec37 83% + ); + } + + .card-text { + margin-top: 5%; + font-size: 14px; + } +} diff --git a/src/app/components/statistics/statistics.component.spec.ts b/src/app/components/statistics/statistics.component.spec.ts new file mode 100644 index 0000000..ba5837e --- /dev/null +++ b/src/app/components/statistics/statistics.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { StatisticsComponent } from "./statistics.component"; + +describe("StatisticsComponent", () => { + let component: StatisticsComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [StatisticsComponent], + }); + fixture = TestBed.createComponent(StatisticsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/statistics/statistics.component.ts b/src/app/components/statistics/statistics.component.ts new file mode 100644 index 0000000..b790d59 --- /dev/null +++ b/src/app/components/statistics/statistics.component.ts @@ -0,0 +1,12 @@ +import { Component, Input } from "@angular/core"; +import { StatisticsCard } from "src/app/pages/home/home.component"; + +@Component({ + selector: "app-statistics", + templateUrl: "./statistics.component.html", + styleUrls: ["./statistics.component.scss"], +}) +export class StatisticsComponent { + @Input() + statsData: StatisticsCard[]; +} diff --git a/src/app/modules/material/material.module.ts b/src/app/modules/material/material.module.ts index 288bf92..8616541 100644 --- a/src/app/modules/material/material.module.ts +++ b/src/app/modules/material/material.module.ts @@ -6,6 +6,7 @@ import { MatTooltipModule } from "@angular/material/tooltip"; import { MatIconModule } from "@angular/material/icon"; import { MatSnackBarModule } from "@angular/material/snack-bar"; import { MatDialogModule } from "@angular/material/dialog"; +import { MatCardModule } from "@angular/material/card"; import { MatTableModule } from "@angular/material/table"; import { MatPaginatorModule } from "@angular/material/paginator"; import { MatButtonModule } from "@angular/material/button"; @@ -39,6 +40,7 @@ import { MatSnackBarModule, MatIconModule, MatTooltipModule, + MatCardModule, ], exports: [ MatDialogModule, @@ -56,6 +58,7 @@ import { MatSnackBarModule, MatIconModule, MatTooltipModule, + MatCardModule, ], providers: [ { diff --git a/src/app/pages/home/home.component.html b/src/app/pages/home/home.component.html index 9c4ffb3..8c2af6e 100644 --- a/src/app/pages/home/home.component.html +++ b/src/app/pages/home/home.component.html @@ -1,6 +1,10 @@
+
- +
diff --git a/src/app/pages/home/home.component.scss b/src/app/pages/home/home.component.scss index 4d8a5d0..6602954 100644 --- a/src/app/pages/home/home.component.scss +++ b/src/app/pages/home/home.component.scss @@ -1,5 +1,4 @@ .table-container { - margin-top: 20px; display: flex; flex-direction: column; justify-content: center; diff --git a/src/app/pages/home/home.component.ts b/src/app/pages/home/home.component.ts index be17790..3130f8b 100644 --- a/src/app/pages/home/home.component.ts +++ b/src/app/pages/home/home.component.ts @@ -1,8 +1,146 @@ -import { Component } from "@angular/core"; +import { Component, OnInit } from "@angular/core"; +import { MatTableDataSource } from "@angular/material/table"; +import { map } from "rxjs"; +import { + Application, + GetAllEntriesGQL, +} from "src/app/graphql/graphql-codegen-generated"; +import { ClockService } from "src/app/services/clock/clock.service"; + +export type StatisticsCard = { + header: string; + text: string; +}; @Component({ selector: "app-home", templateUrl: "./home.component.html", styleUrls: ["./home.component.scss"], }) -export class HomeComponent {} +export class HomeComponent implements OnInit { + public dataSource: MatTableDataSource = new MatTableDataSource(); + + /* Data passed on to the statistics component for rendering cards */ + public statCards: StatisticsCard[] | undefined; + + constructor(private getAllEntriesQuery: GetAllEntriesGQL) {} + + ngOnInit(): void { + this.fetchTableEntries(); + } + + /** + * Runs GraphQL query to fetch all table entries from the MongoDB Atlas Collection + */ + private fetchTableEntries(): void { + this.getAllEntriesQuery + .fetch() + .pipe(map((response) => response.data.applications)) + .subscribe((data) => { + if (data && data.length > 0) { + this.dataSource = new MatTableDataSource(data); + this.statCards = this.calculateStatistics(data as Application[]); + } + }); + } + + /** + * Calculate statistics from data fetched from the database + */ + private calculateStatistics(data: Application[]): StatisticsCard[] { + const averageProcessingTime: StatisticsCard = + this.getAverageProcessingTime(data); + const onshoreApplicants: StatisticsCard = this.getOnshoreApplicants(data); + const popularAnzscoCode: StatisticsCard = + this.getMostPopularAnzscoCode(data); + const pasApplicationCount: StatisticsCard = + this.getPostAustralianStudyApplicationCount(data); + + return [ + averageProcessingTime, + onshoreApplicants, + popularAnzscoCode, + pasApplicationCount, + ]; + } + + /** + * Calculates average processing time from all entries returned + */ + private getAverageProcessingTime(data: Application[]): StatisticsCard { + const pastMonthApplications: Application[] = data.filter( + (application) => + ClockService.findDateDiff( + application.received_on, + new Date().toISOString(), + ) < 30, + ); + + let avg = 0; + for (const app of pastMonthApplications) { + avg += app.days; + } + + return { + header: `${avg / pastMonthApplications.length} days`, + text: `average response time in past month`, + }; + } + + /** + * Calculates the number of onshore applicants + */ + private getOnshoreApplicants(data: Application[]): StatisticsCard { + const onshoreApplicants: number = data.filter( + (application) => application.location === "Onshore", + ).length; + const onshoreApplicantsPercentage: number = + (onshoreApplicants / data.length) * 100; + + return { + header: `${Math.round(onshoreApplicantsPercentage * 100) / 100}%`, + text: `Onshore Applicants`, + }; + } + + /** + * Searches for the most popular ANZSCO code mentioned amongst all applications + */ + private getMostPopularAnzscoCode(data: Application[]): StatisticsCard { + // Create a dict with the count of each anzsco code from all applications + const anzscoCounts: any = {}; + data.forEach( + (app) => + (anzscoCounts[app.anzsco_code] = + (anzscoCounts[app.anzsco_code] || 0) + 1), + ); + + // Find ANZSCO code with highest occurances from dict + const result = Object.entries(anzscoCounts as object).reduce((a, b) => + a[1] > b[1] ? a : b, + )[0]; + + return { + header: `${result.split(" ")[0]}`, // grab only the numeral and not the entire string + text: `Most Popular ANZSCO Code`, + }; + } + + /** + * Counts the number of post-australian study applications + */ + private getPostAustralianStudyApplicationCount( + data: Application[], + ): StatisticsCard { + const pasApplicationCount: number = data.filter( + (app) => app.stream === "Post Australian Study", + ).length; + const pastApplicationPercentage: number = + (pasApplicationCount / data.length) * 100; + + return { + header: `${Math.round(pastApplicationPercentage * 100) / 100}%`, + text: `Post Australian Study Applications`, + }; + } +} diff --git a/src/app/services/clock/clock.service.spec.ts b/src/app/services/clock/clock.service.spec.ts new file mode 100644 index 0000000..2317cae --- /dev/null +++ b/src/app/services/clock/clock.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from "@angular/core/testing"; + +import { ClockService } from "./clock.service"; + +describe("ClockService", () => { + let service: ClockService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ClockService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/services/clock/clock.service.ts b/src/app/services/clock/clock.service.ts new file mode 100644 index 0000000..d2229e9 --- /dev/null +++ b/src/app/services/clock/clock.service.ts @@ -0,0 +1,22 @@ +import { Injectable } from "@angular/core"; +import { DateTime } from "luxon"; + +@Injectable({ + providedIn: "root", +}) +export class ClockService { + /** + * Finds difference in days given two dates + * @param + * startDate: initial date of application in ISOString format + * endDate: date of receiving result in ISOString format + * @returns + * Days between the two given dates as a number + */ + public static findDateDiff(startDate: string, endDate: string): number { + const start: DateTime = DateTime.fromISO(startDate); + const end: DateTime = DateTime.fromISO(endDate); + + return end.diff(start, "days").toObject().days!; + } +} diff --git a/src/assets/images/landing_page.png b/src/assets/images/landing_page.png index 1f6c312..062f7af 100644 Binary files a/src/assets/images/landing_page.png and b/src/assets/images/landing_page.png differ diff --git a/src/styles.scss b/src/styles.scss index 1f7ed81..2823e0f 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -100,6 +100,7 @@ body { .app-container { display: flex; flex-direction: column; + justify-content: space-between; height: 100vh; width: 60vw; background: $image-panel-bg;