From 21177e6be9473e8a1796edd8e8b1e94bdf901e7f Mon Sep 17 00:00:00 2001
From: Lene Wasskog <lene.wasskog@nibio.no>
Date: Thu, 8 May 2025 15:39:56 +0200
Subject: [PATCH] feat: List and form for observation time series

---
 .../servlet/ObservationController.java        |  67 +-
 .../ObservationTimeSeriesController.java      | 259 ++++++++
 .../controller/session/ObservationBean.java   |   9 +
 .../session/ObservationTimeSeriesBean.java    |   4 +-
 .../nibio/vips/logic/entity/Observation.java  |   1 +
 .../logic/entity/ObservationTimeSeries.java   |   3 +
 .../vips/logic/i18n/vipslogictexts.properties |   9 +
 .../logic/i18n/vipslogictexts_nb.properties   |   9 +
 src/main/webapp/WEB-INF/web.xml               |   8 +
 .../formdefinitions/observationForm.json      |   5 +
 .../observationTimeSeriesForm.json            |  70 +++
 src/main/webapp/templates/master.ftl          |   9 +-
 src/main/webapp/templates/observationForm.ftl |  45 +-
 src/main/webapp/templates/observationList.ftl |  18 +-
 .../templates/observationTimeSeriesForm.ftl   | 576 ++++++++++++++++++
 .../templates/observationTimeSeriesList.ftl   |  57 ++
 16 files changed, 1103 insertions(+), 46 deletions(-)
 create mode 100644 src/main/java/no/nibio/vips/logic/controller/servlet/ObservationTimeSeriesController.java
 create mode 100644 src/main/webapp/formdefinitions/observationTimeSeriesForm.json
 create mode 100644 src/main/webapp/templates/observationTimeSeriesForm.ftl
 create mode 100644 src/main/webapp/templates/observationTimeSeriesList.ftl

diff --git a/src/main/java/no/nibio/vips/logic/controller/servlet/ObservationController.java b/src/main/java/no/nibio/vips/logic/controller/servlet/ObservationController.java
index e9356c02..ec77d014 100755
--- a/src/main/java/no/nibio/vips/logic/controller/servlet/ObservationController.java
+++ b/src/main/java/no/nibio/vips/logic/controller/servlet/ObservationController.java
@@ -38,14 +38,13 @@ import no.nibio.vips.util.DateUtil;
 import no.nibio.vips.util.DateUtilException;
 import no.nibio.vips.util.ExceptionUtil;
 import no.nibio.vips.util.ServletUtil;
-import no.nibio.web.forms.FormUtil;
-import no.nibio.web.forms.FormValidation;
-import no.nibio.web.forms.FormValidationException;
-import no.nibio.web.forms.FormValidator;
+import no.nibio.web.forms.*;
 import org.apache.commons.fileupload2.core.DiskFileItemFactory;
 import org.apache.commons.fileupload2.core.FileItem;
 import org.apache.commons.fileupload2.core.FileUploadException;
 import org.apache.commons.fileupload2.jakarta.servlet6.JakartaServletFileUpload;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
 import java.text.ParseException;
@@ -58,6 +57,8 @@ import java.util.stream.Collectors;
  * @copyright 2014-2022 <a href="http://www.nibio.no/">NIBIO</a>
  */
 public class ObservationController extends HttpServlet {
+    private static final Logger LOGGER = LoggerFactory.getLogger(ObservationController.class);
+
     @PersistenceContext(unitName = "VIPSLogic-PU")
     EntityManager em;
 
@@ -246,13 +247,25 @@ public class ObservationController extends HttpServlet {
                 if (userBean.authorizeUser(user, VipsLogicRole.OBSERVER, VipsLogicRole.OBSERVATION_AUTHORITY,
                     VipsLogicRole.ORGANIZATION_ADMINISTRATOR, VipsLogicRole.SUPERUSER)) {
                     try {
-
                         Observation observation = new Observation();
+                        observation.setTimeOfObservation(new Date());
                         // See if there are presets on the URL string
                         if (request.getParameter("cropOrganismId") != null) {
                             observation.setCropOrganism(
                                 em.find(Organism.class, Integer.valueOf(request.getParameter("cropOrganismId"))));
                         }
+                        if (request.getParameter("observationTimeSeriesId") != null) {
+                            ObservationTimeSeries observationTimeSeries = em.find(ObservationTimeSeries.class,
+                                Integer.valueOf(request.getParameter("observationTimeSeriesId")));
+                            observation.setObservationTimeSeries(observationTimeSeries);
+                            Calendar cal = Calendar.getInstance();
+                            cal.set(Calendar.YEAR, observationTimeSeries.getYear());
+                            cal.set(Calendar.MONTH, Calendar.getInstance().get(Calendar.MONTH));
+                            cal.set(Calendar.DAY_OF_MONTH, Calendar.getInstance().get(Calendar.DAY_OF_MONTH));
+                            observation.setTimeOfObservation(cal.getTime());
+                            request.setAttribute("minTimeOfObservation", getMinDate(observationTimeSeries.getYear()));
+                            request.setAttribute("maxTimeOfObservation", getMaxDate(observationTimeSeries.getYear()));
+                        }
 
                         List<Organism> allCrops;
                         if (request.getParameter("organismId") != null) {
@@ -327,6 +340,13 @@ public class ObservationController extends HttpServlet {
                         request.setAttribute("locationVisibilityFormValue",
                             this.getLocationVisibilityFormValue(observation));
                         request.setAttribute("observation", observation);
+
+                        ObservationTimeSeries observationTimeSeries = observation.getObservationTimeSeries();
+                        if(observationTimeSeries != null) {
+                            request.setAttribute("minTimeOfObservation", getMinDate(observationTimeSeries.getYear()));
+                            request.setAttribute("maxTimeOfObservation", getMaxDate(observationTimeSeries.getYear()));
+                        }
+
                         request.setAttribute("polygonServices", polygonServices);
                         request.setAttribute("noBroadcast", request.getParameter("noBroadcast") != null);
                         request.setAttribute("mapLayers", userBean.getMapLayerJSONForUser(user));
@@ -382,7 +402,6 @@ public class ObservationController extends HttpServlet {
                         Map<String, String[]> parameterMap;
                         List<FileItem> items = null;
                         if (JakartaServletFileUpload.isMultipartContent(request)) {
-
                             // Create a new file upload handler
                             DiskFileItemFactory dfif = DiskFileItemFactory.builder().get();
                             JakartaServletFileUpload upload = new JakartaServletFileUpload(dfif);
@@ -399,16 +418,12 @@ public class ObservationController extends HttpServlet {
                         Observation observation =
                             observationId > 0 ? em.find(Observation.class, observationId) : new Observation();
 
-
-
                         if (formValidation.isValid()) {
                             // Storing observation
                             // Only new observations can set the organism
                             if (observationId <= 0 || user.isSuperUser() || user.isOrganizationAdmin()) {
-                                observation.setOrganism(em.find(Organism.class,
-                                    formValidation.getFormField("organismId").getValueAsInteger()));
-                                observation.setCropOrganism(em.find(Organism.class,
-                                    formValidation.getFormField("cropOrganismId").getValueAsInteger()));
+                                observation.setOrganism(em.find(Organism.class, formValidation.getFormField("organismId").getValueAsInteger()));
+                                observation.setCropOrganism(em.find(Organism.class, formValidation.getFormField("cropOrganismId").getValueAsInteger()));
                             }
                             observation.setTimeOfObservation(
                                 formValidation.getFormField("timeOfObservation").getValueAsDate());
@@ -417,11 +432,16 @@ public class ObservationController extends HttpServlet {
                             } else {
                                 observation.setLastEditedBy(user.getUserId());
                             }
+                            FormField observationTimeSeriesField = formValidation.getFormField("observationTimeSeriesId");
+                            if(observationTimeSeriesField != null && !observationTimeSeriesField.isEmpty()) {
+                                Integer otsIdInt = observationTimeSeriesField.getValueAsInteger();
+                                observation.setObservationTimeSeries(em.find(ObservationTimeSeries.class, otsIdInt));
+                            }
                             observation.setLastEditedTime(new Date());
                             observation.setObservationHeading(
                                 formValidation.getFormField("observationHeading").getWebValue());
                             observation.setObservationText(
-                                formValidation.getFormField("observationText").getWebValue());
+                                formValidation.getFormField("observationText").getWebValue().isEmpty() ? null : formValidation.getFormField("observationText").getWebValue());
                             observation.setObservationData(
                                 formValidation.getFormField("observationData").getWebValue().isEmpty() ?
                                     null
@@ -528,11 +548,8 @@ public class ObservationController extends HttpServlet {
                             request.getRequestDispatcher("/observationForm.ftl").forward(request, response);
                         }
 
-                    } catch (NullPointerException | NumberFormatException | FormValidationException |
-                        FileUploadException ex) {
-                        response.sendError(500, ExceptionUtil.getStackTrace(ex));
                     } catch (Exception ex) {
-                        ex.printStackTrace();
+                        LOGGER.error("Exception occurred while saving observation", ex);
                         response.sendError(500, ExceptionUtil.getStackTrace(ex));
                     }
                 } else {
@@ -635,6 +652,22 @@ public class ObservationController extends HttpServlet {
         }
     }
 
+    private Date getMinDate(int year) {
+        Calendar cal = Calendar.getInstance();
+        cal.set(Calendar.YEAR, year);
+        cal.set(Calendar.MONTH, Calendar.JANUARY);
+        cal.set(Calendar.DAY_OF_MONTH, 1);
+        return cal.getTime();
+    }
+
+    private Date getMaxDate(int year) {
+        Calendar cal = Calendar.getInstance();
+        cal.set(Calendar.YEAR, year);
+        cal.set(Calendar.MONTH, Calendar.DECEMBER);
+        cal.set(Calendar.DAY_OF_MONTH, 31);
+        return cal.getTime();
+    }
+
     public static final String LOCATION_VISIBILITY_FORM_VALUE_PRIVATE = "private";
     public static final String LOCATION_VISIBILITY_FORM_VALUE_PUBLIC = "public";
     public static final String LOCATION_VISIBILITY_FORM_VALUE_MASK_PREFIX = "mask_";
diff --git a/src/main/java/no/nibio/vips/logic/controller/servlet/ObservationTimeSeriesController.java b/src/main/java/no/nibio/vips/logic/controller/servlet/ObservationTimeSeriesController.java
new file mode 100644
index 00000000..a5f60bd0
--- /dev/null
+++ b/src/main/java/no/nibio/vips/logic/controller/servlet/ObservationTimeSeriesController.java
@@ -0,0 +1,259 @@
+package no.nibio.vips.logic.controller.servlet;
+
+import jakarta.ejb.EJB;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import no.nibio.vips.logic.controller.session.*;
+import no.nibio.vips.logic.entity.*;
+import no.nibio.vips.logic.i18n.SessionLocaleUtil;
+import no.nibio.vips.logic.messaging.MessagingBean;
+import no.nibio.vips.logic.util.Globals;
+import no.nibio.vips.util.ExceptionUtil;
+import no.nibio.vips.util.ServletUtil;
+import no.nibio.web.forms.*;
+import org.apache.commons.fileupload2.core.DiskFileItemFactory;
+import org.apache.commons.fileupload2.core.FileItem;
+import org.apache.commons.fileupload2.jakarta.servlet6.JakartaServletFileUpload;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.time.LocalDate;
+import java.util.*;
+
+public class ObservationTimeSeriesController extends HttpServlet {
+    private static final org.slf4j.Logger LOGGER = LoggerFactory.getLogger(ObservationTimeSeriesController.class);
+
+    private static final int START_YEAR = 2016;
+    public static final String LOCATION_VISIBILITY_FORM_VALUE_PRIVATE = "private";
+    public static final String LOCATION_VISIBILITY_FORM_VALUE_PUBLIC = "public";
+    public static final String LOCATION_VISIBILITY_FORM_VALUE_MASK_PREFIX = "mask_";
+
+    @PersistenceContext(unitName = "VIPSLogic-PU")
+    EntityManager em;
+
+    @EJB
+    UserBean userBean;
+    @EJB
+    ObservationTimeSeriesBean observationTimeSeriesBean;
+    @EJB
+    ObservationBean observationBean;
+    @EJB
+    MessagingBean messagingBean;
+    @EJB
+    OrganismBean organismBean;
+    @EJB
+    PointOfInterestBean pointOfInterestBean;
+
+    /**
+     * Handles the HTTP <code>GET</code> method.
+     *
+     * @param request  servlet request
+     * @param response servlet response
+     * @throws ServletException if a servlet-specific error occurs
+     * @throws IOException      if an I/O error occurs
+     */
+    @Override
+    protected void doGet(HttpServletRequest request, HttpServletResponse response)
+        throws ServletException, IOException {
+        processRequest(request, response);
+    }
+
+    /**
+     * Handles the HTTP <code>POST</code> method.
+     *
+     * @param request  servlet request
+     * @param response servlet response
+     * @throws ServletException if a servlet-specific error occurs
+     * @throws IOException      if an I/O error occurs
+     */
+    @Override
+    protected void doPost(HttpServletRequest request, HttpServletResponse response)
+        throws ServletException, IOException {
+        processRequest(request, response);
+    }
+
+    protected void processRequest(HttpServletRequest request, HttpServletResponse response)
+        throws ServletException, IOException {
+        LOGGER.info("Process request in ObservationTimeSeriesController");
+        response.setContentType("text/html;charset=UTF-8");
+        String action = request.getParameter("action");
+        VipsLogicUser user = (VipsLogicUser) request.getSession().getAttribute("user");
+        List<ObservationTimeSeries> observationTimeSeriesList = observationTimeSeriesBean.getObservationTimeSeriesListForUser(user);
+
+        if (request.getServletPath().endsWith("/observationTimeSeries")) {
+            LOGGER.info("Action is {}", action);
+            if (action == null) {
+                LOGGER.info("Display list of observationTimeSeries");
+                request.setAttribute("observationTimeSeriesList", observationTimeSeriesList);
+                LOGGER.info("Found {} observationTimeSeries", observationTimeSeriesList.size());
+
+                Map<String, Long> observationCounts = new HashMap<>();
+                for (ObservationTimeSeries observationTimeSeries : observationTimeSeriesList) {
+                    Long count = em.createNamedQuery("Observation.findCountByObservationTimeSeries", Long.class)
+                        .setParameter("observationTimeSeries", observationTimeSeries).getSingleResult();
+                    observationCounts.put(String.valueOf(observationTimeSeries.getObservationTimeSeriesId()), count);
+                }
+                request.setAttribute("observationCounts", observationCounts);
+                request.getRequestDispatcher("/observationTimeSeriesList.ftl").forward(request, response);
+                return;
+            }
+            boolean authorized =
+                userBean.authorizeUser(user, VipsLogicRole.OBSERVER, VipsLogicRole.ORGANIZATION_ADMINISTRATOR,
+                    VipsLogicRole.SUPERUSER);
+            if (!authorized) {
+                LOGGER.info("User {} not authorized", user.getUserId());
+                response.sendError(403, "Access not authorized");
+            }
+            if (action.equals("editObservationTimeSeriesForm")) {
+                Integer observationTimeSeriesId = Integer.valueOf(request.getParameter("observationTimeSeriesId"));
+                LOGGER.info("Display form for editing observationTimeSeries {}", observationTimeSeriesId);
+                ObservationTimeSeries observationTimeSeries =
+                    observationTimeSeriesBean.getObservationTimeSeries(observationTimeSeriesId);
+                Long observationCount = em.createNamedQuery("Observation.findCountByObservationTimeSeries", Long.class)
+                    .setParameter("observationTimeSeries", observationTimeSeries).getSingleResult();
+
+                request.setAttribute("observationTimeSeries", observationTimeSeries);
+                request.setAttribute("isEditable", observationCount == 0);
+                buildFormRequest(request, user, observationTimeSeries);
+                request.setAttribute("observations",
+                    observationBean.getObservationsForTimeSeries(observationTimeSeries));
+                request.getRequestDispatcher("/observationTimeSeriesForm.ftl").forward(request, response);
+            } else if (action.equals("newObservationTimeSeriesForm")) {
+                LOGGER.info("Create new observationTimeSeries");
+                ObservationTimeSeries observationTimeSeries = new ObservationTimeSeries();
+                request.setAttribute("observationTimeSeries", observationTimeSeries);
+                buildFormRequest(request, user, observationTimeSeries);
+                request.setAttribute("isEditable", true);
+                request.getRequestDispatcher("/observationTimeSeriesForm.ftl").forward(request, response);
+            } else if (action.equals("observationTimeSeriesFormSubmit")) {
+                LOGGER.info("Submit changes for observationTimeSeries");
+                Map<String, String[]> parameterMap;
+                List<FileItem> items;
+                if (JakartaServletFileUpload.isMultipartContent(request)) {
+                    DiskFileItemFactory dfif = DiskFileItemFactory.builder().get();
+                    JakartaServletFileUpload upload = new JakartaServletFileUpload(dfif);
+                    items = upload.parseRequest(request);
+                    parameterMap = FormUtil.getParameterMap(items, "UTF-8");
+                } else {
+                    parameterMap = request.getParameterMap();
+                }
+                ObservationTimeSeries observationTimeSeries = new ObservationTimeSeries();
+                try {
+                    FormValidation formValidation = FormValidator.validateForm(
+                        "observationTimeSeriesForm",
+                        parameterMap,
+                        SessionLocaleUtil.getI18nBundle(request),
+                        getServletContext()
+                    );
+                    Integer observationTimeSeriesId =
+                        formValidation.getFormField("observationTimeSeriesId").getValueAsInteger();
+                    if (observationTimeSeriesId > 0) {
+                        observationTimeSeries = em.find(ObservationTimeSeries.class, observationTimeSeriesId);
+                    }
+                    if (formValidation.isValid()) {
+                        Long observationCount = 0L;
+                        observationTimeSeries.setUserId(user.getUserId());
+                        if(observationTimeSeriesId >= 0) {
+                            observationTimeSeries.setLastModifiedBy(user.getUserId());
+                            observationTimeSeries.setLastModified(new Date());
+                            observationCount = em.createNamedQuery("Observation.findCountByObservationTimeSeries", Long.class)
+                                .setParameter("observationTimeSeries", observationTimeSeries).getSingleResult();
+                        }
+                        // Only observation time series without observations can set the crop, pest and location
+                        if (observationCount == 0) {
+                            observationTimeSeries.setOrganism(em.find(Organism.class, formValidation.getFormField("organismId").getValueAsInteger()));
+                            observationTimeSeries.setCropOrganism(em.find(Organism.class, formValidation.getFormField("cropOrganismId").getValueAsInteger()));
+                            observationTimeSeries.setYear(formValidation.getFormField("year").getValueAsInteger());
+                            setObservationLocationVisibility(observationTimeSeries, formValidation.getFormField("locationVisibility").getWebValue());
+                            observationTimeSeries.setLocationPointOfInterestId(
+                                formValidation.getFormField("locationPointOfInterestId").getValueAsInteger());
+                        }
+                        observationTimeSeries.setName(formValidation.getFormField("name").getWebValue());
+                        observationTimeSeries.setDescription(formValidation.getFormField("description").getWebValue());
+                        observationTimeSeries = observationTimeSeriesBean.storeObservationTimeSeries(observationTimeSeries);
+                        LOGGER.info("Redirect to edit form with observationTimeSeriesId={}", observationTimeSeries.getObservationTimeSeriesId());
+                        response.sendRedirect(Globals.PROTOCOL + "://" +
+                            ServletUtil.getServerName(request) +
+                            "/observationTimeSeries?action=editObservationTimeSeriesForm&observationTimeSeriesId=" +
+                            observationTimeSeries.getObservationTimeSeriesId() +
+                            "&messageKey=observationTimeSeriesStored"
+                        );
+                    } else {
+                        // Redirect to form with error messages
+                        buildFormRequest(request, user, observationTimeSeries);
+                        request.setAttribute("formValidation", formValidation);
+                        request.getRequestDispatcher("/observationTimeSeriesForm.ftl").forward(request, response);
+                    }
+                } catch (FormValidationException e) {
+                    LOGGER.error("Exception at form validation", e);
+                    response.sendError(500, ExceptionUtil.getStackTrace(e));
+                }
+            } else if (action.equals("deleteObservationTimeSeries")) {
+                Integer observationTimeSeriesId = Integer.valueOf(request.getParameter("observationTimeSeriesId"));
+                LOGGER.info("Delete observationTimeSeries {}", observationTimeSeriesId);
+            }
+        }
+    }
+
+    private void buildFormRequest(HttpServletRequest request, VipsLogicUser user, ObservationTimeSeries observationTimeSeries) {
+        request.setAttribute("observationTimeSeries", observationTimeSeries);
+        request.setAttribute("years", generateYearList());
+        request.setAttribute("mapLayers", userBean.getMapLayerJSONForUser(user));
+        request.setAttribute("defaultMapCenter", user.getOrganizationId().getDefaultMapCenter());
+        request.setAttribute("defaultMapZoom", user.getOrganizationId().getDefaultMapZoom());
+        request.setAttribute("locationPointOfInterests",
+            pointOfInterestBean.getRelevantPointOfInterestsForUser(user));
+        request.setAttribute("locationVisibilityFormValue",
+            getLocationVisibilityFormValue(observationTimeSeries));
+        List<PolygonService> polygonServices =
+            observationBean.getPolygonServicesForOrganization(user.getOrganization_id());
+        request.setAttribute("polygonServices", polygonServices);
+        List<Organism> allPests = em.createNamedQuery("Organism.findAllPests").getResultList();
+        request.setAttribute("allPests", organismBean.sortOrganismsByLocalName(allPests,
+            SessionLocaleUtil.getCurrentLocale(request).getLanguage()));
+        List<Organism> allCrops = em.createNamedQuery("Organism.findAllCrops").getResultList();
+        request.setAttribute("allCrops", organismBean.sortOrganismsByLocalName(allCrops,
+            SessionLocaleUtil.getCurrentLocale(request).getLanguage()));
+        request.setAttribute("hierarchyCategories",
+            organismBean.getHierarchyCategoryNames(SessionLocaleUtil.getCurrentLocale(request)));
+    }
+
+    private List<Integer> generateYearList() {
+        List<Integer> years = new ArrayList<>();
+        for (int year = START_YEAR; year <= LocalDate.now().getYear(); year++) {
+            years.add(year);
+        }
+        return years;
+    }
+
+    private String getLocationVisibilityFormValue(ObservationTimeSeries observationTimeSeries) {
+        // Private is private, no matter what
+        if (observationTimeSeries.getLocationIsPrivate()) {
+            return LOCATION_VISIBILITY_FORM_VALUE_PRIVATE;
+        }
+        // Public can either be completely public or masked by a polygon layer. e.g. county borders
+        if (observationTimeSeries.getPolygonService() == null) {
+            return LOCATION_VISIBILITY_FORM_VALUE_PUBLIC;
+        }
+        return LOCATION_VISIBILITY_FORM_VALUE_MASK_PREFIX + observationTimeSeries.getPolygonService()
+            .getPolygonServiceId();
+    }
+
+    private void setObservationLocationVisibility(ObservationTimeSeries observationTimeSeries, String formValue) {
+        // Private is private, no matter what
+        observationTimeSeries.setLocationIsPrivate(
+            formValue.equals(ObservationController.LOCATION_VISIBILITY_FORM_VALUE_PRIVATE));
+        observationTimeSeries.setPolygonService(
+            // If private or public, set no polygon service
+            formValue.equals(ObservationController.LOCATION_VISIBILITY_FORM_VALUE_PRIVATE) || formValue.equals(
+                ObservationController.LOCATION_VISIBILITY_FORM_VALUE_PUBLIC)
+                ? null
+                // Otherwise, set selected polygon service
+                : em.find(PolygonService.class, Integer.valueOf(formValue.split("_")[1]))
+        );
+    }
+}
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 80417862..e970296e 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
@@ -189,6 +189,15 @@ public class ObservationBean {
         return retVal;
     }
 
+    public List<Observation> getObservationsForTimeSeries(ObservationTimeSeries observationTimeSeries) {
+        List<Observation> retVal = this.getObservationsWithGeoInfo(em.createNamedQuery("Observation.findByObservationTimeSeries", Observation.class)
+            .setParameter("observationTimeSeries", observationTimeSeries)
+            .getResultList());
+        retVal = this.getObservationsWithLocations(retVal);
+        retVal = this.getObservationsWithObservers(retVal);
+        return retVal;
+    }
+
     public Observation getObservation(Integer observationId) {
         Observation retVal = em.find(Observation.class, observationId);
         if (retVal != null) {
diff --git a/src/main/java/no/nibio/vips/logic/controller/session/ObservationTimeSeriesBean.java b/src/main/java/no/nibio/vips/logic/controller/session/ObservationTimeSeriesBean.java
index 627002b9..177b8bb1 100644
--- a/src/main/java/no/nibio/vips/logic/controller/session/ObservationTimeSeriesBean.java
+++ b/src/main/java/no/nibio/vips/logic/controller/session/ObservationTimeSeriesBean.java
@@ -23,9 +23,7 @@ import jakarta.ejb.EJB;
 import jakarta.ejb.Stateless;
 import jakarta.persistence.EntityManager;
 import jakarta.persistence.PersistenceContext;
-import no.nibio.vips.logic.entity.ObservationTimeSeries;
-import no.nibio.vips.logic.entity.PointOfInterest;
-import no.nibio.vips.logic.entity.VipsLogicUser;
+import no.nibio.vips.logic.entity.*;
 
 @Stateless
 public class ObservationTimeSeriesBean {
diff --git a/src/main/java/no/nibio/vips/logic/entity/Observation.java b/src/main/java/no/nibio/vips/logic/entity/Observation.java
index 478e8dd6..a845e383 100755
--- a/src/main/java/no/nibio/vips/logic/entity/Observation.java
+++ b/src/main/java/no/nibio/vips/logic/entity/Observation.java
@@ -53,6 +53,7 @@ import org.hibernate.type.SqlTypes;
     @NamedQuery(name = "Observation.findByLastEditedBy", query = "SELECT o FROM Observation o WHERE o.lastEditedBy IN(:lastEditedBy)"),
     @NamedQuery(name = "Observation.findByLocationPointOfInterestId", query = "SELECT o FROM Observation o WHERE o.locationPointOfInterestId = :locationPointOfInterestId"),
     @NamedQuery(name = "Observation.findByObservationTimeSeries", query = "SELECT o FROM Observation o WHERE o.observationTimeSeries = :observationTimeSeries"),
+    @NamedQuery(name = "Observation.findCountByObservationTimeSeries", query = "SELECT count(o) FROM Observation o WHERE o.observationTimeSeries = :observationTimeSeries"),
     @NamedQuery(name = "Observation.findByStatusChangedByUserId", query = "SELECT o FROM Observation o WHERE o.statusChangedByUserId IN(:statusChangedByUserId)"),
     @NamedQuery(name = "Observation.findByUserIdAndPeriod", query = "SELECT o FROM Observation o WHERE o.timeOfObservation BETWEEN :start AND :end AND o.userId IN(:userId)"),
     @NamedQuery(name = "Observation.findByUserIdAndStatusTypeId", query = "SELECT o FROM Observation o WHERE o.userId IN(:userId) AND o.statusTypeId= :statusTypeId"),
diff --git a/src/main/java/no/nibio/vips/logic/entity/ObservationTimeSeries.java b/src/main/java/no/nibio/vips/logic/entity/ObservationTimeSeries.java
index 04db0628..86427c99 100644
--- a/src/main/java/no/nibio/vips/logic/entity/ObservationTimeSeries.java
+++ b/src/main/java/no/nibio/vips/logic/entity/ObservationTimeSeries.java
@@ -61,9 +61,12 @@ public class ObservationTimeSeries implements Serializable {
 
     public ObservationTimeSeries() {
         this.GISEntityUtil = new GISEntityUtil();
+        this.source = "WEB";
+        this.created = new Date();
     }
 
     public ObservationTimeSeries(String source) {
+        this();
         this.source = source;
     }
 
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 e8b9b640..28c36c9d 100755
--- a/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts.properties
+++ b/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts.properties
@@ -1088,3 +1088,12 @@ observationCount=Observation count
 weatherDatasource=Weather datasource
 useWeatherStation=Use weather station
 multipleNewWarningMsg=This form is for adding the same forecast configuration to many weather stations simultaneously.
+
+timeSeriesList = Time series
+forTimeSeries=for time series
+newObservationTimeSeries=New observation time series
+editObservationTimeSeries=Edit observation time series
+addNewObservationInTimeSeries=Add observation to time series
+observationTimeSeriesName=Name
+observationTimeSeriesDescription=Description
+year=Year
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 69c8db61..a5b139b2 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
@@ -1087,3 +1087,12 @@ observationCount=Antall observasjoner
 weatherDatasource=V\u00e6rdatakilde
 useWeatherStation=Bruk v\u00e6rstasjon
 multipleNewWarningMsg=Dette skjemaet er en snarvei for \u00e5 opprette likelydende varsel p\u00e5 flere v\u00e6rstasjoner. <a href="/forecastConfiguration?action=viewForecastConfiguration&forecastConfigurationId=-1">Klikk her</a> hvis du vil opprette bare ett varsel.
+
+timeSeriesList = Tidsserier
+forTimeSeries=for tidsserie
+newObservationTimeSeries=Ny tidsserie
+editObservationTimeSeries=Rediger tidsserie
+addNewObservationInTimeSeries=Legg til ny observasjon i tidsserien
+observationTimeSeriesName=Navn
+observationTimeSeriesDescription=Beskrivelse
+year=\u00c5r
diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml
index d532ad2e..85ee5b44 100755
--- a/src/main/webapp/WEB-INF/web.xml
+++ b/src/main/webapp/WEB-INF/web.xml
@@ -59,6 +59,10 @@
         <servlet-name>MessageController</servlet-name>
         <servlet-class>no.nibio.vips.logic.controller.servlet.MessageController</servlet-class>
     </servlet>
+    <servlet>
+        <servlet-name>ObservationTimeSeriesServlet</servlet-name>
+        <servlet-class>no.nibio.vips.logic.controller.servlet.ObservationTimeSeriesController</servlet-class>
+    </servlet>
     <servlet>
         <servlet-name>ObservationServlet</servlet-name>
         <servlet-class>no.nibio.vips.logic.controller.servlet.ObservationController</servlet-class>
@@ -139,6 +143,10 @@
         <servlet-name>MessageController</servlet-name>
         <url-pattern>/message</url-pattern>
     </servlet-mapping>
+    <servlet-mapping>
+        <servlet-name>ObservationTimeSeriesServlet</servlet-name>
+        <url-pattern>/observationTimeSeries</url-pattern>
+    </servlet-mapping>
     <servlet-mapping>
         <servlet-name>ObservationServlet</servlet-name>
         <url-pattern>/observation</url-pattern>
diff --git a/src/main/webapp/formdefinitions/observationForm.json b/src/main/webapp/formdefinitions/observationForm.json
index 82fa5aab..70d4e495 100755
--- a/src/main/webapp/formdefinitions/observationForm.json
+++ b/src/main/webapp/formdefinitions/observationForm.json
@@ -24,6 +24,11 @@
             "dataType" : "INTEGER",
             "required" : true
         },
+        {
+            "name" : "observationTimeSeriesId",
+            "dataType" : "INTEGER",
+            "required" : false
+        },
         {
             "name" : "organismId",
             "dataType" : "INTEGER",
diff --git a/src/main/webapp/formdefinitions/observationTimeSeriesForm.json b/src/main/webapp/formdefinitions/observationTimeSeriesForm.json
new file mode 100644
index 00000000..1510bfb1
--- /dev/null
+++ b/src/main/webapp/formdefinitions/observationTimeSeriesForm.json
@@ -0,0 +1,70 @@
+{
+  "_licenseNote": [
+    "Copyright (c) 2014 NIBIO <http://www.nibio.no/>. ",
+    "",
+    "This file is part of VIPSLogic. ",
+
+    "This program is free software: you can redistribute it and/or modify",
+    "it under the terms of the GNU Affero General Public License as published by",
+    "the Free Software Foundation, either version 3 of the License, or",
+    "(at your option) any later version.",
+
+    "This program is distributed in the hope that it will be useful,",
+    "but WITHOUT ANY WARRANTY; without even the implied warranty of",
+    "MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the",
+    "GNU Affero General Public License for more details.",
+
+    "You should have received a copy of the GNU Affero General Public License",
+    "along with this program.  If not, see <https://www.gnu.org/licenses/>."
+  ],
+  "_comment" : "Structure of the observationTimeSeriesForm and how to validate it",
+  "fields": [
+    {
+      "name" : "observationTimeSeriesId",
+      "dataType" : "INTEGER",
+      "required" : true
+    },
+    {
+      "name" : "organismId",
+      "dataType" : "INTEGER",
+      "fieldType" : "SELECT_SINGLE",
+      "required" : true,
+      "nullValue" : "-1"
+    },
+    {
+      "name" : "cropOrganismId",
+      "dataType" : "INTEGER",
+      "fieldType" : "SELECT_SINGLE",
+      "required" : true,
+      "nullValue" : "-1"
+    },
+    {
+      "name" : "year",
+      "dataType" : "INTEGER",
+      "timestampFormat" : "SELECT_SINGLE",
+      "required" : true
+    },
+    {
+      "name" : "locationPointOfInterestId",
+      "dataType" : "INTEGER",
+      "fieldType" : "SELECT_SINGLE",
+      "required" : true
+    },
+    {
+      "name" : "locationVisibility",
+      "dataType" : "STRING",
+      "fieldType" : "RADIO",
+      "required" : true
+    },
+    {
+      "name" : "name",
+      "dataType" : "STRING",
+      "required" : true
+    },
+    {
+      "name" : "description",
+      "dataType" : "STRING",
+      "required" : false
+    }
+  ]
+}
diff --git a/src/main/webapp/templates/master.ftl b/src/main/webapp/templates/master.ftl
index 712e6aaf..ef26ccca 100755
--- a/src/main/webapp/templates/master.ftl
+++ b/src/main/webapp/templates/master.ftl
@@ -66,9 +66,12 @@
 		  <#if user.isOrganizationAdmin() || user.isSuperUser() || user.isMessageAuthor()>
 		  	<li><a href="/message">${i18nBundle.messages}</a></li>
 		  </#if>
-                  <#if user.isOrganizationAdmin() || user.isSuperUser() || user.isAppleFruitMothAdministrator() || user.hasRole(3?int) || user.hasRole(4?int)>
-                    <li><a href="/observation">${i18nBundle.observations}</a></li>
-                  </#if>
+			<#if user.isOrganizationAdmin() || user.isSuperUser() || user.isAppleFruitMothAdministrator() || user.hasRole(3?int) || user.hasRole(4?int)>
+				<li><a href="/observationTimeSeries">${i18nBundle.timeSeriesList}</a></li>
+			</#if>
+		  <#if user.isOrganizationAdmin() || user.isSuperUser() || user.isAppleFruitMothAdministrator() || user.hasRole(3?int) || user.hasRole(4?int)>
+			<li><a href="/observation">${i18nBundle.observations}</a></li>
+		  </#if>
 		  <li><a href="/forecastConfiguration">${i18nBundle.forecasts}</a></li>
                   <#if user.isOrganizationAdmin() || user.isSuperUser() || user.isOrganismEditor()>
 		  	 <li><a href="/organism">${i18nBundle.organisms}</a></li>
diff --git a/src/main/webapp/templates/observationForm.ftl b/src/main/webapp/templates/observationForm.ftl
index d4cd9e01..152b692a 100755
--- a/src/main/webapp/templates/observationForm.ftl
+++ b/src/main/webapp/templates/observationForm.ftl
@@ -64,19 +64,18 @@
             initGisInfoMap("observationFormMap", [${defaultMapCenter.x?c}, ${defaultMapCenter.y?c}], ${defaultMapZoom}, false, geoInfo, chooseFromMapLayers);
             </#if>
             <#if editAccess!="W">
-            GISInfoMap.removeInteraction(draw);
-            document.getElementById("drawOptions").innerHTML = "";
+                GISInfoMap.removeInteraction(draw);
+                document.getElementById("drawOptions").innerHTML = "";
             </#if>
             <#if observation.observationData?has_content>
-            observationData = ${observation.observationData};
-            getDataSchema(${observation.organism.organismId}, organizationId);
+                observationData = ${observation.observationData};
+                getDataSchema(${observation.organism.organismId}, organizationId);
             <#elseif observation.organism?has_content>
-            // Setting
-            initObservationData(${observation.organism.organismId}, organizationId);
+                // Setting
+                initObservationData(${observation.organism.organismId}, organizationId);
             </#if>
-
             <#if observation.observationTimeSeries?has_content>
-                displayObservationTimeSeriesInfo("${observation.cropOrganismId}", "${observation.organismId}", "${observation.locationPointOfInterestId}")
+                displayObservationTimeSeriesInfo("${observation.observationTimeSeries.cropOrganismId}", "${observation.observationTimeSeries.organismId}", "${observation.observationTimeSeries.locationPointOfInterestId}")
             </#if>
 
             // Activating file selection
@@ -518,7 +517,7 @@
               role="button">${i18nBundle.back}</a><#if observation.observationId?has_content><a
                     href="/observation?action=newObservationForm" class="btn btn-default"
                     role="button">${i18nBundle.addNew}</a></#if></p>
-        <h1><#if observation.observationId?has_content>${i18nBundle.editObservation}<#else>${i18nBundle.newObservation}</#if><#if shortcut?has_content> - ${shortcut.getLocalLabel(currentLocale.language)?lower_case}</#if></h1>
+        <h1><#if observation.observationId?has_content>${i18nBundle.editObservation}<#else>${i18nBundle.newObservation}</#if><#if observation.observationTimeSeriesId?has_content> ${i18nBundle.forTimeSeries}</#if><#if shortcut?has_content> - ${shortcut.getLocalLabel(currentLocale.language)?lower_case}</#if></h1>
         <div id="errorMsgEl" class="alert alert-danger" <#if !formValidation?has_content> style="display:none;"</#if>>
             <#if formValidation?has_content>${formValidation.validationMessages?replace("\n", "<br>")}</#if>
         </div>
@@ -550,7 +549,7 @@
                     </#if>
                     <#if observation.observationTimeSeries?has_content>
                         <div class="form-group">
-                            <label>${i18nBundle.timeSeries}: ${observation.observationTimeSeries.name}</label><br>
+                            <label>${i18nBundle.timeSeries}: ${observation.observationTimeSeries.name} (${observation.observationTimeSeries.year})</label><br>
                             <i>${i18nBundle.timeSeriesNonEditable}</i>
                         </div>
                         <div class="form-group">
@@ -565,10 +564,26 @@
                             <label for="locationPointOfInterestId">${i18nBundle.location}</label>
                             <span id="locationDisplayName"></span>
                         </div>
-                        <input type="hidden" name="cropOrganismId" value="${observation.cropOrganism.organismId}">
-                        <input type="hidden" name="organismId" value="${observation.organism.organismId}">
-                        <input type="hidden" name="locationPointOfInterestId"
-                               value="${observation.locationPointOfInterestId}">
+                        <div class="form-group">
+                            <label for="locationVisibility">Synlighet</label>
+                            <span id="locationVisibility">
+                                <#if locationVisibilityFormValue == 'public'>
+                                    ${i18nBundle.locationIsPublic}
+                                <#elseif locationVisibilityFormValue == 'private'>
+                                    ${i18nBundle.locationIsPrivate}
+                                <#else>
+                                    <#list polygonServices as polygonService>
+                                        <#if locationVisibilityFormValue == "mask_" + polygonService.polygonServiceId>
+                                            ${i18nBundle.maskObservationWith} ${polygonService.polygonServiceName}
+                                        </#if>
+                                    </#list>
+                                </#if>
+                            </span>
+                        </div>
+                        <input type="hidden" name="observationTimeSeriesId" value="${observation.observationTimeSeries.observationTimeSeriesId}">
+                        <input type="hidden" name="cropOrganismId" value="${observation.observationTimeSeries.cropOrganism.organismId}">
+                        <input type="hidden" name="organismId" value="${observation.observationTimeSeries.organism.organismId}">
+                        <input type="hidden" name="locationPointOfInterestId" value="${observation.observationTimeSeries.locationPointOfInterestId}">
                         <input type="hidden" name="locationVisibility" value="${locationVisibilityFormValue}">
                     <#else>
                         <#if ! observation.organism?has_content>
@@ -681,7 +696,7 @@
                     <#setting time_zone=user.organizationId.defaultTimeZone!"UTC">
                     <div class="form-group">
                         <label for="timeOfObservation">${i18nBundle.timeOfObservation}</label>
-                        <input type="date" class="form-control" id="timeOfObservation" name="timeOfObservation" placeholder="${i18nBundle.timeOfObservation}" value="${observation.timeOfObservation?string("yyyy-MM-dd")!""}" onblur="validateField(this);" <#if editAccess!="W">readonly="readonly"</#if>/>
+                        <input type="date" <#if observation.observationTimeSeries?has_content>min="${minTimeOfObservation?string("yyyy-MM-dd")}" max="${maxTimeOfObservation?string("yyyy-MM-dd")}"</#if> class="form-control" id="timeOfObservation" name="timeOfObservation" placeholder="${i18nBundle.timeOfObservation}" value="${observation.timeOfObservation?string("yyyy-MM-dd")}" onblur="validateField(this);" <#if editAccess!="W">readonly="readonly"</#if>/>
                         <span class="help-block" id="${formId}_timeOfObservation_validation"></span>
                     </div>
                     <div class="form-group">
diff --git a/src/main/webapp/templates/observationList.ftl b/src/main/webapp/templates/observationList.ftl
index 90db0a5a..93845dab 100755
--- a/src/main/webapp/templates/observationList.ftl
+++ b/src/main/webapp/templates/observationList.ftl
@@ -117,22 +117,24 @@
 		<thead>
 			<th>${i18nBundle.timeOfObservation}</th>
 			<th>${i18nBundle.organism}</th>
-                        <th>${i18nBundle.cropOrganismId}</th>
-                        <th>${i18nBundle.location}</th>
-                        <th>${i18nBundle.observer}</th>
-                        <th>${i18nBundle.heading}</th>
+            <th>${i18nBundle.cropOrganismId}</th>
+            <th>${i18nBundle.location}</th>
+            <th>${i18nBundle.observer}</th>
+            <th>${i18nBundle.heading}</th>
+            <th>Tidsserie</th>
 			<th>${i18nBundle.status}</th>
 			<th></th>
 		</thead>
 		<tbody>
 		<#list observations as observation>
 		    <tr>
-		    	<td>${observation.timeOfObservation?string("yyyy-MM-dd HH:mmZ")}</td>
+		    	<td>${observation.timeOfObservation?string("yyyy-MM-dd")}</td>
 		    	<td>${observation.organism.latinName!""}/${observation.organism.getLocalName(currentLocale.language)!""}</td>
-                        <td>${observation.cropOrganism.latinName!""}/${observation.cropOrganism.getLocalName(currentLocale.language)!""}</td>
+                <td>${observation.cropOrganism.latinName!""}/${observation.cropOrganism.getLocalName(currentLocale.language)!""}</td>
 		    	<td><#if observation.location?has_content>${observation.location.name!""}</#if></td>
-                        <td><#if observation.user?has_content>${observation.user.firstName!""} ${observation.user.lastName!""}</#if></td>
-                        <td>${observation.observationHeading}</td>
+                <td><#if observation.user?has_content>${observation.user.firstName!""} ${observation.user.lastName!""}</#if></td>
+                <td>${observation.observationHeading}</td>
+                <td><#if observation.observationTimeSeries?has_content><a href="/observationTimeSeries?action=editObservationTimeSeriesForm&observationTimeSeriesId=${observation.observationTimeSeriesId}">${observation.observationTimeSeries.name}</a></#if></td>
 		    	<td><#switch observation.statusTypeId><#case 1>${i18nBundle.pending}<#break><#case 2>${i18nBundle.rejected}<#break><#case 3>${i18nBundle.approved}</#switch></td>
 		    	<td><#if user.isSuperUser() || user.isOrganizationAdmin() || userIsObservationAuthority || observation.userId == user.userId><a href="/observation?action=editObservationForm&observationId=${observation.observationId}" class="btn btn-default" role="button">${i18nBundle.edit}</a></#if></td>
 		    </tr>
diff --git a/src/main/webapp/templates/observationTimeSeriesForm.ftl b/src/main/webapp/templates/observationTimeSeriesForm.ftl
new file mode 100644
index 00000000..06047181
--- /dev/null
+++ b/src/main/webapp/templates/observationTimeSeriesForm.ftl
@@ -0,0 +1,576 @@
+<#include "master.ftl">
+<#macro page_head>
+    <title><#if observationTimeSeries.observationTimeSeriesId?has_content>${i18nBundle.editObservation}<#else>${i18nBundle.newObservation}</#if></title>
+</#macro>
+<#macro custom_css>
+    <link rel="stylesheet" type="text/css" href="/css/3rdparty/jquery.datetimepicker.css"/>
+    <link rel="stylesheet" type="text/css" href="/css/3rdparty/ol.css?t=20170623"/>
+    <link rel="stylesheet" type="text/css" href="/css/3rdparty/ol-layerswitcher.css"/>
+    <link rel="stylesheet" type="text/css" href="/css/map.css"/>
+</#macro>
+<#macro custom_js>
+<script type="text/javascript" src="/js/3rdparty/modernizr_custom.js"></script>
+<script type="text/javascript" src="/js/3rdparty/moment.min.js"></script>
+<script type="text/javascript" src="/js/3rdparty/jsoneditor.js"></script>
+<script type="text/javascript" src="/js/3rdparty/ol.js?t=20170623"></script>
+<script type="text/javascript" src="js/3rdparty/ol-layerswitcher.js"></script>
+
+<script type="text/javascript" src="/js/constants.js"></script>
+<script type="text/javascript" src="/js/resourcebundle.js"></script>
+<script type="text/javascript" src="/js/validateForm.js"></script>
+<script type="text/javascript" src="/js/util.js"></script>
+<script type="text/javascript" src="/js/objectGISInfoMap.js?t=20170623"></script>
+<script type="text/javascript" src="/js/poiFormMap.js"></script>
+<script>
+    let organizationId = ${user.organizationId.organizationId};
+    let selectedCropId = <#if observationTimeSeries.cropOrganism?has_content>${observationTimeSeries.cropOrganism.organismId?c}<#else>null</#if>;
+
+    $(document).ready(function () {
+        loadFormDefinition("observationTimeSeriesForm");
+        refreshLocationPointOfInterests(<#if observationTimeSeries.locationPointOfInterestId?has_content>${observationTimeSeries.locationPointOfInterestId}<#else>null</#if>);
+        <#if !observationTimeSeries.organism?has_content>
+            initCropCategories();
+        </#if>
+        <#if isEditable?has_content && !isEditable>
+            displayObservationTimeSeriesInfo("${observationTimeSeries.cropOrganismId}", "${observationTimeSeries.organismId}", "${observationTimeSeries.locationPointOfInterestId}")
+        </#if>
+        filterCrops(-1);
+    });
+
+    let thePopup;
+    function addNewLocationPopup() {
+        thePopup = window.open("/poi?action=newPoiForm&organizationId=1&returnIdCallback=refreshLocationPointOfInterests");
+    }
+
+    let allPois = [];
+    function fetchPOIs(callback) {
+        $.getJSON("/rest/poi/user", function (json) {
+            callback(json);
+        });
+    }
+
+    function refreshLocationPointOfInterests(selectedPointOfInterestId) {
+        console.log("Refresh POIs!", selectedPointOfInterestId)
+        const poiTypes = {
+            0: "${i18nBundle.genericPlaces}",
+            2: "${i18nBundle.farms}",
+            3: "${i18nBundle.fields}",
+            5: "${i18nBundle.traps}"
+        };
+        const poiListElement = document.getElementById("locationPointOfInterestId");
+        if (!poiListElement) {
+            return;
+        }
+
+        fetchPOIs(function (allPois) {
+            buildPOIList(poiListElement, allPois, poiTypes, selectedPointOfInterestId);
+            showCorrectMap();
+        });
+    }
+
+    function buildPOIList(poiListElement, allPois, poiTypes, selectedPointOfInterestId) {
+        poiListElement.options.length = 1;
+        for (const [typeId, typeName] of Object.entries(poiTypes)) {
+            poiListElement.options[poiListElement.options.length] = new Option("-- " + typeName + " --", "-1");
+
+            for (let i = 0; i < allPois.length; i++) {
+                const poi = allPois[i];
+
+                if (poi.pointOfInterestTypeId == typeId) {
+                    const poiOption = new Option(poi.name, poi.pointOfInterestId);
+                    if (poi.pointOfInterestId === selectedPointOfInterestId) {
+                        poiOption.selected = true;
+                    }
+                    poiListElement.options[poiListElement.options.length] = poiOption;
+                }
+            }
+        }
+    }
+
+    function initLocationMap(locationPointOfInterestId) {
+        if (locationPointOfInterestId != null) {
+            $.getJSON("/rest/poi/" + locationPointOfInterestId, function (json) {
+                    var poi = json;
+                    if (map === undefined || map === null) {
+                        initMap([poi.longitude, poi.latitude], 10, ${user.organizationId.organizationId}, locationPointOfInterestId, true);
+                    } else {
+                        map.getView().setCenter(ol.proj.transform([poi.longitude, poi.latitude], 'EPSG:4326', map.getView().getProjection().getCode()));
+                        map.getView().setZoom(10);
+                        stationMarker.setPosition(ol.proj.transform([poi.longitude, poi.latitude], 'EPSG:4326', map.getView().getProjection().getCode()));
+                    }
+                }
+            );
+        } else {
+            initMap([${defaultMapCenter.x?c}, ${defaultMapCenter.y?c}], ${defaultMapZoom}, ${user.organizationId.organizationId}, locationPointOfInterestId, true);
+        }
+    }
+
+    function showCorrectMap() {
+        let locationList = document.getElementById("locationPointOfInterestId");
+        if (locationList.options[locationList.options.selectedIndex].value == "-1") {
+            document.getElementById("poiFormMap").style.display = "block";
+            initLocationMap(null);
+        } else {
+            document.getElementById("poiFormMap").style.display = "block";
+            initLocationMap(locationList.options[locationList.options.selectedIndex].value);
+        }
+    }
+
+    /**
+     * Crop, pest and location should not be editable for time series with observations. We avoid displaying
+     * form elements in these cases, and rather display the values statically.
+     */
+    function displayObservationTimeSeriesInfo(cropOrganismId, organismId, locationPointOfInterestId) {
+        console.log("Display info!")
+        document.getElementById("cropDisplayName").innerHTML = nameForCropOrganismId(cropOrganismId);
+        document.getElementById("pestDisplayName").innerHTML = nameForOrganismId(organismId);
+        fetchPOIs(function (allPois) {
+            const locationName = nameForLocationPointOfInterestId(locationPointOfInterestId, allPois);
+            document.getElementById("locationDisplayName").innerHTML = locationName || null;
+        });
+        initLocationMap(locationPointOfInterestId)
+    }
+
+    // Return name for given cropOrganismId
+    function nameForCropOrganismId(cropOrganismId) {
+        const crop = cropList.find(crop => crop.organismId == cropOrganismId);
+        return crop ? crop.displayName : null;
+    }
+
+    // Return name for given organismId
+    function nameForOrganismId(organismId) {
+        const pest = organismList.find(pest => pest.organismId == organismId);
+        return pest ? pest.displayName : null;
+    }
+
+    // Return name for given point of interest id
+    function nameForLocationPointOfInterestId(pointOfInterestId, allPois) {
+        const location = allPois.find(loc => loc.pointOfInterestId == pointOfInterestId);
+        return location ? location.name : null;
+    }
+
+    let cropCategories = [];
+
+    function initCropCategories() {
+        $.getJSON("/rest/organism/cropcategory/${user.organizationId.organizationId}", function (json) {
+                cropCategories = json;
+                renderCropCategories();
+            }
+        );
+    }
+
+    function renderCropCategories() {
+        let cropCategoryIdList = document.getElementById("cropCategoryIdList");
+        console.info("renderCropCategories", cropCategories)
+        for (let i in cropCategories) {
+            let cropCategory = cropCategories[i];
+            // Best effort getting name for crop category
+            let catName = cropCategory.defaultName;
+            for (let j in cropCategory.cropCategoryLocalSet) {
+                let cLocal = cropCategory.cropCategoryLocalSet[j];
+                if (cLocal.cropCategoryLocalPK.locale == "${currentLocale}") {
+                    catName = cLocal.localName;
+                }
+            }
+            let cOption = new Option(catName, cropCategory.cropCategoryId);
+            cropCategoryIdList.options[cropCategoryIdList.options.length] = cOption;
+        }
+    }
+
+    function filterCrops(selectedCropCategoryId) {
+        // Nothing has been selected, render all in one go
+        if (selectedCropCategoryId < 0) {
+            var cropOptions = [];
+            for (var j = 0; j < cropList.length; j++) {
+                //var cropOption = cropOrganismIdList.options[j];
+                var crop = cropList[j];
+                var cropOption = new Option(crop.displayName, crop.organismId);
+                if (selectedCropId != null && selectedCropId == crop.organismId) {
+                    cropOption.selected = true;
+                }
+                cropOptions.push(cropOption);
+
+            }
+            renderCropList(cropOptions, []);
+        } else {
+            // Searching for selected crop category
+            for (var i in cropCategories) {
+                var cropCategory = cropCategories[i];
+                if (cropCategory.cropCategoryId == parseInt(selectedCropCategoryId)) {
+                    // Filter based on match
+                    var matchingCropOrganismOptions = [];
+                    var theRest = [];
+                    //var cropOrganismIdList = document.getElementById("cropOrganismIdList");
+                    for (var j = 0; j < cropList.length; j++) {
+                        //var cropOption = cropOrganismIdList.options[j];
+                        var crop = cropList[j];
+                        var cropOption = new Option(crop.displayName, crop.organismId);
+                        if (selectedCropId != null && selectedCropId == crop.organismId) {
+                            cropOption.selected = true;
+                        }
+                        if (cropCategory.cropOrganismIds.indexOf(crop.organismId) >= 0 && cropCategory.maxHierarchyCategoryId >= crop.hierarchyCategoryId) {
+                            matchingCropOrganismOptions.push(cropOption);
+                        } else if (crop.organismId > 0) {
+                            theRest.push(cropOption);
+                        }
+                    }
+                    renderCropList(matchingCropOrganismOptions, theRest);
+                }
+            }
+        }
+    }
+
+    function renderCropList(matchingCropOrganismOptions, theRest) {
+        var cropOrganismIdList = document.getElementById("cropOrganismIdList");
+        if (!cropOrganismIdList) {
+            // HTML element does not exist for observations in time series
+            return;
+        }
+        cropOrganismIdList.options.length = 1;
+        for (var i in matchingCropOrganismOptions) {
+            cropOrganismIdList.options[cropOrganismIdList.options.length] = matchingCropOrganismOptions[i];
+        }
+        cropOrganismIdList.options[cropOrganismIdList.options.length] = new Option("----", -1);
+        for (var i in theRest) {
+            cropOrganismIdList.options[cropOrganismIdList.options.length] = theRest[i];
+        }
+    }
+
+    var cropList = [
+        <#if ! observationTimeSeries.observationTimeSeriesId?has_content || user.isSuperUser() || user.isOrganizationAdmin()>
+        {organismId: -10, displayName: "${i18nBundle.missingInDatabase}", hierarchyCategoryId: -1},
+        <#list allCrops as cropOrganism>
+        {
+            organismId: ${cropOrganism.organismId?c},
+            displayName: "${cropOrganism.getLocalName(currentLocale.language)!""} (${cropOrganism.latinName!""}) ${hierarchyCategories.getName(cropOrganism.hierarchyCategoryId)?upper_case}",
+            hierarchyCategoryId: ${cropOrganism.hierarchyCategoryId!"-1"}
+        }<#sep>,
+        </#list>
+        <#else>
+        <#list allCrops as cropOrganism>
+        <#if (observationTimeSeries.cropOrganism?has_content && observationTimeSeries.cropOrganism.organismId == cropOrganism.organismId)>
+        {
+            organismId: ${cropOrganism.organismId},
+            displayName: "${cropOrganism.getLocalName(currentLocale.language)!""} (${cropOrganism.latinName!""}) ${hierarchyCategories.getName(cropOrganism.hierarchyCategoryId)?upper_case}",
+            hierarchyCategoryId: ${cropOrganism.hierarchyCategoryId}
+        }
+        </#if>
+        </#list>
+        </#if>
+    ];
+
+    var organismList = [
+        <#list allPests as organism>
+        {
+            organismId: ${organism.organismId},
+            displayName: "${organism.getLocalName(currentLocale.language)!""} (${organism.latinName!""})"
+        },
+        </#list>
+    ];
+
+    var updateCropPests = function () {
+        var theForm = document.getElementById('observationTimeSeriesForm');
+        var selectedCropId = theForm["cropOrganismId"].options[theForm["cropOrganismId"].options.selectedIndex].value;
+        // If this is not a new observation, or the selected crop is not in the database, we keep calm
+        if (theForm["observationTimeSeriesId"].value !== "-1" || selectedCropId == "-10") {
+            return;
+        }
+        $.getJSON("/rest/organism/croppest/" + selectedCropId, function (json) {
+            updateCropPestsCallback(json);
+        })
+            .fail(function () {
+                alert("Error getting pests for this crop. Please contact system administrator");
+            });
+    };
+
+    var updateCropPestsCallback = function (cropPest) {
+        var pestList = document.getElementById('observationTimeSeriesForm')["organismId"];
+        if (cropPest == null) {
+            // Need to reorganize pests back to default
+            var allPests = [];
+            for (var i = 2; i < pestList.options.length; i++) {
+                allPests.push(pestList.options[i]);
+            }
+            allPests.sort(compareSelectListOptions);
+            pestList.options.length = 2; // Keeping the top two
+            for (var i = 0; i < allPests.length; i++) {
+                pestList.options[pestList.options.length] = allPests[i];
+            }
+        } else {
+            var prioritatedPests = [];
+            var unprioritatedPests = []
+            for (var i = 2; i < pestList.options.length; i++) {
+                if (cropPest.pestOrganismIds.indexOf(parseInt(pestList.options[i].value)) >= 0) {
+                    //console.log(pestList.options[i].value + " is prioritated");
+                    prioritatedPests.push(pestList.options[i]);
+                } else if (pestList.options[i].value != "-1") // Avoiding the "---" option
+                {
+                    //console.log(pestList.options[i].value + " is unprioritated");
+                    unprioritatedPests.push(pestList.options[i]);
+                }
+
+            }
+            pestList.options.length = 2; // Keeping the top two
+            for (var i = 0; i < prioritatedPests.length; i++) {
+                pestList.options[pestList.options.length] = prioritatedPests[i];
+            }
+            pestList.options[pestList.options.length] = new Option("---", "-1");
+            for (var i = 0; i < unprioritatedPests.length; i++) {
+                pestList.options[pestList.options.length] = unprioritatedPests[i];
+            }
+        }
+    };
+
+    function prepareFormSubmit(theForm) {
+        // Extract GIS info from OpenLayers
+        theForm['geoInfo'].value = getFeatures();
+        try {
+            // If the form is quantified: Inspect the fields and write
+            // JSON string to the generic form field "observationData";
+            if (theForm.isQuantified.checked) {
+                const errors = editor.validate();
+                if (errors.length) {
+                    alert(errors);
+                    return false;
+                }
+                theForm['observationData'].value = JSON.stringify(editor.getValue());
+            }
+            validateGIS(theForm);
+            //console.info('validateGIS = ' + (validateGIS(this)));
+            //return false; // DEBUG setting
+            return validateForm(theForm) && validateGIS(theForm); // PROD setting
+        } catch (e) {
+            console.log(e.message);
+            console.log(e);
+            return false;
+        }
+    }
+</script>
+
+</#macro>
+
+<#macro page_contents>
+<div class="singleBlockContainer">
+    <p><a href="/observationTimeSeries" class="btn btn-default back" role="button">${i18nBundle.back}</a><#if observationTimeSeries.observationTimeSeriesId?has_content><a href="/observation?action=newObservationForm&observationTimeSeriesId=${observationTimeSeries.observationTimeSeriesId}" class="btn btn-default" role="button">${i18nBundle.addNewObservationInTimeSeries}</a></#if></p>
+    <h1><#if observationTimeSeries.observationTimeSeriesId?has_content>${i18nBundle.editObservationTimeSeries}<#else>${i18nBundle.newObservationTimeSeries}</#if><#if shortcut?has_content> - ${shortcut.getLocalLabel(currentLocale.language)?lower_case}</#if></h1>
+    <p><i>Kultur, organisme, år og sted kan ikke redigeres for tidsserier som inneholder observasjoner.</i></p>
+    <div id="errorMsgEl" class="alert alert-danger" <#if !formValidation?has_content> style="display:none;"</#if>>
+        <#if formValidation?has_content>${formValidation.validationMessages?replace("\n", "<br>")}</#if>
+    </div>
+    <#if messageKey?has_content>
+        <div class="alert alert-success">${i18nBundle(messageKey)}</div>
+    </#if>
+    <div class="row">
+        <div class="col-md-6">
+            <#assign formId = "observationTimeSeriesForm">
+            <form id="${formId}" role="form" action="/observationTimeSeries?action=observationTimeSeriesFormSubmit" enctype="multipart/form-data" method="POST" onsubmit="return prepareFormSubmit(this);">
+                <input type="hidden" name="geoInfo" value=""/>
+                <input type="hidden" name="observationTimeSeriesId" value="${observationTimeSeries.observationTimeSeriesId!"-1"}"/>
+                <#if observationTimeSeries.user?has_content>
+                    <div class="form-group">
+                        <label>Registrert av</label>
+                        <span>${observationTimeSeries.user.firstName} ${observationTimeSeries.user.lastName}</span>
+                    </div>
+                </#if>
+                <#if observationTimeSeries.lastModifiedByUser?has_content>
+                    <div class="form-group">
+                        <label>${i18nBundle.lastEditedBy}</label>
+                        <span>${observationTimeSeries.lastModifiedByUser.firstName} ${observationTimeSeries.lastModifiedByUser.lastName}</span>
+                    </div>
+                </#if>
+                <#if isEditable?has_content && !isEditable>
+                    <div class="form-group">
+                        <label for="cropDisplayName">${i18nBundle.cropOrganismId}</label>
+                        <span id="cropDisplayName"></span>
+                    </div>
+                    <div class="form-group">
+                        <label for="pestDisplayName">${i18nBundle.organism}</label>
+                        <span id="pestDisplayName"></span>
+                    </div>
+                    <div class="form-group">
+                        <label>År</label> ${observationTimeSeries.year}
+                    </div>
+                    <div class="form-group">
+                        <label for="locationDisplayName">${i18nBundle.location}</label>
+                        <span id="locationDisplayName"></span>
+                    </div>
+                    <div class="form-group">
+                        <label for="locationVisibility">Synlighet</label>
+                        <span id="locationVisibility">
+                            <#if locationVisibilityFormValue == 'public'>
+                                ${i18nBundle.locationIsPublic}
+                            <#elseif locationVisibilityFormValue == 'private'>
+                                ${i18nBundle.locationIsPrivate}
+                            <#else>
+                                <#list polygonServices as polygonService>
+                                    <#if locationVisibilityFormValue == "mask_" + polygonService.polygonServiceId>
+                                        ${i18nBundle.maskObservationWith} ${polygonService.polygonServiceName}
+                                    </#if>
+                                </#list>
+                            </#if>
+                        </span>
+                    </div>
+                    <input type="hidden" name="observationTimeSeriesId" value="${observationTimeSeries.observationTimeSeriesId}">
+                    <input type="hidden" name="year" value="${observationTimeSeries.year}">
+                    <input type="hidden" name="cropOrganismId" value="${observationTimeSeries.cropOrganism.organismId}">
+                    <input type="hidden" name="organismId" value="${observationTimeSeries.organism.organismId}">
+                    <input type="hidden" name="locationPointOfInterestId" value="${observationTimeSeries.locationPointOfInterestId}">
+                    <input type="hidden" name="locationVisibility" value="${locationVisibilityFormValue}">
+                <#else>
+                    <div class="form-group">
+                        <label for="cropCategoryId">${i18nBundle.listSelectedCropCategoryOnTop}</label>
+                        <select class="form-control" id="cropCategoryIdList" name="cropCategoryId"
+                                onchange="filterCrops(this.options[this.options.selectedIndex].value);">
+                            <option value="-1">${i18nBundle.pleaseSelect} ${i18nBundle.cropCategory?lower_case}</option>
+                            <!-- Options added by JavaScript function renderCropCategories() -->
+                        </select>
+                    </div>
+                    <div class="form-group">
+                        <label for="cropOrganismId">${i18nBundle.cropOrganismId}</label>
+                        <select class="form-control" id="cropOrganismIdList" name="cropOrganismId"
+                                <#if observationTimeSeries.observationTimeSeriesId?has_content && !user.isSuperUser() && !user.isOrganizationAdmin()>readonly="readonly" <#else> onblur="validateField(this);" onchange="updateCropPests();"</#if>>
+                            <#if !observationTimeSeries.observationTimeSeriesId?has_content || user.isSuperUser() || user.isOrganizationAdmin()>
+                                <option value="-1">${i18nBundle.pleaseSelect} ${i18nBundle.cropOrganismId?lower_case}</option>
+                                <option value="-10"
+                                        <#if (observationTimeSeries.cropOrganism?has_content && observationTimeSeries.cropOrganism.organismId == -10)>selected="selected"</#if>>${i18nBundle.missingInDatabase}</option>
+                            </#if>
+                        </select>
+                        <span class="help-block" id="${formId}_cropOrganismId_validation"></span>
+                    </div>
+                    <div class="form-group">
+                        <label for="organismId">${i18nBundle.organism}</label>
+                        <select class="form-control" id="organismId" name="organismId"
+                                <#if observationTimeSeries.organism?has_content && ! user.isSuperUser() && ! user.isOrganizationAdmin()>readonly="readonly"
+                                onblur="validateField(this);"</#if>>
+                            <#if !observationTimeSeries.organism?has_content>
+                                <option value="-1">${i18nBundle.pleaseSelect} ${i18nBundle.organism?lower_case}</option>
+                                <option value="-10"
+                                        <#if (observationTimeSeries.organism?has_content && observationTimeSeries.organism.organismId == -10)>selected="selected"</#if>>${i18nBundle.missingInDatabase}</option>
+                                <#list allPests as organism>
+                                    <option value="${organism.organismId}">${organism.getLocalName(currentLocale.language)!""} (${organism.latinName!""}) ${hierarchyCategories.getName(organism.hierarchyCategoryId)?upper_case}</option>
+                                </#list>
+                            <#else>
+                                <#list allPests as organism>
+                                    <option value="${organism.organismId}" <#if (observationTimeSeries.organism?has_content && observationTimeSeries.organism.organismId == organism.organismId)>selected="selected"</#if>>${organism.getLocalName(currentLocale.language)!""}
+                                        (${organism.latinName!""}
+                                        ) ${hierarchyCategories.getName(organism.hierarchyCategoryId)?upper_case}</option>
+                                </#list>
+                            </#if>
+                        </select>
+                        <span class="help-block" id="${formId}_organismId_validation"></span>
+                    </div>
+
+                    <div class="form-group">
+                        <label for="yearList">${i18nBundle.year}</label>
+                        <select class="form-control" id="yearList" name="year">
+                            <#if !observationTimeSeries.observationTimeSeriesId?has_content>
+                                <option value="-1">${i18nBundle.pleaseSelect} ${i18nBundle.year?lower_case}</option>
+                            </#if>
+                            <#list years as year>
+                                <option value="${year}" <#if observationTimeSeries.observationTimeSeriesId?has_content && observationTimeSeries.year == year>selected</#if>>${year}</option>
+                            </#list>
+                        </select>
+                    </div>
+                    <div class="form-group">
+                        <label for="locationPointOfInterestId">${i18nBundle.location}&nbsp;&nbsp;<button
+                                    role="button" type="button"
+                                    onclick="addNewLocationPopup();">${i18nBundle.addNew}</button>
+                        </label>
+                        <select class="form-control" name="locationPointOfInterestId" id="locationPointOfInterestId" onchange="showCorrectMap();">
+                            <option value="-1">${i18nBundle.pleaseSelect} ${i18nBundle.location?lower_case}</option>
+                        </select>
+                        <span class="help-block" id="${formId}_locationPointOfInterestId_validation"></span>
+                    </div>
+                    <div class="form-group">
+                        <div class="radio">
+                            <label>
+                                <input type="radio" name="locationVisibility" value="public"
+                                       <#if locationVisibilityFormValue == "public">checked="checked"</#if>
+                                />
+                            </label>
+                            ${i18nBundle.locationIsPublic}
+                        </div>
+                        <div class="radio">
+
+                            <label>
+                                <input type="radio" name="locationVisibility" value="private"
+                                       <#if locationVisibilityFormValue == "private">checked="checked"</#if>
+                                />
+                            </label>
+                            ${i18nBundle.locationIsPrivate}
+                        </div>
+                        <#list polygonServices as polygonService>
+                            <div class="radio">
+                                <label>
+                                    <input type="radio" name="locationVisibility"
+                                           value="mask_${polygonService.polygonServiceId}"
+                                           <#if locationVisibilityFormValue == "mask_" + polygonService.polygonServiceId>checked="checked"</#if>
+                                    />
+                                    ${i18nBundle.maskObservationWith} ${polygonService.polygonServiceName}
+                                </label>
+                            </div>
+                        </#list>
+                    </div>
+
+                </#if>
+                <div class="form-group">
+                    <label for="name">${i18nBundle.observationTimeSeriesName}</label>
+                    <input type="text" class="form-control" id="name" name="name" placeholder=""
+                           value="${observationTimeSeries.name!""}" onblur="validateField(this);" />
+                    <span class="help-block" id="${formId}_name_validation"></span>
+                </div>
+                <div class="form-group">
+                    <label for="description">${i18nBundle.observationTimeSeriesDescription}</label>
+                    <textarea class="form-control" id="description" name="description" placeholder="">${observationTimeSeries.description!""}</textarea>
+                    <span class="help-block" id="${formId}_description_validation"></span>
+                </div>
+                <button type="submit" class="btn btn-default">${i18nBundle.submit}</button>
+                <#if observationTimeSeries.observationTimeSeriesId?has_content>
+                    <button type="button" class="btn btn-danger"
+                            onclick="if(confirm('${i18nBundle.confirmDelete}')){window.location.href='/observationTimeSeries?action=deleteObservationTimeSeries&observationTimeSeriesId=${observationTimeSeries.observationTimeSeriesId}';}">${i18nBundle.delete}</button>
+                </#if>
+
+            </form>
+        </div>
+        <div class="col-md-6">
+            <div id="poiFormMap" class="map" style="height: 400px;">
+                <div id="popover"></div>
+            </div>
+        </div>
+    </div>
+    <#if observations?has_content>
+        <h2>Registrerte observasjoner</h2>
+        <div class="row mt-3">
+            <div class="table-responsive">
+                <table class="table table-striped">
+                    <thead>
+                    <th>${i18nBundle.timeOfObservation}</th>
+                    <th>${i18nBundle.cropOrganismId}</th>
+                    <th>${i18nBundle.organism}</th>
+                    <th>${i18nBundle.location}</th>
+                    <th>${i18nBundle.observer}</th>
+                    <th>${i18nBundle.heading}</th>
+                    <th>${i18nBundle.status}</th>
+                    <th></th>
+                    </thead>
+                    <tbody>
+                    <#list observations as observation>
+                        <tr>
+                            <td>${observation.timeOfObservation?string("yyyy-MM-dd")}</td>
+                            <td>${observation.cropOrganism.latinName!""}/${observation.cropOrganism.getLocalName(currentLocale.language)!""}</td>
+                            <td>${observation.organism.latinName!""}/${observation.organism.getLocalName(currentLocale.language)!""}</td>
+                            <td><#if observation.location?has_content>${observation.location.name!""}</#if></td>
+                            <td><#if observation.user?has_content>${observation.user.firstName!""} ${observation.user.lastName!""}</#if></td>
+                            <td>${observation.observationHeading}</td>
+                            <td><#switch observation.statusTypeId><#case 1>${i18nBundle.pending}<#break><#case 2>${i18nBundle.rejected}<#break><#case 3>${i18nBundle.approved}</#switch></td>
+                            <td><#if user.isSuperUser() || user.isOrganizationAdmin() || userIsObservationAuthority || observation.userId == user.userId><a href="/observation?action=editObservationForm&observationId=${observation.observationId}" class="btn btn-default" role="button">${i18nBundle.edit}</a></#if></td>
+                        </tr>
+                    </#list>
+                    </tbody>
+                </table>
+            </div>
+        </div>
+    </#if>
+</div>
+</#macro>
+<@page_html/>
diff --git a/src/main/webapp/templates/observationTimeSeriesList.ftl b/src/main/webapp/templates/observationTimeSeriesList.ftl
new file mode 100644
index 00000000..6a496e8a
--- /dev/null
+++ b/src/main/webapp/templates/observationTimeSeriesList.ftl
@@ -0,0 +1,57 @@
+<#include "master.ftl">
+<#setting time_zone=user.organizationId.defaultTimeZone!"UTC">
+<#macro page_head>
+    <title>${i18nBundle.timeSeriesList}</title>
+</#macro>
+<#macro custom_css>
+    <link href="/css/3rdparty/chosen.min.css" rel="stylesheet"/>
+</#macro>
+<#macro custom_js>
+    <script type="text/javascript" src="/js/3rdparty/chosen.jquery.min.js"></script>
+    <script type="text/javascript" src="/js/3rdparty/jquery.datetimepicker.js"></script>
+</#macro>
+<#macro page_contents>
+    <div class="singleBlockContainer">
+        <h1>${i18nBundle.timeSeriesList}</h1>
+        <#if messageKey?has_content>
+            <div class="alert alert-success">${i18nBundle(messageKey)}</div>
+        </#if>
+        <a href="/observationTimeSeries?action=newObservationTimeSeriesForm" class="btn btn-default" role="button">${i18nBundle.addNew}</a>
+
+        <div class="table-responsive">
+            <table class="table table-striped">
+                <thead>
+                <th>Tittel</th>
+                <th>År</th>
+                <th>${i18nBundle.cropOrganismId}</th>
+                <th>${i18nBundle.organism}</th>
+                <th>${i18nBundle.location}</th>
+                <th>${i18nBundle.observer}</th>
+                <th>${i18nBundle.observationCount}</th>
+                <th></th>
+                </thead>
+                <tbody>
+                <#list observationTimeSeriesList as timeSeries>
+                    <tr>
+                        <td>${timeSeries.name}</td>
+                        <td>${timeSeries.year}</td>
+                        <td>${timeSeries.cropOrganism.latinName!""}
+                            /${timeSeries.cropOrganism.getLocalName(currentLocale.language)!""}</td>
+                        <td>${timeSeries.organism.latinName!""}
+                            /${timeSeries.organism.getLocalName(currentLocale.language)!""}</td>
+                        <td><#if timeSeries.locationPointOfInterest?has_content>${timeSeries.locationPointOfInterest.name!""}</#if></td>
+                        <td><#if timeSeries.user?has_content>${timeSeries.user.firstName!""} ${timeSeries.user.lastName!""}</#if></td>
+                        <td>${observationCounts[timeSeries.observationTimeSeriesId?string]}</td>
+                        <td><#if user.isSuperUser() || user.isOrganizationAdmin() || userIsObservationAuthority || observation.userId == user.userId>
+                                <a
+                                href="/observationTimeSeries?action=editObservationTimeSeriesForm&observationTimeSeriesId=${timeSeries.observationTimeSeriesId}"
+                                class="btn btn-default" role="button">${i18nBundle.edit}</a></#if></td>
+                    </tr>
+                </#list>
+                </tbody>
+            </table>
+        </div>
+
+    </div>
+</#macro>
+<@page_html/>
-- 
GitLab