From 203902ade55167a784ccd19b6e061d5cbbb527fb Mon Sep 17 00:00:00 2001 From: Bruno Perel Date: Fri, 19 Dec 2025 18:36:58 +0100 Subject: [PATCH 1/2] duckguessr: Try Pinia Colada --- apps/duckguessr/app/pages/admin/clean.vue | 282 ++++++++++------------ apps/duckguessr/env.d.ts | 1 + apps/duckguessr/nuxt.config.ts | 41 +++- apps/duckguessr/package.json | 2 + apps/duckguessr/pnpm-lock.yaml | 37 +++ 5 files changed, 211 insertions(+), 152 deletions(-) diff --git a/apps/duckguessr/app/pages/admin/clean.vue b/apps/duckguessr/app/pages/admin/clean.vue index 02a6e6565..0f4cdc843 100644 --- a/apps/duckguessr/app/pages/admin/clean.vue +++ b/apps/duckguessr/app/pages/admin/clean.vue @@ -212,7 +212,10 @@ :disabled="isLoading" :variant="variant" :pressed="decision === id" - @click="entryurlsPendingMaintenanceWithUrls[index]!.decision = id" + @click=" + entryurlsPendingMaintenanceWithUrls[index]!.decision = + id + " > {{ title }} @@ -253,18 +256,10 @@ import { getUrl } from "~/composables/url"; import type { entryurlDetailsDecision } from "~duckguessr-prisma-browser"; import { duckguessrSocketInjectionKey } from "~/composables/useDuckguessrSocket"; import type { BaseButtonVariant } from "bootstrap-vue-next"; -const { maintenanceSocket } = inject(duckguessrSocketInjectionKey)!; +import { useQuery } from "@pinia/colada"; +import "~group-by"; -interface DatasetWithDecisionCounts { - id: number; - name: string; - decisions: { - ok: number | null; - shows_author: number | null; - no_drawing: number | null; - null: number | null; - }; -} +const { maintenanceSocket } = inject(duckguessrSocketInjectionKey)!; interface Decision { variant: keyof BaseButtonVariant; @@ -273,23 +268,7 @@ interface Decision { } const { t } = useI18n(); -const datasetsGroupedByDecision = ref<{ - [key: string]: DatasetWithDecisionCounts; -}>({}); -const datasets = ref<{ text: string; value: string | null }[]>([]); -const entryurlsPendingMaintenanceWithUrls = ref< - { - sitecodeUrl: string; - decision: entryurlDetailsDecision; - url: string; - }[] ->([]); -const validatedAndRemainingImageCount = ref<{ - not_validated: number; - validated: number; -}>(); const selectedDataset = ref(); -const isLoading = ref(false); const currentPage = ref(1); const totalRows = ref(10000); const rowsPerPage = 60; @@ -297,15 +276,17 @@ const rowsPerPage = 60; const user = computed(() => playerStore().playerUser); const isAllowed = computed( () => - user.value && - [ - "brunoperel", - "Wizyx", - "remifanpicsou", - "Alex Puaud", - "GlxbltHugo", - "Picsou22", - ].includes(user.value.username), + !!( + user.value && + [ + "brunoperel", + "Wizyx", + "remifanpicsou", + "Alex Puaud", + "GlxbltHugo", + "Picsou22", + ].includes(user.value.username) + ), ); const CLOUDINARY_URL_ROOT = import.meta.env.VITE_CLOUDINARY_URL_ROOT; @@ -334,138 +315,137 @@ const decisionsWithNonValidated = ref({ }, ...decisions, } satisfies Record); -const loadDatasets = async () => { - datasetsGroupedByDecision.value = ( - await maintenanceSocket.getMaintenanceData() - ).reduce<{ [key: string]: DatasetWithDecisionCounts }>( - ( - acc, - { - name, - decision, - count, - }: { name: string; decision: string; count: number }, - ) => ({ - ...acc, - [name]: { - ...(acc[name] || { name }), - decisions: { - ...((acc[name] || { decisions: {} }).decisions || {}), - [decision + ""]: count, - }, - }, - }), - {}, - ); - datasets.value = [ - { value: null, text: "Select a dataset" }, - ...Object.values(datasetsGroupedByDecision.value).map( - ({ name, decisions }: DatasetWithDecisionCounts) => ({ - value: name, - text: - name + - " (accepted: " + - (decisions.ok || 0) + - ", rejected: " + - ((decisions.shows_author || 0) + (decisions.no_drawing || 0)) + - ", left to validate: " + - (decisions.null || 0) + - ")", - }), +const { data: maintenanceData, refresh: refreshDatasets } = useQuery({ + key: ["maintenance", "datasets"], + query: () => maintenanceSocket.getMaintenanceData(), + enabled: () => isAllowed.value, +}); + +const datasetsGroupedByDecision = computed(() => + Object.fromEntries( + Object.entries((maintenanceData.value || []).groupBy("name", "[]")).map( + ([name, items]) => { + const decisions = items.groupBy("decision", "count"); + return [ + name, + { + name, + decisions: { + ok: decisions.ok ?? null, + shows_author: decisions.shows_author ?? null, + no_drawing: decisions.no_drawing ?? null, + null: decisions.null ?? null, + }, + }, + ]; + }, ), - ]; -}; -const loadImagesToMaintain = async ( - datasetName: string | null, - decisionsWithNonValidated: Record, - offset: number, -) => { - if (!datasetName) { - validatedAndRemainingImageCount.value = undefined; - return; - } - isLoading.value = true; - const entryurlsToMaintain = - await maintenanceSocket.getMaintenanceDataForDataset( - datasetName, - ( - Object.keys(decisionsWithNonValidated) as ( - | entryurlDetailsDecision - | "null" - )[] - ).filter((key) => decisionsWithNonValidated[key].pressed), - offset, - ); - await loadDatasets(); - isLoading.value = false; - entryurlsPendingMaintenanceWithUrls.value = entryurlsToMaintain.map( - (data) => ({ - ...data, - decision: data.entryurlDetails.decision || "ok", - url: getUrl(data.sitecodeUrl), - }), - ); + ), +); - const datasetsAndDecisions = - datasetsGroupedByDecision.value[datasetName].decisions; - validatedAndRemainingImageCount.value = { - not_validated: datasetsAndDecisions.null || 0, - validated: - (datasetsAndDecisions.ok || 0) + - (datasetsAndDecisions.shows_author || 0) + - (datasetsAndDecisions.no_drawing || 0), - }; -}; +const datasets = computed(() => [ + { value: null, text: "Select a dataset" }, + ...(!maintenanceData.value + ? [] + : Object.values(datasetsGroupedByDecision.value).map( + ({ name, decisions }) => ({ + value: name, + text: `${name} (${[ + `accepted: ${decisions.ok || 0}`, + `rejected: ${(decisions.shows_author || 0) + (decisions.no_drawing || 0)}`, + `left to validate: ${decisions.null || 0}`, + ].join(",")})`, + }), + )), +]); -watch( - decisionsWithNonValidated, - async (newValue) => { - await loadImagesToMaintain( - selectedDataset.value ?? null, - newValue, - (currentPage.value - 1) * rowsPerPage, - ); - }, - { deep: true }, +const activeDecisions = computed(() => + ( + Object.keys(decisionsWithNonValidated.value) as ( + | entryurlDetailsDecision + | "null" + )[] + ).filter((key) => decisionsWithNonValidated.value[key].pressed), ); -watch(selectedDataset, async (newValue) => { - await loadImagesToMaintain( - newValue ?? null, - decisionsWithNonValidated.value, +const { + data: entryurlsToMaintain, + asyncStatus, + refresh, +} = useQuery({ + key: () => [ + "maintenance", + "images", + selectedDataset.value || null, + activeDecisions.value, (currentPage.value - 1) * rowsPerPage, - ); + ], + query: () => + !selectedDataset.value + ? Promise.resolve([]) + : maintenanceSocket.getMaintenanceDataForDataset( + selectedDataset.value, + activeDecisions.value, + (currentPage.value - 1) * rowsPerPage, + ), + enabled: () => !!selectedDataset.value && !!isAllowed.value, }); -watch(currentPage, async (newValue) => { - await loadImagesToMaintain( - selectedDataset.value ?? null, - decisionsWithNonValidated.value, - (newValue - 1) * rowsPerPage, - ); -}); +const entryurlsPendingMaintenanceWithUrlsBase = computed(() => + (entryurlsToMaintain.value || []).map( + (data) => + ({ + sitecodeUrl: data.sitecodeUrl, + decision: data.entryurlDetails.decision || "ok", + url: getUrl(data.sitecodeUrl), + }) as const, + ), +); + +const entryurlsPendingMaintenanceWithUrls = ref< + { + sitecodeUrl: string; + decision: entryurlDetailsDecision; + url: string; + }[] +>([]); watch( - isAllowed, - async () => { - await loadDatasets(); + entryurlsPendingMaintenanceWithUrlsBase, + (newValue) => { + entryurlsPendingMaintenanceWithUrls.value = [...newValue]; }, { immediate: true }, ); +const datasetsAndDecisions = computed(() => + !selectedDataset.value + ? undefined + : datasetsGroupedByDecision.value[selectedDataset.value]?.decisions, +); + +const validatedAndRemainingImageCount = computed(() => + !datasetsAndDecisions.value + ? undefined + : { + not_validated: datasetsAndDecisions.value.null || 0, + validated: + (datasetsAndDecisions.value.ok || 0) + + (datasetsAndDecisions.value.shows_author || 0) + + (datasetsAndDecisions.value.no_drawing || 0), + }, +); + +const isLoading = computed(() => asyncStatus.value === "loading"); + const submitInvalidations = async () => { - isLoading.value = true; await maintenanceSocket.updateMaintenanceData( - entryurlsPendingMaintenanceWithUrls.value, - ); - - await loadImagesToMaintain( - selectedDataset.value ?? null, - decisionsWithNonValidated.value, - currentPage.value - 1, + entryurlsPendingMaintenanceWithUrls.value.map( + ({ sitecodeUrl, decision }) => ({ sitecodeUrl, decision }), + ), ); - isLoading.value = false; + await Promise.all([refresh(), refreshDatasets()]); }; diff --git a/apps/duckguessr/env.d.ts b/apps/duckguessr/env.d.ts index b7c7d1b2e..123276ea4 100644 --- a/apps/duckguessr/env.d.ts +++ b/apps/duckguessr/env.d.ts @@ -1,6 +1,7 @@ /// interface ImportMetaEnv { + readonly MODE: string; readonly VITE_CLOUDINARY_URL_ROOT: string; readonly VITE_DM_URL: string; readonly VITE_DM_SOCKET_URL: string; diff --git a/apps/duckguessr/nuxt.config.ts b/apps/duckguessr/nuxt.config.ts index 46a3cbe11..9153e31a2 100644 --- a/apps/duckguessr/nuxt.config.ts +++ b/apps/duckguessr/nuxt.config.ts @@ -63,6 +63,7 @@ export default defineNuxtConfig({ ], "@nuxt/eslint", "unplugin-icons/nuxt", + "@pinia/colada-nuxt", ], // Auto-imports @@ -114,6 +115,31 @@ export default defineNuxtConfig({ resolve: { dedupe: ["vue", "vue-i18n", "@vueuse/core", "bootstrap-vue-next"], }, + server: { + watch: { + // Reduce file watching to prevent EMFILE errors + ignored: [ + "**/node_modules/**", + "**/.git/**", + "**/dist/**", + "**/.nuxt/**", + "**/.output/**", + "**/storybook-static/**", + "**/.turbo/**", + "**/api/prisma/client_*/**", + "**/.pnpm-store/**", + "**/coverage/**", + "**/.storybook/**", + "**/.pnpm/**", + "**/builds/**", + ], + // Use polling as fallback if native watching fails + usePolling: false, + // Reduce the number of files watched + interval: 100, + binaryInterval: 300, + }, + }, }, // App configuration @@ -129,7 +155,20 @@ export default defineNuxtConfig({ sourcemap: { client: "hidden", + server: false, }, - ignore: ["**/node_modules", "**/dist", ".git/**", "api/prisma/client_*"], + ignore: [ + "**/node_modules", + "**/dist", + ".git/**", + "api/prisma/client_*", + "**/.pnpm-store/**", + "**/coverage/**", + "**/.storybook/**", + "**/.nuxt/**", + "**/.output/**", + "**/storybook-static/**", + "**/.turbo/**", + ], }); diff --git a/apps/duckguessr/package.json b/apps/duckguessr/package.json index 70cb69d34..db438af44 100644 --- a/apps/duckguessr/package.json +++ b/apps/duckguessr/package.json @@ -24,6 +24,8 @@ "dependencies": { "@nuxtjs/i18n": "^10.2.1", "@nuxtjs/storybook": "^9.0.1", + "@pinia/colada": "^0.19.1", + "@pinia/colada-nuxt": "0.2.6", "@pinia/nuxt": "^0.11.3", "@sentry/nuxt": "^10.30.0", "@unhead/vue": "^2.0.19", diff --git a/apps/duckguessr/pnpm-lock.yaml b/apps/duckguessr/pnpm-lock.yaml index e6b3a980d..2bad90a0a 100644 --- a/apps/duckguessr/pnpm-lock.yaml +++ b/apps/duckguessr/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: '@nuxtjs/storybook': specifier: ^9.0.1 version: 9.0.1(@types/node@22.19.3)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.1)(nuxt@4.2.2(@parcel/watcher@2.5.1)(@types/node@22.19.3)(@vue/compiler-sfc@3.5.25)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@2.6.1))(ioredis@5.8.2)(magicast@0.5.1)(optionator@0.9.4)(rollup@4.53.3)(sass@1.96.0)(terser@5.44.1)(typescript@5.9.3)(vite@7.2.7(@types/node@22.19.3)(jiti@2.6.1)(sass@1.96.0)(terser@5.44.1)(yaml@2.8.2))(vue-tsc@3.1.5(typescript@5.9.3))(yaml@2.8.2))(optionator@0.9.4)(rollup@4.53.3)(sass@1.96.0)(storybook@10.1.8(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(terser@5.44.1)(typescript@5.9.3)(vite@7.2.7(@types/node@22.19.3)(jiti@2.6.1)(sass@1.96.0)(terser@5.44.1)(yaml@2.8.2))(vue-tsc@3.1.5(typescript@5.9.3))(vue@3.5.25(typescript@5.9.3))(yaml@2.8.2) + '@pinia/colada': + specifier: ^0.19.1 + version: 0.19.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3)) + '@pinia/colada-nuxt': + specifier: 0.2.6 + version: 0.2.6(@pinia/colada@0.19.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3)))(magicast@0.5.1) '@pinia/nuxt': specifier: ^0.11.3 version: 0.11.3(magicast@0.5.1)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.25(typescript@5.9.3))) @@ -1751,6 +1757,17 @@ packages: resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} engines: {node: '>= 10.0.0'} + '@pinia/colada-nuxt@0.2.6': + resolution: {integrity: sha512-I+KtZPqrPiLfbfHHgWPsjQBe7dTx4veLdeSv3vXJXiSx74hBvXfiXpe//yeR1Vwa9VvU71mtb28WHlxXxolY1w==} + peerDependencies: + '@pinia/colada': '>=0.19.0' + + '@pinia/colada@0.19.1': + resolution: {integrity: sha512-n1+TKgvGHR1Umz6Nzy5MYmow8b3xVuaFlxR8A7q5yioi/5hGLME4Wh/6WT/9Z8BP+1a9lI7cvmniRQUbPOdNig==} + peerDependencies: + pinia: ^2.2.6 || ^3.0.0 + vue: ^3.5.17 + '@pinia/nuxt@0.11.3': resolution: {integrity: sha512-7WVNHpWx4qAEzOlnyrRC88kYrwnlR/PrThWT0XI1dSNyUAXu/KBv9oR37uCgYkZroqP5jn8DfzbkNF3BtKvE9w==} peerDependencies: @@ -2629,6 +2646,9 @@ packages: '@vue/devtools-api@7.7.9': resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==} + '@vue/devtools-api@8.0.5': + resolution: {integrity: sha512-DgVcW8H/Nral7LgZEecYFFYXnAvGuN9C3L3DtWekAncFBedBczpNW8iHKExfaM559Zm8wQWrwtYZ9lXthEHtDw==} + '@vue/devtools-core@8.0.5': resolution: {integrity: sha512-dpCw8nl0GDBuiL9SaY0mtDxoGIEmU38w+TQiYEPOLhW03VDC0lfNMYXS/qhl4I0YlysGp04NLY4UNn6xgD0VIQ==} peerDependencies: @@ -8129,6 +8149,19 @@ snapshots: '@parcel/watcher-win32-ia32': 2.5.1 '@parcel/watcher-win32-x64': 2.5.1 + '@pinia/colada-nuxt@0.2.6(@pinia/colada@0.19.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3)))(magicast@0.5.1)': + dependencies: + '@nuxt/kit': 4.2.2(magicast@0.5.1) + '@pinia/colada': 0.19.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3)) + transitivePeerDependencies: + - magicast + + '@pinia/colada@0.19.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3))': + dependencies: + '@vue/devtools-api': 8.0.5 + pinia: 3.0.4(typescript@5.9.3)(vue@3.5.25(typescript@5.9.3)) + vue: 3.5.25(typescript@5.9.3) + '@pinia/nuxt@0.11.3(magicast@0.5.1)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.25(typescript@5.9.3)))': dependencies: '@nuxt/kit': 4.2.2(magicast@0.5.1) @@ -9169,6 +9202,10 @@ snapshots: dependencies: '@vue/devtools-kit': 7.7.9 + '@vue/devtools-api@8.0.5': + dependencies: + '@vue/devtools-kit': 8.0.5 + '@vue/devtools-core@8.0.5(vite@7.2.7(@types/node@22.19.3)(jiti@2.6.1)(sass@1.96.0)(terser@5.44.1)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))': dependencies: '@vue/devtools-kit': 8.0.5 From 07c6af1bbfa4e8eb72296ffc9313a4ed40350a5b Mon Sep 17 00:00:00 2001 From: Bruno Perel Date: Tue, 27 Jan 2026 15:08:44 +0100 Subject: [PATCH 2/2] Bump Colada, add Colada devtools --- apps/duckguessr/app/app.vue | 2 ++ apps/duckguessr/package.json | 5 ++-- apps/duckguessr/pnpm-lock.yaml | 50 +++++++++++++++++----------------- 3 files changed, 30 insertions(+), 27 deletions(-) diff --git a/apps/duckguessr/app/app.vue b/apps/duckguessr/app/app.vue index 8d9da7318..ae4e55b86 100644 --- a/apps/duckguessr/app/app.vue +++ b/apps/duckguessr/app/app.vue @@ -1,8 +1,10 @@