refactor: replace index queries with filter method for database queries, enhance profile management with flash count tracking, and introduce ProfileCard component for improved UI consistency

This commit is contained in:
Ben Allfree
2025-11-24 04:24:10 -08:00
parent 508497f626
commit 10f4ff520a
9 changed files with 167 additions and 106 deletions

View File

@@ -8,29 +8,29 @@
* @module
*/
import type {
DataModelFromSchemaDefinition,
DocumentByName,
TableNamesInDataModel,
SystemTableNames,
} from "convex/server";
import { AnyDataModel } from "convex/server";
import type { GenericId } from "convex/values";
import schema from "../schema.js";
/**
* No `schema.ts` file found!
*
* This generated code has permissive types like `Doc = any` because
* Convex doesn't know your schema. If you'd like more type safety, see
* https://docs.convex.dev/using/schemas for instructions on how to add a
* schema file.
*
* After you change a schema, rerun codegen with `npx convex dev`.
*/
/**
* The names of all of your Convex tables.
*/
export type TableNames = TableNamesInDataModel<DataModel>;
export type TableNames = string;
/**
* The type of a document stored in Convex.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Doc<TableName extends TableNames> = DocumentByName<
DataModel,
TableName
>;
export type Doc = any;
/**
* An identifier for a document in Convex.
@@ -42,10 +42,8 @@ export type Doc<TableName extends TableNames> = DocumentByName<
*
* IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Id<TableName extends TableNames | SystemTableNames> =
export type Id<TableName extends TableNames = TableNames> =
GenericId<TableName>;
/**
@@ -57,4 +55,4 @@ export type Id<TableName extends TableNames | SystemTableNames> =
* This type is used to parameterize methods like `queryGeneric` and
* `mutationGeneric` to make them type-safe.
*/
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;
export type DataModel = AnyDataModel;

View File

