schedule-history: revamp schedule history page
Some checks failed
/ push (push) Has been cancelled

This commit is contained in:
Tigor Hutasuhut 2024-06-04 21:08:36 +07:00
parent 4ae2b36b4b
commit f71cda7c92
8 changed files with 261 additions and 151 deletions

View file

@ -0,0 +1,24 @@
package api
import (
"net/http"
"github.com/stephenafamo/bob/dialect/sqlite/sm"
"github.com/tigorlazuardi/redmage/models"
"github.com/tigorlazuardi/redmage/pkg/errs"
"golang.org/x/net/context"
)
func (api *API) ScheduleHistoryLatest(ctx context.Context) (result *models.ScheduleHistory, err error) {
ctx, span := tracer.Start(ctx, "*API.ScheduleHistoryLatest")
defer span.End()
result, err = models.ScheduleHistories.Query(ctx, api.db, sm.OrderBy(models.ScheduleHistoryColumns.CreatedAt).Desc()).One()
if err != nil {
if err.Error() == "sql: no rows in result set" {
return result, errs.Wrapw(err, "last schedule history not found").Code(http.StatusNotFound)
}
return result, errs.Wrapw(err, "failed to find last schedule history")
}
return result, nil
}

View file

@ -3,30 +3,28 @@ package api
import (
"context"
"strconv"
"strings"
"time"
"github.com/stephenafamo/bob"
"github.com/stephenafamo/bob/dialect/sqlite"
"github.com/stephenafamo/bob/dialect/sqlite/dialect"
"github.com/stephenafamo/bob/dialect/sqlite/sm"
"github.com/tigorlazuardi/redmage/api/utils"
"github.com/tigorlazuardi/redmage/models"
"github.com/tigorlazuardi/redmage/pkg/errs"
)
type ScheduleHistoryListParams struct {
Subreddit string
After time.Time
Before time.Time
Time time.Time
Direction string
Limit int64
Offset int64
OrderBy string
Sort string
Limit int64
Offset int64
}
func (params *ScheduleHistoryListParams) FillFromQuery(query Queryable) {
params.Subreddit = query.Get("subreddit")
params.Direction = query.Get("direction")
params.Limit, _ = strconv.ParseInt(query.Get("limit"), 10, 64)
if params.Limit < 1 {
params.Limit = 100
@ -42,33 +40,29 @@ func (params *ScheduleHistoryListParams) FillFromQuery(query Queryable) {
now := time.Now()
afterInt, _ := strconv.ParseInt(query.Get("after"), 10, 64)
if afterInt > 0 {
params.After = time.Unix(afterInt, 0)
} else if afterInt < 0 {
params.After = now.Add(time.Duration(afterInt) * time.Second)
timeInt, _ := strconv.ParseInt(query.Get("time"), 10, 64)
if timeInt > 0 {
params.Time = time.Unix(timeInt, 0)
} else if timeInt < 0 {
params.Time = now.Add(time.Duration(timeInt) * time.Second)
}
beforeInt, _ := strconv.ParseInt(query.Get("before"), 10, 64)
if beforeInt > 0 {
params.Before = time.Unix(beforeInt, 0)
} else if beforeInt < 0 {
params.Before = now.Add(time.Duration(beforeInt) * time.Second)
if params.Time.After(now) {
params.Time = time.Time{}
}
params.OrderBy = query.Get("order_by")
params.Sort = query.Get("sort")
}
func (params ScheduleHistoryListParams) CountQuery() (expr []bob.Mod[*dialect.SelectQuery]) {
if params.Subreddit != "" {
expr = append(expr, models.SelectWhere.ScheduleHistories.Subreddit.EQ(params.Subreddit))
}
if !params.After.IsZero() {
expr = append(expr, models.SelectWhere.ScheduleHistories.CreatedAt.GTE(params.After.Unix()))
}
if !params.Before.IsZero() {
expr = append(expr, models.SelectWhere.ScheduleHistories.CreatedAt.LTE(params.Before.Unix()))
if !params.Time.IsZero() {
if params.Direction == "before" {
expr = append(expr,
models.SelectWhere.ScheduleHistories.CreatedAt.GTE(params.Time.Unix()),
)
} else {
expr = append(expr, models.SelectWhere.ScheduleHistories.CreatedAt.LT(params.Time.Unix()))
}
}
return expr
@ -82,15 +76,7 @@ func (params ScheduleHistoryListParams) Query() (expr []bob.Mod[*dialect.SelectQ
if params.Offset > 0 {
expr = append(expr, sm.Offset(params.Offset))
}
if params.OrderBy != "" {
if strings.ToLower(params.Sort) == "desc" {
expr = append(expr, sm.OrderBy(sqlite.Quote(params.OrderBy)).Desc())
} else {
expr = append(expr, sm.OrderBy(sqlite.Quote(params.OrderBy)).Asc())
}
} else {
expr = append(expr, sm.OrderBy(models.ScheduleHistoryColumns.CreatedAt).Desc(), sm.OrderBy(models.ScheduleHistoryColumns.Status).Desc())
}
expr = append(expr, sm.OrderBy(models.ScheduleHistoryColumns.CreatedAt).Desc())
return expr
}
@ -100,6 +86,71 @@ type ScheduleHistoryListResult struct {
Total int64 `json:"count"`
}
func (result ScheduleHistoryListResult) GetLast() *models.ScheduleHistory {
if len(result.Schedules) > 0 {
return result.Schedules[len(result.Schedules)-1]
}
return nil
}
func (result ScheduleHistoryListResult) GetLastTime() time.Time {
if schedule := result.GetLast(); schedule != nil {
return time.Unix(schedule.CreatedAt, 0)
}
return time.Now()
}
func (result ScheduleHistoryListResult) GetFirstTime() time.Time {
if schedule := result.GetFirst(); schedule != nil {
return time.Unix(schedule.CreatedAt, 0)
}
return time.Now()
}
func (result ScheduleHistoryListResult) GetFirst() *models.ScheduleHistory {
if len(result.Schedules) > 0 {
return result.Schedules[0]
}
return nil
}
func (result ScheduleHistoryListResult) SplitByDay() (out []ScheduleHistoryListResultDay) {
out = make([]ScheduleHistoryListResultDay, 0, 4)
var lastDay time.Time
var lastIdx int
for _, schedule := range result.Schedules {
t := utils.StartOfDay(time.Unix(schedule.CreatedAt, 0).In(time.Local))
if !t.Equal(lastDay) {
out = append(out, ScheduleHistoryListResultDay{
Date: t,
})
lastDay = t
lastIdx = len(out) - 1
out[lastIdx].Schedules = append(out[lastIdx].Schedules, schedule)
out[lastIdx].Total += 1
} else {
out[lastIdx].Schedules = append(out[lastIdx].Schedules, schedule)
out[lastIdx].Total += 1
}
}
return
}
type ScheduleHistoryListResultDay struct {
Date time.Time `json:"date"`
ScheduleHistoryListResult
}
func (resultDay ScheduleHistoryListResultDay) GetLast() *models.ScheduleHistory {
if len(resultDay.Schedules) > 0 {
return resultDay.Schedules[len(resultDay.Schedules)-1]
}
return nil
}
func (api *API) ScheduleHistoryList(ctx context.Context, params ScheduleHistoryListParams) (result ScheduleHistoryListResult, err error) {
ctx, span := tracer.Start(ctx, "*API.ScheduleHistoryList")
defer span.End()

7
api/utils/utils.go Normal file
View file

@ -0,0 +1,7 @@
package utils
import "time"
func StartOfDay(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}

View file

@ -1,11 +1,11 @@
package pubsub
import (
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill-bolt/pkg/bolt"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/tigorlazuardi/redmage/config"
"github.com/tigorlazuardi/redmage/pkg/errs"
"github.com/tigorlazuardi/redmage/pkg/log"
"go.etcd.io/bbolt"
)
@ -23,7 +23,7 @@ func NewPublisher(db *bbolt.DB) (message.Publisher, error) {
return bolt.NewPublisher(db, bolt.PublisherConfig{
Common: bolt.CommonConfig{
Bucket: []bolt.BucketName{bolt.BucketName("watermill")},
Logger: &log.WatermillLogger{},
Logger: watermill.NopLogger{},
},
})
}
@ -33,7 +33,7 @@ func NewSubscriber(db *bbolt.DB) (message.Subscriber, error) {
Common: bolt.CommonConfig{
Bucket: []bolt.BucketName{bolt.BucketName("watermill")},
Marshaler: nil,
Logger: &log.WatermillLogger{},
Logger: watermill.NopLogger{},
},
})
}

View file

@ -18,7 +18,7 @@ func (routes *Routes) PageScheduleHistory(rw http.ResponseWriter, req *http.Requ
var data schedulehistories.Data
data.Params.FillFromQuery(req.URL.Query())
result, err := routes.API.ScheduleHistoryListByDate(ctx, data.Params)
result, err := routes.API.ScheduleHistoryList(ctx, data.Params)
if err != nil {
log.New(ctx).Err(err).Error("Failed to list schedule histories")
code, message := errs.HTTPMessage(err)
@ -30,7 +30,15 @@ func (routes *Routes) PageScheduleHistory(rw http.ResponseWriter, req *http.Requ
return
}
data.ScheduleHistories = result.Schedules
data.ScheduleHistories = result
if latest, _ := routes.API.ScheduleHistoryLatest(ctx); latest != nil {
if first := data.ScheduleHistories.GetFirst(); first != nil {
if first.ID == latest.ID {
data.IsCurrent = true
}
}
}
if err := schedulehistories.View(c, data).Render(ctx, rw); err != nil {
log.New(ctx).Err(err).Error("Failed to render schedule histories view")

View file

@ -6,20 +6,20 @@ templ ActionButton(components ...templ.Component) {
<div
class="max-xs:toast max-xs:z-40"
x-data="{ show: false }"
@click="show = !show; if (!show) document.activeElement.blur()"
@click.away="show = false"
>
<div class="dropdown dropdown-hover dropdown-top xs:dropdown-bottom dropdown-end">
<div
tabindex="0"
role="button"
class="btn btn-primary max-xs:btn-circle max-lg:btn-square xs:btn-outline m-1 max-xs:border-none"
x-ref="button"
>
@icons.Kebab("h-8 w-8")
</div>
<ul
tabindex="0"
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52 m-0 border-primary border-2"
@click="document.activeElement.blur()"
>
for i, component := range components {
if i > 0 {

View file

@ -34,7 +34,6 @@ templ ChevronBoldLeft(class ...string) {
class={ strings.Join(class, " ") }
}
>
<title>chevron-left</title>
<desc>Created with Sketch Beta.</desc>
<defs></defs>
<g id="Page-1" stroke="currentColor" stroke-width="1" fill="currentColor" fill-rule="evenodd" sketch:type="MSPage">

View file

@ -2,24 +2,21 @@ package schedulehistories
import "github.com/tigorlazuardi/redmage/views"
import "github.com/tigorlazuardi/redmage/views/components"
import "github.com/tigorlazuardi/redmage/models"
import "github.com/tigorlazuardi/redmage/api"
import "fmt"
import "time"
import "github.com/tigorlazuardi/redmage/views/icons"
import "github.com/tigorlazuardi/redmage/models"
type Data struct {
ScheduleHistories models.ScheduleHistorySlice
Params api.ScheduleHistoryListByDateParams
ScheduleHistories api.ScheduleHistoryListResult
Params api.ScheduleHistoryListParams
FirstSchedule *models.ScheduleHistory
LastSchedule *models.ScheduleHistory
IsCurrent bool
Error string
}
func (data Data) isCurrentDay() bool {
now := time.Now()
return now.Format(time.DateOnly) == data.Params.Date.Format(time.DateOnly)
}
templ View(c *views.Context, data Data) {
@components.Doctype() {
@components.Head(c,
@ -42,77 +39,81 @@ templ Content(c *views.Context, data Data) {
<main class="prose min-w-full">
<h1>Schedule History ({ time.Local.String() })</h1>
<div class="divider my-0"></div>
@dateBar(data, true)
if len(data.ScheduleHistories) == 0 {
@dateBar(data)
if len(data.ScheduleHistories.Schedules) == 0 {
<h2>There are no history schedules found for current date.</h2>
}
if len(data.ScheduleHistories) > 0 {
<div class="grid sm:grid-cols-[1fr,9fr] gap-x-4 gap-y-2 sm:gap-y-4">
<span class="font-bold max-sm:hidden text-center">Time</span>
<span class="font-bold max-sm:hidden">Event</span>
for i, schedule := range data.ScheduleHistories {
if i > 0 {
<div class="divider sm:hidden"></div>
if len(data.ScheduleHistories.Schedules) > 0 {
for _, history := range data.ScheduleHistories.SplitByDay() {
<h1 class="my-6">{ history.Date.Format("Monday, 02 January 2006") }</h1>
<div class="divider my-2"></div>
<div class="grid sm:grid-cols-[1fr,9fr] gap-x-4 gap-y-2 sm:gap-y-4">
<span class="font-bold max-sm:hidden text-center">Time</span>
<span class="font-bold max-sm:hidden">Event</span>
for i, schedule := range history.Schedules {
if i > 0 {
<div class="divider sm:hidden"></div>
}
<div
x-data={ fmt.Sprintf(`{ time: %d, get tooltip() { return dayjs.unix(this.time).tz(dayjs.tz.guess()).format('ddd, D MMM YYYY HH:mm:ss Z') } }`, schedule.CreatedAt) }
:data-tip="tooltip"
class="tooltip"
>
<p class="font-bold max-sm:text-left my-0">
{ time.Unix(schedule.CreatedAt, 0).Format("15:04:05") }
</p>
</div>
if api.ScheduleStatus(schedule.Status) == api.ScheduleStatusDisabled {
<span>
Subreddit
@subredditLink(schedule.Subreddit)
scheduler has been set to { api.ScheduleStatusDisabled.String() } status.
</span>
} else if api.ScheduleStatus(schedule.Status) == api.ScheduleStatusEnabled {
<span>
Subreddit
@subredditLink(schedule.Subreddit)
{ " " }
has been <b>{ api.ScheduleStatusEnabled.String() }</b> { "for" } automatic scheduling.
</span>
} else if api.ScheduleStatus(schedule.Status) == api.ScheduleStatusStandby {
<span>
Subreddit
@subredditLink(schedule.Subreddit)
{ " " }
has finished
<b class="text-secondary">{ api.ScheduleStatusDownloading.String() }</b>
and turned to <b>{ api.ScheduleStatusStandby.String() }</b> status.
</span>
} else if api.ScheduleStatus(schedule.Status) == api.ScheduleStatusEnqueued {
<span>
Subreddit
@subredditLink(schedule.Subreddit)
{ " " }
is <b class="text-accent">{ api.ScheduleStatusEnqueued.String() } </b> { "for" } downloading.
</span>
} else if api.ScheduleStatus(schedule.Status) == api.ScheduleStatusDownloading {
<span>
Subreddit
@subredditLink(schedule.Subreddit)
{ " " }
has started <b class="text-secondary">{ api.ScheduleStatusDownloading.String() }</b>.
</span>
} else if api.ScheduleStatus(schedule.Status) == api.ScheduleStatusError {
<span>
Subreddit
@subredditLink(schedule.Subreddit)
{ " " }
finishes <b class="text-secondary">{ api.ScheduleStatusDownloading.String() }</b>
with <b class="text-error">{ api.ScheduleStatusError.String() }</b> of <span class="text-error">"{ schedule.ErrorMessage }"</span>.
</span>
}
}
<div
x-data={ fmt.Sprintf(`{ time: %d, get tooltip() { return dayjs.unix(this.time).tz(dayjs.tz.guess()).format('ddd, D MMM YYYY HH:mm:ss Z') } }`, schedule.CreatedAt) }
:data-tip="tooltip"
class="tooltip"
>
<p class="font-bold max-sm:text-left my-0">
{ time.Unix(schedule.CreatedAt, 0).Format("15:04:05") }
</p>
</div>
if api.ScheduleStatus(schedule.Status) == api.ScheduleStatusDisabled {
<span>
Subreddit
@subredditLink(schedule.Subreddit)
scheduler has been set to { api.ScheduleStatusDisabled.String() } status.
</span>
} else if api.ScheduleStatus(schedule.Status) == api.ScheduleStatusEnabled {
<span>
Subreddit
@subredditLink(schedule.Subreddit)
{ " " }
has been <b>{ api.ScheduleStatusEnabled.String() }</b> { "for" } automatic scheduling.
</span>
} else if api.ScheduleStatus(schedule.Status) == api.ScheduleStatusStandby {
<span>
Subreddit
@subredditLink(schedule.Subreddit)
{ " " }
has finished
<b class="text-secondary">{ api.ScheduleStatusDownloading.String() }</b>
and turned to <b>{ api.ScheduleStatusStandby.String() }</b> status.
</span>
} else if api.ScheduleStatus(schedule.Status) == api.ScheduleStatusEnqueued {
<span>
Subreddit
@subredditLink(schedule.Subreddit)
{ " " }
is <b class="text-accent">{ api.ScheduleStatusEnqueued.String() } </b> { "for" } downloading.
</span>
} else if api.ScheduleStatus(schedule.Status) == api.ScheduleStatusDownloading {
<span>
Subreddit
@subredditLink(schedule.Subreddit)
{ " " }
has started <b class="text-secondary">{ api.ScheduleStatusDownloading.String() }</b>.
</span>
} else if api.ScheduleStatus(schedule.Status) == api.ScheduleStatusError {
<span>
Subreddit
@subredditLink(schedule.Subreddit)
{ " " }
finishes <b class="text-secondary">{ api.ScheduleStatusDownloading.String() }</b>
with <b class="text-error">{ api.ScheduleStatusError.String() }</b> of <span class="text-error">"{ schedule.ErrorMessage }"</span>.
</span>
}
}
</div>
</div>
}
}
if len(data.ScheduleHistories) > 20 {
@dateBar(data, false)
if len(data.ScheduleHistories.Schedules) > 20 {
@dateBar(data)
}
@actionButton(data)
</main>
@ -120,14 +121,25 @@ templ Content(c *views.Context, data Data) {
templ actionButton(data Data) {
<div class="xs:hidden">
@components.ActionButton(
actionButtonNext(data),
actionButtonPrev(data),
)
@components.ActionButton(actionButtonItems(data)...)
</div>
}
templ dateBar(data Data, showDate bool) {
func actionButtonItems(data Data) []templ.Component {
out := make([]templ.Component, 0, 2)
if !data.IsCurrent {
out = append(out, actionButtonPrev(data))
}
if len(data.ScheduleHistories.Schedules) >= int(data.Params.Limit) {
out = append(out, actionButtonNext(data))
}
if data.IsCurrent {
out = append(out, actionButtonRefresh())
}
return out
}
templ dateBar(data Data) {
<div
class="flex flex-wrap justify-between my-4 items-center"
hx-boost="true"
@ -135,50 +147,59 @@ templ dateBar(data Data, showDate bool) {
hx-swap="outerHTML"
hx-target="#root-content"
>
if data.isCurrentDay() {
<a
href="/history"
class="btn btn-primary btn-outline btn-square text-base-100"
>
@icons.Refresh("w-6 h-6")
</a>
if data.IsCurrent {
<div class="tooltip" data-tip="Refresh">
<a
href="/history"
class="btn btn-primary btn-outline btn-square text-base-100"
>
@icons.Refresh("w-6 h-6")
</a>
</div>
} else {
<a
href={ templ.SafeURL(fmt.Sprintf("/history?date=%s", data.Params.Date.Add(time.Hour*24).Format(time.DateOnly))) }
class="btn btn-primary btn-outline btn-square text-base-100"
>
@icons.ChevronBoldLeft("w-6 h-6")
</a>
<div class="tooltip" data-tip="Previous">
<a
href={ templ.SafeURL(fmt.Sprintf("/history?time=%d&direction=before", data.ScheduleHistories.GetFirstTime().Unix())) }
class="btn btn-primary btn-outline btn-square text-base-100"
>
@icons.ChevronBoldLeft("w-6 h-6")
</a>
</div>
}
if showDate {
<span class="max-xs:hidden text-primary font-bold sm:text-2xl">{ data.Params.Date.Format("Monday, 02 January 2006") }</span>
<span class="xs:hidden text-primary font-bold">{ data.Params.Date.Format("Mon, 02 Jan") }</span>
if len(data.ScheduleHistories.Schedules) >= int(data.Params.Limit) {
<div class="tooltip" data-tip="Next">
<a
href={ templ.SafeURL(fmt.Sprintf("/history?time=%d", data.ScheduleHistories.GetLastTime().Unix())) }
class="btn btn-primary btn-outline btn-square text-base-100 no-underline"
>
@icons.ChevronBoldRight("w-6 h-6")
</a>
</div>
}
<div class="tooltip" data-tip="Next">
<a
href={ templ.SafeURL(fmt.Sprintf("/history?date=%s", data.Params.Date.Add(time.Hour*-24).Format(time.DateOnly))) }
class="btn btn-primary btn-outline btn-square text-base-100 no-underline"
>
@icons.ChevronBoldRight("w-6 h-6")
</a>
</div>
</div>
}
templ actionButtonNext(data Data) {
<a
href={ templ.SafeURL(fmt.Sprintf("/history?date=%s", data.Params.Date.Add(time.Hour*-24).Format(time.DateOnly))) }
href={ templ.SafeURL(fmt.Sprintf("/history?time=%d", data.ScheduleHistories.GetLastTime().Unix())) }
class="btn btn-ghost btn-sm no-underline m-0"
>Next</a>
}
templ actionButtonPrev(data Data) {
<a
href={ templ.SafeURL(fmt.Sprintf("/history?date=%s", data.Params.Date.Add(time.Hour*24).Format(time.DateOnly))) }
href={ templ.SafeURL(fmt.Sprintf("/history?time=%d&direction=before", data.ScheduleHistories.GetFirstTime().Unix())) }
class="btn btn-ghost btn-sm no-underline m-0"
>Previous</a>
}
templ actionButtonRefresh() {
<a
href="/history"
class="btn btn-ghost btn-sm no-underline m-0"
>Refresh</a>
}
templ subredditLink(subreddit string) {
<a href={ templ.URL(fmt.Sprintf("/subreddits/details/%s", subreddit)) } class="text-primary">{ subreddit }</a>
}