Skip to content

Commit 992af3b

Browse files
Show User Statistics on the user profile (#672)
* This commit will show the user statistics on the user profile. * thsi commit will update the navbar component in the profile/username route to remove unwanted parts from the username * refactor: format nav-bar * fix: remove duplicate query and fix count() usage --------- Co-authored-by: David Scheidt <[email protected]>
1 parent c5b55c9 commit 992af3b

File tree

3 files changed

+235
-159
lines changed

3 files changed

+235
-159
lines changed

app/components/nav-bar.tsx

Lines changed: 86 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,97 @@
1-
import { LogIn, Mailbox, Plus } from "lucide-react";
2-
import { Link, useLocation } from "react-router";
3-
import Menu from "./header/menu";
4-
import { Button } from "./ui/button";
1+
import { LogIn, Mailbox, Plus } from 'lucide-react'
2+
import { Link, useLocation } from 'react-router'
3+
import Menu from './header/menu'
4+
import { Button } from './ui/button'
55

66
import {
7-
DropdownMenu,
8-
DropdownMenuGroup,
9-
DropdownMenuItem,
10-
DropdownMenuContent,
11-
DropdownMenuTrigger,
12-
} from "@/components/ui/dropdown-menu";
13-
import { useOptionalUser } from "~/utils";
7+
DropdownMenu,
8+
DropdownMenuGroup,
9+
DropdownMenuItem,
10+
DropdownMenuContent,
11+
DropdownMenuTrigger,
12+
} from '@/components/ui/dropdown-menu'
13+
import { useOptionalUser } from '~/utils'
1414

1515
export function NavBar() {
16-
const location = useLocation();
17-
const parts = location.pathname
18-
.split("/")
19-
.slice(1)
20-
.map((item) => item.charAt(0).toUpperCase() + item.slice(1).toLowerCase());
16+
const location = useLocation()
17+
const parts = location.pathname
18+
.split('/')
19+
.slice(1)
20+
.map((item) => {
21+
const decoded = decodeURIComponent(item)
22+
return decoded.charAt(0).toUpperCase() + decoded.slice(1)
23+
}) // prevents empty parts from showing
2124

22-
// User is optional
23-
// If no user render Login button
24-
const user = useOptionalUser();
25+
// User is optional
26+
// If no user render Login button
27+
const user = useOptionalUser()
2528

26-
return (
27-
<div className="border-b bg-white dark:bg-dark-background dark:text-dark-text p-4">
28-
<div className="flex h-16 items-center justify-between">
29-
<div className="flex max-w-screen-xl flex-wrap items-center justify-between">
30-
<Link to="/" className="flex items-center md:pr-4">
31-
<img src="/logo.png" className="mr-3 h-6 sm:h-9" alt="osem Logo" />
32-
</Link>
33-
<span className="dark:text-dark-green hidden self-center whitespace-nowrap text-xl font-semibold text-light-green md:block">
34-
{parts.join(" / ")}
35-
</span>
36-
</div>
37-
<div className="flex items-center gap-2">
38-
{user ? (
39-
<>
40-
<DropdownMenu>
41-
<DropdownMenuTrigger asChild>
42-
<Button variant="outline" size="icon" className="mx-2">
43-
<Plus className="h-4 w-4" />
44-
</Button>
45-
</DropdownMenuTrigger>
29+
return (
30+
<div className="border-b bg-white p-4 dark:bg-dark-background dark:text-dark-text">
31+
<div className="flex h-16 items-center justify-between">
32+
<div className="flex max-w-screen-xl flex-wrap items-center justify-between">
33+
<Link to="/" className="flex items-center md:pr-4">
34+
<img src="/logo.png" className="mr-3 h-6 sm:h-9" alt="osem Logo" />
35+
</Link>
36+
<span className="hidden self-center whitespace-nowrap text-xl font-semibold text-light-green dark:text-dark-green md:block">
37+
{parts.join(' / ')}
38+
</span>
39+
</div>
40+
<div className="flex items-center gap-2">
41+
{user ? (
42+
<>
43+
<DropdownMenu>
44+
<DropdownMenuTrigger asChild>
45+
<Button variant="outline" size="icon" className="mx-2">
46+
<Plus className="h-4 w-4" />
47+
</Button>
48+
</DropdownMenuTrigger>
4649

47-
<DropdownMenuContent
48-
align="end"
49-
forceMount
50-
className="dark:bg-dark-background dark:text-dark-text"
51-
>
52-
<DropdownMenuGroup>
53-
<Link to="/device/new">
54-
<DropdownMenuItem>
55-
<span>New device</span>
56-
</DropdownMenuItem>
57-
</Link>
50+
<DropdownMenuContent
51+
align="end"
52+
forceMount
53+
className="dark:bg-dark-background dark:text-dark-text"
54+
>
55+
<DropdownMenuGroup>
56+
<Link to="/device/new">
57+
<DropdownMenuItem>
58+
<span>New device</span>
59+
</DropdownMenuItem>
60+
</Link>
5861

59-
<Link to="/device/transfer">
60-
<DropdownMenuItem disabled>
61-
<span>Transfer device</span>
62-
</DropdownMenuItem>
63-
</Link>
64-
</DropdownMenuGroup>
65-
</DropdownMenuContent>
66-
</DropdownMenu>
62+
<Link to="/device/transfer">
63+
<DropdownMenuItem disabled>
64+
<span>Transfer device</span>
65+
</DropdownMenuItem>
66+
</Link>
67+
</DropdownMenuGroup>
68+
</DropdownMenuContent>
69+
</DropdownMenu>
6770

68-
<Button variant="outline" size="icon" disabled>
69-
<Mailbox className="h-4 w-4" />
70-
</Button>
71+
<Button variant="outline" size="icon" disabled>
72+
<Mailbox className="h-4 w-4" />
73+
</Button>
7174

72-
<div className="px-8">
73-
<Menu />
74-
</div>
75-
</>
76-
) : (
77-
<div className="px-8">
78-
<div className="pointer-events-auto box-border h-10 w-10">
79-
<button
80-
type="button"
81-
className="h-10 w-10 rounded-full border border-gray-100 bg-white text-center text-black hover:bg-gray-100"
82-
>
83-
<Link to="/login">
84-
<LogIn className="mx-auto h-6 w-6" />
85-
</Link>
86-
</button>
87-
</div>
88-
</div>
89-
)}
90-
</div>
91-
</div>
92-
</div>
93-
);
75+
<div className="px-8">
76+
<Menu />
77+
</div>
78+
</>
79+
) : (
80+
<div className="px-8">
81+
<div className="pointer-events-auto box-border h-10 w-10">
82+
<button
83+
type="button"
84+
className="h-10 w-10 rounded-full border border-gray-100 bg-white text-center text-black hover:bg-gray-100"
85+
>
86+
<Link to="/login">
87+
<LogIn className="mx-auto h-6 w-6" />
88+
</Link>
89+
</button>
90+
</div>
91+
</div>
92+
)}
93+
</div>
94+
</div>
95+
</div>
96+
)
9497
}

app/models/profile.server.ts

Lines changed: 109 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,124 @@
1-
import { eq, type ExtractTablesWithRelations } from "drizzle-orm";
2-
import { type PgTransaction } from "drizzle-orm/pg-core";
3-
import { type PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js";
4-
import { drizzleClient } from "~/db.server";
5-
import { type User, type Profile, profile } from "~/schema";
6-
import type * as schema from "~/schema";
1+
import {
2+
eq,
3+
type ExtractTablesWithRelations,
4+
count,
5+
inArray,
6+
} from 'drizzle-orm'
7+
import { type PgTransaction } from 'drizzle-orm/pg-core'
8+
import { type PostgresJsQueryResultHKT } from 'drizzle-orm/postgres-js'
9+
import { drizzleClient } from '~/db.server'
10+
import { type User, type Profile, profile, sensor, measurement } from '~/schema'
11+
import type * as schema from '~/schema'
712

8-
export async function getProfileByUserId(id: Profile["id"]) {
9-
return drizzleClient.query.profile.findFirst({
10-
where: (profile, { eq }) => eq(profile.userId, id),
11-
with: {
12-
profileImage: true,
13-
},
14-
});
13+
export async function getProfileByUserId(id: Profile['id']) {
14+
return drizzleClient.query.profile.findFirst({
15+
where: (profile, { eq }) => eq(profile.userId, id),
16+
with: {
17+
profileImage: true,
18+
},
19+
})
1520
}
1621

17-
export async function getProfileByUsername(username: Profile["username"]) {
18-
return drizzleClient.query.profile.findFirst({
19-
where: (profile, { eq }) => eq(profile.username, username),
20-
with: {
21-
user: {
22-
with: {
23-
devices: true,
24-
},
25-
},
26-
profileImage: true,
27-
},
28-
});
22+
export async function getProfileByUsername(username: Profile['username']) {
23+
return drizzleClient.query.profile.findFirst({
24+
where: (profile, { eq }) => eq(profile.username, username),
25+
with: {
26+
user: {
27+
with: {
28+
devices: true,
29+
},
30+
},
31+
profileImage: true,
32+
},
33+
})
2934
}
3035

3136
export async function updateProfile(
32-
id: Profile["id"],
33-
username: Profile["username"],
34-
visibility: Profile["public"],
37+
id: Profile['id'],
38+
username: Profile['username'],
39+
visibility: Profile['public'],
3540
) {
36-
try {
37-
const result = await drizzleClient
38-
.update(profile)
39-
.set({ username, public: visibility })
40-
.where(eq(profile.id, id));
41-
return result;
42-
} catch (error) {
43-
throw error;
44-
}
41+
try {
42+
const result = await drizzleClient
43+
.update(profile)
44+
.set({ username, public: visibility })
45+
.where(eq(profile.id, id))
46+
return result
47+
} catch (error) {
48+
throw error
49+
}
4550
}
4651

4752
export async function createProfile(
48-
userId: User["id"],
49-
username: Profile["username"],
53+
userId: User['id'],
54+
username: Profile['username'],
5055
) {
51-
return drizzleClient.transaction(t =>
52-
createProfileWithTransaction(t, userId, username));
56+
return drizzleClient.transaction((t) =>
57+
createProfileWithTransaction(t, userId, username),
58+
)
5359
}
5460

5561
export async function createProfileWithTransaction(
56-
transaction: PgTransaction<PostgresJsQueryResultHKT, typeof schema, ExtractTablesWithRelations<typeof schema>>,
57-
userId: User["id"],
58-
username: Profile["username"],
62+
transaction: PgTransaction<
63+
PostgresJsQueryResultHKT,
64+
typeof schema,
65+
ExtractTablesWithRelations<typeof schema>
66+
>,
67+
userId: User['id'],
68+
username: Profile['username'],
5969
) {
60-
return transaction
61-
.insert(profile)
62-
.values({
63-
username,
64-
public: false,
65-
userId,
66-
});
67-
}
70+
return transaction.insert(profile).values({
71+
username,
72+
public: false,
73+
userId,
74+
})
75+
}
76+
77+
function formatCount(num: number): string {
78+
if (num >= 1_000_000) {
79+
return `${(num / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
80+
}
81+
if (num >= 1_000) {
82+
return `${(num / 1_000).toFixed(1).replace(/\.0$/, '')}k`
83+
}
84+
return num.toString()
85+
}
86+
87+
// function to get sensors and measurements count for a profile
88+
export async function getProfileSensorsAndMeasurementsCount(profile: Profile) {
89+
const userId = profile.userId
90+
if (userId == null) return { sensorsCount: '0', measurementsCount: '0' }
91+
92+
const devices = await drizzleClient.query.device.findMany({
93+
where: (device, { eq }) => eq(device.userId, userId),
94+
})
95+
const deviceIds = devices.map((device) => device.id)
96+
97+
if (deviceIds.length === 0) {
98+
return { sensorsCount: '0', measurementsCount: '0' }
99+
}
100+
101+
// Get sensor IDs for measurements count
102+
const sensors = await drizzleClient.query.sensor.findMany({
103+
where: (s, { inArray }) => inArray(s.deviceId, deviceIds),
104+
columns: { id: true },
105+
})
106+
const sensorsCount = sensors.length
107+
const sensorIds = sensors.map((s) => s.id)
108+
109+
// Count measurements using COUNT query
110+
let measurementsCount = 0
111+
if (sensorIds.length > 0) {
112+
const [measurementsResult] = await drizzleClient
113+
.select({ count: count(measurement.value) })
114+
.from(measurement)
115+
.where(inArray(measurement.sensorId, sensorIds))
116+
117+
measurementsCount = measurementsResult.count
118+
}
119+
120+
return {
121+
sensorsCount: formatCount(sensorsCount),
122+
measurementsCount: formatCount(measurementsCount),
123+
}
124+
}

0 commit comments

Comments
 (0)