Skip to content

feat: implement interest zone management with customizable labels and…#189

Open
bancho22 wants to merge 3 commits intovbuch:mainfrom
bancho22:feat/ui-ux-redesign-188
Open

feat: implement interest zone management with customizable labels and…#189
bancho22 wants to merge 3 commits intovbuch:mainfrom
bancho22:feat/ui-ux-redesign-188

Conversation

@bancho22
Copy link
Collaborator

@bancho22 bancho22 requested a review from vbuch February 19, 2026 12:19
@bancho22 bancho22 self-assigned this Feb 19, 2026
@vercel
Copy link

vercel bot commented Feb 19, 2026

Someone is attempting to deploy a commit to the Valery Buchinsky's projects Team on Vercel.

A member of the Team first needs to authorize it.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request implements multi-zone management functionality allowing users to create and manage multiple interest zones with customizable labels and colors. The feature introduces a new UI with segmented controls to switch between viewing zones and events, along with a modal-driven zone creation flow that aligns with issue #188.

Changes:

  • Added zone type system with predefined labels and colors (Home, Office, Parents, School, Gym, Other) stored in ZONE_TYPES constant
  • Extended Interest data model with optional label and color fields throughout the stack (web, ingest, API, database)
  • Refactored map circle rendering from React components to imperative native Google Maps API for better performance and cleanup
  • Implemented new UI components: AddZoneModal, SegmentedControl, ZoneList, and ZoneBadges for zone management
  • Modified onboarding flow to show zone creation prompt only once per user session using localStorage
  • Removed floating "Add Interest" button in favor of integrated zone management in sidebar

Reviewed changes

Copilot reviewed 28 out of 29 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
web/lib/zoneTypes.ts Defines predefined zone types with labels and hex colors
web/lib/types.ts Extends Interest interface with optional label and color fields
web/lib/hooks/useOnboardingFlow.ts Adds hasSeenZoneCreationPrompt flag to prevent repeated prompts
web/lib/hooks/useOnboardingFlow.test.ts Tests for new zone creation prompt dismissal behavior
web/lib/hooks/useInterests.ts Updates addInterest to accept label/color metadata
web/lib/hooks/useInterestManagement.ts Passes pending zone metadata through target mode
web/lib/hooks/useInterestManagement.test.ts Updates tests for new metadata parameters
web/components/onboarding/LoadingButton.tsx Removes outdated comment reference
web/components/onboarding/AddZoneModal.tsx New modal for selecting zone type, color, and radius
web/components/onboarding/AddZoneModal.test.tsx Tests for zone modal interactions
web/components/onboarding/AddInterestButton.tsx Removed - replaced by integrated zone management
web/components/ZoneList.tsx Displays zones as a list with colored badges on desktop
web/components/ZoneBadges.tsx Displays zones as inline badges on mobile
web/components/SegmentedControl.tsx Toggle component for switching between zones/events views
web/components/SegmentedControl.test.tsx Tests for segmented control
web/components/MessagesGrid.tsx Adds headerContent prop to support custom headers
web/components/MapContainer.tsx Removes AddInterestButton, passes pendingColor to target mode
web/components/MapComponent.tsx Passes map instance and pendingColor to child components
web/components/InterestTargetMode.tsx Refactored to use native Google Maps Circle with color support
web/components/InterestCircles.tsx Complete refactor to native Google Maps API for proper cleanup
web/components/HomeContent.tsx Integrates view mode switching, zone list/badges, and add zone modal
web/app/api/interests/route.ts Handles label/color in POST/PATCH, includes in responses
web/mocks/handlers.ts Updates mock API to handle label/color fields
web/mocks/fixtures/interests.ts Adds sample data with labels and colors
ingest/notifications/interest-fetcher.ts Fetches label/color fields from database
ingest/lib/types.ts Adds label/color fields to Interest type
docs/features/multi-zone-support.md Documentation for new multi-zone feature
web/package.json Adds @testing-library/user-event dependency
pnpm-lock.yaml Lockfile updates for new dependency
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

