Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ Gemfile

expo-env.d.ts
# @end expo-cli
.dual-graph/
155 changes: 155 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<!-- dgc-policy-v10 -->
# Dual-Graph Context Policy

This project uses a local dual-graph MCP server for efficient context retrieval.

## MANDATORY: Always follow this order

1. **Call `graph_continue` first** — before any file exploration, grep, or code reading.

2. **If `graph_continue` returns `needs_project=true`**: call `graph_scan` with the
current project directory (`pwd`). Do NOT ask the user.

3. **If `graph_continue` returns `skip=true`**: project has fewer than 5 files.
Do NOT do broad or recursive exploration. Read only specific files if their names
are mentioned, or ask the user what to work on.

4. **Read `recommended_files`** using `graph_read` — **one call per file**.
- `graph_read` accepts a single `file` parameter (string). Call it separately for each
recommended file. Do NOT pass an array or batch multiple files into one call.
- `recommended_files` may contain `file::symbol` entries (e.g. `src/auth.ts::handleLogin`).
Pass them verbatim to `graph_read(file: "src/auth.ts::handleLogin")` — it reads only
that symbol's lines, not the full file.
- Example: if `recommended_files` is `["src/auth.ts::handleLogin", "src/db.ts"]`,
call `graph_read(file: "src/auth.ts::handleLogin")` and `graph_read(file: "src/db.ts")`
as two separate calls (they can be parallel).

5. **Check `confidence` and obey the caps strictly:**
- `confidence=high` -> Stop. Do NOT grep or explore further.
- `confidence=medium` -> If recommended files are insufficient, call `fallback_rg`
at most `max_supplementary_greps` time(s) with specific terms, then `graph_read`
at most `max_supplementary_files` additional file(s). Then stop.
- `confidence=low` -> Call `fallback_rg` at most `max_supplementary_greps` time(s),
then `graph_read` at most `max_supplementary_files` file(s). Then stop.

## Token Usage

A `token-counter` MCP is available for tracking live token usage.

- To check how many tokens a large file or text will cost **before** reading it:
`count_tokens({text: "<content>"})`
- To log actual usage after a task completes (if the user asks):
`log_usage({input_tokens: <est>, output_tokens: <est>, description: "<task>"})`
- To show the user their running session cost:
`get_session_stats()`

Live dashboard URL is printed at startup next to "Token usage".

## Rules

- Do NOT use `rg`, `grep`, or bash file exploration before calling `graph_continue`.
- Do NOT do broad/recursive exploration at any confidence level.
- `max_supplementary_greps` and `max_supplementary_files` are hard caps - never exceed them.
- Do NOT dump full chat history.
- Do NOT call `graph_retrieve` more than once per turn.
- After edits, call `graph_register_edit` with the changed files. Use `file::symbol` notation (e.g. `src/auth.ts::handleLogin`) when the edit targets a specific function, class, or hook.

## Context Store

Whenever you make a decision, identify a task, note a next step, fact, or blocker during a conversation, append it to `.dual-graph/context-store.json`.

**Entry format:**
```json
{"type": "decision|task|next|fact|blocker", "content": "one sentence max 15 words", "tags": ["topic"], "files": ["relevant/file.ts"], "date": "YYYY-MM-DD"}
```

**To append:** Read the file → add the new entry to the array → Write it back → call `graph_register_edit` on `.dual-graph/context-store.json`.

**Rules:**
- Only log things worth remembering across sessions (not every minor detail)
- `content` must be under 15 words
- `files` lists the files this decision/task relates to (can be empty)
- Log immediately when the item arises — not at session end

## Session End

When the user signals they are done (e.g. "bye", "done", "wrap up", "end session"), proactively update `CONTEXT.md` in the project root with:
- **Current Task**: one sentence on what was being worked on
- **Key Decisions**: bullet list, max 3 items
- **Next Steps**: bullet list, max 3 items

Keep `CONTEXT.md` under 20 lines total. Do NOT summarize the full conversation — only what's needed to resume next session.

---

# Project: Resgrid Unit (React Native / Expo)

## Tech Stack

TypeScript · React Native · Expo (managed, prebuild) · Zustand · React Query · React Hook Form · react-i18next · react-native-mmkv · Axios · @rnmapbox/maps · gluestack-ui · lucide-react-native

## Code Style

- Write concise, type-safe TypeScript. Avoid `any`; use precise types and interfaces for props/state.
- Use functional components and hooks; never class components. Use `React.FC` for typed components.
- Enable strict mode in `tsconfig.json`.
- Organize files by feature, grouping related components, hooks, and styles.
- All components must be mobile-friendly and responsive, supporting both iOS and Android.
- This is an Expo managed project using prebuild — **do not make native code changes** outside Expo prebuild capabilities.

## Naming Conventions

- Variables and functions: `camelCase` (e.g., `isFetchingData`, `handleUserInput`)
- Components: `PascalCase` (e.g., `UserProfile`, `ChatScreen`)
- Files and directories: `lowercase-hyphenated` (e.g., `user-profile.tsx`, `chat-screen/`)

