diff --git a/pom.xml b/pom.xml
index c2c4bc1d53910e4c7691ea374c6b6c4c0fec6932..e372339da515df616f6d0ebcc41bc1d67186e204 100755
--- a/pom.xml
+++ b/pom.xml
@@ -266,6 +266,21 @@
             <version>2.0.11</version>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <groupId>org.apache.poi</groupId>
+            <artifactId>poi</artifactId>
+            <version>5.3.0</version>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.poi</groupId>
+            <artifactId>poi-ooxml</artifactId>
+            <version>5.3.0</version>
+        </dependency>
+        <dependency>
+            <groupId>commons-io</groupId>
+            <artifactId>commons-io</artifactId>
+            <version>2.13.0</version>
+        </dependency>
     </dependencies>
 
     <build>
diff --git a/src/main/java/no/nibio/vips/logic/controller/session/ObservationBean.java b/src/main/java/no/nibio/vips/logic/controller/session/ObservationBean.java
index e205348f4a9e392b952760fb525d084c284033a6..7c79e6dd1ea1a0e21c8cdb622a191246708ec01e 100755
--- a/src/main/java/no/nibio/vips/logic/controller/session/ObservationBean.java
+++ b/src/main/java/no/nibio/vips/logic/controller/session/ObservationBean.java
@@ -520,10 +520,6 @@ public class ObservationBean {
                 .filter(o -> o.getObservationTimeSeriesId() != null)
                 .forEach(o -> o.setObservationTimeSeries(timeSeriesMap.get(o.getObservationTimeSeriesId())));
 
-        for (Observation o : observations) {
-            LOGGER.info("{}", o);
-        }
-
         return observations;
     }
     private List<Observation> getObservationsWithObservers(List<Observation> observations) {
diff --git a/src/main/java/no/nibio/vips/logic/service/ObservationService.java b/src/main/java/no/nibio/vips/logic/service/ObservationService.java
index daf359258690e26b9c4a7393a5b2ce1e945554c3..128a5bf9c241cb2103a9bc04d34486a1d313def7 100755
--- a/src/main/java/no/nibio/vips/logic/service/ObservationService.java
+++ b/src/main/java/no/nibio/vips/logic/service/ObservationService.java
@@ -32,6 +32,7 @@ import no.nibio.vips.logic.entity.rest.ObservationListItem;
 import no.nibio.vips.logic.entity.rest.PointMappingResponse;
 import no.nibio.vips.logic.entity.rest.ReferencedPoint;
 import no.nibio.vips.logic.messaging.MessagingBean;
+import no.nibio.vips.logic.util.ExcelFileGenerator;
 import no.nibio.vips.logic.util.GISEntityUtil;
 import no.nibio.vips.logic.util.Globals;
 import org.jboss.resteasy.annotations.GZIP;
@@ -59,6 +60,8 @@ import java.net.URI;
 import java.text.ParseException;
 import java.text.SimpleDateFormat;
 import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
 import java.util.*;
 import java.util.stream.Collectors;
 
@@ -130,12 +133,13 @@ public class ObservationService {
     }
 
     /**
-     * @param organizationId Database ID of the organization
-     * @param pestId         Database ID of the pest
-     * @param cropId         Database ID of the crop
-     * @param cropCategoryId cropCategoryId Database IDs of the crop category/categories
-     * @param fromStr        format "yyyy-MM-dd"
-     * @param toStr          format "yyyy-MM-dd"
+     * @param organizationId          Database ID of the organization
+     * @param observationTimeSeriesId Database ID of the observation time series
+     * @param pestId                  Database ID of the pest
+     * @param cropId                  Database ID of the crop
+     * @param cropCategoryId          cropCategoryId Database IDs of the crop category/categories
+     * @param fromStr                 format "yyyy-MM-dd"
+     * @param toStr                   format "yyyy-MM-dd"
      * @return Observation objects for which the user is authorized to observe with properties relevant for lists
      */
     @GET
@@ -158,6 +162,56 @@ public class ObservationService {
         return Response.ok().entity(this.getFilteredObservationListItems(organizationId, observationTimeSeriesId, pestId, cropId, cropCategoryId, fromStr, toStr, userUUID, localeStr, isPositive)).build();
     }
 
+    /**
+     * @param organizationId          Database ID of the organization
+     * @param observationTimeSeriesId Database ID of the observation time series
+     * @param pestId                  Database ID of the pest
+     * @param cropId                  Database ID of the crop
+     * @param cropCategoryId          cropCategoryId Database IDs of the crop category/categories
+     * @param fromStr                 format "yyyy-MM-dd"
+     * @param toStr                   format "yyyy-MM-dd"
+     * @return Observation objects for which the user is authorized to observe with properties relevant for lists
+     */
+    @GET
+    @Path("list/filter/{organizationId}/xlsx")
+    @GZIP
+    @Produces("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
+    public Response getFilteredObservationListItemsAsXlsx(
+            @PathParam("organizationId") Integer organizationId,
+            @QueryParam("observationTimeSeriesId") Integer observationTimeSeriesId,
+            @QueryParam("pestId") Integer pestId,
+            @QueryParam("cropId") Integer cropId,
+            @QueryParam("cropCategoryId") List<Integer> cropCategoryId,
+            @QueryParam("from") String fromStr,
+            @QueryParam("to") String toStr,
+            @QueryParam("userUUID") String userUUID,
+            @QueryParam("locale") String localeStr,
+            @QueryParam("isPositive") Boolean isPositive
+    ) {
+        VipsLogicUser user = getVipsLogicUser(userUUID);
+        ULocale locale = new ULocale(localeStr != null ? localeStr :
+                user != null ? user.getOrganizationId().getDefaultLocale() :
+                        userBean.getOrganization(organizationId).getDefaultLocale());
+        LOGGER.info("Generate xlsx file for observations for user {}", user != null ? user.getUserId() : "unregistered");
+
+        LocalDateTime now = LocalDateTime.now();
+        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
+        String filenameTimestamp = now.format(formatter);
+
+        try {
+            List<ObservationListItem> observations = getFilteredObservationListItems(organizationId, observationTimeSeriesId, pestId, cropId, cropCategoryId, fromStr, toStr, userUUID, localeStr, isPositive);
+            byte[] excelFile = ExcelFileGenerator.generateExcel(observations, locale);
+
+            return Response
+                    .ok(excelFile)
+                    .header("Content-Disposition", "attachment; filename=\"" + filenameTimestamp + "-observations.xlsx\"")
+                    .build();
+        } catch (IOException e) {
+            LOGGER.error(e.getMessage());
+            return Response.serverError().entity("Error generating Excel file: " + e.getMessage()).build();
+        }
+    }
+
     private List<ObservationListItem> getFilteredObservationListItems(
             Integer organizationId,
             Integer observationTimeSeriesId,
@@ -169,11 +223,7 @@ public class ObservationService {
             String userUUID,
             String localeStr,
             Boolean isPositive) {
-        VipsLogicUser user = (VipsLogicUser) httpServletRequest.getSession().getAttribute("user");
-
-        if (user == null && userUUID != null) {
-            user = userBean.findVipsLogicUser(UUID.fromString(userUUID));
-        }
+        VipsLogicUser user = getVipsLogicUser(userUUID);
         ULocale locale = new ULocale(localeStr != null ? localeStr :
                 user != null ? user.getOrganizationId().getDefaultLocale() :
                         userBean.getOrganization(organizationId).getDefaultLocale());
@@ -766,14 +816,14 @@ public class ObservationService {
     }
 
     /**
-     * @param organizationId Database id of the organization
+     * @param organizationId          Database id of the organization
      * @param observationTimeSeriesId Database id of the observation time series
-     * @param pestId         Database id of the pest
-     * @param cropId         Database id of the crop
-     * @param cropCategoryId Database ids of the crop categories
-     * @param fromStr        format "yyyy-MM-dd"
-     * @param toStr          format "yyyy-MM-dd"
-     * @param user           The user that requests this (used for authorization)
+     * @param pestId                  Database id of the pest
+     * @param cropId                  Database id of the crop
+     * @param cropCategoryId          Database ids of the crop categories
+     * @param fromStr                 format "yyyy-MM-dd"
+     * @param toStr                   format "yyyy-MM-dd"
+     * @param user                    The user that requests this (used for authorization)
      * @return A list of observations that meets the filter criteria
      */
     private List<Observation> getFilteredObservationsFromBackend(
@@ -803,10 +853,10 @@ public class ObservationService {
             LOGGER.info("Return {} masked public observations for unregistered user", retVal.size());
             return sortObservationsByDateAndId(retVal);
         }
-         // Else: This is a registered user without special privileges. Show public observations + user's own
+        // Else: This is a registered user without special privileges. Show public observations + user's own
         // Making sure we don't add duplicates
-        Set<Integer> obsIds = retVal.stream().map(o->o.getObservationId()).collect(Collectors.toSet());
-        retVal.addAll(observationBean.getObservationsForUser(user).stream().filter(o->!obsIds.contains(o.getObservationId())).collect(Collectors.toList()));
+        Set<Integer> obsIds = retVal.stream().map(o -> o.getObservationId()).collect(Collectors.toSet());
+        retVal.addAll(observationBean.getObservationsForUser(user).stream().filter(o -> !obsIds.contains(o.getObservationId())).collect(Collectors.toList()));
         LOGGER.info("Return {} masked public observations and user's own observations for registered user {}", retVal.size(), user.getUserId());
         return sortObservationsByDateAndId(retVal);
     }
@@ -1085,6 +1135,21 @@ public class ObservationService {
                 .collect(Collectors.toList());
     }
 
+    /**
+     * Find VipsLogic user from session or given userUUID
+     *
+     * @param userUUID the UUID of the user
+     * @return the corresponding VipsLogicUser
+     */
+    private VipsLogicUser getVipsLogicUser(String userUUID) {
+        VipsLogicUser user = (VipsLogicUser) httpServletRequest.getSession().getAttribute("user");
+        if (user == null && userUUID != null) {
+            user = userBean.findVipsLogicUser(UUID.fromString(userUUID));
+        }
+        return user;
+    }
+
+
     /**
      * Utility method for getting the string value of a given property in a given map.
      *
diff --git a/src/main/java/no/nibio/vips/logic/util/ExcelFileGenerator.java b/src/main/java/no/nibio/vips/logic/util/ExcelFileGenerator.java
new file mode 100644
index 0000000000000000000000000000000000000000..0e927d28cfd17357b6d7d3ad2633cd6ff51cc045
--- /dev/null
+++ b/src/main/java/no/nibio/vips/logic/util/ExcelFileGenerator.java
@@ -0,0 +1,201 @@
+package no.nibio.vips.logic.util;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.ibm.icu.util.ULocale;
+import no.nibio.vips.logic.entity.rest.ObservationListItem;
+import no.nibio.vips.observationdata.ObservationDataSchema;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.apache.poi.ss.usermodel.*;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+
+public final class ExcelFileGenerator {
+
+    private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+    private static final ObjectMapper objectMapper = new ObjectMapper();
+
+    private static final int COL_INDEX_DATE = 0;
+    private static final int COL_INDEX_LOCATION = 1;
+    private static final int COL_INDEX_ORGANISM = 2;
+    private static final int COL_INDEX_CROP_ORGANISM = 3;
+    private static final int COL_INDEX_HEADING = 4;
+    private static final int COL_START_INDEX_DATA = 5;
+
+    public static byte[] generateExcel(List<ObservationListItem> observations, ULocale locale) throws IOException {
+        ResourceBundle rb = ResourceBundle.getBundle("no.nibio.vips.logic.i18n.vipslogictexts", locale.toLocale());
+
+        try (XSSFWorkbook workbook = new XSSFWorkbook();
+             ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+
+            // Create main mainSheet for all observations, with header row
+            Sheet mainSheet = workbook.createSheet(rb.getString("allObservations"));
+            createHeaderRow(mainSheet, rb);
+
+            int mainSheetRowIndex = 1;
+            // Add one row for each observation in list of all observations
+            for (ObservationListItem item : observations) {
+                createItemRow(mainSheet, mainSheetRowIndex++, item);
+            }
+            autoSizeColumns(mainSheet, 0, COL_INDEX_HEADING);
+
+            // Prepare list of observations for each type of pest
+            Map<Integer, List<ObservationListItem>> pestObservations = getObservationsForEachPest(observations);
+
+            // Create sheets for each individual pest type
+            for (Integer pestId : pestObservations.keySet()) {
+                List<ObservationListItem> observationsForPest = pestObservations.get(pestId);
+                ObservationListItem firstObservationForPest = observationsForPest.get(0);
+                String pestName = firstObservationForPest.getOrganismName();
+                Sheet pestSheet = workbook.createSheet(pestName);
+                Row headerRow = createHeaderRow(pestSheet, rb);
+
+                // Add column titles for observation data
+                Map<String, String> dataColumnTitles = getObservationDataColumnTitles(firstObservationForPest.getObservationDataSchema());
+                int pestSheetColIndex = COL_START_INDEX_DATA;
+                for (String key : dataColumnTitles.keySet()) {
+                    headerRow.createCell(pestSheetColIndex++).setCellValue(dataColumnTitles.get(key));
+                }
+
+                int pestSheetRowIndex = 1;
+                for (ObservationListItem item : observationsForPest) {
+                    Row row = createItemRow(pestSheet, pestSheetRowIndex++, item);
+
+                    if (item.getObservationData() != null) {
+                        Map<String, Object> observationDataMap = objectMapper.readValue(item.getObservationData(), HashMap.class);
+                        if (observationDataMap != null) {
+                            pestSheetColIndex = COL_START_INDEX_DATA;
+                            for (String key : dataColumnTitles.keySet()) {
+                                Object value = observationDataMap.get(key);
+                                if (value instanceof Number) {
+                                    row.createCell(pestSheetColIndex++).setCellValue(((Number) value).intValue());
+                                } else {
+                                    row.createCell(pestSheetColIndex++).setCellValue(value != null ? value.toString() : "");
+                                }
+                            }
+                        }
+                    }
+                }
+                autoSizeColumns(pestSheet, COL_INDEX_DATE, COL_START_INDEX_DATA + dataColumnTitles.size());
+            }
+
+            workbook.write(out);
+            return out.toByteArray();
+        }
+    }
+
+    /**
+     * Auto-size columns of given sheet, from startIndex to endIndex, to ensure that content fits
+     *
+     * @param sheet      The sheet of which to auto-size columns
+     * @param startIndex The index of the first column to auto-size
+     * @param endIndex   The index of the last column to auto-size
+     */
+    private static void autoSizeColumns(Sheet sheet, int startIndex, int endIndex) {
+        for (int i = startIndex; i <= endIndex; i++) {
+            sheet.autoSizeColumn(i);
+        }
+    }
+
+    /**
+     * Create map with data property name as key, and corresponding localized name as value
+     *
+     * @param observationDataSchema The observation data schema which contains localized names and other info
+     * @return result map
+     */
+    private static Map<String, String> getObservationDataColumnTitles(ObservationDataSchema observationDataSchema) throws JsonProcessingException {
+        JsonNode rootNode = objectMapper.readTree(observationDataSchema.getDataSchema());
+        JsonNode propertiesNode = rootNode.path("properties");
+        Map<String, String> resultMap = new HashMap<>();
+        Iterator<Map.Entry<String, JsonNode>> fields = propertiesNode.fields();
+        while (fields.hasNext()) {
+            Map.Entry<String, JsonNode> field = fields.next();
+            String key = field.getKey();
+            String value = field.getValue().path("title").asText();
+            resultMap.put(key, value);
+        }
+        return resultMap;
+    }
+
+    /**
+     * Find the name of the point of interest in given geoInfo string
+     *
+     * @param geoInfo The geoInfo which might contain the name of the point of interest
+     * @return the point of interest name, or empty string
+     */
+    private static String getPointOfInterestName(String geoInfo) throws JsonProcessingException {
+        JsonNode rootNode = objectMapper.readTree(geoInfo);
+        JsonNode featuresNode = rootNode.path("features");
+        if (featuresNode.isArray() && !featuresNode.isEmpty()) {
+            JsonNode firstFeature = featuresNode.get(0);
+            JsonNode propertiesNode = firstFeature.path("properties");
+            return propertiesNode.path("pointOfInterestName").asText();
+        }
+        return "";
+    }
+
+    /**
+     * Create map with pestId as key, and list of corresponding observations as value
+     *
+     * @param observations The original list of observations
+     * @return result map
+     */
+    private static Map<Integer, List<ObservationListItem>> getObservationsForEachPest(List<ObservationListItem> observations) {
+        Map<Integer, List<ObservationListItem>> pestObservations = new HashMap<>();
+        for (ObservationListItem observation : observations) {
+            Integer pestId = observation.getOrganismId();
+            if (!pestObservations.containsKey(pestId)) {
+                List<ObservationListItem> observationList = new ArrayList<>();
+                observationList.add(observation);
+                pestObservations.put(pestId, observationList);
+            } else {
+                pestObservations.get(pestId).add(observation);
+            }
+        }
+        return pestObservations;
+    }
+
+    /**
+     * Create row with given index, for given observation list item
+     *
+     * @param sheet    The sheet to which a row will be added
+     * @param rowIndex The index of the row
+     * @param item     The item of which to add data
+     * @return the newly created row
+     */
+    private static Row createItemRow(Sheet sheet, int rowIndex, ObservationListItem item) throws JsonProcessingException {
+        LocalDate localDateOfObservation = item.getTimeOfObservation().toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
+        String pointOfInterestName = getPointOfInterestName(item.getGeoInfo());
+
+        Row row = sheet.createRow(rowIndex);
+        row.createCell(COL_INDEX_DATE).setCellValue(localDateOfObservation.format(DATE_FORMATTER));
+        row.createCell(COL_INDEX_LOCATION).setCellValue(pointOfInterestName);
+        row.createCell(COL_INDEX_ORGANISM).setCellValue(item.getOrganismName());
+        row.createCell(COL_INDEX_CROP_ORGANISM).setCellValue(item.getCropOrganismName());
+        row.createCell(COL_INDEX_HEADING).setCellValue(item.getObservationHeading());
+        return row;
+    }
+
+    /**
+     * Create first row of given sheet, with standard set of column titles
+     *
+     * @param sheet The sheet to which a row will be added
+     * @param rb    A resource bundle enabling localized messages
+     * @return the newly created header row
+     */
+    public static Row createHeaderRow(Sheet sheet, ResourceBundle rb) {
+        Row headerRow = sheet.createRow(0);
+        headerRow.createCell(COL_INDEX_DATE).setCellValue(rb.getString("timeOfObservation"));
+        headerRow.createCell(COL_INDEX_LOCATION).setCellValue(rb.getString("location"));
+        headerRow.createCell(COL_INDEX_ORGANISM).setCellValue(rb.getString("organism"));
+        headerRow.createCell(COL_INDEX_CROP_ORGANISM).setCellValue(rb.getString("cropOrganismId"));
+        headerRow.createCell(COL_INDEX_HEADING).setCellValue(rb.getString("observationHeading"));
+        return headerRow;
+    }
+}
diff --git a/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts.properties b/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts.properties
index ea2b3d71b173ccf69b53bbcdf2fb17817f0cf4fb..e7caec3ae87ef744e17f826821f0ca3da4b91607 100755
--- a/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts.properties
+++ b/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts.properties
@@ -556,6 +556,8 @@ observationDataField_trapCountCropInside = Number of trap counts inside the fiel
 
 observationDataField_unit = Measuring unit
 
+allObservations = All observations
+
 observationDeleted = Observation was deleted
 
 observationHeading = Observation heading
diff --git a/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts_nb.properties b/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts_nb.properties
index 8629648d65bb2c78e259818059c644696ecfdbd6..1d3db33c3426184e4df91b2ccd1dca2d3e632b81 100755
--- a/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts_nb.properties
+++ b/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts_nb.properties
@@ -354,7 +354,7 @@ greeting = Velkommen til
 
 groupMembers = Gruppemedlemmer
 
-heading = Overskrift
+heading = Tittel
 
 help = Hjelp
 
@@ -556,9 +556,11 @@ observationDataField_trapCountCropInside = Antall insekter, fellefangst inne i f
 
 observationDataField_unit = M\u00e5leenhet
 
+allObservations = Alle observasjoner
+
 observationDeleted = Observasjonen ble slettet
 
-observationHeading = Observasjons-overskrift
+observationHeading = Observasjonstittel
 
 observationMap = Observasjonskart
 
@@ -894,7 +896,7 @@ thresholdRelativeHumidity = Terskelverdi relativ luftfuktighet (%)
 
 tillageMethod = Jordarbeiding
 
-timeOfObservation = Observasjonstidspunkt
+timeOfObservation = Observasjonsdato
 
 timeZone = Tidssone