Compare commits

..
67 Commits
Author SHA1 Message Date
gisle cf61cf39c2 schedule all items in requirements.
continuous-integration/drone/push Build is passing
2023-09-24 14:41:54 +02:00
gisle d7661adc09 fix tag error in item sprints doing the opposite of what it should.
continuous-integration/drone/push Build is passing
2023-09-23 16:04:10 +02:00
gisle b2effd918d fix stat sprints.
continuous-integration/drone/push Build is passing
2023-05-11 20:43:59 +02:00
gisle 9e6bdb5fd4 allow non-optional tags for multi-tag scopes with ! prefix to restore old behavior.
continuous-integration/drone/push Build is passing
2023-05-09 20:36:01 +02:00
gisle 2aa5919615 change tag handling in sprints to be OR instead of AND.
continuous-integration/drone/push Build is passing
2023-05-06 13:28:32 +02:00
gisle 4e5a525219 fix crash on opening create item modal in scope overview.
continuous-integration/drone/push Build is passing
2023-02-27 21:10:06 +01:00
gisle ba3e9606e3 fix crash when there's an empty requirement.
continuous-integration/drone/push Build is passing
2023-02-27 20:52:30 +01:00
gisle 63674efde4 add frontend for jump to requirements.
continuous-integration/drone/push Build is passing
2023-02-26 15:50:58 +01:00
gisle 9ea7cf8ca4 add tag to project link in endpoints for listing requirements.
continuous-integration/drone/push Build is passing
2023-02-25 17:58:22 +01:00
gisle fefb1b5e46 fix endpoints for listing requirements.
continuous-integration/drone/push Build is passing
2023-02-25 16:59:08 +01:00
gisle e983e68ed6 add endpoints for listing requirements.
continuous-integration/drone/push Build is passing
2023-02-24 15:37:00 +01:00
gisle 0ed233fb8a fix the front page history title for real this time.
continuous-integration/drone/push Build is passing
2023-02-24 15:04:21 +01:00
gisle 80e18c3c8a fix the front page history incorrectly claiming yesterday is today for most of the day.
continuous-integration/drone/push Build is passing
2023-02-22 20:20:43 +01:00
gisle d25e24ed8a add more history to front page, fix dev proxy not working
continuous-integration/drone/push Build is passing
2023-02-21 19:44:52 +01:00
gisle fe7a98b277 fix month
continuous-integration/drone/push Build is passing
2022-12-18 18:10:59 +01:00
gisle 1f1fdcd34e fix empty sprints causing an exception to be thrown
continuous-integration/drone/push Build is passing
2022-11-30 20:08:12 +01:00
gisle 14f3f7dc49 item filter will match project/requirement tags.
continuous-integration/drone/push Build is passing
2022-11-20 22:54:39 +01:00
gisle 98175e4160 add API and GUI of moving requirements between projects
continuous-integration/drone/push Build is passing
2022-11-20 22:06:55 +01:00
gisle 158386d6fd fix project edits being ignored in UI unless they change the pretty ID.
continuous-integration/drone/push Build is passing
2022-11-20 17:31:31 +01:00
gisle 995504d969 add tags to sprints.
continuous-integration/drone/push Build is passing
2022-11-20 16:20:46 +01:00
gisle 97ad87680a fix tags for projects and reqs, and add GUI for them.
continuous-integration/drone/push Build is passing
2022-11-20 15:46:52 +01:00
gisle c565ca5ea7 add tags to more stuff.
continuous-integration/drone/push Build is passing
2022-11-20 13:47:03 +01:00
gisle 30bc93f9ee fix bug with tag fetching, add tags to item frontend.
continuous-integration/drone/push Build is passing
2022-11-20 10:46:54 +01:00
gisle 926723a12e add item tags to backend
continuous-integration/drone/push Build is passing
2022-11-19 17:26:42 +01:00
gisle 78361977f2 fix kanji misalignment in sub-section titles.
continuous-integration/drone/push Build is passing
2022-11-16 22:43:02 +01:00
gisle 9ac4a89af7 unoops color oops
continuous-integration/drone/push Build is passing
2022-11-16 21:16:24 +01:00
gisle be8631feac fix add another clearing stuffs.
continuous-integration/drone/push Build is passing
2022-11-05 12:26:15 +01:00
gisle 9238e216ba fix missing last metric in burndown chart.
continuous-integration/drone/push Build is passing
2022-11-01 23:43:55 +01:00
gisle d88352cc24 fix progress bars rounding instead of flooring for segmented bars.
continuous-integration/drone/push Build is passing
2022-11-01 19:52:46 +01:00
gisle 7276b026f5 fix this_month, prev_month and next_month date ranges getting funky near the month change.
continuous-integration/drone/push Build is passing
2022-10-31 19:17:24 +01:00
gisle 16ec7b9bb5 add password change form. 2022-10-31 19:03:00 +01:00
gisle b4fb329247 >_>
continuous-integration/drone/push Build is passing
2022-10-16 13:02:17 +02:00
gisle 2f4a85f0a9 fix sorting on the front page only?
continuous-integration/drone/push Build is passing
2022-10-16 12:28:35 +02:00
gisle 614687b0a9 fix unstable sorting of items with the same scheduled date.
continuous-integration/drone/push Build is passing
2022-10-16 12:14:31 +02:00
gisle 01d8e8f014 fix issue with burndown charts when first day has acq items.
continuous-integration/drone/push Build is passing
2022-10-15 13:14:55 +02:00
gisle fa91b4df12 fix issue with burndown charts when first day has acq items.
continuous-integration/drone/push Build is passing
2022-10-15 13:08:45 +02:00
gisle b322383d3d add burndown (burnup?) graphs.
continuous-integration/drone/push Build is passing
2022-10-15 12:52:46 +02:00
gisle a242cc4ee4 fix progress bars not being fullwidth when target < 1.
continuous-integration/drone/push Build is passing
2022-10-14 20:15:26 +02:00
gisle 8cf56a4349 abbreviated date
continuous-integration/drone/push Build is passing
2022-10-14 19:48:54 +02:00
gisle 27dea52fec add boat for item requirement planning.
continuous-integration/drone/push Build is passing
2022-10-14 16:41:20 +02:00
gisle 8c951be14d add timer for scheduled items.
continuous-integration/drone/push Build is passing
2022-10-14 16:35:08 +02:00
gisle acaaca1b48 add total for overview item list.
continuous-integration/drone/push Build is passing
2022-10-12 20:30:37 +02:00
gisle 7064e9c693 fix add another sticking between modal openings.
continuous-integration/drone/push Build is passing
2022-10-10 19:36:09 +02:00
gisle a2baad731b fix requirement change not working on edit.
continuous-integration/drone/push Build is passing
2022-10-09 14:13:42 +02:00
gisle 9edf074ebd fix scheduled items not showing in history.
continuous-integration/drone/push Build is passing
2022-10-08 14:47:19 +02:00
gisle b716c79434 add scheduled date to item sorting.
continuous-integration/drone/push Build is passing
2022-10-08 14:36:38 +02:00
gisle 20b23ae822 add scheduling.
continuous-integration/drone/push Build is passing
2022-10-08 12:52:23 +02:00
gisle a328184ad1 fix bug with add another.
continuous-integration/drone/push Build is passing
2022-10-08 12:01:53 +02:00
gisle 26aaa5ac3c add 'add another' checkbox
continuous-integration/drone/push Build is passing
2022-10-08 11:32:35 +02:00
gisle d17abd20d1 refactor auth to run it per request rather than carry it around the app.
continuous-integration/drone/push Build is passing
2022-10-08 11:16:34 +02:00
gisle 900769db9f unset concurrency limit.
continuous-integration/drone/push Build is passing
2022-10-02 22:20:51 +02:00
gisle 9f8ea798a6 set concurrency limit to avoid 4 cold starts.
continuous-integration/drone/push Build is passing
2022-10-02 22:00:48 +02:00
gisle b95a43abef Revert "fix ci."
continuous-integration/drone/push Build is passing
This reverts commit c467448737.
2022-10-02 16:30:16 +02:00
gisle c467448737 fix ci.
continuous-integration/drone/push Build was killed
2022-10-02 16:29:12 +02:00
gisle 16ffae271d fix ci.
continuous-integration/drone/push Build is passing
2022-10-02 16:27:28 +02:00
gisle 8e09378644 fix ci.
continuous-integration/drone/push Build is passing
2022-10-02 16:02:06 +02:00
gisle 44685d9aa9 fix ci.
continuous-integration/drone/push Build is passing
2022-10-02 15:52:11 +02:00
gisle 82fef3eea9 fix ci
continuous-integration/drone/push Build is passing
2022-10-02 13:46:36 +02:00
gisle f6eddd4fbb fix ci
continuous-integration/drone/push Build is passing
2022-10-02 13:00:32 +02:00
gisle 539552a4ca fix ci
continuous-integration/drone/push Build is passing
2022-10-02 12:56:03 +02:00
gisle 4f4f67efa9 fix ci
continuous-integration/drone/push Build is passing
2022-10-02 12:33:46 +02:00
gisle 12e3f8053b fix ci
continuous-integration/drone/push Build is failing
2022-10-02 12:21:59 +02:00
gisle d1da0eeafa fix ci
continuous-integration/drone/push Build is failing
2022-10-02 12:13:24 +02:00
gisle 8cd2412b25 fix ci
continuous-integration/drone/push Build is failing
2022-10-02 12:05:46 +02:00
gisle e11a230921 fix ci
continuous-integration/drone/push Build is failing
2022-10-02 11:43:02 +02:00
gisle d6d3877805 fix ci
continuous-integration/drone/push Build is failing
2022-10-02 11:41:23 +02:00
gisle f57149c0d0 un-SSR this b→þ©ħ
continuous-integration/drone/push Build is failing
2022-10-02 11:37:26 +02:00
108 changed files with 3392 additions and 806 deletions
+107 -31
View File
@@ -4,38 +4,114 @@ kind: pipeline
type: docker
steps:
- name: go-test
image: golang:1.18
- name: backend-build
image: golang:1.19
depends_on: []
commands:
- go mod download
- CGO_ENABLED=0 go build -ldflags "-w -s" -o build/handler cmd/stufflog3/main.go
- name: backend-test
image: golang:1.19
depends_on: []
commands:
- go generate ./...
- go test -v ./...
- name: docker-backend-tag
image: plugins/docker
settings:
auto_tag: true
username:
from_secret: docker_username
password:
from_secret: docker_password
repo: r.vmaple.dev/stufflog3/backend
registry: r.vmaple.dev
when:
event:
- tag
- name: backend-migrate
image: golang:1.19
depends_on: []
environment:
DB_CONNECT:
from_secret: DB_CONNECT
commands:
- go install github.com/pressly/goose/v3/cmd/goose@latest
- cd scripts/goose-mysql/
- goose mysql "$DB_CONNECT" up
- name: docker-frontend-tag
image: plugins/docker
settings:
dockerfile: ./frontend/Dockerfile
context: ./frontend
auto_tag: true
username:
from_secret: docker_username
password:
from_secret: docker_password
repo: r.vmaple.dev/stufflog3/frontend
registry: r.vmaple.dev
when:
event:
- tag
- name: backend-deploy
image: node:18.3.0
depends_on:
- backend-build
- backend-test
- backend-migrate
environment:
AWS_ACCESS_KEY_ID:
from_secret: AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY:
from_secret: AWS_SECRET_ACCESS_KEY
AWS_DEFAULT_REGION:
from_secret: STUFFLOG3_AWS_REGION
STUFFLOG3_AWS_CLIENT_ID:
from_secret: STUFFLOG3_AWS_CLIENT_ID
STUFFLOG3_AWS_CLIENT_SECRET:
from_secret: STUFFLOG3_AWS_CLIENT_SECRET
STUFFLOG3_AWS_POOL_CLIENT_ID:
from_secret: STUFFLOG3_AWS_POOL_CLIENT_ID
STUFFLOG3_AWS_POOL_CLIENT_SECRET:
from_secret: STUFFLOG3_AWS_POOL_CLIENT_SECRET
STUFFLOG3_AWS_POOL_ID:
from_secret: STUFFLOG3_AWS_POOL_ID
STUFFLOG3_AWS_REGION:
from_secret: STUFFLOG3_AWS_REGION
STUFFLOG3_MYSQL_HOST:
from_secret: STUFFLOG3_MYSQL_HOST
STUFFLOG3_MYSQL_PASSWORD:
from_secret: STUFFLOG3_MYSQL_PASSWORD
STUFFLOG3_MYSQL_PORT:
from_secret: STUFFLOG3_MYSQL_PORT
STUFFLOG3_MYSQL_SCHEMA:
from_secret: STUFFLOG3_MYSQL_SCHEMA
STUFFLOG3_MYSQL_USERNAME:
from_secret: STUFFLOG3_MYSQL_USERNAME
DOMAIN_NAME:
from_secret: DOMAIN_NAME
CERTIFICATE_ARN:
from_secret: CERTIFICATE_ARN
CERTIFICATE_NAME:
from_secret: CERTIFICATE_NAME
HOSTED_ZONE_ID:
from_secret: HOSTED_ZONE_ID
S3_WEBUI_BUCKET:
from_secret: S3_WEBUI_BUCKET
IAM_ROLE:
from_secret: IAM_ROLE
commands:
- apt-get update > /dev/null 2>&1
- apt-get -y install awscli zip > /dev/null 2>&1
- npm install -g serverless > /dev/null 2>&1
- npm install -g serverless-domain-manager > /dev/null 2>&1
- npm install -g serverless-apigateway-service-proxy > /dev/null 2>&1
- serverless deploy
- name: frontend-build
image: node:18.3.0
depends_on: []
environment:
VITE_STUFFLOG3_AWS_POOL_REGION:
from_secret: VITE_STUFFLOG3_AWS_POOL_REGION
VITE_STUFFLOG3_AWS_POOL_ID:
from_secret: VITE_STUFFLOG3_AWS_POOL_ID
VITE_STUFFLOG3_AWS_POOL_PUBLIC_CLIENT_ID:
from_secret: VITE_STUFFLOG3_AWS_POOL_PUBLIC_CLIENT_ID
commands:
- cd frontend
- npm install
- npm run build
- cp static/* build/
- name: frontend-deploy
image: amazon/aws-cli:latest
depends_on:
- frontend-build
environment:
AWS_ACCESS_KEY_ID:
from_secret: AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY:
from_secret: AWS_SECRET_ACCESS_KEY
AWS_DEFAULT_REGION:
from_secret: STUFFLOG3_AWS_REGION
S3_WEBUI_BUCKET:
from_secret: S3_WEBUI_BUCKET
commands:
- cd frontend/build
- aws s3 sync --delete . s3://$S3_WEBUI_BUCKET
+1 -1
View File
@@ -8,7 +8,7 @@ COPY . .
# Build server and tools (CGO disabled to make them 100% static)
ENV CGO_ENABLED 0
RUN go build -installsuffix cgo -ldflags="-s -w" -o /binaries/stufflog3-local ./cmd/stufflog3-local
RUN go build -installsuffix cgo -ldflags="-s -w" -o /binaries/stufflog3 ./cmd/stufflog3
## 2. Distribute
# Use alpine linux
-140
View File
@@ -1,140 +0,0 @@
package main
import (
"git.aiterp.net/stufflog3/stufflog3/ports/cognitoauth"
"git.aiterp.net/stufflog3/stufflog3/ports/httpapi"
"git.aiterp.net/stufflog3/stufflog3/ports/mysql"
"git.aiterp.net/stufflog3/stufflog3/usecases/auth"
"git.aiterp.net/stufflog3/stufflog3/usecases/items"
"git.aiterp.net/stufflog3/stufflog3/usecases/projects"
"git.aiterp.net/stufflog3/stufflog3/usecases/scopes"
"git.aiterp.net/stufflog3/stufflog3/usecases/sprints"
"git.aiterp.net/stufflog3/stufflog3/usecases/stats"
"github.com/gin-gonic/gin"
"log"
"os"
"os/signal"
"strconv"
"syscall"
)
func main() {
db, err := mysql.Connect(
os.Getenv("STUFFLOG3_MYSQL_HOST"),
envInt("STUFFLOG3_MYSQL_PORT"),
os.Getenv("STUFFLOG3_MYSQL_USERNAME"),
os.Getenv("STUFFLOG3_MYSQL_PASSWORD"),
os.Getenv("STUFFLOG3_MYSQL_SCHEMA"),
)
if err != nil {
log.Println("Failed to open database:", err)
os.Exit(1)
}
cognitoAuth, err := cognitoauth.New(
os.Getenv("STUFFLOG3_AWS_REGION"),
os.Getenv("STUFFLOG3_AWS_CLIENT_ID"),
os.Getenv("STUFFLOG3_AWS_CLIENT_SECRET"),
os.Getenv("STUFFLOG3_AWS_POOL_ID"),
os.Getenv("STUFFLOG3_AWS_POOL_CLIENT_ID"),
os.Getenv("STUFFLOG3_AWS_POOL_CLIENT_SECRET"),
)
if err != nil {
log.Println("Failed to setup cognito:", err)
os.Exit(1)
}
authService := &auth.Service{
Provider: cognitoAuth,
}
scopesService := &scopes.Service{
Auth: authService,
Repository: db.Scopes(),
StatsLister: db.Stats(),
}
statsService := &stats.Service{
Scopes: scopesService,
Repository: db.Stats(),
}
itemsService := &items.Service{
Auth: authService,
Scopes: scopesService,
Stats: statsService,
Repository: db.Items(),
RequirementFetcher: db.Projects(),
ProjectFetcher: db.Projects(),
}
projectsService := &projects.Service{
Auth: authService,
Scopes: scopesService,
Stats: statsService,
Items: itemsService,
Repository: db.Projects(),
}
sprintsService := &sprints.Service{
Scopes: scopesService,
Items: itemsService,
Projects: projectsService,
Repository: db.Sprints(),
}
server := gin.New()
httpapi.Auth(server.Group("/api/v1/auth"), authService)
apiV1 := server.Group("/api/v1")
if os.Getenv("STUFFLOG3_USE_DUMMY_USER") == "true" {
log.Println("Using dummy UUID")
apiV1.Use(httpapi.DummyMiddleware(authService, "c11230be-4912-4313-83b0-410a248b5bd1"))
} else {
apiV1.Use(httpapi.JwtValidatorMiddleware(authService))
}
apiV1Scopes := apiV1.Group("/scopes")
httpapi.Scopes(apiV1Scopes, scopesService)
apiV1ScopesSub := apiV1Scopes.Group("/:scope_id")
apiV1ScopesSub.Use(httpapi.ScopeMiddleware(scopesService, authService))
httpapi.Projects(apiV1ScopesSub.Group("/projects"), projectsService)
httpapi.Items(apiV1ScopesSub.Group("/items"), itemsService)
httpapi.Sprints(apiV1ScopesSub.Group("/sprints"), sprintsService)
httpapi.Stats(apiV1ScopesSub.Group("/stats"), statsService)
apiV1AllScopes := apiV1.Group("/all-scopes")
apiV1AllScopes.Use(httpapi.AllScopeMiddleware(scopesService, authService))
httpapi.ItemsAllScopes(apiV1AllScopes.Group("/items"), itemsService)
httpapi.SprintsAllScopes(apiV1AllScopes.Group("/sprints"), sprintsService)
exitSignal := make(chan os.Signal)
signal.Notify(exitSignal, os.Interrupt, os.Kill, syscall.SIGTERM)
errCh := make(chan error)
go func() {
err := server.Run(":8239")
if err != nil {
errCh <- err
}
}()
select {
case sig := <-exitSignal:
{
log.Println("Received signal", sig)
os.Exit(0)
}
case err := <-errCh:
{
log.Println("Server run failed:", err)
os.Exit(2)
}
}
}
func envInt(key string) int {
value := os.Getenv(key)
if value == "" {
return 0
}
intValue, err := strconv.Atoi(value)
if err != nil {
return 0
}
return intValue
}
@@ -2,6 +2,7 @@ package main
import (
"context"
"flag"
"git.aiterp.net/stufflog3/stufflog3/ports/cognitoauth"
"git.aiterp.net/stufflog3/stufflog3/ports/httpapi"
"git.aiterp.net/stufflog3/stufflog3/ports/mysql"
@@ -17,10 +18,16 @@ import (
"github.com/gin-gonic/gin"
"log"
"os"
"os/signal"
"strconv"
"syscall"
)
var flagLocal = flag.Bool("local", false, "If set, run locally")
func main() {
flag.Parse()
db, err := mysql.Connect(
os.Getenv("STUFFLOG3_MYSQL_HOST"),
envInt("STUFFLOG3_MYSQL_PORT"),
@@ -94,18 +101,46 @@ func main() {
apiV1ScopesSub := apiV1Scopes.Group("/:scope_id")
apiV1ScopesSub.Use(httpapi.ScopeMiddleware(scopesService, authService))
httpapi.Projects(apiV1ScopesSub.Group("/projects"), projectsService)
httpapi.Requirements(apiV1ScopesSub.Group("/requirements"), projectsService)
httpapi.Items(apiV1ScopesSub.Group("/items"), itemsService)
httpapi.Sprints(apiV1ScopesSub.Group("/sprints"), sprintsService)
httpapi.Stats(apiV1ScopesSub.Group("/stats"), statsService)
apiV1AllScopes := apiV1.Group("/all-scopes")
apiV1AllScopes.Use(httpapi.AllScopeMiddleware(scopesService, authService))
httpapi.ItemsAllScopes(apiV1AllScopes, itemsService)
httpapi.ItemsAllScopes(apiV1AllScopes.Group("/items"), itemsService)
httpapi.SprintsAllScopes(apiV1AllScopes.Group("/sprints"), sprintsService)
httpapi.RequirementsAllScopes(apiV1AllScopes.Group("/requirements"), projectsService)
ginLambda := ginadapter.New(server)
lambda.Start(func(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
return ginLambda.ProxyWithContext(ctx, request)
})
if *flagLocal {
exitSignal := make(chan os.Signal)
signal.Notify(exitSignal, os.Interrupt, os.Kill, syscall.SIGTERM)
errCh := make(chan error)
go func() {
err := server.Run(":8239")
if err != nil {
errCh <- err
}
}()
select {
case sig := <-exitSignal:
{
log.Println("Received signal", sig)
os.Exit(0)
}
case err := <-errCh:
{
log.Println("Server run failed:", err)
os.Exit(2)
}
}
} else {
ginLambda := ginadapter.New(server)
lambda.Start(func(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
return ginLambda.ProxyWithContext(ctx, request)
})
}
}
func envInt(key string) int {
+20
View File
@@ -1,7 +1,10 @@
package entities
import (
"git.aiterp.net/stufflog3/stufflog3/internal/genutils"
"git.aiterp.net/stufflog3/stufflog3/models"
"sort"
"strings"
"time"
)
@@ -13,6 +16,7 @@ type Item struct {
RequirementID *int `json:"requirementId,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
Tags []string `json:"tags"`
CreatedTime time.Time `json:"createdTime"`
AcquiredTime *time.Time `json:"acquiredTime"`
ScheduledDate *models.Date `json:"scheduledDate"`
@@ -33,6 +37,16 @@ func (item *Item) AcquiredBetween(fromTime, toTime time.Time) bool {
return item.AcquiredTime != nil && !item.AcquiredTime.Before(fromTime) && item.AcquiredTime.Before(toTime)
}
func (item *Item) HasTag(tag string) bool {
for _, tag2 := range item.Tags {
if strings.EqualFold(tag, tag2) {
return true
}
}
return false
}
func (item *Item) ApplyUpdate(update models.ItemUpdate) {
if update.RequirementID != nil {
if *update.RequirementID <= 0 {
@@ -62,4 +76,10 @@ func (item *Item) ApplyUpdate(update models.ItemUpdate) {
if update.ClearAcquiredTime {
item.AcquiredTime = nil
}
if update.RemoveTags != nil || update.AddTags != nil {
item.Tags = genutils.SliceWithout(item.Tags, update.RemoveTags)
item.Tags = genutils.SliceWithUniques(item.Tags, update.AddTags)
sort.Strings(item.Tags)
}
}
+19
View File
@@ -1,7 +1,9 @@
package entities
import (
"git.aiterp.net/stufflog3/stufflog3/internal/genutils"
"git.aiterp.net/stufflog3/stufflog3/models"
"sort"
"time"
)
@@ -13,6 +15,7 @@ type Project struct {
Name string `json:"name"`
Description string `json:"description"`
Status models.Status `json:"status"`
Tags []string `json:"tags"`
}
func (project *Project) Update(update models.ProjectUpdate) {
@@ -28,6 +31,12 @@ func (project *Project) Update(update models.ProjectUpdate) {
if update.Status != nil {
project.Status = *update.Status
}
if update.RemoveTags != nil || update.AddTags != nil {
project.Tags = genutils.SliceWithout(project.Tags, update.RemoveTags)
project.Tags = genutils.SliceWithUniques(project.Tags, update.AddTags)
sort.Strings(project.Tags)
}
}
type Requirement struct {
@@ -39,6 +48,7 @@ type Requirement struct {
Description string `json:"description"`
IsCoarse bool `json:"isCoarse"`
Status models.Status `json:"status"`
Tags []string `json:"tags"`
}
func (requirement *Requirement) Update(update models.RequirementUpdate) {
@@ -57,6 +67,15 @@ func (requirement *Requirement) Update(update models.RequirementUpdate) {
if update.AggregateRequired != nil {
requirement.AggregateRequired = *update.AggregateRequired
}
if update.ProjectID != nil {
requirement.ProjectID = *update.ProjectID
}
if update.RemoveTags != nil || update.AddTags != nil {
requirement.Tags = genutils.SliceWithout(requirement.Tags, update.RemoveTags)
requirement.Tags = genutils.SliceWithUniques(requirement.Tags, update.AddTags)
sort.Strings(requirement.Tags)
}
}
type RequirementStat struct {
+9
View File
@@ -12,6 +12,8 @@ type Sprint struct {
Description string `json:"description"`
Kind models.SprintKind `json:"kind"`
Tags []string `json:"tags"`
FromTime time.Time `json:"fromTime"`
ToTime time.Time `json:"toTime"`
@@ -58,4 +60,11 @@ func (sprint *Sprint) ApplyUpdate(update models.SprintUpdate) {
if update.AggregateRequired != nil {
sprint.AggregateRequired = *update.AggregateRequired
}
if update.Tags != nil {
if len(update.Tags) == 0 {
sprint.Tags = nil
} else {
sprint.Tags = update.Tags
}
}
}
+190 -161
View File
@@ -10,11 +10,11 @@
"devDependencies": {
"@aws-amplify/auth": "^4.5.6",
"@aws-amplify/core": "^4.5.6",
"@djverrall/sveltekit-lambda-adapter": "^0.0.14",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
"@nikso/adapter-serverless": "^0.1.0",
"@sveltejs/adapter-auto": "next",
"@sveltejs/adapter-node": "^1.0.0-next.78",
"@sveltejs/adapter-static": "^1.0.0-next.43",
"@sveltejs/kit": "next",
"@types/cookie": "^0.5.1",
"@types/marked": "^4.0.3",
@@ -23,6 +23,7 @@
"cookie": "^0.5.0",
"fa-svelte": "^3.1.0",
"insane": "^2.6.2",
"layercake": "^7.1.0",
"marked": "^4.0.17",
"node-sass": "^7.0.1",
"sass": "^1.52.3",
@@ -7224,34 +7225,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@djverrall/sveltekit-lambda-adapter": {
"version": "0.0.14",
"resolved": "https://registry.npmjs.org/@djverrall/sveltekit-lambda-adapter/-/sveltekit-lambda-adapter-0.0.14.tgz",
"integrity": "sha512-kjg3du9tS9aFAoLMiD/MH36exky9tg27zw1aG8VUY3M5xL6g0nbstULPBZYwSkVFcBXupVLbBkWihFXCzNqxgQ==",
"dev": true,
"dependencies": {
"mime-types": "^2.1.35",
"node-fetch": "^3.2.3"
}
},
"node_modules/@djverrall/sveltekit-lambda-adapter/node_modules/node-fetch": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.2.6.tgz",
"integrity": "sha512-LAy/HZnLADOVkVPubaxHDft29booGglPFDr2Hw0J1AercRh01UiVFm++KMDnJeH9sHgNB4hsXPii7Sgym/sTbw==",
"dev": true,
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.1.1.tgz",
@@ -8494,6 +8467,12 @@
"tiny-glob": "^0.2.9"
}
},
"node_modules/@sveltejs/adapter-static": {
"version": "1.0.0-next.43",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-1.0.0-next.43.tgz",
"integrity": "sha512-PAgSp1GA8HkYE4p30/sBvJme2nefhcTBJafqQdMNoUksWZF2WzuL8OEO8wa9ndE6cghcGk3j6Ve0Oskg/wtTOw==",
"dev": true
},
"node_modules/@sveltejs/adapter-vercel": {
"version": "1.0.0-next.58",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-vercel/-/adapter-vercel-1.0.0-next.58.tgz",
@@ -11042,6 +11021,88 @@
"integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==",
"dev": true
},
"node_modules/d3-array": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.0.tgz",
"integrity": "sha512-3yXFQo0oG3QCxbF06rMPFyGRMGJNS7NvsV1+2joOjbBE+9xvWQ8+GcMJAjRCzw06zQ3/arXeJgbPYcjUCuC+3g==",
"dev": true,
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"dev": true,
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"dev": true,
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dev": true,
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"dev": true,
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.0.0.tgz",
"integrity": "sha512-zmV3lRnlaLI08y9IMRXSDshQb5Nj77smnfpnd2LrBa/2K281Jijactokeak14QacHs/kKq0AQ121nidNYlarbQ==",
"dev": true,
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"dev": true,
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/dashdash": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
@@ -11054,15 +11115,6 @@
"node": ">=0.10"
}
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz",
"integrity": "sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA==",
"dev": true,
"engines": {
"node": ">= 12"
}
},
"node_modules/dayjs": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.3.tgz",
@@ -12733,29 +12785,6 @@
"pend": "~1.2.0"
}
},
"node_modules/fetch-blob": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.1.5.tgz",
"integrity": "sha512-N64ZpKqoLejlrwkIAnb9iLSA3Vx/kjgzpcDhygcqJ2KKjky8nCgUQ+dzXtbrLaWZGZNmNfQTsiQ0weZ1svglHg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/figures": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
@@ -13014,18 +13043,6 @@
"node": ">= 0.12"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"dev": true,
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/formidable": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-2.0.1.tgz",
@@ -14131,6 +14148,15 @@
"he": "0.5.0"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"dev": true,
"engines": {
"node": ">=12"
}
},
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@@ -15137,6 +15163,15 @@
"node": ">=6"
}
},
"node_modules/layercake": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/layercake/-/layercake-7.1.0.tgz",
"integrity": "sha512-DV6FsmG0c5a2UyB15QBKBhgxhUW7E1615UTiTQGs2cnfSHPdgo8PfhAdX/E6AZwYhN9oLECpINBhEg3WNlIKjw==",
"dev": true,
"dependencies": {
"d3-scale": "^4.0.2"
}
},
"node_modules/lazystream": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
@@ -16721,25 +16756,6 @@
"node": ">= 0.10.5"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
@@ -21633,15 +21649,6 @@
"defaults": "^1.0.3"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz",
"integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==",
"dev": true,
"engines": {
"node": ">= 8"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@@ -28179,29 +28186,6 @@
"to-fast-properties": "^2.0.0"
}
},
"@djverrall/sveltekit-lambda-adapter": {
"version": "0.0.14",
"resolved": "https://registry.npmjs.org/@djverrall/sveltekit-lambda-adapter/-/sveltekit-lambda-adapter-0.0.14.tgz",
"integrity": "sha512-kjg3du9tS9aFAoLMiD/MH36exky9tg27zw1aG8VUY3M5xL6g0nbstULPBZYwSkVFcBXupVLbBkWihFXCzNqxgQ==",
"dev": true,
"requires": {
"mime-types": "^2.1.35",
"node-fetch": "^3.2.3"
},
"dependencies": {
"node-fetch": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.2.6.tgz",
"integrity": "sha512-LAy/HZnLADOVkVPubaxHDft29booGglPFDr2Hw0J1AercRh01UiVFm++KMDnJeH9sHgNB4hsXPii7Sgym/sTbw==",
"dev": true,
"requires": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
}
}
}
},
"@fortawesome/fontawesome-common-types": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.1.1.tgz",
@@ -29231,6 +29215,12 @@
"tiny-glob": "^0.2.9"
}
},
"@sveltejs/adapter-static": {
"version": "1.0.0-next.43",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-1.0.0-next.43.tgz",
"integrity": "sha512-PAgSp1GA8HkYE4p30/sBvJme2nefhcTBJafqQdMNoUksWZF2WzuL8OEO8wa9ndE6cghcGk3j6Ve0Oskg/wtTOw==",
"dev": true
},
"@sveltejs/adapter-vercel": {
"version": "1.0.0-next.58",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-vercel/-/adapter-vercel-1.0.0-next.58.tgz",
@@ -31318,6 +31308,67 @@
}
}
},
"d3-array": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.0.tgz",
"integrity": "sha512-3yXFQo0oG3QCxbF06rMPFyGRMGJNS7NvsV1+2joOjbBE+9xvWQ8+GcMJAjRCzw06zQ3/arXeJgbPYcjUCuC+3g==",
"dev": true,
"requires": {
"internmap": "1 - 2"
}
},
"d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"dev": true
},
"d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"dev": true
},
"d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dev": true,
"requires": {
"d3-color": "1 - 3"
}
},
"d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"dev": true,
"requires": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
}
},
"d3-time": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.0.0.tgz",
"integrity": "sha512-zmV3lRnlaLI08y9IMRXSDshQb5Nj77smnfpnd2LrBa/2K281Jijactokeak14QacHs/kKq0AQ121nidNYlarbQ==",
"dev": true,
"requires": {
"d3-array": "2 - 3"
}
},
"d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"dev": true,
"requires": {
"d3-time": "1 - 3"
}
},
"dashdash": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
@@ -31327,12 +31378,6 @@
"assert-plus": "^1.0.0"
}
},
"data-uri-to-buffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz",
"integrity": "sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA==",
"dev": true
},
"dayjs": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.3.tgz",
@@ -32591,16 +32636,6 @@
"pend": "~1.2.0"
}
},
"fetch-blob": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.1.5.tgz",
"integrity": "sha512-N64ZpKqoLejlrwkIAnb9iLSA3Vx/kjgzpcDhygcqJ2KKjky8nCgUQ+dzXtbrLaWZGZNmNfQTsiQ0weZ1svglHg==",
"dev": true,
"requires": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
}
},
"figures": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
@@ -32789,15 +32824,6 @@
"mime-types": "^2.1.12"
}
},
"formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"dev": true,
"requires": {
"fetch-blob": "^3.1.2"
}
},
"formidable": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-2.0.1.tgz",
@@ -33660,6 +33686,12 @@
"he": "0.5.0"
}
},
"internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"dev": true
},
"invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@@ -34486,6 +34518,15 @@
"integrity": "sha512-8QADVssbrFjivHWQU7KkMgptGTl6WAcSdlbBPY4uNF+mWr6DGcKrvY2w4FQJoXch7+fKMjj0dRrL75vk3k23OA==",
"dev": true
},
"layercake": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/layercake/-/layercake-7.1.0.tgz",
"integrity": "sha512-DV6FsmG0c5a2UyB15QBKBhgxhUW7E1615UTiTQGs2cnfSHPdgo8PfhAdX/E6AZwYhN9oLECpINBhEg3WNlIKjw==",
"dev": true,
"requires": {
"d3-scale": "^4.0.2"
}
},
"lazystream": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
@@ -35822,12 +35863,6 @@
"minimatch": "^3.0.2"
}
},
"node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"dev": true
},
"node-fetch": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
@@ -39702,12 +39737,6 @@
"defaults": "^1.0.3"
}
},
"web-streams-polyfill": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz",
"integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==",
"dev": true
},
"webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+2
View File
@@ -17,6 +17,7 @@
"@nikso/adapter-serverless": "^0.1.0",
"@sveltejs/adapter-auto": "next",
"@sveltejs/adapter-node": "^1.0.0-next.78",
"@sveltejs/adapter-static": "^1.0.0-next.43",
"@sveltejs/kit": "next",
"@types/cookie": "^0.5.1",
"@types/marked": "^4.0.3",
@@ -25,6 +26,7 @@
"cookie": "^0.5.0",
"fa-svelte": "^3.1.0",
"insane": "^2.6.2",
"layercake": "^7.1.0",
"marked": "^4.0.17",
"node-sass": "^7.0.1",
"sass": "^1.52.3",
+1 -4
View File
@@ -10,14 +10,11 @@ declare namespace App {
}
// interface Platform {}
interface Session {
user?: import("$lib/models/user").default
idToken?: string
req?: any
groupOptions?: import("$lib/models/items").GroupItemsOptions
amplifyConfig: any
}
interface Stuff {
idToken?: string,
title?: string,
scope?: import("$lib/models/scope").default
}
+2 -66
View File
@@ -1,67 +1,3 @@
import cookie from 'cookie';
import { Amplify, withSSRContext } from 'aws-amplify';
import type { GetSession, Handle } from '@sveltejs/kit';
Amplify.configure({
Auth: {
region: process.env.STUFFLOG3_AWS_POOL_REGION,
userPoolId: process.env.STUFFLOG3_AWS_POOL_ID,
userPoolWebClientId: process.env.STUFFLOG3_AWS_POOL_PUBLIC_CLIENT_ID,
},
ssr: true,
});
export const getSession: GetSession = ({ locals, request }) => {
return {
idToken: locals.idToken,
user: locals.user,
req: { headers: { cookie: request.headers.get("Cookie") } },
amplifyConfig: {
region: process.env.STUFFLOG3_AWS_POOL_REGION,
userPoolId: process.env.STUFFLOG3_AWS_POOL_ID,
userPoolWebClientId: process.env.STUFFLOG3_AWS_POOL_PUBLIC_CLIENT_ID,
},
};
};
export const handle: Handle = async({ event, resolve }) => {
const {Auth} = withSSRContext({
req: { headers: { cookie: event.request.headers.get("Cookie") } }
});
const newCookies = {};
try {
const curr = await Auth.currentAuthenticatedUser();
event.locals.idToken = curr.signInUserSession?.idToken?.jwtToken;
if (event.locals.idToken != null) {
event.locals.user = {
id: curr.signInUserSession.idToken.payload["sub"],
name: curr.signInUserSession.idToken.payload["cognito:username"],
}
}
const store = curr?.storage?.store || {};
const cookies = cookie.parse(event.request.headers.get("Cookie"));
for (const key in store) {
if (store[key] != cookies[key]) {
newCookies[key] = store[key]
}
}
} catch(err) {
console.warn(err)
}
const response = await resolve(event);
for (const key in newCookies) {
response.headers.append("Set-Cookie", cookie.serialize(key, newCookies[key], {
path: "/",
maxAge: 8640000,
expires: new Date(Date.now() + 8640000000),
}));
}
return response;
export async function handle({ event, resolve }) {
return resolve(event, { ssr: false });
}

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

+16 -5
View File
@@ -2,7 +2,7 @@ import type { TimeInterval } from "$lib/models/common";
import type Item from "$lib/models/item";
import type { GroupItemsOptions, ItemFilter, ItemGroup, ItemInput } from "$lib/models/item";
import type Project from "$lib/models/project";
import type { ProjectEntry, ProjectInput, Requirement, RequirementInput } from "$lib/models/project";
import type { ProjectEntry, ProjectInput, Requirement, RequirementInput, RequirementWithoutItems } from "$lib/models/project";
import type { ScopeInput } from "$lib/models/scope";
import type Scope from "$lib/models/scope";
import type { SprintInput, SprintInputPart } from "$lib/models/sprint";
@@ -11,6 +11,8 @@ import type Stat from "$lib/models/stat";
import type { StatInput } from "$lib/models/stat";
import type User from "$lib/models/user";
import type { AuthResult } from "$lib/models/user";
import { Auth } from "aws-amplify";
import { getContext, setContext } from "svelte";
type FetchImpl = (info: RequestInfo, init?: RequestInit) => Promise<Response>;
@@ -25,7 +27,7 @@ export default class SL3APIClient {
if (typeof(root) === "string") {
this.root = root || "";
} else if (root === true) {
this.root = process.env.STUFFLOG3_API;
this.root = import.meta.env.VITE_STUFFLOG3_API;
} else {
this.root = ""
}
@@ -53,6 +55,12 @@ export default class SL3APIClient {
return this.fetch<{projects: ProjectEntry[]}>("GET", `scopes/${scopeId}/projects`).then(r => r.projects);
}
async listRequirements(scopeId: number | null): Promise<RequirementWithoutItems[]> {
const scopePrefix = scopeId !== null ? `scopes/${scopeId}` : `all-scopes`;
return this.fetch<{requirements: RequirementWithoutItems[]}>("GET", `${scopePrefix}/requirements`).then(r => r.requirements);
}
async createRequirement(scopeId: number, projectId: number, input: RequirementInput): Promise<Requirement> {
return this.fetch<{requirement: Requirement}>("POST", `scopes/${scopeId}/projects/${projectId}/requirements`, input).then(r => r.requirement);
}
@@ -158,9 +166,12 @@ export default class SL3APIClient {
bodyData = JSON.stringify(body);
}
if (this.tokenPromise != null) {
headers["Authorization"] = `Bearer ${await this.tokenPromise}`;
}
try {
const sess = await Auth.currentSession();
const idToken = sess?.getIdToken()?.getJwtToken();
headers["Authorization"] = `Bearer ${idToken}`;
} catch(_) {}
const res = await this.fetcher(`${this.root}/api/v1/${path}`, {
method, headers,
@@ -0,0 +1,131 @@
<script lang="ts" context="module">
const MILESTONE_COLORS = [
"#f4b083",
"#d8dce4",
"#ffd966",
"#8ef88e",
"#84f5ff",
];
</script>
<script lang="ts">
import { LayerCake, Svg } from 'layercake';
import Line from './layercake/Line.svelte';
import AxisY from './layercake/AxisY.svelte';
import type { SprintBurndownDataPoint } from '$lib/models/sprint';
import { getTimeContext } from '../contexts/TimeContext.svelte';
import AxisX from './layercake/AxisX.svelte';
const {now} = getTimeContext();
export let data: SprintBurndownDataPoint[]
export let from: Date | string | number
export let to: Date | string | number
export let milestone: number = 10000;
export let green: boolean = false;
let after = false;
let today = 0;
let lineColor = "#777777";
let points: {x: number, y: number}[] = [];
let milestones: number[] = [];
let milestoneIndex = 0;
let fromTime: number
let toTime: number
let timeSplits: number[] = [];
$: fromTime = Math.floor(new Date(from).getTime() / 86400000) * 86400000;
$: toTime = Math.ceil(new Date(to).getTime() / 86400000) * 86400000;
$: after = new Date(to).getTime() < $now.getTime();
$: today = Math.floor($now.getTime() / 86400000) * 86400000;
$: {
const dataMap = {}
dataMap[fromTime] = 0;
if (after) {
dataMap[toTime] = data?.[data?.length - 1]?.value || 0;
} else {
dataMap[today] = data?.[data?.length - 1]?.value || 0;
}
timeSplits = [];
if (toTime - fromTime > (86400000 * 350)) {
let current = new Date(fromTime);
current.setUTCDate(1);
while (current.getTime() < toTime) {
timeSplits.push(current.getTime());
current.setUTCMonth(current.getUTCMonth() + 1);
}
} else if (toTime - fromTime > (86400000 * 7)) {
for (let x = fromTime; x < toTime; x += 86400000 * 7) {
timeSplits.push(x)
}
} else {
for (let x = fromTime; x < toTime; x += 86400000) {
timeSplits.push(x)
}
}
let last = 0;
for (const {date, value} of data) {
const currentX = new Date(date+"T00:00:00Z").getTime();
const prevX = currentX - 86400000;
if (dataMap[prevX] == null && prevX > fromTime) {
dataMap[prevX] = last;
}
dataMap[currentX] = value;
last = value;
}
milestones = [];
milestoneIndex = 0;
for (let n = milestone; n < last; n += milestone) {
milestones.push(n);
milestoneIndex += 1;
if (milestoneIndex == MILESTONE_COLORS.length - 1) {
break
}
}
if (green) {
lineColor = "#78ff78";
} else {
lineColor = MILESTONE_COLORS[milestoneIndex];
}
points = Object.keys(dataMap).map(k => ({x: parseInt(k), y: dataMap[k]})).sort((a,b) => a.x - b.x);
};
</script>
<div class="chart-container">
<LayerCake
padding={{ top: 0, right: 5, bottom: 0, left: 5 }}
x="x"
y="y"
yNice={8}
xDomain={[fromTime, toTime + (3600000*3)]}
yDomain={[0, milestones.length == 0 ? milestone : null]}
data={points}
>
<Svg>
<AxisX
ticks={timeSplits}
formatTick={_t => ""}
/>
<AxisY
ticks={milestones}
formatTick={_tick => ""}
gridlines={true}
/>
<Line stroke={lineColor} />
</Svg>
</LayerCake>
</div>
<style lang="sass">
.chart-container
width: 100%
height: 30px
</style>
@@ -8,6 +8,7 @@ import { significantDecimals } from "$lib/utils/numbers";
export let percentageOnly = false;
export let weighted = false;
export let compact = false;
export let planned: number = null;
export let fullwidth = false;
export let name: string;
@@ -27,9 +28,12 @@ import { significantDecimals } from "$lib/utils/numbers";
boat = ($now.getTime() - from.getTime()) / (to.getTime() - from.getTime());
boatEnabled = (boat > 0 && boat < 1)
} else if (planned != null) {
boat = Math.min(1, Math.max(planned, count) / target);
boatEnabled = true;
} else {
boatEnabled = false;
boat = 0;
boatEnabled = false;
}
}
</script>
@@ -9,18 +9,19 @@
export let error: string | null = null;
export let closable: boolean = false;
export let disabled: boolean = false;
export let nobody: boolean = false;
const {closeModal} = getModalContext();
</script>
{#if show}
<div class="modal-background">
<div class="modal" class:wide>
<div class="header">
<div class="modal" class:wide class:nobody>
<div class="header" class:nobody>
<div class="title" class:noclose={!closable}>{verb} {noun}</div>
{#if (closable)}
<div class="x">
<div class="button" on:click={closeModal}>
<div class="button" on:click={closeModal} class:nobody>
<Icon name="times" />
</div>
</div>
@@ -29,10 +30,10 @@
{#if (error != null)}
<div class="error">{error}</div>
{/if}
<div class="body">
<div class="body" class:nobody>
<slot></slot>
</div>
<div class="button-row">
<div class="button-row" class:nobody>
<button disabled={disabled} type="submit">{verb} {noun}</button>
<button disabled={!closable} on:click|preventDefault={closeModal} >Cancel</button>
</div>
@@ -64,6 +65,10 @@
border-radius: 0.5em;
background: $color-entry1;
&.nobody {
background: none;
}
}
div.modal.wide {
max-width: 80ch;
@@ -71,7 +76,6 @@
div.header {
padding: 1em;
margin-bottom: 0;
padding-bottom: 0;
}
@@ -149,6 +153,10 @@
button:disabled {
color: $color-entry4;
}
&.nobody {
display: none;
}
}
div.modal :global(label) {
@@ -176,7 +184,7 @@
padding: 0.45em 1ch;
width: 100%;
}
div.modal :global(select option) {
div.modal :global(select option), div.modal :global(select optgroup) {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
@@ -2,7 +2,7 @@
<style lang="sass">
div.modal-body
padding: 1em
padding: 0 1em
padding-left: 0
flex: 1
@@ -69,8 +69,15 @@
nonSegmented = alwaysSmooth || (target >= 60);
if (!nonSegmented) {
ons = Math.round(ons);
offs = Math.round(offs);
ons = Math.floor(ons);
offs = Math.ceil(offs);
if (ons + offs > target) {
if (offs > 0) {
offs -= 1;
} else {
ons -= 1;
}
}
}
} else {
offClass = "none";
@@ -78,6 +85,12 @@
ons = 0;
offs = 1;
}
if (nonSegmented && (ons + offs) < 1) {
const factor = (ons + offs);
ons = (ons / factor)
offs = (offs / factor)
}
}
$: {
@@ -0,0 +1,34 @@
<script lang="ts">
export let names: string[]
</script>
<div class="tag-row">
{#each names as name (name)}
<div class="tag">{name}<span class="comma">,</span></div>
{/each}
</div>
<style lang="sass">
@import "../../css/colors"
div.tag-row
display: flex
flex-direction: row
flex-wrap: wrap
&:empty
display: none
> div.tag
margin: 0.25em 0.5ch
background-color: $color-entry2
padding: 0.25em 1ch
border-radius: 0.25em
font-size: 0.75em
> span.comma
font-size: 0
&:first-child
margin-left: 0
</style>
@@ -0,0 +1,114 @@
<!--
@component
Generates an SVG x-axis. This component is also configured to detect if your x-scale is an ordinal scale. If so, it will place the markers in the middle of the bandwidth.
-->
<script>
import { getContext } from 'svelte';
const { width, height, xScale, yRange } = getContext('LayerCake');
/** @type {Boolean} [gridlines=true] - Extend lines from the ticks into the chart space */
export let gridlines = true;
/** @type {Boolean} [tickMarks=false] - Show a vertical mark for each tick. */
export let tickMarks = false;
/** @type {Boolean} [baseline=false] – Show a solid line at the bottom. */
export let baseline = false;
/** @type {Boolean} [snapTicks=false] - Instead of centering the text on the first and the last items, align them to the edges of the chart. */
export let snapTicks = false;
/** @type {Function} [formatTick=d => d] - A function that passes the current tick value and expects a nicely formatted value in return. */
export let formatTick = d => d;
/** @type {Number|Array|Function} [ticks] - If this is a number, it passes that along to the [d3Scale.ticks](https://github.com/d3/d3-scale) function. If this is an array, hardcodes the ticks to those values. If it's a function, passes along the default tick values and expects an array of tick values in return. If nothing, it uses the default ticks supplied by the D3 function. */
export let ticks = undefined;
/** @type {Number} [xTick=0] - TK */
export let xTick = 0;
/** @type {Number} [yTick=16] - The distance from the baseline to place each tick value. */
export let yTick = 16;
$: isBandwidth = typeof $xScale.bandwidth === 'function';
$: tickVals = Array.isArray(ticks) ? ticks :
isBandwidth ?
$xScale.domain() :
typeof ticks === 'function' ?
ticks($xScale.ticks()) :
$xScale.ticks(ticks);
function textAnchor(i) {
if (snapTicks === true) {
if (i === 0) {
return 'start';
}
if (i === tickVals.length - 1) {
return 'end';
}
}
return 'middle';
}
</script>
<g class="axis x-axis" class:snapTicks>
{#each tickVals as tick, i (tick)}
<g class="tick tick-{i}" transform="translate({$xScale(tick)},{Math.max(...$yRange)})">
{#if gridlines !== false}
<line class="gridline" y1={$height * -1} y2="0" x1="0" x2="0" />
{/if}
{#if tickMarks === true}
<line
class="tick-mark"
y1={0}
y2={6}
x1={xTick || isBandwidth ? $xScale.bandwidth() / 2 : 0}
x2={xTick || isBandwidth ? $xScale.bandwidth() / 2 : 0}
/>
{/if}
<text
x={xTick || isBandwidth ? $xScale.bandwidth() / 2 : 0}
y={8}
dx=""
dy=""
font-size="0.66em"
text-anchor={textAnchor(i)}>{formatTick(tick)}</text
>
</g>
{/each}
{#if baseline === true}
<line class="baseline" y1={$height + 0.5} y2={$height + 0.5} x1="0" x2={$width} />
{/if}
</g>
<style lang="scss">
@import "../../../css/colors.sass";
.tick {
font-size: 0.725em;
font-weight: 200;
}
line,
.tick line {
stroke: $color-entry2;
stroke-dasharray: 1;
}
.tick text {
fill: $color-entry5;
}
.tick .tick-mark,
.baseline {
stroke-dasharray: 0;
}
/* This looks slightly better */
.axis.snapTicks .tick:last-child text {
transform: translateX(3px);
}
.axis.snapTicks .tick.tick-0 text {
transform: translateX(-3px);
}
</style>
@@ -0,0 +1,101 @@
<!--
@component
Generates an HTML y-axis.
-->
<script>
import { getContext } from 'svelte';
const { padding, xRange, yScale } = getContext('LayerCake');
/** @type {Boolean} [gridlines=true] - Extend lines from the ticks into the chart space */
export let gridlines = true;
/** @type {Boolean} [tickMarks=false] - Show a vertical mark for each tick. */
export let tickMarks = false;
/** @type {Function} [formatTick=d => d] - A function that passes the current tick value and expects a nicely formatted value in return. */
export let formatTick = d => d;
/** @type {Number|Array|Function} [ticks=4] - If this is a number, it passes that along to the [d3Scale.ticks](https://github.com/d3/d3-scale) function. If this is an array, hardcodes the ticks to those values. If it's a function, passes along the default tick values and expects an array of tick values in return. */
export let ticks = 4;
/** @type {Number} [xTick=0] - How far over to position the text marker. */
export let xTick = 0;
/** @type {Number} [yTick=0] - How far up and down to position the text marker. */
export let yTick = 0;
/** @type {Number} [dxTick=0] - Any optional value passed to the `dx` attribute on the text marker and tick mark (if visible). This is ignored on the text marker if your scale is ordinal. */
export let dxTick = 0;
/** @type {Number} [dyTick=-4] - Any optional value passed to the `dy` attribute on the text marker and tick mark (if visible). This is ignored on the text marker if your scale is ordinal. */
export let dyTick = -4;
/** @type {String} [textAnchor='start'] The CSS `text-anchor` passed to the label. This is automatically set to "end" if the scale has a bandwidth method, like in ordinal scales. */
export let textAnchor = 'start';
$: isBandwidth = typeof $yScale.bandwidth === 'function';
$: tickVals = Array.isArray(ticks) ? ticks :
isBandwidth ?
$yScale.domain() :
typeof ticks === 'function' ?
ticks($yScale.ticks()) :
$yScale.ticks(ticks);
</script>
<g class='axis y-axis' transform='translate({-$padding.left}, 0)'>
{#each tickVals as tick (tick)}
<g class='tick tick-{tick}' transform='translate({$xRange[0] + (isBandwidth ? $padding.left : 0)}, {$yScale(tick)})'>
{#if gridlines !== false}
<line
class="gridline"
x1={5}
x2='98.%'
y1={yTick + (isBandwidth ? ($yScale.bandwidth() / 2) : 0)}
y2={yTick + (isBandwidth ? ($yScale.bandwidth() / 2) : 0)}
></line>
{/if}
{#if tickMarks === true}
<line
class='tick-mark'
x1='0'
x2='{isBandwidth ? -6 : 6}'
y1={yTick + (isBandwidth ? ($yScale.bandwidth() / 2) : 0)}
y2={yTick + (isBandwidth ? ($yScale.bandwidth() / 2) : 0)}
></line>
{/if}
<text
x='{xTick}'
y='{yTick + (isBandwidth ? $yScale.bandwidth() / 2 : 0)}'
dx='{isBandwidth ? -9 : dxTick}'
dy='{isBandwidth ? 4 : dyTick}'
style="text-anchor:{isBandwidth ? 'end' : textAnchor};"
>{formatTick(tick)}</text>
</g>
{/each}
</g>
<style lang="scss">
@import "../../../css/colors.sass";
.tick {
font-size: .725em;
font-weight: 200;
}
.tick line {
stroke: $color-entry2;
}
.tick .gridline {
stroke-dasharray: 2;
}
.tick text {
fill: #666;
}
.tick.tick-0 line {
stroke-dasharray: 0;
}
</style>
@@ -0,0 +1,27 @@
<!--
@component
Generates an SVG area shape using the `area` function from [d3-shape](https://github.com/d3/d3-shape).
-->
<script>
import { getContext } from 'svelte';
const { data, xGet, yGet } = getContext('LayerCake');
/** @type {String} [stroke='#ab00d6'] - The shape's fill color. This is technically optional because it comes with a default value but you'll likely want to replace it with your own color. */
export let stroke = '#ab00d6';
$: path = 'M' + $data
.map(d => $xGet(d) + ',' + $yGet(d))
.join('L');
</script>
<path class='path-line' d='{path}' {stroke}></path>
<style>
.path-line {
fill: none;
stroke-linejoin: round;
stroke-linecap: round;
stroke-width: 1;
}
</style>
@@ -25,12 +25,14 @@
import { getScopeContext } from "./ScopeContext.svelte";
import type Item from "$lib/models/item";
import type { ItemFilter, ItemGroup } from "$lib/models/item";
import { getStores } from "$app/stores";
export let items: Item[];
export let groups: ItemGroup[];
export let filter: ItemFilter;
const {scope} = getScopeContext();
const {page} = getStores();
let itemsWritable = writable<Item[]>(items);
let groupsWritable = writable<ItemGroup[]>(groups);
@@ -24,8 +24,10 @@
import { getScopeContext } from "./ScopeContext.svelte";
import deepEqual from "$lib/utils/object";
import { sl3 } from "$lib/clients/sl3";
import { getStores } from "$app/stores";
const {scope} = getScopeContext();
const {page} = getStores();
export let lists: Record<string, Item[]>
export let filters: Record<string, ItemFilter>
@@ -3,16 +3,16 @@
export type ModalData =
| { name: "closed" }
| { name: "scope.create" }
| { name: "scope.edit", scope: Scope }
| { name: "scope.delete", scope: Scope }
| { name: "item.create", requirement?: Requirement }
| { name: "scope.create" }
| { name: "scope.edit", scope: Scope }
| { name: "scope.delete", scope: Scope }
| { name: "item.create", requirement?: RequirementWithoutItems }
| { name: "item.edit", item: Item }
| { name: "item.acquire", item: Item }
| { name: "item.delete", item: Item }
| { name: "requirement.create", project: ProjectEntry }
| { name: "requirement.edit", requirement: Requirement }
| { name: "requirement.delete", requirement: Requirement }
| { name: "requirement.edit", requirement: RequirementWithoutItems }
| { name: "requirement.delete", requirement: RequirementWithoutItems }
| { name: "project.create" }
| { name: "project.edit", project: Project }
| { name: "project.delete", project: Project }
@@ -22,6 +22,8 @@
| { name: "sprint.create" }
| { name: "sprint.edit", sprint: Sprint }
| { name: "sprint.delete", sprint: Sprint }
| { name: "requirement.jump" }
| { name: "" }
interface ModalContextData {
currentModal: Readable<ModalData>
@@ -37,12 +39,12 @@
<script lang="ts">
import { writable, type Readable } from "svelte/store";
import { getContext, onMount, setContext } from "svelte";
import type { ProjectEntry, Requirement } from "$lib/models/project";
import type { ProjectEntry, Requirement, RequirementWithoutItems } from "$lib/models/project";
import type Item from "$lib/models/item";
import type Project from "$lib/models/project";
import type Stat from "$lib/models/stat";
import type Sprint from "$lib/models/sprint";
import type Scope from "$lib/models/scope";
import type Scope from "$lib/models/scope";
let store = writable<ModalData>({name: "closed"});
@@ -44,12 +44,15 @@
try {
const newProject = await sl3(fetch).findProject($scope.id, project.id)
projectWritable.set(newProject);
} catch(_) {}
} catch(e) {
console.warn("Failed to reload project:", e);
}
loading = false;
}
$: if (project !== lastSet) {
lastSet = project;
projectWritable.set(project);
}
</script>
@@ -22,10 +22,12 @@
import { sl3 } from "$lib/clients/sl3";
import { getScopeContext } from "./ScopeContext.svelte";
import type { ProjectEntry } from "$lib/models/project";
import { getStores } from "$app/stores";
export let projects: ProjectEntry[];
const {scope} = getScopeContext();
const {page} = getStores();
let projectWritable = writable<ProjectEntry[]>(projects);
let loading = false;
@@ -0,0 +1,52 @@
<script lang="ts" context="module">
const contextKey = {ctx: "requirementListCtx"};
interface RequirementListContextData {
requirements: Readable<RequirementWithoutItems[]>,
reloadRequirementList(): Promise<void>,
};
const fallback: RequirementListContextData = {
requirements: readable([]),
reloadRequirementList: () => Promise.resolve()
};
export function getRequirementListContext() {
return getContext(contextKey) as RequirementListContextData || fallback
}
</script>
<script lang="ts">
import { readable, writable, type Readable } from "svelte/store";
import { getContext, onMount, setContext } from "svelte";
import { sl3 } from "$lib/clients/sl3";
import { getScopeContext } from "./ScopeContext.svelte";
import type { RequirementWithoutItems } from "$lib/models/project";
const {scope} = getScopeContext();
let requirementsWritable = writable<RequirementWithoutItems[]>([]);
let loading = false;
setContext<RequirementListContextData>(contextKey, {
requirements: {subscribe: requirementsWritable.subscribe},
reloadRequirementList,
});
async function reloadRequirementList() {
if (loading) {
return
}
loading = true;
try {
const newProjects = await sl3(fetch).listRequirements($scope?.id || null)
requirementsWritable.set(newProjects);
} catch(_) {}
loading = false;
}
</script>
<slot></slot>
@@ -20,11 +20,13 @@
import { readable, writable, type Readable } from "svelte/store";
import { getContext, setContext } from "svelte";
import { sl3 } from "$lib/clients/sl3";
import { getScopeContext } from "./ScopeContext.svelte";
import type Scope from "$lib/models/scope";
import { getStores } from "$app/stores";
export let scopes: Scope[];
const {page} = getStores();
let scopesWritable = writable<Scope[]>(scopes);
let loading = false;
let lastSet = scopes;
@@ -24,6 +24,7 @@
import type Sprint from "$lib/models/sprint";
import parseInterval from "$lib/utils/timeinterval";
import { getTimeContext } from "./TimeContext.svelte";
import { getStores } from "$app/stores";
export let sprints: Sprint[];
export let intervalString: string;
@@ -31,6 +32,7 @@
const {scope} = getScopeContext();
const {now} = getTimeContext();
const {page} = getStores();
let sprintsWritable = writable<Sprint[]>(sprints);
let loading = false;
@@ -0,0 +1,30 @@
<script lang="ts">
import type { ProjectEntry } from "$lib/models/project";
import { STATUS_NAMES } from "$lib/models/status";
import type Status from "$lib/models/status";
import { getProjectListContext } from "../contexts/ProjectListContext.svelte";
import { STATUS_ORDER } from "../scope/ProjectMenu.svelte";
const { projects } = getProjectListContext();
export let value: number;
export let disabled: boolean = false;
let projectsByStatus: Record<Status, ProjectEntry[]>;
$: projectsByStatus = $projects.reduce((p, c) => (
{...p, [c.status]: [...p[c.status], c]}
), {0: [], 1: [], 2: [], 3: [], 4: [], 5: [], 6: []});
</script>
<select disabled={disabled} bind:value={value}>
{#each STATUS_ORDER as status (status)}
{#if projectsByStatus[status].length > 0}
<optgroup label={STATUS_NAMES[status]}>
{#each projectsByStatus[status] as project (project.id)}
<option value={project.id}>{project.name}</option>
{/each}
</optgroup>
{/if}
{/each}
</select>
@@ -0,0 +1,78 @@
<script lang="ts">
import { endOfMonth, endOfWeek, endOfYear, formatDate, nextMonth, nextYear } from "$lib/utils/date";
export let value: string;
export let openDate: Date;
export let notScheduledText = "Not Scheduled";
let selected: "none" | "date" | "eod" | "eot" | "eow" | "eonw" | "eonnw" | "eom" | "eonm" | "eoy" | "eony" = !!value ? "date" : "none";
$: {
switch (selected) {
case "eod":
value = formatDate(openDate);
break;
case "eot":
value = formatDate(new Date(openDate.getTime() + 86400000));
break;
case "eow":
value = formatDate(endOfWeek(openDate))
break;
case "eonw":
value = formatDate(endOfWeek(new Date(openDate.getTime() + (86400000 * 7))))
break;
case "eonnw":
value = formatDate(endOfWeek(new Date(openDate.getTime() + (86400000 * 14))))
break;
case "eom":
value = formatDate(endOfMonth(openDate))
break;
case "eonm":
value = formatDate(endOfMonth(nextMonth(openDate)))
break;
case "eoy":
value = formatDate(endOfYear(openDate))
break;
case "eony":
value = formatDate(endOfYear(nextYear(openDate)))
break;
case "date":
if (value == "") {
value = formatDate(openDate);
}
break;
case "none":
value = "";
break;
}
}
</script>
<select bind:value={selected}>
<option value="none">{notScheduledText}</option>
<option value="eod">Today</option>
<option value="eot">Tomorrow</option>
<option value="eow">Sunday</option>
<option value="eonw">Next Sunday</option>
<option value="eonnw">The Sunday after Next</option>
<option value="eom">End of Month</option>
<option value="eonm">End of Next Month</option>
<option value="eoy">End of Year</option>
<option value="eony">End of Next Year</option>
<option value="date">Specific Date</option>
</select>
{#if selected !== "none"}
<input type="date" disabled={selected !== "date"} bind:value={value} />
{/if}
<style>
select {
margin-bottom: 0.25em !important;
}
input {
margin-top: 0 !important;
resize: none !important;
}
</style>
@@ -1,9 +1,9 @@
<script lang="ts">
import { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
import { getScopeListContext } from "../contexts/ScopeListContext.svelte";
import type { StatValueInput } from "$lib/models/stat";
import Checkbox from "../layout/Checkbox.svelte";
import type Scope from "$lib/models/scope";
import { getScopeContext } from "../contexts/ScopeContext.svelte";
export let value: StatValueInput[] = [];
export let showAcquired = false;
@@ -14,13 +14,14 @@
export let initOnList: number[] = [];
export let overrideScopeId: number = null;
const {scope} = getScopeContext();
const {scopes} = getScopeListContext();
const {scope} = getScopeContext();
let acquiredMap: Record<number, number>;
let requiredMap: Record<number, number>;
let enabledMap: Record<number, boolean>;
let actualScope: Scope;
let value2: any;
function initialize(actualScope: Scope) {
acquiredMap = {};
@@ -44,7 +45,7 @@
}
}
$: actualScope = overrideScopeId !== null && $scopes.length > 0 ? $scopes.find(s => s.id === overrideScopeId) : $scope;
$: actualScope = $scopes.find(s => s.id === overrideScopeId) || $scope;
$: {
if (acquiredMap == null) {
@@ -52,11 +53,15 @@
}
}
$: value = actualScope.stats.filter(s => enabledMap[s.id]).map(s => ({
statId: s.id,
required: requiredMap[s.id],
acquired: acquiredMap[s.id],
}));
$: {
value = actualScope.stats.filter(s => enabledMap[s.id]).map(s => ({
statId: s.id,
required: requiredMap[s.id],
acquired: acquiredMap[s.id],
}));
value2 = value; // hax
}
</script>
<div class="stat-input">
@@ -9,8 +9,6 @@
import Icon, { type IconName } from "../layout/Icon.svelte";
export let status: Status;
console.log(STATUS_ENTRIES, status)
</script>
<div class="status-select">
@@ -0,0 +1,91 @@
<script lang="ts">
import Icon from "../layout/Icon.svelte";
export let value: string[];
export let exclaimMode = false;
let nextTag = "";
function onKey(ev: KeyboardEvent) {
if ((ev.metaKey||ev.ctrlKey) && nextTag === "" && ev.key === "Backspace") {
value = value.slice(0, -1);
}
if ((ev.metaKey||ev.ctrlKey) && nextTag !== "" && ev.key === "Enter") {
value = [...value, nextTag];
nextTag = "";
ev.preventDefault();
}
}
$: while (nextTag.includes(",")) {
const newTags = nextTag.split(",").map(t => t.trim());
value = [...value, ...newTags.slice(0, -1)];
nextTag = newTags[newTags.length - 1];
}
$: value = (value||[]).filter((e, i) => !value.slice(0, i).includes(e));
$: if (exclaimMode) {
value = (value||[]).filter((e, i) => (
e.startsWith("!")
? !value.slice(i+1).includes(e.slice(1))
: !value.slice(i+1).includes("!"+e)
));
}
</script>
<div class="tag-input">
{#each value as tag (tag)}
<div class="tag">
<span>{tag}</span>
<span class="comma">,</span>
<span class="x" on:click={() => value = value.filter(v => v !== tag)}>
<Icon name="times" />
</span>
</div>
{/each}
<input placeholder="Add tag (type , or press Ctrl+Enter to add)" on:keyup={onKey} bind:value={nextTag} />
</div>
<style lang="sass">
@import "../../css/colors"
div.tag-input
width: calc(100% - 2ch)
margin-bottom: 1em
margin-top: 0.20em
min-height: 2em
background: $color-entryhalf
color: $color-entry8
border: none
outline: none
resize: vertical
padding: 0.5em 1ch
display: flex
flex-direction: row
flex-wrap: wrap
> div.tag
margin: 0.25em 0.5ch
background-color: $color-entry1
padding: 0.25em 1ch
border-radius: 0.25em
span.x
font-size: 0.75em
line-height: 1em
user-select: none
cursor: pointer
&:hover
color: $color-entry12
span.comma
font-size: 0
> input
margin-bottom: 0.125em
</style>
@@ -15,7 +15,7 @@
</script>
{#each $groups as group (group.label)}
{#if (group.label >= interval.min && group.label <= interval.max)}
{#if (group.label >= interval.min)}
<HistoryGroupSection group={group} items={$items} />
{/if}
{/each}
+25 -15
View File
@@ -3,31 +3,41 @@
</script>
<div class="row">
<div class="header-row">
{#if title != ""}
<h2>{title}</h2>
{/if}
<slot name="right"></slot>
<div class="header-col">
<slot name="over"></slot>
<div class="header-row">
{#if title != ""}
<h2>{title}</h2>
{/if}
<slot name="right"></slot>
</div>
<slot name="under"></slot>
</div>
<slot></slot>
</div>
<style lang="sass">
@import "../../css/colors.sass"
div.row
margin: 0.5em 1ch
padding: 0.5em 1ch
div.header-row
display: flex
flex-direction: row
div.header-col
border-bottom: 1px solid $color-entry2-transparent
padding-bottom: 0.1em
&:empty
display: none
div.header-row
display: flex
flex-direction: row
&:empty
display: none
> h2
font-size: 1.25em
margin: 0
margin-bottom: 0.25em
margin-right: auto
> h2
font-size: 1.25em
margin: 0
margin-bottom: 0.05em
margin-right: auto
</style>
@@ -13,6 +13,7 @@
export let icon: IconName = null;
export let status: Status = null;
export let event: string = "";
export let href: string | null = null;
let blankEvent: boolean
@@ -29,7 +30,13 @@
</StatusColor>
{/if}
</div>
<Title value={title} big={big} small={small} sub={subtitle} />
{#if href != null}
<a href={href}>
<Title value={title} big={big} small={small} sub={subtitle} />
</a>
{:else}
<Title value={title} big={big} small={small} sub={subtitle} />
{/if}
<div class="right" class:noProgress>
<slot name="right"></slot>
</div>
@@ -51,6 +58,13 @@
:global(div.sub-section)
background: $color-entry2-transparent
a
text-decoration: none
opacity: 0.9
&:hover
opacity: 1
div.entry-icon
opacity: 0.6
margin-right: 1.5ch
@@ -37,6 +37,7 @@
h2
font-size: 1.25em
line-height: 1.15em
margin: 0
color: $color-entry10
@@ -1,3 +1,7 @@
<script lang="ts" context="module">
export type ItemSubSectionEvent = "created" | "scheduled" | "acquired" | "none"
</script>
<script lang="ts">
import Markdown from "$lib/components/common/Markdown.svelte";
import SubSection from "$lib/components/layout/SubSection.svelte";
@@ -9,12 +13,16 @@
import AmountRow from "../common/AmountRow.svelte";
import type { IconName } from "../layout/Icon.svelte";
import Icon from "../layout/Icon.svelte";
import { formatTime } from "$lib/utils/date";
import { formatDays, formatPrettyTime, formatTime } from "$lib/utils/date";
import RequirementReference from "./RequirementReference.svelte";
import ItemTimeInfo from "./ItemTimeInfo.svelte";
import ItemTimeInfo from "./ItemTimeInfo.svelte";
import { getTimeContext } from "../contexts/TimeContext.svelte";
import TagRow from "../common/TagRow.svelte";
const {now} = getTimeContext();
export let item: Item;
export let event: "created" | "scheduled" | "acquired" | "none" = "none";
export let event: ItemSubSectionEvent = "none";
export let compact = false;
export let showAcquiredTime = false;
export let hideStats = false;
@@ -33,10 +41,10 @@ import ItemTimeInfo from "./ItemTimeInfo.svelte";
eventText = (showAcquiredTime || event === "acquired") ? formatTime(item.acquiredTime) : void(0)
}
} else if (event === "scheduled") {
if (item.acquiredTime) {
icon = "hourglass";
if (!item.acquiredTime) {
icon = "calendar";
status = Status.Blocked;
eventText = "scheduled"
eventText = formatDays(new Date(item.scheduledDate), $now)
}
} else if (event === "created") {
icon = "lightbulb";
@@ -61,6 +69,7 @@ import ItemTimeInfo from "./ItemTimeInfo.svelte";
{:else}
<ItemTimeInfo item={item} />
{/if}
<TagRow names={item.tags} />
{#if !compact}
<Markdown source={item.description} />
{/if}
@@ -14,27 +14,32 @@
$: {
const ct = new Date(item.createdTime);
let t = ct;
let noTime = false;
if (item.acquiredTime != null) {
const at = new Date(item.acquiredTime);
t = at;
if (Math.abs(at.getTime() - ct.getTime()) < 300000) {
action = "Created/Acquired"
action = "Created/Acquired";
} else {
action = "Acquired"
action = "Acquired";
}
colorClass = "acquired"
colorClass = "acquired";
} else if (item.scheduledDate != null) {
t = new Date(item.scheduledDate);
action = "Scheduled for"
colorClass = "scheduled"
action = "Scheduled for";
colorClass = "scheduled";
noTime = true;
} else {
action = "Created"
colorClass = "created"
action = "Created";
colorClass = "created";
}
timeStr = formatPrettyTime(t, $now);
if (noTime) {
timeStr = timeStr.split(" at ")[0];
}
}
</script>
@@ -4,6 +4,7 @@
import Main from "$lib/components/layout/Main.svelte";
import Option from "$lib/components/layout/Option.svelte";
import OptionsRow from "$lib/components/layout/OptionsRow.svelte";
import TagRow from "../common/TagRow.svelte";
import { getProjectContext } from "../contexts/ProjectContext.svelte";
import Icon from "../layout/Icon.svelte";
import RequirementEntry from "./RequirementSection.svelte";
@@ -14,6 +15,7 @@
<Main big title={$project.name}>
<Progress alwaysSmooth titlePercentageOnly thin green status={$project.status} count={$project.totalAcquired} target={$project.totalRequired} />
<Progress alwaysSmooth titlePercentageOnly thinner gray count={$project.totalPlanned} target={$project.totalRequired} />
<TagRow names={$project.tags} />
<Markdown source={$project.description} />
<OptionsRow slot="right">
<Option open={{name: "requirement.create", project: $project}}><Icon name="plus" /></Option>
@@ -13,6 +13,7 @@
export let project: IDAndName
export let requirement: IDAndName
export let scopeId: number
export let projectOnly: boolean = false;
const {scope} = getScopeContext();
const {scopes} = getScopeListContext();
@@ -33,8 +34,10 @@
<div class="reference">
<a href={projectLink}>{project.name}</a>
<span>&gt;</span>
<a href={requirementLink}>{requirement.name}</a>
{#if !projectOnly}
<span>&gt;</span>
<a href={requirementLink}>{requirement.name}</a>
{/if}
</div>
<style lang="sass">
@@ -11,8 +11,8 @@
import ItemEntry from "./ItemSubSection.svelte";
import Icon from "../layout/Icon.svelte";
import { projectPrettyId } from "$lib/utils/prettyIds";
import AmountRow from "../common/AmountRow.svelte";
import AggregateAmountRow from "./AggregateAmountRow.svelte";
import AggregateAmountRow from "./AggregateAmountRow.svelte";
import TagRow from "../common/TagRow.svelte";
export let requirement: Requirement;
</script>
@@ -21,8 +21,10 @@ import AggregateAmountRow from "./AggregateAmountRow.svelte";
<Section title={requirement.name} icon={STATUS_ICONS[requirement.status]} status={requirement.status}>
<Progress alwaysSmooth titlePercentageOnly thin green status={requirement.status} count={requirement.totalAcquired} target={requirement.totalRequired} />
<Progress alwaysSmooth titlePercentageOnly thinner gray count={requirement.totalPlanned} target={requirement.totalRequired} />
<TagRow names={requirement.tags} />
<Markdown source={requirement.description} />
<OptionsRow slot="right">
<Option open={{name: "item.multischedule", items: requirement.items}}><Icon name="calendar" /></Option>
<Option open={{name: "item.create", requirement}}><Icon name="plus" /></Option>
<Option open={{name: "requirement.edit", requirement}}><Icon name="pen" /></Option>
<Option open={{name: "requirement.delete", requirement}} color="red"><Icon name="trash" /></Option>
@@ -30,14 +32,14 @@ import AggregateAmountRow from "./AggregateAmountRow.svelte";
<LabeledProgressRow>
{#each requirement.stats as stat (stat.id)}
{#if !requirement.isCoarse || stat.required > 0}
<LabeledProgress count={stat.acquired} target={stat.required} name={stat.name} />
<LabeledProgress count={stat.acquired} target={stat.required} planned={stat.planned} name={stat.name} />
{/if}
{/each}
</LabeledProgressRow>
{#each requirement.items as item (item.id)}
{#each (requirement.items||[]) as item (item.id)}
<ItemEntry item={item} />
{/each}
{#if requirement.aggregateRequired > 0}
<AggregateAmountRow items={requirement.items} totalAcquired={requirement.totalAcquired} aggregateRequired={requirement.aggregateRequired} />
<AggregateAmountRow items={requirement.items||[]} totalAcquired={requirement.totalAcquired} aggregateRequired={requirement.aggregateRequired} />
{/if}
</Section>
@@ -0,0 +1,52 @@
<script lang="ts">
import Markdown from "$lib/components/common/Markdown.svelte";
import Progress from "$lib/components/common/Progress.svelte";
import Option from "$lib/components/layout/Option.svelte";
import OptionsRow from "$lib/components/layout/OptionsRow.svelte";
import type { RequirementWithoutItems } from "$lib/models/project";
import { STATUS_ICONS } from "../common/StatusIcon.svelte";
import Icon from "../layout/Icon.svelte";
import { projectPrettyId, scopePrettyId } from "$lib/utils/prettyIds";
import TagRow from "../common/TagRow.svelte";
import SubSection from "../layout/SubSection.svelte";
import RequirementReference from "./RequirementReference.svelte";
import { getScopeContext } from "../contexts/ScopeContext.svelte";
import { getScopeListContext } from "../contexts/ScopeListContext.svelte";
export let requirement: RequirementWithoutItems;
const {scope} = getScopeContext();
const {scopes} = getScopeListContext();
let tags: string[]
$: {
tags = [...requirement.tags, ...(requirement.project?.tags||[])].sort().filter((t, i, a) => a[i-1] !== t);
}
let projectLink: string
let requirementLink: string
$: {
let actualScope = $scope;
if (actualScope == null || actualScope.id !== requirement.project.scopeId) {
actualScope = $scopes.find(s => s.id === requirement.project.scopeId);
}
projectLink = `/${scopePrettyId(actualScope)}/projects/${projectPrettyId(requirement.project)}`
requirementLink = `${projectLink}#${projectPrettyId(requirement)}`
}
</script>
<div id={projectPrettyId(requirement)} />
<SubSection href={requirementLink} small title={requirement.name} icon={STATUS_ICONS[requirement.status]} status={requirement.status}>
<Progress alwaysSmooth titlePercentageOnly thin green status={requirement.status} count={requirement.totalAcquired} target={requirement.totalRequired} />
<Progress alwaysSmooth titlePercentageOnly thinner gray count={requirement.totalPlanned} target={requirement.totalRequired} />
<RequirementReference projectOnly project={requirement.project} requirement={requirement} scopeId={requirement.project.scopeId} />
<TagRow names={tags} />
<Markdown source={requirement.description} />
<OptionsRow slot="right">
<Option open={{name: "item.create", requirement}}><Icon name="plus" /></Option>
<Option open={{name: "requirement.edit", requirement}}><Icon name="pen" /></Option>
<Option open={{name: "requirement.delete", requirement}} color="red"><Icon name="trash" /></Option>
</OptionsRow>
</SubSection>
@@ -1,20 +1,100 @@
<script lang="ts">
import { addDays, endOfDay, formatWeekdayDate, formatWeekdayTitle, startOfDay } from "$lib/utils/date";
import { significantDecimals } from "$lib/utils/numbers";
import { morningOf } from "$lib/utils/timeinterval";
import { getItemMultiListContext } from "../contexts/ItemMultiListContext.svelte";
import { getTimeContext } from "../contexts/TimeContext.svelte";
import Row from "../layout/Row.svelte";
import ItemSubSection from "../project/ItemSubSection.svelte";
import ItemSubSection, { type ItemSubSectionEvent } from "../project/ItemSubSection.svelte";
export let key: string;
export let title: string;
export let showAcquiredTime: boolean = false;
export let useAcquiredTotal: boolean = false;
export let useDayFilter: number | null = null;
export let selectedDay: number = 0;
export let event: ItemSubSectionEvent = "none";
const {now} = getTimeContext();
const {lists} = getItemMultiListContext();
let dayTitle = "";
let filteredList = [];
$: {
if (useDayFilter != null) {
const maxTime = addDays(morningOf($now), -(selectedDay - 1)).toISOString();
const minTime = addDays(morningOf($now), -selectedDay).toISOString();
filteredList = $lists[key].filter(i => i.acquiredTime >= minTime && i.acquiredTime <= maxTime)
dayTitle = formatWeekdayTitle(morningOf($now), new Date(minTime))
} else {
filteredList = $lists[key] || [];
}
}
let totalStr = "";
$: {
totalStr = significantDecimals(filteredList.map(v => useAcquiredTotal ? v.weightedAcquired : v.weightedRequired).reduce((p,c) => p + c, 0));
if (Number(totalStr) < 0.01) {
totalStr = "";
}
}
</script>
{#if ($lists[key]||[]).length > 0}
<Row title={title}>
{#each $lists[key] as item (item.id)}
<ItemSubSection showAcquiredTime={showAcquiredTime} item={item} />
{#if (filteredList.length > 0 || useDayFilter != null)}
<Row title={title || dayTitle}>
<div class="total" slot="right">{totalStr}</div>
<div class="day-filters" slot="over">
{#if (useDayFilter != null)}
{#each {length: useDayFilter} as _, i}
<div on:click={() => selectedDay = i} class="option" class:selected={i === selectedDay}>
<div class="inner"></div>
</div>
{/each}
{/if}
</div>
{#each filteredList as item (item.id)}
<ItemSubSection event={event} showAcquiredTime={showAcquiredTime} item={item} />
{/each}
</Row>
{/if}
{/if}
<style lang="sass">
@import "../../css/colors.sass"
div.total
color: $color-entry5
margin-top: 0.25em
div.day-filters
display: flex
flex-direction: row
max-width: 100%
flex-wrap: wrap
div.option
padding: 0.1em 0.25em
cursor: pointer
&:first-child
margin-left: -0.25em
@media screen and (max-width: 749px)
margin-left: 0
> div.inner
width: 1.25ch
height: 1.25ch
border: 1px solid $color-entry2
@media screen and (max-width: 749px)
width: 2.5ch
height: 2.5ch
&.selected > div.inner
border-color: $color-entry7
background-color: $color-entry7
&:hover > div.inner
border-color: $color-entry9
</style>
@@ -1,7 +1,7 @@
<script lang="ts" context="module">
import Status from "$lib/models/status";
const STATUS_ORDER = [
export const STATUS_ORDER = [
Status.Active,
Status.Background,
Status.Available,
@@ -3,9 +3,11 @@
import { SprintKind } from "$lib/models/sprint";
import Amount from "../common/Amount.svelte";
import AmountRow from "../common/AmountRow.svelte";
import BurndownChart from "../common/BurndownChart.svelte";
import LabeledProgress from "../common/LabeledProgress.svelte";
import LabeledProgressRow from "../common/LabeledProgressRow.svelte";
import Markdown from "../common/Markdown.svelte";
import SprintKindSelect from "../controls/SprintKindSelect.svelte";
import ItemSubSection from "../project/ItemSubSection.svelte";
export let sprint: Sprint
@@ -18,13 +20,22 @@
} else {
timeRange = void(0);
}
</script>
let aggregateRequired = 0;
let aggregateSmooth = false;
$: if (sprint.kind === SprintKind.Stats && sprint.aggregateRequired < 1) {
aggregateSmooth = true;
aggregateRequired = sprint.progress.reduce((a, c) => a + (c.weight * c.required), 0);
} else {
aggregateSmooth = false;
aggregateRequired = sprint.aggregateRequired;
}
</script>
<Markdown source={sprint.description} />
{#if sprint.kind === SprintKind.Items}
<LabeledProgress timeRange={timeRange} green name={sprint.aggregateName || "Items"} count={sprint.itemsAcquired} target={sprint.itemsRequired} />
{:else}
<LabeledProgress timeRange={timeRange} name={sprint.aggregateName} count={sprint.aggregateAcquired} target={sprint.aggregateRequired} weighted={!sprint.isUnweighted} />
<LabeledProgress alwaysSmooth={aggregateSmooth} timeRange={timeRange} name={sprint.aggregateName} count={sprint.aggregateAcquired} target={aggregateRequired} weighted={!sprint.isUnweighted} />
{/if}
{#if !sprint.isCoarse}
<LabeledProgressRow>
@@ -33,6 +44,12 @@
{/each}
</LabeledProgressRow>
{/if}
{#if sprint.kind !== SprintKind.Items && sprint.aggregateBurndown != null && sprint.aggregateBurndown.length > 0}
<BurndownChart data={sprint.aggregateBurndown} from={sprint.fromTime} to={sprint.toTime} milestone={aggregateRequired||1} />
{/if}
{#if sprint.kind === SprintKind.Items && sprint.itemBurndown != null && sprint.itemBurndown.length > 0}
<BurndownChart green data={sprint.itemBurndown} from={sprint.fromTime} to={sprint.toTime} milestone={sprint.itemsRequired||1} />
{/if}
{#if sprint.kind === SprintKind.Items}
{#each (sprint.items||[]) as item (item.id)}
{#if !compact || !item.acquiredTime}
+6 -2
View File
@@ -7,6 +7,7 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { getStores } from "$app/stores";
import { sl3 } from "$lib/clients/sl3";
import Modal from "$lib/components/common/Modal.svelte";
@@ -18,6 +19,7 @@
import { getProjectListContext } from "$lib/components/contexts/ProjectListContext.svelte";
import { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
import { getSprintListContext } from "$lib/components/contexts/SprintListContext.svelte";
import type Item from "$lib/models/item";
import type Stat from "$lib/models/stat";
import { scopePrettyId } from "$lib/utils/prettyIds";
@@ -28,6 +30,7 @@
const {reloadSprintList} = getSprintListContext();
const {reloadItemList} = getItemListContext();
const {reloadItemLists} = getItemMultiListContext();
const {page} = getStores();
let endpoint: string;
@@ -70,11 +73,12 @@
break;
case "requirement.delete":
init(`projects/${$project.id}/requirements/${$currentModal.requirement.id}`,
$currentModal.requirement.items.map(i => ({
init(`projects/${$currentModal.requirement.project?.id || $project.id}/requirements/${$currentModal.requirement.id}`,
(($currentModal.requirement as any).items||[]).map((i: Item) => ({
op: "Detach item",
name: i.name,
})),
$currentModal.requirement.project?.scopeId || void(0)
);
noun = "Requirement";
name = $currentModal.requirement.name;
@@ -1,6 +1,7 @@
<script lang="ts">
import { sl3 } from "$lib/clients/sl3";
import { getStores } from "$app/stores";
import { sl3 } from "$lib/clients/sl3";
import Modal from "$lib/components/common/Modal.svelte";
import ModalBody from "$lib/components/common/ModalBody.svelte";
import { getItemListContext } from "$lib/components/contexts/ItemListContext.svelte";
@@ -18,6 +19,7 @@
const {reloadItemList} = getItemListContext();
const {reloadSprintList} = getSprintListContext();
const {reloadItemLists} = getItemMultiListContext();
const {page} = getStores();
let item: Partial<ItemInput>
let itemId: number
+57 -24
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { sl3 } from "$lib/clients/sl3";
import { getStores } from "$app/stores";
import { sl3 } from "$lib/clients/sl3";
import Modal from "$lib/components/common/Modal.svelte";
import ModalBody from "$lib/components/common/ModalBody.svelte";
import { getItemListContext } from "$lib/components/contexts/ItemListContext.svelte";
@@ -11,11 +12,14 @@
import { getScopeListContext } from "$lib/components/contexts/ScopeListContext.svelte";
import { getSprintListContext } from "$lib/components/contexts/SprintListContext.svelte";
import AcquiredTimeInput from "$lib/components/controls/AcquiredTimeInput.svelte";
import RequirementSelect from "$lib/components/controls/RequirementSelect.svelte";
import RequirementSelect from "$lib/components/controls/RequirementSelect.svelte";
import ScheduledDateInput from "$lib/components/controls/ScheduledDateInput.svelte";
import StatInput from "$lib/components/controls/StatInput.svelte";
import TagInput from "$lib/components/controls/TagInput.svelte";
import Checkbox from "$lib/components/layout/Checkbox.svelte";
import type Item from "$lib/models/item";
import type { ItemInput } from "$lib/models/item";
import type { Requirement } from "$lib/models/project";
import type { Requirement, RequirementWithoutItems } from "$lib/models/project";
import type Scope from "$lib/models/scope";
import { formatFormTime } from "$lib/utils/date";
import { statDiff } from "$lib/utils/stat";
@@ -36,28 +40,33 @@ import RequirementSelect from "$lib/components/controls/RequirementSelect.svelte
let requirementName: string
let op: string
let initOnList: number[] = [];
let oldTags: string[] = [];
let error: string
let loading: boolean
let show: boolean
$: switch ($currentModal.name) {
case "item.create":
initCreate($scope, $currentModal.requirement)
break;
case "item.edit": {
const reqId = $currentModal.item.requirementId;
initEdit($currentModal.item, $project.requirements.find(r => r.id === reqId), $scope)
break;
let addAnother: boolean
let nameElem: HTMLElement
$: if (!show) {
switch ($currentModal.name) {
case "item.create":
initCreate($scope, $currentModal.requirement)
break;
case "item.edit": {
const reqId = $currentModal.item.requirementId;
initEdit($currentModal.item, $project.requirements.find(r => r.id === reqId), $scope)
break;
}
}
default:
loading = false;
error = null;
show = false;
} else if (!$currentModal.name?.startsWith("item.")) {
loading = false;
error = null;
show = false;
addAnother = false;
}
function initCreate(scope: Scope, requirement?: Requirement) {
function initCreate(scope: Scope, requirement?: RequirementWithoutItems) {
let stats = requirement?.stats.map(s => ({statId: s.id, required: 0, acquired: 0}));
if (stats == null) {
stats = scope.stats.map(s => ({statId: s.id, required: 0, acquired: 0}))
@@ -72,10 +81,11 @@ import RequirementSelect from "$lib/components/controls/RequirementSelect.svelte
stats: stats,
acquiredTime: null,
scheduledDate: null,
tags: [],
}
op = "Create"
scopeId = scope.id;
scopeId = requirement?.project?.scopeId || scope?.id;
openedDate = new Date();
requirementName = requirement?.name;
show = true;
@@ -101,6 +111,7 @@ import RequirementSelect from "$lib/components/controls/RequirementSelect.svelte
stats: ctxStats.map(s => inputStats.find(s2 => s2.statId === s.statId) || s),
acquiredTime: formatFormTime(current.acquiredTime),
scheduledDate: current.scheduledDate,
tags: [...current.tags],
};
itemId = current.id;
scopeId = current.scopeId;
@@ -110,6 +121,8 @@ import RequirementSelect from "$lib/components/controls/RequirementSelect.svelte
openedDate = new Date();
requirementName = current.requirement?.name;
show = true;
oldTags = [...current.tags];
}
async function submit() {
@@ -119,7 +132,9 @@ import RequirementSelect from "$lib/components/controls/RequirementSelect.svelte
const submission: ItemInput = {
...item,
acquiredTime: item.acquiredTime ? new Date(item.acquiredTime).toISOString() : void(0),
stats: item.stats.filter(s => s.required > 0),
scheduledDate: item.scheduledDate || void(0),
clearScheduledDate: !item.scheduledDate,
stats: item.stats.filter(s => s.required > 0).map(s => ({...s})),
}
try {
@@ -145,6 +160,11 @@ import RequirementSelect from "$lib/components/controls/RequirementSelect.svelte
submission.clearAcquiredTime = true;
}
submission.addTags = submission.tags.filter(t => !oldTags.includes(t));
submission.removeTags = oldTags.filter(t => !submission.tags.includes(t));
delete submission.tags
submission.stats = statDiff(currentStats, submission.stats)
await sl3(fetch).updateItem(scopeId, itemId, submission);
break;
@@ -159,7 +179,13 @@ import RequirementSelect from "$lib/components/controls/RequirementSelect.svelte
await reloadSprintList();
reloadItemLists();
closeModal();
if (addAnother) {
item.name = "";
item.description = "";
nameElem.focus();
} else {
closeModal();
}
} catch(err) {
if (err.statusCode != null) {
error = err.statusMessage;
@@ -177,18 +203,25 @@ import RequirementSelect from "$lib/components/controls/RequirementSelect.svelte
<Modal wide closable show={show} verb={op} noun="Item" disabled={loading} error={error}>
<ModalBody>
<label for="name">Name</label>
<input name="name" type="text" bind:value={item.name} />
<input bind:this={nameElem} name="name" type="text" bind:value={item.name} />
<label for="acquiredTime">Acquired</label>
<AcquiredTimeInput openDate={openedDate} bind:value={item.acquiredTime} />
<label for="description">Description</label>
<textarea name="description" bind:value={item.description} />
<label for="scheduledTime">Scheduled</label>
<ScheduledDateInput openDate={openedDate} bind:value={item.scheduledDate} />
<label for="tags">Tags</label>
<TagInput bind:value={item.tags} />
{#if op === "Create"}
<Checkbox bind:checked={addAnother} label="Add another." />
{/if}
</ModalBody>
<ModalBody>
{#if requirementName != null}
{#if $project.id != null || requirementName != null}
<label for="req">Project Requirement</label>
{#if $project.id != null}
<RequirementSelect disabled={op === "Create"} requirements={$project.requirements} bind:value={item.requirementId} />
{:else}
{:else if requirementName != null}
<input disabled name="req" type="text" value={requirementName} />
{/if}
{/if}
@@ -0,0 +1,85 @@
<script lang="ts">
import { sl3 } from "$lib/clients/sl3";
import Modal from "$lib/components/common/Modal.svelte";
import ModalBody from "$lib/components/common/ModalBody.svelte";
import { getItemListContext } from "$lib/components/contexts/ItemListContext.svelte";
import { getItemMultiListContext } from "$lib/components/contexts/ItemMultiListContext.svelte";
import { getModalContext } from "$lib/components/contexts/ModalContext.svelte";
import { getProjectContext } from "$lib/components/contexts/ProjectContext.svelte";
import { getSprintListContext } from "$lib/components/contexts/SprintListContext.svelte";
import ScheduledDateInput from "$lib/components/controls/ScheduledDateInput.svelte";
import type Item from "$lib/models/item";
const {currentModal, closeModal} = getModalContext();
const {reloadProject} = getProjectContext();
const {reloadItemList} = getItemListContext();
const {reloadSprintList} = getSprintListContext();
const {reloadItemLists} = getItemMultiListContext();
let items: Item[]
let openedDate: Date
let scheduleDate: string;
let error: string
let loading: boolean
let show: boolean
$: switch ($currentModal.name) {
case "item.multischedule":
init($currentModal.items)
break;
default:
loading = false;
error = null;
show = false;
}
function init(selectedItems: Item[]) {
openedDate = new Date();
items = selectedItems.map(i => ({...i}));
show = true;
scheduleDate = selectedItems.find(i => i.scheduledDate)?.scheduledDate || "";
}
async function submit() {
error = null;
loading = true;
try {
for (const item of items) {
await sl3(fetch).updateItem(item.scopeId, item.id, {
scheduledDate: scheduleDate || void(0),
clearScheduledDate: scheduleDate === "",
});
}
reloadItemLists();
await reloadProject();
await reloadItemList();
await reloadSprintList();
closeModal();
} catch(err) {
if (err.statusCode != null) {
error = err.statusMessage;
} else {
error = err
}
} finally {
loading = false;
}
}
</script>
<form on:submit|preventDefault={submit}>
<Modal closable show={show} verb="Schedule" noun="Items" disabled={loading} error={error}>
<ModalBody>
<label for="stats">Date</label>
<ScheduledDateInput notScheduledText="Clear Scheduled Date" openDate={openedDate} bind:value={scheduleDate} />
<p>This changes the scheduled date on all items in the requirement, regardless of whether they already have a scheduled date.</p>
</ModalBody>
</Modal>
</form>
@@ -1,8 +1,8 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { goto } from "$app/navigation";
import { getStores } from "$app/stores";
import { sl3 } from "$lib/clients/sl3";
import Modal from "$lib/components/common/Modal.svelte";
import ModalBody from "$lib/components/common/ModalBody.svelte";
import { getModalContext } from "$lib/components/contexts/ModalContext.svelte";
@@ -10,6 +10,7 @@ import { goto } from "$app/navigation";
import { getProjectListContext } from "$lib/components/contexts/ProjectListContext.svelte";
import { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
import StatusSelect from "$lib/components/controls/StatusSelect.svelte";
import TagInput from "$lib/components/controls/TagInput.svelte";
import type Project from "$lib/models/project";
import type { ProjectEntry, ProjectInput, Requirement, RequirementInput } from "$lib/models/project";
import Status from "$lib/models/status";
@@ -19,10 +20,12 @@ import { goto } from "$app/navigation";
const {scope} = getScopeContext();
const {reloadProject} = getProjectContext();
const {reloadProjectList} = getProjectListContext();
const {page} = getStores();
let project: ProjectInput
let projectId: number
let oldStats: RequirementInput["stats"]
let oldTags: string[]
let error: string
let loading: boolean
@@ -52,6 +55,7 @@ import { goto } from "$app/navigation";
description: "",
status: Status.Available,
createdTime: void(0),
tags: [],
}
show = true;
@@ -62,8 +66,10 @@ import { goto } from "$app/navigation";
name: current.name,
description: current.description,
status: current.status,
tags: [...current.tags],
}
oldTags = current.tags;
projectId = current.id;
show = true;
}
@@ -79,7 +85,14 @@ import { goto } from "$app/navigation";
projectId = newProject.id
break;
case "Edit":
await sl3(fetch).updateProject($scope.id, projectId, project);
const submission = {
...project,
addTags: project.tags.filter(t => !oldTags.includes(t)),
removeTags: oldTags.filter(t => !project.tags.includes(t)),
tags: void(0),
}
await sl3(fetch).updateProject($scope.id, projectId, submission);
break;
}
@@ -112,6 +125,7 @@ import { goto } from "$app/navigation";
<textarea name="description" bind:value={project.description} />
<label for="stats">Status</label>
<StatusSelect bind:status={project.status} />
<TagInput bind:value={project.tags} />
</ModalBody>
</Modal>
</form>
@@ -1,17 +1,18 @@
<script lang="ts">
import { sl3 } from "$lib/clients/sl3";
import Modal from "$lib/components/common/Modal.svelte";
import ModalBody from "$lib/components/common/ModalBody.svelte";
import { getModalContext } from "$lib/components/contexts/ModalContext.svelte";
import { getProjectContext } from "$lib/components/contexts/ProjectContext.svelte";
import { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
import ProjectSelect from "$lib/components/controls/ProjectSelect.svelte";
import StatInput from "$lib/components/controls/StatInput.svelte";
import StatusSelect from "$lib/components/controls/StatusSelect.svelte";
import Checkbox from "$lib/components/layout/Checkbox.svelte";
import type { ProjectEntry, Requirement, RequirementInput } from "$lib/models/project";
import TagInput from "$lib/components/controls/TagInput.svelte";
import Checkbox from "$lib/components/layout/Checkbox.svelte";
import type { ProjectEntry, RequirementInput, RequirementWithoutItems } from "$lib/models/project";
import Status from "$lib/models/status";
import { statDiff } from "$lib/utils/stat";
import { statDiff } from "$lib/utils/stat";
const {currentModal, closeModal} = getModalContext();
const {scope} = getScopeContext();
@@ -22,6 +23,8 @@ import { statDiff } from "$lib/utils/stat";
let requirementId: number
let oldStats: RequirementInput["stats"]
let showAggregateRequired: boolean
let oldTags: string[]
let scopeId: number
let error: string
let loading: boolean
@@ -55,13 +58,15 @@ import { statDiff } from "$lib/utils/stat";
status: Status.Available,
isCoarse: false,
aggregateRequired: 0,
tags: [],
projectId: project.id,
}
projectId = project.id;
show = true;
}
function initEdit(current: Requirement, project: ProjectEntry) {
function initEdit(current: RequirementWithoutItems, project: ProjectEntry) {
requirement = {
name: current.name,
description: current.description,
@@ -69,11 +74,15 @@ import { statDiff } from "$lib/utils/stat";
stats: current.stats.map(s => ({acquired: s.acquired, required: s.required, statId: s.id})),
isCoarse: current.isCoarse,
aggregateRequired: current.aggregateRequired,
tags: [...current.tags],
projectId: project.id,
}
oldTags = [...current.tags];
oldStats = [...requirement.stats];
projectId = project.id;
projectId = current.project?.id || project.id;
requirementId = current.id;
scopeId = current.project?.scopeId || $scope?.id;
show = true;
}
@@ -93,10 +102,13 @@ import { statDiff } from "$lib/utils/stat";
case "Edit":
const submission: Partial<RequirementInput> = {
...requirement,
stats: statDiff(oldStats, requirement.stats, -1)
stats: statDiff(oldStats, requirement.stats, -1),
addTags: requirement.tags.filter(t => !oldTags.includes(t)),
removeTags: oldTags.filter(t => !requirement.tags.includes(t)),
projectId: requirement.projectId !== projectId ? requirement.projectId : void(0),
};
await sl3(fetch).updateRequirement($scope.id, projectId, requirementId, submission);
await sl3(fetch).updateRequirement(scopeId || $scope.id, projectId, requirementId, submission);
break;
}
@@ -128,12 +140,15 @@ import { statDiff } from "$lib/utils/stat";
<label for="aggregateRequired">Aggregate Required (Zero for old behavior)</label>
<input name="aggregateRequired" type="number" bind:value={requirement.aggregateRequired} />
{/if}
<TagInput bind:value={requirement.tags} />
</ModalBody>
<ModalBody>
<label for="req">Project Requirement</label>
<ProjectSelect disabled={op === "Create"} bind:value={requirement.projectId} />
<label for="stats">Status</label>
<StatusSelect bind:status={requirement.status} />
<label for="stats">Stats</label>
<StatInput showRequired bind:value={requirement.stats} />
<StatInput overrideScopeId={scopeId} showRequired bind:value={requirement.stats} />
<Checkbox bind:checked={requirement.isCoarse} label="Hide 0-requirement stats." />
</ModalBody>
</Modal>
@@ -0,0 +1,160 @@
<script lang="ts">
import Modal from "$lib/components/common/Modal.svelte";
import ModalBody from "$lib/components/common/ModalBody.svelte";
import { getModalContext } from "$lib/components/contexts/ModalContext.svelte";
import { getRequirementListContext } from "$lib/components/contexts/RequirementListContext.svelte";
import RequirementSubSection from "$lib/components/project/RequirementSubSection.svelte";
import type { RequirementWithoutItems } from "$lib/models/project";
import Status from "$lib/models/status";
import { onMount, tick } from "svelte";
const {currentModal, closeModal} = getModalContext();
const {requirements, reloadRequirementList} = getRequirementListContext();
let error: string
let loading: boolean;
let show: boolean;
let search: string = "";
let searchRaw: string = "";
let searchBar: HTMLInputElement;
let filteredRequirements: RequirementWithoutItems[] = [];
let searchTimeout: any = null;
$: if ($currentModal.name !== "closed") {
show = false;
}
$: {
clearTimeout(searchTimeout);
let newSearch = searchRaw.slice();
searchTimeout = setTimeout(() => {
search = newSearch;
}, 100);
}
$: {
filteredRequirements = $requirements.filter(r => r.status > Status.Blocked && r.status < Status.Completed);
if (search.length > 0) {
const scoreMap = new Map<number, number>();
const searchLc = search.toLocaleLowerCase();
const searchWords = searchLc.split(" ");
for (const req of filteredRequirements) {
const projectNameLc = req.project.name?.toLowerCase() || "";
const nameLc = req.name.toLowerCase();
const nameWords = nameLc.split(" ");
let score = 0;
// prefix
if (nameLc.startsWith(searchLc)) {
score += 30;
}
// prefix
if (projectNameLc.startsWith(searchLc)) {
score += 25;
}
// abbreviation
if (searchWords.length === 1) {
const nameAbbr = nameWords.map(w => w.slice(0, 1)).filter(w => w.length).join("");
if (nameAbbr == searchLc) {
score += 30;
}
if (nameAbbr.startsWith(searchLc)) {
score += 25;
}
}
// tags
const allTags = [...req.tags, ...(req.project?.tags||[])];
for (const tag of allTags) {
const tagLc = tag.toLowerCase();
if (tagLc === tag) {
score += 30
} else if (tagLc.startsWith(searchLc)) {
score += 30 - (allTags.length * 3)
}
}
// project partial match
if (projectNameLc.includes(searchLc)) {
score += 15;
}
// description partial match
if (req.description.toLocaleLowerCase().includes(searchLc)) {
score += 4 * searchLc.length;
}
// letters in order
let j = 0;
let b = 1;
for (let i = 0; i < nameLc.length; i++) {
if (nameLc.charAt(i) == searchLc.charAt(j)) {
score += b;
j += 1;
b += 1;
if (searchLc.charAt(j) === " ") {
j += 1;
}
} else {
b = 1;
}
}
score -= (searchLc.length - j) * 2
scoreMap.set(req.id, score);
}
filteredRequirements = filteredRequirements
.filter(r => scoreMap.get(r.id) > searchLc.length )
.sort((a,b) => scoreMap.get(b.id) - scoreMap.get(a.id));
} else if (filteredRequirements.length > 3) {
filteredRequirements = [];
}
}
onMount(() => {
function onHotkey(e: KeyboardEvent) {
if ($currentModal.name === "closed") {
if (e.shiftKey && e.key.toLowerCase() === "r") {
show = true;
tick().then(() => searchBar.focus());
reloadRequirementList();
closeModal();
} else if (e.key.toLowerCase() === "escape") {
show = false;
}
}
}
window.addEventListener("keyup", onHotkey);
return () => window.removeEventListener("keyup", onHotkey);
})
async function submit() {}
</script>
<form on:submit|preventDefault={submit}>
<Modal wide nobody show={show} verb="Jump" noun={"to Requrirement"} disabled={loading} error={error}>
<ModalBody>
<input bind:value={searchRaw} bind:this={searchBar} placeholder="type to search requirements" />
<div class="requirement-list">
{#each filteredRequirements as req (req.id)}
<RequirementSubSection requirement={req} />
{/each}
</div>
</ModalBody>
</Modal>
</form>
<style lang="sass">
@import "../css/colors"
input
font-size: 1.5em
background-color: $color-entry1 !important
</style>
@@ -1,6 +1,7 @@
<script lang="ts">
import { sl3 } from "$lib/clients/sl3";
import { getStores } from "$app/stores";
import { sl3 } from "$lib/clients/sl3";
import Modal from "$lib/components/common/Modal.svelte";
import ModalBody from "$lib/components/common/ModalBody.svelte";
import { getModalContext } from "$lib/components/contexts/ModalContext.svelte";
@@ -12,6 +13,7 @@
const {currentModal, closeModal} = getModalContext();
const {updateScope} = getScopeContext();
const {reloadScopeList} = getScopeListContext();
const {page} = getStores();
let scope: ScopeInput
let scopeId: number
@@ -1,6 +1,7 @@
<script lang="ts">
import { sl3 } from "$lib/clients/sl3";
import { getStores } from "$app/stores";
import { sl3 } from "$lib/clients/sl3";
import Modal from "$lib/components/common/Modal.svelte";
import ModalBody from "$lib/components/common/ModalBody.svelte";
import { getModalContext } from "$lib/components/contexts/ModalContext.svelte";
@@ -8,17 +9,19 @@
import { getSprintListContext } from "$lib/components/contexts/SprintListContext.svelte";
import PartInput from "$lib/components/controls/PartInput.svelte";
import SprintKindSelect from "$lib/components/controls/SprintKindSelect.svelte";
import TagInput from "$lib/components/controls/TagInput.svelte";
import TimeRangeInput from "$lib/components/controls/TimeRangeInput.svelte";
import Checkbox from "$lib/components/layout/Checkbox.svelte";
import type Scope from "$lib/models/scope";
import type Sprint from "$lib/models/sprint";
import { SprintKind, type SprintInput, type SprintInputPart } from "$lib/models/sprint";
import { formatFormTime } from "$lib/utils/date";
import { partsDiff } from "$lib/utils/sprint";
import { formatFormTime } from "$lib/utils/date";
import { partsDiff } from "$lib/utils/sprint";
const {currentModal, closeModal} = getModalContext();
const {scope} = getScopeContext();
const {reloadSprintList} = getSprintListContext();
const {page} = getStores();
let sprint: SprintInput
let sprintId: number
@@ -59,6 +62,7 @@ import { partsDiff } from "$lib/utils/sprint";
isTimed: false,
isUnweighted: false,
parts: [],
tags: [],
}
intervalName = "this_month";
scopeId = scope.id;
@@ -80,6 +84,7 @@ import { partsDiff } from "$lib/utils/sprint";
isCoarse: current.isCoarse,
isTimed: current.isTimed,
isUnweighted: current.isUnweighted,
tags: [...(current.tags||[])],
};
sprintId = current.id;
scopeId = current.scopeId;
@@ -152,6 +157,8 @@ import { partsDiff } from "$lib/utils/sprint";
<TimeRangeInput openDate={openedDate} bind:from={sprint.fromTime} bind:to={sprint.toTime} bind:intervalName={intervalName} />
<label for="aggregateName">Aggregate Name</label>
<input name="aggregateName" type="text" bind:value={sprint.aggregateName} />
<label for="tags">Tags</label>
<TagInput exclaimMode bind:value={sprint.tags} />
{#if sprint.kind != SprintKind.Items}
<label for="aggregateValue">Aggregate Goal</label>
<input name="aggregateValue" type="number" bind:value={sprint.aggregateRequired} />
@@ -1,19 +1,20 @@
<script lang="ts">
import { sl3 } from "$lib/clients/sl3";
import { getStores } from "$app/stores";
import { sl3 } from "$lib/clients/sl3";
import Modal from "$lib/components/common/Modal.svelte";
import ModalBody from "$lib/components/common/ModalBody.svelte";
import { getModalContext } from "$lib/components/contexts/ModalContext.svelte";
import { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
import Checkbox from "$lib/components/layout/Checkbox.svelte";
import Checkbox from "$lib/components/layout/Checkbox.svelte";
import type { Requirement } from "$lib/models/project";
import type Scope from "$lib/models/scope";
import type { StatInput } from "$lib/models/stat";
import type Stat from "$lib/models/stat";
import { getAllContexts } from "svelte";
const {currentModal, closeModal} = getModalContext();
const {scope, upsertStat} = getScopeContext();
const {page} = getStores();
let stat: StatInput
let statId: number
+5
View File
@@ -18,6 +18,7 @@ export default interface Item {
weightedRequired: number
project: ItemProject
requirement: ItemRequirement
tags: string[]
}
interface ItemProject {
@@ -42,7 +43,11 @@ export interface ItemInput {
acquiredTime?: string
scheduledDate?: string
clearAcquiredTime?: boolean
clearScheduledDate?: boolean
stats: StatValueInput[]
tags: string[]
addTags?: string[]
removeTags?: string[]
}
export interface ItemFilter {
+21 -1
View File
@@ -19,6 +19,7 @@ export interface ProjectEntry {
name: string
status: number
statusName: string
tags: string[]
}
export interface ProjectInput {
@@ -26,9 +27,12 @@ export interface ProjectInput {
name: string
description: string
status: number
tags: string[]
addTags?: string[]
removeTags?: string[]
}
export interface Requirement {
export interface RequirementWithoutItems {
id: number
name: string
description: string
@@ -40,9 +44,21 @@ export interface Requirement {
isCoarse: boolean
aggregateRequired: number
stats: StatProgressWithPlanned[]
tags: string[]
project?: RequirementProjectLink
}
export interface Requirement extends RequirementWithoutItems {
items: Item[]
}
export interface RequirementProjectLink {
id: number
scopeId: number
name: string
tags: string[]
}
export interface RequirementInput {
name: string
description: string
@@ -50,4 +66,8 @@ export interface RequirementInput {
isCoarse: boolean
aggregateRequired: number
stats: StatValueInput[]
tags: string[]
addTags?: string[]
removeTags?: string[]
projectId?: number
}
+10
View File
@@ -8,6 +8,8 @@ export default interface Sprint {
description: string
kind: SprintKind
tags?: string[]
fromTime: string
toTime: string
@@ -19,15 +21,22 @@ export default interface Sprint {
aggregateRequired: number
aggregateAcquired: number
aggregatePlanned: number
aggregateBurndown?: SprintBurndownDataPoint[]
itemsAcquired?: number
itemsRequired?: number
itemBurndown?: SprintBurndownDataPoint[]
partIds: number[]
items: Item[]
progress: StatProgress[]
}
export interface SprintBurndownDataPoint {
date: string
value: number
}
export enum SprintKind {
Items = 0,
Requirements = 1,
@@ -49,6 +58,7 @@ export interface SprintInput {
aggregateName?: string
aggregateRequired?: number
parts?: SprintInputPart[]
tags?: string[]
}
export interface SprintInputPart {
+50 -9
View File
@@ -1,5 +1,5 @@
const weekDay = ["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"];
const monthNames = ["January","February","March","April","June","july","August","September","October","November","December"]
const monthNames = ["January","February","March","April","May","June","july","August","September","October","November","December"]
export function formatFormTime(time: Date | string): string {
if (!time) {
@@ -51,8 +51,25 @@ export function formatWeekdayDate(time: Date | string | number): string {
return `${time.getFullYear()}-${pad(time.getMonth()+1)}-${pad(time.getDate())} (${weekDay[time.getDay()]})`;
}
export function startOfMonth(now: Date): Date {
return new Date(now.getFullYear(), now.getMonth(), 1);
export function formatWeekdayTitle(now: Date, time: Date | string | number): string {
if (!(time instanceof Date)) {
time = new Date(time);
}
const diff = Math.floor((time.getTime() - now.getTime()) / (86400000));
if (diff === 0) {
return "Today"
} else if (diff === -1) {
return "Yesterday"
} else if (diff >= -6) {
return `${weekDay[time.getDay()]}`;
} else {
return formatWeekdayDate(time);
}
}
export function startOfMonth(now: Date, offset = 0): Date {
return new Date(now.getFullYear(), now.getMonth() + offset);
}
export function startOfYear(now: Date): Date {
@@ -114,7 +131,7 @@ export function endOfDay(now: Date): Date {
}
export function monthName(date: Date): string {
return monthNames[date.getMonth() - 1]
return monthNames[date.getMonth()]
}
export function addDays(date: Date, days: number): Date {
@@ -135,22 +152,46 @@ export function addYears(date: Date, years: number): Date {
return newDate;
}
export function formatDays(date: Date, now: Date) {
const d = startOfDay(date);
const n = startOfDay(now);
const diff = Math.round((d.getTime() - n.getTime()) / (86400000));
if (diff === 0) {
return "today";
} else if (diff < 7) {
return `${diff}d`;
} else {
return `${Math.floor(diff / 7)}w${Math.floor(diff % 7)}d`;
}
}
export function formatPrettyTime(date: Date, now: Date) {
const d = startOfDay(date);
const n = startOfDay(now);
const diff = (d.getTime() - n.getTime()) / (86400000);
const diff = Math.round((d.getTime() - n.getTime()) / (86400000));
const hhmm = `${pad(date.getHours())}:${pad(date.getMinutes())}`;
const dayOfMonth = date.getDate();
var ordinalDay = dayOfMonth + "th";
if (dayOfMonth == 1) {
ordinalDay = dayOfMonth + "st";
} else if (dayOfMonth == 2) {
ordinalDay = dayOfMonth + "nd"
} else if (dayOfMonth == 3) {
ordinalDay = dayOfMonth + "rd"
}
if (diff === 0) {
return `today at ${hhmm}`;
} else if (diff === 1) {
return `tomorrow at ${hhmm}`;
} else if (diff === -1) {
return `yesterday at ${hhmm}`;
} else if (diff > -3 && diff < 6) {
return `${weekDay[date.getDay()]} at ${hhmm}`;
} else if (diff > -7 && diff < 7) {
return `${weekDay[date.getDay()]}, the ${ordinalDay} at ${hhmm}`;
} else {
return `${monthName(date)} ${date.getDate()} at ${hhmm}`;
return `${ordinalDay} of ${monthName(date)} at ${hhmm}`;
}
}
+17 -4
View File
@@ -1,5 +1,5 @@
import type { TimeInterval } from "../models/common";
import { addDays, addMonths, addYears, formatDate, startOfDay, startOfMonth, startOfWeek, startOfYear } from "./date";
import { addDays, addMonths, addYears, endOfYear, formatDate, startOfDay, startOfMonth, startOfWeek, startOfYear } from "./date";
export default function parseInterval(s: string, date: Date): TimeInterval<Date> {
const [verb, args] = s.split(";")[0].split(":")
@@ -43,11 +43,11 @@ export default function parseInterval(s: string, date: Date): TimeInterval<Date>
case "last_week":
return {display: "weeks", min: startOfWeek(addDays(date, -7)), max: startOfWeek(date)}
case "next_month":
return {display: "month", min: startOfMonth(addMonths(date, 1)), max: startOfMonth(addMonths(date, 2))}
return {display: "month", min: startOfMonth(date, 1), max: startOfMonth(date, 2)}
case "this_month":
return {display: "month", min: startOfMonth(date), max: startOfMonth(addMonths(date, 1))}
return {display: "month", min: startOfMonth(date, 0), max: startOfMonth(date, 1)}
case "last_month":
return {display: "month", min: startOfMonth(addMonths(date, -1)), max: startOfMonth(date)}
return {display: "month", min: startOfMonth(date, -1), max: startOfMonth(date, 0)}
case "next_year":
return {display: "year", min: startOfYear(addYears(date, 1)), max: startOfYear(addYears(date, 2))}
case "this_year":
@@ -59,6 +59,10 @@ export default function parseInterval(s: string, date: Date): TimeInterval<Date>
return null
}
export function morningOf(date: Date) {
return new Date(startOfDay(date).getTime() + (3600000 * 5));
}
export function morningInterval(interval: TimeInterval<Date>) {
if (!interval) {
return null
@@ -114,6 +118,15 @@ export function datesOf(interval: TimeInterval<string | Date>): TimeInterval<str
return {min: formatDate(interval.min), max: formatDate(interval.max)}
}
export function scheduledDatesOf(interval: TimeInterval<string | Date>): TimeInterval<string> {
const res = datesOf(interval);
if (res != null) {
res.max = formatDate(endOfYear(addYears(new Date(interval.max), 2)))
}
return res;
}
function capitalize(s: string) {
return `${s.charAt(0).toLocaleUpperCase()}${s.slice(1)}`
}
@@ -4,11 +4,14 @@
import { sl3 } from "$lib/clients/sl3";
import type { ProjectEntry } from "$lib/models/project";
export const load: Load = async({ fetch, params }) => {
export const load: Load = async({ fetch, params, stuff }) => {
const scopeId = parseInt(params.scope.split("-")[0]);
const client = sl3(fetch);
const scope = await sl3(fetch).findScope(scopeId);
const projects = await sl3(fetch).listProjects(scopeId);
const [scope, projects] = await Promise.all([
client.findScope(scopeId),
client.listProjects(scopeId),
]);
return {
props: { projects, scope },
@@ -2,18 +2,18 @@
import type { Load } from "@sveltejs/kit/types/internal";
import { sl3 } from "$lib/clients/sl3";
import parseInterval, { datesOf } from "$lib/utils/timeinterval";
import parseInterval, { scheduledDatesOf } from "$lib/utils/timeinterval";
import type Item from "$lib/models/item";
import type { ItemFilter, ItemGroup } from "$lib/models/item";
export const load: Load = async({ fetch, params, session }) => {
export const load: Load = async({ fetch, params, session, stuff }) => {
const scopeId = parseInt(params.scope.split("-")[0]);
const interval = parseInterval(params.interval, new Date());
const filter = {
createdTime: interval,
acquiredTime: interval,
scheduledDate: datesOf(interval),
scheduledDate: scheduledDatesOf(interval),
};
const {items, groups} = await sl3(fetch).listItems(scopeId, filter, true, session.groupOptions);
@@ -4,10 +4,11 @@
function generateItemFilters(now: Date): {scheduledFilter: ItemFilter, acquiredFilter: ItemFilter, looseFilter: ItemFilter} {
return {
scheduledFilter: {
scheduledDate: datesOf(parseInterval("next:7d", now)),
scheduledDate: datesOf(parseInterval("next:30d", now)),
unAcquired: true,
},
acquiredFilter: {
acquiredTime: morningInterval(parseInterval("today", now)),
acquiredTime: morningInterval(parseInterval("last:15d", now)),
},
looseFilter: {
loose: true,
@@ -17,16 +18,18 @@
}
}
export const load: Load = async({params, url, fetch}) => {
export const load: Load = async({params, stuff, fetch}) => {
const scopeId = parseInt(params.scope.split("-")[0]);
const {acquiredFilter, looseFilter, scheduledFilter} = generateItemFilters(new Date());
const client = sl3(fetch);
const scheduledItems = (await sl3(fetch).listItems(scopeId, scheduledFilter));
const acquiredItems = (await sl3(fetch).listItems(scopeId, acquiredFilter));
const looseItems = (await sl3(fetch).listItems(scopeId, looseFilter));
const sprints = await sl3(fetch).listSprints(scopeId);
const {acquiredFilter, looseFilter, scheduledFilter} = generateItemFilters(new Date());
const [scheduledItems, acquiredItems, looseItems, sprints] = await Promise.all([
client.listItems(scopeId, scheduledFilter),
client.listItems(scopeId, acquiredFilter),
client.listItems(scopeId, looseFilter),
client.listSprints(scopeId),
]);
return {
stuff: { title: "Overview" },
@@ -101,8 +104,8 @@
<Card on:click={openEditScope} pointerCursor><CardHeader>Edit Scope</CardHeader></Card>
<Card on:click={openDeleteScope} pointerCursor><CardHeader>Delete Scope</CardHeader></Card>
</Row>
<ItemListRow title="Scheduled" key="scheduleItems" />
<ItemListRow title="Today" key="acquiredItems" showAcquiredTime />
<ItemListRow event="scheduled" title="Scheduled" key="scheduledItems" />
<ItemListRow title="" key="acquiredItems" useDayFilter={15} showAcquiredTime useAcquiredTotal />
<ItemListRow title="Loose" key="looseItems" />
</Column>
<Column>
@@ -24,6 +24,7 @@
import ItemAcquireModal from "$lib/modals/ItemAcquireModal.svelte";
import DeletionModal from "$lib/modals/DeletionModal.svelte";
import ProjectCreateEditModal from "$lib/modals/ProjectCreateEditModal.svelte";
import MultiScheduleModal from "$lib/modals/MultiScheduleModal.svelte";
export let project: Project;
</script>
@@ -35,4 +36,5 @@
<RequirementCreateModal />
<DeletionModal />
<ProjectCreateEditModal />
<MultiScheduleModal />
</ProjectContext>
@@ -2,7 +2,7 @@
import type { Load } from "@sveltejs/kit/types/internal";
import { sl3 } from "$lib/clients/sl3";
export const load: Load = async({ fetch, params }) => {
export const load: Load = async({ fetch, params, stuff }) => {
const scopeId = parseInt(params.scope.split("-")[0]);
let interval = parseInterval(params.interval, new Date());
+32 -5
View File
@@ -1,13 +1,35 @@
<script lang="ts" context="module">
import type { Load } from "@sveltejs/kit/types/internal";
import { Auth, Amplify } from "aws-amplify";
import backgroundImage from "$lib/assets/background.jpg";
export const load: Load = async({ fetch, session, url }) => {
if (session.user == null && !url.pathname.startsWith("/login")) {
Amplify.configure({
Auth: {
region: import.meta.env.VITE_STUFFLOG3_AWS_POOL_REGION,
userPoolId: import.meta.env.VITE_STUFFLOG3_AWS_POOL_ID,
userPoolWebClientId: import.meta.env.VITE_STUFFLOG3_AWS_POOL_PUBLIC_CLIENT_ID,
},
ssr: false,
});
export const load: Load = async({ url }) => {
let idToken: string | null = null;
try {
const sess = await Auth.currentSession();
idToken = sess?.getIdToken()?.getJwtToken();
} catch(err) {
console.warn(err);
}
if (idToken == null && !url.pathname.startsWith("/login")) {
return { status: 302, redirect: "/login" };
}
return { props: {} };
return { props: {}, stuff: { idToken } };
}
export const ssr = false;
</script>
<script lang="ts">
@@ -16,15 +38,20 @@
import Background from "$lib/components/layout/Background.svelte"
import TimeContext from "$lib/components/contexts/TimeContext.svelte";
import ModalContext from "$lib/components/contexts/ModalContext.svelte";
import RequirementListContext from "$lib/components/contexts/RequirementListContext.svelte";
let opacity = 0.175;
$: opacity = $page.url.pathname === "/" ? 0.3 : 0.2;
export const ssr = false;
</script>
<ModalContext>
<TimeContext>
<Background initialOrientation="landscape" opacity={opacity} />
<slot></slot>
<RequirementListContext>
<Background src={backgroundImage} initialOrientation="landscape" opacity={opacity} />
<slot></slot>
</RequirementListContext>
</TimeContext>
</ModalContext>
+11 -10
View File
@@ -1,12 +1,16 @@
import type { RequestHandler } from "@sveltejs/kit";
export const get: RequestHandler = async({ request, url, params, locals }) => {
const proxyUrl = `${process.env.STUFFLOG3_API}/api/${params.any}${url.search}`;
const proxyUrl = `${import.meta.env.VITE_STUFFLOG3_API}/api/${params.any}${url.search}`;
const headers = {};
if (locals.idToken != null) {
headers["Authorization"] = `Bearer ${locals.idToken}`;
}
request.headers.forEach((v, k) => {
if (k.toLowerCase() == "host") {
return;
}
headers[k] = v;
})
const res = await fetch(proxyUrl, {
method: request.method,
@@ -17,15 +21,12 @@ export const get: RequestHandler = async({ request, url, params, locals }) => {
throw err;
});
const body = JSON.stringify(await res.json());
const result = {
status: res.status,
body: await res.text(),
headers: {},
body,
}
res.headers.forEach((v, k) => {
result.headers[k] = v;
})
return result;
}
+18 -9
View File
@@ -5,10 +5,11 @@
function generateItemFilters(now: Date): {scheduledFilter: ItemFilter, acquiredFilter: ItemFilter, looseFilter: ItemFilter} {
return {
scheduledFilter: {
scheduledDate: datesOf(parseInterval("next:7d", now)),
scheduledDate: datesOf(parseInterval("next:30d", now)),
unAcquired: true,
},
acquiredFilter: {
acquiredTime: morningInterval(parseInterval("today", now)),
acquiredTime: morningInterval(parseInterval("last:15d", now)),
},
looseFilter: {
loose: true,
@@ -19,14 +20,18 @@
}
export const load: Load = async({ fetch }) => {
const scopes = await sl3(fetch).listScopes();
const client = sl3(fetch);
const scopes = await client.listScopes();
const {acquiredFilter, looseFilter, scheduledFilter} = generateItemFilters(new Date());
const scheduledItems = (await sl3(fetch).listItems("ALL", scheduledFilter));
const acquiredItems = (await sl3(fetch).listItems("ALL", acquiredFilter));
const looseItems = (await sl3(fetch).listItems("ALL", looseFilter));
const sprints = await sl3(fetch).listSprints("ALL");
const [scheduledItems, acquiredItems, looseItems, sprints] = await Promise.all([
client.listItems("ALL", scheduledFilter),
client.listItems("ALL", acquiredFilter),
client.listItems("ALL", looseFilter),
client.listSprints("ALL"),
]);
return {
props: { scopes, scheduledItems, acquiredItems, looseItems, sprints }
@@ -59,6 +64,8 @@
import SprintListContext from "$lib/components/contexts/SprintListContext.svelte";
import type Sprint from "$lib/models/sprint";
import Icon from "$lib/components/layout/Icon.svelte";
import RequirementJumpModal from "$lib/modals/RequirementJumpModal.svelte";
import RequirementCreateModal from "$lib/modals/RequirementCreateModal.svelte";
export let scopes: Scope[] = [];
export let acquiredItems: Item[];
@@ -86,8 +93,8 @@
</OptionsRow>
<ScopeLinkList />
</Row>
<ItemListRow title="Scheduled" key="scheduleItems" />
<ItemListRow title="Today" key="acquiredItems" showAcquiredTime />
<ItemListRow event="scheduled" title="Scheduled" key="scheduledItems" />
<ItemListRow title="" key="acquiredItems" useDayFilter={15} showAcquiredTime useAcquiredTotal />
<ItemListRow title="Loose" key="looseItems" />
</Column>
<Column>
@@ -102,6 +109,8 @@
<DeletionModal />
<ScopeCreateUpdateModal />
<SprintCreateUpdateModal />
<RequirementCreateModal />
<RequirementJumpModal />
</SprintListContext>
</ScopeListContext>
</ItemMultiListContext>
+34 -30
View File
@@ -1,54 +1,51 @@
<script lang="ts" context="module">
import type { Load } from "@sveltejs/kit/types/internal";
export const load: Load = async({ fetch, session, url }) => {
if (session.user != null) {
return { status: 302, redirect: "/" };
}
return { props: {} };
}
</script>
<script lang="ts">
import { Amplify, Auth } from 'aws-amplify';
import { Auth } from 'aws-amplify';
import { goto } from '$app/navigation';
import Modal from '$lib/components/common/Modal.svelte';
import ModalBody from '$lib/components/common/ModalBody.svelte';
import { session } from '$app/stores';
Amplify.configure({
Auth: $session.amplifyConfig,
ssr: true,
});
let username = '';
let password = '';
let newPassword = '';
let newPassword2 = '';
let error: string;
let loading = false;
let changeMode = false;
let changeUser = null;
async function handleSignIn() {
error = void(0);
loading = true;
if (newPassword !== newPassword2) {
error = "The two passwords do not match, please re-enter."
return
}
try {
const res = await Auth.signIn(username, password);
if (changeMode) {
const res = await Auth.completeNewPassword(changeUser, newPassword)
$session.user = {
id: res.attributes.sub,
name: res.username,
};
$session.idToken = res.signInUserSession.idToken.jwtToken;
changeMode = false;
password = "";
} else {
const res = await Auth.signIn(username, password);
goto('/', {replaceState: true});
if (res.challengeName == "NEW_PASSWORD_REQUIRED") {
newPassword = "";
newPassword2 = "";
changeMode = true
changeUser = res
}
goto('/', {replaceState: true});
}
} catch (err) {
error = err?.toString() || err;
}
loading = false;
}
</script>
<form on:submit|preventDefault={handleSignIn}>
@@ -56,8 +53,15 @@
<ModalBody>
<label for="name">Username</label>
<input type="text" bind:value={username} />
<label for="name">Password</label>
<input type="password" bind:value={password} />
{#if changeMode}
<label for="name">New Password</label>
<input type="password" bind:value={newPassword} />
<label for="name">Confirm New Password</label>
<input type="password" bind:value={newPassword2} />
{:else}
<label for="name">Password</label>
<input type="password" bind:value={password} />
{/if}
</ModalBody>
</Modal>
</form>
+10 -2
View File
@@ -1,5 +1,5 @@
import preprocess from 'svelte-preprocess';
import adapter from '@sveltejs/adapter-node';
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
@@ -8,7 +8,15 @@ const config = {
preprocess: preprocess(),
kit: {
adapter: adapter(),
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: "index.html"
}),
prerender: {
enabled: true,
},
vite: {
resolve: {
+1 -34
View File
@@ -1,36 +1,3 @@
{
"compilerOptions": {
"moduleResolution": "node",
"module": "es2020",
"lib": ["es2020", "DOM"],
"target": "es2020",
/**
svelte-preprocess cannot figure out whether you have a value or a type, so tell TypeScript
to enforce using \`import type\` instead of \`import\` for Types.
*/
"importsNotUsedAsValues": "error",
/**
TypeScript doesn't know about import usages in the template because it only sees the
script of a Svelte file. Therefore preserve all value imports. Requires TS 4.5 or higher.
*/
"preserveValueImports": true,
"isolatedModules": true,
"resolveJsonModule": true,
/**
To have warnings/errors of the Svelte compiler at the correct position,
enable source maps by default.
*/
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"allowJs": true,
"checkJs": true,
"paths": {
"$lib": ["src/lib"],
"$lib/*": ["src/lib/*"]
}
},
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.ts", "src/**/*.svelte"]
"extends": "./.svelte-kit/tsconfig.json"
}
+1 -1
View File
@@ -4,7 +4,6 @@ go 1.18
require (
github.com/Masterminds/squirrel v1.5.2
github.com/aws/aws-lambda-go v1.19.1
github.com/aws/aws-sdk-go v1.44.27
github.com/awslabs/aws-lambda-go-api-proxy v0.13.3
github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1
@@ -16,6 +15,7 @@ require (
)
require (
github.com/aws/aws-lambda-go v1.19.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.13.0 // indirect
+17
View File
@@ -0,0 +1,17 @@
package genutils
func Retain[T any](arr []T, cb func(T) bool) []T {
return RetainInPlace(append(arr[:0:0], arr...), cb)
}
func RetainInPlace[T any](arr []T, cb func(T) bool) []T {
count := 0
for _, v := range arr {
if cb(v) {
arr[count] = v
count += 1
}
}
return arr[:count]
}
+24
View File
@@ -0,0 +1,24 @@
package genutils
func SliceWithUniques[T comparable](slice []T, elemsToAdd []T) []T {
newSlice := slice[:len(slice):len(slice)]
addLoop:
for _, elem := range elemsToAdd {
for _, existing := range slice {
if existing == elem {
continue addLoop
}
}
newSlice = append(newSlice, elem)
}
return newSlice
}
func SliceWithout[T comparable](originals []T, elemsToRemove []T) []T {
return Retain(originals, func(v T) bool {
return !Contains(elemsToRemove, v)
})
}
+7
View File
@@ -0,0 +1,7 @@
package validate
import "strings"
func Tag(s string) bool {
return !strings.ContainsAny(s, ",;\t\r\n <>")
}
+49
View File
@@ -0,0 +1,49 @@
package validate
import (
"fmt"
"git.aiterp.net/stufflog3/stufflog3/internal/genutils"
"git.aiterp.net/stufflog3/stufflog3/models"
)
func Tags(existingTags, addTags, removeTags []string) error {
for i, tag := range addTags {
if !Tag(tag) {
return models.BadInputError{
Object: "ItemInput",
Field: "addTags",
Problem: fmt.Sprintf("Invalid tag: %s", tag),
Element: tag,
}
}
if genutils.Contains(addTags[:i], tag) {
return models.BadInputError{
Object: "ItemUpdate",
Field: "addTags",
Problem: fmt.Sprintf("The tag %s already exists!", tag),
Element: tag,
}
}
if genutils.Contains(existingTags, tag) {
return models.BadInputError{
Object: "ItemUpdate",
Field: "addTags",
Problem: fmt.Sprintf("The tag %s already exists!", tag),
Element: tag,
}
}
}
for _, tag := range removeTags {
if !genutils.Contains(existingTags, tag) {
return models.BadInputError{
Object: "ItemUpdate",
Field: "removeTags",
Problem: fmt.Sprintf("The tag %s does not exist!", tag),
Element: tag,
}
}
}
return nil
}
+1
View File
@@ -55,6 +55,7 @@ type BadInputError struct {
Problem string `json:"problem"`
Min interface{} `json:"min,omitempty"`
Max interface{} `json:"max,omitempty"`
Element interface{} `json:"element,omitempty"`
}
func (e BadInputError) Error() string {
+3
View File
@@ -9,6 +9,8 @@ type ItemUpdate struct {
Description *string `json:"description"`
AcquiredTime *time.Time `json:"acquiredTime"`
ScheduledDate *Date `json:"scheduledDate"`
AddTags []string `json:"addTags"`
RemoveTags []string `json:"removeTags"`
ClearAcquiredTime bool `json:"clearAcquiredTime"`
ClearScheduledDate bool `json:"clearScheduledDate"`
@@ -24,6 +26,7 @@ type ItemFilter struct {
ProjectIDs []int `json:"projectIds,omitempty"`
RequirementIDs []int `json:"requirementIds,omitempty"`
StatIDs []int `json:"statIds,omitempty"`
Tags []string `json:"tags,omitempty"`
Loose bool `json:"loose,omitempty"`
UnScheduled bool `json:"unScheduled,omitempty"`
UnAcquired bool `json:"unAcquired,omitempty"`
+14 -9
View File
@@ -1,16 +1,21 @@
package models
type ProjectUpdate struct {
Name *string `json:"name,omitempty"`
OwnerID *string `json:"ownerId,omitempty"`
Status *Status `json:"status,omitempty"`
Description *string `json:"description,omitempty"`
Name *string `json:"name,omitempty"`
OwnerID *string `json:"ownerId,omitempty"`
Status *Status `json:"status,omitempty"`
Description *string `json:"description,omitempty"`
AddTags []string `json:"addTags"`
RemoveTags []string `json:"removeTags"`
}
type RequirementUpdate struct {
Name *string `json:"name"`
Description *string `json:"description"`
Status *Status `json:"status"`
IsCoarse *bool `json:"isCoarse"`
AggregateRequired *int `json:"aggregateRequired"`
Name *string `json:"name"`
Description *string `json:"description"`
Status *Status `json:"status"`
IsCoarse *bool `json:"isCoarse"`
AggregateRequired *int `json:"aggregateRequired"`
AddTags []string `json:"addTags"`
RemoveTags []string `json:"removeTags"`
ProjectID *int `json:"projectId"`
}
+1
View File
@@ -12,6 +12,7 @@ type SprintUpdate struct {
IsUnweighted *bool `json:"isUnweighted"`
AggregateName *string `json:"aggregateName"`
AggregateRequired *int `json:"aggregateRequired"`
Tags []string `json:"tags"`
}
// SprintKind decides the composition of stat bars (SB) and what objects are included in the result (R)
+6 -1
View File
@@ -5,11 +5,16 @@ import (
"git.aiterp.net/stufflog3/stufflog3/models"
"git.aiterp.net/stufflog3/stufflog3/usecases/projects"
"github.com/gin-gonic/gin"
"strings"
)
func Projects(g *gin.RouterGroup, service *projects.Service) {
g.GET("", handler("projects", func(c *gin.Context) (interface{}, error) {
return service.List(c.Request.Context())
if tagsQuery := c.Query("tags"); tagsQuery != "" {
return service.ListByTags(c.Request.Context(), strings.Split(tagsQuery, ","))
} else {
return service.List(c.Request.Context())
}
}))
g.GET("/:project_id", handler("project", func(c *gin.Context) (interface{}, error) {
+18
View File
@@ -0,0 +1,18 @@
package httpapi
import (
"git.aiterp.net/stufflog3/stufflog3/usecases/projects"
"github.com/gin-gonic/gin"
)
func RequirementsAllScopes(g *gin.RouterGroup, service *projects.Service) {
g.GET("", handler("requirements", func(c *gin.Context) (interface{}, error) {
return service.ListRequirementAllScopes(c.Request.Context())
}))
}
func Requirements(g *gin.RouterGroup, service *projects.Service) {
g.GET("", handler("requirements", func(c *gin.Context) (interface{}, error) {
return service.ListRequirements(c.Request.Context())
}))
}
+60 -1
View File
@@ -1,6 +1,7 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"fmt"
@@ -12,6 +13,8 @@ import (
"git.aiterp.net/stufflog3/stufflog3/usecases/scopes"
"git.aiterp.net/stufflog3/stufflog3/usecases/sprints"
"git.aiterp.net/stufflog3/stufflog3/usecases/stats"
"github.com/Masterminds/squirrel"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
@@ -75,7 +78,7 @@ func Connect(host string, port int, username, password, database string) (*Datab
}
q := mysqlcore.New(db)
return &Database{db: db, q: q}, nil
}
@@ -96,6 +99,14 @@ func intPtr(nullInt32 sql.NullInt32) *int {
}
}
func csvPtr(nullString sql.NullString) []string {
if !nullString.Valid {
return nil
}
return strings.Split(nullString.String, ",")
}
func sqlTimePtr(ptr *time.Time) sql.NullTime {
if ptr != nil {
return sql.NullTime{Time: *ptr, Valid: true}
@@ -129,3 +140,51 @@ func sqlJsonPtr(ptr interface{}) sqltypes.NullRawMessage {
return sqltypes.NullRawMessage{Valid: false}
}
}
func sqlCsvPtr(a []string) sql.NullString {
if len(a) == 0 {
return sql.NullString{Valid: false}
}
return sql.NullString{Valid: true, String: strings.Join(a, ",")}
}
const (
tagObjectKindItem = iota
tagObjectKindRequirement
tagObjectKindProject
)
func fetchTags(ctx context.Context, db *sql.DB, kind int, ids []int, cb func(id int, tag string)) error {
query, args, err := squirrel.Select("object_id, tag_name").
From("tag").
Where(squirrel.Eq{"object_id": ids, "object_kind": kind}).
OrderBy("tag_name").
ToSql()
if err != nil {
return err
}
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return err
}
for rows.Next() {
var tagStr string
var objectID int
err := rows.Scan(&objectID, &tagStr)
if err != nil {
return err
}
cb(objectID, tagStr)
}
if err := rows.Close(); err != nil {
return err
}
if err := rows.Err(); err != nil {
return err
}
return nil
}
+182 -8
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"git.aiterp.net/stufflog3/stufflog3/entities"
"git.aiterp.net/stufflog3/stufflog3/internal/genutils"
"git.aiterp.net/stufflog3/stufflog3/models"
"git.aiterp.net/stufflog3/stufflog3/ports/mysql/mysqlcore"
"git.aiterp.net/stufflog3/stufflog3/ports/mysql/sqltypes"
@@ -23,6 +24,11 @@ func (r *itemRepository) Find(ctx context.Context, scopeID, itemID int) (*entiti
return nil, err
}
tags, err := r.q.ListTagsByObject(ctx, mysqlcore.ListTagsByObjectParams{
ObjectKind: tagObjectKindItem,
ObjectID: row.ID,
})
return &entities.Item{
ID: row.ID,
ScopeID: row.ScopeID,
@@ -34,6 +40,7 @@ func (r *itemRepository) Find(ctx context.Context, scopeID, itemID int) (*entiti
CreatedTime: row.CreatedTime,
AcquiredTime: timePtr(row.AcquiredTime),
ScheduledDate: row.ScheduledDate.AsPtr(),
Tags: tags,
}, nil
}
@@ -54,6 +61,9 @@ func (r *itemRepository) Fetch(ctx context.Context, filter models.ItemFilter) ([
if filter.StatIDs != nil && len(filter.StatIDs) == 0 {
return []entities.Item{}, nil
}
if filter.Tags != nil && len(filter.Tags) == 0 {
return []entities.Item{}, nil
}
sq := squirrel.Select(
"i.id, i.scope_id, i.project_requirement_id, pr.project_id, i.owner_id, i.name," +
@@ -126,6 +136,10 @@ func (r *itemRepository) Fetch(ctx context.Context, filter models.ItemFilter) ([
seen := make(map[int]bool, 32)
res := make([]entities.Item, 0, 32)
ids := genutils.Set[int]{}
projectIDs := genutils.Set[int]{}
requirementIDs := genutils.Set[int]{}
for rows.Next() {
item := entities.Item{}
@@ -161,29 +175,130 @@ func (r *itemRepository) Fetch(ctx context.Context, filter models.ItemFilter) ([
item.ProjectID = intPtr(projectId)
item.AcquiredTime = timePtr(acquiredTime)
item.ScheduledDate = scheduledDate.AsPtr()
item.Tags = []string{}
ids.Add(item.ID)
if item.ProjectID != nil {
projectIDs.Add(*item.ProjectID)
requirementIDs.Add(*item.RequirementID)
}
res = append(res, item)
}
err = fetchTags(ctx, r.db, tagObjectKindItem, ids.Values(), func(id int, tag string) {
for i := range res {
if id == res[i].ID {
res[i].Tags = append(res[i].Tags, tag)
}
}
})
if err != nil {
return nil, err
}
if len(filter.Tags) > 0 {
projectTagMap := make(map[int][]string, 64)
err = fetchTags(ctx, r.db, tagObjectKindProject, projectIDs.Values(), func(id int, tag string) {
projectTagMap[id] = append(projectTagMap[id], tag)
})
if err != nil {
return nil, err
}
requirementTagMap := make(map[int][]string, 64)
err = fetchTags(ctx, r.db, tagObjectKindRequirement, requirementIDs.Values(), func(id int, tag string) {
requirementTagMap[id] = append(requirementTagMap[id], tag)
})
if err != nil {
return nil, err
}
res = genutils.RetainInPlace(res, func(item entities.Item) bool {
good := false
for _, tag := range filter.Tags {
if strings.HasPrefix(tag, "!") {
tag = tag[1:]
if !item.HasTag(tag) {
if item.RequirementID != nil {
if !genutils.Contains(requirementTagMap[*item.RequirementID], tag) &&
!genutils.Contains(projectTagMap[*item.ProjectID], tag) {
return false
}
} else {
return false
}
}
good = true
} else {
if item.HasTag(tag) {
good = true
} else if item.RequirementID != nil &&
(genutils.Contains(requirementTagMap[*item.RequirementID], tag) ||
genutils.Contains(projectTagMap[*item.ProjectID], tag)) {
good = true
}
}
}
return good
})
}
sort.Slice(res, func(i, j int) bool {
if res[i].AcquiredTime == nil && res[j].AcquiredTime != nil {
// Acquired time, descending
ati, atj := res[i].AcquiredTime, res[j].AcquiredTime
if ati == nil && atj != nil {
return true
}
if res[i].AcquiredTime != nil && res[j].AcquiredTime == nil {
if ati != nil && atj == nil {
return false
}
if res[i].AcquiredTime != nil && res[j].AcquiredTime != nil {
return res[i].AcquiredTime.After(*res[j].AcquiredTime)
} else {
return res[i].CreatedTime.After(res[j].CreatedTime)
if ati != nil && atj != nil {
return ati.After(*atj)
}
// Scheduled date, ascending
cti, ctj := res[i].CreatedTime, res[j].CreatedTime
sdi, sdj := res[i].ScheduledDate, res[j].ScheduledDate
if sdi != nil && sdj == nil {
return true
}
if sdi == nil && sdj != nil {
return false
}
if sdi != nil && sdj != nil {
if *sdi == *sdj {
// This should change the behavior on the front page only. #hax
if filter.UnAcquired {
return cti.Before(ctj)
} else {
return cti.After(ctj)
}
}
return sdi.Before(*sdj)
}
// Created time, descending
return cti.After(ctj)
})
return res, nil
}
func (r *itemRepository) Insert(ctx context.Context, item entities.Item) (*entities.Item, error) {
res, err := r.q.InsertItem(ctx, mysqlcore.InsertItemParams{
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
q := mysqlcore.New(tx)
res, err := q.InsertItem(ctx, mysqlcore.InsertItemParams{
ScopeID: item.ScopeID,
ProjectRequirementID: sqlIntPtr(item.RequirementID),
Name: item.Name,
@@ -202,13 +317,37 @@ func (r *itemRepository) Insert(ctx context.Context, item entities.Item) (*entit
}
item.ID = int(id)
for _, tag := range item.Tags {
err := q.InsertTag(ctx, mysqlcore.InsertTagParams{
ObjectKind: tagObjectKindItem,
ObjectID: item.ID,
TagName: tag,
})
if err != nil {
return nil, err
}
}
err = tx.Commit()
if err != nil {
return nil, err
}
return &item, nil
}
func (r *itemRepository) Update(ctx context.Context, item entities.Item, update models.ItemUpdate) error {
item.ApplyUpdate(update)
return r.q.UpdateItem(ctx, mysqlcore.UpdateItemParams{
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
q := mysqlcore.New(tx)
err = q.UpdateItem(ctx, mysqlcore.UpdateItemParams{
ProjectRequirementID: sqlIntPtr(item.RequirementID),
Name: item.Name,
Description: item.Description,
@@ -217,9 +356,44 @@ func (r *itemRepository) Update(ctx context.Context, item entities.Item, update
OwnerID: item.OwnerID,
ID: item.ID,
})
if err != nil {
return err
}
for _, tag := range update.RemoveTags {
err := q.DeleteTag(ctx, mysqlcore.DeleteTagParams{
ObjectKind: tagObjectKindItem,
ObjectID: item.ID,
TagName: tag,
})
if err != nil {
return err
}
}
for _, tag := range update.AddTags {
err = q.InsertTag(ctx, mysqlcore.InsertTagParams{
ObjectKind: tagObjectKindItem,
ObjectID: item.ID,
TagName: tag,
})
if err != nil {
return err
}
}
return tx.Commit()
}
func (r *itemRepository) Delete(ctx context.Context, item entities.Item) error {
err := r.q.DeleteTagByObject(ctx, mysqlcore.DeleteTagByObjectParams{
ObjectKind: tagObjectKindItem,
ObjectID: item.ID,
})
if err != nil {
return err
}
return r.q.DeleteItem(ctx, item.ID)
}
+60
View File
@@ -102,6 +102,12 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
if q.deleteStatStmt, err = db.PrepareContext(ctx, deleteStat); err != nil {
return nil, fmt.Errorf("error preparing query DeleteStat: %w", err)
}
if q.deleteTagStmt, err = db.PrepareContext(ctx, deleteTag); err != nil {
return nil, fmt.Errorf("error preparing query DeleteTag: %w", err)
}
if q.deleteTagByObjectStmt, err = db.PrepareContext(ctx, deleteTagByObject); err != nil {
return nil, fmt.Errorf("error preparing query DeleteTagByObject: %w", err)
}
if q.getItemStmt, err = db.PrepareContext(ctx, getItem); err != nil {
return nil, fmt.Errorf("error preparing query GetItem: %w", err)
}
@@ -138,6 +144,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
if q.insertStatStmt, err = db.PrepareContext(ctx, insertStat); err != nil {
return nil, fmt.Errorf("error preparing query InsertStat: %w", err)
}
if q.insertTagStmt, err = db.PrepareContext(ctx, insertTag); err != nil {
return nil, fmt.Errorf("error preparing query InsertTag: %w", err)
}
if q.listItemStatProgressStmt, err = db.PrepareContext(ctx, listItemStatProgress); err != nil {
return nil, fmt.Errorf("error preparing query ListItemStatProgress: %w", err)
}
@@ -171,9 +180,15 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
if q.listProjectRequirementsStmt, err = db.PrepareContext(ctx, listProjectRequirements); err != nil {
return nil, fmt.Errorf("error preparing query ListProjectRequirements: %w", err)
}
if q.listProjectRequirementsByScopeIDStmt, err = db.PrepareContext(ctx, listProjectRequirementsByScopeID); err != nil {
return nil, fmt.Errorf("error preparing query ListProjectRequirementsByScopeID: %w", err)
}
if q.listProjectRequirementsStatsStmt, err = db.PrepareContext(ctx, listProjectRequirementsStats); err != nil {
return nil, fmt.Errorf("error preparing query ListProjectRequirementsStats: %w", err)
}
if q.listProjectRequirementsStatsByScopeIDStmt, err = db.PrepareContext(ctx, listProjectRequirementsStatsByScopeID); err != nil {
return nil, fmt.Errorf("error preparing query ListProjectRequirementsStatsByScopeID: %w", err)
}
if q.listProjectsStmt, err = db.PrepareContext(ctx, listProjects); err != nil {
return nil, fmt.Errorf("error preparing query ListProjects: %w", err)
}
@@ -201,6 +216,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
if q.listStatsStmt, err = db.PrepareContext(ctx, listStats); err != nil {
return nil, fmt.Errorf("error preparing query ListStats: %w", err)
}
if q.listTagsByObjectStmt, err = db.PrepareContext(ctx, listTagsByObject); err != nil {
return nil, fmt.Errorf("error preparing query ListTagsByObject: %w", err)
}
if q.replaceItemStatProgressStmt, err = db.PrepareContext(ctx, replaceItemStatProgress); err != nil {
return nil, fmt.Errorf("error preparing query ReplaceItemStatProgress: %w", err)
}
@@ -366,6 +384,16 @@ func (q *Queries) Close() error {
err = fmt.Errorf("error closing deleteStatStmt: %w", cerr)
}
}
if q.deleteTagStmt != nil {
if cerr := q.deleteTagStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing deleteTagStmt: %w", cerr)
}
}
if q.deleteTagByObjectStmt != nil {
if cerr := q.deleteTagByObjectStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing deleteTagByObjectStmt: %w", cerr)
}
}
if q.getItemStmt != nil {
if cerr := q.getItemStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing getItemStmt: %w", cerr)
@@ -426,6 +454,11 @@ func (q *Queries) Close() error {
err = fmt.Errorf("error closing insertStatStmt: %w", cerr)
}
}
if q.insertTagStmt != nil {
if cerr := q.insertTagStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing insertTagStmt: %w", cerr)
}
}
if q.listItemStatProgressStmt != nil {
if cerr := q.listItemStatProgressStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing listItemStatProgressStmt: %w", cerr)
@@ -481,11 +514,21 @@ func (q *Queries) Close() error {
err = fmt.Errorf("error closing listProjectRequirementsStmt: %w", cerr)
}
}
if q.listProjectRequirementsByScopeIDStmt != nil {
if cerr := q.listProjectRequirementsByScopeIDStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing listProjectRequirementsByScopeIDStmt: %w", cerr)
}
}
if q.listProjectRequirementsStatsStmt != nil {
if cerr := q.listProjectRequirementsStatsStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing listProjectRequirementsStatsStmt: %w", cerr)
}
}
if q.listProjectRequirementsStatsByScopeIDStmt != nil {
if cerr := q.listProjectRequirementsStatsByScopeIDStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing listProjectRequirementsStatsByScopeIDStmt: %w", cerr)
}
}
if q.listProjectsStmt != nil {
if cerr := q.listProjectsStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing listProjectsStmt: %w", cerr)
@@ -531,6 +574,11 @@ func (q *Queries) Close() error {
err = fmt.Errorf("error closing listStatsStmt: %w", cerr)
}
}
if q.listTagsByObjectStmt != nil {
if cerr := q.listTagsByObjectStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing listTagsByObjectStmt: %w", cerr)
}
}
if q.replaceItemStatProgressStmt != nil {
if cerr := q.replaceItemStatProgressStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing replaceItemStatProgressStmt: %w", cerr)
@@ -646,6 +694,8 @@ type Queries struct {
deleteSprintStmt *sql.Stmt
deleteSprintPartStmt *sql.Stmt
deleteStatStmt *sql.Stmt
deleteTagStmt *sql.Stmt
deleteTagByObjectStmt *sql.Stmt
getItemStmt *sql.Stmt
getItemStatProgressBetweenStmt *sql.Stmt
getProjectStmt *sql.Stmt
@@ -658,6 +708,7 @@ type Queries struct {
insertScopeStmt *sql.Stmt
insertSprintStmt *sql.Stmt
insertStatStmt *sql.Stmt
insertTagStmt *sql.Stmt
listItemStatProgressStmt *sql.Stmt
listItemStatProgressMultiStmt *sql.Stmt
listItemsAcquiredBetweenStmt *sql.Stmt
@@ -669,7 +720,9 @@ type Queries struct {
listItemsScheduledBetweenStmt *sql.Stmt
listItemsScheduledBetweenNoScopeStmt *sql.Stmt
listProjectRequirementsStmt *sql.Stmt
listProjectRequirementsByScopeIDStmt *sql.Stmt
listProjectRequirementsStatsStmt *sql.Stmt
listProjectRequirementsStatsByScopeIDStmt *sql.Stmt
listProjectsStmt *sql.Stmt
listScopeMembersStmt *sql.Stmt
listScopeMembersMultiStmt *sql.Stmt
@@ -679,6 +732,7 @@ type Queries struct {
listSprintsAtStmt *sql.Stmt
listSprintsBetweenStmt *sql.Stmt
listStatsStmt *sql.Stmt
listTagsByObjectStmt *sql.Stmt
replaceItemStatProgressStmt *sql.Stmt
replaceProjectRequirementStatStmt *sql.Stmt
replaceScopeMemberStmt *sql.Stmt
@@ -721,6 +775,8 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
deleteSprintStmt: q.deleteSprintStmt,
deleteSprintPartStmt: q.deleteSprintPartStmt,
deleteStatStmt: q.deleteStatStmt,
deleteTagStmt: q.deleteTagStmt,
deleteTagByObjectStmt: q.deleteTagByObjectStmt,
getItemStmt: q.getItemStmt,
getItemStatProgressBetweenStmt: q.getItemStatProgressBetweenStmt,
getProjectStmt: q.getProjectStmt,
@@ -733,6 +789,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
insertScopeStmt: q.insertScopeStmt,
insertSprintStmt: q.insertSprintStmt,
insertStatStmt: q.insertStatStmt,
insertTagStmt: q.insertTagStmt,
listItemStatProgressStmt: q.listItemStatProgressStmt,
listItemStatProgressMultiStmt: q.listItemStatProgressMultiStmt,
listItemsAcquiredBetweenStmt: q.listItemsAcquiredBetweenStmt,
@@ -744,7 +801,9 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
listItemsScheduledBetweenStmt: q.listItemsScheduledBetweenStmt,
listItemsScheduledBetweenNoScopeStmt: q.listItemsScheduledBetweenNoScopeStmt,
listProjectRequirementsStmt: q.listProjectRequirementsStmt,
listProjectRequirementsByScopeIDStmt: q.listProjectRequirementsByScopeIDStmt,
listProjectRequirementsStatsStmt: q.listProjectRequirementsStatsStmt,
listProjectRequirementsStatsByScopeIDStmt: q.listProjectRequirementsStatsByScopeIDStmt,
listProjectsStmt: q.listProjectsStmt,
listScopeMembersStmt: q.listScopeMembersStmt,
listScopeMembersMultiStmt: q.listScopeMembersMultiStmt,
@@ -754,6 +813,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
listSprintsAtStmt: q.listSprintsAtStmt,
listSprintsBetweenStmt: q.listSprintsBetweenStmt,
listStatsStmt: q.listStatsStmt,
listTagsByObjectStmt: q.listTagsByObjectStmt,
replaceItemStatProgressStmt: q.replaceItemStatProgressStmt,
replaceProjectRequirementStatStmt: q.replaceProjectRequirementStatStmt,
replaceScopeMemberStmt: q.replaceScopeMemberStmt,
+7
View File
@@ -84,6 +84,7 @@ type Sprint struct {
Kind int
AggregateName string
AggregateRequired int
TagsCsv sql.NullString
}
type SprintPart struct {
@@ -101,3 +102,9 @@ type Stat struct {
AllowedAmounts sqltypes.NullRawMessage
IsPrimary bool
}
type Tag struct {
ObjectKind int32
ObjectID int
TagName string
}
+124 -23
View File
@@ -22,7 +22,9 @@ func (q *Queries) ClearItemProjectRequirement(ctx context.Context, projectRequir
}
const clearProjectRequirementStatsByStat = `-- name: ClearProjectRequirementStatsByStat :exec
DELETE FROM project_requirement_stat WHERE stat_id = ?
DELETE
FROM project_requirement_stat
WHERE stat_id = ?
`
func (q *Queries) ClearProjectRequirementStatsByStat(ctx context.Context, statID int) error {
@@ -31,7 +33,9 @@ func (q *Queries) ClearProjectRequirementStatsByStat(ctx context.Context, statID
}
const deleteAllProjectRequirementStats = `-- name: DeleteAllProjectRequirementStats :exec
DELETE FROM project_requirement_stat WHERE project_requirement_id = ?
DELETE
FROM project_requirement_stat
WHERE project_requirement_id = ?
`
func (q *Queries) DeleteAllProjectRequirementStats(ctx context.Context, projectRequirementID int) error {
@@ -40,7 +44,9 @@ func (q *Queries) DeleteAllProjectRequirementStats(ctx context.Context, projectR
}
const deleteAllProjectRequirements = `-- name: DeleteAllProjectRequirements :exec
DELETE FROM project_requirement WHERE project_id = ?
DELETE
FROM project_requirement
WHERE project_id = ?
`
func (q *Queries) DeleteAllProjectRequirements(ctx context.Context, projectID int) error {
@@ -49,7 +55,9 @@ func (q *Queries) DeleteAllProjectRequirements(ctx context.Context, projectID in
}
const deleteAllScopeProjectRequirements = `-- name: DeleteAllScopeProjectRequirements :exec
DELETE FROM project_requirement WHERE scope_id = ?
DELETE
FROM project_requirement
WHERE scope_id = ?
`
func (q *Queries) DeleteAllScopeProjectRequirements(ctx context.Context, scopeID int) error {
@@ -58,7 +66,9 @@ func (q *Queries) DeleteAllScopeProjectRequirements(ctx context.Context, scopeID
}
const deleteAllScopeProjects = `-- name: DeleteAllScopeProjects :exec
DELETE FROM project WHERE scope_id = ?
DELETE
FROM project
WHERE scope_id = ?
`
func (q *Queries) DeleteAllScopeProjects(ctx context.Context, scopeID int) error {
@@ -67,7 +77,10 @@ func (q *Queries) DeleteAllScopeProjects(ctx context.Context, scopeID int) error
}
const deleteProject = `-- name: DeleteProject :exec
DELETE FROM project WHERE id = ? AND scope_id = ?
DELETE
FROM project
WHERE id = ?
AND scope_id = ?
`
type DeleteProjectParams struct {
@@ -81,7 +94,10 @@ func (q *Queries) DeleteProject(ctx context.Context, arg DeleteProjectParams) er
}
const deleteProjectRequirement = `-- name: DeleteProjectRequirement :exec
DELETE FROM project_requirement WHERE id = ? AND scope_id = ?
DELETE
FROM project_requirement
WHERE id = ?
AND scope_id = ?
`
type DeleteProjectRequirementParams struct {
@@ -95,7 +111,10 @@ func (q *Queries) DeleteProjectRequirement(ctx context.Context, arg DeleteProjec
}
const deleteProjectRequirementStat = `-- name: DeleteProjectRequirementStat :exec
DELETE FROM project_requirement_stat WHERE project_requirement_id = ? AND stat_id = ?
DELETE
FROM project_requirement_stat
WHERE project_requirement_id = ?
AND stat_id = ?
`
type DeleteProjectRequirementStatParams struct {
@@ -109,7 +128,10 @@ func (q *Queries) DeleteProjectRequirementStat(ctx context.Context, arg DeletePr
}
const getProject = `-- name: GetProject :one
SELECT id, scope_id, owner_id, name, status, description, created_time FROM project WHERE id = ? AND scope_id = ?
SELECT id, scope_id, owner_id, name, status, description, created_time
FROM project
WHERE id = ?
AND scope_id = ?
`
type GetProjectParams struct {
@@ -183,7 +205,9 @@ func (q *Queries) InsertProjectRequirement(ctx context.Context, arg InsertProjec
}
const listProjectRequirements = `-- name: ListProjectRequirements :many
SELECT id, scope_id, project_id, name, status, description, is_coarse, aggregate_required FROM project_requirement WHERE project_id = ?
SELECT id, scope_id, project_id, name, status, description, is_coarse, aggregate_required
FROM project_requirement
WHERE project_id = ?
`
func (q *Queries) ListProjectRequirements(ctx context.Context, projectID int) ([]ProjectRequirement, error) {
@@ -218,9 +242,48 @@ func (q *Queries) ListProjectRequirements(ctx context.Context, projectID int) ([
return items, nil
}
const listProjectRequirementsByScopeID = `-- name: ListProjectRequirementsByScopeID :many
SELECT id, scope_id, project_id, name, status, description, is_coarse, aggregate_required
FROM project_requirement
WHERE scope_id = ?
`
func (q *Queries) ListProjectRequirementsByScopeID(ctx context.Context, scopeID int) ([]ProjectRequirement, error) {
rows, err := q.query(ctx, q.listProjectRequirementsByScopeIDStmt, listProjectRequirementsByScopeID, scopeID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ProjectRequirement{}
for rows.Next() {
var i ProjectRequirement
if err := rows.Scan(
&i.ID,
&i.ScopeID,
&i.ProjectID,
&i.Name,
&i.Status,
&i.Description,
&i.IsCoarse,
&i.AggregateRequired,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listProjectRequirementsStats = `-- name: ListProjectRequirementsStats :many
SELECT prs.project_requirement_id, prs.stat_id, prs.required FROM project_requirement pr
RIGHT JOIN project_requirement_stat prs ON pr.id = prs.project_requirement_id
SELECT prs.project_requirement_id, prs.stat_id, prs.required
FROM project_requirement pr
RIGHT JOIN project_requirement_stat prs ON pr.id = prs.project_requirement_id
WHERE pr.project_id = ?
`
@@ -247,8 +310,41 @@ func (q *Queries) ListProjectRequirementsStats(ctx context.Context, projectID in
return items, nil
}
const listProjectRequirementsStatsByScopeID = `-- name: ListProjectRequirementsStatsByScopeID :many
SELECT prs.project_requirement_id, prs.stat_id, prs.required
FROM project_requirement pr
RIGHT JOIN project_requirement_stat prs ON pr.id = prs.project_requirement_id
WHERE pr.scope_id = ?
`
func (q *Queries) ListProjectRequirementsStatsByScopeID(ctx context.Context, scopeID int) ([]ProjectRequirementStat, error) {
rows, err := q.query(ctx, q.listProjectRequirementsStatsByScopeIDStmt, listProjectRequirementsStatsByScopeID, scopeID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ProjectRequirementStat{}
for rows.Next() {
var i ProjectRequirementStat
if err := rows.Scan(&i.ProjectRequirementID, &i.StatID, &i.Required); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listProjects = `-- name: ListProjects :many
SELECT id, scope_id, owner_id, name, status, description, created_time FROM project WHERE scope_id = ? ORDER BY status, name
SELECT id, scope_id, owner_id, name, status, description, created_time
FROM project
WHERE scope_id = ?
ORDER BY status, name
`
func (q *Queries) ListProjects(ctx context.Context, scopeID int) ([]Project, error) {
@@ -300,11 +396,12 @@ func (q *Queries) ReplaceProjectRequirementStat(ctx context.Context, arg Replace
const updateProject = `-- name: UpdateProject :exec
UPDATE project
SET owner_id = ?,
name = ?,
status = ?,
SET owner_id = ?,
name = ?,
status = ?,
description = ?
WHERE id = ? AND scope_id = ?
WHERE id = ?
AND scope_id = ?
`
type UpdateProjectParams struct {
@@ -330,12 +427,14 @@ func (q *Queries) UpdateProject(ctx context.Context, arg UpdateProjectParams) er
const updateProjectRequirement = `-- name: UpdateProjectRequirement :exec
UPDATE project_requirement
SET name = ?,
status = ?,
description = ?,
is_coarse = ?,
aggregate_required = ?
WHERE id = ? AND scope_id = ?
SET name = ?,
status = ?,
description = ?,
is_coarse = ?,
aggregate_required = ?,
project_id = ?
WHERE id = ?
AND scope_id = ?
`
type UpdateProjectRequirementParams struct {
@@ -344,6 +443,7 @@ type UpdateProjectRequirementParams struct {
Description string
IsCoarse bool
AggregateRequired int
ProjectID int
ID int
ScopeID int
}
@@ -355,6 +455,7 @@ func (q *Queries) UpdateProjectRequirement(ctx context.Context, arg UpdateProjec
arg.Description,
arg.IsCoarse,
arg.AggregateRequired,
arg.ProjectID,
arg.ID,
arg.ScopeID,
)
+48 -21
View File
@@ -12,7 +12,9 @@ import (
)
const deleteAllScopeSprints = `-- name: DeleteAllScopeSprints :exec
DELETE FROM sprint WHERE scope_id = ?
DELETE
FROM sprint
WHERE scope_id = ?
`
func (q *Queries) DeleteAllScopeSprints(ctx context.Context, scopeID int) error {
@@ -21,7 +23,9 @@ func (q *Queries) DeleteAllScopeSprints(ctx context.Context, scopeID int) error
}
const deleteAllSprintParts = `-- name: DeleteAllSprintParts :exec
DELETE FROM sprint_part WHERE sprint_id = ?
DELETE
FROM sprint_part
WHERE sprint_id = ?
`
func (q *Queries) DeleteAllSprintParts(ctx context.Context, sprintID int) error {
@@ -30,7 +34,9 @@ func (q *Queries) DeleteAllSprintParts(ctx context.Context, sprintID int) error
}
const deleteSprint = `-- name: DeleteSprint :exec
DELETE FROM sprint WHERE id = ?
DELETE
FROM sprint
WHERE id = ?
`
func (q *Queries) DeleteSprint(ctx context.Context, id int) error {
@@ -39,7 +45,10 @@ func (q *Queries) DeleteSprint(ctx context.Context, id int) error {
}
const deleteSprintPart = `-- name: DeleteSprintPart :exec
DELETE FROM sprint_part WHERE sprint_id = ? AND object_id = ?
DELETE
FROM sprint_part
WHERE sprint_id = ?
AND object_id = ?
`
type DeleteSprintPartParams struct {
@@ -53,7 +62,10 @@ func (q *Queries) DeleteSprintPart(ctx context.Context, arg DeleteSprintPartPara
}
const getSprint = `-- name: GetSprint :one
SELECT id, scope_id, name, description, from_time, to_time, is_timed, is_coarse, is_unweighted, kind, aggregate_name, aggregate_required FROM sprint WHERE id = ? AND scope_id = ?
SELECT id, scope_id, name, description, from_time, to_time, is_timed, is_coarse, is_unweighted, kind, aggregate_name, aggregate_required, tags_csv
FROM sprint
WHERE id = ?
AND scope_id = ?
`
type GetSprintParams struct {
@@ -77,15 +89,16 @@ func (q *Queries) GetSprint(ctx context.Context, arg GetSprintParams) (Sprint, e
&i.Kind,
&i.AggregateName,
&i.AggregateRequired,
&i.TagsCsv,
)
return i, err
}
const insertSprint = `-- name: InsertSprint :execresult
INSERT INTO sprint (
scope_id, name, description, kind, from_time, to_time,
is_timed, is_coarse, aggregate_name, aggregate_required, is_unweighted
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO sprint (scope_id, name, description, kind, from_time, to_time,
is_timed, is_coarse, aggregate_name, aggregate_required,
is_unweighted, tags_csv)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
type InsertSprintParams struct {
@@ -100,6 +113,7 @@ type InsertSprintParams struct {
AggregateName string
AggregateRequired int
IsUnweighted bool
TagsCsv sql.NullString
}
func (q *Queries) InsertSprint(ctx context.Context, arg InsertSprintParams) (sql.Result, error) {
@@ -115,11 +129,14 @@ func (q *Queries) InsertSprint(ctx context.Context, arg InsertSprintParams) (sql
arg.AggregateName,
arg.AggregateRequired,
arg.IsUnweighted,
arg.TagsCsv,
)
}
const listSprintParts = `-- name: ListSprintParts :many
SELECT sprint_id, object_id, required FROM sprint_part WHERE sprint_id = ?
SELECT sprint_id, object_id, required
FROM sprint_part
WHERE sprint_id = ?
`
func (q *Queries) ListSprintParts(ctx context.Context, sprintID int) ([]SprintPart, error) {
@@ -146,7 +163,11 @@ func (q *Queries) ListSprintParts(ctx context.Context, sprintID int) ([]SprintPa
}
const listSprintsAt = `-- name: ListSprintsAt :many
SELECT id, scope_id, name, description, from_time, to_time, is_timed, is_coarse, is_unweighted, kind, aggregate_name, aggregate_required FROM sprint WHERE scope_id = ? AND from_time <= ? AND to_time > ?
SELECT id, scope_id, name, description, from_time, to_time, is_timed, is_coarse, is_unweighted, kind, aggregate_name, aggregate_required, tags_csv
FROM sprint
WHERE scope_id = ?
AND from_time <= ?
AND to_time > ?
`
type ListSprintsAtParams struct {
@@ -176,6 +197,7 @@ func (q *Queries) ListSprintsAt(ctx context.Context, arg ListSprintsAtParams) ([
&i.Kind,
&i.AggregateName,
&i.AggregateRequired,
&i.TagsCsv,
); err != nil {
return nil, err
}
@@ -191,7 +213,8 @@ func (q *Queries) ListSprintsAt(ctx context.Context, arg ListSprintsAtParams) ([
}
const listSprintsBetween = `-- name: ListSprintsBetween :many
SELECT id, scope_id, name, description, from_time, to_time, is_timed, is_coarse, is_unweighted, kind, aggregate_name, aggregate_required FROM sprint
SELECT id, scope_id, name, description, from_time, to_time, is_timed, is_coarse, is_unweighted, kind, aggregate_name, aggregate_required, tags_csv
FROM sprint
WHERE scope_id = ?
AND from_time < ?
AND to_time >= ?
@@ -226,6 +249,7 @@ func (q *Queries) ListSprintsBetween(ctx context.Context, arg ListSprintsBetween
&i.Kind,
&i.AggregateName,
&i.AggregateRequired,
&i.TagsCsv,
); err != nil {
return nil, err
}
@@ -258,15 +282,16 @@ func (q *Queries) ReplaceSprintPart(ctx context.Context, arg ReplaceSprintPartPa
const updateSprint = `-- name: UpdateSprint :exec
UPDATE sprint
SET name = ?,
description = ?,
from_time = ?,
to_time = ?,
is_timed = ?,
is_coarse = ?,
is_unweighted = ?,
aggregate_name = ?,
aggregate_required = ?
SET name = ?,
description = ?,
from_time = ?,
to_time = ?,
is_timed = ?,
is_coarse = ?,
is_unweighted = ?,
aggregate_name = ?,
aggregate_required = ?,
tags_csv = ?
WHERE id = ?
`
@@ -280,6 +305,7 @@ type UpdateSprintParams struct {
IsUnweighted bool
AggregateName string
AggregateRequired int
TagsCsv sql.NullString
ID int
}
@@ -294,6 +320,7 @@ func (q *Queries) UpdateSprint(ctx context.Context, arg UpdateSprintParams) erro
arg.IsUnweighted,
arg.AggregateName,
arg.AggregateRequired,
arg.TagsCsv,
arg.ID,
)
return err
+97
View File
@@ -0,0 +1,97 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.13.0
// source: tag.sql
package mysqlcore
import (
"context"
)
const deleteTag = `-- name: DeleteTag :exec
DELETE
FROM tag
WHERE object_kind = ?
AND object_id = ?
AND tag_name = ?
`
type DeleteTagParams struct {
ObjectKind int32
ObjectID int
TagName string
}
func (q *Queries) DeleteTag(ctx context.Context, arg DeleteTagParams) error {
_, err := q.exec(ctx, q.deleteTagStmt, deleteTag, arg.ObjectKind, arg.ObjectID, arg.TagName)
return err
}
const deleteTagByObject = `-- name: DeleteTagByObject :exec
DELETE
FROM tag
WHERE object_kind = ?
AND object_id = ?
`
type DeleteTagByObjectParams struct {
ObjectKind int32
ObjectID int
}
func (q *Queries) DeleteTagByObject(ctx context.Context, arg DeleteTagByObjectParams) error {
_, err := q.exec(ctx, q.deleteTagByObjectStmt, deleteTagByObject, arg.ObjectKind, arg.ObjectID)
return err
}
const insertTag = `-- name: InsertTag :exec
INSERT INTO tag (object_kind, object_id, tag_name)
VALUES (?, ?, ?)
`
type InsertTagParams struct {
ObjectKind int32
ObjectID int
TagName string
}
func (q *Queries) InsertTag(ctx context.Context, arg InsertTagParams) error {
_, err := q.exec(ctx, q.insertTagStmt, insertTag, arg.ObjectKind, arg.ObjectID, arg.TagName)
return err
}
const listTagsByObject = `-- name: ListTagsByObject :many
SELECT tag_name
FROM tag
WHERE object_kind = ?
AND object_id = ?
`
type ListTagsByObjectParams struct {
ObjectKind int32
ObjectID int
}
func (q *Queries) ListTagsByObject(ctx context.Context, arg ListTagsByObjectParams) ([]string, error) {
rows, err := q.query(ctx, q.listTagsByObjectStmt, listTagsByObject, arg.ObjectKind, arg.ObjectID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []string{}
for rows.Next() {
var tag_name string
if err := rows.Scan(&tag_name); err != nil {
return nil, err
}
items = append(items, tag_name)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+280 -4
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"git.aiterp.net/stufflog3/stufflog3/entities"
"git.aiterp.net/stufflog3/stufflog3/internal/genutils"
"git.aiterp.net/stufflog3/stufflog3/models"
"git.aiterp.net/stufflog3/stufflog3/ports/mysql/mysqlcore"
"github.com/Masterminds/squirrel"
@@ -24,6 +25,11 @@ func (r *projectRepository) Find(ctx context.Context, scopeID, projectID int) (*
return nil, err
}
tags, err := r.q.ListTagsByObject(ctx, mysqlcore.ListTagsByObjectParams{
ObjectKind: tagObjectKindProject,
ObjectID: row.ID,
})
return &entities.Project{
ID: row.ID,
ScopeID: row.ScopeID,
@@ -32,6 +38,7 @@ func (r *projectRepository) Find(ctx context.Context, scopeID, projectID int) (*
Name: row.Name,
Description: row.Description,
Status: models.Status(row.Status),
Tags: tags,
}, nil
}
@@ -78,6 +85,8 @@ func (r *projectRepository) FetchProjects(ctx context.Context, scopeID int, ids
return nil, err
}
project.Tags = []string{}
projects = append(projects, project)
}
if err := rows.Close(); err != nil {
@@ -87,6 +96,18 @@ func (r *projectRepository) FetchProjects(ctx context.Context, scopeID int, ids
return nil, err
}
// Fill tags
err = fetchTags(ctx, r.db, tagObjectKindProject, ids, func(id int, tag string) {
for i := range projects {
if projects[i].ID == id {
projects[i].Tags = append(projects[i].Tags, tag)
}
}
})
if err != nil {
return nil, err
}
return projects, nil
}
@@ -101,6 +122,7 @@ func (r *projectRepository) List(ctx context.Context, scopeID int) ([]entities.P
}
res := make([]entities.Project, 0, len(rows))
ids := make([]int, 0, len(rows))
for _, row := range rows {
res = append(res, entities.Project{
ID: row.ID,
@@ -110,14 +132,74 @@ func (r *projectRepository) List(ctx context.Context, scopeID int) ([]entities.P
Name: row.Name,
Description: row.Description,
Status: models.Status(row.Status),
Tags: []string{},
})
ids = append(ids, row.ID)
}
// Fill tags
err = fetchTags(ctx, r.db, tagObjectKindProject, ids, func(id int, tag string) {
for i := range res {
if id == res[i].ID {
res[i].Tags = append(res[i].Tags, tag)
}
}
})
if err != nil {
return nil, err
}
return res, nil
}
func (r *projectRepository) ListByTags(ctx context.Context, scopeID int, tags []string) ([]entities.Project, error) {
query, args, err := squirrel.Select("object_id, tag_name").
From("tag").
Where(squirrel.Eq{"tag_name": tags, "object_kind": tagObjectKindProject}).
ToSql()
if err != nil {
return nil, err
}
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
ids := make([]int, 0, 16)
matches := make(map[int]int, 64)
for rows.Next() {
var objectID int
var tagName string
err := rows.Scan(&objectID, &tagName)
if err != nil {
return nil, err
}
if matches[objectID] == 0 {
ids = append(ids, objectID)
}
matches[objectID] += 1
}
err = rows.Close()
if err != nil {
return nil, err
}
ids = genutils.RetainInPlace(ids, func(id int) bool {
return matches[id] == len(tags)
})
return r.FetchProjects(ctx, scopeID, ids...)
}
func (r *projectRepository) Insert(ctx context.Context, project entities.Project) (*entities.Project, error) {
res, err := r.q.InsertProject(ctx, mysqlcore.InsertProjectParams{
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
q := r.q.WithTx(tx)
res, err := q.InsertProject(ctx, mysqlcore.InsertProjectParams{
ScopeID: project.ScopeID,
OwnerID: project.OwnerID,
Name: project.Name,
@@ -134,13 +216,37 @@ func (r *projectRepository) Insert(ctx context.Context, project entities.Project
}
project.ID = int(id)
for _, tag := range project.Tags {
err := q.InsertTag(ctx, mysqlcore.InsertTagParams{
ObjectKind: tagObjectKindProject,
ObjectID: project.ID,
TagName: tag,
})
if err != nil {
return nil, err
}
}
err = tx.Commit()
if err != nil {
return nil, err
}
return &project, nil
}
func (r *projectRepository) Update(ctx context.Context, project entities.Project, update models.ProjectUpdate) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
q := r.q.WithTx(tx)
project.Update(update)
return r.q.UpdateProject(ctx, mysqlcore.UpdateProjectParams{
err = q.UpdateProject(ctx, mysqlcore.UpdateProjectParams{
OwnerID: project.OwnerID,
Name: project.Name,
Status: int(project.Status),
@@ -149,6 +255,33 @@ func (r *projectRepository) Update(ctx context.Context, project entities.Project
ID: project.ID,
ScopeID: project.ScopeID,
})
if err != nil {
return err
}
for _, tag := range update.RemoveTags {
err := q.DeleteTag(ctx, mysqlcore.DeleteTagParams{
ObjectKind: tagObjectKindProject,
ObjectID: project.ID,
TagName: tag,
})
if err != nil {
return err
}
}
for _, tag := range update.AddTags {
err = q.InsertTag(ctx, mysqlcore.InsertTagParams{
ObjectKind: tagObjectKindProject,
ObjectID: project.ID,
TagName: tag,
})
if err != nil {
return err
}
}
return tx.Commit()
}
func (r *projectRepository) Delete(ctx context.Context, project entities.Project) error {
@@ -174,6 +307,22 @@ func (r *projectRepository) Delete(ctx context.Context, project entities.Project
if err != nil {
return err
}
err = q.DeleteTagByObject(ctx, mysqlcore.DeleteTagByObjectParams{
ObjectKind: tagObjectKindRequirement,
ObjectID: req.ID,
})
if err != nil {
return err
}
}
err = q.DeleteTagByObject(ctx, mysqlcore.DeleteTagByObjectParams{
ObjectKind: tagObjectKindProject,
ObjectID: project.ID,
})
if err != nil {
return err
}
err = q.DeleteAllProjectRequirements(ctx, project.ID)
@@ -226,10 +375,25 @@ func (r *projectRepository) FetchRequirements(ctx context.Context, scopeID int,
return nil, nil, err
}
requirement.Tags = []string{}
requirements = append(requirements, requirement)
ids = append(ids, requirement.ID)
}
// Fill tags
err = fetchTags(ctx, r.db, tagObjectKindRequirement, ids, func(id int, tag string) {
for i := range requirements {
if id == requirements[i].ID {
requirements[i].Tags = append(requirements[i].Tags, tag)
break
}
}
})
if err != nil {
return nil, nil, err
}
query, args, err = squirrel.Select("project_requirement_id, stat_id, required").
From("project_requirement_stat").
Where(squirrel.Eq{"project_requirement_id": requirementIDs}).
@@ -258,6 +422,35 @@ func (r *projectRepository) FetchRequirements(ctx context.Context, scopeID int,
return requirements, stats, nil
}
func (r *projectRepository) ListRequirementsByScope(ctx context.Context, scopeID int) ([]entities.Requirement, []entities.RequirementStat, []entities.Project, error) {
reqRows, err := r.q.ListProjectRequirementsByScopeID(ctx, scopeID)
if err != nil && err != sql.ErrNoRows {
return nil, nil, nil, err
}
statsRows, err := r.q.ListProjectRequirementsStatsByScopeID(ctx, scopeID)
if err != nil && err != sql.ErrNoRows {
return nil, nil, nil, err
}
ids := genutils.Set[int]{}
for _, reqRow := range reqRows {
ids.Add(reqRow.ProjectID)
}
projects, err := r.FetchProjects(ctx, scopeID, ids.Values()...)
if err != nil && err != sql.ErrNoRows {
return nil, nil, nil, err
}
req, stats, err := r.fillRequirements(ctx, reqRows, statsRows)
if err != nil {
return nil, nil, nil, err
}
return req, stats, projects, err
}
func (r *projectRepository) ListRequirements(ctx context.Context, projectID int) ([]entities.Requirement, []entities.RequirementStat, error) {
reqRows, err := r.q.ListProjectRequirements(ctx, projectID)
if err != nil && err != sql.ErrNoRows {
@@ -269,7 +462,12 @@ func (r *projectRepository) ListRequirements(ctx context.Context, projectID int)
return nil, nil, err
}
return r.fillRequirements(ctx, reqRows, statsRows)
}
func (r *projectRepository) fillRequirements(ctx context.Context, reqRows []mysqlcore.ProjectRequirement, statsRows []mysqlcore.ProjectRequirementStat) ([]entities.Requirement, []entities.RequirementStat, error) {
requirements := make([]entities.Requirement, 0, len(reqRows))
ids := make([]int, 0, len(reqRows))
for _, row := range reqRows {
requirements = append(requirements, entities.Requirement{
ID: row.ID,
@@ -280,7 +478,22 @@ func (r *projectRepository) ListRequirements(ctx context.Context, projectID int)
IsCoarse: row.IsCoarse,
AggregateRequired: row.AggregateRequired,
Status: models.Status(row.Status),
Tags: []string{},
})
ids = append(ids, row.ID)
}
// Fill tags
err := fetchTags(ctx, r.db, tagObjectKindRequirement, ids, func(id int, tag string) {
for i := range requirements {
if id == requirements[i].ID {
requirements[i].Tags = append(requirements[i].Tags, tag)
break
}
}
})
if err != nil {
return nil, nil, err
}
stats := make([]entities.RequirementStat, 0, len(statsRows))
@@ -296,7 +509,14 @@ func (r *projectRepository) ListRequirements(ctx context.Context, projectID int)
}
func (r *projectRepository) CreateRequirement(ctx context.Context, requirement entities.Requirement) (*entities.Requirement, error) {
res, err := r.q.InsertProjectRequirement(ctx, mysqlcore.InsertProjectRequirementParams{
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
q := r.q.WithTx(tx)
res, err := q.InsertProjectRequirement(ctx, mysqlcore.InsertProjectRequirementParams{
ScopeID: requirement.ScopeID,
ProjectID: requirement.ProjectID,
Name: requirement.Name,
@@ -314,14 +534,37 @@ func (r *projectRepository) CreateRequirement(ctx context.Context, requirement e
return nil, err
}
for _, tag := range requirement.Tags {
err := q.InsertTag(ctx, mysqlcore.InsertTagParams{
ObjectKind: tagObjectKindRequirement,
ObjectID: int(id),
TagName: tag,
})
if err != nil {
return nil, err
}
}
err = tx.Commit()
if err != nil {
return nil, err
}
requirement.ID = int(id)
return &requirement, nil
}
func (r *projectRepository) UpdateRequirement(ctx context.Context, requirement entities.Requirement, update models.RequirementUpdate) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
q := r.q.WithTx(tx)
requirement.Update(update)
return r.q.UpdateProjectRequirement(ctx, mysqlcore.UpdateProjectRequirementParams{
_ = q.UpdateProjectRequirement(ctx, mysqlcore.UpdateProjectRequirementParams{
Name: requirement.Name,
Status: int(requirement.Status),
Description: requirement.Description,
@@ -329,7 +572,32 @@ func (r *projectRepository) UpdateRequirement(ctx context.Context, requirement e
ID: requirement.ID,
ScopeID: requirement.ScopeID,
AggregateRequired: requirement.AggregateRequired,
ProjectID: requirement.ProjectID,
})
for _, tag := range update.RemoveTags {
err := q.DeleteTag(ctx, mysqlcore.DeleteTagParams{
ObjectKind: tagObjectKindRequirement,
ObjectID: requirement.ID,
TagName: tag,
})
if err != nil {
return err
}
}
for _, tag := range update.AddTags {
err = q.InsertTag(ctx, mysqlcore.InsertTagParams{
ObjectKind: tagObjectKindRequirement,
ObjectID: requirement.ID,
TagName: tag,
})
if err != nil {
return err
}
}
return tx.Commit()
}
func (r *projectRepository) DeleteRequirement(ctx context.Context, requirement entities.Requirement) error {
@@ -358,6 +626,14 @@ func (r *projectRepository) DeleteRequirement(ctx context.Context, requirement e
return err
}
err = q.DeleteTagByObject(ctx, mysqlcore.DeleteTagByObjectParams{
ObjectKind: tagObjectKindRequirement,
ObjectID: requirement.ID,
})
if err != nil {
return err
}
return tx.Commit()
}
+65 -23
View File
@@ -1,8 +1,14 @@
-- name: ListProjects :many
SELECT * FROM project WHERE scope_id = ? ORDER BY status, name;
SELECT *
FROM project
WHERE scope_id = ?
ORDER BY status, name;
-- name: GetProject :one
SELECT * FROM project WHERE id = ? AND scope_id = ?;
SELECT *
FROM project
WHERE id = ?
AND scope_id = ?;
-- name: InsertProject :execresult
INSERT INTO project (scope_id, owner_id, name, status, description)
@@ -10,17 +16,28 @@ VALUES (?, ?, ?, ?, ?);
-- name: UpdateProject :exec
UPDATE project
SET owner_id = ?,
name = ?,
status = ?,
SET owner_id = ?,
name = ?,
status = ?,
description = ?
WHERE id = ? AND scope_id = ?;
WHERE id = ?
AND scope_id = ?;
-- name: DeleteProject :exec
DELETE FROM project WHERE id = ? AND scope_id = ?;
DELETE
FROM project
WHERE id = ?
AND scope_id = ?;
-- name: ListProjectRequirements :many
SELECT * FROM project_requirement WHERE project_id = ?;
SELECT *
FROM project_requirement
WHERE project_id = ?;
-- name: ListProjectRequirementsByScopeID :many
SELECT *
FROM project_requirement
WHERE scope_id = ?;
-- name: InsertProjectRequirement :execresult
INSERT INTO project_requirement (scope_id, project_id, name, status, description, is_coarse, aggregate_required)
@@ -28,18 +45,25 @@ VALUES (?, ?, ?, ?, ?, ?, ?);
-- name: UpdateProjectRequirement :exec
UPDATE project_requirement
SET name = ?,
status = ?,
description = ?,
is_coarse = ?,
aggregate_required = ?
WHERE id = ? AND scope_id = ?;
SET name = ?,
status = ?,
description = ?,
is_coarse = ?,
aggregate_required = ?,
project_id = ?
WHERE id = ?
AND scope_id = ?;
-- name: DeleteProjectRequirement :exec
DELETE FROM project_requirement WHERE id = ? AND scope_id = ?;
DELETE
FROM project_requirement
WHERE id = ?
AND scope_id = ?;
-- name: DeleteAllProjectRequirements :exec
DELETE FROM project_requirement WHERE project_id = ?;
DELETE
FROM project_requirement
WHERE project_id = ?;
-- name: ClearItemProjectRequirement :exec
UPDATE item
@@ -47,25 +71,43 @@ SET project_requirement_id = NULL
WHERE project_requirement_id = ?;
-- name: ListProjectRequirementsStats :many
SELECT prs.* FROM project_requirement pr
RIGHT JOIN project_requirement_stat prs ON pr.id = prs.project_requirement_id
SELECT prs.*
FROM project_requirement pr
RIGHT JOIN project_requirement_stat prs ON pr.id = prs.project_requirement_id
WHERE pr.project_id = ?;
-- name: ListProjectRequirementsStatsByScopeID :many
SELECT prs.*
FROM project_requirement pr
RIGHT JOIN project_requirement_stat prs ON pr.id = prs.project_requirement_id
WHERE pr.scope_id = ?;
-- name: ReplaceProjectRequirementStat :exec
REPLACE INTO project_requirement_stat (project_requirement_id, stat_id, required)
VALUES (?, ?, ?);
-- name: DeleteProjectRequirementStat :exec
DELETE FROM project_requirement_stat WHERE project_requirement_id = ? AND stat_id = ?;
DELETE
FROM project_requirement_stat
WHERE project_requirement_id = ?
AND stat_id = ?;
-- name: DeleteAllProjectRequirementStats :exec
DELETE FROM project_requirement_stat WHERE project_requirement_id = ?;
DELETE
FROM project_requirement_stat
WHERE project_requirement_id = ?;
-- name: ClearProjectRequirementStatsByStat :exec
DELETE FROM project_requirement_stat WHERE stat_id = ?;
DELETE
FROM project_requirement_stat
WHERE stat_id = ?;
-- name: DeleteAllScopeProjects :exec
DELETE FROM project WHERE scope_id = ?;
DELETE
FROM project
WHERE scope_id = ?;
-- name: DeleteAllScopeProjectRequirements :exec
DELETE FROM project_requirement WHERE scope_id = ?;
DELETE
FROM project_requirement
WHERE scope_id = ?;
+41 -21
View File
@@ -1,50 +1,70 @@
-- name: ListSprintsAt :many
SELECT * FROM sprint WHERE scope_id = ? AND from_time <= sqlc.arg(time) AND to_time > sqlc.arg(time);
SELECT *
FROM sprint
WHERE scope_id = ?
AND from_time <= sqlc.arg(time)
AND to_time > sqlc.arg(time);
-- name: ListSprintsBetween :many
SELECT * FROM sprint
SELECT *
FROM sprint
WHERE scope_id = sqlc.arg(scope_id)
AND from_time < sqlc.arg(to_time)
AND to_time >= sqlc.arg(from_time)
ORDER BY from_time, name;
-- name: GetSprint :one
SELECT * FROM sprint WHERE id = ? AND scope_id = ?;
SELECT *
FROM sprint
WHERE id = ?
AND scope_id = ?;
-- name: InsertSprint :execresult
INSERT INTO sprint (
scope_id, name, description, kind, from_time, to_time,
is_timed, is_coarse, aggregate_name, aggregate_required, is_unweighted
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
INSERT INTO sprint (scope_id, name, description, kind, from_time, to_time,
is_timed, is_coarse, aggregate_name, aggregate_required,
is_unweighted, tags_csv)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
-- name: UpdateSprint :exec
UPDATE sprint
SET name = ?,
description = ?,
from_time = ?,
to_time = ?,
is_timed = ?,
is_coarse = ?,
is_unweighted = ?,
aggregate_name = ?,
aggregate_required = ?
SET name = ?,
description = ?,
from_time = ?,
to_time = ?,
is_timed = ?,
is_coarse = ?,
is_unweighted = ?,
aggregate_name = ?,
aggregate_required = ?,
tags_csv = ?
WHERE id = ?;
-- name: DeleteSprint :exec
DELETE FROM sprint WHERE id = ?;
DELETE
FROM sprint
WHERE id = ?;
-- name: DeleteAllScopeSprints :exec
DELETE FROM sprint WHERE scope_id = ?;
DELETE
FROM sprint
WHERE scope_id = ?;
-- name: ListSprintParts :many
SELECT * FROM sprint_part WHERE sprint_id = ?;
SELECT *
FROM sprint_part
WHERE sprint_id = ?;
-- name: ReplaceSprintPart :exec
REPLACE INTO sprint_part (sprint_id, object_id, required)
VALUES (?, ?, ?);
-- name: DeleteSprintPart :exec
DELETE FROM sprint_part WHERE sprint_id = ? AND object_id = ?;
DELETE
FROM sprint_part
WHERE sprint_id = ?
AND object_id = ?;
-- name: DeleteAllSprintParts :exec
DELETE FROM sprint_part WHERE sprint_id = ?;
DELETE
FROM sprint_part
WHERE sprint_id = ?;
+22
View File
@@ -0,0 +1,22 @@
-- name: InsertTag :exec
INSERT INTO tag (object_kind, object_id, tag_name)
VALUES (?, ?, ?);
-- name: DeleteTag :exec
DELETE
FROM tag
WHERE object_kind = ?
AND object_id = ?
AND tag_name = ?;
-- name: DeleteTagByObject :exec
DELETE
FROM tag
WHERE object_kind = ?
AND object_id = ?;
-- name: ListTagsByObject :many
SELECT tag_name
FROM tag
WHERE object_kind = ?
AND object_id = ?;
+5
View File
@@ -39,6 +39,7 @@ func (r *sprintRepository) Find(ctx context.Context, scopeID, sprintID int) (*en
IsUnweighted: row.IsUnweighted,
AggregateName: row.AggregateName,
AggregateRequired: row.AggregateRequired,
Tags: csvPtr(row.TagsCsv),
}, nil
}
@@ -67,6 +68,7 @@ func (r *sprintRepository) ListAt(ctx context.Context, scopeID int, at time.Time
IsUnweighted: row.IsUnweighted,
AggregateName: row.AggregateName,
AggregateRequired: row.AggregateRequired,
Tags: csvPtr(row.TagsCsv),
})
}
@@ -102,6 +104,7 @@ func (r *sprintRepository) ListBetween(ctx context.Context, scopeID int, from, t
IsUnweighted: row.IsUnweighted,
AggregateName: row.AggregateName,
AggregateRequired: row.AggregateRequired,
Tags: csvPtr(row.TagsCsv),
})
}
@@ -121,6 +124,7 @@ func (r *sprintRepository) Insert(ctx context.Context, sprint entities.Sprint) (
AggregateName: sprint.AggregateName,
AggregateRequired: sprint.AggregateRequired,
IsUnweighted: sprint.IsUnweighted,
TagsCsv: sqlCsvPtr(sprint.Tags),
})
if err != nil {
return nil, err
@@ -147,6 +151,7 @@ func (r *sprintRepository) Update(ctx context.Context, sprint entities.Sprint, u
IsUnweighted: sprint.IsUnweighted,
AggregateName: sprint.AggregateName,
AggregateRequired: sprint.AggregateRequired,
TagsCsv: sqlCsvPtr(sprint.Tags),
ID: sprint.ID,
})
@@ -0,0 +1,16 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE tag (
object_kind TINYINT NOT NULL,
object_id INT NOT NULL,
tag_name VARCHAR(255) NOT NULL,
PRIMARY KEY (object_kind, object_id, tag_name),
UNIQUE (object_kind, tag_name, object_id)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS tag;
-- +goose StatementEnd
@@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE sprint ADD COLUMN tags_csv VARCHAR(1024) NULL;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE sprint DROP COLUMN tags_csv;
-- +goose StatementEnd
+50 -14
View File
@@ -7,13 +7,18 @@ provider:
runtime: go1.x
memorySize: 512
stage: prod
region: ${env:AWS_DEFAULT_REGION}
region: ${env:STUFFLOG3_AWS_REGION}
role: ${env:IAM_ROLE}
tags:
Name: Stufflog3
Type: Application
apiGateway:
shouldStartNameWithService: true
minimumCompressionSize: 2048
binaryMediaTypes:
- 'image/jpeg'
- 'image/png'
- 'image/*'
versionFunctions: false
environment:
STUFFLOG3_AWS_CLIENT_ID: ${env:STUFFLOG3_AWS_CLIENT_ID}
@@ -27,7 +32,6 @@ provider:
STUFFLOG3_MYSQL_PORT: ${env:STUFFLOG3_MYSQL_PORT}
STUFFLOG3_MYSQL_SCHEMA: ${env:STUFFLOG3_MYSQL_SCHEMA}
STUFFLOG3_MYSQL_USERNAME: ${env:STUFFLOG3_MYSQL_USERNAME}
STUFFLOG3_USE_DUMMY_USER: ${env:STUFFLOG3_USE_DUMMY_USER}
functions:
handleApi:
@@ -40,18 +44,7 @@ functions:
- http:
method: ANY
path: /api/{proxy+}
timeout: 30
handleBFF:
runtime: nodejs16.x
handler: svelte.handler
package:
include:
- ./build/frontend/lambda/**
events:
- http:
method: ANY
path: /{proxy+}
timeout: 30
timeout: 15
package:
individually: true
@@ -63,6 +56,49 @@ plugins:
- serverless-apigateway-service-proxy
custom:
webuiBucket: ${env:S3_WEBUI_BUCKET}
apiGatewayServiceProxies:
- s3:
path: /_app/{myPath+}
method: get
action: GetObject
bucket: ${self:custom.webuiBucket}
pathOverride: '_app/{myPath}'
roleArn: ${self:provider.role}
requestParameters:
'integration.request.path.myPath': 'method.request.path.myPath'
'integration.request.path.object': 'method.request.path.myPath'
'integration.request.header.cache-control': "'public, max-age=86400, immutable'"
- s3:
path: /favicon.png
method: get
action: GetObject
bucket: ${self:custom.webuiBucket}
roleArn: ${self:provider.role}
requestParameters:
'integration.request.path.myPath': 'method.request.path.myPath'
'integration.request.path.object': 'method.request.path.myPath'
'integration.request.header.cache-control': "'public, max-age=86400, immutable'"
- s3:
path: /
method: get
action: GetObject
bucket: ${self:custom.webuiBucket}
roleArn: ${self:provider.role}
key: index.html
requestParameters:
'integration.request.header.cache-control': "'public, max-age=86400, immutable'"
- s3:
path: /{myPath+}
method: get
action: GetObject
bucket: ${self:custom.webuiBucket}
roleArn: ${self:provider.role}
key: index.html
requestParameters:
'integration.request.header.cache-control': "'public, max-age=86400, immutable'"
customDomain:
domainName: ${env:DOMAIN_NAME}
basePath: ''

Some files were not shown because too many files have changed in this diff Show More