## Styling

- Use `gluestack-ui` components from `components/ui` when available.
- For anything without a Gluestack component, use `StyleSheet.create()` or Styled Components.
- Support both **dark mode and light mode**.
- Follow WCAG accessibility guidelines for mobile.

## Performance

- Minimize `useEffect`, `useState`, and heavy computation inside render methods.
- Use `React.memo()` for components with static props.
- Optimize `FlatList` with `removeClippedSubviews`, `maxToRenderPerBatch`, `windowSize`, and `getItemLayout` when items have a consistent size.
- Avoid anonymous functions in `renderItem` or event handlers.

## Internationalization

- All user-visible text **must** be wrapped in `t()` from `react-i18next`.
- Translation dictionary files live in `src/translations/`.

## Libraries — use these, not alternatives

| Purpose | Library |
|---|---|
| Package manager | `yarn` |
| State management | `zustand` |
| Data fetching | `react-query` |
| Forms | `react-hook-form` |
| i18n | `react-i18next` |
| Local storage | `react-native-mmkv` |
| Secure storage | Expo SecureStore |
| HTTP | `axios` |
| Maps / navigation | `@rnmapbox/maps` |
| Icons | `lucide-react-native` (use directly in markup, not via gluestack Icon wrapper) |

## Conditional Rendering

Use ternary `? :` for conditional rendering — **never `&&`**.

## Testing

- Use Jest. Generate tests for all new components, services, and logic.
- Ensure tests run without errors before considering a task done.

## Best Practices

- Follow React Native's threading model for smooth UI performance.
- Use React Navigation for navigation and deep linking.
- Handle errors gracefully and provide user feedback.
- Implement proper offline support.
- Optimize for low-end devices.
16 changes: 16 additions & 0 deletions jest-setup.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
import '@testing-library/react-native/extend-expect';

// Mock @sentry/react-native — native module (RNSentry) is unavailable in Jest
jest.mock('@sentry/react-native', () => ({
captureException: jest.fn(),
captureMessage: jest.fn(),
init: jest.fn(),
wrap: jest.fn((fn: any) => fn),
withScope: jest.fn((cb: any) => cb({ setExtra: jest.fn(), setTag: jest.fn() })),
setUser: jest.fn(),
setTag: jest.fn(),
setExtra: jest.fn(),
addBreadcrumb: jest.fn(),
configureScope: jest.fn(),
ReactNavigationInstrumentation: jest.fn(),
ReactNativeTracing: jest.fn(),
}));

// react-hook form setup for testing
// @ts-ignore
global.window = {};
Expand Down
173 changes: 172 additions & 1 deletion src/api/mapping/mapping.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,59 @@
import { type FeatureCollection } from 'geojson';

import { getBaseApiUrl } from '@/lib/storage/app';
import { type GetMapDataAndMarkersResult } from '@/models/v4/mapping/getMapDataAndMarkersResult';
import { type GetMapLayersResult } from '@/models/v4/mapping/getMapLayersResult';
import {
type GetAllActiveLayersResult,
type GetCustomMapLayerResult,
type GetCustomMapResult,
type GetCustomMapsResult,
type GetGeoJSONResult,
type GetIndoorMapFloorResult,
type GetIndoorMapResult,
type GetIndoorMapsResult,
type SearchAllMapFeaturesResult,
type SearchCustomMapRegionsResult,
type SearchIndoorLocationsResult,
} from '@/models/v4/mapping/mappingResults';

import { createCachedApiEndpoint } from '../common/cached-client';
import { createApiEndpoint } from '../common/client';

const getMayLayersApi = createApiEndpoint('/Mapping/GetMayLayers');

const getMapDataAndMarkersApi = createApiEndpoint('/Mapping/GetMapDataAndMarkers');

// Indoor map endpoints
const getIndoorMapsApi = createCachedApiEndpoint('/Mapping/GetIndoorMaps', {
ttl: 5 * 60 * 1000,
enabled: true,
});
const getIndoorMapApi = createApiEndpoint('/Mapping/GetIndoorMap');
const getIndoorMapFloorApi = createApiEndpoint('/Mapping/GetIndoorMapFloor');
const getIndoorMapZonesGeoJSONApi = createApiEndpoint('/Mapping/GetIndoorMapZonesGeoJSON');
const searchIndoorLocationsApi = createApiEndpoint('/Mapping/SearchIndoorLocations');
const getNearbyIndoorMapsApi = createApiEndpoint('/Mapping/GetNearbyIndoorMaps');

// Custom map endpoints
const getCustomMapsApi = createCachedApiEndpoint('/Mapping/GetCustomMaps', {
ttl: 5 * 60 * 1000,
enabled: true,
});
const getCustomMapApi = createApiEndpoint('/Mapping/GetCustomMap');
const getCustomMapLayerApi = createApiEndpoint('/Mapping/GetCustomMapLayer');
const getMapLayerGeoJSONApi = createApiEndpoint('/Mapping/GetMapLayerGeoJSON');
const getCustomMapRegionsGeoJSONApi = createApiEndpoint('/Mapping/GetCustomMapRegionsGeoJSON');
const searchCustomMapRegionsApi = createApiEndpoint('/Mapping/SearchCustomMapRegions');

