Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Configurable metrics and the visits wizard 🧙‍♂️ #2270

Open
wants to merge 48 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
355e9cb
remove rating route, add new properties to Visit type, update models …
rebecarubio Oct 9, 2024
3fb5817
work in the ui, implement wizard step
rebecarubio Oct 9, 2024
9035bb3
Add VisitWizard component and move logic into there & add new data in…
ziggabyte Oct 9, 2024
350f2d3
Add VisitWizard component and move logic into there & add new data in…
ziggabyte Oct 9, 2024
eb41b8c
Merge branch 'undocumented/wizard-visits' of github.com:zetkin/app.ze…
ziggabyte Oct 9, 2024
8136428
Create "previous"-button.
ziggabyte Oct 10, 2024
22be13d
Refactor everything in PlaceDialog to make life easier.
ziggabyte Oct 10, 2024
eb21bc4
First logic for showing previous desicion with "change" buttons.
ziggabyte Oct 10, 2024
f765afb
Refactor logic of display of previous choices.
ziggabyte Oct 10, 2024
7baa1dd
Disable "record visit" button and show message if household was visit…
ziggabyte Oct 10, 2024
8aeb377
Add some test styling to the "previous" items.
ziggabyte Oct 10, 2024
a0bda37
Add logic and UI to log visit directly from "place" screen.
ziggabyte Oct 10, 2024
3f4cebd
Add ellipsis menu with option to add household.
ziggabyte Oct 10, 2024
55da3ee
Remove unused messages and change title on button to match.
ziggabyte Oct 10, 2024
b9974b5
Agument types with new fields for configurable metrics
richardolsson Oct 11, 2024
f5fdcaa
Update ZetkinCanvassAssignment type
richardolsson Oct 11, 2024
8b14752
Add metrics and responses to canvassing APIs
richardolsson Oct 11, 2024
f91bb0a
First version of stepper rendering metrics from the outside.
ziggabyte Oct 11, 2024
aca6811
Show previous response.
ziggabyte Oct 11, 2024
612af96
Go back to the previous step you click on.
ziggabyte Oct 11, 2024
c8eff53
Refactor logic to go back and forward + add save button at the end.
ziggabyte Oct 11, 2024
709574a
Add metrics to stats API
richardolsson Oct 11, 2024
9ea71d6
Add bar chart with metrics on canvass assignment overview page
richardolsson Oct 11, 2024
6a41fe3
Display questions for scale5 metrics.
ziggabyte Oct 13, 2024
e7a1144
Add textfield for note to official.
ziggabyte Oct 13, 2024
7af940c
Merge branch 'undocumented/wizard-visits' of github.com:zetkin/app.ze…
ziggabyte Oct 13, 2024
5b9ed20
Get metric data from the canvass assignment.
ziggabyte Oct 13, 2024
6c03dda
add new editor page, add logic to create and save a boolean question,…
rebecarubio Oct 14, 2024
59f1b8e
add logic to delete metrics in route, extract in a new ZetkinMetric t…
rebecarubio Oct 14, 2024
0cb78a0
create new component MetricCard to handle Card logic, refactor editor…
rebecarubio Oct 14, 2024
9b8bb01
add scale5 metric type logic, add Close button in metricCard
rebecarubio Oct 14, 2024
52690b4
add useEffect to change values when editting and new metric is oppene…
rebecarubio Oct 14, 2024
29ee7a3
Fix bug causing canvass assignments to show up in all projects
richardolsson Oct 14, 2024
1f31be2
Fix bug preventing PATCH of canvass assignments without including met…
richardolsson Oct 14, 2024
1a6013b
Consistently use "yes"/"no" as the value for boolean metrics
richardolsson Oct 14, 2024
b8c5f74
Add default metric when creating a canvass assignment.
ziggabyte Oct 14, 2024
3822377
Add button to skip scale questions
ziggabyte Oct 14, 2024
186c4f9
Filter out skipped responses from what is sent to the server.
ziggabyte Oct 14, 2024
ead6a07
Change color of progress bars
richardolsson Oct 14, 2024
23c92ea
Refactor to update look of the "editor" tab
ziggabyte Oct 14, 2024
1b91000
Add some fun logic to prevent deleting the question that defines done.
ziggabyte Oct 14, 2024
8fcc873
Merge branch 'undocumented/wizard-visits' of github.com:zetkin/app.ze…
ziggabyte Oct 14, 2024
141809c
Prevent deleting in edit mode.
ziggabyte Oct 14, 2024
944b1fa
Make all non-essential questions skippable, not just scale questions.
ziggabyte Oct 14, 2024
2ffc421
Add stats for successful visits.
ziggabyte Oct 14, 2024
466d769
Move and clean up canvass assignments file structure.
ziggabyte Oct 15, 2024
b0bea8a
Merge pull request #2265 from zetkin/undocumented/clean-up-canvass
ziggabyte Oct 16, 2024
0a6b67c
Add separate type for the metrics on the backend.
ziggabyte Oct 16, 2024
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
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import mongoose from 'mongoose';
import { NextRequest, NextResponse } from 'next/server';

