diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts index e7796c87a..ffbd90ba6 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts +++ b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts @@ -56,7 +56,20 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { }, assignee: person, assignment: { + campaign: { + id: model.campId, + }, id: model._id.toString(), + metrics: model.metrics.map((m) => ({ + definesDone: m.definesDone, + description: m.description, + id: m._id, + kind: m.kind, + question: m.question, + })), + organization: { + id: model.orgId, + }, title: model.title, }, }); diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/stats/route.ts b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/stats/route.ts index 59948ba59..568c081b4 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/stats/route.ts +++ b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/stats/route.ts @@ -68,7 +68,20 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { }, assignee: person, assignment: { + campaign: { + id: assignmentModel.campId, + }, id: assignmentModel._id.toString(), + metrics: assignmentModel.metrics.map((m) => ({ + definesDone: m.definesDone, + description: m.description, + id: m._id, + kind: m.kind, + question: m.question, + })), + organization: { + id: assignmentModel.orgId, + }, title: assignmentModel.title, }, }); diff --git a/src/app/beta/users/me/canvassassignments/route.ts b/src/app/beta/users/me/canvassassignments/route.ts index c0edec3a4..1249f5f8e 100644 --- a/src/app/beta/users/me/canvassassignments/route.ts +++ b/src/app/beta/users/me/canvassassignments/route.ts @@ -4,9 +4,10 @@ import { NextRequest, NextResponse } from 'next/server'; import BackendApiClient from 'core/api/client/BackendApiClient'; import { CanvassAssignmentModel } from 'features/canvassAssignments/models'; -import { ZetkinCanvassSession } from 'features/canvassAssignments/types'; -import { ZetkinMembership, ZetkinPerson } from 'utils/types/zetkin'; +import { AssignmentWithAreas } from 'features/canvassAssignments/types'; +import { ZetkinMembership } from 'utils/types/zetkin'; import { AreaModel } from 'features/areas/models'; +import { ZetkinArea } from 'features/areas/types'; export async function GET(request: NextRequest) { const headers: IncomingHttpHeaders = {}; @@ -18,21 +19,67 @@ export async function GET(request: NextRequest) { `/api/users/me/memberships` ); - const sessions: ZetkinCanvassSession[] = []; + const assignmentsWithAreas: AssignmentWithAreas[] = []; + //för varje organisation personen är medlem i for await (const membership of memberships) { const { id: personId } = membership.profile; - const person = await apiClient.get( - `/api/orgs/${membership.organization.id}/people/${personId}` - ); - + //plocka ut alla assignments som personen har en tilldelning i const assignmentModels = await CanvassAssignmentModel.find({ orgId: membership.organization.id, 'sessions.personId': { $eq: personId }, }); + //för varje uppdrag personen har en tilldelning i for await (const assignment of assignmentModels) { + const areas: ZetkinArea[] = []; + + //för varje session i det uppdraget + for await (const session of assignment.sessions) { + //om vi är på en session som "tillhör" personen + if (session.personId == personId) { + //lägg till arean i listan över areas + const area = await AreaModel.findOne({ + _id: session.areaId, + }); + + if (area) { + areas.push({ + description: area.description, + id: area._id.toString(), + organization: { + id: area.orgId, + }, + points: area.points, + tags: [], // TODO: is this necessary here? + title: area.title, + }); + } + } + } + + assignmentsWithAreas.push({ + areas: areas, + campaign: { + id: assignment.campId, + }, + id: assignment._id.toString(), + metrics: assignment.metrics.map((m) => ({ + definesDone: m.definesDone, + description: m.description, + id: m._id.toString(), + kind: m.kind, + question: m.question, + })), + organization: { + id: assignment.orgId, + }, + title: assignment.title, + }); + } + + /* for await (const assignment of assignmentModels) { for await (const sessionData of assignment.sessions) { if (sessionData.personId == personId) { const orgId = assignment.orgId; @@ -42,7 +89,7 @@ export async function GET(request: NextRequest) { }); if (area) { - sessions.push({ + assignmentsWithAreas.push({ area: { description: area.description, id: area._id.toString(), @@ -55,17 +102,30 @@ export async function GET(request: NextRequest) { }, assignee: person, assignment: { + campaign: { + id: assignment.campId, + }, id: assignment._id.toString(), + metrics: assignment.metrics.map((m) => ({ + definesDone: m.definesDone, + description: m.description, + id: m._id.toString(), + kind: m.kind, + question: m.question, + })), + organization: { + id: assignment.orgId, + }, title: assignment.title, }, }); } } } - } + } */ } return NextResponse.json({ - data: sessions, + data: assignmentsWithAreas, }); } diff --git a/src/app/my/canvassassignments/[canvassAssId]/page.tsx b/src/app/my/canvassassignments/[canvassAssId]/page.tsx new file mode 100644 index 000000000..e456c222e --- /dev/null +++ b/src/app/my/canvassassignments/[canvassAssId]/page.tsx @@ -0,0 +1,14 @@ +import 'leaflet/dist/leaflet.css'; +import MyCanvassAssignmentPage from 'features/canvassAssignments/components/MyCanvassAssignmentPage'; + +interface PageProps { + params: { + canvassAssId: string; + }; +} + +export default function Page({ params }: PageProps) { + const { canvassAssId } = params; + + return ; +} diff --git a/src/app/o/[orgId]/areas/[areaId]/page.tsx b/src/app/o/[orgId]/areas/[areaId]/page.tsx deleted file mode 100644 index bd2a57f3c..000000000 --- a/src/app/o/[orgId]/areas/[areaId]/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import 'leaflet/dist/leaflet.css'; -import { notFound } from 'next/navigation'; - -import PublicAreaPage from 'features/canvassAssignments/components/PublicAreaPage'; -import { AREAS, hasFeature } from 'utils/featureFlags'; - -interface PageProps { - params: { - areaId: string; - orgId: string; - }; -} - -export default function Page({ params }: PageProps) { - const { orgId, areaId } = params; - const hasAreas = hasFeature(AREAS, parseInt(orgId), process.env); - - if (!hasAreas) { - return notFound(); - } - - return ; -} diff --git a/src/features/canvassAssignments/components/PublicAreaMap.tsx b/src/features/canvassAssignments/components/CanvassAssignmentMap.tsx similarity index 69% rename from src/features/canvassAssignments/components/PublicAreaMap.tsx rename to src/features/canvassAssignments/components/CanvassAssignmentMap.tsx index 291cc9bb6..db17c477b 100644 --- a/src/features/canvassAssignments/components/PublicAreaMap.tsx +++ b/src/features/canvassAssignments/components/CanvassAssignmentMap.tsx @@ -1,5 +1,5 @@ import { FC, useCallback, useEffect, useRef, useState } from 'react'; -import { LatLng, latLngBounds, Map } from 'leaflet'; +import { LatLng, Map, FeatureGroup as FeatureGroupType } from 'leaflet'; import { makeStyles } from '@mui/styles'; import { Add, GpsNotFixed, Remove } from '@mui/icons-material'; import { @@ -7,11 +7,16 @@ import { Button, Divider, IconButton, + MenuItem, + Select, + ToggleButton, + ToggleButtonGroup, Typography, useTheme, } from '@mui/material'; import { AttributionControl, + FeatureGroup, MapContainer, Polygon, TileLayer, @@ -22,9 +27,12 @@ import { DivIconMarker } from 'features/events/components/LocationModal/DivIconM import useCreatePlace from '../hooks/useCreatePlace'; import usePlaces from '../hooks/usePlaces'; import getCrosshairPositionOnMap from '../utils/getCrosshairPositionOnMap'; -import MarkerIcon from '../utils/markerIcon'; import PlaceDialog from './PlaceDialog'; import { CreatePlaceCard } from './CreatePlaceCard'; +import getVisitState, { ProgressState } from '../utils/getVisitState'; +import MarkerIcon from '../utils/MarkerIcon'; +import { ZetkinCanvassAssignment } from '../types'; +import getDoneState from '../utils/getDoneState'; const useStyles = makeStyles((theme) => ({ '@keyframes ghostMarkerBounce': { @@ -55,6 +63,14 @@ const useStyles = makeStyles((theme) => ({ transition: 'opacity 0.1s', zIndex: 1200, }, + filterControls: { + display: 'flex', + flexDirection: 'column', + marginTop: 10, + position: 'absolute', + right: 10, + zIndex: 1000, + }, ghostMarker: { animationDirection: 'alternate', animationDuration: '0.4s', @@ -73,6 +89,12 @@ const useStyles = makeStyles((theme) => ({ padding: '8px', width: '90%', }, + markerFilterButtons: { + backgroundColor: theme.palette.common.white, + borderRadius: 4, + display: 'flex', + flexDirection: 'row', + }, zoomControls: { backgroundColor: theme.palette.common.white, borderRadius: 2, @@ -92,26 +114,38 @@ export type PlaceDialogStep = | 'wizard' | 'pickHousehold'; -type PublicAreaMapProps = { - area: ZetkinArea; - canvassAssId: string | null; +type CanvassAssignmentMapProps = { + areas: ZetkinArea[]; + assignment: ZetkinCanvassAssignment; }; -const PublicAreaMap: FC = ({ canvassAssId, area }) => { +const CanvassAssignmentMap: FC = ({ + areas, + assignment, +}) => { const theme = useTheme(); const classes = useStyles(); - const places = usePlaces(area.organization.id).data || []; - const createPlace = useCreatePlace(area.organization.id); + const places = usePlaces(assignment.organization.id).data || []; + const createPlace = useCreatePlace(assignment.organization.id); const [selectedPlaceId, setSelectedPlaceId] = useState(null); const [anchorEl, setAnchorEl] = useState(null); const [dialogStep, setDialogStep] = useState('place'); const [standingStill, setStandingStill] = useState(false); const [isCreating, setIsCreating] = useState(false); + const [placeFilters, setPlaceFilters] = useState([ + 'all', + 'none', + 'some', + ]); + const [dataToShow, setDataToShow] = useState<'visited' | 'done'>('visited'); const [map, setMap] = useState(null); const crosshairRef = useRef(null); const standingStillTimerRef = useRef(0); + const reactFGref = useRef(null); + + const [zoomed, setZoomed] = useState(false); const selectedPlace = places.find((place) => place.id == selectedPlaceId); const showViewPlaceButton = !!selectedPlace && !anchorEl; @@ -209,6 +243,32 @@ const PublicAreaMap: FC = ({ canvassAssId, area }) => { updateSelection(); }, [places]); + useEffect(() => { + if (map && !zoomed) { + const bounds = reactFGref.current?.getBounds(); + if (bounds?.isValid()) { + map.fitBounds(bounds); + setZoomed(true); + } + } + }, [areas, map]); + + const metricThatDefinesDone = assignment.metrics.find( + (metric) => metric.definesDone + ); + + const filteredPlaces = places.filter((place) => { + const state = + dataToShow == 'visited' + ? getVisitState(place.households, assignment.id) + : getDoneState( + place.households, + assignment.id, + metricThatDefinesDone?.id || '' + ); + return placeFilters.includes(state); + }); + return ( <> @@ -220,6 +280,40 @@ const PublicAreaMap: FC = ({ canvassAssId, area }) => { + + { + setPlaceFilters(newValue); + }} + value={placeFilters} + > + + + + + + + + + + + {!!assignment.metrics.find((metric) => metric.definesDone) && ( + + )} + = ({ canvassAssId, area }) => { setMap(map)} attributionControl={false} - bounds={latLngBounds(area.points)} minZoom={1} style={{ height: '100%', width: '100%' }} zoomControl={false} @@ -290,9 +383,30 @@ const PublicAreaMap: FC = ({ canvassAssId, area }) => { attribution="Leaflet & OpenStreetMap" url="https://tile.openstreetmap.org/{z}/{x}/{y}.png" /> - + { + reactFGref.current = fgRef; + }} + > + {areas.map((area) => ( + + ))} + <> - {places.map((place) => { + {filteredPlaces.map((place) => { + const state = + dataToShow == 'visited' + ? getVisitState(place.households, assignment.id) + : getDoneState( + place.households, + assignment.id, + metricThatDefinesDone?.id || '' + ); + const selected = place.id == selectedPlaceId; const key = `marker-${place.id}-${selected.toString()}`; @@ -310,15 +424,19 @@ const PublicAreaMap: FC = ({ canvassAssId, area }) => { lng: place.position.lng, }} > - + ); })} - {selectedPlace && canvassAssId && ( + {selectedPlace && ( { setAnchorEl(null); @@ -330,7 +448,7 @@ const PublicAreaMap: FC = ({ canvassAssId, area }) => { onUpdateDone={() => setDialogStep('place')} onWizard={() => setDialogStep('wizard')} open={!!anchorEl} - orgId={area.organization.id} + orgId={assignment.organization.id} place={selectedPlace} /> )} @@ -363,4 +481,4 @@ const PublicAreaMap: FC = ({ canvassAssId, area }) => { ); }; -export default PublicAreaMap; +export default CanvassAssignmentMap; diff --git a/src/features/canvassAssignments/components/MyCanvassAssignmentPage.tsx b/src/features/canvassAssignments/components/MyCanvassAssignmentPage.tsx new file mode 100644 index 000000000..c6daab6c3 --- /dev/null +++ b/src/features/canvassAssignments/components/MyCanvassAssignmentPage.tsx @@ -0,0 +1,77 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import { FC } from 'react'; +import { Avatar, Box } from '@mui/material'; + +import useOrganization from 'features/organizations/hooks/useOrganization'; +import ZUIFutures from 'zui/ZUIFutures'; +import useServerSide from 'core/useServerSide'; +import useMyCanvassAssignments from '../hooks/useMyCanvassAssignments'; +import { AssignmentWithAreas } from '../types'; + +const CanvassAssignmentMap = dynamic(() => import('./CanvassAssignmentMap'), { + ssr: false, +}); + +const AssignmentPage: FC<{ assignment: AssignmentWithAreas }> = ({ + assignment, +}) => { + const orgFuture = useOrganization(assignment.organization.id); + const isServer = useServerSide(); + + if (isServer) { + return null; + } + return ( + + {({ data: { org } }) => ( + <> + + + + {org.title} + + + {assignment.title ?? 'Untitled canvassassignment'} + + + + + + + )} + + ); +}; + +type MyCanvassAssignmentPageProps = { + canvassAssId: string; +}; + +const MyCanvassAssignmentPage: FC = ({ + canvassAssId, +}) => { + const myAssignments = useMyCanvassAssignments().data || []; + const assignment = myAssignments.find( + (assignment) => assignment.id == canvassAssId + ); + + if (!assignment) { + return null; + } + + return ; +}; + +export default MyCanvassAssignmentPage; diff --git a/src/features/canvassAssignments/components/MyCanvassAssignmentsPage.tsx b/src/features/canvassAssignments/components/MyCanvassAssignmentsPage.tsx index 71d384fa7..1e129d81f 100644 --- a/src/features/canvassAssignments/components/MyCanvassAssignmentsPage.tsx +++ b/src/features/canvassAssignments/components/MyCanvassAssignmentsPage.tsx @@ -11,17 +11,15 @@ import { Typography, } from '@mui/material'; -import useMyCanvassSessions from '../hooks/useMyCanvassSessions'; +import useMyCanvassAssignments from '../hooks/useMyCanvassAssignments'; import useCanvassAssignment from '../hooks/useCanvassAssignment'; import useOrganization from 'features/organizations/hooks/useOrganization'; import ZUIFutures from 'zui/ZUIFutures'; -import { ZetkinCanvassSession } from '../types'; const CanvassAssignmentCard: FC<{ - areaId: string; assignmentId: string; orgId: number; -}> = ({ areaId, orgId, assignmentId }) => { +}> = ({ orgId, assignmentId }) => { const router = useRouter(); const assignmentFuture = useCanvassAssignment(orgId, assignmentId); const organizationFuture = useOrganization(orgId); @@ -44,9 +42,7 @@ const CanvassAssignmentCard: FC<{ diff --git a/src/features/canvassAssignments/components/PlaceDialog/Place.tsx b/src/features/canvassAssignments/components/PlaceDialog/Place.tsx index 8fcc054b1..effebd903 100644 --- a/src/features/canvassAssignments/components/PlaceDialog/Place.tsx +++ b/src/features/canvassAssignments/components/PlaceDialog/Place.tsx @@ -1,9 +1,8 @@ -import { Check } from '@mui/icons-material'; import { Box, Divider, Typography } from '@mui/material'; import { FC } from 'react'; -import { isWithinLast24Hours } from 'features/canvassAssignments/utils/isWithinLast24Hours'; import { ZetkinPlace } from 'features/canvassAssignments/types'; +import ZUIRelativeTime from 'zui/ZUIRelativeTime'; type PlaceProps = { onSelectHousehold: (householdId: string) => void; @@ -36,32 +35,33 @@ const Place: FC = ({ onSelectHousehold, place }) => { {`${place.households.length} household/s`} - + {place.households.map((household) => { - const visitedRecently = isWithinLast24Hours( - household.visits.map((t) => t.timestamp) - ); - /*const mostRecentVisit = household.visits.toSorted( - (a, b) => { - const dateA = new Date(a.timestamp); - const dateB = new Date(b.timestamp); - if (dateA > dateB) { - return -1; - } else if (dateB > dateA) { - return 1; - } else { - return 0; - } - } - )[0];*/ + const sortedVisits = household.visits.toSorted((a, b) => { + const dateA = new Date(a.timestamp); + const dateB = new Date(b.timestamp); + if (dateA > dateB) { + return -1; + } else if (dateB > dateA) { + return 1; + } else { + return 0; + } + }); + + const mostRecentVisit = + sortedVisits.length > 0 ? sortedVisits[0] : null; return ( { onSelectHousehold(household.id); }} @@ -70,7 +70,9 @@ const Place: FC = ({ onSelectHousehold, place }) => { {household.title || 'Untitled household'} - {visitedRecently ? : ''} + {mostRecentVisit && ( + + )} ); })} diff --git a/src/features/canvassAssignments/components/PlaceDialog/index.tsx b/src/features/canvassAssignments/components/PlaceDialog/index.tsx index 4db5fc320..0833e1487 100644 --- a/src/features/canvassAssignments/components/PlaceDialog/index.tsx +++ b/src/features/canvassAssignments/components/PlaceDialog/index.tsx @@ -1,10 +1,4 @@ -import { - ArrowBackIos, - Check, - Close, - Edit, - MoreVert, -} from '@mui/icons-material'; +import { ArrowBackIos, Close, Edit, MoreVert } from '@mui/icons-material'; import { FC, useState } from 'react'; import { Box, @@ -22,12 +16,12 @@ import VisitWizard from './VisitWizard'; import EditPlace from './EditPlace'; import Place from './Place'; import Household from './Household'; -import { isWithinLast24Hours } from 'features/canvassAssignments/utils/isWithinLast24Hours'; import ZUIFuture from 'zui/ZUIFuture'; -import { PlaceDialogStep } from '../PublicAreaMap'; +import { PlaceDialogStep } from '../CanvassAssignmentMap'; import { ZetkinPlace } from 'features/canvassAssignments/types'; import usePlaceMutations from 'features/canvassAssignments/hooks/usePlaceMutations'; import useCanvassAssignment from 'features/canvassAssignments/hooks/useCanvassAssignment'; +import ZUIRelativeTime from 'zui/ZUIRelativeTime'; type PlaceDialogProps = { canvassAssId: string; @@ -135,7 +129,7 @@ const PlaceDialog: FC = ({ > - Log visit + Pick household @@ -206,38 +200,61 @@ const PlaceDialog: FC = ({ /> )} {dialogStep == 'pickHousehold' && ( - - Choose household - {place.households.map((household) => { - const visitedRecently = isWithinLast24Hours( - household.visits.map((t) => t.timestamp) - ); - return ( - { - if (!visitedRecently) { + + + {place.households.map((household) => { + const sortedVisits = household.visits.toSorted((a, b) => { + const dateA = new Date(a.timestamp); + const dateB = new Date(b.timestamp); + if (dateA > dateB) { + return -1; + } else if (dateB > dateA) { + return 1; + } else { + return 0; + } + }); + + const mostRecentVisit = + sortedVisits.length > 0 ? sortedVisits[0] : null; + + return ( + { setSelectedHouseholdId(household.id); onWizard(); - } - }} - width="100%" - > - - - {household.title || 'Untitled household'} - + }} + width="100%" + > + + + {household.title || 'Untitled household'} + + + {mostRecentVisit && ( + + )} - {visitedRecently ? : ''} - - ); - })} + ); + })} + )} {dialogStep === 'edit' && ( @@ -266,8 +283,8 @@ const PlaceDialog: FC = ({ onWizardStart={() => { onWizard(); }} - visitedRecently={isWithinLast24Hours( - selectedHousehold.visits.map((t) => t.timestamp) + visitedInThisAssignment={selectedHousehold.visits.some( + (visit) => visit.canvassAssId == canvassAssId )} /> )} diff --git a/src/features/canvassAssignments/components/PublicAreaPage.tsx b/src/features/canvassAssignments/components/PublicAreaPage.tsx deleted file mode 100644 index 2d0394040..000000000 --- a/src/features/canvassAssignments/components/PublicAreaPage.tsx +++ /dev/null @@ -1,64 +0,0 @@ -'use client'; - -import { Avatar, Box, Typography } from '@mui/material'; -import { FC } from 'react'; -import dynamic from 'next/dynamic'; -import { useSearchParams } from 'next/navigation'; - -import useArea from '../../areas/hooks/useArea'; -import useOrganization from 'features/organizations/hooks/useOrganization'; -import ZUIFutures from 'zui/ZUIFutures'; -import useServerSide from 'core/useServerSide'; - -const PublicAreaMap = dynamic(() => import('./PublicAreaMap'), { ssr: false }); - -type PublicAreaPageProps = { - areaId: string; - orgId: number; -}; - -const PublicAreaPage: FC = ({ areaId, orgId }) => { - const orgFuture = useOrganization(orgId); - const areaFuture = useArea(orgId, areaId); - const searchParams = useSearchParams(); - - const canvassAssId = searchParams?.get('canvassAssId') || null; - - const isServer = useServerSide(); - if (isServer) { - return null; - } - - return ( - - {({ data: { area, org } }) => ( - <> - - - - {org.title} - - - {area.title ?? 'Untitled canvassassignment'} - - {area.description ?? 'Untitled area'} - - - - - - - - )} - - ); -}; - -export default PublicAreaPage; diff --git a/src/features/canvassAssignments/hooks/useMyCanvassSessions.ts b/src/features/canvassAssignments/hooks/useMyCanvassAssignments.ts similarity index 66% rename from src/features/canvassAssignments/hooks/useMyCanvassSessions.ts rename to src/features/canvassAssignments/hooks/useMyCanvassAssignments.ts index aa9776560..ee5564242 100644 --- a/src/features/canvassAssignments/hooks/useMyCanvassSessions.ts +++ b/src/features/canvassAssignments/hooks/useMyCanvassAssignments.ts @@ -1,21 +1,19 @@ import { loadListIfNecessary } from 'core/caching/cacheUtils'; import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; import { myAssignmentsLoad, myAssignmentsLoaded } from '../store'; -import { ZetkinCanvassSession } from '../types'; +import { AssignmentWithAreas } from '../types'; -export default function useMyCanvassSessions() { +export default function useMyCanvassAssignments() { const apiClient = useApiClient(); const dispatch = useAppDispatch(); const mySessions = useAppSelector( - (state) => state.canvassAssignments.mySessionsList + (state) => state.canvassAssignments.myAssignmentsWithAreasList ); return loadListIfNecessary(mySessions, dispatch, { actionOnLoad: () => myAssignmentsLoad(), actionOnSuccess: (data) => myAssignmentsLoaded(data), loader: () => - apiClient.get( - '/beta/users/me/canvassassignments' - ), + apiClient.get('/beta/users/me/canvassassignments'), }); } diff --git a/src/features/canvassAssignments/store.ts b/src/features/canvassAssignments/store.ts index f135aa31c..56ffa6669 100644 --- a/src/features/canvassAssignments/store.ts +++ b/src/features/canvassAssignments/store.ts @@ -13,6 +13,7 @@ import { ZetkinCanvassAssignment, ZetkinCanvassSession, ZetkinPlace, + AssignmentWithAreas, } from './types'; export interface CanvassAssignmentsStoreSlice { @@ -25,7 +26,7 @@ export interface CanvassAssignmentsStoreSlice { string, RemoteList >; - mySessionsList: RemoteList; + myAssignmentsWithAreasList: RemoteList; placeList: RemoteList; statsByCanvassAssId: Record< string, @@ -36,7 +37,7 @@ export interface CanvassAssignmentsStoreSlice { const initialState: CanvassAssignmentsStoreSlice = { assigneesByCanvassAssignmentId: {}, canvassAssignmentList: remoteList(), - mySessionsList: remoteList(), + myAssignmentsWithAreasList: remoteList(), placeList: remoteList(), sessionsByAssignmentId: {}, statsByCanvassAssId: {}, @@ -220,23 +221,20 @@ const canvassAssignmentSlice = createSlice({ new Date().toISOString(); }, myAssignmentsLoad: (state) => { - state.mySessionsList.isLoading = true; + state.myAssignmentsWithAreasList.isLoading = true; }, myAssignmentsLoaded: ( state, - action: PayloadAction + action: PayloadAction ) => { - const sessions = action.payload; + const assignments = action.payload; const timestamp = new Date().toISOString(); - state.mySessionsList = remoteList( - sessions.map((session) => ({ - ...session, - id: `${session.assignment.id} ${session.assignee.id}`, - })) + state.myAssignmentsWithAreasList = remoteList(assignments); + state.myAssignmentsWithAreasList.loaded = timestamp; + state.myAssignmentsWithAreasList.items.forEach( + (item) => (item.loaded = timestamp) ); - state.mySessionsList.loaded = timestamp; - state.mySessionsList.items.forEach((item) => (item.loaded = timestamp)); }, placeCreated: (state, action: PayloadAction) => { const place = action.payload; diff --git a/src/features/canvassAssignments/types.ts b/src/features/canvassAssignments/types.ts index 814def005..332f5a793 100644 --- a/src/features/canvassAssignments/types.ts +++ b/src/features/canvassAssignments/types.ts @@ -21,6 +21,10 @@ export type ZetkinCanvassAssignment = { title: string | null; }; +export type AssignmentWithAreas = ZetkinCanvassAssignment & { + areas: ZetkinArea[]; +}; + export type ZetkinCanvassAssignmentPostBody = Partial< Omit > & { @@ -73,10 +77,7 @@ export type ZetkinPlacePatchBody = Partial< export type ZetkinCanvassSession = { area: ZetkinArea; assignee: ZetkinPerson; - assignment: { - id: string; - title: string | null; - }; + assignment: ZetkinCanvassAssignment; }; export type ZetkinCanvassSessionPostBody = { diff --git a/src/features/canvassAssignments/utils/MarkerIcon.tsx b/src/features/canvassAssignments/utils/MarkerIcon.tsx new file mode 100644 index 000000000..5a76b50ca --- /dev/null +++ b/src/features/canvassAssignments/utils/MarkerIcon.tsx @@ -0,0 +1,56 @@ +import { FC } from 'react'; +import { useTheme } from '@mui/styles'; +import { lighten } from '@mui/system'; + +import { ProgressState } from './getVisitState'; + +interface MarkerIconProps { + dataToShow?: 'done' | 'visited'; + state?: ProgressState; + selected: boolean; +} + +const MarkerIcon: FC = ({ + dataToShow = 'visited', + state = 'none', + selected, +}) => { + const theme = useTheme(); + + const circleColors: Record = { + all: + dataToShow == 'visited' + ? theme.palette.primary.main + : theme.palette.success.main, + none: theme.palette.grey[400], + some: + dataToShow == 'visited' + ? lighten(theme.palette.primary.main, 0.5) + : lighten(theme.palette.success.main, 0.5), + }; + + return ( + + + + + + ); +}; + +export default MarkerIcon; diff --git a/src/features/canvassAssignments/utils/getDoneState.ts b/src/features/canvassAssignments/utils/getDoneState.ts new file mode 100644 index 000000000..215e67fe3 --- /dev/null +++ b/src/features/canvassAssignments/utils/getDoneState.ts @@ -0,0 +1,38 @@ +import { Household } from '../types'; +import { ProgressState } from './getVisitState'; + +export default function getDoneState( + households: Household[], + canvassAssId: string, + metricId: string +): ProgressState { + let numberOfDoneHouseholds = 0; + households.forEach((household) => { + household.visits.forEach((visit) => { + if (visit.canvassAssId == canvassAssId) { + const done = visit.responses.find( + (response) => + response.metricId == metricId && response.response == 'yes' + ); + + if (done) { + numberOfDoneHouseholds++; + } + } + }); + }); + + if ( + numberOfDoneHouseholds > 0 && + numberOfDoneHouseholds == households.length + ) { + return 'all'; + } else if ( + numberOfDoneHouseholds > 0 && + numberOfDoneHouseholds < households.length + ) { + return 'some'; + } else { + return 'none'; + } +} diff --git a/src/features/canvassAssignments/utils/getVisitState.spec.ts b/src/features/canvassAssignments/utils/getVisitState.spec.ts new file mode 100644 index 000000000..f30341ff9 --- /dev/null +++ b/src/features/canvassAssignments/utils/getVisitState.spec.ts @@ -0,0 +1,80 @@ +import getVisitState from './getVisitState'; + +describe('getVisitState()', () => { + it('returns "none" when passed an empty array', () => { + const state = getVisitState([], '123'); + + expect(state).toEqual('none'); + }); + + it('returns "none" when passed an array where no housholds have visits in current assignment', () => { + const state = getVisitState( + [ + { id: '1', title: 'Door 1', visits: [] }, + { + id: '2', + title: 'Door 2', + visits: [ + { + canvassAssId: '345', + id: 'a', + noteToOfficial: '', + responses: [], + timestamp: '20230503', + }, + ], + }, + ], + '123' + ); + + expect(state).toEqual('none'); + }); + + it('returns "some" when passed an array where only one household has visits in the current assignment', () => { + const state = getVisitState( + [ + { id: '1', title: 'Door 1', visits: [] }, + { + id: '2', + title: 'Door 2', + visits: [ + { + canvassAssId: '123', + id: 'a', + noteToOfficial: '', + responses: [], + timestamp: '20230503', + }, + ], + }, + ], + '123' + ); + + expect(state).toEqual('some'); + }); + + it('returns "all" when passed an array where all households have visits in the current assignment', () => { + const state = getVisitState( + [ + { + id: '2', + title: 'Door 2', + visits: [ + { + canvassAssId: '123', + id: 'a', + noteToOfficial: '', + responses: [], + timestamp: '20230503', + }, + ], + }, + ], + '123' + ); + + expect(state).toEqual('all'); + }); +}); diff --git a/src/features/canvassAssignments/utils/getVisitState.ts b/src/features/canvassAssignments/utils/getVisitState.ts new file mode 100644 index 000000000..2356261de --- /dev/null +++ b/src/features/canvassAssignments/utils/getVisitState.ts @@ -0,0 +1,33 @@ +import { Household } from '../types'; + +export type ProgressState = 'none' | 'some' | 'all'; + +export default function getVisitState( + households: Household[], + canvassAssId: string | null +): ProgressState { + let numberOfVisitedHouseholds = 0; + households.forEach((household) => { + const hasVisitsInCurrentAssignment = household.visits.some((visit) => { + return visit.canvassAssId == canvassAssId; + }); + + if (hasVisitsInCurrentAssignment) { + numberOfVisitedHouseholds++; + } + }); + + if ( + numberOfVisitedHouseholds > 0 && + numberOfVisitedHouseholds == households.length + ) { + return 'all'; + } else if ( + numberOfVisitedHouseholds > 0 && + numberOfVisitedHouseholds < households.length + ) { + return 'some'; + } else { + return 'none'; + } +} diff --git a/src/features/canvassAssignments/utils/isWithinLast24Hours.tsx b/src/features/canvassAssignments/utils/isWithinLast24Hours.tsx deleted file mode 100644 index 02195e6aa..000000000 --- a/src/features/canvassAssignments/utils/isWithinLast24Hours.tsx +++ /dev/null @@ -1,18 +0,0 @@ -export const isWithinLast24Hours = (timestamps: string[]): boolean => { - if (timestamps.length === 0) { - return false; - } - const now = Date.now(); - const twentyFourHoursInMillis = 24 * 60 * 60 * 1000; - const twentyFourHoursAgo = now - twentyFourHoursInMillis; - - return timestamps.some((timestamp) => { - const parsedTimestamp = Date.parse(timestamp); - - if (isNaN(parsedTimestamp)) { - return false; - } - - return parsedTimestamp >= twentyFourHoursAgo && parsedTimestamp <= now; - }); -}; diff --git a/src/features/canvassAssignments/utils/markerIcon.tsx b/src/features/canvassAssignments/utils/markerIcon.tsx deleted file mode 100644 index 639465d2a..000000000 --- a/src/features/canvassAssignments/utils/markerIcon.tsx +++ /dev/null @@ -1,24 +0,0 @@ -interface MarkerIconProps { - selected: boolean; -} - -const MarkerIcon: React.FC = ({ selected }) => ( - - - -); - -export default MarkerIcon; diff --git a/src/utils/testing/mocks/mockState.ts b/src/utils/testing/mocks/mockState.ts index 2fb3840b1..611f45eff 100644 --- a/src/utils/testing/mocks/mockState.ts +++ b/src/utils/testing/mocks/mockState.ts @@ -25,7 +25,7 @@ export default function mockState(overrides?: RootState) { canvassAssignments: { assigneesByCanvassAssignmentId: {}, canvassAssignmentList: remoteList(), - mySessionsList: remoteList(), + myAssignmentsWithAreasList: remoteList(), placeList: remoteList(), sessionsByAssignmentId: {}, statsByCanvassAssId: {},