// Discovery endpoints
const getAllActiveLayersApi = createCachedApiEndpoint('/Mapping/GetAllActiveLayers', {
ttl: 5 * 60 * 1000,
enabled: true,
});
const searchAllMapFeaturesApi = createApiEndpoint('/Mapping/SearchAllMapFeatures');

// --- Existing Endpoints ---

export const getMapDataAndMarkers = async (signal?: AbortSignal) => {
const response = await getMapDataAndMarkersApi.get<GetMapDataAndMarkersResult>(undefined, signal);
return response.data;
Expand All @@ -21,3 +68,127 @@ export const getMayLayers = async (type: number, signal?: AbortSignal) => {
);
return response.data;
};

// --- Indoor Maps ---

export const getIndoorMaps = async () => {
const response = await getIndoorMapsApi.get<GetIndoorMapsResult>();
return response.data;
};

export const getIndoorMap = async (mapId: string) => {
const response = await getIndoorMapApi.get<GetIndoorMapResult>({
id: encodeURIComponent(mapId),
});
return response.data;
};

export const getIndoorMapFloor = async (floorId: string) => {
const response = await getIndoorMapFloorApi.get<GetIndoorMapFloorResult>({
floorId: encodeURIComponent(floorId),
});
return response.data;
};

export const getIndoorMapZonesGeoJSON = async (floorId: string) => {
const response = await getIndoorMapZonesGeoJSONApi.get<GetGeoJSONResult>({
floorId: encodeURIComponent(floorId),
});
return response.data;
};

export const searchIndoorLocations = async (term: string, mapId?: string) => {
const params: Record<string, unknown> = { term: encodeURIComponent(term) };
if (mapId) {
params.mapId = encodeURIComponent(mapId);
}
const response = await searchIndoorLocationsApi.get<SearchIndoorLocationsResult>(params);
return response.data;
};

export const getNearbyIndoorMaps = async (lat: number, lon: number, radiusMeters: number) => {
const response = await getNearbyIndoorMapsApi.get<GetIndoorMapsResult>({
lat,
lon,
radiusMeters,
});
return response.data;
};

// --- Custom Maps ---

export const getCustomMaps = async (type?: number) => {
const params: Record<string, unknown> = {};
if (type !== undefined) {
params.type = encodeURIComponent(type);
}
const response = await getCustomMapsApi.get<GetCustomMapsResult>(params);
return response.data;
};

export const getCustomMap = async (mapId: string) => {
const response = await getCustomMapApi.get<GetCustomMapResult>({
id: encodeURIComponent(mapId),
});
return response.data;
};

export const getCustomMapLayer = async (layerId: string) => {
const response = await getCustomMapLayerApi.get<GetCustomMapLayerResult>({
layerId: encodeURIComponent(layerId),
});
return response.data;
};

export const getMapLayerGeoJSON = async (layerId: string) => {
const response = await getMapLayerGeoJSONApi.get<GetGeoJSONResult>({
layerId: encodeURIComponent(layerId),
});
return response.data;
};

export const getCustomMapRegionsGeoJSON = async (layerId: string) => {
const response = await getCustomMapRegionsGeoJSONApi.get<GetGeoJSONResult>({
layerId: encodeURIComponent(layerId),
});
return response.data;
};

export const searchCustomMapRegions = async (term: string, layerId?: string) => {
const params: Record<string, unknown> = { term: encodeURIComponent(term) };
if (layerId) {
params.layerId = encodeURIComponent(layerId);
}
const response = await searchCustomMapRegionsApi.get<SearchCustomMapRegionsResult>(params);
return response.data;
};

// --- Discovery & Search ---

export const getAllActiveLayers = async () => {
const response = await getAllActiveLayersApi.get<GetAllActiveLayersResult>();
return response.data;
};

export const searchAllMapFeatures = async (term: string, type?: 'all' | 'indoor' | 'custom') => {
const params: Record<string, unknown> = { term: encodeURIComponent(term) };
if (type) {
params.type = type;
}
const response = await searchAllMapFeaturesApi.get<SearchAllMapFeaturesResult>(params);
return response.data;
};

// --- URL Helpers (no fetch needed, constructs URLs for components) ---

export const getFloorImageUrl = (floorId: string): string => {
return `${getBaseApiUrl()}/Mapping/GetIndoorMapFloorImage/${encodeURIComponent(floorId)}`;
};

export const getCustomMapLayerImageUrl = (layerId: string): string => {
return `${getBaseApiUrl()}/Mapping/GetCustomMapLayerImage/${encodeURIComponent(layerId)}`;
};

export const getCustomMapTileUrl = (layerId: string): string => {
return `${getBaseApiUrl()}/Mapping/GetCustomMapTile/${encodeURIComponent(layerId)}/{z}/{x}/{y}`;
};
Loading
Loading