This commit is contained in:
Jake Walker 2023-07-14 16:05:57 +01:00
parent dabcd7b75a
commit 4c02dbb1d4
7 changed files with 221 additions and 64 deletions

View file

@ -7,4 +7,4 @@ indent_style = space
indent_size = 4
[*.{yaml,yml}]
indent_size = 2
indent_size = 2

View file

@ -1,10 +0,0 @@
<script lang="ts">
let count: number = 0
const increment = () => {
count += 1
}
</script>
<button on:click={increment}>
count is {count}
</button>

View file

@ -20,13 +20,20 @@
let newItemName = "";
let newItemDate = (new Date()).toISOString().split("T")[0];
let newItemDuration = "1h";
let newItemRawTags = "";
$: newItemTags = newItemRawTags.split(";").filter(x => x);
function createItem() {
$createMutation.mutate({
name: newItemName,
date: newItemDate,
duration: newItemDuration
duration: newItemDuration,
tags: newItemTags
});
newItemName = "";
newItemDuration = "1h";
document.getElementById("name-input").focus();
}
</script>
@ -62,7 +69,12 @@
<div class="vstack gap-3 justify-content-center h-100">
{#each items as item}
<div class="hstack gap-3">
<span class="me-auto">{item.name}</span>
<span class="me-auto">
{item.name}
{#each item.tags as tag}
<span class="badge bg-info me-1">{tag.name}</span>
{/each}
</span>
<button type="button" class="btn btn-danger btn-sm" on:click={() => {
if (!confirm(`Are you sure you want to delete "${item.name}"?`)) return;
$deleteMutation.mutate(item.id)
@ -79,7 +91,7 @@
{/each}
{/if}
</div>
<div class="col-md-6">
<h2>New</h2>
<form on:submit|preventDefault={createItem}>
@ -95,15 +107,24 @@
<label for="duration-input" class="form-label">Duration</label>
<input type="text" class="form-control" id="duration-input" bind:value={newItemDuration} required />
</div>
<div class="mb-3">
<label for="tags-input" class="form-label">Tags</label>
<input type="text" class="form-control" id="tags-input" bind:value={newItemRawTags} />
<div class="form-text">
{#each newItemTags as tag}
<span class="badge bg-info me-1">{tag}</span>
{/each}
</div>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</form>
</div>
</div>
<h2>Raw Data</h2>
{#if $queryResult.data}
<div class="small mt-2">
<pre><code>{JSON.stringify($queryResult.data, null, 2)}</code></pre>
</div>
{/if}
</div>
</div>

View file

@ -1,6 +1,13 @@
const SCHEDULE_DAYS = 14;
const API_URL = import.meta.env.PROD ? "/api" : import.meta.env.VITE_API_URL;
type ScheduleTag = {
id: number,
createdAt: string,
updatedAt: string,
name: string
}
type ScheduleItem = {
id: number,
createdAt: string,
@ -9,12 +16,14 @@ type ScheduleItem = {
description: string | null,
duration: string,
date: string
tags: ScheduleTag[]
};
type Schedule = { [date: string]: ScheduleItem[] };
export async function getSchedule(): Promise<Schedule> {
const res = await fetch(`${API_URL}/schedule?days=${SCHEDULE_DAYS}`);
if (res?.ok) {
return await res.json();
} else {
@ -22,7 +31,7 @@ export async function getSchedule(): Promise<Schedule> {
}
}
export async function createScheduleItem(data: { name: string, duration: string, date: string }) {
export async function createScheduleItem(data: { name: string, duration: string, date: string, tags: string[] }) {
const res = await fetch(`${API_URL}/items`, {
method: "POST",
headers: {
@ -33,7 +42,7 @@ export async function createScheduleItem(data: { name: string, duration: string,
date: new Date(data.date).toISOString()
})
});
if (res?.ok) {
return await res.json();
} else {
@ -45,10 +54,10 @@ export async function deleteScheduleItem(id: number) {
const res = await fetch(`${API_URL}/items/${id}`, {
method: "DELETE"
});
if (res?.ok) {
return;
} else {
throw new Error(`Invalid response code ${res?.status}`);
}
}
}

View file

@ -4,17 +4,18 @@ import (
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func GetScheduleItems(db *gorm.DB, fromDate time.Time, toDate time.Time) ([]ScheduleItem, error) {
var items []ScheduleItem
res := db.Where("date BETWEEN ? AND ?", fromDate.Format(time.DateOnly), toDate.Format(time.DateOnly)).Find(&items)
res := db.Preload("Tags").Where("date BETWEEN ? AND ?", fromDate.Format(time.DateOnly), toDate.Format(time.DateOnly)).Find(&items)
return items, res.Error
}
func GetScheduleItem(db *gorm.DB, id int) (*ScheduleItem, error) {
var item ScheduleItem
res := db.First(&item, id)
res := db.Preload("Tags").First(&item, id)
if res.RowsAffected < 1 {
return nil, nil
@ -37,15 +38,83 @@ func DeleteScheduleItem(db *gorm.DB, id int) (bool, error) {
return true, nil
}
func CreateScheduleItem(db *gorm.DB, name string, description *string, duration Duration, date time.Time) (ScheduleItem, error) {
func CreateScheduleItem(db *gorm.DB, name string, description *string, duration Duration, date time.Time, tagNames []string) (ScheduleItem, error) {
var tags = []ScheduleTag{}
var err error
if len(tagNames) > 0 {
tags, err = UpsertScheduleTags(db, tagNames)
if err != nil {
return ScheduleItem{}, err
}
}
item := ScheduleItem{
Name: name,
Description: description,
Duration: duration,
Date: date,
Tags: tags,
}
res := db.Create(&item)
return item, res.Error
}
func GetScheduleTags(db *gorm.DB) ([]ScheduleTag, error) {
var tags []ScheduleTag
res := db.Find(&tags)
return tags, res.Error
}
func GetScheduleTag(db *gorm.DB, id int) (*ScheduleTag, error) {
var tag ScheduleTag
res := db.First(&tag, id)
if res.RowsAffected < 1 {
return nil, nil
}
return &tag, res.Error
}
func DeleteScheduleTag(db *gorm.DB, id int) (bool, error) {
res := db.Delete(&ScheduleTag{}, id)
if res.Error != nil {
return false, res.Error
}
if res.RowsAffected < 1 {
return false, nil
}
return true, nil
}
func CreateScheduleTag(db *gorm.DB, name string) (ScheduleTag, error) {
tag := ScheduleTag{
Name: name,
}
res := db.Create(&tag)
return tag, res.Error
}
func UpsertScheduleTags(db *gorm.DB, names []string) ([]ScheduleTag, error) {
var tags = []ScheduleTag{}
for _, name := range names {
tags = append(tags, ScheduleTag{Name: name})
}
res := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&tags)
return tags, res.Error
}
func Cleanup(db *gorm.DB) error {
res := db.Exec("select * from schedule_tags where id not in (select schedule_tag_id from schedule_item_tags);")
return res.Error
}

130
main.go
View file

@ -1,15 +1,16 @@
package main
import (
"embed"
"github.com/gofiber/fiber/v2/middleware/filesystem"
"log"
"embed"
"log"
"net/http"
"time"
"github.com/gofiber/fiber/v2/middleware/filesystem"
"github.com/glebarez/sqlite"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
@ -17,18 +18,24 @@ import (
var f embed.FS
type Model struct {
ID uint `gorm:"primaryKey;not null" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
ID uint `gorm:"primaryKey;not null" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
type ScheduleItem struct {
Model
Name string `gorm:"not null" json:"name"`
Description *string `json:"description"`
Duration Duration `gorm:"not null;default:\"1h\"" json:"duration"`
Date time.Time `gorm:"not null" json:"date"`
Name string `gorm:"not null" json:"name"`
Description *string `json:"description"`
Duration Duration `gorm:"not null;default:\"1h\"" json:"duration"`
Date time.Time `gorm:"not null" json:"date"`
Tags []ScheduleTag `gorm:"many2many:schedule_item_tags;constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"tags"`
}
type ScheduleTag struct {
Model
Name string `gorm:"not null;unique;index" json:"name"`
}
type NewScheduleItem struct {
@ -36,6 +43,11 @@ type NewScheduleItem struct {
Description *string `json:"description" form:"description"`
Duration *Duration `json:"duration" form:"duration"`
Date *time.Time `json:"date" form:"date"`
Tags []string `json:"tags"`
}
type NewScheduleTag struct {
Name *string `json:"name" form:"name"`
}
func getDateValues(c *fiber.Ctx) (time.Time, time.Time, error) {
@ -63,7 +75,7 @@ func getDateValues(c *fiber.Ctx) (time.Time, time.Time, error) {
func dateRange(from time.Time, to time.Time) []time.Time {
var dates []time.Time
for d := from; d.After(to) == false; d = d.AddDate(0, 0, 1) {
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
dates = append(dates, d)
}
@ -76,13 +88,13 @@ func main() {
log.Fatalf("failed to open database: %v", err)
}
err = db.AutoMigrate(&ScheduleItem{})
err = db.AutoMigrate(&ScheduleItem{}, &ScheduleTag{})
if err != nil {
log.Fatalf("failed to migrate database: %v", err)
}
app := fiber.New()
app.Use(cors.New())
app.Get("/api/items", func(c *fiber.Ctx) error {
@ -109,7 +121,7 @@ func main() {
return err
}
if input.Name == nil || len(*input.Name) < 0 {
if input.Name == nil || len(*input.Name) == 0 {
return c.Status(fiber.StatusBadRequest).SendString("Name is required")
}
@ -121,7 +133,7 @@ func main() {
return c.Status(fiber.StatusBadRequest).SendString("Date is required")
}
item, err := CreateScheduleItem(db, *input.Name, input.Description, *input.Duration, *input.Date)
item, err := CreateScheduleItem(db, *input.Name, input.Description, *input.Duration, *input.Date, input.Tags)
if err != nil {
log.Printf("failed to create schedule item: %v", err)
@ -144,7 +156,7 @@ func main() {
log.Printf("failed to find schedule items: %v", err)
return c.SendStatus(fiber.StatusInternalServerError)
}
schedule := map[string][]ScheduleItem{}
for _, date := range dateRange(fromDate, toDate) {
@ -197,11 +209,83 @@ func main() {
return c.SendStatus(http.StatusOK)
})
app.Use(filesystem.New(filesystem.Config{
Root: http.FS(f),
PathPrefix: "public",
}))
app.Get("/api/tags", func(c *fiber.Ctx) error {
tags, err := GetScheduleTags(db)
if err != nil {
log.Printf("failed to find schedule tags: %v", err)
return c.SendStatus(fiber.StatusInternalServerError)
}
return c.JSON(tags)
})
app.Post("/api/tags", func(c *fiber.Ctx) error {
input := new(NewScheduleTag)
if err := c.BodyParser(input); err != nil {
return err
}
if input.Name == nil || len(*input.Name) == 0 {
return c.Status(fiber.StatusBadRequest).SendString("Name is required")
}
tag, err := CreateScheduleTag(db, *input.Name)
if err != nil {
log.Printf("failed to create schedule tag: %v", err)
return c.SendStatus(fiber.StatusInternalServerError)
}
return c.JSON(tag)
})
app.Get("/api/tags/:id<int>", func(c *fiber.Ctx) error {
id, err := c.ParamsInt("id")
if err != nil {
return c.Status(fiber.StatusBadRequest).SendString("Invalid id")
}
tag, err := GetScheduleTag(db, id)
if err != nil {
log.Printf("failed to find schedule tag: %v", err)
return c.SendStatus(fiber.StatusInternalServerError)
}
if tag == nil {
return c.SendStatus(fiber.StatusNotFound)
}
return c.JSON(*tag)
})
app.Delete("/api/tags/:id<int>", func(c *fiber.Ctx) error {
id, err := c.ParamsInt("id")
if err != nil {
return c.Status(fiber.StatusBadRequest).SendString("Invalid id")
}
ok, err := DeleteScheduleTag(db, id)
if err != nil {
log.Printf("failed to delete schedule item: %v", err)
return c.SendStatus(fiber.StatusInternalServerError)
}
if !ok {
return c.SendStatus(fiber.StatusNotFound)
}
return c.SendStatus(http.StatusOK)
})
app.Use(filesystem.New(filesystem.Config{
Root: http.FS(f),
PathPrefix: "public",
}))
log.Fatal(app.Listen(":3000"))
}

View file

@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Schedule</title>
<script type="module" crossorigin src="/assets/index-b0266e6c.js"></script>
<link rel="stylesheet" href="/assets/index-7823ff9f.css">
</head>
<body>
<div id="app"></div>
</body>
</html>