diff --git a/services/cron/cron.go b/services/cron/cron.go
index e3f31d08f0..63db75ab3b 100644
--- a/services/cron/cron.go
+++ b/services/cron/cron.go
@@ -106,6 +106,12 @@ func ListTasks() TaskTable {
 			next = e.NextRun()
 			prev = e.PreviousRun()
 		}
+
+		// If the manual run is after the cron run, use that instead.
+		if prev.Before(task.LastRun) {
+			prev = task.LastRun
+		}
+
 		task.lock.Lock()
 		tTable = append(tTable, &TaskTableRow{
 			Name:        task.Name,
diff --git a/services/cron/tasks.go b/services/cron/tasks.go
index ea1925c26c..d2c3d1d812 100644
--- a/services/cron/tasks.go
+++ b/services/cron/tasks.go
@@ -9,6 +9,7 @@ import (
 	"reflect"
 	"strings"
 	"sync"
+	"time"
 
 	"code.gitea.io/gitea/models/db"
 	system_model "code.gitea.io/gitea/models/system"
@@ -37,6 +38,8 @@ type Task struct {
 	LastMessage string
 	LastDoer    string
 	ExecTimes   int64
+	// This stores the time of the last manual run of this task.
+	LastRun time.Time
 }
 
 // DoRunAtStart returns if this task should run at the start
@@ -88,6 +91,12 @@ func (t *Task) RunWithUser(doer *user_model.User, config Config) {
 		}
 	}()
 	graceful.GetManager().RunWithShutdownContext(func(baseCtx context.Context) {
+		// Store the time of this run, before the function is executed, so it
+		// matches the behavior of what the cron library does.
+		t.lock.Lock()
+		t.LastRun = time.Now()
+		t.lock.Unlock()
+
 		pm := process.GetManager()
 		doerName := ""
 		if doer != nil && doer.ID != -1 {
diff --git a/tests/integration/api_admin_test.go b/tests/integration/api_admin_test.go
index 6613d4b715..4e222e22e6 100644
--- a/tests/integration/api_admin_test.go
+++ b/tests/integration/api_admin_test.go
@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"net/http"
 	"testing"
+	"time"
 
 	asymkey_model "code.gitea.io/gitea/models/asymkey"
 	auth_model "code.gitea.io/gitea/models/auth"
@@ -282,3 +283,53 @@ func TestAPIRenameUser(t *testing.T) {
 	})
 	MakeRequest(t, req, http.StatusOK)
 }
+
+func TestAPICron(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	// user1 is an admin user
+	session := loginUser(t, "user1")
+
+	t.Run("List", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadAdmin)
+		urlStr := fmt.Sprintf("/api/v1/admin/cron?token=%s", token)
+		req := NewRequest(t, "GET", urlStr)
+		resp := MakeRequest(t, req, http.StatusOK)
+
+		assert.Equal(t, "26", resp.Header().Get("X-Total-Count"))
+
+		var crons []api.Cron
+		DecodeJSON(t, resp, &crons)
+		assert.Len(t, crons, 26)
+	})
+
+	t.Run("Execute", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		now := time.Now()
+		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin)
+		/// Archive cleanup is harmless, because in the text environment there are none
+		/// and is thus an NOOP operation and therefore doesn't interfere with any other
+		/// tests.
+		urlStr := fmt.Sprintf("/api/v1/admin/cron/archive_cleanup?token=%s", token)
+		req := NewRequest(t, "POST", urlStr)
+		MakeRequest(t, req, http.StatusNoContent)
+
+		// Check for the latest run time for this cron, to ensure it
+		// has been run.
+		urlStr = fmt.Sprintf("/api/v1/admin/cron?token=%s", token)
+		req = NewRequest(t, "GET", urlStr)
+		resp := MakeRequest(t, req, http.StatusOK)
+
+		var crons []api.Cron
+		DecodeJSON(t, resp, &crons)
+
+		for _, cron := range crons {
+			if cron.Name == "archive_cleanup" {
+				assert.True(t, now.Before(cron.Prev))
+			}
+		}
+	})
+}