import { CanvassAssigneeModel } from 'features/areas/models';
import { CanvassAssigneeModel } from 'features/canvassAssignments/models';
import asOrgAuthorized from 'utils/api/asOrgAuthorized';

type RouteMeta = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import mongoose from 'mongoose';
import { NextRequest } from 'next/server';

import { ZetkinCanvassAssignee } from 'features/canvassAssignments/types';
import { CanvassAssigneeModel } from 'features/canvassAssignments/models';
import asOrgAuthorized from 'utils/api/asOrgAuthorized';
import { CanvassAssigneeModel } from 'features/areas/models';
import { ZetkinCanvassAssignee } from 'features/areas/types';

type RouteMeta = {
params: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import mongoose from 'mongoose';
import { NextRequest, NextResponse } from 'next/server';

import asOrgAuthorized from 'utils/api/asOrgAuthorized';
import { CanvassAssignmentModel } from 'features/areas/models';
import { ZetkinCanvassAssignment } from 'features/areas/types';
import { CanvassAssignmentModel } from 'features/canvassAssignments/models';
import {
ZetkinCanvassAssignment,
ZetkinMetric,
} from 'features/canvassAssignments/types';

type RouteMeta = {
params: {
Expand Down Expand Up @@ -34,6 +37,13 @@ export async function GET(request: NextRequest, { params }: RouteMeta) {
const canvassAssignment: ZetkinCanvassAssignment = {
campaign: { id: canvassAssignmentModel.campId },
id: canvassAssignmentModel._id.toString(),
metrics: (canvassAssignmentModel.metrics || []).map((metric) => ({
definesDone: metric.definesDone || false,
description: metric.description || '',
id: metric._id,
kind: metric.kind,
question: metric.question,
})),
organization: { id: orgId },
title: canvassAssignmentModel.title,
};
Expand All @@ -54,14 +64,68 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) {
await mongoose.connect(process.env.MONGODB_URL || '');

const payload = await request.json();
const { metrics: newMetrics, title } = payload;

const model = await CanvassAssignmentModel.findOneAndUpdate(
if (newMetrics) {
// Find existing metrics to remove
const assignment = await CanvassAssignmentModel.findById(
params.canvassAssId
).select('metrics');

if (!assignment) {
return new NextResponse(null, { status: 404 });
}

const existingMetricsIds = assignment.metrics.map((metric) =>
metric._id.toString()
);

// Identify metrics to be deleted
const metricsToDelete = existingMetricsIds.filter(
(id) => !newMetrics.some((metric: ZetkinMetric) => metric.id === id)
);

// Remove metrics that are no longer included
if (metricsToDelete.length > 0) {
await CanvassAssignmentModel.updateOne(
{ _id: params.canvassAssId },
{ $pull: { metrics: { _id: { $in: metricsToDelete } } } }
);
}

for (const metric of newMetrics) {
if (metric.id) {
// If the metric has an ID, update it
await CanvassAssignmentModel.updateOne(
{ _id: params.canvassAssId, 'metrics._id': metric.id },
{
$set: {
'metrics.$.definesDone': metric.definesDone,
'metrics.$.description': metric.description,
'metrics.$.kind': metric.kind,
'metrics.$.question': metric.question,
},
}
);
} else {
// If no ID exists, push it as a new metric
await CanvassAssignmentModel.updateOne(
{ _id: params.canvassAssId },
{
$push: { metrics: metric },
}
);
}
}
}

await CanvassAssignmentModel.updateOne(
{ _id: params.canvassAssId },
{
title: payload.title,
},
{ new: true }
{ title }
);
const model = await CanvassAssignmentModel.findById(
params.canvassAssId
).populate('metrics');

if (!model) {
return new NextResponse(null, { status: 404 });
Expand All @@ -71,6 +135,13 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) {
data: {
campaign: { id: model.campId },
id: model._id.toString(),
metrics: (model.metrics || []).map((metric) => ({
definesDone: metric.definesDone || false,
description: metric.description || '',
id: metric._id,
kind: metric.kind,
question: metric.question,
})),
organization: { id: orgId },
title: model.title,
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import mongoose from 'mongoose';
import { NextRequest, NextResponse } from 'next/server';

import { AreaModel, CanvassAssignmentModel } from 'features/areas/models';
import { ZetkinCanvassSession } from 'features/areas/types';
import { CanvassAssignmentModel } from 'features/canvassAssignments/models';
import { ZetkinCanvassSession } from 'features/canvassAssignments/types';
import asOrgAuthorized from 'utils/api/asOrgAuthorized';
import { ZetkinPerson } from 'utils/types/zetkin';
import { AreaModel } from 'features/areas/models';

type RouteMeta = {
params: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
import mongoose from 'mongoose';
import { NextRequest, NextResponse } from 'next/server';

import asOrgAuthorized from 'utils/api/asOrgAuthorized';
import { ZetkinPerson } from 'utils/types/zetkin';
import isPointInsidePolygon from 'features/canvassAssignments/utils/isPointInsidePolygon';
import {
AreaModel,
CanvassAssignmentModel,
PlaceModel,
} from 'features/areas/models';
} from 'features/canvassAssignments/models';
import {
Household,
Visit,
ZetkinArea,
ZetkinCanvassAssignmentStats,
ZetkinCanvassSession,
ZetkinPlace,
} from 'features/areas/types';
import asOrgAuthorized from 'utils/api/asOrgAuthorized';
import { ZetkinPerson } from 'utils/types/zetkin';
import isPointInsidePolygon from 'features/areas/utils/isPointInsidePolygon';
} from 'features/canvassAssignments/types';
import { AreaModel } from 'features/areas/models';
import { ZetkinArea } from 'features/areas/types';

type RouteMeta = {
params: {
Expand All @@ -35,40 +36,40 @@ export async function GET(request: NextRequest, { params }: RouteMeta) {
await mongoose.connect(process.env.MONGODB_URL || '');

//Find all sessions of the assignment
const model = await CanvassAssignmentModel.findOne({
const assignmentModel = await CanvassAssignmentModel.findOne({
_id: params.canvassAssId,
});

if (!model) {
if (!assignmentModel) {
return new NextResponse(null, { status: 404 });
}

const sessions: ZetkinCanvassSession[] = [];

for await (const sessionData of model.sessions) {
for await (const sessionData of assignmentModel.sessions) {
const person = await apiClient.get<ZetkinPerson>(
`/api/orgs/${orgId}/people/${sessionData.personId}`
);
const area = await AreaModel.findOne({
const areaModel = await AreaModel.findOne({
_id: sessionData.areaId,
});

if (area && person) {
if (areaModel && person) {
sessions.push({
area: {
description: area.description,
id: area._id.toString(),
description: areaModel.description,
id: areaModel._id.toString(),
organization: {
id: orgId,
},
points: area.points,
points: areaModel.points,
tags: [], //TODO: Is this really neccessary here?
title: area.title,
title: areaModel.title,
},
assignee: person,
assignment: {
id: model._id.toString(),
title: model.title,
id: assignmentModel._id.toString(),
title: assignmentModel.title,
},
});
}
Expand All @@ -90,7 +91,6 @@ export async function GET(request: NextRequest, { params }: RouteMeta) {
orgId: orgId,
position: model.position,
title: model.title,
type: model.type,
}));

type PlaceWithAreaId = ZetkinPlace & { areaId: ZetkinArea['id'] };
Expand All @@ -116,10 +116,55 @@ export async function GET(request: NextRequest, { params }: RouteMeta) {
];

const visitsInAreas: Visit[] = [];
const successfulVisitsInAreas: Visit[] = [];
const visitedPlacesInAreas: string[] = [];
const visitedAreas: string[] = [];
const householdsInAreas: Household[] = [];

const configuredMetrics = assignmentModel.metrics;
const idOfMetricThatDefinesDone = configuredMetrics.find(
(metric) => metric.definesDone
)?._id;
const accumulatedMetrics: ZetkinCanvassAssignmentStats['metrics'] =
configuredMetrics.map((metric) => ({
metric: {
definesDone: metric.definesDone,
description: metric.description,
id: metric._id,
kind: metric.kind,
question: metric.question,
},
values: metric.kind == 'boolean' ? [0] : [0, 0, 0, 0, 0],
}));

allPlaces.forEach((place) => {
place.households.forEach((household) => {
household.visits.forEach((visit) => {
if (visit.canvassAssId == params.canvassAssId) {
visit.responses.forEach((response) => {
const configuredMetric = configuredMetrics.find(
(candidate) => candidate._id == response.metricId
);

const accumulatedMetric = accumulatedMetrics.find(
(accum) => accum.metric.id == response.metricId
);

if (accumulatedMetric && configuredMetric) {
if (response.response == 'yes') {
accumulatedMetric.values[0]++;
} else if (configuredMetric.kind == 'scale5') {
const rating = parseInt(response.response);
const index = rating - 1;
accumulatedMetric.values[index]++;
}
}
});
}
});
});
});

uniquePlacesInAreas.forEach((place) => {
householdsInAreas.push(...place.households);
place.households.forEach((household) => {
Expand All @@ -128,6 +173,14 @@ export async function GET(request: NextRequest, { params }: RouteMeta) {
visitedAreas.push(place.areaId);
visitedPlacesInAreas.push(place.id);
visitsInAreas.push(visit);

visit.responses.forEach((response) => {
if (response.metricId == idOfMetricThatDefinesDone) {
if (response.response == 'yes') {
successfulVisitsInAreas.push(visit);
}
}
});
}
});
});
Expand Down Expand Up @@ -156,9 +209,11 @@ export async function GET(request: NextRequest, { params }: RouteMeta) {

return Response.json({
data: {
metrics: accumulatedMetrics,
num_areas: uniqueAreas.length,
num_households: householdsInAreas.length,
num_places: uniquePlacesInAreas.length,
num_successful_visited_households: successfulVisitsInAreas.length,
num_visited_areas: Array.from(new Set(visitedAreas)).length,
num_visited_households: visitsInAreas.length,
num_visited_households_outside_areas: visitsOutsideAreas.length,
Expand Down
17 changes: 16 additions & 1 deletion src/app/beta/orgs/[orgId]/canvassassignments/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import mongoose from 'mongoose';
import { NextRequest, NextResponse } from 'next/server';

import asOrgAuthorized from 'utils/api/asOrgAuthorized';
import { CanvassAssignmentModel } from 'features/areas/models';
import { CanvassAssignmentModel } from 'features/canvassAssignments/models';

type RouteMeta = {
params: {
Expand All @@ -28,6 +28,13 @@ export async function GET(request: NextRequest, { params }: RouteMeta) {
id: assignment.campId,
},
id: assignment._id.toString(),
metrics: (assignment.metrics || []).map((metric) => ({
definesDone: metric.definesDone || false,
description: metric.description || '',
id: metric._id,
kind: metric.kind,
question: metric.question,
})),
organization: {
id: orgId,
},
Expand All @@ -52,6 +59,7 @@ export async function POST(request: NextRequest, { params }: RouteMeta) {

const model = new CanvassAssignmentModel({
campId: payload.campaign_id,
metrics: payload.metrics || [],
orgId: orgId,
title: payload.title,
});
Expand All @@ -62,6 +70,13 @@ export async function POST(request: NextRequest, { params }: RouteMeta) {
data: {
campaign: { id: model.campId },
id: model._id.toString(),
metrics: model.metrics.map((metric) => ({
definesDone: metric.definesDone || false,
description: metric.description || '',
id: metric._id,
kind: metric.kind,
question: metric.question,
})),
organization: { id: orgId },
title: model.title,
},
Expand Down
Loading
Loading