From 108bb546fcd2d1530fab3e9b30e112c78cde5a2b Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 20 Feb 2026 13:56:40 +0100 Subject: [PATCH 1/7] Refactor votes fetching to use @google-cloud/firestore Switch from firebase-admin to @google-cloud/firestore for reading votes data at build time. Remove credentials file handling and related Dockerfile steps. Simplify environment variable usage. --- .github/workflows/deploy.yml | 3 - Dockerfile | 7 +- lib/repositories/VotesRepository.ts | 171 +++++++--------------------- 3 files changed, 44 insertions(+), 137 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 373eaf4c..c269829e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -110,12 +110,9 @@ jobs: env: GH_TOKEN: ${{ steps.generate_token.outputs.token }} run: | - echo ${{ secrets.FIREBASE_TOKEN }} | base64 -d > ./credentials.json echo "Building Docker Image with tag $IMAGE_NAME" docker build --build-arg GH_TOKEN=${{ env.GH_TOKEN }} \ - --build-arg PROJECT_ID=analysis-tools-dev \ -t ${IMAGE_NAME} . - rm ./credentials.json - name: 'Push Docker Image' run: | diff --git a/Dockerfile b/Dockerfile index 6b10381e..f079e305 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,19 +4,16 @@ WORKDIR /src COPY package.json package-lock.json /src/ RUN npm ci -ENV GOOGLE_APPLICATION_CREDENTIALS=/src/credentials.json -ENV FIREBASE_PROJECT_ID=analysis-tools-dev ARG GH_TOKEN -ARG PROJECT_ID COPY . /src # Build runs npm run build-data (prebuild hook) which fetches tools data # from GitHub repos and generates static JSON files, then runs next build +# Note: Votes will be skipped during build if GOOGLE_APPLICATION_CREDENTIALS is not set RUN npm run build -RUN rm /src/credentials.json FROM node:20 WORKDIR /src COPY --from=build /src /src -ENTRYPOINT ["npm", "run", "start"] +ENTRYPOINT ["npm", "run", "start"] \ No newline at end of file diff --git a/lib/repositories/VotesRepository.ts b/lib/repositories/VotesRepository.ts index 06b5a0f4..af751687 100644 --- a/lib/repositories/VotesRepository.ts +++ b/lib/repositories/VotesRepository.ts @@ -1,20 +1,16 @@ /** * VotesRepository * - * Repository class for accessing votes data from Firebase. - * Provides a simplified interface for fetching votes at build time. + * Repository class for accessing votes data from Firestore. + * Uses Google Cloud Application Default Credentials. */ import type { VotesApiData } from 'utils/types'; -import fs from 'fs'; export class VotesRepository { private static instance: VotesRepository | null = null; - private initPromise: Promise | null = null; private votesCache: VotesApiData | null = null; - private credentialsValidated = false; - // Private constructor for singleton pattern // eslint-disable-next-line @typescript-eslint/no-empty-function private constructor() {} @@ -25,99 +21,12 @@ export class VotesRepository { return VotesRepository.instance; } - private async initFirebase(): Promise { - if (this.initPromise) { - return this.initPromise; - } - - this.initPromise = (async () => { - const { apps, credential } = await import('firebase-admin'); - const { initializeApp } = await import('firebase-admin/app'); - - if (!apps.length) { - initializeApp({ - credential: credential.applicationDefault(), - databaseURL: 'https://analysis-tools-dev.firebaseio.com', - }); - } - })(); - - return this.initPromise; - } - - /** - * Checks if Firebase credentials environment variable is set. - * Returns false if not configured (graceful skip). - * Throws an error if configured but invalid (fail fast). - */ - isConfigured(): boolean { - const credentialsPath = process.env.GOOGLE_APPLICATION_CREDENTIALS; - - // If env var is not set, gracefully skip (not an error) - if (!credentialsPath) { - return false; - } - - // If already validated, return true - if (this.credentialsValidated) { - return true; - } - - // Env var is set, so credentials MUST be valid - fail fast if not - - // Check if file exists - if (!fs.existsSync(credentialsPath)) { - throw new Error( - `Firebase credentials file not found at: ${credentialsPath}. ` + - `Either unset GOOGLE_APPLICATION_CREDENTIALS or provide a valid credentials file.`, - ); - } - - // Check if file contains valid JSON with required fields - let credentials: Record; - try { - const fileContent = fs.readFileSync(credentialsPath, 'utf-8'); - credentials = JSON.parse(fileContent); - } catch (error) { - if (error instanceof SyntaxError) { - throw new Error( - `Firebase credentials file contains invalid JSON: ${credentialsPath}`, - ); - } - throw new Error( - `Error reading Firebase credentials file: ${credentialsPath} - ${error}`, - ); - } - - // Check for required fields in a service account key file - const requiredFields = [ - 'type', - 'project_id', - 'private_key_id', - 'private_key', - 'client_email', - ]; - const missingFields = requiredFields.filter( - (field) => !credentials[field], - ); - - if (missingFields.length > 0) { - throw new Error( - `Firebase credentials file is missing required fields: ${missingFields.join( - ', ', - )}. ` + `File: ${credentialsPath}`, - ); - } - - this.credentialsValidated = true; - return true; + private isConfigured(): boolean { + return !!process.env.GOOGLE_APPLICATION_CREDENTIALS; } async fetchAll(): Promise { if (!this.isConfigured()) { - console.warn( - 'Firebase credentials not configured. Skipping votes fetch.', - ); return null; } @@ -125,26 +34,28 @@ export class VotesRepository { return this.votesCache; } - await this.initFirebase(); - - const { getFirestore } = await import('firebase-admin/firestore'); - const db = getFirestore(); - - const votesCol = db.collection('tags'); - const voteSnapshot = await votesCol.get(); - - const votes: VotesApiData = {}; - voteSnapshot.docs.forEach((doc) => { - const data = doc.data(); - votes[doc.id] = { - sum: data.sum || 0, - upVotes: data.upVotes || 0, - downVotes: data.downVotes || 0, - }; - }); - - this.votesCache = votes; - return votes; + try { + const { Firestore } = await import('@google-cloud/firestore'); + const db = new Firestore({ projectId: 'analysis-tools-dev' }); + + const voteSnapshot = await db.collection('tags').get(); + + const votes: VotesApiData = {}; + voteSnapshot.docs.forEach((doc) => { + const data = doc.data(); + votes[doc.id] = { + sum: data.sum || 0, + upVotes: data.upVotes || 0, + downVotes: data.downVotes || 0, + }; + }); + + this.votesCache = votes; + return votes; + } catch (error) { + console.error('Error fetching votes from Firestore:', error); + return null; + } } async fetchForTool(toolId: string): Promise<{ @@ -158,28 +69,30 @@ export class VotesRepository { return defaultVotes; } - await this.initFirebase(); + try { + const { Firestore } = await import('@google-cloud/firestore'); + const db = new Firestore({ projectId: 'analysis-tools-dev' }); - const { getFirestore } = await import('firebase-admin/firestore'); - const db = getFirestore(); + const key = `toolsyaml${toolId}`; + const doc = await db.collection('tags').doc(key).get(); - const key = `toolsyaml${toolId}`; - const doc = await db.collection('tags').doc(key).get(); + if (!doc.exists) { + return defaultVotes; + } - if (!doc.exists) { + const data = doc.data(); + return { + votes: data?.sum || 0, + upVotes: data?.upVotes || 0, + downVotes: data?.downVotes || 0, + }; + } catch (error) { + console.error(`Error fetching votes for tool ${toolId}:`, error); return defaultVotes; } - - const data = doc.data(); - return { - votes: data?.sum || 0, - upVotes: data?.upVotes || 0, - downVotes: data?.downVotes || 0, - }; } clearCache(): void { this.votesCache = null; - this.credentialsValidated = false; } } From c3261a0c6ffae61cdee0699fef21cd57f6db177b Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 20 Feb 2026 14:55:09 +0100 Subject: [PATCH 2/7] Remove VotesRepository and rely on static votes data in tools - Delete VotesRepository and related imports/usages - Update types to require votes fields (not optional) - Fetch votes in build-data script and include in static tools data - Simplify vote percentage display logic in components - Always show upvote percentage in UI - Set API votes endpoint to no-store cache - Remove dynamic votes fetching from static props - Update sorting and utility functions for new vote fields --- .../tools/listPage/ToolCard/ToolCard.tsx | 12 +- .../InformationCard/InformationCard.tsx | 14 +- components/tools/types.ts | 6 +- components/widgets/VoteWidget/VoteWidget.tsx | 2 +- components/widgets/VoteWidget/query.ts | 2 +- lib/repositories/StatsRepository.ts | 8 +- lib/repositories/ToolsRepository.ts | 51 ++----- lib/repositories/VotesRepository.ts | 98 ------------- lib/repositories/index.ts | 1 - pages/api/votes/[toolId].ts | 1 + pages/index.tsx | 9 +- pages/languages/index.tsx | 7 +- pages/tag/[slug].tsx | 8 +- pages/tool/[slug].tsx | 21 +-- pages/tools/index.tsx | 12 +- scripts/build-data.ts | 129 +++++++++++++++--- utils-api/votes.ts | 4 +- utils/types.ts | 6 +- utils/votes.ts | 17 +-- 19 files changed, 165 insertions(+), 243 deletions(-) delete mode 100644 lib/repositories/VotesRepository.ts diff --git a/components/tools/listPage/ToolCard/ToolCard.tsx b/components/tools/listPage/ToolCard/ToolCard.tsx index 908d58f0..e443021e 100644 --- a/components/tools/listPage/ToolCard/ToolCard.tsx +++ b/components/tools/listPage/ToolCard/ToolCard.tsx @@ -140,13 +140,11 @@ const ToolCard: FC = ({ tool }) => { ))} - {tool.upvotePercentage !== undefined && ( -
  • - - {tool.upvotePercentage}% upvoted - -
  • - )} +
  • + + {tool.upvotePercentage}% upvoted + +
  • diff --git a/components/tools/toolPage/ToolInfoSidebar/InformationCard/InformationCard.tsx b/components/tools/toolPage/ToolInfoSidebar/InformationCard/InformationCard.tsx index b7858d8d..7b581fc4 100644 --- a/components/tools/toolPage/ToolInfoSidebar/InformationCard/InformationCard.tsx +++ b/components/tools/toolPage/ToolInfoSidebar/InformationCard/InformationCard.tsx @@ -34,15 +34,11 @@ const InformationCard: FC = ({ tool }) => { Information - {tool.upVotes && tool.downVotes && ( - - )} + {tool.installation && ( = ({ ); } - if (error || !votesData) { + if (error) { return null; } diff --git a/components/widgets/VoteWidget/query.ts b/components/widgets/VoteWidget/query.ts index 74cd4c6f..7c3931bc 100644 --- a/components/widgets/VoteWidget/query.ts +++ b/components/widgets/VoteWidget/query.ts @@ -44,7 +44,7 @@ export async function fetchToolVotesData( ): Promise> { try { const voteApiURL = `${getApiURL(APIPaths.VOTES)}/${toolId}`; - const response = await fetch(voteApiURL); + const response = await fetch(voteApiURL, { cache: 'no-store' }); return await response.json(); } catch (error) { return { diff --git a/lib/repositories/StatsRepository.ts b/lib/repositories/StatsRepository.ts index be6cc63f..4a3b284e 100644 --- a/lib/repositories/StatsRepository.ts +++ b/lib/repositories/StatsRepository.ts @@ -103,9 +103,9 @@ export class StatsRepository { ); } - getPopularLanguageStats(votes?: VotesApiData | null): ToolsByLanguage { + getPopularLanguageStats(): ToolsByLanguage { const toolsRepo = ToolsRepository.getInstance(); - const tools = toolsRepo.withVotes(votes || null); + const tools = toolsRepo.getAll(); const languageStats = this.getLanguageStats(); for (const [toolId, tool] of Object.entries(tools)) { @@ -154,9 +154,9 @@ export class StatsRepository { return languageStats; } - getMostViewedTools(votes?: VotesApiData | null): Tool[] { + getMostViewedTools(): Tool[] { const toolsRepo = ToolsRepository.getInstance(); - const tools = toolsRepo.withVotes(votes || null); + const tools = toolsRepo.getAll(); const toolStats = this.loadToolStats(); const mostViewedToolIds = Object.keys(toolStats); diff --git a/lib/repositories/ToolsRepository.ts b/lib/repositories/ToolsRepository.ts index b6d1a8c5..8c053b69 100644 --- a/lib/repositories/ToolsRepository.ts +++ b/lib/repositories/ToolsRepository.ts @@ -7,9 +7,8 @@ import * as fs from 'fs'; import * as path from 'path'; -import type { ToolsApiData, ApiTool, VotesApiData } from 'utils/types'; +import type { ToolsApiData, ApiTool } from 'utils/types'; import type { Tool } from '@components/tools/types'; -import { calculateUpvotePercentage } from 'utils/votes'; interface BuildMeta { buildTime: string; @@ -75,7 +74,6 @@ export class ToolsRepository { return { ...tool, id: toolId, - votes: tool.votes || 0, } as Tool; } @@ -88,10 +86,16 @@ export class ToolsRepository { return Object.entries(tools).map(([id, tool]) => ({ ...tool, id, - votes: tool.votes || 0, })) as Tool[]; } + /** + * Alias for toArray() - returns all tools as an array with IDs + */ + getAllAsArray(): Tool[] { + return this.toArray(); + } + findWhere(predicate: (tool: ApiTool, id: string) => boolean): Tool[] { const tools = this.getAll(); return Object.entries(tools) @@ -99,7 +103,6 @@ export class ToolsRepository { .map(([id, tool]) => ({ ...tool, id, - votes: tool.votes || 0, })) as Tool[]; } @@ -147,44 +150,6 @@ export class ToolsRepository { return null; } - withVotes(votes: VotesApiData | null): ToolsApiData { - const tools = this.getAll(); - - if (!votes) { - return tools; - } - - const result: ToolsApiData = {}; - - for (const [toolId, tool] of Object.entries(tools)) { - const key = `toolsyaml${toolId}`; - const v = votes[key]; - - const sum = v?.sum || 0; - const upVotes = v?.upVotes || 0; - const downVotes = v?.downVotes || 0; - - result[toolId] = { - ...tool, - votes: sum, - upVotes, - downVotes, - upvotePercentage: calculateUpvotePercentage(upVotes, downVotes), - }; - } - - return result; - } - - withVotesAsArray(votes: VotesApiData | null): Tool[] { - const tools = this.withVotes(votes); - return Object.entries(tools).map(([id, tool]) => ({ - ...tool, - id, - votes: tool.votes || 0, - })) as Tool[]; - } - clearCache(): void { this.toolsData = null; } diff --git a/lib/repositories/VotesRepository.ts b/lib/repositories/VotesRepository.ts deleted file mode 100644 index af751687..00000000 --- a/lib/repositories/VotesRepository.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * VotesRepository - * - * Repository class for accessing votes data from Firestore. - * Uses Google Cloud Application Default Credentials. - */ - -import type { VotesApiData } from 'utils/types'; - -export class VotesRepository { - private static instance: VotesRepository | null = null; - private votesCache: VotesApiData | null = null; - - // eslint-disable-next-line @typescript-eslint/no-empty-function - private constructor() {} - - static getInstance(): VotesRepository { - if (!VotesRepository.instance) { - VotesRepository.instance = new VotesRepository(); - } - return VotesRepository.instance; - } - - private isConfigured(): boolean { - return !!process.env.GOOGLE_APPLICATION_CREDENTIALS; - } - - async fetchAll(): Promise { - if (!this.isConfigured()) { - return null; - } - - if (this.votesCache) { - return this.votesCache; - } - - try { - const { Firestore } = await import('@google-cloud/firestore'); - const db = new Firestore({ projectId: 'analysis-tools-dev' }); - - const voteSnapshot = await db.collection('tags').get(); - - const votes: VotesApiData = {}; - voteSnapshot.docs.forEach((doc) => { - const data = doc.data(); - votes[doc.id] = { - sum: data.sum || 0, - upVotes: data.upVotes || 0, - downVotes: data.downVotes || 0, - }; - }); - - this.votesCache = votes; - return votes; - } catch (error) { - console.error('Error fetching votes from Firestore:', error); - return null; - } - } - - async fetchForTool(toolId: string): Promise<{ - votes: number; - upVotes: number; - downVotes: number; - }> { - const defaultVotes = { votes: 0, upVotes: 0, downVotes: 0 }; - - if (!this.isConfigured()) { - return defaultVotes; - } - - try { - const { Firestore } = await import('@google-cloud/firestore'); - const db = new Firestore({ projectId: 'analysis-tools-dev' }); - - const key = `toolsyaml${toolId}`; - const doc = await db.collection('tags').doc(key).get(); - - if (!doc.exists) { - return defaultVotes; - } - - const data = doc.data(); - return { - votes: data?.sum || 0, - upVotes: data?.upVotes || 0, - downVotes: data?.downVotes || 0, - }; - } catch (error) { - console.error(`Error fetching votes for tool ${toolId}:`, error); - return defaultVotes; - } - } - - clearCache(): void { - this.votesCache = null; - } -} diff --git a/lib/repositories/index.ts b/lib/repositories/index.ts index 3ec60ed3..12d19b7c 100644 --- a/lib/repositories/index.ts +++ b/lib/repositories/index.ts @@ -1,5 +1,4 @@ export { ToolsRepository } from './ToolsRepository'; export { TagsRepository } from './TagsRepository'; export { StatsRepository } from './StatsRepository'; -export { VotesRepository } from './VotesRepository'; export { ToolsFilter } from './ToolsFilter'; diff --git a/pages/api/votes/[toolId].ts b/pages/api/votes/[toolId].ts index ef2cf1f0..22cd9b3c 100644 --- a/pages/api/votes/[toolId].ts +++ b/pages/api/votes/[toolId].ts @@ -6,6 +6,7 @@ export default async function handler( res: NextApiResponse<{ data: VotesData | null; error?: string }>, ) { try { + res.setHeader('Cache-Control', 'no-store, max-age=0'); const { toolId } = req.query; if (!toolId || typeof toolId !== 'string') { diff --git a/pages/index.tsx b/pages/index.tsx index 384a8e5f..adc4e004 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -16,19 +16,18 @@ import { Tool, ToolsByLanguage } from '@components/tools'; import { getArticlesPreviews } from 'utils-api/blog'; import { getSponsors } from 'utils-api/sponsors'; import { getFaq } from 'utils-api/faq'; -import { StatsRepository, VotesRepository } from '@lib/repositories'; +import { StatsRepository } from '@lib/repositories'; export const getStaticProps: GetStaticProps = async () => { const sponsors = getSponsors(); const faq = getFaq(); const previews = await getArticlesPreviews(); - const votesRepo = VotesRepository.getInstance(); const statsRepo = StatsRepository.getInstance(); - const votes = await votesRepo.fetchAll(); - const popularLanguages = statsRepo.getPopularLanguageStats(votes); - const mostViewed = statsRepo.getMostViewedTools(votes); + // Votes are now included in static tools.json data + const popularLanguages = statsRepo.getPopularLanguageStats(); + const mostViewed = statsRepo.getMostViewedTools(); return { props: { diff --git a/pages/languages/index.tsx b/pages/languages/index.tsx index 5394f9ff..834ffd08 100644 --- a/pages/languages/index.tsx +++ b/pages/languages/index.tsx @@ -9,17 +9,16 @@ import { Article, SponsorData } from 'utils/types'; import { ToolsByLanguage } from '@components/tools'; import { getArticlesPreviews } from 'utils-api/blog'; import { getSponsors } from 'utils-api/sponsors'; -import { StatsRepository, VotesRepository } from '@lib/repositories'; +import { StatsRepository } from '@lib/repositories'; export const getStaticProps: GetStaticProps = async () => { const sponsors = getSponsors(); const previews = getArticlesPreviews(); - const votesRepo = VotesRepository.getInstance(); const statsRepo = StatsRepository.getInstance(); - const votes = await votesRepo.fetchAll(); - const popularLanguages = statsRepo.getPopularLanguageStats(votes); + // Votes are included in static tools.json data + const popularLanguages = statsRepo.getPopularLanguageStats(); return { props: { diff --git a/pages/tag/[slug].tsx b/pages/tag/[slug].tsx index 5f8b2fd8..14b4e3b1 100644 --- a/pages/tag/[slug].tsx +++ b/pages/tag/[slug].tsx @@ -22,7 +22,6 @@ import { TagsRepository, ToolsRepository, ToolsFilter, - VotesRepository, } from '@lib/repositories'; export const getStaticPaths: GetStaticPaths = async () => { @@ -50,7 +49,6 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { const tagsRepo = TagsRepository.getInstance(); const toolsRepo = ToolsRepository.getInstance(); - const votesRepo = VotesRepository.getInstance(); const tagData = tagsRepo.getDescription(slug); @@ -59,9 +57,9 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { tagName = tagData.name; } - const votes = await votesRepo.fetchAll(); - const toolsWithVotes = toolsRepo.withVotes(votes); - const filter = ToolsFilter.from(toolsWithVotes); + // Votes are now included in static tools.json data + const tools = toolsRepo.getAll(); + const filter = ToolsFilter.from(tools); const previews = await getArticlesPreviews(); const sponsors = getSponsors(); diff --git a/pages/tool/[slug].tsx b/pages/tool/[slug].tsx index ae1ee5f3..f7586d44 100644 --- a/pages/tool/[slug].tsx +++ b/pages/tool/[slug].tsx @@ -16,8 +16,7 @@ import { getArticlesPreviews } from 'utils-api/blog'; import { getSponsors } from 'utils-api/sponsors'; import { ToolGallery } from '@components/tools/toolPage/ToolGallery'; import { Comments } from '@components/core/Comments'; -import { calculateUpvotePercentage } from 'utils/votes'; -import { ToolsRepository, VotesRepository } from '@lib/repositories'; +import { ToolsRepository } from '@lib/repositories'; // This function gets called at build time export const getStaticPaths: GetStaticPaths = async () => { @@ -46,7 +45,6 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { } const toolsRepo = ToolsRepository.getInstance(); - const votesRepo = VotesRepository.getInstance(); const apiTool = toolsRepo.getById(slug); if (!apiTool) { @@ -57,29 +55,20 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { } const sponsors = getSponsors(); - const votes = await votesRepo.fetchAll(); const previews = await getArticlesPreviews(); const icon = toolsRepo.getIcon(slug); - // Calculate the upvote percentage based on the votes - const voteKey = `toolsyaml${slug}`; - const voteData = votes ? votes[voteKey] : null; - const upvotePercentage = calculateUpvotePercentage( - voteData?.upVotes, - voteData?.downVotes, - ); - + // Votes are now included in static tools.json data const tool = { ...apiTool, - upvotePercentage, id: slug, icon: icon, }; - // Get all tools with votes for alternatives - const allToolsWithVotes = toolsRepo.withVotesAsArray(votes); + // Get all tools for alternatives + const allTools = toolsRepo.getAllAsArray(); let alternatives: Tool[] = []; - const allAlternatives = allToolsWithVotes.filter((t) => t.id !== slug); + const allAlternatives = allTools.filter((t) => t.id !== slug); // Show only tools with the same type, languages, and categories alternatives = allAlternatives.filter((alt) => { diff --git a/pages/tools/index.tsx b/pages/tools/index.tsx index 36888d88..1c7c2dd6 100644 --- a/pages/tools/index.tsx +++ b/pages/tools/index.tsx @@ -11,23 +11,17 @@ import { getArticlesPreviews } from 'utils-api/blog'; import { LanguageFilterOption } from '@components/tools/listPage/ToolsSidebar/FilterCard/LanguageFilterCard'; import { FilterOption } from '@components/tools/listPage/ToolsSidebar/FilterCard/FilterCard'; import { Tool } from '@components/tools/types'; -import { - ToolsRepository, - TagsRepository, - VotesRepository, -} from '@lib/repositories'; +import { ToolsRepository, TagsRepository } from '@lib/repositories'; export const getStaticProps: GetStaticProps = async () => { const sponsors = getSponsors(); const articles = await getArticlesPreviews(); - // Get tools with votes + // Get tools (votes are already included in static data from build-data script) const toolsRepo = ToolsRepository.getInstance(); - const votesRepo = VotesRepository.getInstance(); const tagsRepo = TagsRepository.getInstance(); - const votes = await votesRepo.fetchAll(); - const tools = toolsRepo.withVotesAsArray(votes); + const tools = toolsRepo.getAllAsArray(); // Sort tools by votes initially const sortedTools = [...tools].sort( diff --git a/scripts/build-data.ts b/scripts/build-data.ts index 7c1422f6..611a2f9d 100644 --- a/scripts/build-data.ts +++ b/scripts/build-data.ts @@ -3,6 +3,7 @@ * * This script fetches tools data from the analysis-tools-dev GitHub repositories * and consolidates them into a single static JSON file for use at runtime. + * Also fetches votes from Firestore if credentials are available. * * Run with: npm run build-data */ @@ -10,9 +11,10 @@ import * as fs from 'fs'; import * as path from 'path'; import * as https from 'https'; +import { Firestore } from '@google-cloud/firestore'; -// Types -interface ToolData { +// Types (raw source data vs. enriched output data) +interface RawToolData { name: string; categories: string[]; languages: string[]; @@ -32,16 +34,30 @@ interface ToolData { wrapper: string | null; } -interface ToolsData { - [key: string]: ToolData; +interface ToolData extends RawToolData { + votes: number; + upVotes: number; + downVotes: number; + upvotePercentage: number; } +type RawToolsData = Record; +type EnrichedToolsData = Record; + interface StatsData { [key: string]: number; } +interface VotesData { + [key: string]: { + sum: number; + upVotes: number; + downVotes: number; + }; +} + interface BuildOutput { - tools: ToolsData; + tools: EnrichedToolsData; meta: { buildTime: string; staticAnalysisCount: number; @@ -107,10 +123,10 @@ function fetchJSON(url: string): Promise { * Merge tools from multiple sources, handling duplicates */ function mergeTools( - staticTools: ToolsData, - dynamicTools: ToolsData, -): ToolsData { - const merged: ToolsData = { ...staticTools }; + staticTools: RawToolsData, + dynamicTools: RawToolsData, +): RawToolsData { + const merged: RawToolsData = { ...staticTools }; for (const [id, tool] of Object.entries(dynamicTools)) { if (merged[id]) { @@ -134,7 +150,7 @@ function mergeTools( /** * Validate tool data */ -function validateTools(tools: ToolsData): void { +function validateTools(tools: RawToolsData): void { const errors: string[] = []; for (const [id, tool] of Object.entries(tools)) { @@ -161,7 +177,7 @@ function validateTools(tools: ToolsData): void { /** * Extract unique tags (languages and others) from tools */ -function extractTags(tools: ToolsData): { +function extractTags(tools: EnrichedToolsData): { languages: string[]; others: string[]; } { @@ -206,6 +222,75 @@ async function fetchStats(): Promise<{ } } +/** + * Fetch votes from Firestore + * Fails fast if credentials are not configured + */ +async function fetchVotes(): Promise { + if (!process.env.GOOGLE_APPLICATION_CREDENTIALS) { + throw new Error( + 'GOOGLE_APPLICATION_CREDENTIALS is not set. ' + + 'Votes are required for the build. ' + + 'Please set up Google Cloud credentials to access Firestore.', + ); + } + + const db = new Firestore({ projectId: 'analysis-tools-dev' }); + const snapshot = await db.collection('tags').get(); + + const votes: VotesData = {}; + snapshot.docs.forEach((doc) => { + const data = doc.data(); + votes[doc.id] = { + sum: data.sum || 0, + upVotes: data.upVotes || 0, + downVotes: data.downVotes || 0, + }; + }); + + console.log(`Fetched ${Object.keys(votes).length} votes from Firestore`); + return votes; +} + +/** + * Calculate upvote percentage + */ +function calculateUpvotePercentage(upVotes: number, downVotes: number): number { + const totalVotes = upVotes + downVotes; + if (totalVotes === 0) { + return 0; + } + return Math.round((upVotes / totalVotes) * 100); +} + +/** + * Add votes to tools + */ +function addVotesToTools( + tools: RawToolsData, + votes: VotesData, +): EnrichedToolsData { + const result: EnrichedToolsData = {}; + + for (const [id, tool] of Object.entries(tools)) { + const key = `toolsyaml${id}`; + const v = votes[key]; + + const upVotes = v?.upVotes || 0; + const downVotes = v?.downVotes || 0; + + result[id] = { + ...tool, + votes: v?.sum || 0, + upVotes, + downVotes, + upvotePercentage: calculateUpvotePercentage(upVotes, downVotes), + }; + } + + return result; +} + /** * Main build function */ @@ -213,11 +298,12 @@ async function main(): Promise { console.log('Building tools data...\n'); try { - // Fetch data from both repositories and stats - const [staticTools, dynamicTools, stats] = await Promise.all([ - fetchJSON(STATIC_ANALYSIS_URL), - fetchJSON(DYNAMIC_ANALYSIS_URL), + // Fetch data from both repositories, stats, and votes + const [staticTools, dynamicTools, stats, votes] = await Promise.all([ + fetchJSON(STATIC_ANALYSIS_URL), + fetchJSON(DYNAMIC_ANALYSIS_URL), fetchStats(), + fetchVotes(), ]); const staticCount = Object.keys(staticTools).length; @@ -227,15 +313,18 @@ async function main(): Promise { console.log(`Fetched ${dynamicCount} dynamic analysis tools`); // Merge tools - const mergedTools = mergeTools(staticTools, dynamicTools); - const totalCount = Object.keys(mergedTools).length; + const rawTools = mergeTools(staticTools, dynamicTools); + const totalCount = Object.keys(rawTools).length; console.log(`Total unique tools: ${totalCount}`); + // Add votes to tools + const enrichedTools = addVotesToTools(rawTools, votes); + // Validate - validateTools(mergedTools); + validateTools(rawTools); // Extract tags for reference - const tags = extractTags(mergedTools); + const tags = extractTags(enrichedTools); console.log(`\nFound ${tags.languages.length} unique languages`); console.log(`Found ${tags.others.length} unique other tags`); @@ -247,7 +336,7 @@ async function main(): Promise { // Prepare output const output: BuildOutput = { - tools: mergedTools, + tools: enrichedTools, meta: { buildTime: new Date().toISOString(), staticAnalysisCount: staticCount, diff --git a/utils-api/votes.ts b/utils-api/votes.ts index 08f1e14b..83878280 100644 --- a/utils-api/votes.ts +++ b/utils-api/votes.ts @@ -11,7 +11,7 @@ export interface VotesData { votes: number; upVotes: number; downVotes: number; - upvotePercentage?: number; + upvotePercentage: number; } export interface Vote { @@ -146,6 +146,7 @@ export const getToolVotes = async (toolId: string): Promise => { votes: 0, upVotes: 0, downVotes: 0, + upvotePercentage: 0, }; } const upVotes = Number(data.upVotes || 0); @@ -162,6 +163,7 @@ export const getToolVotes = async (toolId: string): Promise => { votes: 0, upVotes: 0, downVotes: 0, + upvotePercentage: 0, }; } }; diff --git a/utils/types.ts b/utils/types.ts index 71af4311..12c55190 100644 --- a/utils/types.ts +++ b/utils/types.ts @@ -27,9 +27,9 @@ export interface ApiTool { resources: ToolResource[] | null; wrapper: string | null; votes: number; - upVotes?: number; - downVotes?: number; - upvotePercentage?: number; + upVotes: number; + downVotes: number; + upvotePercentage: number; } export interface ToolResource { diff --git a/utils/votes.ts b/utils/votes.ts index 6047a700..62ec922f 100644 --- a/utils/votes.ts +++ b/utils/votes.ts @@ -22,13 +22,12 @@ export const sortByVote = (a: Tool, b: Tool) => { // sort tools by popularity export const sortByPopularity = (a: Tool, b: Tool) => { - const upvoteDiff = (b.upvotePercentage || 0) - (a.upvotePercentage || 0); + const upvoteDiff = b.upvotePercentage - a.upvotePercentage; if (upvoteDiff === 0) { return b.votes - a.votes; - } else { - return upvoteDiff; } + return upvoteDiff; }; export const validateVoteAction = (action: unknown) => { @@ -51,17 +50,9 @@ export const submitVote = async (toolId: string, action: VoteAction) => { }; export const calculateUpvotePercentage = ( - upVotes: number | undefined, - downVotes: number | undefined, + upVotes: number, + downVotes: number, ) => { - if (!upVotes) { - return 0; - } - - if (!downVotes) { - return 100; - } - const totalVotes = upVotes + downVotes; if (totalVotes === 0) { return 0; From 1805207d1d35889ccc704827de50dccaab4b1b47 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 20 Feb 2026 14:59:12 +0100 Subject: [PATCH 3/7] Add sorting by most and least popular to tools list --- context/ToolsProvider.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/context/ToolsProvider.tsx b/context/ToolsProvider.tsx index 2216960d..eeac5e43 100644 --- a/context/ToolsProvider.tsx +++ b/context/ToolsProvider.tsx @@ -145,6 +145,13 @@ function sortTools(tools: Tool[], sorting?: string): Tool[] { case 'alphabetical_desc': return sorted.sort((a, b) => b.name.localeCompare(a.name)); case 'most_popular': + return sorted.sort( + (a, b) => b.upvotePercentage - a.upvotePercentage, + ); + case 'least_popular': + return sorted.sort( + (a, b) => a.upvotePercentage - b.upvotePercentage, + ); case 'votes_desc': default: return sorted.sort((a, b) => (b.votes || 0) - (a.votes || 0)); From 081867040c59f83d9e79bf44737e7d571a761d30 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 20 Feb 2026 15:11:05 +0100 Subject: [PATCH 4/7] Add radio selection mode to LanguageFilterCard and update filtering logic - Language filter now supports radio mode for single-language selection - Sidebar merges popular and all languages into one radio group - Filtering logic updated to only match tools with exactly one language when a single language is selected - Popularity sorting now deprioritizes deprecated tools --- .../FilterCard/LanguageFilterCard.tsx | 32 +++++++++++----- .../listPage/ToolsSidebar/ToolsSidebar.tsx | 22 ++++++----- context/ToolsProvider.tsx | 37 ++++++++++++++----- utils/votes.ts | 15 ++++++-- 4 files changed, 74 insertions(+), 32 deletions(-) diff --git a/components/tools/listPage/ToolsSidebar/FilterCard/LanguageFilterCard.tsx b/components/tools/listPage/ToolsSidebar/FilterCard/LanguageFilterCard.tsx index 24cdfa05..1b7610b1 100644 --- a/components/tools/listPage/ToolsSidebar/FilterCard/LanguageFilterCard.tsx +++ b/components/tools/listPage/ToolsSidebar/FilterCard/LanguageFilterCard.tsx @@ -21,6 +21,7 @@ export interface LanguageFilterCardProps { options: LanguageFilterOption[]; limit?: number; className?: string; + selectionMode?: 'checkbox' | 'radio'; } const LanguageFilterCard: FC = ({ @@ -30,6 +31,7 @@ const LanguageFilterCard: FC = ({ options, limit = 10, className, + selectionMode, }) => { const { search, @@ -45,6 +47,8 @@ const LanguageFilterCard: FC = ({ // Fade out background when not showing all options const [faded, setFaded] = useState(styles.faded); + const isRadioMode = + selectionMode === 'radio' || heading === 'Popular Languages'; const toggleAll = () => { if (listLimit === 999) { @@ -61,8 +65,12 @@ const LanguageFilterCard: FC = ({ updateFilter(searchFilter, []); }; - const handleCheckboxChange = (value: string) => { + const handleOptionChange = (value: string) => { const searchFilter = filter as SearchFilter; + if (isRadioMode) { + updateFilter(searchFilter, [value]); + return; + } toggleFilter(searchFilter, value); }; @@ -77,13 +85,14 @@ const LanguageFilterCard: FC = ({ return values !== undefined && values.length > 0; }; - // Sort options: checked items first, then by count + // Sort options by popularity; keep radio selection from reordering items const sortedOptions = [...options].sort((a, b) => { - const aChecked = isChecked(a.value); - const bChecked = isChecked(b.value); - if (aChecked && !bChecked) return -1; - if (!aChecked && bChecked) return 1; - // Then sort by count + if (!isRadioMode) { + const aChecked = isChecked(a.value); + const bChecked = isChecked(b.value); + if (aChecked && !bChecked) return -1; + if (!aChecked && bChecked) return 1; + } const aCount = getLanguageCount(a.value); const bCount = getLanguageCount(b.value); return bCount - aCount; @@ -129,13 +138,18 @@ const LanguageFilterCard: FC = ({ return (
  • - handleCheckboxChange(option.value) + handleOptionChange(option.value) } />