@@ -89,7 +89,7 @@ export const triggerBuildViaProfile = mutation({
// Check if build already exists with this hash
let existingBuild = await ctx.db
.query('builds')
.withIndex('by_hash', (q) => q.eq('buildHash', buildHash))
.filter((q) => q.eq('buildHash', buildHash))
.first()
let buildId: Id<'builds'>
@@ -112,7 +112,7 @@ export const triggerBuildViaProfile = mutation({
// Handle race condition
existingBuild = await ctx.db
.query('builds')
.withIndex('by_hash', (q) => q.eq('buildHash', buildHash))
.filter((q) => q.eq('buildHash', buildHash))
.first()
if (existingBuild && existingBuild._id !== buildId) {
@@ -124,9 +124,10 @@ export const triggerBuildViaProfile = mutation({
// Create or update profileBuild record
// Check if a profileBuild already exists for this profile+target combination
const profileId = args.profileId as string
const profileBuilds = await ctx.db
.query('profileBuilds')
.withIndex('by_profile', (q) => q.eq('profileId', args.profileId))
.filter((q) => q.eq('profileId', profileId))
.collect()
// Find existing profileBuild with matching target by checking the build
@@ -169,9 +170,10 @@ export const listByProfile = query({
args: { profileId: v.id('profiles') },
handler: async (ctx, args) => {
// Query profileBuilds for this profile
const listProfileId = args.profileId as string
const profileBuilds = await ctx.db
.query('profileBuilds')
.withIndex('by_profile', (q) => q.eq('profileId', args.profileId))
.filter((q) => q.eq('profileId', listProfileId))
.collect()
// Get builds for each profileBuild
@@ -206,9 +208,10 @@ export const get = query({
// Check if user has access via profileBuilds
// Get all profileBuilds for this build
const buildId = args.buildId as string
const profileBuilds = await ctx.db
.query('profileBuilds')
.withIndex('by_build', (q) => q.eq('buildId', args.buildId))
.filter((q) => q.eq('buildId', buildId))
.collect()
// Check if any of these profileBuilds link to a profile owned by the user
@@ -251,9 +254,10 @@ export const deleteBuild = mutation({
}
// Find profileBuild linking this profile to this build
const buildId = args.buildId as string
const profileBuilds = await ctx.db
.query('profileBuilds')
.withIndex('by_build', (q) => q.eq('buildId', args.buildId))
.filter((q) => q.eq('buildId', buildId))
.collect()
const profileBuild = profileBuilds.find(

View File

@@ -11,7 +11,7 @@ export const list = query({
return await ctx.db
.query('profiles')
.withIndex('by_user', (q) => q.eq('userId', userId))
.filter((q) => q.eq(q.field('userId'), userId))
.collect()
},
})
@@ -19,10 +19,11 @@ export const list = query({
export const listPublic = query({
args: {},
handler: async (ctx) => {
// Get all profiles and filter for public ones (isPublic === true or undefined)
// Note: Index query with optional field may not work as expected, so we filter manually
const allProfiles = await ctx.db.query('profiles').collect()
return allProfiles.filter((p) => p.isPublic !== false)
const allProfiles = await ctx.db
.query('profiles')
.filter((q) => q.eq(q.field('isPublic'), true))
.collect()
return allProfiles.sort((a, b) => (b.flashCount ?? 0) - (a.flashCount ?? 0))
},
})
@@ -46,9 +47,10 @@ export const get = query({
export const getTargets = query({
args: { profileId: v.id('profiles') },
handler: async (ctx, args) => {
const profileId = args.profileId as string
const profileBuilds = await ctx.db
.query('profileBuilds')
.withIndex('by_profile', (q) => q.eq('profileId', args.profileId))
.filter((q) => q.eq('profileId', profileId))
.collect()
// Get unique targets from builds
const builds = await Promise.all(
@@ -72,7 +74,7 @@ export const getProfileTarget = query({
// Get all profileBuilds for this profile
const profileBuilds = await ctx.db
.query('profileBuilds')
.withIndex('by_profile', (q) => q.eq('profileId', args.profileId))
.filter((q) => q.eq(q.field('profileId'), args.profileId))
.collect()
// Find the profileBuild with matching target by checking the build
@@ -98,7 +100,7 @@ export const getFlashCount = query({
handler: async (ctx, args) => {
const profileBuilds = await ctx.db
.query('profileBuilds')
.withIndex('by_profile', (q) => q.eq('profileId', args.profileId))
.filter((q) => q.eq(q.field('profileId'), args.profileId))
.collect()
let successCount = 0
@@ -112,6 +114,27 @@ export const getFlashCount = query({
},
})
export const recordFlash = mutation({
args: {
profileId: v.id('profiles'),
},
handler: async (ctx, args) => {
const profile = await ctx.db.get(args.profileId)
if (!profile) {
throw new Error('Profile not found')
}
const nextCount = (profile.flashCount ?? 0) + 1
await ctx.db.patch(args.profileId, {
flashCount: nextCount,
updatedAt: Date.now(),
})
return nextCount
},
})
export const create = mutation({
args: {
name: v.string(),
@@ -119,7 +142,7 @@ export const create = mutation({
targets: v.optional(v.array(v.string())),
config: v.any(),
version: v.string(),
isPublic: v.optional(v.boolean()),
isPublic: v.boolean(),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx)
@@ -131,6 +154,7 @@ export const create = mutation({
description: args.description,
config: args.config,
version: args.version,
flashCount: 0,
updatedAt: Date.now(),
isPublic: args.isPublic ?? true,
})
@@ -150,7 +174,7 @@ export const update = mutation({
targets: v.optional(v.array(v.string())),
config: v.any(),
version: v.optional(v.string()),
isPublic: v.optional(v.boolean()),
isPublic: v.boolean(),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx)
@@ -168,6 +192,7 @@ export const update = mutation({
config: args.config,
version: args.version,
isPublic: args.isPublic,
flashCount: profile.flashCount ?? 0,
updatedAt: Date.now(),
})
@@ -190,7 +215,7 @@ export const remove = mutation({
// Delete associated profileBuilds
const profileBuilds = await ctx.db
.query('profileBuilds')
.withIndex('by_profile', (q) => q.eq('profileId', args.id))
.filter((q) => q.eq(q.field('profileId'), args.id))
.collect()
for (const profileBuild of profileBuilds) {

View File

@@ -1,35 +0,0 @@
import { authTables } from '@convex-dev/auth/server'
import { defineSchema, defineTable } from 'convex/server'
import { v } from 'convex/values'
export default defineSchema({
...authTables,
profiles: defineTable({
userId: v.id('users'),
name: v.string(),
description: v.string(),
config: v.any(), // JSON object for flags
version: v.string(),
updatedAt: v.number(),
isPublic: v.optional(v.boolean()),
})
.index('by_user', ['userId'])
.index('by_public', ['isPublic']),
builds: defineTable({
target: v.string(),
githubRunId: v.number(),
status: v.string(), // Accepts arbitrary status strings (e.g., "queued", "checking_out", "building", "uploading", "success", "failure")
startedAt: v.number(),
completedAt: v.optional(v.number()),
buildHash: v.string(),
artifactPath: v.optional(v.string()), // Path to artifact in R2 (e.g., "/abc123.uf2" or "/abc123.bin")
}).index('by_hash', ['buildHash']),
profileBuilds: defineTable({
profileId: v.id('profiles'),
buildId: v.id('builds'),
})
.index('by_profile', ['profileId'])
.index('by_build', ['buildId']),
})

View File

@@ -0,0 +1,52 @@
import type { Doc } from '../../convex/_generated/dataModel'
export const profileCardClasses =
'border border-slate-800 rounded-lg p-6 bg-slate-900/50 flex flex-col gap-4'
interface ProfilePillsProps {
version: string
flashCount?: number
flashLabel?: string
}
export function ProfileStatisticPills({
version,
flashCount,
flashLabel,
}: ProfilePillsProps) {
const normalizedCount = flashCount ?? 0
const normalizedLabel =
flashLabel ?? (normalizedCount === 1 ? 'flash' : 'flashes')
return (
<div className="flex items-center justify-between text-xs font-semibold uppercase tracking-wide">
<span className="inline-flex items-center rounded-full bg-slate-800/80 text-slate-200 px-3 py-1">
{version}
</span>
<span className="inline-flex items-center rounded-full bg-cyan-500/10 text-cyan-300 px-3 py-1">
{normalizedCount} {normalizedLabel}
</span>
</div>
)
}
interface ProfileCardContentProps {
profile: Doc<'profiles'>
}
export function ProfileCardContent({ profile }: ProfileCardContentProps) {
const flashCount = profile.flashCount ?? 0
return (
<>
<div className="flex-1">
<h3 className="text-xl font-semibold mb-2">{profile.name}</h3>
<p className="text-slate-300 text-sm leading-relaxed">
{profile.description}
</p>
</div>
<ProfileStatisticPills
version={profile.version}
flashCount={flashCount}
/>
</>
)
}

View File

@@ -3,6 +3,10 @@ import { Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import {
ProfileCardContent,
profileCardClasses,
} from '@/components/ProfileCard'
import ProfileEditor from '@/components/ProfileEditor'
import { Button } from '@/components/ui/button'
import { api } from '../../convex/_generated/api'
@@ -67,19 +71,9 @@ export default function Dashboard() {
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{profiles?.map((profile) => (
<div
key={profile._id}
className="border border-slate-800 rounded-lg p-6 bg-slate-900/50"
>
<h3 className="text-xl font-semibold mb-2">{profile.name}</h3>
<p className="text-slate-400 text-sm mb-1">
Version:{' '}
<span className="text-slate-200">{profile.version}</span>
</p>
<p className="text-slate-300 text-sm mb-4 leading-relaxed">
{profile.description}
</p>
<div className="flex gap-2">
<div key={profile._id} className={profileCardClasses}>
<ProfileCardContent profile={profile} />
<div className="flex gap-2 pt-2">
<Button size="sm" asChild>
<Link to={`/profiles/${profile._id}`}>Use</Link>
</Button>

View File

@@ -1,5 +1,9 @@
import { useQuery } from 'convex/react'
import { useNavigate } from 'react-router-dom'
import {
ProfileCardContent,
profileCardClasses,
} from '@/components/ProfileCard'
import { api } from '../../convex/_generated/api'
export default function LandingPage() {
@@ -44,16 +48,9 @@ export default function LandingPage() {
navigate(`/profiles/${profile._id}`)
}
}}
className="border border-slate-800 rounded-lg p-6 bg-slate-900/50 hover:bg-slate-900 cursor-pointer transition-colors text-left"
className={`${profileCardClasses} hover:bg-slate-900 cursor-pointer transition-colors text-left`}
>
<h3 className="text-xl font-semibold mb-2">{profile.name}</h3>
<p className="text-slate-400 text-sm">
Version:{' '}
<span className="text-slate-200">{profile.version}</span>
</p>
<p className="text-slate-300 text-sm mt-3 leading-relaxed">
{profile.description}
</p>
<ProfileCardContent profile={profile} />
</button>
))}
</div>

View File

@@ -3,6 +3,7 @@ import * as React from 'react'
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { ProfileStatisticPills } from '@/components/ProfileCard'
import { Button } from '@/components/ui/button'
import { api } from '../../convex/_generated/api'
import type { Id } from '../../convex/_generated/dataModel'
@@ -93,14 +94,23 @@ export default function ProfileDetail() {
}
}
const totalFlashes = profile.flashCount ?? 0
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto">
<h1 className="text-4xl font-bold mb-2">{profile.name}</h1>
<p className="text-slate-400 mb-8">
Version: {profile.version} Flashed {flashCount} time
{flashCount !== 1 ? 's' : ''}
</p>
<div className="space-y-4 mb-8">
<div>
<h1 className="text-4xl font-bold mb-2">{profile.name}</h1>
<p className="text-slate-400">
Flashed {flashCount} time{flashCount !== 1 ? 's' : ''}
</p>
</div>
<ProfileStatisticPills
version={profile.version}
flashCount={totalFlashes}
/>
</div>
<div className="space-y-8">
{/* Enabled Modules */}

View File

@@ -1,4 +1,4 @@
import { useQuery } from 'convex/react'
import { useMutation, useQuery } from 'convex/react'
import {
ArrowLeft,
CheckCircle,
@@ -7,6 +7,7 @@ import {
XCircle,
} from 'lucide-react'
import { Link, useParams } from 'react-router-dom'
import { ProfileStatisticPills } from '@/components/ProfileCard'
import { Button } from '@/components/ui/button'
import { humanizeStatus } from '@/lib/utils'
import { api } from '../../convex/_generated/api'
@@ -28,6 +29,7 @@ export default function ProfileFlash() {
api.profiles.get,
id ? { id: id as Id<'profiles'> } : 'skip'
)
const recordFlash = useMutation(api.profiles.recordFlash)
if (data === undefined || profile === undefined) {
return (
@@ -79,6 +81,19 @@ export default function ProfileFlash() {
const includedModules = modulesData.modules.filter(
(module) => profile.config?.[module.id] === false
)
const totalFlashes = profile.flashCount ?? 0
const handleDownload = async () => {
if (!id || !build.artifactUrl) return
try {
await recordFlash({ profileId: id as Id<'profiles'> })
} catch (error) {
console.error('Failed to record flash', error)
} finally {
window.open(build.artifactUrl, '_blank', 'noopener,noreferrer')
}
}
const getStatusColor = (status: string) => {
if (status === 'success') return 'text-green-400'
@@ -124,6 +139,10 @@ export default function ProfileFlash() {
<p className="text-slate-200 leading-relaxed">
{profile.description}
</p>
<ProfileStatisticPills
version={profile.version}
flashCount={totalFlashes}
/>
</div>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
@@ -176,15 +195,12 @@ export default function ProfileFlash() {
{build.status === 'success' && build.artifactUrl && (
<div>
<a
href={build.artifactUrl}
target="_blank"
rel="noopener noreferrer"
<Button
onClick={handleDownload}
className="bg-cyan-600 hover:bg-cyan-700 w-full"
>
<Button className="bg-cyan-600 hover:bg-cyan-700 w-full">
Download Firmware
</Button>
</a>
Download Firmware
</Button>
</div>
)}
</div>