Fix Wahoo import bugs, add Import All, update spec
Bugs fixed: - FIT parser returns timestamps as Date objects, not strings — convert to ISO 8601 before passing to generateGpx (caused str.replace crash) - FIT parser already converts semicircles to degrees — remove redundant conversion that produced near-zero coordinates - Wahoo CDN URLs are pre-signed S3 URLs — remove Bearer auth header from download requests (caused 400 "Unsupported Authorization Type") - Filter out third-party workouts (fitness_app_id >= 1000) since Wahoo does not share their data via the API UX improvements: - Individual imports use fetcher (no page refresh), button shows "Importing..." inline - "Import all" button imports all unimported workouts on the page sequentially with progress indicator - Add @vitejs/plugin-basic-ssl for local HTTPS dev (opt-in via HTTPS=1) Also adds unit test for FIT-to-GPX conversion with real fixture file, and updates wahoo-import spec to reflect all behaviors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
01d7620617
commit
20b91ef511
10 changed files with 258 additions and 58 deletions
BIN
apps/journal/app/lib/sync/providers/__fixtures__/wahoo-ride.fit
Normal file
BIN
apps/journal/app/lib/sync/providers/__fixtures__/wahoo-ride.fit
Normal file
Binary file not shown.
63
apps/journal/app/lib/sync/providers/wahoo.test.ts
Normal file
63
apps/journal/app/lib/sync/providers/wahoo.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { wahooProvider } from "./wahoo";
|
||||
|
||||
const fixturePath = resolve(__dirname, "__fixtures__/wahoo-ride.fit");
|
||||
const fitBuffer = readFileSync(fixturePath);
|
||||
|
||||
describe("wahooProvider.convertToGpx", () => {
|
||||
it("converts a FIT file to valid GPX with correct coordinates", async () => {
|
||||
const gpx = await wahooProvider.convertToGpx(Buffer.from(fitBuffer));
|
||||
|
||||
expect(gpx).not.toBeNull();
|
||||
expect(gpx).toContain('<?xml version="1.0"');
|
||||
expect(gpx).toContain("<gpx");
|
||||
expect(gpx).toContain("<trkpt");
|
||||
});
|
||||
|
||||
it("produces coordinates in valid lat/lon range (not semicircles)", async () => {
|
||||
const gpx = await wahooProvider.convertToGpx(Buffer.from(fitBuffer));
|
||||
expect(gpx).not.toBeNull();
|
||||
|
||||
const latMatches = gpx!.matchAll(/lat="([^"]+)"/g);
|
||||
const lonMatches = gpx!.matchAll(/lon="([^"]+)"/g);
|
||||
|
||||
const lats = [...latMatches].map((m) => parseFloat(m[1]!));
|
||||
const lons = [...lonMatches].map((m) => parseFloat(m[1]!));
|
||||
|
||||
expect(lats.length).toBeGreaterThan(10);
|
||||
expect(lons.length).toBeGreaterThan(10);
|
||||
|
||||
for (const lat of lats) {
|
||||
expect(lat).toBeGreaterThan(-90);
|
||||
expect(lat).toBeLessThan(90);
|
||||
// Should be real-world coords, not near-zero from double-conversion
|
||||
expect(Math.abs(lat)).toBeGreaterThan(1);
|
||||
}
|
||||
for (const lon of lons) {
|
||||
expect(lon).toBeGreaterThan(-180);
|
||||
expect(lon).toBeLessThan(180);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes ISO 8601 timestamps (not Date objects)", async () => {
|
||||
const gpx = await wahooProvider.convertToGpx(Buffer.from(fitBuffer));
|
||||
expect(gpx).not.toBeNull();
|
||||
|
||||
const timeMatches = gpx!.matchAll(/<time>([^<]+)<\/time>/g);
|
||||
const times = [...timeMatches].map((m) => m[1]!);
|
||||
|
||||
expect(times.length).toBeGreaterThan(10);
|
||||
for (const t of times) {
|
||||
expect(t).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
||||
expect(new Date(t).toString()).not.toBe("Invalid Date");
|
||||
}
|
||||
});
|
||||
|
||||
it("includes elevation data", async () => {
|
||||
const gpx = await wahooProvider.convertToGpx(Buffer.from(fitBuffer));
|
||||
expect(gpx).not.toBeNull();
|
||||
expect(gpx).toContain("<ele>");
|
||||
});
|
||||
});
|
||||
|
|
@ -85,6 +85,7 @@ export const wahooProvider: SyncProvider = {
|
|||
name: string;
|
||||
workout_type: string;
|
||||
starts: string;
|
||||
fitness_app_id?: number;
|
||||
workout_summary?: {
|
||||
duration_active_accum?: number;
|
||||
distance_accum?: number;
|
||||
|
|
@ -96,8 +97,11 @@ export const wahooProvider: SyncProvider = {
|
|||
per_page: number;
|
||||
};
|
||||
|
||||
// Wahoo does not share workout data from third-party apps (fitness_app_id >= 1000)
|
||||
const wahooWorkouts = data.workouts.filter((w) => !w.fitness_app_id || w.fitness_app_id < 1000);
|
||||
|
||||
return {
|
||||
workouts: data.workouts.map((w) => ({
|
||||
workouts: wahooWorkouts.map((w) => ({
|
||||
id: String(w.id),
|
||||
name: w.name || `Workout ${w.id}`,
|
||||
type: w.workout_type ?? "unknown",
|
||||
|
|
@ -118,9 +122,7 @@ export const wahooProvider: SyncProvider = {
|
|||
|
||||
async downloadFile(tokens: TokenSet, workout: Workout): Promise<Buffer> {
|
||||
if (!workout.fileUrl) throw new Error("No file URL for workout");
|
||||
const resp = await fetch(workout.fileUrl, {
|
||||
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
||||
});
|
||||
const resp = await fetch(workout.fileUrl);
|
||||
if (!resp.ok) throw new Error(`Wahoo file download failed: ${resp.status}`);
|
||||
return Buffer.from(await resp.arrayBuffer());
|
||||
},
|
||||
|
|
@ -139,18 +141,17 @@ export const wahooProvider: SyncProvider = {
|
|||
position_lat?: number;
|
||||
position_long?: number;
|
||||
altitude?: number;
|
||||
timestamp?: string;
|
||||
timestamp?: string | Date;
|
||||
}>;
|
||||
|
||||
// FIT uses semicircles — convert to degrees
|
||||
const SEMICIRCLE_TO_DEG = 180 / Math.pow(2, 31);
|
||||
// fit-file-parser already converts semicircles to degrees
|
||||
const trackPoints = records
|
||||
.filter((r) => r.position_lat != null && r.position_long != null)
|
||||
.map((r) => ({
|
||||
lat: r.position_lat! * SEMICIRCLE_TO_DEG,
|
||||
lon: r.position_long! * SEMICIRCLE_TO_DEG,
|
||||
lat: r.position_lat!,
|
||||
lon: r.position_long!,
|
||||
ele: r.altitude,
|
||||
time: r.timestamp,
|
||||
time: r.timestamp instanceof Date ? r.timestamp.toISOString() : r.timestamp,
|
||||
}));
|
||||
|
||||
if (trackPoints.length < 2) return null;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { data, redirect } from "react-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { data, redirect, useFetcher } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Route } from "./+types/sync.import.$provider";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
|
|
@ -107,60 +108,144 @@ export function meta({ data: loaderData }: Route.MetaArgs) {
|
|||
return [{ title: `Import from ${name} — trails.cool` }];
|
||||
}
|
||||
|
||||
export default function SyncImportPage({ loaderData, actionData }: Route.ComponentProps) {
|
||||
function WorkoutRow({
|
||||
workout,
|
||||
provider,
|
||||
alreadyImported,
|
||||
}: {
|
||||
workout: { id: string; name: string; fileUrl?: string; startedAt: string; distance: number | null; duration: number | null };
|
||||
provider: { id: string; name: string };
|
||||
alreadyImported: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("journal");
|
||||
const fetcher = useFetcher<{ imported?: string }>();
|
||||
const isImporting = fetcher.state !== "idle";
|
||||
const isImported = alreadyImported || fetcher.data?.imported === workout.id;
|
||||
|
||||
return (
|
||||
<li className="flex items-center justify-between rounded-lg border border-gray-200 p-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-gray-900">{workout.name}</p>
|
||||
{!workout.fileUrl && (
|
||||
<span
|
||||
className="inline-flex items-center rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700"
|
||||
title={t("sync.noGpsTooltip", { provider: provider.name })}
|
||||
>
|
||||
{t("sync.noGps")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex gap-3 text-sm text-gray-500">
|
||||
{workout.startedAt && <ClientDate iso={workout.startedAt} />}
|
||||
{workout.distance != null && <span>{(workout.distance / 1000).toFixed(1)} km</span>}
|
||||
{workout.duration != null && <span>{Math.round(workout.duration / 60)} min</span>}
|
||||
</div>
|
||||
</div>
|
||||
{isImported ? (
|
||||
<span className="text-sm text-green-600">{t("sync.imported")}</span>
|
||||
) : isImporting ? (
|
||||
<span className="text-sm text-gray-400">{t("sync.importing")}</span>
|
||||
) : (
|
||||
<fetcher.Form method="post">
|
||||
<input type="hidden" name="workoutId" value={workout.id} />
|
||||
<input type="hidden" name="workoutName" value={workout.name} />
|
||||
<input type="hidden" name="fileUrl" value={workout.fileUrl ?? ""} />
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700"
|
||||
>
|
||||
{t("sync.import")}
|
||||
</button>
|
||||
</fetcher.Form>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SyncImportPage({ loaderData }: Route.ComponentProps) {
|
||||
const { provider, workouts, page, totalPages } = loaderData;
|
||||
const { t } = useTranslation("journal");
|
||||
const justImported = (actionData as { imported?: string })?.imported;
|
||||
|
||||
const importableWorkouts = workouts.filter((w) => !w.imported);
|
||||
const [importAllActive, setImportAllActive] = useState(false);
|
||||
const [importAllIndex, setImportAllIndex] = useState(0);
|
||||
const importAllFetcher = useFetcher<{ imported?: string }>();
|
||||
const importAllRef = useRef({ index: 0, workouts: importableWorkouts });
|
||||
|
||||
useEffect(() => {
|
||||
importAllRef.current.workouts = importableWorkouts;
|
||||
}, [importableWorkouts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!importAllActive) return;
|
||||
if (importAllFetcher.state !== "idle") return;
|
||||
|
||||
// Previous import finished — advance to next
|
||||
const nextIndex = importAllFetcher.data ? importAllRef.current.index + 1 : importAllRef.current.index;
|
||||
importAllRef.current.index = nextIndex;
|
||||
setImportAllIndex(nextIndex);
|
||||
|
||||
if (nextIndex >= importAllRef.current.workouts.length) {
|
||||
setImportAllActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const w = importAllRef.current.workouts[nextIndex]!;
|
||||
const formData = new FormData();
|
||||
formData.set("workoutId", w.id);
|
||||
formData.set("workoutName", w.name);
|
||||
formData.set("fileUrl", w.fileUrl ?? "");
|
||||
importAllFetcher.submit(formData, { method: "post" });
|
||||
}, [importAllActive, importAllFetcher.state, importAllFetcher.data]);
|
||||
|
||||
const startImportAll = useCallback(() => {
|
||||
if (importableWorkouts.length === 0) return;
|
||||
importAllRef.current = { index: 0, workouts: importableWorkouts };
|
||||
setImportAllIndex(0);
|
||||
setImportAllActive(true);
|
||||
|
||||
const w = importableWorkouts[0]!;
|
||||
const formData = new FormData();
|
||||
formData.set("workoutId", w.id);
|
||||
formData.set("workoutName", w.name);
|
||||
formData.set("fileUrl", w.fileUrl ?? "");
|
||||
importAllFetcher.submit(formData, { method: "post" });
|
||||
}, [importableWorkouts, importAllFetcher]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{t("sync.importFrom", { provider: provider.name })}
|
||||
</h1>
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{t("sync.importFrom", { provider: provider.name })}
|
||||
</h1>
|
||||
{importableWorkouts.length > 0 && (
|
||||
importAllActive ? (
|
||||
<span className="text-sm text-gray-500">
|
||||
{t("sync.importingProgress", { current: importAllIndex + 1, total: importAllRef.current.workouts.length })}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={startImportAll}
|
||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
{t("sync.importAll", { count: importableWorkouts.length })}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{workouts.length === 0 ? (
|
||||
<p className="mt-8 text-center text-gray-500">{t("sync.noWorkouts")}</p>
|
||||
) : (
|
||||
<ul className="mt-6 space-y-3">
|
||||
{workouts.map((w) => (
|
||||
<li
|
||||
<WorkoutRow
|
||||
key={w.id}
|
||||
className="flex items-center justify-between rounded-lg border border-gray-200 p-4"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-gray-900">{w.name}</p>
|
||||
{!w.fileUrl && (
|
||||
<span
|
||||
className="inline-flex items-center rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700"
|
||||
title={t("sync.noGpsTooltip", { provider: provider.name })}
|
||||
>
|
||||
{t("sync.noGps")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex gap-3 text-sm text-gray-500">
|
||||
{w.startedAt && <ClientDate iso={w.startedAt} />}
|
||||
{w.distance != null && <span>{(w.distance / 1000).toFixed(1)} km</span>}
|
||||
{w.duration != null && <span>{Math.round(w.duration / 60)} min</span>}
|
||||
</div>
|
||||
</div>
|
||||
{w.imported || justImported === w.id ? (
|
||||
<span className="text-sm text-green-600">{t("sync.imported")}</span>
|
||||
) : (
|
||||
<form method="post">
|
||||
<input type="hidden" name="workoutId" value={w.id} />
|
||||
<input type="hidden" name="workoutName" value={w.name} />
|
||||
<input type="hidden" name="fileUrl" value={w.fileUrl ?? ""} />
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700"
|
||||
>
|
||||
{t("sync.import")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</li>
|
||||
workout={w}
|
||||
provider={provider}
|
||||
alreadyImported={w.imported || importAllFetcher.data?.imported === w.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@
|
|||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export default defineConfig({
|
|||
plugins: [
|
||||
tailwindcss(),
|
||||
reactRouter(),
|
||||
...(process.env.HTTPS === "1" ? [import("@vitejs/plugin-basic-ssl").then((m) => m.default())] : []),
|
||||
sentryVitePlugin({
|
||||
org: "trails-qq",
|
||||
project: "journal",
|
||||
|
|
|
|||
|
|
@ -32,11 +32,15 @@ New Wahoo workouts SHALL be automatically imported when they complete.
|
|||
#### Scenario: Webhook receives new workout
|
||||
- **WHEN** Wahoo sends a `workout_summary` webhook to `/api/sync/webhook/wahoo`
|
||||
- **THEN** the system identifies the user via `provider_user_id`
|
||||
- **AND** downloads the FIT file from Wahoo's CDN
|
||||
- **AND** downloads the FIT file from Wahoo's CDN (without auth headers, as CDN URLs are pre-signed)
|
||||
- **AND** converts it to GPX
|
||||
- **AND** creates a journal activity with the GPX, stats, and PostGIS geometry
|
||||
- **AND** records the import in `sync_imports` to prevent duplicates
|
||||
|
||||
#### Scenario: Webhook for workout without file
|
||||
- **WHEN** a webhook arrives for a workout with no FIT file URL
|
||||
- **THEN** the activity is created without GPX or geometry
|
||||
|
||||
#### Scenario: Duplicate webhook
|
||||
- **WHEN** a webhook arrives for a workout already imported
|
||||
- **THEN** the import is skipped silently (idempotent)
|
||||
|
|
@ -51,20 +55,46 @@ Users SHALL be able to browse and selectively import older Wahoo workouts.
|
|||
#### Scenario: View workout list
|
||||
- **WHEN** a user visits the Wahoo import page
|
||||
- **THEN** their Wahoo workouts are listed with date, type, duration, and distance
|
||||
- **AND** already-imported workouts are marked
|
||||
- **AND** already-imported workouts are marked as "Imported"
|
||||
- **AND** third-party workouts (fitness_app_id >= 1000) are filtered out, as Wahoo does not share their data via the API
|
||||
- **AND** workouts without a FIT file show a "No GPS" badge with a tooltip explaining the provider has no route data
|
||||
|
||||
#### Scenario: Import workout
|
||||
#### Scenario: Import single workout
|
||||
- **WHEN** a user clicks "Import" on a Wahoo workout
|
||||
- **THEN** the FIT file is downloaded, converted to GPX, and a new activity is created
|
||||
- **THEN** the import runs in the background using a fetcher (no page refresh)
|
||||
- **AND** the button shows "Importing..." during the import
|
||||
- **AND** changes to "Imported" when complete
|
||||
|
||||
#### Scenario: Import all workouts
|
||||
- **WHEN** a user clicks "Import all"
|
||||
- **THEN** all unimported workouts on the current page are imported sequentially
|
||||
- **AND** a progress indicator shows "Importing X of Y..."
|
||||
|
||||
### Requirement: Activity import metadata
|
||||
Imported activities SHALL show their origin in the UI.
|
||||
|
||||
#### Scenario: View imported activity
|
||||
- **WHEN** a user views an activity that was imported from Wahoo
|
||||
- **THEN** an "Imported from wahoo" badge is displayed on the detail page
|
||||
|
||||
#### Scenario: Delete and reimport
|
||||
- **WHEN** a user deletes an imported activity
|
||||
- **THEN** the sync_imports record is also deleted
|
||||
- **AND** the workout appears as importable again on the import page
|
||||
|
||||
### Requirement: FIT to GPX conversion
|
||||
The system SHALL convert Wahoo's FIT binary files to GPX format.
|
||||
|
||||
#### Scenario: Convert FIT with GPS data
|
||||
- **WHEN** a FIT file contains GPS track records
|
||||
- **THEN** track points with lat, lon, elevation, and timestamp are extracted
|
||||
- **THEN** track points with lat, lon, elevation, and ISO 8601 timestamps are extracted
|
||||
- **AND** coordinates are used as-is from the FIT parser (which already converts semicircles to degrees)
|
||||
- **AND** a valid GPX string is produced using `generateGpx`
|
||||
|
||||
#### Scenario: FIT without GPS data
|
||||
- **WHEN** a FIT file has no GPS records (e.g., indoor trainer workout)
|
||||
- **THEN** the activity is created without GPX or geometry (stats only)
|
||||
|
||||
#### Scenario: Workout without FIT file
|
||||
- **WHEN** a workout has no file URL (e.g., aborted recording, third-party app data)
|
||||
- **THEN** the activity is created without GPX or geometry
|
||||
|
|
|
|||
|
|
@ -186,6 +186,9 @@ export default {
|
|||
noWorkouts: "Keine Workouts gefunden.",
|
||||
noGps: "Kein GPS",
|
||||
noGpsTooltip: "{{provider}} stellt für diese Aktivität keine Routendaten bereit",
|
||||
importing: "Importiere...",
|
||||
importAll: "Alle importieren ({{count}})",
|
||||
importingProgress: "Importiere {{current}} von {{total}}...",
|
||||
previous: "Zurück",
|
||||
next: "Weiter",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -186,6 +186,9 @@ export default {
|
|||
noWorkouts: "No workouts found.",
|
||||
noGps: "No GPS",
|
||||
noGpsTooltip: "{{provider}} does not provide route data for this activity",
|
||||
importing: "Importing...",
|
||||
importAll: "Import all ({{count}})",
|
||||
importingProgress: "Importing {{current}} of {{total}}...",
|
||||
previous: "Previous",
|
||||
next: "Next",
|
||||
},
|
||||
|
|
|
|||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
|
|
@ -254,6 +254,9 @@ importers:
|
|||
'@types/react-dom':
|
||||
specifier: 'catalog:'
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
'@vitejs/plugin-basic-ssl':
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0(vite@6.4.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))
|
||||
pino-pretty:
|
||||
specifier: ^13.1.3
|
||||
version: 13.1.3
|
||||
|
|
@ -2069,6 +2072,12 @@ packages:
|
|||
resolution: {integrity: sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@vitejs/plugin-basic-ssl@2.3.0':
|
||||
resolution: {integrity: sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==}
|
||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||
peerDependencies:
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
|
||||
'@vitest/expect@4.1.2':
|
||||
resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==}
|
||||
|
||||
|
|
@ -5495,6 +5504,10 @@ snapshots:
|
|||
'@typescript-eslint/types': 8.57.2
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
||||
'@vitejs/plugin-basic-ssl@2.3.0(vite@6.4.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))':
|
||||
dependencies:
|
||||
vite: 6.4.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)
|
||||
|
||||
'@vitest/expect@4.1.2':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue