diff --git a/pom.xml b/pom.xml
index 43ddcb1c1ac15097a54958a389ea93245a0ee2ea..1b18ec74e849d188a3f1add271dda6cb60e555e7 100755
--- a/pom.xml
+++ b/pom.xml
@@ -209,7 +209,7 @@
         <dependency>
             <groupId>no.nibio.vips</groupId>
             <artifactId>VIPSCommon</artifactId>
-            <version>2.0.3-SNAPSHOT</version>
+            <version>2.1.0</version>
         </dependency>
         <dependency>
             <groupId>javax</groupId>
diff --git a/src/main/java/no/nibio/vips/logic/VIPSLogicApplication.java b/src/main/java/no/nibio/vips/logic/VIPSLogicApplication.java
index a2d59c2e342bea1b638b1cd3ef8fcb1c610369f3..68256d9a181b43c79ccd34f668c9cf7560e7c4ea 100755
--- a/src/main/java/no/nibio/vips/logic/VIPSLogicApplication.java
+++ b/src/main/java/no/nibio/vips/logic/VIPSLogicApplication.java
@@ -55,8 +55,8 @@ public class VIPSLogicApplication extends Application
         resources.add(no.nibio.vips.logic.messaging.sms.SMSHandlingService.class);
         resources.add(no.nibio.vips.logic.modules.applefruitmoth.AppleFruitMothService.class);
         resources.add(no.nibio.vips.logic.service.ObservationService.class);
+        resources.add(no.nibio.vips.logic.service.ObservationTimeSeriesService.class);
         resources.add(no.nibio.vips.logic.service.ModelFormService.class);
-        
         resources.add(no.nibio.vips.logic.service.JacksonConfig.class);
         //resources.add(no.nibio.vips.logic.service.JSONBConfig.class);
         //resources.add(no.nibio.vips.coremanager.service.ManagerResourceImpl.class);
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 212388ab9961fb819023385ac5fe2c9c947a4953..64bbec3af66d153652142c9ff242c7b18e556c32 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
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2018 NIBIO <http://www.nibio.no/>. 
+ * Copyright (c) 2018 NIBIO <http://www.nibio.no/>.
  *
  * 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
@@ -26,7 +26,7 @@ import com.ibm.icu.util.ULocale;
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Files;
-import java.nio.file.Paths; 
+import java.nio.file.Paths;
 import java.nio.file.Path;
 import java.nio.file.StandardOpenOption;
 import java.util.ArrayList;
@@ -49,18 +49,8 @@ import javax.persistence.NoResultException;
 import javax.persistence.PersistenceContext;
 import javax.persistence.Query;
 import javax.servlet.http.HttpServletRequest;
-import no.nibio.vips.logic.entity.CropCategory;
-import no.nibio.vips.logic.entity.Gis;
-import no.nibio.vips.logic.entity.Observation;
-import no.nibio.vips.logic.entity.ObservationFormShortcut;
-import no.nibio.vips.logic.entity.ObservationIllustration;
-import no.nibio.vips.logic.entity.ObservationIllustrationPK;
-import no.nibio.vips.logic.entity.ObservationStatusType;
-import no.nibio.vips.logic.entity.Organism;
-import no.nibio.vips.logic.entity.Organization;
-import no.nibio.vips.logic.entity.PointOfInterest;
-import no.nibio.vips.logic.entity.PolygonService;
-import no.nibio.vips.logic.entity.VipsLogicUser;
+
+import no.nibio.vips.logic.entity.*;
 import no.nibio.vips.logic.i18n.SessionLocaleUtil;
 import no.nibio.vips.logic.util.SystemTime;
 import org.apache.commons.codec.binary.Base64;
@@ -75,178 +65,161 @@ import org.wololo.geojson.FeatureCollection;
 import org.wololo.geojson.GeoJSONFactory;
 
 /**
- * @copyright 2014-2020 <a href="http://www.nibio.no/">NIBIO</a>
  * @author Tor-Einar Skog <tor-einar.skog@nibio.no>
+ * @copyright 2014-2020 <a href="http://www.nibio.no/">NIBIO</a>
  */
 @Stateless
 public class ObservationBean {
-    @PersistenceContext(unitName="VIPSLogic-PU")
+    private static Logger LOGGER = LoggerFactory.getLogger(ObservationBean.class);
+    @PersistenceContext(unitName = "VIPSLogic-PU")
     EntityManager em;
-    
     @EJB
     PointOfInterestBean pointOfInterestBean;
     @EJB
     UserBean userBean;
 
-    private static Logger LOGGER = LoggerFactory.getLogger(ObservationBean.class);
-    
-    public List<Observation> getObservations(Integer organizationId)
-    {
-        Organization organization = em.find(Organization.class,organizationId);
+    public List<Observation> getObservations(Integer organizationId) {
+        Organization organization = em.find(Organization.class, organizationId);
         List<Observation> observations = em.createNamedQuery("Observation.findByOrganizationId")
                 .setParameter("organizationId", organization)
                 .getResultList();
-        
+
         observations = this.getObservationsWithGeoInfo(observations);
         observations = this.getObservationsWithLocations(observations);
         observations = this.getObservationsWithObservers(observations);
-        
-        return observations; 
+
+        return observations;
     }
-     
-    public List<Observation> getObservations(Integer organizationId, Date periodStart, Date periodEnd)
-    {
-        Organization organization = em.find(Organization.class,organizationId);
+
+    public List<Observation> getObservations(Integer organizationId, Date periodStart, Date periodEnd) {
+        Organization organization = em.find(Organization.class, organizationId);
         List<Observation> observations = em.createNamedQuery("Observation.findByOrganizationIdAndPeriod")
                 .setParameter("organizationId", organization)
                 .setParameter("start", periodStart)
                 .setParameter("end", periodEnd)
                 .getResultList();
-        
+
         observations = this.getObservationsWithGeoInfo(observations);
         observations = this.getObservationsWithLocations(observations);
         observations = this.getObservationsWithObservers(observations);
-        
+
         return observations;
     }
-    
-    public List<Observation> getObservations(Integer organizationId, Integer statusTypeId)
-    {
-        Organization organization= em.find(Organization.class, organizationId);
+
+    public List<Observation> getObservations(Integer organizationId, Integer statusTypeId) {
+        Organization organization = em.find(Organization.class, organizationId);
         /*List<VipsLogicUser> users = em.createNamedQuery("VipsLogicUser.findByOrganizationId", VipsLogicUser.class)
                                         .setParameter("organizationId", organization)
                                         .getResultList();*/
-        
+
         List<Observation> retVal = this.getObservationsWithGeoInfo(em.createNamedQuery("Observation.findByOrganizationIdAndStatusTypeId")
                 .setParameter("organizationId", organization)
                 .setParameter("statusTypeId", statusTypeId)
                 .getResultList());
-        
+
         return retVal;
     }
-    
-    public List<Observation> getObservationsForUser(VipsLogicUser user)
-    {
+
+    public List<Observation> getObservationsForUser(VipsLogicUser user) {
         List<Observation> retVal = this.getObservationsWithGeoInfo(em.createNamedQuery("Observation.findByUserId")
                 .setParameter("userId", user.getUserId())
                 .getResultList());
-        
+
         retVal = this.getObservationsWithLocations(retVal);
         retVal = this.getObservationsWithObservers(retVal);
-        
+
         return retVal;
     }
-    
+
     public List<Observation> getObservationsLastEditedByUser(VipsLogicUser user) {
         List<Observation> retVal = this.getObservationsWithGeoInfo(em.createNamedQuery("Observation.findByLastEditedBy")
                 .setParameter("lastEditedBy", user.getUserId())
                 .getResultList());
-        
+
         retVal = this.getObservationsWithLocations(retVal);
         retVal = this.getObservationsWithObservers(retVal);
-        
+
         return retVal;
     }
-    
+
     public List<Observation> getObservationsStatusChangedByUser(VipsLogicUser user) {
         List<Observation> retVal = this.getObservationsWithGeoInfo(em.createNamedQuery("Observation.findByStatusChangedByUserId")
                 .setParameter("statusChangedByUserId", user.getUserId())
                 .getResultList());
-        
+
         retVal = this.getObservationsWithLocations(retVal);
         retVal = this.getObservationsWithObservers(retVal);
-        
+
         return retVal;
     }
-    
-    public List<Observation> getObservationsForUser(VipsLogicUser user, Date periodStart, Date periodEnd)
-    {
+
+    public List<Observation> getObservationsForUser(VipsLogicUser user, Date periodStart, Date periodEnd) {
         List<Observation> retVal = this.getObservationsWithGeoInfo(em.createNamedQuery("Observation.findByUserIdAndPeriod")
                 .setParameter("userId", user.getUserId())
                 .setParameter("start", periodStart)
                 .setParameter("end", periodEnd)
                 .getResultList());
-        
+
         retVal = this.getObservationsWithLocations(retVal);
         retVal = this.getObservationsWithObservers(retVal);
-        
+
         return retVal;
     }
-    
-    public List<Observation> getObservationsForUser(VipsLogicUser user, Integer statusTypeId)
-    {
+
+    public List<Observation> getObservationsForUser(VipsLogicUser user, Integer statusTypeId) {
         List<Observation> retVal = this.getObservationsWithGeoInfo(em.createNamedQuery("Observation.findByUserIdAndStatusTypeId")
                 .setParameter("userId", user.getUserId())
                 .setParameter("statusTypeId", statusTypeId)
                 .getResultList());
-        
+
         return retVal;
     }
-    
-    public Observation getObservation(Integer observationId)
-    {
+
+    public Observation getObservation(Integer observationId) {
         Observation retVal = em.find(Observation.class, observationId);
-        if(retVal != null)
-        {
+        if (retVal != null) {
             retVal.setGeoinfos(this.getGeoinfoForObservation(retVal));
             retVal.setUser(em.find(VipsLogicUser.class, retVal.getUserId()));
-            if(retVal.getLastEditedBy() != null)
-            {
+            if (retVal.getLastEditedBy() != null) {
                 retVal.setLastEditedByUser(em.find(VipsLogicUser.class, retVal.getLastEditedBy()));
             }
         }
         return retVal;
     }
-    
-    public List<Gis> getGeoinfoForObservation(Observation obs)
-    {
+
+    public List<Gis> getGeoinfoForObservation(Observation obs) {
         List<Integer> gisIds = em.createNativeQuery("SELECT gis_id FROM public.gis_observation WHERE observation_id = :observationId")
                 .setParameter("observationId", obs.getObservationId())
                 .getResultList();
-        
-        if(gisIds != null && ! gisIds.isEmpty())
-        {
-            return em.createNamedQuery("Gis.findByGisIds",Gis.class)
+
+        if (gisIds != null && !gisIds.isEmpty()) {
+            return em.createNamedQuery("Gis.findByGisIds", Gis.class)
                     .setParameter("gisIds", gisIds)
                     .getResultList();
-        }
-        else
-        {
+        } else {
             return null;
         }
     }
-    
-    public List<Observation> getObservationsWithGeoInfo(List<Observation> observations)
-    {
-        if(observations.isEmpty())
-        {
+
+    public List<Observation> getObservationsWithGeoInfo(List<Observation> observations) {
+        if (observations.isEmpty()) {
             return observations;
         }
-        
+
         // Using this method as opposed to db query for each observation, we 
         // slim the time down from 24 seconds to 270 milliseconds! DB connections are expensive....
-        
+
         // Indexing observations
         Map<Integer, Observation> obsBucket = new HashMap<>();
         observations.stream().forEach((obs) -> {
             obsBucket.put(obs.getObservationId(), obs);
         });
-        
+
         // Getting many-to-many relations
         Query q = em.createNativeQuery("SELECT gis_id, observation_id FROM public.gis_observation WHERE observation_id IN :observationIds");
         q.setParameter("observationIds", obsBucket.keySet());
         List<Object[]> gisObservationIds = q.getResultList();
-        
+
         // Collecting and indexing geoInfo
         Query q2 = em.createNativeQuery("SELECT * FROM public.gis WHERE gis_id IN (SELECT gis_id FROM public.gis_observation WHERE observation_id IN :observationIds)", Gis.class);
         List<Gis> geoInfos = q2.setParameter("observationIds", obsBucket.keySet()).getResultList();
@@ -254,7 +227,7 @@ public class ObservationBean {
         geoInfos.stream().forEach((geoinfo) -> {
             gisBucket.put(geoinfo.getGisId(), geoinfo);
         });
-        
+
         // Iterating the many-to-many relations,
         // adding geoinfo to the correct observations
         gisObservationIds.stream().forEach((gisObsIds) -> {
@@ -262,12 +235,11 @@ public class ObservationBean {
             Integer observationId = (Integer) gisObsIds[1];
             obsBucket.get(observationId).addGeoInfo(gisBucket.get(gisId));
         });
-        
+
         return observations;
     }
 
     /**
-     * 
      * @param observation
      * @return The merged object
      */
@@ -283,14 +255,13 @@ public class ObservationBean {
                 .setParameter("observationId", retVal.getObservationId())
                 .executeUpdate();
         // Then persist the new ones
-        if(observation.getGeoinfos() != null && ! observation.getGeoinfos().isEmpty())
-        {
+        if (observation.getGeoinfos() != null && !observation.getGeoinfos().isEmpty()) {
             observation.getGeoinfos().stream().forEach((gis) -> {
                 em.persist(gis);
             });
-            
+
             Query q = em.createNativeQuery("INSERT INTO public.gis_observation(gis_id,observation_id) VALUES(:gisId,:observationId)")
-                .setParameter("observationId", retVal.getObservationId());
+                    .setParameter("observationId", retVal.getObservationId());
             observation.getGeoinfos().stream().forEach((gis) -> {
                 q.setParameter("gisId", gis.getGisId())
                         .executeUpdate();
@@ -304,54 +275,49 @@ public class ObservationBean {
             em.persist(gis);
             gises.add(gis);
         }*/
-        
+
         // The GisObservations are not included in the merged object, so we should add them
         retVal.setGeoinfos(this.getGeoinfoForObservation(retVal));
-        
+
         return retVal;
     }
 
     public void deleteObservation(Integer observationId) {
         Observation observation = em.find(Observation.class, observationId);
-        if(observation != null)
-        {
-        	// Delete all current group memberships
+        if (observation != null) {
+            // Delete all current group memberships
             em.createNativeQuery("DELETE FROM public.organization_group_observation WHERE observation_id=:observationId")
                     .setParameter("observationId", observation.getObservationId())
                     .executeUpdate();
-            
+
             // Delete all illustrations (including removing files on disk)
             String[] filesToDelete = observation.getObservationIllustrationSet().stream()
-            		.map(ill->ill.getObservationIllustrationPK().getFileName())
-            		.collect(Collectors.toList())
-            		.toArray(new String[0]);
+                    .map(ill -> ill.getObservationIllustrationPK().getFileName())
+                    .collect(Collectors.toList())
+                    .toArray(new String[0]);
             this.deleteObservationIllustration(observation, filesToDelete);
             em.remove(observation);
         }
     }
 
     /**
-     * 
      * @param organizationId
      * @param season
-     * @return 
+     * @return
      */
     public List<Observation> getBroadcastObservations(Integer organizationId, Integer season) {
-        Organization organization= em.find(Organization.class, organizationId);
+        Organization organization = em.find(Organization.class, organizationId);
         /*List<VipsLogicUser> users = em.createNamedQuery("VipsLogicUser.findByOrganizationId", VipsLogicUser.class)
                                         .setParameter("organizationId", organization)
         
         .getResultList();*/
         List<Observation> retVal = null;
-        if(season == null)
-        {
-             retVal = this.getObservationsWithGeoInfo(em.createNamedQuery("Observation.findByOrganizationIdAndStatusTypeIdAndBroadcastMessage")
+        if (season == null) {
+            retVal = this.getObservationsWithGeoInfo(em.createNamedQuery("Observation.findByOrganizationIdAndStatusTypeIdAndBroadcastMessage")
                     .setParameter("organizationId", organization)
                     .setParameter("statusTypeId", Observation.STATUS_TYPE_ID_APPROVED)
                     .getResultList());
-        }
-        else
-        {
+        } else {
             Calendar cal = Calendar.getInstance();
             cal.set(season, Calendar.JANUARY, 1, 0, 0, 0);
             Date start = cal.getTime();
@@ -361,31 +327,29 @@ public class ObservationBean {
         }
         return retVal;
     }
-    
+
     /**
-     * 
      * @param organizationId
-     * @param start When period starts. Default: Jan 1st 2000
-     * @param end When period ends. Default: 100 years from now
-     * @return 
+     * @param start          When period starts. Default: Jan 1st 2000
+     * @param end            When period ends. Default: 100 years from now
+     * @return
      */
     public List<Observation> getBroadcastObservations(Integer organizationId, Date start, Date end) {
-        if(start == null || end == null)
-        {
+        if (start == null || end == null) {
             Calendar cal = Calendar.getInstance();
-            if(start == null) // Default Jan 1st 2000
+            if (start == null) // Default Jan 1st 2000
             {
-                cal.set(2000, Calendar.JANUARY,1,0,0,0);
+                cal.set(2000, Calendar.JANUARY, 1, 0, 0, 0);
                 start = cal.getTime();
             }
-            if(end == null) // Default: Today + 100 years
+            if (end == null) // Default: Today + 100 years
             {
                 cal.setTime(SystemTime.getSystemTime());
                 cal.add(Calendar.YEAR, 100);
                 end = cal.getTime();
             }
         }
-        Organization organization= em.find(Organization.class, organizationId);
+        Organization organization = em.find(Organization.class, organizationId);
         return this.getObservationsWithGeoInfo(em.createNamedQuery("Observation.findByOrganizationIdAndStatusTypeIdAndBroadcastMessageAndPeriod")
                 .setParameter("organizationId", organization)
                 .setParameter("statusTypeId", Observation.STATUS_TYPE_ID_APPROVED)
@@ -393,45 +357,42 @@ public class ObservationBean {
                 .setParameter("end", end)
                 .getResultList());
     }
-    
-    
+
+
     /**
-     * 
      * @param observation
      * @return [OBSERVATION_ILLUSTRATION_PATH]/[ORGANISM_ID]/
      */
-    private String getFilePath(Observation observation)
-    {
-        return System.getProperty("no.nibio.vips.logic.OBSERVATION_ILLUSTRATION_PATH") + "/" 
-                        + observation.getOrganismId();
-    }
-    
-    public Observation storeObservationIllustration(Observation observation, String fileName, String base64Data)
-    {
-    	String[] metaAndData = base64Data.split(",");
-		byte[] imageData = Base64.decodeBase64(metaAndData[1]);
-		Path path = Paths.get(this.getFilePath(observation) + "/" + fileName);
-		try {
-			// Make sure the directory exists
-			File testDirectoryfile = new File(this.getFilePath(observation));
-	        if(!testDirectoryfile.exists())
-	        {
-	            testDirectoryfile.mkdirs();
-	        }
-			Files.write(path,imageData, StandardOpenOption.CREATE);
-			ObservationIllustration newIllustration = new ObservationIllustration(new ObservationIllustrationPK(observation.getObservationId(), fileName));
-	        newIllustration = em.merge(newIllustration);
-	        
-	        // Add the new illustration 
-	        if(observation.getObservationIllustrationSet() == null)
-	        {
-	            observation.setObservationIllustrationSet(new HashSet<ObservationIllustration>());
-	        }
-	        observation.getObservationIllustrationSet().add(newIllustration);
-
-			return observation;
-		}
-		catch(IOException ex) {ex.printStackTrace(); return observation;}
+    private String getFilePath(Observation observation) {
+        return System.getProperty("no.nibio.vips.logic.OBSERVATION_ILLUSTRATION_PATH") + "/"
+                + observation.getOrganismId();
+    }
+
+    public Observation storeObservationIllustration(Observation observation, String fileName, String base64Data) {
+        String[] metaAndData = base64Data.split(",");
+        byte[] imageData = Base64.decodeBase64(metaAndData[1]);
+        Path path = Paths.get(this.getFilePath(observation) + "/" + fileName);
+        try {
+            // Make sure the directory exists
+            File testDirectoryfile = new File(this.getFilePath(observation));
+            if (!testDirectoryfile.exists()) {
+                testDirectoryfile.mkdirs();
+            }
+            Files.write(path, imageData, StandardOpenOption.CREATE);
+            ObservationIllustration newIllustration = new ObservationIllustration(new ObservationIllustrationPK(observation.getObservationId(), fileName));
+            newIllustration = em.merge(newIllustration);
+
+            // Add the new illustration
+            if (observation.getObservationIllustrationSet() == null) {
+                observation.setObservationIllustrationSet(new HashSet<ObservationIllustration>());
+            }
+            observation.getObservationIllustrationSet().add(newIllustration);
+
+            return observation;
+        } catch (IOException ex) {
+            ex.printStackTrace();
+            return observation;
+        }
     }
 
     public Observation storeObservationIllustration(Observation observation, FileItem item) throws Exception {
@@ -442,32 +403,29 @@ public class ObservationBean {
         // Check availability, and adapt filename until available
         Integer fileNameSuffix = 1;
         File illustration = new File(filePath + "/" + fileName);
-        while(illustration.exists())
-        {
+        while (illustration.exists()) {
             fileName = observation.getObservationId() + "_illustration_" + fileNameSuffix + "." + FilenameUtils.getExtension(item.getName());
             illustration = new File(filePath + "/" + fileName);
             fileNameSuffix++;
         }
         File testDirectoryfile = new File(filePath);
         // If directory does not exist, create it
-        if(!testDirectoryfile.exists())
-        {
+        if (!testDirectoryfile.exists()) {
             testDirectoryfile.mkdirs();
         }
 
         // Store file
         item.write(illustration);
-        
+
         // Update MessageIllustrations
         observation = em.merge(observation);
         // Remove the old illustration(s)
-               
+
         ObservationIllustration newIllustration = new ObservationIllustration(new ObservationIllustrationPK(observation.getObservationId(), fileName));
         em.persist(newIllustration);
-        
+
         // Add the new illustration 
-        if(observation.getObservationIllustrationSet() == null)
-        {
+        if (observation.getObservationIllustrationSet() == null) {
             observation.setObservationIllustrationSet(new HashSet<ObservationIllustration>());
         }
         observation.getObservationIllustrationSet().add(newIllustration);
@@ -476,83 +434,78 @@ public class ObservationBean {
 
     public Observation deleteObservationIllustration(Observation observation, String[] deleteIllustrations) {
         observation = em.merge(observation);
-        
-        Set <ObservationIllustration> formerIllustrations = observation.getObservationIllustrationSet();
-        if(formerIllustrations == null)
-        {
-        	return observation;
+
+        Set<ObservationIllustration> formerIllustrations = observation.getObservationIllustrationSet();
+        if (formerIllustrations == null) {
+            return observation;
         }
-        
-        Set  <ObservationIllustration> deleteThese = new HashSet<>();
-        
-    	for(String deleteIllustration:deleteIllustrations)
-    	{
-    		for(ObservationIllustration formerIllustration:formerIllustrations)
-            {
-        		if(formerIllustration.getObservationIllustrationPK().getFileName()
-        				.equals(deleteIllustration))
-        		{
-        			deleteThese.add(formerIllustration);
-        		}
-        	}
+
+        Set<ObservationIllustration> deleteThese = new HashSet<>();
+
+        for (String deleteIllustration : deleteIllustrations) {
+            for (ObservationIllustration formerIllustration : formerIllustrations) {
+                if (formerIllustration.getObservationIllustrationPK().getFileName()
+                        .equals(deleteIllustration)) {
+                    deleteThese.add(formerIllustration);
+                }
+            }
+        }
+
+        for (ObservationIllustration ill : deleteThese) {
+            observation.getObservationIllustrationSet().remove(ill);
+            em.remove(ill);
+            // Physically remove it too
+            File fileToDelete = new File(this.getFilePath(observation) + "/" + ill.getObservationIllustrationPK().getFileName());
+            fileToDelete.delete();
         }
-    	
-    	for(ObservationIllustration ill: deleteThese)
-    	{
-    		observation.getObservationIllustrationSet().remove(ill);
-    		em.remove(ill);
-    		// Physically remove it too
-    		File fileToDelete = new File(this.getFilePath(observation) + "/" + ill.getObservationIllustrationPK().getFileName());
-    		fileToDelete.delete();
-    	}
         return observation;
     }
 
     /**
      * Fetch observations of a particular organism at a particular place and period
+     *
      * @param organismId
      * @param pointOfInterestId
      * @param startDate
      * @param endDate
-     * @return 
+     * @return
      */
     public List<no.nibio.vips.observation.Observation> getObservations(Integer organismId, Integer pointOfInterestId, Date startDate, Date endDate) {
         /*System.out.println("organismId = " + organismId);
         System.out.println("pointOfInterestId = " + pointOfInterestId);
         System.out.println("period= " + startDate + "-" + endDate);*/
         return em.createNativeQuery(
-                "SELECT * FROM public.observation "
-                + "WHERE organism_id = :organismId "
-                + "AND location_point_of_interest_id = :locationPointOfInterestId "
-                + "AND time_of_observation BETWEEN :startDate AND :endDate"
-                ,Observation.class
-        )
+                        "SELECT * FROM public.observation "
+                                + "WHERE organism_id = :organismId "
+                                + "AND location_point_of_interest_id = :locationPointOfInterestId "
+                                + "AND time_of_observation BETWEEN :startDate AND :endDate"
+                        , Observation.class
+                )
                 .setParameter("organismId", organismId)
-                .setParameter("locationPointOfInterestId",pointOfInterestId)
+                .setParameter("locationPointOfInterestId", pointOfInterestId)
                 .setParameter("startDate", startDate)
                 .setParameter("endDate", endDate)
                 .getResultList();
     }
 
     public List<Observation> getObservationsWithLocations(List<Observation> observations) {
-         Set<Integer> locationPointOfInterestIds = new HashSet<>();
-         observations.stream().filter((o) -> (o.getLocationPointOfInterestId() != null)).forEach((o) -> {
-             locationPointOfInterestIds.add(o.getLocationPointOfInterestId());
+        Set<Integer> locationPointOfInterestIds = new HashSet<>();
+        observations.stream().filter((o) -> (o.getLocationPointOfInterestId() != null)).forEach((o) -> {
+            locationPointOfInterestIds.add(o.getLocationPointOfInterestId());
         });
-         // Nothing to do?
-        if(locationPointOfInterestIds.isEmpty())
-        {
+        // Nothing to do?
+        if (locationPointOfInterestIds.isEmpty()) {
             return observations;
         }
-         List<PointOfInterest> pois = pointOfInterestBean.getPois(locationPointOfInterestIds);
-         Map<Integer, PointOfInterest> mappedPois = new HashMap<>();
-         pois.stream().forEach((poi) -> {
-             mappedPois.put(poi.getPointOfInterestId(), poi);
+        List<PointOfInterest> pois = pointOfInterestBean.getPois(locationPointOfInterestIds);
+        Map<Integer, PointOfInterest> mappedPois = new HashMap<>();
+        pois.stream().forEach((poi) -> {
+            mappedPois.put(poi.getPointOfInterestId(), poi);
         });
-         observations.stream().filter((o) -> (o.getLocationPointOfInterestId() != null)).forEach((o) -> {
-             o.setLocation(mappedPois.get(o.getLocationPointOfInterestId()));
+        observations.stream().filter((o) -> (o.getLocationPointOfInterestId() != null)).forEach((o) -> {
+            o.setLocation(mappedPois.get(o.getLocationPointOfInterestId()));
         });
-         return observations;
+        return observations;
     }
 
     private List<Observation> getObservationsWithObservers(List<Observation> observations) {
@@ -561,42 +514,39 @@ public class ObservationBean {
             userIds.add(o.getUserId());
         });
         // Nothing to do?
-        if(userIds.isEmpty())
-        {
+        if (userIds.isEmpty()) {
             return observations;
         }
-         List<VipsLogicUser> users = userBean.getUsers(userIds);
-         Map<Integer, VipsLogicUser> mappedUsers = new HashMap<>();
-         users.stream().forEach((user) -> {
-             mappedUsers.put(user.getUserId(), user);
+        List<VipsLogicUser> users = userBean.getUsers(userIds);
+        Map<Integer, VipsLogicUser> mappedUsers = new HashMap<>();
+        users.stream().forEach((user) -> {
+            mappedUsers.put(user.getUserId(), user);
         });
-         observations.stream().filter((o) -> (o.getUserId() != null)).forEach((o) -> {
-             o.setUser(mappedUsers.get(o.getUserId()));
+        observations.stream().filter((o) -> (o.getUserId() != null)).forEach((o) -> {
+            o.setUser(mappedUsers.get(o.getUserId()));
         });
         return observations;
     }
-    
-    public List<Observation> getObservationsOfPest(Integer pestOrganismId)
-    {
-        List <Observation> observations = 
-            em.createNamedQuery("Observation.findByOrganism")
-                    .setParameter("organism", em.find(Organism.class, pestOrganismId))
-                    .getResultList();
-        
+
+    public List<Observation> getObservationsOfPest(Integer pestOrganismId) {
+        List<Observation> observations =
+                em.createNamedQuery("Observation.findByOrganism")
+                        .setParameter("organism", em.find(Organism.class, pestOrganismId))
+                        .getResultList();
+
         observations = this.getObservationsWithGeoInfo(observations);
         observations = this.getObservationsWithLocations(observations);
         observations = getObservationsWithObservers(observations);
         return observations;
     }
-    
-    public List<Observation> getObservationsOfPestForUser(VipsLogicUser user, Integer pestOrganismId)
-    {
-        List <Observation> observations = 
-            em.createNamedQuery("Observation.findByUserIdAndOrganism")
-                    .setParameter("userId", user.getUserId())
-                    .setParameter("organism", em.find(Organism.class, pestOrganismId))
-                    .getResultList();
-        
+
+    public List<Observation> getObservationsOfPestForUser(VipsLogicUser user, Integer pestOrganismId) {
+        List<Observation> observations =
+                em.createNamedQuery("Observation.findByUserIdAndOrganism")
+                        .setParameter("userId", user.getUserId())
+                        .setParameter("organism", em.find(Organism.class, pestOrganismId))
+                        .getResultList();
+
         observations = this.getObservationsWithGeoInfo(observations);
         observations = this.getObservationsWithLocations(observations);
         observations = this.getObservationsWithObservers(observations);
@@ -604,60 +554,52 @@ public class ObservationBean {
     }
 
     public List<Observation> getFilteredObservations(
-            Integer organizationId, 
-            Integer pestId, 
-            Integer cropId, 
+            Integer organizationId,
+            Integer pestId,
+            Integer cropId,
             List<Integer> cropCategoryId,
-            Date from, 
+            Date from,
             Date to,
             Boolean isPositive
-    ) 
-    {
+    ) {
         // The minimum SQL
         String sql = "SELECT * FROM public.observation \n" +
-                     "WHERE status_type_id = :statusTypeId \n " + 
-                     "AND user_id IN (SELECT user_id FROM public.vips_logic_user WHERE organization_id = :organizationId) \n";
-       
+                "WHERE status_type_id = :statusTypeId \n " +
+                "AND user_id IN (SELECT user_id FROM public.vips_logic_user WHERE organization_id = :organizationId) \n";
+
         Map<String, Object> parameters = new HashMap<>();
         parameters.put("statusTypeId", ObservationStatusType.STATUS_APPROVED);
         parameters.put("organizationId", organizationId);
-        
+
         // Filter for pest
-        if(pestId != null && pestId > 0)
-        {
+        if (pestId != null && pestId > 0) {
             sql += "AND organism_id = :organismId \n";
             parameters.put("organismId", pestId);
         }
         // Filter either for crop or cropCategoryId
-        if(cropId != null && cropId > 0)
-        {
+        if (cropId != null && cropId > 0) {
             sql += "AND crop_organism_id = :cropOrganismId \n";
             parameters.put("cropOrganismId", cropId);
-        }
-        else if(cropCategoryId != null && ! cropCategoryId.isEmpty())
-        {
+        } else if (cropCategoryId != null && !cropCategoryId.isEmpty()) {
             List<CropCategory> cropCategories = em.createNamedQuery("CropCategory.findByCropCategoryIds", CropCategory.class)
                     .setParameter("cropCategoryIds", cropCategoryId)
                     .getResultList();
-            List<Integer> cropIds = new ArrayList(cropCategories.stream().flatMap(cC->Arrays.asList(cC.getCropOrganismIds()).stream()).collect(Collectors.toSet()));
-                    
+            List<Integer> cropIds = new ArrayList(cropCategories.stream().flatMap(cC -> Arrays.asList(cC.getCropOrganismIds()).stream()).collect(Collectors.toSet()));
+
             sql += "AND crop_organism_id IN (:cropOrganismIds) \n";
             parameters.put("cropOrganismIds", cropIds);
         }
         // Filter for dates
-        if(from != null)
-        {
+        if (from != null) {
             sql += "AND time_of_observation >= :from \n";
             parameters.put("from", from);
         }
-        if(to != null)
-        {
+        if (to != null) {
             sql += "AND time_of_observation <= :to \n";
             parameters.put("to", to);
         }
         // Filter for positive/negative registrations
-        if(isPositive != null)
-        {
+        if (isPositive != null) {
             sql += "AND is_positive = :isPositive \n";
             parameters.put("isPositive", isPositive);
         }
@@ -667,24 +609,26 @@ public class ObservationBean {
         Query q = em.createNativeQuery(sql, Observation.class);
         // Setting the parameters one by one
         parameters.keySet().stream().forEach(
-                (key)->{LOGGER.debug(key + ": " + parameters.get(key)); q.setParameter(key, parameters.get(key));}
+                (key) -> {
+                    LOGGER.debug(key + ": " + parameters.get(key));
+                    q.setParameter(key, parameters.get(key));
+                }
         );
-        
+
         //Date start = new Date();
-        
+
         List<Observation> observations = q.getResultList();
         //System.out.println("Finding obs took " + (new Date().getTime() - start.getTime()) + " milliseconds");
-        
+
         //start = new Date();
         observations.stream().forEach(
-                (observation)->observation.setUser(em.find(VipsLogicUser.class, observation.getUserId()))
+                (observation) -> observation.setUser(em.find(VipsLogicUser.class, observation.getUserId()))
         );
-        
+
         //System.out.println("Finding users took " + (new Date().getTime() - start.getTime()) + " milliseconds");
-        
+
         List<Observation> retVal = new ArrayList<>();
-        if(! observations.isEmpty())
-        {
+        if (!observations.isEmpty()) {
             //Date start = new Date();
             retVal = this.getObservationsWithGeoInfo(observations);
             //System.out.println("Finding geoinfo took " + (new Date().getTime() - start.getTime()) + " milliseconds");
@@ -693,9 +637,9 @@ public class ObservationBean {
             //System.out.println("Finding locations took " + (new Date().getTime() - start.getTime()) + " milliseconds");
         }
 
-        
+
         return retVal;
-        
+
     }
 
     public List<Organism> getObservedPests(Integer organizationId) {
@@ -706,9 +650,9 @@ public class ObservationBean {
         return em.createNamedQuery("Organism.findByOrganismIds")
                 .setParameter("organismIds", pestIds)
                 .getResultList();
-        
+
     }
-    
+
     public List<Organism> getObservedCrops(Integer organizationId) {
         Query q = em.createNativeQuery("SELECT DISTINCT crop_organism_id FROM public.observation WHERE user_id IN ("
                 + " SELECT user_id FROM vips_logic_user WHERE organization_id = :organizationId"
@@ -717,7 +661,7 @@ public class ObservationBean {
         return em.createNamedQuery("Organism.findByOrganismIds")
                 .setParameter("organismIds", cropIds)
                 .getResultList();
-        
+
     }
 
     public Observation getObservationFromGeoJSON(String geoJSON) throws IOException {
@@ -727,23 +671,22 @@ public class ObservationBean {
         Map<String, Object> properties = firstAndBest.getProperties();
         Observation observation = new Observation();
         Integer observationId = (Integer) properties.get("observationId");
-        if(observationId > 0)
-        {
+        if (observationId > 0) {
             observation = em.find(Observation.class, observationId);
         }
         observation.setObservationData((String) properties.get("observationData"));
         ObjectMapper mapper = new ObjectMapper();
         observation.setCropOrganism(mapper.convertValue(properties.get("cropOrganism"), Organism.class));
         observation.setOrganism(mapper.convertValue(properties.get("organism"), Organism.class));
-        observation.setObservationHeading((String)properties.get("observationHeading"));
-        observation.setObservationText((String)properties.get("observationText"));
+        observation.setObservationHeading((String) properties.get("observationHeading"));
+        observation.setObservationText((String) properties.get("observationText"));
         observation.setTimeOfObservation(new Date((Long) properties.get("timeOfObservation")));
         observation.setGeoinfo(geoJSON);
         observation.setStatusTypeId((Integer) properties.get("statusTypeId"));
         observation.setStatusRemarks((String) properties.get("statusRemarks"));
         observation.setIsQuantified((Boolean) properties.get("isQuantified"));
         observation.setBroadcastMessage((Boolean) properties.get("broadcastMessage"));
-        return observation; 
+        return observation;
     }
 
     public void deleteGisObservationByGis(Integer gisId) {
@@ -759,41 +702,34 @@ public class ObservationBean {
                 .getResultList();
     }
 
-    public  List<Integer> getOrganizationGroupIds(Observation observation) {
-        if(observation.getObservationId() != null)
-        {
+    public List<Integer> getOrganizationGroupIds(Observation observation) {
+        if (observation.getObservationId() != null) {
             return em.createNativeQuery("SELECT organization_group_id FROM public.organization_group_observation "
-                    + "WHERE observation_id = :observationId")
+                            + "WHERE observation_id = :observationId")
                     .setParameter("observationId", observation.getObservationId())
                     .getResultList();
-        }
-        else
-        {
+        } else {
             return new ArrayList<>();
         }
     }
-    
+
     public void storeOrganizationGroupObservationIds(Observation obs, String[] organizationGroupIds) {
         // First delete all current group memberships
         em.createNativeQuery("DELETE FROM public.organization_group_observation WHERE observation_id=:observationId")
                 .setParameter("observationId", obs.getObservationId())
                 .executeUpdate();
-        
-        if(organizationGroupIds != null)
-        {
+
+        if (organizationGroupIds != null) {
             Query q = em.createNativeQuery("INSERT INTO public.organization_group_observation (organization_group_id, observation_id) "
-                    + "VALUES(:organizationGroupId, :observationId)")
+                            + "VALUES(:organizationGroupId, :observationId)")
                     .setParameter("observationId", obs.getObservationId());
             // Then add
-            for(String groupIdStr:organizationGroupIds)
-            {
+            for (String groupIdStr : organizationGroupIds) {
                 try {
                     Integer groupId = Integer.valueOf(groupIdStr);
                     q.setParameter("organizationGroupId", groupId);
                     q.executeUpdate();
-                }
-                catch(NumberFormatException ex)
-                {
+                } catch (NumberFormatException ex) {
                     // Continue
                 }
             }
@@ -802,30 +738,27 @@ public class ObservationBean {
 
     /**
      * Returns the first time an observation of the given pest registered in the system was made
+     *
      * @param organismId
-     * @return 
+     * @return
      */
     public Date getFirstObservationTime(Integer organismId) {
-        
-        try
-        {
-            List<Observation> obs =  em.createNamedQuery("Observation.findFirstByOrganism")
+
+        try {
+            List<Observation> obs = em.createNamedQuery("Observation.findFirstByOrganism")
                     .setParameter("organism", em.find(Organism.class, organismId))
                     .getResultList();
             return obs.get(0).getTimeOfObservation();
-        }
-        catch(NoResultException | IndexOutOfBoundsException ex)
-        {
+        } catch (NoResultException | IndexOutOfBoundsException ex) {
             return null;
         }
-                
+
     }
 
-    public PolygonService getPolygonService(Integer polygonServiceId)
-    {
-    	return em.find(PolygonService.class, polygonServiceId);
+    public PolygonService getPolygonService(Integer polygonServiceId) {
+        return em.find(PolygonService.class, polygonServiceId);
     }
-    
+
     public List<PolygonService> getPolygonServicesForOrganization(Integer organizationId) {
         return em.createNativeQuery("SELECT * FROM polygon_service p WHERE p.polygon_service_id IN (SELECT polygon_service_id FROM public.organization_polygon_service WHERE organization_id=:organizationId)", PolygonService.class)
                 .setParameter("organizationId", organizationId)
@@ -840,7 +773,8 @@ public class ObservationBean {
 
     /**
      * Part of the cleaning up dependencies procedure for when deleting a POI
-     * @param poi 
+     *
+     * @param poi
      */
     public void deleteObservationsForLocation(PointOfInterest poi) {
         em.createNamedQuery("Observation.findByLocationPointOfInterestId", Observation.class)
@@ -848,99 +782,96 @@ public class ObservationBean {
                 .getResultList().stream()
                 .forEach(obs -> em.remove(obs));
     }
-    
+
+    public void deleteObservationsForObservationTimeSeries(ObservationTimeSeries observationTimeSeries) {
+        em.createNamedQuery("Observation.findByObservationTimeSeries", Observation.class)
+                .setParameter("observationTimeSeries", observationTimeSeries)
+                .getResultList()
+                .forEach(obs -> em.remove(obs));
+    }
+
     /**
-     * Returns the appropriate observation data schema 
+     * Returns the appropriate observation data schema
      * If no existing schema, returns the standard (Requring just a number)
+     *
      * @param organizationId
      * @param organismId
-     * @param httpServletRequest
-     * @return 
+     * @return
      */
-    public ObservationDataSchema getObservationDataSchema(Integer organizationId, Integer organismId)
-    {
-        try
-        {
+    public ObservationDataSchema getObservationDataSchema(Integer organizationId, Integer organismId) {
+        try {
             return em.createNamedQuery("ObservationDataSchema.findByPK", ObservationDataSchema.class)
-                .setParameter("organizationId", organizationId)
-                .setParameter("organismId", organismId)
-                .getSingleResult();
-            
-            
-        }
-        catch(NoResultException ex)
-        {
+                    .setParameter("organizationId", organizationId)
+                    .setParameter("organismId", organismId)
+                    .getSingleResult();
+
+
+        } catch (NoResultException ex) {
             //System.out.println("Could not find schema for orgId " + organizationId + " and organismId " + organismId);
             return this.getStandardSchema(organizationId);
         }
     }
-    
+
     /**
      * If there exist title translations for this schema, it
+     *
      * @param schema
      * @param httpServletRequest
-     * @return 
+     * @return
      */
-    public ObservationDataSchema getLocalizedObservationDataSchema(ObservationDataSchema ods, HttpServletRequest httpServletRequest, ULocale locale) throws IOException
-    {
-        if(locale != null)
-        {
-        	SessionLocaleUtil.setCurrentLocale(httpServletRequest, locale);
+    public ObservationDataSchema getLocalizedObservationDataSchema(ObservationDataSchema ods, HttpServletRequest httpServletRequest, ULocale locale) throws IOException {
+        if (locale != null) {
+            SessionLocaleUtil.setCurrentLocale(httpServletRequest, locale);
         }
         ResourceBundle bundle = SessionLocaleUtil.getI18nBundle(httpServletRequest);
-            
-            // We iterate the schema, replacing default field labels with
-            // translated ones
-            // First: Convert to Jackson JsonNode tree
-            ObjectMapper m = new ObjectMapper();
-            JsonNode rootNode = m.readTree(ods.getDataSchema());
-            // Is this the full schema or just the "properties" property?
-            JsonNode propertiesNode = rootNode.get("properties") == null ? rootNode : rootNode.get("properties");
-
-            Iterator<Entry<String, JsonNode>> nodeIterator = propertiesNode.fields();
-            
-            String fieldKeyPrefix = "observationDataField_";
-            // Loop through each field
-            while (nodeIterator.hasNext()) {
-                Map.Entry<String, JsonNode> schemaPropertyField = (Map.Entry<String, JsonNode>) nodeIterator.next();
-                // Get the property field key (e.g. "counting2")
-                String fieldKey = schemaPropertyField.getKey();
-                // Find a translation.
-                if(bundle.containsKey(fieldKeyPrefix + fieldKey))
-                {
-                    // If found, replace with translation
-                    // Get the property field (e.g. {"title":"Counting 2"} )
-                    JsonNode schemaProperty = schemaPropertyField.getValue();
-                    ((ObjectNode)schemaProperty).put("title", bundle.getString(fieldKeyPrefix + fieldKey));
-                    ((ObjectNode)propertiesNode).replace(fieldKey, schemaProperty);
-                }
-            }
-            
-            // I repeat: Is this the full schema or just the "properties" property?
-            if(rootNode.get("properties") != null)
-            {
-                ((ObjectNode)rootNode).replace("properties", propertiesNode);
-            }
-            else
-            {
-                rootNode = propertiesNode;
+
+        // We iterate the schema, replacing default field labels with
+        // translated ones
+        // First: Convert to Jackson JsonNode tree
+        ObjectMapper m = new ObjectMapper();
+        JsonNode rootNode = m.readTree(ods.getDataSchema());
+        // Is this the full schema or just the "properties" property?
+        JsonNode propertiesNode = rootNode.get("properties") == null ? rootNode : rootNode.get("properties");
+
+        Iterator<Entry<String, JsonNode>> nodeIterator = propertiesNode.fields();
+
+        String fieldKeyPrefix = "observationDataField_";
+        // Loop through each field
+        while (nodeIterator.hasNext()) {
+            Map.Entry<String, JsonNode> schemaPropertyField = (Map.Entry<String, JsonNode>) nodeIterator.next();
+            // Get the property field key (e.g. "counting2")
+            String fieldKey = schemaPropertyField.getKey();
+            // Find a translation.
+            if (bundle.containsKey(fieldKeyPrefix + fieldKey)) {
+                // If found, replace with translation
+                // Get the property field (e.g. {"title":"Counting 2"} )
+                JsonNode schemaProperty = schemaPropertyField.getValue();
+                ((ObjectNode) schemaProperty).put("title", bundle.getString(fieldKeyPrefix + fieldKey));
+                ((ObjectNode) propertiesNode).replace(fieldKey, schemaProperty);
             }
-            ods.setDataSchema(m.writeValueAsString(rootNode));
-            return ods;
+        }
+
+        // I repeat: Is this the full schema or just the "properties" property?
+        if (rootNode.get("properties") != null) {
+            ((ObjectNode) rootNode).replace("properties", propertiesNode);
+        } else {
+            rootNode = propertiesNode;
+        }
+        ods.setDataSchema(m.writeValueAsString(rootNode));
+        return ods;
     }
-    
+
     /**
-     * 
      * @param organizationId
-     * @return 
+     * @return
      */
-    private ObservationDataSchema getStandardSchema(Integer organizationId){
+    private ObservationDataSchema getStandardSchema(Integer organizationId) {
         ObservationDataSchema retVal = new ObservationDataSchema();
         retVal.setDataSchema("{\n"
-        		+ "  \"$schema\": \"http://json-schema.org/draft-04/schema#\",\n"
-        		+ "  \"type\": \"object\",\n"
-        		+ "  \"title\": \"Default schema\",\n"
-        		+ "  \"properties\": {"
+                + "  \"$schema\": \"http://json-schema.org/draft-04/schema#\",\n"
+                + "  \"type\": \"object\",\n"
+                + "  \"title\": \"Default schema\",\n"
+                + "  \"properties\": {"
                 + "\"number\":{\"title\":\"Number\"},"
                 + "\"unit\":{\"title\":\"Unit\"}"
                 + "}"
@@ -955,7 +886,6 @@ public class ObservationBean {
         retVal.setObservationDataSchemaPK(pk);
         return retVal;
     }
-    
-    
+
 
 }
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
new file mode 100644
index 0000000000000000000000000000000000000000..be5bd46f578d71bc1a00fd800f840a2ff86281f8
--- /dev/null
+++ b/src/main/java/no/nibio/vips/logic/controller/session/ObservationTimeSeriesBean.java
@@ -0,0 +1,133 @@
+/*
+ * Copyright (c) 2018 NIBIO <http://www.nibio.no/>.
+ *
+ * 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/>.
+ *
+ */
+
+package no.nibio.vips.logic.controller.session;
+
+import no.nibio.vips.logic.entity.*;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.ejb.EJB;
+import javax.ejb.Stateless;
+import javax.persistence.EntityManager;
+import javax.persistence.PersistenceContext;
+import javax.persistence.Query;
+import java.util.*;
+
+@Stateless
+public class ObservationTimeSeriesBean {
+
+    @PersistenceContext(unitName = "VIPSLogic-PU")
+    EntityManager em;
+    @EJB
+    PointOfInterestBean pointOfInterestBean;
+    @EJB
+    UserBean userBean;
+    @EJB
+    ObservationBean observationBean;
+
+    public List<ObservationTimeSeries> getObservationTimeSeriesListForUser(VipsLogicUser user) {
+        List<ObservationTimeSeries> resultList = em.createNamedQuery("ObservationTimeSeries.findByUserId", ObservationTimeSeries.class)
+                .setParameter("userId", user.getUserId())
+                .getResultList();
+        this.enrichObservationTimeSeriesListWithPointOfInterest(resultList);
+        this.enrichObservationTimeSeriesListWithObservers(resultList);
+        return resultList;
+    }
+
+    public ObservationTimeSeries getObservationTimeSeries(Integer id) {
+        ObservationTimeSeries ots = em.find(ObservationTimeSeries.class, id);
+        if (ots != null) {
+            ots.setUser(em.find(VipsLogicUser.class, ots.getUserId()));
+            if (ots.getLastModifiedBy() != null) {
+                ots.setLastModifiedByUser(em.find(VipsLogicUser.class, ots.getLastModifiedBy()));
+            }
+        }
+        return ots;
+    }
+
+    /**
+     * @param ots the observation time series
+     * @return The merged object
+     */
+    public ObservationTimeSeries storeObservationTimeSeries(ObservationTimeSeries ots) {
+        return em.merge(ots);
+    }
+
+    public void deleteObservationTimeSeries(Integer id) {
+        ObservationTimeSeries observationTimeSeries = em.find(ObservationTimeSeries.class, id);
+        if (observationTimeSeries != null) {
+            // The app prevents deletion of time series with observations
+            observationBean.deleteObservationsForObservationTimeSeries(observationTimeSeries);
+            em.remove(observationTimeSeries);
+        }
+    }
+
+    /**
+     * Enrich given list of observation time series with point of interest information
+     *
+     * @param otsList The list of observation time series to enrich
+     */
+    public void enrichObservationTimeSeriesListWithPointOfInterest(List<ObservationTimeSeries> otsList) {
+        Set<Integer> locationPoiIds = new HashSet<>();
+        otsList.stream().filter((o) -> (o.getLocationPointOfInterestId() != null)).forEach((o) -> {
+            locationPoiIds.add(o.getLocationPointOfInterestId());
+        });
+        if (locationPoiIds.isEmpty()) {
+            return;
+        }
+        List<PointOfInterest> pois = pointOfInterestBean.getPois(locationPoiIds);
+        Map<Integer, PointOfInterest> mappedPois = new HashMap<>();
+        pois.stream().forEach((poi) -> {
+            mappedPois.put(poi.getPointOfInterestId(), poi);
+        });
+        otsList.stream().filter((o) -> (o.getLocationPointOfInterestId() != null)).forEach((o) -> {
+            o.setLocationPointOfInterest(mappedPois.get(o.getLocationPointOfInterestId()));
+        });
+    }
+
+    public void enrichObservationTimeSeriesWithPointOfInterest(ObservationTimeSeries ots) {
+        if (ots == null || ots.getLocationPointOfInterestId() == null) {
+            return;
+        }
+        ots.setLocationPointOfInterest(pointOfInterestBean.getPointOfInterest(ots.getLocationPointOfInterestId()));
+    }
+
+    /**
+     * Enrich given list of observation time series with user information
+     *
+     * @param otsList The list of observation time series to enrich
+     */
+    private void enrichObservationTimeSeriesListWithObservers(List<ObservationTimeSeries> otsList) {
+        Set<Integer> userIds = new HashSet<>();
+        otsList.stream().filter((o) -> (o.getUserId() != null)).forEach((o) -> {
+            userIds.add(o.getUserId());
+        });
+        if (userIds.isEmpty()) {
+            return;
+        }
+        List<VipsLogicUser> users = userBean.getUsers(userIds);
+        Map<Integer, VipsLogicUser> mappedUsers = new HashMap<>();
+        users.stream().forEach((user) -> {
+            mappedUsers.put(user.getUserId(), user);
+        });
+        otsList.stream().filter((o) -> (o.getUserId() != null)).forEach((o) -> {
+            o.setUser(mappedUsers.get(o.getUserId()));
+        });
+    }
+}
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 cd35d8f8f44239a720899d9377f517f12282a9d1..b0b092d053042e9187d30be2022dd9108e84cc7f 100755
--- a/src/main/java/no/nibio/vips/logic/entity/Observation.java
+++ b/src/main/java/no/nibio/vips/logic/entity/Observation.java
@@ -19,20 +19,7 @@ package no.nibio.vips.logic.entity;
 
 import java.io.Serializable;
 import java.util.Date;
-import javax.persistence.Basic;
-import javax.persistence.Column;
-import javax.persistence.Entity;
-import javax.persistence.GeneratedValue;
-import javax.persistence.GenerationType;
-import javax.persistence.Id;
-import javax.persistence.JoinColumn;
-import javax.persistence.ManyToOne;
-import javax.persistence.NamedQueries;
-import javax.persistence.NamedQuery;
-import javax.persistence.Table;
-import javax.persistence.Temporal;
-import javax.persistence.TemporalType;
-import javax.persistence.Transient;
+import javax.persistence.*;
 import javax.validation.constraints.NotNull;
 import javax.xml.bind.annotation.XmlRootElement;
 import com.fasterxml.jackson.annotation.JsonIgnore;
@@ -41,9 +28,6 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import javax.persistence.CascadeType;
-import javax.persistence.FetchType;
-import javax.persistence.OneToMany;
 import javax.validation.constraints.Size;
 import no.nibio.vips.logic.util.GISEntityUtil;
 import no.nibio.vips.gis.GISUtil;
@@ -62,12 +46,14 @@ import org.hibernate.annotations.TypeDefs;
 @Table(name = "observation")
 @XmlRootElement
 @TypeDefs( {@TypeDef( name= "StringJsonObject", typeClass = StringJsonUserType.class)})
+
 @NamedQueries({
     @NamedQuery(name = "Observation.findAll", query = "SELECT o FROM Observation o"),
     @NamedQuery(name = "Observation.findByObservationId", query = "SELECT o FROM Observation o WHERE o.observationId = :observationId"),
     @NamedQuery(name = "Observation.findByUserId", query = "SELECT o FROM Observation o WHERE o.userId IN(:userId)"),
     @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.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"),
@@ -91,6 +77,7 @@ public class Observation implements Serializable, no.nibio.vips.observation.Obse
     private Integer userId;
     private Integer lastEditedBy;
     private List<Gis> geoinfo;
+    private ObservationTimeSeries observationTimeSeries;
     private Integer locationPointOfInterestId;
     //private Double observedValue;
     //private Integer denominator;
@@ -120,9 +107,18 @@ public class Observation implements Serializable, no.nibio.vips.observation.Obse
     private GISEntityUtil GISEntityUtil;
     private GISUtil GISUtil;
 
+    // Should be defined as an enum (WEB/APP), but PostgreSQLEnumType and hibernate6 seems to be necessary
+    // https://stackoverflow.com/questions/50818649/hibernate-and-java-and-postgres-enumeratedvalue-enumtype-string-caused-by
+    private String source;
+
     public Observation() {
+        this("WEB");
+    }
+
+    public Observation(String source) {
         this.GISEntityUtil = new GISEntityUtil();
         this.GISUtil = new GISUtil();
+        this.source = source;
     }
 
     public Observation(Integer observationId) {
@@ -272,7 +268,38 @@ public class Observation implements Serializable, no.nibio.vips.observation.Obse
 
     @Override
     public String toString() {
-        return "no.nibio.vips.logic.entity.Observation[ observationId=" + observationId + " ]";
+        return "Observation{" +
+                "observationId=" + observationId +
+                ", timeOfObservation=" + timeOfObservation +
+                ", organism=" + organism +
+                ", cropOrganism=" + cropOrganism +
+                ", userId=" + userId +
+                ", lastEditedBy=" + lastEditedBy +
+                ", geoinfo=" + geoinfo +
+                ", observationTimeSeries=" + observationTimeSeries +
+                ", locationPointOfInterestId=" + locationPointOfInterestId +
+                ", observationHeading='" + observationHeading + '\'' +
+                ", observationText='" + observationText + '\'' +
+                ", statusTypeId=" + statusTypeId +
+                ", statusChangedByUserId=" + statusChangedByUserId +
+                ", statusChangedTime=" + statusChangedTime +
+                ", lastEditedTime=" + lastEditedTime +
+                ", statusRemarks='" + statusRemarks + '\'' +
+                ", observationData='" + observationData + '\'' +
+                ", isQuantified=" + isQuantified +
+                ", isPositive=" + isPositive +
+                ", broadcastMessage=" + broadcastMessage +
+                ", locationIsPrivate=" + locationIsPrivate +
+                ", polygonService=" + polygonService +
+                ", observationDataSchema=" + observationDataSchema +
+                ", user=" + user +
+                ", lastEditedByUser=" + lastEditedByUser +
+                ", location=" + location +
+                ", observationIllustrationSet=" + observationIllustrationSet +
+                ", GISEntityUtil=" + GISEntityUtil +
+                ", GISUtil=" + GISUtil +
+                ", source=" + source +
+                '}';
     }
 
     /**
@@ -531,6 +558,22 @@ public class Observation implements Serializable, no.nibio.vips.observation.Obse
         this.observationIllustrationSet = observationIllustrationSet;
     }
 
+    /**
+      * @return the observation time series
+     */
+    @JoinColumn(name = "observation_time_series_id", referencedColumnName = "observation_time_series_id")
+    @ManyToOne
+    public ObservationTimeSeries getObservationTimeSeries() {
+        return observationTimeSeries;
+    }
+
+    /**
+     * @param observationTimeSeries the observation time series to set
+     */
+    public void setObservationTimeSeries(ObservationTimeSeries observationTimeSeries) {
+        this.observationTimeSeries = observationTimeSeries;
+    }
+
     /**
      * @return the locationPointOfInterestId
      */
@@ -679,4 +722,12 @@ public class Observation implements Serializable, no.nibio.vips.observation.Obse
     public void setIsPositive(Boolean positive) {
         isPositive = positive;
     }
+
+    public String getSource() {
+        return source;
+    }
+
+    public void setSource(String source) {
+        this.source = source;
+    }
 }
diff --git a/src/main/java/no/nibio/vips/logic/entity/ObservationTimeSeries.java b/src/main/java/no/nibio/vips/logic/entity/ObservationTimeSeries.java
new file mode 100644
index 0000000000000000000000000000000000000000..b222732163440a174fb531b8d86a2fcfc4dd4ec4
--- /dev/null
+++ b/src/main/java/no/nibio/vips/logic/entity/ObservationTimeSeries.java
@@ -0,0 +1,390 @@
+/*
+ * Copyright (c) 2014 NIBIO <http://www.nibio.no/>.
+ *
+ * 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/>.
+ *
+ */
+package no.nibio.vips.logic.entity;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import no.nibio.vips.logic.util.StringJsonUserType;
+import org.hibernate.annotations.TypeDef;
+import org.hibernate.annotations.TypeDefs;
+
+import javax.persistence.*;
+import javax.validation.constraints.NotNull;
+import javax.xml.bind.annotation.XmlRootElement;
+import java.io.Serializable;
+import java.util.Date;
+
+@Entity
+@Table(name = "observation_time_series")
+@XmlRootElement
+@TypeDefs({@TypeDef(name = "StringJsonObject", typeClass = StringJsonUserType.class)})
+@NamedQueries({
+        @NamedQuery(name = "ObservationTimeSeries.findAll", query = "SELECT ots FROM ObservationTimeSeries ots"),
+        @NamedQuery(name = "ObservationTimeSeries.findByObservationTimeSeriesId", query = "SELECT ots FROM ObservationTimeSeries ots WHERE ots.observationTimeSeriesId = :id"),
+        @NamedQuery(name = "ObservationTimeSeries.findByOrganizationId", query = "SELECT ots FROM ObservationTimeSeries ots WHERE ots.userId IN(SELECT v.userId FROM VipsLogicUser v WHERE v.organizationId = :organizationId OR  v.organizationId IN(SELECT o.organizationId FROM Organization o WHERE o.parentOrganizationId = :organizationId))"),
+        @NamedQuery(name = "ObservationTimeSeries.findByUserId", query = "SELECT ots FROM ObservationTimeSeries ots WHERE ots.userId IN(:userId)")
+})
+public class ObservationTimeSeries implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+    private Integer observationTimeSeriesId;
+    private Organism cropOrganism;
+    private Organism organism;
+    private Integer year;
+    private String name;
+    private String description;
+    private Date created;
+    private Integer userId;
+    private VipsLogicUser user; // Transient
+    private String source;
+    private Date lastModified;
+    private Integer lastModifiedBy;
+    private VipsLogicUser lastModifiedByUser; // Transient
+    private Integer locationPointOfInterestId;
+    private PointOfInterest locationPointOfInterest;
+    private Boolean locationIsPrivate;
+    private PolygonService polygonService;
+
+    public ObservationTimeSeries() {
+    }
+
+    public ObservationTimeSeries(String source) {
+        this.source = source;
+    }
+
+    /**
+     * @return the id of the observation time series
+     */
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    @Basic(optional = false)
+    @Column(name = "observation_time_series_id")
+    public Integer getObservationTimeSeriesId() {
+        return observationTimeSeriesId;
+    }
+
+    /**
+     * @param id the observation time series id to set
+     */
+    public void setObservationTimeSeriesId(Integer id) {
+        this.observationTimeSeriesId = id;
+    }
+
+
+    /**
+     * @return the crop organism
+     */
+    @JoinColumn(name = "crop_organism_id", referencedColumnName = "organism_id")
+    @ManyToOne
+    public Organism getCropOrganism() {
+        return cropOrganism;
+    }
+
+    /**
+     * @param cropOrganism the crop organism to set
+     */
+    public void setCropOrganism(Organism cropOrganism) {
+        this.cropOrganism = cropOrganism;
+    }
+
+    /**
+     * @return the crop organism id
+     */
+    @Transient
+    public Integer getCropOrganismId() {
+        return this.getCropOrganism() != null ? this.getCropOrganism().getOrganismId() : null;
+    }
+
+    /**
+     * @return the organism
+     */
+    @JoinColumn(name = "organism_id", referencedColumnName = "organism_id")
+    @ManyToOne
+    public Organism getOrganism() {
+        return organism;
+    }
+
+    /**
+     * @param organism the organism to set
+     */
+    public void setOrganism(Organism organism) {
+        this.organism = organism;
+    }
+
+    /**
+     * @return the organism id
+     */
+    @Transient
+    public Integer getOrganismId() {
+        return this.getOrganism() != null ? this.getOrganism().getOrganismId() : null;
+    }
+
+    /**
+     * @return the year of the observation time series
+     */
+    @Column(name = "year")
+    public Integer getYear() {
+        return year;
+    }
+
+    /**
+     * @param year the observation time series year to set
+     */
+    public void setYear(Integer year) {
+        this.year = year;
+    }
+
+    /**
+     * @return the name of the observation time series
+     */
+    @Column(name = "name")
+    public String getName() {
+        return name;
+    }
+
+    /**
+     * @param name the observation time series name to set
+     */
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    /**
+     * @return the description of the observation time series
+     */
+    @Column(name = "description")
+    public String getDescription() {
+        return description;
+    }
+
+    /**
+     * @param description the observation time series text to set
+     */
+    public void setDescription(String description) {
+        this.description = description;
+    }
+
+    /**
+     * @return the creation date of the observation time series
+     */
+    @NotNull
+    @Basic(optional = false)
+    @Column(name = "created")
+    @Temporal(TemporalType.TIMESTAMP)
+    public Date getCreated() {
+        return created;
+    }
+
+    /**
+     * @param created the creation date to set
+     */
+    public void setCreated(Date created) {
+        this.created = created;
+    }
+
+    @NotNull
+    @Basic(optional = false)
+    @Temporal(TemporalType.TIMESTAMP)
+    @Column(name = "last_modified")
+    public Date getLastModified() {
+        return lastModified;
+    }
+
+    public void setLastModified(Date lastModified) {
+        this.lastModified = lastModified;
+    }
+
+    /**
+     * @return the userId
+     */
+    @Column(name = "user_id")
+    public Integer getUserId() {
+        return userId;
+    }
+
+    /**
+     * @param userId the userId to set
+     */
+    public void setUserId(Integer userId) {
+        this.userId = userId;
+    }
+
+    /**
+     * @return the user
+     */
+    @Transient
+    @JsonIgnore
+    public VipsLogicUser getUser() {
+        return user;
+    }
+
+    /**
+     * @param user the user to set
+     */
+    public void setUser(VipsLogicUser user) {
+        this.user = user;
+    }
+
+    /**
+     * @return from where the observation time series originally was created, either WEB or APP
+     */
+    @Column(name = "source")
+    public String getSource() {
+        return source;
+    }
+
+    /**
+     * @param source From where the observation time series originally was created
+     */
+    public void setSource(String source) {
+        this.source = source;
+    }
+
+    @Column(name = "last_modified_by")
+    public Integer getLastModifiedBy() {
+        return lastModifiedBy;
+    }
+
+    /**
+     * @param lastModifiedBy the lastModifiedBy to set
+     */
+    public void setLastModifiedBy(Integer lastModifiedBy) {
+        this.lastModifiedBy = lastModifiedBy;
+    }
+
+    /**
+     * @return the user who last modified the observation time series
+     */
+    @Transient
+    @JsonIgnore
+    public VipsLogicUser getLastModifiedByUser() {
+        return lastModifiedByUser;
+    }
+
+    /**
+     * @param lastModifiedByUser the lastModifiedByUser to set
+     */
+    public void setLastModifiedByUser(VipsLogicUser lastModifiedByUser) {
+        this.lastModifiedByUser = lastModifiedByUser;
+    }
+
+    /**
+     * @return the locationPointOfInterestId
+     */
+    @Column(name = "location_point_of_interest_id")
+    public Integer getLocationPointOfInterestId() {
+        return locationPointOfInterestId;
+    }
+
+    /**
+     * @param locationPointOfInterestId the locationPointOfInterestId to set
+     */
+    public void setLocationPointOfInterestId(Integer locationPointOfInterestId) {
+        this.locationPointOfInterestId = locationPointOfInterestId;
+    }
+
+    /**
+     * @return the location
+     */
+    @Transient
+    public PointOfInterest getLocationPointOfInterest() {
+        return locationPointOfInterest;
+    }
+
+    /**
+     * @param locationPointOfInterest the location to set
+     */
+    public void setLocationPointOfInterest(PointOfInterest locationPointOfInterest) {
+        this.locationPointOfInterest = locationPointOfInterest;
+    }
+
+    /**
+     * @return the locationIsPrivate
+     */
+    @Column(name = "location_is_private")
+    public Boolean getLocationIsPrivate() {
+        return locationIsPrivate != null ? locationIsPrivate : false;
+    }
+
+    /**
+     * @param locationIsPrivate the locationIsPrivate to set
+     */
+    public void setLocationIsPrivate(Boolean locationIsPrivate) {
+        this.locationIsPrivate = locationIsPrivate;
+    }
+
+    /**
+     * @return the polygon service
+     */
+    @JoinColumn(name = "polygon_service_id", referencedColumnName = "polygon_service_id")
+    @ManyToOne
+    public PolygonService getPolygonService() {
+        return this.polygonService;
+    }
+
+    /**
+     * @param polygonService the polygon service to set
+     */
+    public void setPolygonService(PolygonService polygonService) {
+        this.polygonService = polygonService;
+    }
+
+    @Override
+    public int hashCode() {
+        int hash = 0;
+        hash += (observationTimeSeriesId != null ? observationTimeSeriesId.hashCode() : 0);
+        return hash;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        ObservationTimeSeries that = (ObservationTimeSeries) o;
+        return observationTimeSeriesId.equals(that.observationTimeSeriesId);
+    }
+
+    @Override
+    public String toString() {
+        return "ObservationTimeSeries{" +
+                "observationTimeSeriesId=" + observationTimeSeriesId +
+                ", cropOrganism=" + cropOrganism +
+                ", organism=" + organism +
+                ", year=" + year +
+                ", name='" + name + '\'' +
+                ", description='" + description + '\'' +
+                ", created=" + created +
+                ", userId=" + userId +
+                ", user=" + user +
+                ", source=" + source +
+                ", lastModified=" + lastModified +
+                ", lastModifiedBy=" + lastModifiedBy +
+                ", lastModifiedByUser=" + lastModifiedByUser +
+                ", locationPointOfInterestId=" + locationPointOfInterestId +
+                ", locationPointOfInterest=" + locationPointOfInterest +
+                ", locationIsPrivate=" + locationIsPrivate +
+                ", polygonService=" + polygonService +
+                '}';
+    }
+
+    public int compareTo(ObservationTimeSeries other) {
+        if (this.getYear() != null && other.getYear() != null) {
+            return other.getYear().compareTo(this.getYear());
+        }
+        return this.getName().compareTo(other.getName());
+    }
+}
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 bb17d6b1dd03dbd8b9adcd22b9c9b3db4575cf15..a6a9604ba915a4f4207869b4818d19ba329175e9 100755
--- a/src/main/java/no/nibio/vips/logic/service/ObservationService.java
+++ b/src/main/java/no/nibio/vips/logic/service/ObservationService.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2022 NIBIO <http://www.nibio.no/>. 
+ * Copyright (c) 2022 NIBIO <http://www.nibio.no/>.
  *
  * 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
@@ -22,45 +22,11 @@ import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.ibm.icu.util.ULocale;
 import com.webcohesion.enunciate.metadata.rs.TypeHint;
-
-import java.io.IOException;
-import java.net.URI;
-import java.text.ParseException;
-import java.text.SimpleDateFormat;
-import java.time.Instant;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.UUID;
-import java.util.stream.Collectors;
-import javax.ejb.EJB;
-import javax.servlet.http.HttpServletRequest;
-import javax.ws.rs.Consumes;
-import javax.ws.rs.DELETE;
-import javax.ws.rs.GET;
-import javax.ws.rs.POST;
-import javax.ws.rs.Path;
-import javax.ws.rs.PathParam;
-import javax.ws.rs.Produces;
-import javax.ws.rs.QueryParam;
-import javax.ws.rs.client.Client;
-import javax.ws.rs.client.ClientBuilder;
-import javax.ws.rs.client.Entity;
-import javax.ws.rs.client.WebTarget;
-import javax.ws.rs.core.Context;
-import javax.ws.rs.core.MediaType;
-import javax.ws.rs.core.Response;
-import javax.ws.rs.core.Response.Status;
 import no.nibio.vips.gis.GISUtil;
 import no.nibio.vips.logic.controller.session.ObservationBean;
+import no.nibio.vips.logic.controller.session.ObservationTimeSeriesBean;
 import no.nibio.vips.logic.controller.session.OrganismBean;
 import no.nibio.vips.logic.controller.session.UserBean;
-
 import no.nibio.vips.logic.entity.*;
 import no.nibio.vips.logic.entity.rest.ObservationListItem;
 import no.nibio.vips.logic.entity.rest.PointMappingResponse;
@@ -72,34 +38,52 @@ import org.jboss.resteasy.annotations.GZIP;
 import org.locationtech.jts.geom.Coordinate;
 import org.locationtech.jts.geom.Geometry;
 import org.locationtech.jts.geom.Point;
-import org.wololo.geojson.Feature;
-
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import org.wololo.geojson.FeatureCollection;
+import org.wololo.geojson.Feature;
 import org.wololo.geojson.GeoJSON;
 
+import javax.ejb.EJB;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.*;
+import javax.ws.rs.client.Client;
+import javax.ws.rs.client.ClientBuilder;
+import javax.ws.rs.client.Entity;
+import javax.ws.rs.client.WebTarget;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.Response.Status;
+import java.io.IOException;
+import java.net.URI;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.util.*;
+import java.util.stream.Collectors;
+
 /**
- * @copyright 2016-2022 <a href="http://www.nibio.no/">NIBIO</a>
  * @author Tor-Einar Skog <tor-einar.skog@nibio.no>
+ * @copyright 2016-2022 <a href="http://www.nibio.no/">NIBIO</a>
  */
 @Path("rest/observation")
 public class ObservationService {
-    
+
     private final static Logger LOGGER = LoggerFactory.getLogger(ObservationService.class);
-    
-    @Context
-    private HttpServletRequest httpServletRequest;
-    
     @EJB
     UserBean userBean;
     @EJB
     ObservationBean observationBean;
     @EJB
     OrganismBean organismBean;
+
+    @EJB
+    ObservationTimeSeriesBean observationTimeSeriesBean;
     @EJB
     MessagingBean messagingBean;
-    
+    @Context
+    private HttpServletRequest httpServletRequest;
+
     /*
      * PostGIS tip:
      * How to query for observations within a bounding box
@@ -107,16 +91,15 @@ public class ObservationService {
      *   ST_SetSRID(ST_MakeBox2D(ST_MakePoint(2.9004, 57.7511), ST_MakePoint(32.4316, 71.3851)),4326),
      *   gis_geom);
      * First point is SW, last is NE (but could be anything?)
-    */
-    
+     */
+
     /**
-     * 
      * @param organizationId Database ID of the organization
-     * @param pestId Database ID of the pest
-     * @param cropId Database ID of the crop
+     * @param pestId         Database ID of the pest
+     * @param cropId         Database ID of the crop
      * @param cropCategoryId Database IDs of the crop category/categories
-     * @param fromStr format "yyyy-MM-dd"
-     * @param toStr format "yyyy-MM-dd"
+     * @param fromStr        format "yyyy-MM-dd"
+     * @param toStr          format "yyyy-MM-dd"
      * @return Observation objects with all data (full tree)
      */
     @GET
@@ -132,8 +115,7 @@ public class ObservationService {
             @QueryParam("from") String fromStr,
             @QueryParam("to") String toStr,
             @QueryParam("isPositive") Boolean isPositive
-    )
-    {
+    ) {
         return Response.ok().entity(getFilteredObservationsFromBackend(
                 organizationId,
                 pestId,
@@ -144,15 +126,14 @@ public class ObservationService {
                 isPositive
         )).build();
     }
-    
+
     /**
-     * 
      * @param organizationId Database ID of the organization
-     * @param pestId Database ID of the pest
-     * @param cropId Database ID of the crop
+     * @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 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
@@ -170,13 +151,11 @@ public class ObservationService {
             @QueryParam("userUUID") String userUUID,
             @QueryParam("locale") String localeStr,
             @QueryParam("isPositive") Boolean isPositive
-    )
-    {
+    ) {
         return Response.ok().entity(this.getFilteredObservationListItems(organizationId, pestId, cropId, cropCategoryId, fromStr, toStr, userUUID, localeStr, isPositive)).build();
     }
-    
-    
-    
+
+
     private List<ObservationListItem> getFilteredObservationListItems(
             Integer organizationId,
             Integer pestId,
@@ -187,54 +166,51 @@ public class ObservationService {
             String userUUID,
             String localeStr,
             Boolean isPositive
-    )
-    {
+    ) {
         VipsLogicUser user = (VipsLogicUser) httpServletRequest.getSession().getAttribute("user");
-        
-        if(user == null && userUUID != null)
-        {
+
+        if (user == null && userUUID != null) {
             user = userBean.findVipsLogicUser(UUID.fromString(userUUID));
         }
-        ULocale locale = new ULocale(localeStr != null ? localeStr : 
-        	user != null ? user.getOrganizationId().getDefaultLocale() : 
-        		userBean.getOrganization(organizationId).getDefaultLocale());
+        ULocale locale = new ULocale(localeStr != null ? localeStr :
+                user != null ? user.getOrganizationId().getDefaultLocale() :
+                        userBean.getOrganization(organizationId).getDefaultLocale());
 
         List<ObservationListItem> observations = getFilteredObservationsFromBackend(
-                        organizationId,
-                        pestId,
-                        cropId,
-                        cropCategoryId,
-                        fromStr,
-                        toStr,
-                        isPositive,
-                        user
-                ).stream().map(obs -> { 
-                    try {
-                                return obs.getListItem(locale.getLanguage(), 
-                                                observationBean.getLocalizedObservationDataSchema(
-                                                                observationBean.getObservationDataSchema(organizationId, obs.getOrganismId()),
-                                                                httpServletRequest,
-                                                                locale
-                                                                )
-                                                );
-                        } catch (IOException e) {
-                                // TODO Auto-generated catch block
-                                e.printStackTrace();
-                                return null;
-                        }
-                }).collect(Collectors.toList());
+                organizationId,
+                pestId,
+                cropId,
+                cropCategoryId,
+                fromStr,
+                toStr,
+                isPositive,
+                user
+        ).stream().map(obs -> {
+            try {
+                return obs.getListItem(locale.getLanguage(),
+                        observationBean.getLocalizedObservationDataSchema(
+                                observationBean.getObservationDataSchema(organizationId, obs.getOrganismId()),
+                                httpServletRequest,
+                                locale
+                        )
+                );
+            } catch (IOException e) {
+                // TODO Auto-generated catch block
+                e.printStackTrace();
+                return null;
+            }
+        }).collect(Collectors.toList());
         //o.setObservationDataSchema(observationBean.getObservationDataSchema(observer.getOrganizationId().getOrganizationId(), o.getOrganismId()));
         return observations;
     }
-    
+
     /**
-     * 
      * @param organizationId Database ID of the organization
-     * @param pestId Database ID of the pest
-     * @param cropId Database ID of the crop
+     * @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 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
@@ -252,24 +228,21 @@ public class ObservationService {
             @QueryParam("userUUID") String userUUID,
             @QueryParam("locale") String localeStr,
             @QueryParam("isPositive") Boolean isPositive
-    )
-    {
+    ) {
         List<ObservationListItem> observations = this.getFilteredObservationListItems(organizationId, pestId, cropId, cropCategoryId, fromStr, toStr, userUUID, localeStr, isPositive);
         Collections.sort(observations);
         String retVal = "ObservationID;organismName;cropOrganismName;timeOfObservation;lat/lon;observationHeading;observationData";
         GISUtil gisUtil = new GISUtil();
-        for(ObservationListItem obs:observations)
-        {
+        for (ObservationListItem obs : observations) {
             // Get latlon from geoInfo
             List<Geometry> geometries = gisUtil.getGeometriesFromGeoJSON(obs.getGeoInfo());
             Coordinate c = null;
-            if(geometries.size() == 1 && geometries.get(0) instanceof Point)
-            {
-                c = ((Point)geometries.get(0)).getCoordinate();
+            if (geometries.size() == 1 && geometries.get(0) instanceof Point) {
+                c = ((Point) geometries.get(0)).getCoordinate();
             }
-            retVal += "\n" + obs.getObservationId() 
-                    + ";" + obs.getOrganismName() 
-                    + ";" + obs.getCropOrganismName() 
+            retVal += "\n" + obs.getObservationId()
+                    + ";" + obs.getOrganismName()
+                    + ";" + obs.getCropOrganismName()
                     + ";" + obs.getTimeOfObservation()
                     + ";" + (c != null ? c.getY() + "," + c.getX() : "")
                     + ";" + obs.getObservationHeading()
@@ -279,13 +252,12 @@ 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 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 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
      */
     private List<Observation> getFilteredObservationsFromBackend(
@@ -296,33 +268,31 @@ public class ObservationService {
             String fromStr,
             String toStr,
             Boolean isPositive
-    )
-    {
+    ) {
         SimpleDateFormat format = new SimpleDateFormat(Globals.defaultDateFormat);
         //TODO Set correct timeZone!!!
         Date from = null;
         Date to = null;
-        try
-        {
+        try {
             from = fromStr != null ? format.parse(fromStr) : null;
             to = toStr != null ? format.parse(toStr) : null;
+        } catch (ParseException ex) {
+            System.out.println("ERROR");
         }
-        catch(ParseException ex){ System.out.println("ERROR");}
-                
+
         return observationBean.getFilteredObservations(
-            organizationId,
-            pestId,
-            cropId,
-            cropCategoryId,
-            from,
-            to,
-            isPositive
+                organizationId,
+                pestId,
+                cropId,
+                cropCategoryId,
+                from,
+                to,
+                isPositive
         );
-        
+
     }
 
     /**
-     *
      * @param organizationId
      * @param pestId
      * @param cropId
@@ -330,106 +300,104 @@ public class ObservationService {
      * @param fromStr
      * @param toStr
      * @return
-     *
      * @responseExample application/json
      * {
-     *     "type": "FeatureCollection",
-     *     "features": [
-     *         {
-     *             "type": "Feature",
-     *             "id": 18423,
-     *             "geometry": {
-     *                 "type": "Point",
-     *                 "coordinates": [
-     *                     10.782463095356452,
-     *                     59.35794998304658,
-     *                     0.0
-     *                 ]
-     *             },
-     *             "properties": {
-     *                 "observationId": 18446,
-     *                 "observationHeading": "NLR Øst, Huggenes: Det er funnet potettørråte i Råde",
-     *                 "organism": {
-     *                     "organismId": 14,
-     *                     "latinName": "Phytophthora infestans",
-     *                     "tradeName": "",
-     *                     "logicallyDeleted": false,
-     *                     "isPest": true,
-     *                     "isCrop": false,
-     *                     "parentOrganismId": 124,
-     *                     "hierarchyCategoryId": 120,
-     *                     "organismLocaleSet": [
-     *                         {
-     *                             "organismLocalePK": {
-     *                                 "organismId": 14,
-     *                                 "locale": "nb"
-     *                             },
-     *                             "localName": "Potettørråte"
-     *                         },
-     *                         {
-     *                             "organismLocalePK": {
-     *                                 "organismId": 14,
-     *                                 "locale": "en"
-     *                             },
-     *                             "localName": "Late blight"
-     *                         }
-     *                     ],
-     *                     "organismExternalResourceSet": [],
-     *                     "childOrganisms": null,
-     *                     "extraProperties": {},
-     *                     "observationDataSchema": null
-     *                 },
-     *                 "cropOrganism": {
-     *                     "organismId": 5,
-     *                     "latinName": "Solanum tuberosum",
-     *                     "tradeName": "",
-     *                     "logicallyDeleted": false,
-     *                     "isPest": false,
-     *                     "isCrop": true,
-     *                     "parentOrganismId": 4,
-     *                     "hierarchyCategoryId": 120,
-     *                     "organismLocaleSet": [
-     *                         {
-     *                             "organismLocalePK": {
-     *                                 "organismId": 5,
-     *                                 "locale": "bs"
-     *                             },
-     *                             "localName": "Potato"
-     *                         },
-     *                         {
-     *                             "organismLocalePK": {
-     *                                 "organismId": 5,
-     *                                 "locale": "nb"
-     *                             },
-     *                             "localName": "Potet"
-     *                         },
-     *                         {
-     *                             "organismLocalePK": {
-     *                                 "organismId": 5,
-     *                                 "locale": "en"
-     *                             },
-     *                             "localName": "Potato"
-     *                         },
-     *                         {
-     *                             "organismLocalePK": {
-     *                                 "organismId": 5,
-     *                                 "locale": "hr"
-     *                             },
-     *                             "localName": "Krompir"
-     *                         }
-     *                     ],
-     *                     "organismExternalResourceSet": [],
-     *                     "childOrganisms": null,
-     *                     "extraProperties": {},
-     *                     "observationDataSchema": null
-     *                 },
-     *                 "observationText": "Fredag 2.juli ble det gjort første funn av potettørråte i Råde. Det er noen flekker på bladene på øvre del av planten. Smitten ser ut til å ha kommet som sekundærsmitte med vinden.",
-     *                 "timeOfObservation": "2021-07-02T11:00:00+02:00"
-     *             }
-     *         }
-     *     ]
+     * "type": "FeatureCollection",
+     * "features": [
+     * {
+     * "type": "Feature",
+     * "id": 18423,
+     * "geometry": {
+     * "type": "Point",
+     * "coordinates": [
+     * 10.782463095356452,
+     * 59.35794998304658,
+     * 0.0
+     * ]
+     * },
+     * "properties": {
+     * "observationId": 18446,
+     * "observationHeading": "NLR Øst, Huggenes: Det er funnet potettørråte i Råde",
+     * "organism": {
+     * "organismId": 14,
+     * "latinName": "Phytophthora infestans",
+     * "tradeName": "",
+     * "logicallyDeleted": false,
+     * "isPest": true,
+     * "isCrop": false,
+     * "parentOrganismId": 124,
+     * "hierarchyCategoryId": 120,
+     * "organismLocaleSet": [
+     * {
+     * "organismLocalePK": {
+     * "organismId": 14,
+     * "locale": "nb"
+     * },
+     * "localName": "Potettørråte"
+     * },
+     * {
+     * "organismLocalePK": {
+     * "organismId": 14,
+     * "locale": "en"
+     * },
+     * "localName": "Late blight"
+     * }
+     * ],
+     * "organismExternalResourceSet": [],
+     * "childOrganisms": null,
+     * "extraProperties": {},
+     * "observationDataSchema": null
+     * },
+     * "cropOrganism": {
+     * "organismId": 5,
+     * "latinName": "Solanum tuberosum",
+     * "tradeName": "",
+     * "logicallyDeleted": false,
+     * "isPest": false,
+     * "isCrop": true,
+     * "parentOrganismId": 4,
+     * "hierarchyCategoryId": 120,
+     * "organismLocaleSet": [
+     * {
+     * "organismLocalePK": {
+     * "organismId": 5,
+     * "locale": "bs"
+     * },
+     * "localName": "Potato"
+     * },
+     * {
+     * "organismLocalePK": {
+     * "organismId": 5,
+     * "locale": "nb"
+     * },
+     * "localName": "Potet"
+     * },
+     * {
+     * "organismLocalePK": {
+     * "organismId": 5,
+     * "locale": "en"
+     * },
+     * "localName": "Potato"
+     * },
+     * {
+     * "organismLocalePK": {
+     * "organismId": 5,
+     * "locale": "hr"
+     * },
+     * "localName": "Krompir"
+     * }
+     * ],
+     * "organismExternalResourceSet": [],
+     * "childOrganisms": null,
+     * "extraProperties": {},
+     * "observationDataSchema": null
+     * },
+     * "observationText": "Fredag 2.juli ble det gjort første funn av potettørråte i Råde. Det er noen flekker på bladene på øvre del av planten. Smitten ser ut til å ha kommet som sekundærsmitte med vinden.",
+     * "timeOfObservation": "2021-07-02T11:00:00+02:00"
+     * }
+     * }
+     * ]
      * }
-     *
      */
     @GET
     @Path("filter/{organizationId}/geoJSON")
@@ -444,37 +412,37 @@ public class ObservationService {
             @QueryParam("from") String fromStr,
             @QueryParam("to") String toStr,
             @QueryParam("isPositive") Boolean isPositive
-            
-    )
-    {
+
+    ) {
         SimpleDateFormat format = new SimpleDateFormat(Globals.defaultDateFormat);
         //TODO Set correct timeZone!!!
         Date from = null;
         Date to = null;
-        try
-        {
+        try {
             from = fromStr != null ? format.parse(fromStr) : null;
             to = toStr != null ? format.parse(toStr) : null;
+        } catch (ParseException ex) {
+            System.out.println("ERROR");
         }
-        catch(ParseException ex){ System.out.println("ERROR");}
-                
+
         List<Observation> filteredObservations = this.getFilteredObservationsFromBackend(
-            organizationId,
-            pestId,
-            cropId,
-            cropCategoryId,
-            fromStr,
-            toStr,
-            isPositive
+                organizationId,
+                pestId,
+                cropId,
+                cropCategoryId,
+                fromStr,
+                toStr,
+                isPositive
         );
 
         GISEntityUtil gisUtil = new GISEntityUtil();
         return Response.ok().entity(gisUtil.getGeoJSONFromObservations(filteredObservations)).build();
     }
-    
+
     /**
      * Get a list of all observed pests for one organization
      * Practical for building effective select lists
+     *
      * @param organizationId Database ID of organization
      * @return list of all observed pests for one organization
      */
@@ -482,30 +450,30 @@ public class ObservationService {
     @Path("pest/{organizationId}")
     @Produces("application/json;charset=UTF-8")
     @TypeHint(Organism[].class)
-    public Response getObservedPests(@PathParam("organizationId") Integer organizationId)
-    {
+    public Response getObservedPests(@PathParam("organizationId") Integer organizationId) {
         return Response.ok().entity(observationBean.getObservedPests(organizationId)).build();
     }
-    
+
     /**
      * Get a list of all crop cultures where observations have been made for one organization
      * Practical for building effective select lists
      * TODO: Should be cached??
+     *
      * @param organizationId Database ID of organization
-     * @return 
+     * @return
      */
     @GET
     @Path("crop/{organizationId}")
     @Produces("application/json;charset=UTF-8")
     @TypeHint(Organism[].class)
-    public Response getObservedCrops(@PathParam("organizationId") Integer organizationId)
-    {
+    public Response getObservedCrops(@PathParam("organizationId") Integer organizationId) {
         return Response.ok().entity(observationBean.getObservedCrops(organizationId)).build();
     }
-    
-    
+
+
     /**
      * Publicly available observations per organization
+     *
      * @param organizationId Database ID of organization
      * @return APPROVED observations
      */
@@ -514,13 +482,14 @@ public class ObservationService {
     @GZIP
     @Produces("application/json;charset=UTF-8")
     @TypeHint(Observation[].class)
-    public Response getObservations(@PathParam("organizationId") Integer organizationId){
+    public Response getObservations(@PathParam("organizationId") Integer organizationId) {
         return Response.ok().entity(observationBean.getObservations(organizationId, Observation.STATUS_TYPE_ID_APPROVED)).build();
     }
-    
+
     /**
      * Get observations for a user
      * Requires a valid UUID to be provided in the Authorization header
+     *
      * @param observationIds Comma separated list of Observation Ids
      * @return Filtering by observation ids
      */
@@ -529,66 +498,56 @@ public class ObservationService {
     @Produces("application/json;charset=UTF-8")
     @TypeHint(Observation[].class)
     public Response getObservationsForUser(
-    		@QueryParam("observationIds") String observationIds
-    		)
-    {
-    	try
-	    	{
-			VipsLogicUser user = userBean.getUserFromUUID(httpServletRequest);
-			if(user != null)
-			{
-				List<Observation> allObs = observationBean.getObservationsForUser(user);
-				if(observationIds != null)
-				{
-					Set<Integer> observationIdSet = Arrays.asList(observationIds.split(",")).stream()
-							.map(s->Integer.valueOf(s))
-							.collect(Collectors.toSet());
-					return Response.ok().entity(
-							allObs.stream()
-							.filter(obs->observationIdSet.contains(obs.getObservationId()))
-							.collect(Collectors.toList())
-							)
-							.build();
-				}
-				return Response.ok().entity(allObs).build();
-			}
-			else
-			{
-				return Response.status(Status.UNAUTHORIZED).build();
-			}
-    	}
-    	catch(Exception e)
-    	{
-    		return Response.serverError().entity(e.getMessage()).build();
-    	}
+            @QueryParam("observationIds") String observationIds
+    ) {
+        try {
+            VipsLogicUser user = userBean.getUserFromUUID(httpServletRequest);
+            if (user != null) {
+                List<Observation> allObs = observationBean.getObservationsForUser(user);
+                if (observationIds != null) {
+                    Set<Integer> observationIdSet = Arrays.asList(observationIds.split(",")).stream()
+                            .map(s -> Integer.valueOf(s))
+                            .collect(Collectors.toSet());
+                    return Response.ok().entity(
+                                    allObs.stream()
+                                            .filter(obs -> observationIdSet.contains(obs.getObservationId()))
+                                            .collect(Collectors.toList())
+                            )
+                            .build();
+                }
+                return Response.ok().entity(allObs).build();
+            } else {
+                return Response.status(Status.UNAUTHORIZED).build();
+            }
+        } catch (Exception e) {
+            return Response.serverError().entity(e.getMessage()).build();
+        }
     }
-    
+
     /**
      * Get minimized (only synchronization info) observations for a user
      * Used by the Observation app
      * Requires a valid UUID to be provided in the Authorization header
+     *
      * @return
      */
     @GET
     @Path("list/minimized/user")
     @Produces("application/json;charset=UTF-8")
     @TypeHint(ObservationSyncInfo[].class)
-    public Response getMinimizedObservationsForUser()
-    {
-		VipsLogicUser user = userBean.getUserFromUUID(httpServletRequest);
-		if(user != null)
-		{
-			return Response.ok().entity(observationBean.getObservationsForUser(user).stream()
-					.map(obs->new ObservationSyncInfo(obs)).collect(Collectors.toList())).build();
-		}
-		else
-		{
-			return Response.status(Status.UNAUTHORIZED).build();
-		}
+    public Response getMinimizedObservationsForUser() {
+        VipsLogicUser user = userBean.getUserFromUUID(httpServletRequest);
+        if (user != null) {
+            return Response.ok().entity(observationBean.getObservationsForUser(user).stream()
+                    .map(obs -> new ObservationSyncInfo(obs)).collect(Collectors.toList())).build();
+        } else {
+            return Response.status(Status.UNAUTHORIZED).build();
+        }
     }
-   
+
     /**
      * Publicly available observations per organization
+     *
      * @param organizationId Database id of the organization
      * @return APPROVED observations
      */
@@ -602,41 +561,33 @@ public class ObservationService {
             @QueryParam("season") Integer season,
             @QueryParam("timeOfObservationFrom") String timeOfObservationFrom,
             @QueryParam("timeOfObservationTo") String timeOfObservationTo
-    )
-    {
-        if((timeOfObservationFrom != null && ! timeOfObservationFrom.isEmpty()) 
-                || (timeOfObservationTo != null && ! timeOfObservationTo.isEmpty()))
-        {
+    ) {
+        if ((timeOfObservationFrom != null && !timeOfObservationFrom.isEmpty())
+                || (timeOfObservationTo != null && !timeOfObservationTo.isEmpty())) {
             Date from = null;
             Date to = null;
-            try
-            {
+            try {
                 SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd");
-                if(timeOfObservationFrom != null && ! timeOfObservationFrom.isEmpty())
-                {
+                if (timeOfObservationFrom != null && !timeOfObservationFrom.isEmpty()) {
                     from = format.parse(timeOfObservationFrom);
                 }
-                if(timeOfObservationTo != null && ! timeOfObservationTo.isEmpty())
-                {
+                if (timeOfObservationTo != null && !timeOfObservationTo.isEmpty()) {
                     to = format.parse(timeOfObservationTo);
                 }
                 return Response.ok().entity(observationBean.getBroadcastObservations(organizationId, from, to)).build();
-            }
-            catch(ParseException ex)
-            {
+            } catch (ParseException ex) {
                 return Response.status(Response.Status.BAD_REQUEST).entity("Invalid date format").build();
             }
-        }
-        else
-        {
+        } else {
             return Response.ok().entity(observationBean.getBroadcastObservations(organizationId, season)).build();
         }
     }
 
     /**
      * Get one observation
+     *
      * @param observationId Database ID of the observation
-     * @param userUUID UUID that identifies the user (e.g. from VIPSWeb)
+     * @param userUUID      UUID that identifies the user (e.g. from VIPSWeb)
      * @return
      */
     @GET
@@ -646,54 +597,50 @@ public class ObservationService {
     public Response getObservation(
             @PathParam("observationId") Integer observationId,
             @QueryParam("userUUID") String userUUID
-    ){
+    ) {
         // Observation needs to be masked here as well, or does it create trouble for VIPSLogic observation admin?
         Observation o = observationBean.getObservation(observationId);
-        if(o == null)
-        {
+        if (o == null) {
             return Response.status(Status.NOT_FOUND).build();
         }
         // Which organization does this observation belong to?
         VipsLogicUser observer = userBean.getVipsLogicUser(o.getUserId());
         o.setObservationDataSchema(observationBean.getObservationDataSchema(observer.getOrganizationId().getOrganizationId(), o.getOrganismId()));
-      
+
         VipsLogicUser user = (VipsLogicUser) httpServletRequest.getSession().getAttribute("user");
-        if(user == null && userUUID != null)
-        {
+        if (user == null && userUUID != null) {
             user = userBean.findVipsLogicUser(UUID.fromString(userUUID));
         }
         // Modification of location information:
         // 1) If location is private, only the owner or super users/org admins may view them
         // slett all geoInfo
-        if(user == null || (! user.isSuperUser() && ! user.isOrganizationAdmin()))
-        {
+        if (user == null || (!user.isSuperUser() && !user.isOrganizationAdmin())) {
             // Hide completely for all users except super and orgadmin
-            if(o.getLocationIsPrivate() && (user == null || ! o.getUserId().equals(user.getUserId()))) 
-            {
+            if (o.getLocationIsPrivate() && (user == null || !o.getUserId().equals(user.getUserId()))) {
                 o.setGeoinfos(null);
             }
             // This means the user wants to hide the exact location,
             // so mask for all users except super and orgadmin
-            else if(o.getPolygonService() != null) 
-            {
+            else if (o.getPolygonService() != null) {
                 //System.out.println("Masking observation");
                 List<Observation> intermediary = new ArrayList<>();
                 intermediary.add(o);
-                intermediary = this.maskObservations(o.getPolygonService(), 
+                intermediary = this.maskObservations(o.getPolygonService(),
                         observationBean.getObservationsWithLocations(
                                 observationBean.getObservationsWithGeoInfo(intermediary)
                         )
-                    );
+                );
                 o = intermediary.get(0);
             }
         }
-        
+
         return Response.ok().entity(o).build();
     }
-    
+
     /**
      * Polygon services are used to mask observations (privacy concerns)
      * They may vary greatly between organizations (different countries)
+     *
      * @param organizationId Database id of the organization
      * @return A list of available polygon services for the requested organization
      */
@@ -702,45 +649,42 @@ public class ObservationService {
     @Produces("application/json;charset=UTF-8")
     @TypeHint(PolygonService[].class)
     public Response getPolygonServicesForOrganization(
-    		@PathParam("organizationId") Integer organizationId
-    		)
-    {
-    	return Response.ok().entity(observationBean.getPolygonServicesForOrganization(organizationId)).build();
+            @PathParam("organizationId") Integer organizationId
+    ) {
+        return Response.ok().entity(observationBean.getPolygonServicesForOrganization(organizationId)).build();
     }
-    
-   
+
+
     /**
      * Deletes a gis entity and its corresponding observation
+     *
      * @param gisId Database id of the gis entity
-     * @return 
+     * @return
      */
     @DELETE
     @Path("gisobservation/{gisId}")
-    public Response deleteGisObservation(@PathParam("gisId") Integer gisId)
-    {
+    public Response deleteGisObservation(@PathParam("gisId") Integer gisId) {
         VipsLogicUser user = (VipsLogicUser) httpServletRequest.getSession().getAttribute("user");
         // If no user, send error message back to client
-        if(user == null)
-        {
+        if (user == null) {
             return Response.status(Response.Status.UNAUTHORIZED).build();
         }
-        if(!userBean.authorizeUser(user, 
-                    VipsLogicRole.OBSERVER, 
-                    VipsLogicRole.OBSERVATION_AUTHORITY, 
-                    VipsLogicRole.ORGANIZATION_ADMINISTRATOR,
-                    VipsLogicRole.SUPERUSER
-                )
-            )
-        {
+        if (!userBean.authorizeUser(user,
+                VipsLogicRole.OBSERVER,
+                VipsLogicRole.OBSERVATION_AUTHORITY,
+                VipsLogicRole.ORGANIZATION_ADMINISTRATOR,
+                VipsLogicRole.SUPERUSER
+        )
+        ) {
             return Response.status(Response.Status.FORBIDDEN).build();
         }
         observationBean.deleteGisObservationByGis(gisId);
         return Response.noContent().build();
     }
-    
+
     /**
-     *
      * Stores an observation from geoJson
+     *
      * @param geoJSON
      * @return the Url of the created entity (observation)
      */
@@ -749,26 +693,22 @@ public class ObservationService {
     @Path("gisobservation")
     @Consumes("application/json;charset=UTF-8")
     @Produces("application/json;charset=UTF-8")
-    public Response storeGisObservation(String geoJSON)
-    {
-        try
-        {
+    public Response storeGisObservation(String geoJSON) {
+        try {
             // Create the Observation
             Observation observation = observationBean.getObservationFromGeoJSON(geoJSON);
             VipsLogicUser user = (VipsLogicUser) httpServletRequest.getSession().getAttribute("user");
             // If no user, send error message back to client
-            if(user == null)
-            {
+            if (user == null) {
                 return Response.status(Response.Status.UNAUTHORIZED).build();
             }
-            if(!userBean.authorizeUser(user, 
-                    VipsLogicRole.OBSERVER, 
-                    VipsLogicRole.OBSERVATION_AUTHORITY, 
+            if (!userBean.authorizeUser(user,
+                    VipsLogicRole.OBSERVER,
+                    VipsLogicRole.OBSERVATION_AUTHORITY,
                     VipsLogicRole.ORGANIZATION_ADMINISTRATOR,
                     VipsLogicRole.SUPERUSER
-                )
             )
-            {
+            ) {
                 return Response.status(Response.Status.FORBIDDEN).build();
             }
             observation.setUserId(user.getUserId());
@@ -777,14 +717,14 @@ public class ObservationService {
             observation = observationBean.storeObservation(observation);
             GISEntityUtil gisUtil = new GISEntityUtil();
             return Response.created(URI.create("/observation/" + observation.getObservationId())).entity(gisUtil.getGeoJSONFromObservation(observation)).build();
-        }catch (IOException ex)
-        {
+        } catch (IOException ex) {
             return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(ex).build();
         }
     }
 
     /**
      * Returns the time of the first observation in the system of the pest with given Id
+     *
      * @param organismId Database id of the given organism
      * @return the time of the first observation in the system of the pest with given Id
      */
@@ -792,16 +732,16 @@ public class ObservationService {
     @Path("first/{organismId}")
     @Produces("text/plain;charset=UTF-8")
     @TypeHint(Date.class)
-    public Response getFirstObservation(@PathParam("organismId") Integer organismId)
-    {
+    public Response getFirstObservation(@PathParam("organismId") Integer organismId) {
         Date firstObsTime = observationBean.getFirstObservationTime(organismId);
         return firstObsTime != null ? Response.ok().entity(firstObsTime).build()
                 : Response.status(404).entity("No observations of organism with id=" + organismId).build();
     }
-    
+
     /**
      * When was the last time a change was made in cropCategories or organisms? Used for sync with the Field observation
      * app.
+     *
      * @return last time a change was made in cropCategories or organisms
      * @responseExample application/json {"lastUpdated": "2021-02-08T00:00:00Z"}
      */
@@ -809,24 +749,22 @@ public class ObservationService {
     @Path("organismsystemupdated")
     @Produces(MediaType.APPLICATION_JSON)
     @TypeHint(Date.class)
-    public Response getDateOfLastOrganismSystemUpdate()
-    {
-    	HashMap<String, Object> result = new HashMap<>();
-    	Instant lastUpdated = organismBean.getLatestUpdateOfOrganisms();
-    	result.put("lastUpdated", lastUpdated != null ? lastUpdated: "1970-01-01T00:00:00Z");
-    	return Response.ok().entity(result).build();
+    public Response getDateOfLastOrganismSystemUpdate() {
+        HashMap<String, Object> result = new HashMap<>();
+        Instant lastUpdated = organismBean.getLatestUpdateOfOrganisms();
+        result.put("lastUpdated", lastUpdated != null ? lastUpdated : "1970-01-01T00:00:00Z");
+        return Response.ok().entity(result).build();
     }
-    
+
 
     /**
-     * 
      * @param organizationId Database id of the organization
-     * @param pestId Database id of the pest
-     * @param cropId Database id of the crop
+     * @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 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(
@@ -842,17 +780,15 @@ public class ObservationService {
         List<Observation> filteredObservations = this.getFilteredObservationsFromBackend(organizationId, pestId, cropId, cropCategoryId, fromStr, toStr, isPositive);
         //filteredObservations.forEach(o->System.out.println(o.getObservationId()));
         // If superuser or orgadmin: Return everything, unchanged, uncensored
-        if(user != null && (user.isSuperUser() || user.isOrganizationAdmin()))
-        {
+        if (user != null && (user.isSuperUser() || user.isOrganizationAdmin())) {
             return filteredObservations;
         }
-        List<Observation> retVal = filteredObservations.stream().filter(obs->obs.getBroadcastMessage() || (isPositive == null || !isPositive)).collect(Collectors.toList());
+        List<Observation> retVal = filteredObservations.stream().filter(obs -> obs.getBroadcastMessage() || (isPositive == null || !isPositive)).collect(Collectors.toList());
         //retVal.forEach(o->System.out.println(o.getObservationId()));
         retVal = this.maskObservations(retVal);
         //retVal.forEach(o->System.out.println(o.getObservationId()));
         // If user is not logged in, return only the publicly available observations
-        if(user == null)
-        {
+        if (user == null) {
             return retVal;
         }
         // Else: This is a registered user without special privileges. Show public observations + user's own
@@ -863,68 +799,63 @@ public class ObservationService {
     /**
      * Runs through the observations, check each to see if it should be masked
      * (for privacy reasons) through a polygon service
+     *
      * @param observations The list of observations to check
      * @return The list of observations, with the locations masked with the selected polygon service
      */
     private List<Observation> maskObservations(List<Observation> observations) {
         //System.out.println("maskObservations(List<Observation> observations)");
-        
+
         // Placing all observations with a polygon service in the correct bucket.
         Map<PolygonService, List<Observation>> registeredPolygonServicesInObservationList = new HashMap<>();
-        observations.stream().filter((obs) -> { return (!obs.getLocationIsPrivate() && obs.getPolygonService() != null);}).forEachOrdered((obs) -> {
+        observations.stream().filter((obs) -> {
+            return (!obs.getLocationIsPrivate() && obs.getPolygonService() != null);
+        }).forEachOrdered((obs) -> {
             List<Observation> obsWithPolyServ = registeredPolygonServicesInObservationList.getOrDefault(obs.getPolygonService(), new ArrayList<>());
             obsWithPolyServ.add(obs);
             registeredPolygonServicesInObservationList.put(obs.getPolygonService(), obsWithPolyServ);
         });
-        
+
         // No buckets filled = No masking needed, return list unmodified
-        if(registeredPolygonServicesInObservationList.isEmpty())
-        {
-            return observations; 
-        }
-        else
-        {
+        if (registeredPolygonServicesInObservationList.isEmpty()) {
+            return observations;
+        } else {
             // Loop through, mask
-            Map<Integer,Observation> maskedObservations = new HashMap<>();
+            Map<Integer, Observation> maskedObservations = new HashMap<>();
             registeredPolygonServicesInObservationList.keySet().forEach((pService) -> {
                 this.maskObservations(pService, registeredPolygonServicesInObservationList.get(pService))
-                        .forEach(o->maskedObservations.put(o.getObservationId(), o));
+                        .forEach(o -> maskedObservations.put(o.getObservationId(), o));
             });
-            
+
             // Adding the rest of the observations (the ones that don't need masking)
-            observations.stream().filter(o->maskedObservations.get(o.getObservationId())==null).forEach(o->maskedObservations.put(o.getObservationId(), o));
+            observations.stream().filter(o -> maskedObservations.get(o.getObservationId()) == null).forEach(o -> maskedObservations.put(o.getObservationId(), o));
             return new ArrayList<>(maskedObservations.values());
         }
     }
 
     /**
-     *
      * @param polygonService The polygon service that should be used for masking
-     * @param observations The list of observations to mask
+     * @param observations   The list of observations to mask
      * @return The list of observations, with the locations masked with the selected polygon service
      */
-    private List<Observation> maskObservations(PolygonService polygonService, List<Observation> observations)
-    {
+    private List<Observation> maskObservations(PolygonService polygonService, List<Observation> observations) {
         //observations.forEach(o->System.out.println(o.getObservationId()));
         Client client = ClientBuilder.newClient();
         WebTarget target = client.target(polygonService.getGisSearchUrlTemplate());
         List<ReferencedPoint> points = observations.stream()
-                .filter(obs -> (obs.getGeoinfos()!=null && !obs.getGeoinfos().isEmpty()) || obs.getLocation() != null)
-                .map(obs->{
+                .filter(obs -> (obs.getGeoinfos() != null && !obs.getGeoinfos().isEmpty()) || obs.getLocation() != null)
+                .map(obs -> {
                     ReferencedPoint rp = new ReferencedPoint();
                     rp.setId(String.valueOf(obs.getObservationId()));
-                    if(obs.getGeoinfos() != null)
-                    {
+                    if (obs.getGeoinfos() != null) {
                         rp.setLon(obs.getGeoinfos().get(0).getGisGeom().getCoordinate().x);
                         rp.setLat(obs.getGeoinfos().get(0).getGisGeom().getCoordinate().y);
-                    }
-                    else
-                    {
+                    } else {
                         rp.setLon(obs.getLocation().getLongitude());
                         rp.setLat(obs.getLocation().getLatitude());
                     }
                     return rp;
-        }).collect(Collectors.toList());
+                }).collect(Collectors.toList());
         /*System.out.println("maskobservations - target.request() about to be called");
         ObjectMapper oMapper = new ObjectMapper();
         try {
@@ -936,13 +867,11 @@ public class ObservationService {
                 .post(Entity.entity(points.toArray(new ReferencedPoint[points.size()]), MediaType.APPLICATION_JSON), PointMappingResponse.class);
         // We need to loop through the observations and find corresponding featurecollections and replace those
         Map<Integer, Feature> indexedPolygons = new HashMap<>();
-        for(Feature feature:response.getFeatureCollection().getFeatures())
-        {
-            indexedPolygons.put((Integer)feature.getProperties().get("id"), feature);
+        for (Feature feature : response.getFeatureCollection().getFeatures()) {
+            indexedPolygons.put((Integer) feature.getProperties().get("id"), feature);
         }
         GISEntityUtil gisEntityUtil = new GISEntityUtil();
-        for(Map mapping:response.getMapping())
-        {
+        for (Map mapping : response.getMapping()) {
             Integer observationId = Integer.valueOf((String) mapping.get("id"));
             Integer borderId = (Integer) mapping.get("borderid");
             observations.stream().filter((o) -> (o.getObservationId().equals(observationId))).forEachOrdered((o) -> {
@@ -954,13 +883,14 @@ public class ObservationService {
                 o.setLocationPointOfInterestId(null);
             });
         }
-        
+
         return observations;
     }
-    
+
     /**
      * This service is used by the VIPS Field observation app to sync data stored locally on the smartphone with the
      * state of an (potentially non-existent) observation in the VIPSLogic database
+     *
      * @param observationJson Json representation of the observation(s)
      * @return The observation(s) in their merged state, serialized to Json
      */
@@ -970,144 +900,141 @@ public class ObservationService {
     @Produces("application/json;charset=UTF-8")
     @TypeHint(Observation.class)
     public Response syncObservationFromApp(
-    		String observationJson
-    		)
-    {
-    	try
-    	{
-	    	VipsLogicUser user = userBean.getUserFromUUID(httpServletRequest);
-	    	if(user == null)
-	    	{
-	    		return Response.status(Status.UNAUTHORIZED).build();
-	    	}
-	    	ObjectMapper oM = new ObjectMapper();
-	    	SimpleDateFormat df = new SimpleDateFormat(Globals.defaultTimestampFormat);
-	    	try {
-	    		Map<Object,Object> mapFromApp = oM.readValue(observationJson, new TypeReference<HashMap<Object,Object>>(){});
-				// Check if it is marked as deleted or not
-				if(mapFromApp.get("deleted") != null && ((Boolean)mapFromApp.get("deleted").equals(true)))
-				{
-					if(observationBean.getObservation((Integer)mapFromApp.get("observationId")) != null)
-					{
-						observationBean.deleteObservation((Integer)mapFromApp.get("observationId"));
-						return Response.ok().build();
-					}
-					else
-					{
-						return Response.status(Status.NOT_FOUND).build();
-					}
-				}
-				else
-				{
-					Observation mergeObs = ((Integer)mapFromApp.get("observationId")) > 0 ? observationBean.getObservation((Integer)mapFromApp.get("observationId")): new Observation();
-					// Trying to sync a non-existing observation
-					if(mergeObs == null)
-					{
-						return Response.status(Status.NOT_FOUND).build();
-					}
-					// Pest organism
-					mergeObs.setOrganism(organismBean.getOrganism((Integer)mapFromApp.get("organismId")));
-					// Crop organism
-					mergeObs.setCropOrganism(organismBean.getOrganism((Integer)mapFromApp.get("cropOrganismId")));
-					// Other properties
-					mergeObs.setTimeOfObservation(oM.convertValue(mapFromApp.get("timeOfObservation"), new TypeReference<Date>(){}));
+            String observationJson
+    ) {
+        LOGGER.info("In syncObservationFromApp");
+        LOGGER.info(observationJson);
+
+        try {
+            VipsLogicUser user = userBean.getUserFromUUID(httpServletRequest);
+            if (user == null) {
+                return Response.status(Status.UNAUTHORIZED).build();
+            }
+            ObjectMapper oM = new ObjectMapper();
+            SimpleDateFormat df = new SimpleDateFormat(Globals.defaultTimestampFormat);
+            try {
+                Map<Object, Object> mapFromApp = oM.readValue(observationJson, new TypeReference<HashMap<Object, Object>>() {
+                });
+                // Check if it is marked as deleted or not
+                if (mapFromApp.get("deleted") != null && ((Boolean) mapFromApp.get("deleted").equals(true))) {
+                    if (observationBean.getObservation((Integer) mapFromApp.get("observationId")) != null) {
+                        observationBean.deleteObservation((Integer) mapFromApp.get("observationId"));
+                        return Response.ok().build();
+                    } else {
+                        return Response.status(Status.NOT_FOUND).build();
+                    }
+                } else {
+                    Integer observationId = (Integer) mapFromApp.get("observationId");
+                    Observation mergeObs;
+                    if(observationId > 0) {
+                        mergeObs = observationBean.getObservation(observationId);
+                        if (mergeObs == null) {
+                            return Response.status(Status.NOT_FOUND).build();
+                        }
+                    } else {
+                        mergeObs = new Observation("APP");
+                    }
+
+                    // Observation time series
+                    if(mapFromApp.get("observationTimeSeriesId") != null) {
+                        Integer observationTimeSeriesId = (Integer) mapFromApp.get("observationTimeSeriesId");
+                        mergeObs.setObservationTimeSeries(observationTimeSeriesBean.getObservationTimeSeries(observationTimeSeriesId));
+                    }
+                    // Pest organism
+                    mergeObs.setOrganism(organismBean.getOrganism((Integer) mapFromApp.get("organismId")));
+                    // Crop organism
+                    mergeObs.setCropOrganism(organismBean.getOrganism((Integer) mapFromApp.get("cropOrganismId")));
+                    // Other properties
+                    mergeObs.setTimeOfObservation(oM.convertValue(mapFromApp.get("timeOfObservation"), new TypeReference<Date>() {
+                    }));
                     mergeObs.setIsPositive(mapFromApp.get("isPositive") != null ? (Boolean) mapFromApp.get("isPositive") : false);
-					mergeObs.setUserId(mapFromApp.get("userId") != null ? Integer.valueOf((Integer)mapFromApp.get("userId")): user.getUserId());
-					mergeObs.setGeoinfo((String)mapFromApp.get("geoinfo"));
-					mergeObs.setLocationPointOfInterestId(mapFromApp.get("locationPointOfInterestId") != null ? (Integer) mapFromApp.get("locationPointOfInterestId") : null);
-					mergeObs.setObservationHeading(mapFromApp.get("observationHeading") != null ? (String) mapFromApp.get("observationHeading") : null);
-					mergeObs.setObservationText(mapFromApp.get("observationText") != null ? (String) mapFromApp.get("observationText") : null);
-					mergeObs.setBroadcastMessage(mapFromApp.get("broadcastMessage") != null ? (Boolean) mapFromApp.get("broadcastMessage") : false);
-					mergeObs.setStatusTypeId(Integer.valueOf((Integer)mapFromApp.get("statusTypeId")));
-					// If the user has the role of observation approver, change to approved if set to pending
-					if(mergeObs.getStatusTypeId().equals(ObservationStatusType.STATUS_PENDING) && user.isObservationAuthority())
-					{
-						mergeObs.setStatusTypeId(ObservationStatusType.STATUS_APPROVED);
-					}
-					mergeObs.setStatusChangedByUserId(mapFromApp.get("statusChangedByUserId") != null ? (Integer) mapFromApp.get("statusChangedByUserId") : null);
-					mergeObs.setStatusChangedTime(mapFromApp.get("timeOfObservation") != null ? oM.convertValue(mapFromApp.get("timeOfObservation"), new TypeReference<Date>(){}) : null);
-					mergeObs.setStatusRemarks(mapFromApp.get("statusRemarks") != null ? (String) mapFromApp.get("statusRemarks") : null);
-					mergeObs.setIsQuantified(mapFromApp.get("isQuantified") != null ? (Boolean) mapFromApp.get("isQuantified") : false);
-					mergeObs.setLocationIsPrivate(mapFromApp.get("locationIsPrivate") != null ? (Boolean) mapFromApp.get("locationIsPrivate") : false);
-					mergeObs.setPolygonService(mapFromApp.get("polygonService") != null && ! ((String)mapFromApp.get("polygonService")).isEmpty() ? oM.convertValue(mapFromApp.get("polygonService"), new TypeReference<PolygonService>(){}) : null);
-					mergeObs.setObservationDataSchema(observationBean.getObservationDataSchema(user.getOrganization_id(), mergeObs.getOrganismId()));
-					mergeObs.setObservationData(mapFromApp.get("observationData") != null ? mapFromApp.get("observationData").toString() : null);
-					mergeObs.setLastEditedBy(user.getUserId());
-					mergeObs.setLastEditedTime(new Date());
-					
-					// Input check before storing
-					// Location must be set
-					if((mergeObs.getGeoinfo() == null || mergeObs.getGeoinfo().trim().isEmpty()) && mergeObs.getLocationPointOfInterestId() == null)
-					{
-						return Response.status(Status.BAD_REQUEST).entity("The observation is missing location data.").build();
-					}
-                                        
-                                        boolean sendNotification = false;    
-                                        // Storing approval status
-                                        // If superusers or user with correct authorization: Set as approved and send message
-                                        if(userBean.authorizeUser(user, VipsLogicRole.OBSERVATION_AUTHORITY, VipsLogicRole.ORGANIZATION_ADMINISTRATOR, VipsLogicRole.SUPERUSER))
-                                        {
-                                            LOGGER.debug("user properly authorized to register observations");
-                                            LOGGER.debug("observation Id=" + mergeObs.getObservationId());
-                                            LOGGER.debug("broadcast this message? " + mergeObs.getBroadcastMessage());
-                                            if(mergeObs.getObservationId() == null || mergeObs.getObservationId() <= 0)
-                                            {
-                                                mergeObs.setStatusTypeId(Observation.STATUS_TYPE_ID_APPROVED);
-                                                sendNotification = mergeObs.getBroadcastMessage(); // Only send the ones intended for sending
-                                            }
-                                        }
-                                        else if(mergeObs.getObservationId() == null || mergeObs.getObservationId() <= 0)
-                                        {
-                                            mergeObs.setStatusTypeId(Observation.STATUS_TYPE_ID_PENDING);
-                                        }
-					
-					// We need to get an observation Id before storing the illustrations!
-					mergeObs = observationBean.storeObservation(mergeObs);
-                                        
-                                        
-					
-					// ObservationIllustrationSet
-					// Including data that may need to be stored
-					if(mapFromApp.get("observationIllustrationSet") != null)
-					{
-						List<Map<Object,Object>> illusMaps = (List<Map<Object,Object>>) mapFromApp.get("observationIllustrationSet");
-						for(Map<Object,Object> illusMap:illusMaps)
-						{
-							ObservationIllustrationPK pk = oM.convertValue(illusMap.get("observationIllustrationPK"), new TypeReference<ObservationIllustrationPK>(){});
-							
-							if(illusMap.get("deleted") != null && ((Boolean) illusMap.get("deleted")) == true)
-							{
-								observationBean.deleteObservationIllustration(mergeObs, new String[] {pk.getFileName()});
-							}
-							else if(illusMap.get("uploaded") != null && ((Boolean) illusMap.get("uploaded")) == false && illusMap.get("imageTextData") != null)
-							{
-								mergeObs = observationBean.storeObservationIllustration(mergeObs, pk.getFileName(), (String)illusMap.get("imageTextData"));
-							}
-						}
-					}
-                                        LOGGER.debug("sendNotification? " + sendNotification);
-                                        
-                                        // All transactions finished, we can send notifications
-                                        // if conditions are met
-                                        if(sendNotification && !
-                                                (System.getProperty("DISABLE_MESSAGING_SYSTEM") != null && System.getProperty("DISABLE_MESSAGING_SYSTEM").equals("true"))
-                                                )
-                                        {
-                                            LOGGER.debug("Sending the message!");
-                                            messagingBean.sendUniversalMessage(mergeObs);
-                                        }
-                                        
-					return Response.ok().entity(mergeObs).build();
-				}
-			} catch (IOException e) {
-				return Response.serverError().entity(e).build();
-			}
-    	}
-    	catch(Exception e)
-    	{
-            e.printStackTrace();
-    		return Response.serverError().entity(e).build();
-    	}
-    	
+                    mergeObs.setUserId(mapFromApp.get("userId") != null ? (Integer) mapFromApp.get("userId") : user.getUserId());
+                    mergeObs.setGeoinfo((String) mapFromApp.get("geoinfo"));
+                    mergeObs.setLocationPointOfInterestId(mapFromApp.get("locationPointOfInterestId") != null ? (Integer) mapFromApp.get("locationPointOfInterestId") : null);
+                    mergeObs.setObservationHeading(mapFromApp.get("observationHeading") != null ? (String) mapFromApp.get("observationHeading") : null);
+                    mergeObs.setObservationText(mapFromApp.get("observationText") != null ? (String) mapFromApp.get("observationText") : null);
+                    mergeObs.setBroadcastMessage(mapFromApp.get("broadcastMessage") != null ? (Boolean) mapFromApp.get("broadcastMessage") : false);
+                    mergeObs.setStatusTypeId((Integer) mapFromApp.get("statusTypeId"));
+                    // If the user has the role of observation approver, change to approved if set to pending
+                    if (mergeObs.getStatusTypeId().equals(ObservationStatusType.STATUS_PENDING) && user.isObservationAuthority()) {
+                        mergeObs.setStatusTypeId(ObservationStatusType.STATUS_APPROVED);
+                    }
+                    mergeObs.setStatusChangedByUserId(mapFromApp.get("userId") != null ? (Integer) mapFromApp.get("userId") : null);
+                    mergeObs.setStatusChangedTime(mapFromApp.get("timeOfObservation") != null ? oM.convertValue(mapFromApp.get("timeOfObservation"), new TypeReference<Date>() {
+                    }) : null);
+                    mergeObs.setStatusRemarks(mapFromApp.get("statusRemarks") != null ? (String) mapFromApp.get("statusRemarks") : null);
+                    mergeObs.setIsQuantified(mapFromApp.get("isQuantified") != null ? (Boolean) mapFromApp.get("isQuantified") : false);
+                    mergeObs.setLocationIsPrivate(mapFromApp.get("locationIsPrivate") != null ? (Boolean) mapFromApp.get("locationIsPrivate") : false);
+                    Object polygonServiceValue = mapFromApp.get("polygonService");
+                    if(polygonServiceValue != null && !polygonServiceValue.toString().isBlank()) {
+                        PolygonService polygonService = oM.convertValue(mapFromApp.get("polygonService"), PolygonService.class);
+                        mergeObs.setPolygonService(polygonService);
+                    }
+
+                    mergeObs.setObservationDataSchema(observationBean.getObservationDataSchema(user.getOrganization_id(), mergeObs.getOrganismId()));
+                    mergeObs.setObservationData(mapFromApp.get("observationData") != null ? mapFromApp.get("observationData").toString() : null);
+                    mergeObs.setLastEditedBy(user.getUserId());
+                    mergeObs.setLastEditedTime(new Date());
+
+                    // Input check before storing
+                    // Location must be set
+                    if ((mergeObs.getGeoinfo() == null || mergeObs.getGeoinfo().trim().isEmpty()) && mergeObs.getLocationPointOfInterestId() == null) {
+                        return Response.status(Status.BAD_REQUEST).entity("{\"error\": \"The observation is missing location data.\"}").type(MediaType.APPLICATION_JSON).build();
+                    }
+
+                    boolean sendNotification = false;
+                    // Storing approval status
+                    // If superusers or user with correct authorization: Set as approved and send message
+                    if (userBean.authorizeUser(user, VipsLogicRole.OBSERVATION_AUTHORITY, VipsLogicRole.ORGANIZATION_ADMINISTRATOR, VipsLogicRole.SUPERUSER)) {
+                        LOGGER.debug("user properly authorized to register observations");
+                        LOGGER.debug("observation Id=" + mergeObs.getObservationId());
+                        LOGGER.debug("broadcast this message? " + mergeObs.getBroadcastMessage());
+                        if (mergeObs.getObservationId() == null || mergeObs.getObservationId() <= 0) {
+                            mergeObs.setStatusTypeId(Observation.STATUS_TYPE_ID_APPROVED);
+                            sendNotification = mergeObs.getBroadcastMessage(); // Only send the ones intended for sending
+                        }
+                    } else if (mergeObs.getObservationId() == null || mergeObs.getObservationId() <= 0) {
+                        mergeObs.setStatusTypeId(Observation.STATUS_TYPE_ID_PENDING);
+                    }
+
+                    // We need to get an observation Id before storing the illustrations!
+                    mergeObs = observationBean.storeObservation(mergeObs);
+
+                    // ObservationIllustrationSet
+                    // Including data that may need to be stored
+                    if (mapFromApp.get("observationIllustrationSet") != null) {
+                        List<Map<Object, Object>> illusMaps = (List<Map<Object, Object>>) mapFromApp.get("observationIllustrationSet");
+                        for (Map<Object, Object> illusMap : illusMaps) {
+                            ObservationIllustrationPK pk = oM.convertValue(illusMap.get("observationIllustrationPK"), new TypeReference<ObservationIllustrationPK>() {
+                            });
+
+                            if (illusMap.get("deleted") != null && ((Boolean) illusMap.get("deleted")) == true) {
+                                observationBean.deleteObservationIllustration(mergeObs, new String[]{pk.getFileName()});
+                            } else if (illusMap.get("uploaded") != null && ((Boolean) illusMap.get("uploaded")) == false && illusMap.get("imageTextData") != null) {
+                                mergeObs = observationBean.storeObservationIllustration(mergeObs, pk.getFileName(), (String) illusMap.get("imageTextData"));
+                            }
+                        }
+                    }
+                    LOGGER.info("sendNotification? " + sendNotification);
+
+                    // All transactions finished, we can send notifications
+                    // if conditions are met
+                    if (sendNotification && !
+                            (System.getProperty("DISABLE_MESSAGING_SYSTEM") != null && System.getProperty("DISABLE_MESSAGING_SYSTEM").equals("true"))
+                    ) {
+                        LOGGER.debug("Sending the message!");
+                        messagingBean.sendUniversalMessage(mergeObs);
+                    }
+
+                    return Response.ok().entity(mergeObs).build();
+                }
+            } catch (IOException e) {
+                return Response.serverError().entity(e).build();
+            }
+        } catch (Exception e) {
+            LOGGER.error("Exception occurred while syncing observations from app", e);
+            return Response.serverError().entity(e).build();
+        }
+
     }
 }
diff --git a/src/main/java/no/nibio/vips/logic/service/ObservationTimeSeriesService.java b/src/main/java/no/nibio/vips/logic/service/ObservationTimeSeriesService.java
new file mode 100644
index 0000000000000000000000000000000000000000..dc2aa0e64b9dd06d5f22013ff74a62e169cfdf46
--- /dev/null
+++ b/src/main/java/no/nibio/vips/logic/service/ObservationTimeSeriesService.java
@@ -0,0 +1,258 @@
+package no.nibio.vips.logic.service;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.webcohesion.enunciate.metadata.rs.TypeHint;
+import no.nibio.vips.logic.controller.session.ObservationTimeSeriesBean;
+import no.nibio.vips.logic.controller.session.OrganismBean;
+import no.nibio.vips.logic.controller.session.UserBean;
+import no.nibio.vips.logic.entity.Gis;
+import no.nibio.vips.logic.entity.ObservationTimeSeries;
+import no.nibio.vips.logic.entity.PolygonService;
+import no.nibio.vips.logic.entity.VipsLogicUser;
+import no.nibio.vips.logic.entity.rest.PointMappingResponse;
+import no.nibio.vips.logic.entity.rest.ReferencedPoint;
+import no.nibio.vips.logic.util.GISEntityUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.wololo.geojson.Feature;
+
+import javax.ejb.EJB;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.*;
+import javax.ws.rs.client.Client;
+import javax.ws.rs.client.ClientBuilder;
+import javax.ws.rs.client.Entity;
+import javax.ws.rs.client.WebTarget;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import java.io.IOException;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Path("rest/observationtimeseries")
+public class ObservationTimeSeriesService {
+
+    public static final String APPLICATION_JSON = "application/json;charset=UTF-8";
+    private final static Logger LOGGER = LoggerFactory.getLogger(ObservationTimeSeriesService.class);
+    private static final String DELETED = "deleted";
+    private static final String OBSERVATION_TIME_SERIES_ID = "observationTimeSeriesId";
+    private static final String ORGANISM_ID = "organismId";
+    private static final String CROP_ORGANISM_ID = "cropOrganismId";
+    private static final String USER_ID = "userId";
+    private static final String LOCATION_POINT_OF_INTEREST_ID = "locationPointOfInterestId";
+    private static final String YEAR = "year";
+    private static final String NAME = "name";
+    private static final String DESCRIPTION = "description";
+    private static final String LOCATION_IS_PRIVATE = "locationIsPrivate";
+    private static final String POLYGON_SERVICE = "polygonService";
+    @EJB
+    UserBean userBean;
+    @EJB
+    ObservationTimeSeriesBean observationTimeSeriesBean;
+    @EJB
+    OrganismBean organismBean;
+
+    @Context
+    private HttpServletRequest httpServletRequest;
+
+    @GET
+    @Path("list/user")
+    @Produces(APPLICATION_JSON)
+    @TypeHint(ObservationTimeSeries[].class)
+    public Response getObservationsTimeSeriesForUser(@QueryParam("observationTimeSeriesIds") String otsIds) {
+        LOGGER.info("In getObservationsTimeSeriesForUser observationTimeSeriesIds=" + otsIds);
+        try {
+            VipsLogicUser user = userBean.getUserFromUUID(httpServletRequest);
+            if (user != null) {
+                List<ObservationTimeSeries> allObs = observationTimeSeriesBean.getObservationTimeSeriesListForUser(user);
+                if (otsIds != null) {
+                    Set<Integer> observationIdSet = Arrays.stream(otsIds.split(","))
+                            .map(Integer::valueOf)
+                            .collect(Collectors.toSet());
+                    return Response.ok().entity(
+                                    allObs.stream()
+                                            .filter(obs -> observationIdSet.contains(obs.getObservationTimeSeriesId()))
+                                            .collect(Collectors.toList())
+                            )
+                            .build();
+                }
+                return Response.ok().entity(allObs).build();
+            } else {
+                return Response.status(Response.Status.UNAUTHORIZED).build();
+            }
+        } catch (Exception e) {
+            return Response.serverError().entity(e.getMessage()).build();
+        }
+    }
+
+    /**
+     * Get one observation time series
+     *
+     * @param observationTimeSeriesId Database ID of the observation time series
+     * @param userUUID                UUID that identifies the user (e.g. from VIPSWeb)
+     * @return
+     */
+    @GET
+    @Path("{observationTimeSeriesId}")
+    @Produces(APPLICATION_JSON)
+    @TypeHint(ObservationTimeSeries.class)
+    public Response getObservationTimeSeries(@PathParam("observationTimeSeriesId") Integer observationTimeSeriesId, @QueryParam("userUUID") String userUUID) {
+        ObservationTimeSeries ots = observationTimeSeriesBean.getObservationTimeSeries(observationTimeSeriesId);
+        if (ots == null) {
+            return Response.status(Response.Status.NOT_FOUND).build();
+        }
+        VipsLogicUser requester = (VipsLogicUser) httpServletRequest.getSession().getAttribute("user");
+        if (requester == null && userUUID != null) {
+            requester = userBean.findVipsLogicUser(UUID.fromString(userUUID));
+        }
+
+        boolean requesterNotValidUser = requester == null;
+        boolean requesterRegularUser = requester != null && !requester.isSuperUser() && !requester.isOrganizationAdmin();
+        boolean requesterNotCreator = requester != null && !ots.getUserId().equals(requester.getUserId());
+
+        if (requesterNotValidUser || requesterRegularUser) {
+            // Mask for all users except creator, super and orgadmin
+            if (!(ots.getLocationIsPrivate() && (requesterNotValidUser || requesterNotCreator)) && ots.getPolygonService() != null) {
+                observationTimeSeriesBean.enrichObservationTimeSeriesWithPointOfInterest(ots);
+                this.maskLocation(ots.getPolygonService(), ots);
+            }
+        }
+        return Response.ok().entity(ots).build();
+    }
+
+    /**
+     * The location (point of interest) of the given observation time series is masked using the provided polygon service.
+     *
+     * @param polygonService        The polygon service that should be used for masking
+     * @param observationTimeSeries The observation time series to mask location for
+     * @return The observation time series, with location masked with the provided polygon service
+     */
+    private void maskLocation(PolygonService polygonService, ObservationTimeSeries observationTimeSeries) {
+        Client client = ClientBuilder.newClient();
+        WebTarget target = client.target(polygonService.getGisSearchUrlTemplate());
+        ReferencedPoint rp = new ReferencedPoint();
+        rp.setId(String.valueOf(observationTimeSeries.getObservationTimeSeriesId()));
+        rp.setLon(observationTimeSeries.getLocationPointOfInterest().getLongitude());
+        rp.setLat(observationTimeSeries.getLocationPointOfInterest().getLatitude());
+
+        ReferencedPoint[] pointArray = {rp};
+        PointMappingResponse response = target.request(MediaType.APPLICATION_JSON).post(Entity.entity(pointArray, MediaType.APPLICATION_JSON), PointMappingResponse.class);
+        // We need to loop through the observations and find corresponding featurecollections and replace those
+        Map<Integer, Feature> indexedPolygons = new HashMap<>();
+        for (Feature feature : response.getFeatureCollection().getFeatures()) {
+            indexedPolygons.put((Integer) feature.getProperties().get(OBSERVATION_TIME_SERIES_ID), feature);
+        }
+        GISEntityUtil gisEntityUtil = new GISEntityUtil();
+        for (Map mapping : response.getMapping()) {
+            Integer observationTimeSeriesId = Integer.valueOf((String) mapping.get(OBSERVATION_TIME_SERIES_ID));
+            if (observationTimeSeries.getObservationTimeSeriesId().equals(observationTimeSeriesId)) {
+                Integer borderId = (Integer) mapping.get("borderid");
+                Gis polygon = gisEntityUtil.getGisFromFeature(indexedPolygons.get(borderId));
+                List<Gis> gis = new ArrayList<>();
+                gis.add(polygon);
+                observationTimeSeries.setLocationPointOfInterest(null);
+                observationTimeSeries.setLocationPointOfInterestId(null);
+            }
+        }
+    }
+
+    /**
+     * This service is used by the VIPS Field observation app to sync data stored locally on the smartphone with the
+     * state of a (potentially non-existent) observation time series in the VIPSLogic database
+     *
+     * @param jsonOts Json representation of the observation time series
+     * @return The observation time series in its merged state, serialized to Json
+     */
+    @POST
+    @Path("syncfromapp")
+    @Consumes(APPLICATION_JSON)
+    @Produces(APPLICATION_JSON)
+    @TypeHint(ObservationTimeSeries.class)
+    public Response syncObservationTimeSeriesFromApp(String jsonOts) {
+        LOGGER.info("In syncObservationTimeSeriesFromApp");
+        LOGGER.info(jsonOts);
+        VipsLogicUser user = userBean.getUserFromUUID(httpServletRequest);
+        if (user == null) {
+            return Response.status(Response.Status.UNAUTHORIZED).build();
+        }
+        ObjectMapper oM = new ObjectMapper();
+        try {
+            Map<Object, Object> mapFromApp = oM.readValue(jsonOts, new TypeReference<HashMap<Object, Object>>() {
+            });
+
+            Integer otsId = (Integer) mapFromApp.get(OBSERVATION_TIME_SERIES_ID);
+
+            // Check if the observation time series is marked as deleted
+            Boolean isDeleted = (Boolean) mapFromApp.getOrDefault(DELETED, false);
+            if (isDeleted) {
+                // If marked as deleted, delete the observation time series if it exists
+                // Observations in time series are also deleted, but the app currently prevents this.
+                if (observationTimeSeriesBean.getObservationTimeSeries(otsId) != null) {
+                    observationTimeSeriesBean.deleteObservationTimeSeries(otsId);
+                    LOGGER.info("ObservationTimeSeries with id={} deleted", otsId);
+                    return Response.ok().build();
+                } else {
+                    LOGGER.warn("ObservationTimeSeries with id={} not found, nothing deleted", otsId);
+                    return Response.status(Response.Status.NOT_FOUND).build();
+                }
+            }
+
+            Date currentDate = new Date();
+            ObservationTimeSeries otsToSave;
+            if (otsId != null && otsId > 0) {
+                otsToSave = observationTimeSeriesBean.getObservationTimeSeries(otsId);
+                if (otsToSave == null) {
+                    return Response.status(Response.Status.NOT_FOUND).build();
+                }
+            } else {
+                otsToSave = new ObservationTimeSeries("APP");
+                otsToSave.setUserId(user.getUserId());
+                otsToSave.setCreated(currentDate);
+            }
+            otsToSave.setCropOrganism(organismBean.getOrganism(getValueFromMap(mapFromApp, CROP_ORGANISM_ID, Integer.class)));
+            otsToSave.setOrganism(organismBean.getOrganism(getValueFromMap(mapFromApp, ORGANISM_ID, Integer.class)));
+            otsToSave.setLocationPointOfInterestId(getValueFromMap(mapFromApp, LOCATION_POINT_OF_INTEREST_ID, Integer.class));
+            otsToSave.setYear(getValueFromMap(mapFromApp, YEAR, Integer.class));
+            otsToSave.setName(getValueFromMap(mapFromApp, NAME, String.class));
+            otsToSave.setDescription(getValueFromMap(mapFromApp, DESCRIPTION, String.class));
+            otsToSave.setLocationIsPrivate(getValueFromMap(mapFromApp, LOCATION_IS_PRIVATE, Boolean.class));
+
+            Object polygonServiceValue = mapFromApp.get(POLYGON_SERVICE);
+            if (polygonServiceValue != null && !polygonServiceValue.toString().isBlank()) {
+                PolygonService polygonService = oM.convertValue(polygonServiceValue, PolygonService.class);
+                otsToSave.setPolygonService(polygonService);
+            }
+
+            otsToSave.setLastModified(currentDate);
+            otsToSave.setLastModifiedBy(user.getUserId());
+
+            // Input check before storing, location must be set
+            if (otsToSave.getLocationPointOfInterestId() == null) {
+                LOGGER.error("The observation time series is missing location data");
+                return Response.status(Response.Status.BAD_REQUEST).entity("The observation time series is missing location data").build();
+            }
+            LOGGER.info("otsToSave before storing: " + otsToSave);
+            otsToSave = observationTimeSeriesBean.storeObservationTimeSeries(otsToSave);
+            return Response.ok().entity(otsToSave).build();
+        } catch (IOException e) {
+            LOGGER.error("IOException on save ObservationTimeSeries", e);
+            return Response.serverError().entity(e).build();
+        }
+    }
+
+    private <T> T getValueFromMap(Map<Object, Object> map, String key, Class<T> clazz) {
+        Object value = map.get(key);
+        if (clazz.isInstance(value)) {
+            return clazz.cast(value);
+        } else if (clazz == Integer.class && value instanceof String) {
+            try {
+                return clazz.cast(Integer.parseInt((String) value));
+            } catch (NumberFormatException e) {
+                return null;
+            }
+        }
+        return clazz == Boolean.class ? clazz.cast(false) : null;
+    }
+}
diff --git a/src/main/java/no/nibio/vips/logic/service/POIService.java b/src/main/java/no/nibio/vips/logic/service/POIService.java
index e84089cc9235d2b80849ad1e51729727e6adfaed..19132a3b9146e71697b99567cb723a0acd7140b9 100644
--- a/src/main/java/no/nibio/vips/logic/service/POIService.java
+++ b/src/main/java/no/nibio/vips/logic/service/POIService.java
@@ -37,6 +37,7 @@ import javax.ws.rs.core.HttpHeaders;
 import javax.ws.rs.core.Response;
 import javax.ws.rs.core.Response.Status;
 
+import no.nibio.vips.logic.controller.session.PointOfInterestBean;
 import org.jboss.resteasy.spi.HttpRequest;
 import org.locationtech.jts.geom.Coordinate;
 import org.locationtech.jts.geom.Point;
@@ -55,8 +56,9 @@ import no.nibio.vips.logic.entity.Organization;
 import no.nibio.vips.logic.entity.PointOfInterest;
 import no.nibio.vips.logic.entity.PointOfInterestWeatherStation;
 import no.nibio.vips.logic.entity.VipsLogicUser;
-import no.nibio.vips.logic.util.Globals;
 import no.nibio.vips.logic.controller.session.SessionControllerGetter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * @copyright 2022 <a href="http://www.nibio.no/">NIBIO</a>
@@ -64,6 +66,7 @@ import no.nibio.vips.logic.controller.session.SessionControllerGetter;
  */
 @Path("rest/poi")
 public class POIService {
+    private final static Logger LOGGER = LoggerFactory.getLogger(POIService.class);
 
     @Context
     private HttpRequest httpRequest;
@@ -182,22 +185,26 @@ public class POIService {
             return Response.status(Status.UNAUTHORIZED).build();
         }
         ObjectMapper oM = new ObjectMapper();
-        SimpleDateFormat df = new SimpleDateFormat(Globals.defaultTimestampFormat);
         try {
             Map<Object, Object> mapFromApp = oM.readValue(poiJson, new TypeReference<HashMap<Object, Object>>() {
             });
+            Integer pointOfInterestId = (Integer) mapFromApp.get("pointOfInterestId");
+            PointOfInterestBean pointOfInterestBean = SessionControllerGetter.getPointOfInterestBean();
             // Check if it is marked as deleted or not
-            if (mapFromApp.get("deleted") != null && ((Boolean) mapFromApp.get("deleted").equals(true))) {
-                if (SessionControllerGetter.getPointOfInterestBean().getPointOfInterest((Integer) mapFromApp.get("pointOfInterestId")) != null) {
-                    SessionControllerGetter.getPointOfInterestBean().deletePoi((Integer) mapFromApp.get("pointOfInterestId"));
+            if (mapFromApp.get("deleted") != null && (mapFromApp.get("deleted").equals(true))) {
+                if (pointOfInterestBean.getPointOfInterest(pointOfInterestId) != null) {
+                    pointOfInterestBean.deletePoi(pointOfInterestId);
+                    LOGGER.info("POI with id={} deleted", pointOfInterestId);
                     return Response.ok().build();
                 } else {
+                    LOGGER.error("POI with id={} not found, nothing deleted", pointOfInterestId);
                     return Response.status(Status.NOT_FOUND).build();
                 }
             } else {
-                PointOfInterest mergePoi = ((Integer) mapFromApp.get("pointOfInterestId")) > 0 ? SessionControllerGetter.getPointOfInterestBean().getPointOfInterest((Integer) mapFromApp.get("pointOfInterestId")) : PointOfInterest.getInstance((Integer) mapFromApp.get("pointOfInterestTypeId"));
+                PointOfInterest mergePoi = pointOfInterestId > 0 ? pointOfInterestBean.getPointOfInterest(pointOfInterestId) : PointOfInterest.getInstance((Integer) mapFromApp.get("pointOfInterestTypeId"));
                 // Trying to sync a non-existing POI
                 if (mergePoi == null) {
+                    LOGGER.error("POI with id={} not found, nothing updated", pointOfInterestId);
                     return Response.status(Status.NOT_FOUND).build();
                 }
 
@@ -228,11 +235,11 @@ public class POIService {
                 Point p3d = gisUtil.createPointWGS84(coordinate);
                 mergePoi.setGisGeom(p3d);
 
-                mergePoi = SessionControllerGetter.getPointOfInterestBean().getPointOfInterest(SessionControllerGetter.getPointOfInterestBean().storePoi(mergePoi).getPointOfInterestId());
-
+                mergePoi = pointOfInterestBean.getPointOfInterest(pointOfInterestBean.storePoi(mergePoi).getPointOfInterestId());
                 return Response.ok().entity(mergePoi).build();
             }
         } catch (IOException e) {
+            LOGGER.error("Exception when syncing POI", e);
             return Response.serverError().entity(e).build();
         }
 
diff --git a/src/main/resources/db/migration/V17__ObservationTimeSeries.sql b/src/main/resources/db/migration/V17__ObservationTimeSeries.sql
new file mode 100644
index 0000000000000000000000000000000000000000..c278854b4599ea411fecb62c26f17911e28f94b1
--- /dev/null
+++ b/src/main/resources/db/migration/V17__ObservationTimeSeries.sql
@@ -0,0 +1,26 @@
+CREATE TABLE IF NOT EXISTS public.observation_time_series
+(
+    observation_time_series_id SERIAL PRIMARY KEY,
+    crop_organism_id integer NOT NULL,
+    organism_id integer NOT NULL,
+    year integer NOT NULL,
+    name character varying(1023) NOT NULL,
+    description text,
+    source character varying(6) NOT NULL DEFAULT 'WEB',
+    user_id integer NOT NULL,
+    created timestamp with time zone NOT NULL,
+    last_modified_by integer,
+    last_modified timestamp with time zone,
+    location_point_of_interest_id integer NOT NULL,
+    location_is_private boolean DEFAULT false,
+    polygon_service_id integer,
+    CONSTRAINT observation_time_series_crop_organism_id_fkey FOREIGN KEY (crop_organism_id) REFERENCES public.organism (organism_id),
+    CONSTRAINT observation_time_series_organism_id_fkey FOREIGN KEY (organism_id) REFERENCES public.organism (organism_id),
+    CONSTRAINT observation_time_series_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.vips_logic_user (user_id),
+    CONSTRAINT observation_time_series_last_modified_by_fkey FOREIGN KEY (last_modified_by) REFERENCES public.vips_logic_user (user_id),
+    CONSTRAINT observation_time_series_location_point_of_interest_id_fkey FOREIGN KEY (location_point_of_interest_id) REFERENCES public.point_of_interest (point_of_interest_id),
+    CONSTRAINT observation_time_series_polygon_service_id_fkey FOREIGN KEY (polygon_service_id) REFERENCES public.polygon_service (polygon_service_id)
+);
+
+ALTER TABLE observation ADD COLUMN source character varying(6) NOT NULL DEFAULT 'WEB';
+ALTER TABLE observation ADD COLUMN observation_time_series_id integer REFERENCES public.observation_time_series(observation_time_series_id);
\ No newline at end of file
diff --git a/src/test/java/no/nibio/vips/logic/service/ObservationTimeSeriesServiceTest.java b/src/test/java/no/nibio/vips/logic/service/ObservationTimeSeriesServiceTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..030e3261f192a5740979b4b4f3848aa90fe2635f
--- /dev/null
+++ b/src/test/java/no/nibio/vips/logic/service/ObservationTimeSeriesServiceTest.java
@@ -0,0 +1,5 @@
+package no.nibio.vips.logic.service;
+
+public class ObservationTimeSeriesServiceTest {
+
+}
\ No newline at end of file