>
<span
className="w-8 h-8 rounded-full flex-shrink-0"
style={{ backgroundColor: color }}
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using user-provided color values directly in inline styles without validation creates a potential XSS vulnerability. The color value should be validated against a hex color pattern (e.g., /^#[0-9a-f]{6}$/i) before being used in styles to prevent injection attacks.

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +131
export default function AddZoneModal({ onConfirm, onCancel }: AddZoneModalProps) {
const [selectedTypeId, setSelectedTypeId] = useState<string>(ZONE_TYPES[0].id);
const [radius, setRadius] = useState(DEFAULT_RADIUS);

const handleConfirm = () => {
const zoneType = ZONE_TYPES.find((t) => t.id === selectedTypeId) ?? ZONE_TYPES[0];
onConfirm({ label: zoneType.label, color: zoneType.color, radius });
};

return (
<>
{/* Backdrop */}
<div
className={`fixed inset-0 bg-black/40 ${zIndex.modalBackdrop}`}
onClick={onCancel}
aria-hidden="true"
/>

{/* Dialog */}
<div
role="dialog"
aria-modal="true"
aria-labelledby="add-zone-title"
className={`fixed inset-0 flex items-center justify-center ${zIndex.modalContent} p-4`}
>
<div className="bg-white rounded-lg shadow-xl max-w-sm w-full p-6 flex flex-col gap-5">
<h2
id="add-zone-title"
className="text-base font-semibold text-neutral-dark"
>
Добави зона
</h2>

{/* Zone type grid */}
<div className="grid grid-cols-3 gap-2">
{ZONE_TYPES.map((type) => {
const isSelected = type.id === selectedTypeId;
return (
<button
key={type.id}
type="button"
onClick={() => setSelectedTypeId(type.id)}
className={`flex flex-col items-center gap-1.5 p-3 rounded-lg border-2 transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 ${
isSelected
? "border-[var(--color-primary)] bg-neutral-light"
: "border-neutral-border bg-white hover:bg-neutral-light"
}`}
>
<span
className="w-8 h-8 rounded-full flex-shrink-0"
style={{ backgroundColor: type.color }}
aria-hidden="true"
/>
<span className="text-xs font-medium text-neutral-dark leading-tight text-center">
{type.label}
</span>
</button>
);
})}
</div>

{/* Radius slider */}
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center">
<label
htmlFor="zone-radius"
className="text-sm font-medium text-neutral-dark"
>
Радиус
</label>
<span className="text-sm text-neutral">{radius} м</span>
</div>
<input
id="zone-radius"
type="range"
min={MIN_RADIUS}
max={MAX_RADIUS}
step={50}
value={radius}
onChange={(e) => setRadius(Number(e.target.value))}
className="w-full accent-primary"
/>
<div className="flex justify-between text-xs text-neutral">
<span>{MIN_RADIUS} м</span>
<span>{MAX_RADIUS} м</span>
</div>
</div>

{/* Actions */}
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={onCancel}
className={`${buttonSizes.md} ${buttonStyles.secondary} ${borderRadius.sm}`}
>
Отказ
</button>
<button
type="button"
onClick={handleConfirm}
className={`${buttonSizes.md} ${buttonStyles.primary} ${borderRadius.sm}`}
>
Избери на картата
</button>
</div>
</div>
</div>
</>
);
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The modal should support keyboard navigation for better accessibility. Consider adding an Escape key handler to close the modal (onCancel) and potentially Enter key support to confirm the selection when focus is on the modal content.

Copilot uses AI. Check for mistakes.
Comment on lines +308 to +313
if (typeof body.label === "string") {
updates.label = body.label;
}

// Update color if provided
if (typeof body.color === "string") {
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PATCH endpoint allows empty strings for label and color, which could create interests with empty string metadata fields. Consider adding validation to either reject empty strings (length > 0) or explicitly handle them by deleting the field from the database (setting to undefined).

Suggested change
if (typeof body.label === "string") {
updates.label = body.label;
}
// Update color if provided
if (typeof body.color === "string") {
if (typeof body.label === "string") {
if (body.label.trim().length === 0) {
return NextResponse.json(
{ error: "Label cannot be empty" },
{ status: 400 },
);
}
updates.label = body.label;
}
// Update color if provided
if (typeof body.color === "string") {
if (body.color.length === 0) {
return NextResponse.json(
{ error: "Color cannot be empty" },
{ status: 400 },
);
}

Copilot uses AI. Check for mistakes.
Comment on lines +48 to +54
<div className="bg-white rounded-lg shadow-xl max-w-sm w-full p-6 flex flex-col gap-5">
<h2
id="add-zone-title"
className="text-base font-semibold text-neutral-dark"
>
Добави зона
</h2>
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The modal dialog container should stop click propagation to prevent accidentally triggering the backdrop click handler when clicking inside the modal. Add onClick={(e) => e.stopPropagation()} to the inner dialog div (line 48) to prevent clicks from bubbling to the backdrop.

Suggested change
<div className="bg-white rounded-lg shadow-xl max-w-sm w-full p-6 flex flex-col gap-5">
<h2
id="add-zone-title"
className="text-base font-semibold text-neutral-dark"
>
Добави зона
</h2>
<div
className="bg-white rounded-lg shadow-xl max-w-sm w-full p-6 flex flex-col gap-5"
onClick={(e) => e.stopPropagation()}
>
<h2
id="add-zone-title"
className="text-base font-semibold text-neutral-dark"
>
Добави зона

Copilot uses AI. Check for mistakes.
Comment on lines +56 to +60
style={{
borderColor: color,
backgroundColor: `${color}15`,
color,
}}
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using user-provided color values directly in inline styles (lines 56-60) without validation creates a potential XSS vulnerability. While the color comes from the database, it should still be validated as a hex color format before being used in styles. Consider validating the color against a regex pattern (e.g., /^#[0-9a-f]{6}$/i) or using a CSS variable with proper escaping.

Copilot uses AI. Check for mistakes.
Comment on lines +151 to +153
if (typeof color === "string" && color.length > 0) {
interestData.color = color;
}
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The color field should be validated to ensure it's a valid hex color code (e.g., /^#[0-9a-f]{6}$/i). Currently, any non-empty string is accepted, which could lead to invalid color values being stored in the database and potentially causing rendering issues or XSS vulnerabilities if the color is used in CSS without proper sanitization.

Copilot uses AI. Check for mistakes.
Comment on lines +77 to +80
for (const [id, circle] of nativeCircles.entries()) {
if (!desiredIds.has(id)) {
circle.setMap(null);
nativeCircles.delete(id);
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Event listeners added to Google Maps Circle objects are not explicitly cleaned up when circles are removed or updated. While setting the circle's map to null should clean up most resources, explicitly calling google.maps.event.clearInstanceListeners(circle) before removing circles would ensure no memory leaks from event listeners.

Copilot uses AI. Check for mistakes.
Comment on lines +87 to +93
// Cleanup: remove the circle from the map when the component unmounts
return () => {
if (circleRef.current) {
circleRef.current.setMap(null);
circleRef.current = null;
}
};
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Event listeners added to the circle in InterestTargetMode are not explicitly cleaned up. When the circle is removed (line 50 or line 91), consider calling google.maps.event.clearInstanceListeners(circleRef.current) before setting it to null to prevent potential memory leaks.

Copilot uses AI. Check for mistakes.
Comment on lines +148 to +150
if (typeof label === "string" && label.length > 0) {
interestData.label = label;
}
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The label field should have a maximum length validation to prevent excessively long strings from being stored. Consider adding a reasonable limit (e.g., 50 characters) to maintain database integrity and prevent potential DoS attacks through large payloads.

Copilot uses AI. Check for mistakes.
Comment on lines +61 to +79
<button
key={type.id}
type="button"
onClick={() => setSelectedTypeId(type.id)}
className={`flex flex-col items-center gap-1.5 p-3 rounded-lg border-2 transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 ${
isSelected
? "border-[var(--color-primary)] bg-neutral-light"
: "border-neutral-border bg-white hover:bg-neutral-light"
}`}
>
<span
className="w-8 h-8 rounded-full flex-shrink-0"
style={{ backgroundColor: type.color }}
aria-hidden="true"
/>
<span className="text-xs font-medium text-neutral-dark leading-tight text-center">
{type.label}
</span>
</button>
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The zone type selection buttons should have explicit aria-label attributes to improve screen reader accessibility. The colored circle is marked aria-hidden, but the button should clearly announce its purpose, e.g., aria-label="Изберете тип зона: Дома" to provide better context for screen reader users.

Copilot uses AI. Check for mistakes.
| `userId` | string | Owner |
| `coordinates` | {lat,lng}| Center of the zone |
| `radius` | number | Radius in meters (100–1000) |
| `label` | string? | Zone type label (e.g. "Дома") |
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Дома" is really weird Bulgarian. It should be "Вкъщи" all around.

{ id: "parents", label: "Родители", color: "#10B981" },
{ id: "school", label: "Училище", color: "#F59E0B" },
{ id: "gym", label: "Фитнес", color: "#F97316" },
{ id: "other", label: "Друго", color: "#6B7280" },
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need to enumerate? Can we just let the user label it themselves and give them examples or a placeholder?
Also this is now PII as discussed with @laboletory earlier. If they say "this is where I live" this is quite sensitive data...

@vercel
Copy link

vercel bot commented Feb 21, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
oborishte-map Ready Ready Preview, Comment Feb 21, 2026 9:20am

@vbuch
Copy link
Owner

vbuch commented Feb 21, 2026

A lot of feedback from me now.

  1. Not logged in vs logged in is quite a different UI. So if I'm not logged in and then I log in I suddenly stop seeing messages and start seeing my zones. So I wonder "where did the messages go?" which is a bad UX.
image image

Would it make sense to keep the segmented control in the not logged in state but make the "Събития" the default segment? Just an idea. This would also "advertise" the "my zones" feature without.

  1. Once I click my zone, the map is centered on it. Fine. But then what is my next action? Whould I browse throught the pins on the map? Because that's what the UX tells me. The ox animates the map to the center and stops so my next actions should be something on the map. But what is it?

  2. Would it make sense to show the messages from withing this zone as an accordion under the zone itself? @laboletory what do you think? That's just an idea. Messages in my zone #142

  3. We are now listing the zones. This provides great real estate to place the contextual menu for editing or deleting a zone and removing it from the map where it is too hidden. So this is a great chance for quite an improvement of this element.

  4. There are two places to control the radius now.

image image
  1. Wlouldn't it be better to first do the things on the map as they require more attention and then add the title and color? Like when you create a file in your text editor: You create "New file" ("Добави зона"), you type in some contents (move things on the map), you hit "Save as" and enter filename and filetype (You enter zone name and color).

  2. In the previous version the "Добави зона" button was hidden when you were in the zone adding process. Now this is not the case so the user can start adding a zone although they have already started adding a zone. The button should at least be disabled.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants