From 3b8c6432b66ee36f4be0655e75edc76eab50e617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Wed, 17 Apr 2024 18:43:24 +0200 Subject: [PATCH] Event schedule --- main.go | 37 +++++++----------- node.go | 2 +- schedule.go | 57 ++++++++++++++++++++++----- sql/00021.sql | 1 + static/css/main.css | 50 ++++++++++++++++++------ static/js/node.mjs | 89 ++++++++++++++++++++++++++++++++++--------- static/less/main.less | 38 +++++++++++++++++- 7 files changed, 210 insertions(+), 64 deletions(-) create mode 100644 sql/00021.sql diff --git a/main.go b/main.go index cfb70b7..e69bb90 100644 --- a/main.go +++ b/main.go @@ -174,29 +174,6 @@ func main() { // {{{ os.Exit(1) } } // }}} -func scheduleHandler() { // {{{ - // Wait for the approximate minute. - wait := 60000 - time.Now().Sub(time.Now().Truncate(time.Minute)).Milliseconds() - logger.Info("schedule", "wait", wait/1000) - time.Sleep(time.Millisecond * time.Duration(wait)) - tick := time.NewTicker(time.Minute) - for { - for _, event := range ExpiredSchedules() { - notificationManager.Send( - event.UserID, - event.ScheduleUUID, - []byte( - fmt.Sprintf( - "%s\n%s", - event.Time.Format("2006-01-02 15:04"), - event.Description, - ), - ), - ) - } - <-tick.C - } -} // }}} func nodeTree(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ logger.Info("webserver", "request", "/node/tree") @@ -783,8 +760,20 @@ func scheduleList(w http.ResponseWriter, r *http.Request, sess *session.T) { // var err error w.Header().Add("Access-Control-Allow-Origin", "*") + request := struct { + NodeID int + }{} + body, _ := io.ReadAll(r.Body) + if len(body) > 0 { + err = json.Unmarshal(body, &request) + if err != nil { + responseError(w, err) + return + } + } + var schedules []Schedule - schedules, err = FutureSchedules(sess.UserID) + schedules, err = FutureSchedules(sess.UserID, request.NodeID) if err != nil { responseError(w, err) return diff --git a/node.go b/node.go index f453392..66b5a3e 100644 --- a/node.go +++ b/node.go @@ -326,7 +326,7 @@ func CreateNode(userID, parentID int, name string) (node Node, err error) { // { } // }}} func UpdateNode(userID, nodeID, timeOffset int, content string, cryptoKeyID int, markdown bool) (err error) { // {{{ var timezone string - row := service.Db.Conn.QueryRow(`SELECT timezone FROM public.user WHERE id=$1`, userID) + row := service.Db.Conn.QueryRow(`SELECT timezone FROM _webservice.user WHERE id=$1`, userID) err = row.Scan(&timezone) if err != nil { err = werr.Wrap(err).WithCode("002-000F") diff --git a/schedule.go b/schedule.go index 3520283..823126a 100644 --- a/schedule.go +++ b/schedule.go @@ -28,6 +28,32 @@ type Schedule struct { Acknowledged bool } +func scheduleHandler() { // {{{ + // Wait for the approximate minute. + wait := 60000 - time.Now().Sub(time.Now().Truncate(time.Minute)).Milliseconds() + logger.Info("schedule", "wait", wait/1000) + time.Sleep(time.Millisecond * time.Duration(wait)) + tick := time.NewTicker(time.Minute) + for { + schedules := ExpiredSchedules() + logger.Info("FOO", "schedules", schedules) + for _, event := range schedules { + notificationManager.Send( + event.UserID, + event.ScheduleUUID, + []byte( + fmt.Sprintf( + "%s\n%s", + event.Time.Format("2006-01-02 15:04"), + event.Description, + ), + ), + ) + } + <-tick.C + } +} // }}} + func ScanForSchedules(timezone string, content string) (schedules []Schedule) { // {{{ schedules = []Schedule{} @@ -89,7 +115,7 @@ func RetrieveSchedules(userID int, nodeID int) (schedules []Schedule, err error) s.description, s.acknowledged FROM schedule s - INNER JOIN public.user u ON s.user_id = u.id + INNER JOIN _webservice.user u ON s.user_id = u.id WHERE user_id=$1 AND CASE @@ -120,6 +146,7 @@ func (a Schedule) IsEqual(b Schedule) bool { // {{{ return a.UserID == b.UserID && a.Node.ID == b.Node.ID && a.Time.Equal(b.Time) && + a.RemindMinutes == b.RemindMinutes && a.Description == b.Description } // }}} func (s *Schedule) Insert(queryable Queryable) error { // {{{ @@ -166,9 +193,9 @@ func ExpiredSchedules() (schedules []Schedule) { // {{{ (s.time - MAKE_INTERVAL(mins => s.remind_minutes)) AT TIME ZONE u.timezone AS time, s.description FROM schedule s - INNER JOIN public.user u ON s.user_id = u.id + INNER JOIN _webservice.user u ON s.user_id = u.id WHERE - (time - MAKE_INTERVAL(mins => remind_minutes)) < NOW() AND + (time - MAKE_INTERVAL(mins => remind_minutes)) AT TIME ZONE u.timezone < NOW() AND NOT acknowledged ORDER BY time ASC @@ -189,7 +216,7 @@ func ExpiredSchedules() (schedules []Schedule) { // {{{ } return } // }}} -func FutureSchedules(userID int) (schedules []Schedule, err error) {// {{{ +func FutureSchedules(userID int, nodeID int) (schedules []Schedule, err error) {// {{{ schedules = []Schedule{} res := service.Db.Conn.QueryRow(` @@ -197,16 +224,27 @@ func FutureSchedules(userID int) (schedules []Schedule, err error) {// {{{ SELECT s.id, s.user_id, - to_jsonb(n.*) AS node, + jsonb_build_object( + 'id', n.id, + 'name', n.name, + 'updated', n.updated + ) AS node, s.schedule_uuid, - (time - MAKE_INTERVAL(mins => s.remind_minutes)) AT TIME ZONE u.timezone AS time, + time AT TIME ZONE 'UTC' AS time, s.description, - s.acknowledged + s.acknowledged, + s.remind_minutes AS RemindMinutes FROM schedule s - INNER JOIN public.user u ON s.user_id = u.id + INNER JOIN _webservice.user u ON s.user_id = u.id INNER JOIN node n ON s.node_id = n.id WHERE - s.user_id=$1 AND + s.user_id = $1 AND + ( + CASE + WHEN $2 > 0 THEN n.id = $2 + ELSE true + END + ) AND time >= NOW() AND NOT acknowledged ) @@ -215,6 +253,7 @@ func FutureSchedules(userID int) (schedules []Schedule, err error) {// {{{ FROM schedule_events s `, userID, + nodeID, ) var j []byte err = res.Scan(&j) diff --git a/sql/00021.sql b/sql/00021.sql new file mode 100644 index 0000000..88d7364 --- /dev/null +++ b/sql/00021.sql @@ -0,0 +1 @@ +ALTER TABLE public.node ALTER COLUMN updated TYPE timestamptz USING updated::timestamptz; diff --git a/static/css/main.css b/static/css/main.css index a1d51e5..b4a783f 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -526,7 +526,7 @@ header .menu { grid-area: 1 / 1 / 2 / 2; } /* ============================================================= */ -#file-section { +#schedule-section { grid-area: files; justify-self: center; width: calc(100% - 32px); @@ -536,6 +536,23 @@ header .menu { border-radius: 8px; margin-top: 32px; margin-bottom: 32px; + color: #000; +} +#schedule-section .header { + font-weight: bold; + color: #000; + margin-bottom: 16px; +} +#file-section { + grid-area: schedule; + justify-self: center; + width: calc(100% - 32px); + max-width: 900px; + padding: 32px; + background: #f5f5f5; + border-radius: 8px; + margin-top: 32px; + margin-bottom: 16px; } #file-section .header { font-weight: bold; @@ -634,9 +651,9 @@ header .menu { } .layout-tree { display: grid; - grid-template-areas: "header header" "tree crumbs" "tree child-nodes" "tree name" "tree content" "tree checklist" "tree files" "tree blank"; + grid-template-areas: "header header" "tree crumbs" "tree child-nodes" "tree name" "tree content" "tree checklist" "tree schedule" "tree files" "tree blank"; grid-template-columns: min-content 1fr; - grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* files */ 1fr; + grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* schedule */ min-content /* files */ 1fr; /* blank */ color: #fff; min-height: 100%; @@ -669,9 +686,9 @@ header .menu { display: block; } .layout-crumbs { - grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "files" "blank"; + grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "schedule" "files" "blank"; grid-template-columns: 1fr; - grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* files */ 1fr; + grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* schedule */ min-content /* files */ 1fr; /* blank */ } .layout-crumbs #tree { @@ -718,17 +735,17 @@ header .menu { } #app.node { display: grid; - grid-template-areas: "header header" "tree crumbs" "tree child-nodes" "tree name" "tree content" "tree checklist" "tree files" "tree blank"; + grid-template-areas: "header header" "tree crumbs" "tree child-nodes" "tree name" "tree content" "tree checklist" "tree schedule" "tree files" "tree blank"; grid-template-columns: min-content 1fr; - grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* files */ 1fr; + grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* schedule */ min-content /* files */ 1fr; /* blank */ color: #fff; min-height: 100%; } #app.node.toggle-tree { - grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "files" "blank"; + grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "schedule" "files" "blank"; grid-template-columns: 1fr; - grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* files */ 1fr; + grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* schedule */ min-content /* files */ 1fr; /* blank */ } #app.node.toggle-tree #tree { @@ -750,11 +767,22 @@ header .menu { #profile-settings .passwords div { white-space: nowrap; } +#schedule-events { + display: grid; + grid-template-columns: repeat(5, min-content); + grid-gap: 4px 12px; + margin: 32px; + color: #000; + white-space: nowrap; +} +#schedule-events .header { + font-weight: bold; +} @media only screen and (max-width: 932px) { #app.node { - grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "files" "blank"; + grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "schedule" "files" "blank"; grid-template-columns: 1fr; - grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* files */ 1fr; + grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* schedule */ min-content /* files */ 1fr; /* blank */ } #app.node #tree { diff --git a/static/js/node.mjs b/static/js/node.mjs index 3608ab7..be03626 100644 --- a/static/js/node.mjs +++ b/static/js/node.mjs @@ -14,7 +14,6 @@ export class NodeUI extends Component { this.nodeContent = createRef() this.nodeProperties = createRef() this.keys = signal([]) - this.page = signal('node') window.addEventListener('popstate', evt => { if (evt.state && evt.state.hasOwnProperty('nodeID')) @@ -59,7 +58,7 @@ export class NodeUI extends Component { case 'node': if (node.ID == 0) { page = html` -
this.page.value = 'schedule-events'}>Schedule events
+
this.page.value = 'schedule-events'}>Schedule events
${children.length > 0 ? html`
${children}
Notes version ${window._VERSION}
` : html``} ` } else { @@ -73,6 +72,7 @@ export class NodeUI extends Component { ${node.Name} ${padlock} <${NodeContent} key=${node.ID} node=${node} ref=${this.nodeContent} /> + <${NodeEvents} events=${node.ScheduleEvents.value} /> <${Checklist} ui=${this} groups=${node.ChecklistGroups} /> <${NodeFiles} node=${this.node.value} /> ` @@ -250,15 +250,12 @@ export class NodeUI extends Component { }) }//}}} saveNode() {//{{{ - /* - let nodeContent = this.nodeContent.current - if (this.page.value != 'node' || nodeContent === null) - return - */ - let content = this.node.value.content() this.node.value.setContent(content) - this.node.value.save(() => this.props.app.nodeModified.value = false) + this.node.value.save(() => { + this.props.app.nodeModified.value = false + this.node.value.retrieve() + }) }//}}} renameNode() {//{{{ let name = prompt("New name") @@ -389,6 +386,24 @@ class MarkdownContent extends Component { }//}}} } +class NodeEvents extends Component { + render({ events }) {//{{{ + if (events.length == 0) + return html`` + + const eventElements = events.map(evt => { + const dt = evt.Time.split('T') + return html`
${dt[0]} ${dt[1].slice(0, 5)}
` + }) + return html` +
+
Schedule events
+ ${eventElements} +
+ ` + }//}}} +} + class NodeFiles extends Component { render({ node }) {//{{{ if (node.Files === null || node.Files.length == 0) @@ -443,10 +458,16 @@ export class Node { this._decrypted = false this._expanded = false // start value for the TreeNode component, this.ChecklistGroups = {} + this.ScheduleEvents = signal([]) // it doesn't control it afterwards. // Used to expand the crumbs upon site loading. }//}}} retrieve(callback) {//{{{ + this.app.request('/schedule/list', { NodeID: this.ID }) + .then(res => { + this.ScheduleEvents.value = res.ScheduleEvents + }) + this.app.request('/node/retrieve', { ID: this.ID }) .then(res => { this.ParentID = res.Node.ParentID @@ -965,18 +986,50 @@ class ProfileSettings extends Component { } class ScheduleEventList extends Component { - constructor() { + constructor() {//{{{ super() + this.events = signal(null) this.retrieveFutureEvents() - } - render() { - } - retrieveFutureEvents() { - _app.current.request('/schedule/list') - .then(foo=>{ - console.log(foo) + }//}}} + render() {//{{{ + if (this.events.value === null) + return + + let events = this.events.value.map(evt => { + const dt = evt.Time.split('T') + const remind = () => { + if (evt.RemindMinutes > 0) + return html`${evt.RemindMinutes} min` + } + const nodeLink = () => html`${evt.Node.Name}` + + + return html` +
${dt[0]}
+
${dt[1].slice(0, 5)}
+
<${remind} />
+
${evt.Description}
+
<${nodeLink} />
+ ` }) - } + + return html` +
+
Date
+
Time
+
Reminder
+
Event
+
Node
+ ${events} +
+ ` + }//}}} + retrieveFutureEvents() {//{{{ + _app.current.request('/schedule/list') + .then(data => { + this.events.value = data.ScheduleEvents + }) + }//}}} } diff --git a/static/less/main.less b/static/less/main.less index 490c799..ed13f38 100644 --- a/static/less/main.less +++ b/static/less/main.less @@ -617,7 +617,7 @@ header { } /* ============================================================= */ -#file-section { +#schedule-section { grid-area: files; justify-self: center; width: calc(100% - 32px); @@ -627,6 +627,25 @@ header { border-radius: 8px; margin-top: 32px; margin-bottom: 32px; + color: #000; + + .header { + font-weight: bold; + color: #000; + margin-bottom: 16px; + } +} + +#file-section { + grid-area: schedule; + justify-self: center; + width: calc(100% - 32px); + max-width: 900px; + padding: 32px; + background: #f5f5f5; + border-radius: 8px; + margin-top: 32px; + margin-bottom: 16px; .header { font-weight: bold; @@ -751,6 +770,7 @@ header { "tree name" "tree content" "tree checklist" + "tree schedule" "tree files" "tree blank" ; @@ -762,6 +782,7 @@ header { min-content /* name */ min-content /* content */ min-content /* checklist */ + min-content /* schedule */ min-content /* files */ 1fr; /* blank */ color: #fff; @@ -795,6 +816,7 @@ header { "name" "content" "checklist" + "schedule" "files" "blank" ; @@ -806,6 +828,7 @@ header { min-content /* name */ min-content /* content */ min-content /* checklist */ + min-content /* schedule */ min-content /* files */ 1fr; /* blank */ #tree { display: none } @@ -869,6 +892,19 @@ header { } } +#schedule-events { + display: grid; + grid-template-columns: repeat(5, min-content); + grid-gap: 4px 12px; + margin: 32px; + color: #000; + white-space: nowrap; + + .header { + font-weight: bold; + } +} + @media only screen and (max-width: 932px) { #app.node { .layout-crumbs();