Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
befe322
feat(issue-143): add Valibot schemas and fixtures for .comapeocat cat…
Jul 22, 2026
67d6958
feat(issue-143): add usePresets and useCategories data layer hooks
Jul 22, 2026
fce7453
feat(issue-143): add Categories route, nav icon, screen shell, and i18n
Jul 22, 2026
922f894
feat(issue-143): add category card grid with grouped layout
Jul 22, 2026
665c016
fix(issue-143): address review findings — TS errors, unused import, s…
Jul 22, 2026
51e3ccc
fix(issue-143): resolve field labels from useFields data, not preset …
Jul 22, 2026
330242a
feat(issue-143): add category detail pane and field viewer
Jul 22, 2026
576b89c
feat(issue-143): add .comapeocat import with Dexie persistence
Jul 22, 2026
cf107f5
fix(issue-143): restore usePresets backward compat, add useApiPresets
Jul 22, 2026
73017a8
fix(issue-143): fix ESLint set-state-in-effect in useCategorySets
Jul 22, 2026
312003e
fix(issue-143): prettier formatting + TS strict null fix
Jul 22, 2026
017b8be
fix(issue-143): prettier formatting on remaining files
Jul 22, 2026
8cf8a39
fix(issue-143): extract i18n messages + add pt/es translations
Jul 22, 2026
28b93c1
fix(issue-143): address claude-qwen review — error handling, cleanup,…
Jul 22, 2026
e51bbe1
fix(issue-143): add no-project empty state + fix FieldViewer type names
Jul 22, 2026
8888825
fix(issue-143): handleFileChange error handling + i18n coverage
Jul 22, 2026
3d47386
fix(issue-143): update FieldViewer test fixtures to camelCase type names
Jul 22, 2026
9101e14
fix(issue-143): use docRefSchema for preset fieldRefs/iconRef
Jul 22, 2026
dafaf1a
test(issue-143): add regression tests for fieldRefs/iconRef without url
Jul 22, 2026
7fd65a9
fix(issue-143): address claude-qwen review HIGH findings
Jul 22, 2026
31ba168
test(issue-143): add screen success path + real diacritic tests
Jul 22, 2026
e0291f3
test(issue-143): add MEDIUM coverage gaps — CategoryCard interactions…
Jul 22, 2026
e04a189
fix(issue-143): useApiPresets must use project remoteId (base32), not…
Jul 22, 2026
8d86068
fix(issue-143): guard projects loading/error states, assert remoteId …
Jul 22, 2026
fe8fb3f
fix(issue-143): handle 304 Not Modified in handleResponse + no-store …
Jul 22, 2026
abca306
fix(issue-143): remove unsafe 304→{data:[]} fallback, add no-store to…
Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified screenshots/screenshot/archive-sidebar.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/screenshot/create-project-dialog.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/screenshot/home-project-empty-cloud.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
28 changes: 0 additions & 28 deletions screenshots/screenshot/home-project-empty-mobile.png.argos.json

This file was deleted.

Binary file modified screenshots/screenshot/home-project-empty-sentinel.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/screenshot/home-skeleton.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/screenshot/language-selector-open.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
{
"url": "http://localhost:5173/settings",
"url": "http://localhost:5173/data/observations/nonexistent-id",
"colorScheme": "light",
"mediaType": "screen",
"test": {
"id": "99fb1864c5a05941a39e-216675ff9df5f526e28e",
"title": "mobile-settings light",
"id": "99fb1864c5a05941a39e-a72832ff12247d95f3c8",
"title": "mobile-observation-detail light",
"titlePath": [
"mobile-audit.screenshots.ts",
"Mobile audit screenshots",
"mobile-settings light"
"mobile-observation-detail light"
],
"retry": 0,
"retries": 0,
"repeat": 0,
"location": {
"file": "tests/e2e/mobile-audit.screenshots.ts",
"line": 207,
"line": 146,
"column": 3
},
"annotations": []
Expand Down
Binary file removed screenshots/screenshot/mobile-settings.png
Binary file not shown.
Binary file modified screenshots/screenshot/settings-backup-restore.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/screenshot/settings-clear-data.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/screenshot/settings-cloud.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/screenshot/settings-invite-form.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/screenshot/settings-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/screenshot/settings-sentinel.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { AuthenticatedLayout } from '@/components/layout/authenticated-layout';
import { AlertDetailScreen } from '@/screens/AlertDetailScreen';
import { AlertsScreen } from '@/screens/AlertsScreen';
import { CategoriesEditorScreen } from '@/screens/CategoriesEditor';
import { CreateAlertScreen } from '@/screens/CreateAlertScreen';
import { DataScreen } from '@/screens/DataScreen';
import { HomeScreen } from '@/screens/Home/HomeScreen';
Expand Down Expand Up @@ -93,6 +94,18 @@ const settingsRoute = createRoute({
component: SettingsScreen,
});

const categoriesRoute = createRoute({
getParentRoute: () => _authenticatedRoute,
path: '/categories',
component: CategoriesEditorScreen,
});

const categoryDetailRoute = createRoute({
getParentRoute: () => _authenticatedRoute,
path: '/categories/$categoryId',
component: CategoriesEditorScreen,
});

const inviteRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/invite',
Expand All @@ -112,6 +125,8 @@ const routeTree = rootRoute.addChildren([
createAlertRoute,
alertDetailRoute,
settingsRoute,
categoriesRoute,
categoryDetailRoute,
]),
]);

Expand All @@ -136,6 +151,8 @@ export {
createAlertRoute,
alertDetailRoute,
settingsRoute,
categoriesRoute,
categoryDetailRoute,
};

// Export route tree for testing
Expand Down
21 changes: 21 additions & 0 deletions src/components/layout/authenticated-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const messages = defineMessages({
data: { id: 'data.title', defaultMessage: 'Data' },
alerts: { id: 'alerts.title', defaultMessage: 'Alerts' },
map: { id: 'map.title', defaultMessage: 'Map' },
categories: { id: 'categories.title', defaultMessage: 'Categories' },
settings: { id: 'settings.title', defaultMessage: 'Settings' },
appTitle: { id: 'app.title', defaultMessage: 'CoMapeo Cloud' },
});
Expand Down Expand Up @@ -112,6 +113,21 @@ function SettingsIcon(): ReactNode {
);
}

function CategoriesIcon(): ReactNode {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={20}
height={20}
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path d="M2 4a2 2 0 012-2h4a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2V4zm10 0a2 2 0 012-2h4a2 2 0 012 2v4a2 2 0 01-2 2h-4a2 2 0 01-2-2V4zM2 12a2 2 0 012-2h4a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2v-4zm10 0a2 2 0 012-2h4a2 2 0 012 2v4a2 2 0 01-2 2h-4a2 2 0 01-2-2v-4z" />
</svg>
);
}

/** Normalize a URL for comparison by stripping trailing slashes. */
function normalizeUrl(url: string): string {
return url.trim().replace(/\/+$/, '');
Expand Down Expand Up @@ -250,6 +266,11 @@ function AuthenticatedLayoutInner() {
label: intl.formatMessage(messages.map),
icon: <MapIcon />,
},
{
path: '/categories',
label: intl.formatMessage(messages.categories),
icon: <CategoriesIcon />,
},
{
path: '/settings',
label: intl.formatMessage(messages.settings),
Expand Down
16 changes: 16 additions & 0 deletions src/hooks/useApiPresets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';

import { apiClient } from '@/lib/api-client';

/** Fetches presets directly from the archive server API (wire format).
* Uses the project's remoteId (server projectPublicId, base32) — NOT the
* local DB id (hex). Required because the server route parameter is validated
* against a base32 regex pattern. */
export function useApiPresets(projectRemoteId: string | null) {
return useQuery({
queryKey: ['api-presets', projectRemoteId],
queryFn: () => apiClient.getPresets(projectRemoteId!),
enabled: projectRemoteId !== null,
select: (data) => data.data,
});
}
127 changes: 127 additions & 0 deletions src/hooks/useCategories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
interface PresetInput {
docId: string;
name: string;
tags: Record<string, unknown>;
fieldRefs: Array<{ docId: string }>;
iconRef?: { docId: string };
}

export interface Category {
docId: string;
label: string;
fieldRefs: Array<{ docId: string; label?: string }>;
color?: string;
iconRef?: { docId: string };
}

export interface CategoryGroup {
type: string;
categories: Category[];
}

const ACCENT_RE = /[\u0300-\u036f]/g;

function stripDiacritics(s: string): string {
return s.normalize('NFD').replace(ACCENT_RE, '');
}

function normalizeSearch(s: string): string {
return stripDiacritics(s).toLowerCase();
}

function resolveLocaleName(
tags: Record<string, unknown>,
locale: string,
sourceName: string,
): string {
const localeKey = `name:${locale}`;
if (typeof tags[localeKey] === 'string') return tags[localeKey] as string;

if (locale !== 'en') {
const enKey = 'name:en';
if (typeof tags[enKey] === 'string') return tags[enKey] as string;
}

return sourceName;
}

function matchesSearch(
preset: PresetInput,
searchNormalized: string,
locale: string,
fieldLabels?: Map<string, string>,
): boolean {
const label = resolveLocaleName(preset.tags, locale, preset.name);
if (normalizeSearch(label).includes(searchNormalized)) return true;

for (const ref of preset.fieldRefs) {
const resolvedLabel = fieldLabels?.get(ref.docId);
if (
resolvedLabel &&
normalizeSearch(resolvedLabel).includes(searchNormalized)
) {
return true;
}
Comment thread
luandro marked this conversation as resolved.
}

return false;
}

export function normalizeCategories(
data: PresetInput[],
locale: string,
searchQuery: string,
fieldLabels?: Map<string, string>,
): CategoryGroup[] {
if (data.length === 0) return [];

const searchNormalized = normalizeSearch(searchQuery);

const groups = new Map<string, Category[]>();

for (const preset of data) {
if (
searchNormalized &&
!matchesSearch(preset, searchNormalized, locale, fieldLabels)
) {
continue;
}

const rawType = preset.tags.type;
const type =
typeof rawType === 'string' && rawType.trim() !== '' ? rawType : ''; // sentinel — translated at render (CategoryGrid)

const label = resolveLocaleName(preset.tags, locale, preset.name);

if (!groups.has(type)) {
groups.set(type, []);
}
groups.get(type)!.push({
docId: preset.docId,
label,
fieldRefs: preset.fieldRefs.map((ref) => ({
docId: ref.docId,
label: fieldLabels?.get(ref.docId),
})),
color:
typeof preset.tags.color === 'string' ? preset.tags.color : undefined,
iconRef:
typeof preset.iconRef === 'object' &&
preset.iconRef !== null &&
'docId' in preset.iconRef
? { docId: (preset.iconRef as { docId: string }).docId }
: undefined,
});
}

const result: CategoryGroup[] = [];
const sortedTypes = [...groups.keys()].sort();

for (const type of sortedTypes) {
const categories = groups.get(type)!;
categories.sort((a, b) => a.label.localeCompare(b.label));
result.push({ type, categories });
}

return result;
}
63 changes: 63 additions & 0 deletions src/hooks/useCategorySets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { useCallback, useEffect, useState } from 'react';

import { type CategorySetRecord, categoriesDb } from '@/lib/categories-db';

export interface CategorySetSummary {
setId: string;
name: string;
importedAt: string;
categoryCount: number;
}

function toSummary(record: CategorySetRecord): CategorySetSummary {
return {
setId: record.setId,
name: record.name,
importedAt: record.importedAt,
categoryCount: Object.keys(record.categories).length,
};
}

export function useCategorySets() {
const [sets, setSets] = useState<CategorySetSummary[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;
categoriesDb.categorySets
.toArray()
.then((records) => {
if (!cancelled) {
setSets(records.map(toSummary));
setError(null);
}
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load sets');
}
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, []);

const refresh = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const records = await categoriesDb.categorySets.toArray();
setSets(records.map(toSummary));
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to refresh sets');
} finally {
setIsLoading(false);
}
}, []);

return { sets, isLoading, error, refresh };
}
Loading
Loading