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
67 changes: 66 additions & 1 deletion __tests__/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ describe('🧪 Express Application', () => {

describe('DELETE /api/devices/delete', () => {
const body = {
device_uuid: '5804f943-4aaf-432f-83d8-62028827ac57',
device_uuid: 'e062ebb6-4f14-4123-87bc-d31791756107',
};
it('204: should return no content on successful deletion', () => {
return request(app)
Expand Down Expand Up @@ -300,5 +300,70 @@ describe('🧪 Express Application', () => {
return request(app).post('/api/cats/update').send(data).expect(401);
});
});

describe('GET /api/cats/leaderboard/:range', () => {
it('400: should return when passed an invalid date range', () => {
return request(app)
.get('/api/cats/leaderboard/INVALID')
.expect(400)
.then(({ body: { success, message } }) => {
expect(success).toBe(false);
expect(message).toBe("'invalid' is not a recognized range");
});
});
describe('RANGE', () => {
it('200: DAILY', () => {
return request(app)
.get('/api/cats/leaderboard/DAILY')
.expect(200)
.then(({ body: { success, data, range } }) => {
expect(success).toBe(true);
expect(range).toBe('daily');
expect(data).toBeSorted();
expect(data.length).toBe(2);
});
});
it('200: WEEKLY', () => {
return request(app)
.get('/api/cats/leaderboard/WEEKLY')
.expect(200)
.then(({ body: { success, data, range } }) => {
expect(success).toBe(true);
expect(range).toBe('weekly');
expect(data).toBeSorted();
});
});
it('200: MONTHLY', () => {
return request(app)
.get('/api/cats/leaderboard/MONTHLY')
.expect(200)
.then(({ body: { success, data, range } }) => {
expect(success).toBe(true);
expect(range).toBe('monthly');
expect(data).toBeSorted();
});
});
it('200: YEARLY', () => {
return request(app)
.get('/api/cats/leaderboard/YEARLY')
.expect(200)
.then(({ body: { success, data, range } }) => {
expect(success).toBe(true);
expect(range).toBe('yearly');
expect(data).toBeSorted();
});
});
it('200: ALL_TIME', () => {
return request(app)
.get('/api/cats/leaderboard/all_time')
.expect(200)
.then(({ body: { success, data, range } }) => {
expect(success).toBe(true);
expect(range).toBe('all_time');
expect(data).toBeSorted();
});
});
});
});
});
});
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ module.exports = {
transform: {
'^.+.tsx?$': ['ts-jest', {}],
},
setupFilesAfterEnv: ['jest-sorted'],
};
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"globals": "^15.12.0",
"husky": "^9.1.7",
"jest": "^29.7.0",
"jest-sorted": "^1.0.15",
"lint-staged": "^15.2.10",
"nodemon": "^3.1.7",
"prettier": "3.3.3",
Expand All @@ -62,4 +63,4 @@
"npm run format"
]
}
}
}
36 changes: 25 additions & 11 deletions prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,13 @@ async function seeder() {
name: 'Tiddles Collar',
owner_id: user1.id,
uuid: '5804f943-4aaf-432f-83d8-62028827ac57',
last_pulse_at: '2024-11-25T16:22:11.108Z',
last_location: { lon: -1.445, lat: 53.8075 },
location_history: [
{ lon: -1.445, lat: 53.8075 },
{ lon: -1.446, lat: 53.807 },
{ lon: -1.447, lat: 53.8065 },
{ lon: -1.4485, lat: 53.806 },
{ lon: -1.4495, lat: 53.8055 },
{ lon: -1.446, lat: 53.807, timestamp: '2024-11-25T16:15:11.108Z' },
{ lon: -1.447, lat: 53.8065, timestamp: '2024-11-25T16:08:11.108Z' },
{ lon: -1.4485, lat: 53.806, timestamp: '2024-11-25T16:01:11.108Z' },
{ lon: -1.4495, lat: 53.8055, timestamp: '2024-11-25T15:54:11.108Z' },
],
},
});
Expand All @@ -45,17 +46,30 @@ async function seeder() {
name: 'A Collar',
owner_id: user1.id,
uuid: '36932d18-78a2-4ceb-b979-64a5ed441551',
last_pulse_at: '2024-11-25T16:22:11.108Z',
last_location: { lon: -1.447, lat: 53.8035 },
location_history: [
{ lon: -1.447, lat: 53.8035 },
{ lon: -1.4485, lat: 53.804 },
{ lon: -1.449, lat: 53.8045 },
{ lon: -1.45, lat: 53.805 },
{ lon: -1.451106, lat: 53.806201 },
{ lon: -1.4485, lat: 53.804, timestamp: '2024-11-25T16:15:11.108Z' },
{ lon: -1.449, lat: 53.8045, timestamp: '2024-11-25T16:08:11.108Z' },
{ lon: -1.45, lat: 53.805, timestamp: '2024-11-25T16:01:11.108Z' },
{
lon: -1.451106,
lat: 53.806201,
timestamp: '2024-11-25T15:54:11.108Z',
},
],
},
});

// cm3pyzthe000108jka5f7hw99
await prisma.device.upsert({
where: { uuid: 'e062ebb6-4f14-4123-87bc-d31791756107' },
update: {},
create: {
name: 'A Collar',
owner_id: user1.id,
uuid: 'e062ebb6-4f14-4123-87bc-d31791756107',
},
});

await prisma.cat.upsert({
where: { id: 'cm3pr1rkb000008l8fs7icr9g' },
Expand Down
23 changes: 22 additions & 1 deletion src/controllers/cats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,32 @@ export function updateCat(
cats.fetchCatsByUserID(user.id).then((usersCats) => {
// ensure that the authenticated user owns the cat
const catMatch = usersCats.find((cat) => cat.id === result.body.cat_id);
if (!catMatch) return next({ status: 404, message: 'Cat does not exist' });
if (!catMatch) throw new Error('no cat found');

delete result.body.cat_id;
cats.updateCat(catMatch.id, result.body).then((updated_cat) => {
response.status(200).json({ success: true, data: updated_cat });
});
});
}

export async function getLeaderboardWithRange(
request: Request,
response: Response,
next: NextFunction
) {
const validRanges = ['daily', 'weekly', 'monthly', 'yearly', 'all_time'];
let { range } = request.params;

range = range.toLowerCase();

if (!validRanges.includes(range))
return next({
status: 400,
message: `'${range}' is not a recognized range`,
});

const catsData = await cats.getAllCatsWithRange(range);

response.status(200).json({ success: true, data: catsData, range: range });
}
71 changes: 71 additions & 0 deletions src/models/cats.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import extendedClient from '../db/client';
import Coordinate from '../type/CoordinateType';
import coordsToScore from '../utils/coordsToScore';

export async function createCat(
id: string,
Expand Down Expand Up @@ -45,3 +47,72 @@ export async function updateCat(id: string, payload: object) {
},
});
}

export async function getAllCatsWithRange(range: string) {
const startDate = new Date();
const endDate = new Date();

switch (range) {
case 'daily':
startDate.setDate(startDate.getDate() - 1);
break;
case 'weekly':
startDate.setDate(startDate.getDate() - 7);
break;
case 'monthly':
startDate.setMonth(startDate.getMonth() - 1);
break;
case 'yearly':
startDate.setFullYear(startDate.getFullYear() - 1);
break;
case 'all_time':
startDate.setTime(0);
break;
}

const data = await extendedClient.device.findMany({
// Join cat onto the result
include: {
cat: true,
},
where: {
// We don't want devices that have no data, or no associated cat
NOT: [
{
last_pulse_at: null,
},
{
cat: null,
},
],
},
});

// As the location history is nested as an array in the row, we'll just have to do some
// funky array methods. I wanted to use GTE and LTE, but apparently they don't work.
const sortedData = data
.map((cat) => {
// Get all location history that we care about
const historyInRange = cat.location_history.filter((item) => {
const parsedTimestamp = new Date(item.timestamp);
return parsedTimestamp > startDate && parsedTimestamp < endDate;
});
// Convert our history into a useable format
const historyAsCoordinates: Coordinate[] = historyInRange.map((item) => [
item.lat,
item.lon,
]);
// Create our final coordinates array that contains current location plus all the history
const coordinates: Coordinate[] = [
[cat.last_location.lat, cat.last_location.lon],
...historyAsCoordinates,
];
// Calculate the score
const score = coordsToScore(coordinates);
// Return an object with the score K:V appended
return { ...cat, score: score };
})
.sort((a, b) => b.score - a.score); // Sort descending

return sortedData;
}
1 change: 1 addition & 0 deletions src/models/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export async function updateDevice(
history.push({
lat: currentInformation.last_location.lat,
lon: currentInformation.last_location.lon,
timestamp: new Date().toISOString(),
});
// We have previous data. Lets push it to the history before we overwrite
}
Expand Down
1 change: 1 addition & 0 deletions src/routes/cats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ const cats: Router = Router();

cats.post('/create', usernameAuth, controller.createCat);
cats.post('/update', usernameAuth, controller.updateCat);
cats.get('/leaderboard/:range', controller.getLeaderboardWithRange);

export default cats;
3 changes: 3 additions & 0 deletions src/type/CoordinateType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
type Coordinate = [number, number];

export default Coordinate;
2 changes: 1 addition & 1 deletion src/type/LocationHistoryType.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
type LocationHistoryType = { lat: number; lon: number }[];
type LocationHistoryType = { lat: number; lon: number; timestamp: string }[];

export default LocationHistoryType;
25 changes: 25 additions & 0 deletions src/utils/coordsToScore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* @author PhilTBatt
*/
export default function coordsToScore(coordinates: [number, number][]): number {
const earthRadiusPerDegree = 111000;
let totalLength = 0;

for (let i = 0; i < coordinates.length - 1; i++) {
const [lat1, lng1] = coordinates[i];
const [lat2, lng2] = coordinates[i + 1];

const deltaLat = lat2 - lat1;
const deltaLng = lng2 - lng1;

const deltaLatMeters = deltaLat * earthRadiusPerDegree;
const deltaLngMeters =
deltaLng * earthRadiusPerDegree * Math.cos((lat1 * Math.PI) / 180);

totalLength += Math.sqrt(
deltaLatMeters * deltaLatMeters + deltaLngMeters * deltaLngMeters
);
}

return totalLength;
}
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"types": ["jest"],
"types": ["jest", "jest-sorted"],
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
Expand Down