Compare commits

...
97 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
gisle cbfbd7c3e7 fix lambda dep.
continuous-integration/drone/push Build is passing
2022-09-05 21:43:13 +02:00
gisle 344f743dc6 add useful titles to pages.
continuous-integration/drone/push Build is passing
2022-09-05 20:47:57 +02:00
gisle 1b73c83b3b add day summary for history.
continuous-integration/drone/push Build is passing
2022-08-10 21:32:03 +02:00
gisle 3a1142a965 add significant decimals where they make sense.
continuous-integration/drone/push Build is passing
2022-08-10 21:20:33 +02:00
gisle 3737a80c46 fix crash on front page when acqing items.
continuous-integration/drone/push Build is passing
2022-08-10 21:02:03 +02:00
gisle 0d2e027816 floating sprint progress.
continuous-integration/drone/push Build is passing
2022-08-09 21:41:42 +02:00
gisle cac4ac0bf6 filter history events by interval.
continuous-integration/drone/push Build is passing
2022-08-08 18:53:58 +02:00
gisle f5dee09e40 fix item created/acquired time in projects being shown as HH:HH 2022-08-08 18:42:04 +02:00
gisle 53be464d7e move timed-out sprints down.
continuous-integration/drone/push Build is passing
2022-08-05 15:24:46 +02:00
gisle b381e2cbed move requirement aggregate + amounts to the bottom.
continuous-integration/drone/push Build is passing
2022-07-29 13:15:20 +02:00
gisle d7c1fbb21c add more space between paragraphs.
continuous-integration/drone/push Build is passing
2022-07-29 12:16:21 +02:00
gisle d4141f5eb3 add date under items in projects so you don't need to open the modal to see when they're acquired. 2022-07-29 12:14:47 +02:00
gisle 1232721921 add burndown stats for items too.
continuous-integration/drone/push Build is passing
2022-07-28 18:38:16 +02:00
gisle a70bb4bb23 add burndown stats to sprint API.
continuous-integration/drone/push Build is passing
2022-07-28 18:33:48 +02:00
gisle 0ba65038e9 remove console.log from stat input.
continuous-integration/drone/push Build is passing
2022-07-27 18:28:40 +02:00
gisle 6c3fdad79c show requirement name in item modal on other pages too. 2022-07-27 18:27:50 +02:00
gisle e307ed6021 add requirement picker for items (only within project). 2022-07-27 18:24:54 +02:00
gisle e4c41e1d47 add aggregate requirement for requirements that estimate on weights instead.
continuous-integration/drone/push Build is passing
2022-07-27 18:10:23 +02:00
gisle ae47f1a0e5 fix two-wide modals on mobile devices.
continuous-integration/drone/push Build is passing
2022-07-26 19:03:47 +02:00
gisle b9ffe256e9 change status picker from dropdown to icon bar.
continuous-integration/drone/push Build is passing
2022-07-26 18:58:47 +02:00
gisle eff677cc69 change background.
continuous-integration/drone/push Build is passing
2022-07-20 20:02:47 +02:00
gisle 702b2254c8 Count requirement as 100% for project progress if it is completed, failed or dropped.
continuous-integration/drone/push Build is passing
2022-07-18 19:14:45 +02:00
gisle 972ad748f2 add style for <a> to markdown.
continuous-integration/drone/push Build is passing
2022-07-18 17:54:16 +02:00
gisle 2f4c03b9af fix last:Xd interval counting one day too many.
continuous-integration/drone/push Build is passing
2022-07-18 17:48:22 +02:00
gisle 0bbfe3689e fix scope sprints progress showing as progress bars, and fix sprint progress counting to avoid counting items acquired outside of the sprint.
continuous-integration/drone/push Build is passing
2022-07-17 20:02:17 +02:00
gisle a93ff5b292 Move group to backend and change intervals and such to use 05:00 as start of day.
continuous-integration/drone/push Build is passing
2022-07-16 12:44:52 +02:00
gisle ae408390a5 optimize sprint search query. 2022-07-16 00:01:59 +02:00
gisle 5987b7c8e1 fix sprint sorting.
continuous-integration/drone/push Build is passing
2022-07-13 22:13:47 +02:00
gisle e268c1fd97 add plus for scopes too.
continuous-integration/drone/push Build is passing
2022-07-13 20:16:12 +02:00
gisle 7b7a46eae9 fix scope view button not working in scope overview page.
continuous-integration/drone/push Build is passing
2022-07-13 20:13:58 +02:00
130 changed files with 4284 additions and 1052 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 {
+28
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"`
@@ -25,6 +29,24 @@ type ItemStat struct {
Required int `json:"required"`
}
func (item *Item) AcquiredOutside(fromTime, toTime time.Time) bool {
return item.AcquiredTime != nil && (item.AcquiredTime.Before(fromTime) || !item.AcquiredTime.Before(toTime))
}
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 {
@@ -54,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)
}
}
+30 -7
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,16 +31,24 @@ 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 {
ID int `json:"id"`
ScopeID int `json:"scopeId"`
ProjectID int `json:"projectId"`
Name string `json:"name"`
Description string `json:"description"`
IsCoarse bool `json:"isCoarse"`
Status models.Status `json:"status"`
ID int `json:"id"`
ScopeID int `json:"scopeId"`
ProjectID int `json:"projectId"`
AggregateRequired int `json:"aggregateRequired"`
Name string `json:"name"`
Description string `json:"description"`
IsCoarse bool `json:"isCoarse"`
Status models.Status `json:"status"`
Tags []string `json:"tags"`
}
func (requirement *Requirement) Update(update models.RequirementUpdate) {
@@ -53,6 +64,18 @@ func (requirement *Requirement) Update(update models.RequirementUpdate) {
if update.IsCoarse != nil {
requirement.IsCoarse = *update.IsCoarse
}
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 -1
View File
@@ -13,11 +13,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",
@@ -26,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",
+3 -5
View File
@@ -10,14 +10,12 @@ declare namespace App {
}
// interface Platform {}
interface Session {
user?: import("$lib/models/user").default
idToken?: string
req?: any
groupOptions?: import("$lib/utils/items").GroupItemsOptions
amplifyConfig: any
groupOptions?: import("$lib/models/items").GroupItemsOptions
}
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 });
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

+28 -8
View File
@@ -1,8 +1,8 @@
import type { TimeInterval } from "$lib/models/common";
import type Item from "$lib/models/item";
import type { ItemFilter, ItemInput } 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);
}
@@ -73,10 +81,19 @@ export default class SL3APIClient {
return this.fetch<{project: Project}>("PUT", `scopes/${scopeId}/projects/${projectId}`, input).then(r => r.project);
}
async listItems(scopeId: number | "ALL", filter: ItemFilter): Promise<Item[]> {
async listItems(scopeId: number | "ALL", filter: ItemFilter, grouped: true, groupOptions: GroupItemsOptions): Promise<{items: Item[], groups: ItemGroup[]}>
async listItems(scopeId: number | "ALL", filter: ItemFilter, grouped?: false): Promise<Item[]>
async listItems(scopeId: number | "ALL", filter: ItemFilter, grouped?: boolean, groupOptions?: GroupItemsOptions): Promise<Item[] | {items: Item[], groups: ItemGroup[]}> {
const scopePrefix = (scopeId !== "ALL") ? `scopes/${scopeId}` : "all-scopes";
const groupedSuffix = !!grouped ? `&grouped=true&hideCreated=${groupOptions?.hideCreated}&hideAcquired=${groupOptions?.hideAcquired}&hideScheduled=${groupOptions?.hideScheduled}` : "";
return this.fetch<{items: Item[]}>("GET", `${scopePrefix}/items?filter=${encodeURIComponent(JSON.stringify(filter))}`).then(r => r.items);
return this.fetch<{items: Item[], groups: ItemGroup[]}>("GET", `${scopePrefix}/items?filter=${encodeURIComponent(JSON.stringify(filter))}${groupedSuffix}`).then(r => {
if (!grouped) {
return r.items
}
return {groups: r.groups, items: r.items}
});
}
async createItem(scopeId: number, input: ItemInput): Promise<Item> {
@@ -149,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,
@@ -1,6 +1,5 @@
<script lang="ts">
import shortestDecimal from "$lib/utils/numbers";
import { significantDecimals } from "$lib/utils/numbers";
import PlusMinus from "../layout/PlusMinus.svelte"
export let label: string
@@ -8,25 +7,17 @@ import shortestDecimal from "$lib/utils/numbers";
export let target: number = null
export let right: boolean = false;
export let dark: boolean = false;
export let weighted: boolean = false;
let valueRounded: number;
let targetRounded: number;
let valueString: string;
let targetString: string;
$: {
valueRounded = Math.round(value * 1000) / 1000;
if (target != null) {
targetRounded = Math.round(target * 1000) / 1000;
}
}
</script>
$: valueString = !weighted ? Math.round(value).toFixed() : significantDecimals(value);
</script>
<span class:dark class="value">{shortestDecimal(valueRounded)}&times;</span>
<div class="amount" class:right>
<span class:dark class="value">{valueString}&times;</span>
{ #if target != null && targetRounded != valueRounded }
<span class="target">(<PlusMinus offset={valueRounded - targetRounded} />)</span>
<span class="label">{label}</span>
{ #if target != null && target != value }
<span class="target">(<PlusMinus integersOnly={!weighted} offset={value - target} />)</span>
{ /if }
</div>
@@ -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>
@@ -1,10 +1,14 @@
<script lang="ts">
import { significantDecimals } from "$lib/utils/numbers";
export let target = 1;
export let count = 0;
export let green = false;
export let alwaysSmooth = false;
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;
@@ -24,9 +28,12 @@
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>
@@ -38,7 +45,11 @@
{#if percentageOnly}
<span class="ackreq">({((count / target) * 100).toFixed(0)}%)</span>
{:else}
<span class="ackreq">({Math.floor(count)} / {Math.ceil(target)})</span>
{#if weighted}
<span class="ackreq">({significantDecimals(count)} / {significantDecimals(target)})</span>
{:else}
<span class="ackreq">({Math.round(count)} / {Math.round(target)})</span>
{/if}
{/if}
{/if}
</div>
@@ -10,10 +10,19 @@
<div class="markdown">{@html outputHtml}</div>
<style>
<style lang="scss">
@import "../../css/colors.sass";
div.markdown :global(p) {
padding: 0;
margin: 0.25em 0;
margin: 0.5em 0;
&:first-of-type {
margin-top: 0.25em;
}
&:last-of-type {
margin-bottom: 0;
}
}
div.markdown :global(code) {
@@ -58,6 +67,17 @@
counter-reset: li;
}
div.markdown :global(a) {
color: #d1a31b;
text-decoration: none;
transition: 250ms;
&:hover {
color: #FFCC11;
text-decoration: underline;
}
}
div.markdown :global(ul) :global(li:before) {
content: "\2022"; /* Add content: \2022 is the CSS Code/unicode for a bullet */
color: #333; /* Change the color */
@@ -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;
}
@@ -94,6 +98,10 @@
display: flex;
flex-direction: row;
@media screen and (max-width: 749px) {
display: block;
}
}
div.title {
@@ -145,6 +153,10 @@
button:disabled {
color: $color-entry4;
}
&.nobody {
display: none;
}
}
div.modal :global(label) {
@@ -172,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,10 +2,14 @@
<style lang="sass">
div.modal-body
padding: 1em
padding: 0 1em
padding-left: 0
flex: 1
div.modal-body:first-of-type
padding-left: 1em
@media screen and (max-width: 749px)
padding-left: 1em
padding: 0 1em
&:first-of-type
padding-left: 1em
</style>
@@ -67,12 +67,30 @@
// Mark it non-segmented if the target is too high. This prevents a clunky progress bar,
// or a browser freeze if you set the target very high.
nonSegmented = alwaysSmooth || (target >= 60);
if (!nonSegmented) {
ons = Math.floor(ons);
offs = Math.ceil(offs);
if (ons + offs > target) {
if (offs > 0) {
offs -= 1;
} else {
ons -= 1;
}
}
}
} else {
offClass = "none";
nonSegmented = alwaysSmooth;
ons = 0;
offs = 1;
}
if (nonSegmented && (ons + offs) < 1) {
const factor = (ons + offs);
ons = (ons / factor)
offs = (offs / factor)
}
}
$: {
@@ -16,6 +16,6 @@
3: "spinner",
4: "check",
5: "times",
6: "times",
6: "trash",
}
</script>
@@ -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,13 +25,14 @@
import { getScopeContext } from "./ScopeContext.svelte";
import type Item from "$lib/models/item";
import type { ItemFilter, ItemGroup } from "$lib/models/item";
import { groupItems } from "$lib/utils/items";
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);
@@ -50,9 +51,9 @@
}
try {
const newItems = await sl3(fetch).listItems($scope.id, filter)
itemsWritable.set(newItems);
groupsWritable.set(groupItems(newItems, filter.scheduledDate.min))
const res = await sl3(fetch).listItems($scope.id, filter, true, {});
itemsWritable.set(res.items);
groupsWritable.set(res.groups);
} catch(_) {}
loading = false;
@@ -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,13 @@
<script lang="ts">
import type { Requirement } from "$lib/models/project";
export let value: number;
export let requirements: Requirement[];
export let disabled: boolean = false;
</script>
<select disabled={disabled} bind:value={value}>
{#each requirements as req (req.id)}
<option value={req.id}>{req.name} ({req.statusName})</option>
{/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,13 +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],
}));
$: console.log(value);
value2 = value; // hax
}
</script>
<div class="stat-input">
@@ -1,34 +1,66 @@
<script lang="ts">
import Status from "$lib/models/status";
<script lang="ts" context="module">
const STATUS_ENTRIES: [Status, IconName][] = Object.entries(STATUS_ICONS).map(e => [parseInt(e[0]) as Status, e[1]])
</script>
<script lang="ts">
import type Status from "$lib/models/status";
import { STATUS_NAMES } from "$lib/models/status";
import { STATUS_ICONS } from "../common/StatusIcon.svelte";
import Icon, { type IconName } from "../layout/Icon.svelte";
export let status: Status;
</script>
<select class={`sc-${status}`} bind:value={status}>
<option class="sc-0" value={Status.Blocked}>Blocked</option>
<option class="sc-1" value={Status.Available}>Available</option>
<option class="sc-2" value={Status.Background}>Background</option>
<option class="sc-3" value={Status.Active}>Active</option>
<option class="sc-4" value={Status.Completed}>Completed</option>
<option class="sc-5" value={Status.Failed}>Failed</option>
<option class="sc-6" value={Status.Dropped}>Dropped</option>
</select>
<div class="status-select">
{#each STATUS_ENTRIES as [id, iconName] (id)}
<div on:click={() => {status = id}} class="option sc-{id}" class:selected={status === id}>
<Icon name={iconName} />
</div>
{/each}
<div class="status-name">{STATUS_NAMES[status]}</div>
</div>
<style lang="sass">
@import "../../css/colors"
.sc-0
color: $sc-0 !important
.sc-1
color: $sc-1 !important
.sc-2
color: $sc-2 !important
.sc-3
color: $sc-3 !important
.sc-4
color: $sc-4 !important
.sc-5
color: $sc-5 !important
.sc-6
color: $sc-6 !important
div.status-select
background-color: $color-entryhalf
display: flex
flex-direction: row
margin-bottom: 0.8em
margin-top: 0.2em
div.status-name
margin-left: auto
margin-top: 0.275em
margin-right: 1ch
color: $color-entry7
div.option
padding: 0.2em 0.5ch
font-size: 1.175em
color: $color-entry2
cursor: pointer
line-height: 1em
&.sc-4, &.sc-2
border-left: 1px solid $color-entry1
&:hover
color: $color-entry3
&.selected.sc-0
color: $sc-0 !important
&.selected.sc-1
color: $sc-1 !important
&.selected.sc-2
color: $sc-2 !important
&.selected.sc-3
color: $sc-3 !important
&.selected.sc-4
color: $sc-4 !important
&.selected.sc-5
color: $sc-5 !important
&.selected.sc-6
color: $sc-6 !important
</style>
@@ -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>
@@ -1,6 +1,6 @@
<script lang="ts">
import { formatFormTime } from "$lib/utils/date";
import parseInterval from "$lib/utils/timeinterval";
import parseInterval, { morningInterval } from "$lib/utils/timeinterval";
export let from: string;
export let to: string;
@@ -9,7 +9,7 @@
$: {
if (intervalName !== "specific_dates") {
const int = parseInterval(intervalName, openDate);
const int = morningInterval(parseInterval(intervalName, openDate));
from = formatFormTime(int.min);
to = formatFormTime(int.max);
}
@@ -1,10 +1,21 @@
<script lang="ts">
import type { TimeInterval } from "$lib/models/common";
import parseInterval, { datesOf } from "$lib/utils/timeinterval";
import { getItemListContext } from "../contexts/ItemListContext.svelte";
import { getTimeContext } from "../contexts/TimeContext.svelte";
import HistoryGroupSection from "./HistoryGroupSection.svelte";
export let intervalString: string;
const {groups, items} = getItemListContext();
const {now} = getTimeContext();
let interval: TimeInterval<string>
$: interval = datesOf(parseInterval(intervalString, $now));
</script>
{#each $groups as group (group.label)}
<HistoryGroupSection group={group} items={$items} />
{#if (group.label >= interval.min)}
<HistoryGroupSection group={group} items={$items} />
{/if}
{/each}
@@ -2,14 +2,19 @@
import type Item from "$lib/models/item";
import type { ItemGroup } from "$lib/models/item";
import Section from "../layout/Section.svelte";
import AggregateAmountRow from "../project/AggregateAmountRow.svelte";
import ItemSubSection from "../project/ItemSubSection.svelte";
export let group: ItemGroup;
export let items: Item[];
let acquiredItems: Item[];
$: acquiredItems = group.list.filter(ref => ref.event === "acquired").map(ref => items[ref.idx]);
</script>
<Section title={group.label}>
{#each group.list as ref (ref.idx)}
<ItemSubSection item={items[ref.idx]} event={ref.event} />
{/each}
<AggregateAmountRow items={acquiredItems} />
</Section>
@@ -1,12 +1,18 @@
<script lang="ts">
import shortestDecimal from "$lib/utils/numbers";
import { significantDecimals } from "$lib/utils/numbers";
export let offset: number;
export let integersOnly: boolean = false;
let value: string;
let negative: boolean
$: value = (offset > 0) ? `+${shortestDecimal(offset)}` : shortestDecimal(offset);
let negative: boolean;
$: {
if (integersOnly) {
value = (offset > 0) ? `+${Math.round(offset).toFixed()}` : Math.round(offset).toFixed();
} else {
value = (offset > 0) ? `+${significantDecimals(offset)}` : significantDecimals(offset);
}
}
$: negative = offset < 0
</script>
+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
@@ -0,0 +1,53 @@
<script lang="ts">
import type Item from "$lib/models/item";
import Amount from "../common/Amount.svelte";
import AmountRow from "../common/AmountRow.svelte";
import { getScopeContext } from "../contexts/ScopeContext.svelte";
export let items: Item[];
export let totalAcquired: number = null;
export let aggregateRequired: number = null;
const {scope} = getScopeContext();
let amounts: {name: string, amount: number}[] = [];
let total;
let required;
$: {
const statMap: Record<number, number> = {};
total = 0;
required = 0;
for (const item of items) {
for (const stat of item.stats) {
statMap[stat.id] = (statMap[stat.id] || 0) + stat.acquired;
required += stat.required * stat.weight;
total += stat.acquired * stat.weight;
}
}
if (totalAcquired != null) {
total = totalAcquired;
}
if (aggregateRequired != null) {
required = aggregateRequired;
}
amounts = [];
for (const stat of $scope.stats) {
if (!!statMap[stat.id]) {
amounts.push({name: stat.name, amount: statMap[stat.id]});
}
}
}
</script>
<AmountRow>
{#each amounts as {name, amount} (name)}
<Amount label={name} value={amount} />
{/each}
<Amount weighted right label="" dark value={total} target={required} />
</AmountRow>
@@ -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,11 +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 { 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;
@@ -32,10 +41,10 @@
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";
@@ -57,7 +66,10 @@
</OptionsRow>
{#if item.project != null}
<RequirementReference project={item.project} requirement={item.requirement} scopeId={item.scopeId} />
{:else}
<ItemTimeInfo item={item} />
{/if}
<TagRow names={item.tags} />
{#if !compact}
<Markdown source={item.description} />
{/if}
@@ -70,7 +82,7 @@
{/if}
{/each}
{#if item.weightedRequired}
<Amount dark right label="" value={item.weightedAcquired} target={item.weightedRequired} />
<Amount weighted dark right label="" value={item.weightedAcquired} target={item.weightedRequired} />
{/if}
{:else}
{#each item.stats as stat (stat.id)}
@@ -79,7 +91,7 @@
{/if}
{/each}
{#if item.weightedRequired}
<Amount dark right label="" value={item.weightedRequired} />
<Amount weighted dark right label="" value={item.weightedRequired} />
{/if}
{/if}
{/if}
@@ -0,0 +1,69 @@
<script lang="ts">
import type Item from "$lib/models/item";
import { formatPrettyTime } from "$lib/utils/date";
import { getTimeContext } from "../contexts/TimeContext.svelte";
export let item: Item
const {now} = getTimeContext();
let action = "create";
let colorClass = "";
let timeStr = "";
$: {
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";
} else {
action = "Acquired";
}
colorClass = "acquired";
} else if (item.scheduledDate != null) {
t = new Date(item.scheduledDate);
action = "Scheduled for";
colorClass = "scheduled";
noTime = true;
} else {
action = "Created";
colorClass = "created";
}
timeStr = formatPrettyTime(t, $now);
if (noTime) {
timeStr = timeStr.split(" at ")[0];
}
}
</script>
<div class="item-time {colorClass}">
<span class="action">{action}</span>
<span class="timeStr">{timeStr}</span>
</div>
<style lang="sass">
@import "../../css/colors"
div.item-time
font-size: 0.8em
color: $color-entry3
&.created
color: $sc-1-dim
&.acquired
color: $sc-4-dim
&.scheduled
color: $sc-0
> span.timeStr
font-weight: 800
</style>
@@ -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,6 +11,8 @@
import ItemEntry from "./ItemSubSection.svelte";
import Icon from "../layout/Icon.svelte";
import { projectPrettyId } from "$lib/utils/prettyIds";
import AggregateAmountRow from "./AggregateAmountRow.svelte";
import TagRow from "../common/TagRow.svelte";
export let requirement: Requirement;
</script>
@@ -19,8 +21,10 @@
<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>
@@ -28,11 +32,14 @@
<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} />
{/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} />
<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}
@@ -17,7 +17,7 @@
function gotoSprint() {
let sc = $scope;
if (sc.id !== sprint.id) {
if (sc.id !== sprint.scopeId) {
sc = $scopes.find(s => s.id === sprint.scopeId);
}
@@ -19,7 +19,7 @@
<Markdown source={stat.description} />
{#if stat.weight !== 1}
<AmountRow>
<Amount label="Weight" value={stat.weight} />
<Amount weighted label="Weight" value={stat.weight} />
</AmountRow>
{/if}
</SubSection>
+2
View File
@@ -25,9 +25,11 @@ $opacity-entry6: 0.80
$sc-0: #78c9ff
$sc-0-dark: #34424b
$sc-1: #e7e55e
$sc-1-dim: #a3a352
$sc-2: #e7a75e
$sc-3: $color-entry12
$sc-4: #78ff78
$sc-4-dim: #549454
$sc-4-dark: #2f422f
$sc-5: #c94339
$sc-6: #e75ed0
+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
+62 -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,10 +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 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";
@@ -35,28 +40,33 @@
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}))
@@ -71,10 +81,11 @@
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;
@@ -100,6 +111,7 @@
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;
@@ -107,8 +119,10 @@
op = "Edit"
openedDate = new Date();
requirementName = req?.name;
requirementName = current.requirement?.name;
show = true;
oldTags = [...current.tags];
}
async function submit() {
@@ -118,7 +132,9 @@
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 {
@@ -144,6 +160,11 @@
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;
@@ -158,7 +179,13 @@
await reloadSprintList();
reloadItemLists();
closeModal();
if (addAnother) {
item.name = "";
item.description = "";
nameElem.focus();
} else {
closeModal();
}
} catch(err) {
if (err.statusCode != null) {
error = err.statusMessage;
@@ -176,16 +203,27 @@
<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>
<input disabled name="req" type="text" value={requirementName} />
{#if $project.id != null}
<RequirementSelect disabled={op === "Create"} requirements={$project.requirements} bind:value={item.requirementId} />
{:else if requirementName != null}
<input disabled name="req" type="text" value={requirementName} />
{/if}
{/if}
<label for="stats">Stats</label>
<StatInput
@@ -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();
@@ -21,6 +22,9 @@ import { statDiff } from "$lib/utils/stat";
let projectId: number
let requirementId: number
let oldStats: RequirementInput["stats"]
let showAggregateRequired: boolean
let oldTags: string[]
let scopeId: number
let error: string
let loading: boolean
@@ -44,6 +48,8 @@ import { statDiff } from "$lib/utils/stat";
show = false;
}
$: showAggregateRequired = requirement?.stats?.find(s => s.required > 0) == null;
function initCreate(project: ProjectEntry) {
requirement = {
name: "",
@@ -51,24 +57,32 @@ import { statDiff } from "$lib/utils/stat";
stats: [],
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,
status: current.status,
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;
}
@@ -76,6 +90,10 @@ import { statDiff } from "$lib/utils/stat";
error = null;
loading = true;
if (requirement.stats.find(s => s.required > 0)) {
requirement.aggregateRequired = 0;
}
try {
switch (op) {
case "Create":
@@ -84,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;
}
@@ -115,12 +136,19 @@ import { statDiff } from "$lib/utils/stat";
<input name="name" type="text" bind:value={requirement.name} />
<label for="description">Description</label>
<textarea name="description" bind:value={requirement.description} />
{#if showAggregateRequired}
<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
@@ -103,7 +104,7 @@ import Checkbox from "$lib/components/layout/Checkbox.svelte";
</script>
<form on:submit|preventDefault={submit}>
<Modal closable show={show} verb={op} noun="Item" disabled={loading} error={error}>
<Modal closable show={show} verb={op} noun="Stat" disabled={loading} error={error}>
<ModalBody>
<label for="name">Name</label>
<input name="name" type="text" bind:value={stat.name} />
+13
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 {
@@ -68,9 +73,17 @@ export interface ItemGroup {
export interface ItemGroupReference {
idx: number
event: "created" | "acquired" | "scheduled"
time: string
sortKey?: string
}
export interface GroupItemsOptions {
hideCreated?: boolean
hideAcquired?: boolean
hideScheduled?: boolean
}
// this_week
// this_month
// all_time
+23 -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
@@ -38,14 +42,32 @@ export interface Requirement {
totalRequired: number
totalPlanned: number
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
status: Status
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 {
+10
View File
@@ -8,4 +8,14 @@ enum Status {
Dropped,
};
export const STATUS_NAMES: Record<Status, string> = {
[Status.Blocked]: "Blocked",
[Status.Available]: "Available",
[Status.Background]: "Background",
[Status.Active]: "Active",
[Status.Completed]: "Completed",
[Status.Failed]: "Failed",
[Status.Dropped]: "Dropped",
}
export default Status;
+64 -3
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 {
@@ -133,4 +150,48 @@ export function addYears(date: Date, years: number): Date {
const newDate = new Date(date.getTime());
newDate.setFullYear(newDate.getFullYear() + years);
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 = 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 > -7 && diff < 7) {
return `${weekDay[date.getDay()]}, the ${ordinalDay} at ${hhmm}`;
} else {
return `${ordinalDay} of ${monthName(date)} at ${hhmm}`;
}
}
-71
View File
@@ -1,71 +0,0 @@
import type { ItemGroup, ItemGroupReference } from "$lib/models/item";
import type Item from "$lib/models/item";
import { formatDate, formatDateTime, formatTime, formatWeekdayDate } from "./date";
export interface GroupItemsOptions {
hideCreated?: boolean
hideScheduled?: boolean
hideAcquired?: boolean
}
export function groupItemOptionsQuery(opts: GroupItemsOptions): string {
return `hideCreated=${opts.hideCreated},hideScheduled=${opts.hideScheduled},hideAcquired=${opts.hideAcquired}`;
}
export function groupItems(items: Item[], minDate?: string, opts: GroupItemsOptions = {}): ItemGroup[] {
let groups: Record<string, ItemGroup> = {};
for (let i = 0; i < items.length; ++i) {
const item = items[i];
const createKey = formatDate(item.createdTime)
const createSortKey = `${createKey} ${formatTime(item.createdTime)}`
if (!opts.hideCreated) {
if (!item.acquiredTime || item.acquiredTime >= item.createdTime) {
addItem(groups, createKey, i, createSortKey, item, "created");
}
}
if (!opts.hideScheduled) {
if (item.scheduledDate) {
addItem(groups, item.scheduledDate, i, `${item.scheduledDate} 23:59:59`, item, "scheduled");
}
}
if (!opts.hideAcquired) {
if (item.acquiredTime) {
addItem(groups, formatDate(item.acquiredTime), i, formatDateTime(item.acquiredTime), item, "acquired");
}
}
}
return Object.keys(groups).sort((a,b) => (
b.localeCompare(a)
)).map(k => ({
...groups[k],
list: groups[k].list.sort((a, b) => {
return b.sortKey.localeCompare(a.sortKey);
}).map(r => ({...r, sortKey: void(0)})),
})).filter(g => minDate == null || g.label > minDate);
}
function addItem(groups: Record<string, ItemGroup>, key: string, index: number, sortKey: string, item: Item, event: ItemGroupReference["event"]) {
if (groups[key] == null) {
groups[key] = {
label: formatWeekdayDate(sortKey),
list: [],
}
}
const i = groups[key].list.findIndex(r => r.idx === index)
if (i == -1) {
groups[key].list.push({
idx: index,
event: event,
sortKey: sortKey,
})
} else {
groups[key].list[i].event = event;
groups[key].list[i].sortKey = sortKey;
}
}
+17 -1
View File
@@ -8,4 +8,20 @@ export default function shortestDecimal(n: number) {
}
return s;
}
}
export function significantDecimals(n: number): string {
if (n >= 99.95) {
return n.toFixed();
} else if (n >= 9.95) {
return n.toFixed(1);
} else if (n >= 0.01) {
return n.toFixed(2);
} else if (n >= 0.001) {
return n.toFixed(3);
} else if (n >= 0) {
return n.toFixed(2);
} else {
return "-" + significantDecimals(-n);
}
}
+32 -6
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(":")
@@ -25,9 +25,9 @@ export default function parseInterval(s: string, date: Date): TimeInterval<Date>
const [amount, unit] = unitNumber(args);
switch (unit) {
case "d": case "days":
return {display: "days", min: startOfDay(addDays(date, -amount)), max: startOfDay(addDays(date, 1))}
return {display: "days", min: startOfDay(addDays(date, -(amount - 1))), max: startOfDay(addDays(date, 1))}
case "w": case "weeks":
return {display: "weeks", min: startOfDay(addDays(date, -amount * 7)), max: startOfDay(addDays(date, 1))}
return {display: "weeks", min: startOfDay(addDays(date, -((amount * 7) - 1))), max: startOfDay(addDays(date, 1))}
case "m": case "months":
return {display: "days", min: startOfDay(addMonths(date, -amount)), max: startOfDay(addDays(date, 1))}
case "y": case "years":
@@ -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":
@@ -55,6 +55,23 @@ export default function parseInterval(s: string, date: Date): TimeInterval<Date>
case "last_year":
return {display: "year", min: startOfYear(addYears(date, -1)), max: startOfYear(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
}
return {
min: new Date(interval.min.getTime() + 3600000 * 5),
max: new Date(interval.max.getTime() + 3600000 * 5),
}
}
export function intervalLabel(s: string): string {
@@ -101,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 },
@@ -35,6 +38,8 @@
$: hideMobile = $page.url.pathname !== `/${scopePrettyId(scope)}`
</script>
<svelte:head><title>{scope.abbreviation}: {$page.stuff.title} – Stufflog 3</title></svelte:head>
<ScopeContext scope={scope}>
<ProjectListContext projects={projects}>
<Columns wide>
@@ -2,27 +2,26 @@
import type { Load } from "@sveltejs/kit/types/internal";
import { sl3 } from "$lib/clients/sl3";
import parseInterval, { datesOf, intervalLabel } from "$lib/utils/timeinterval";
import { groupItemOptionsQuery, groupItems, type GroupItemsOptions } from "$lib/utils/items";
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 = await sl3(fetch).listItems(scopeId, filter);
const {items, groups} = await sl3(fetch).listItems(scopeId, filter, true, session.groupOptions);
return {
stuff: { title: "History" },
props: {
items, filter,
items, groups, filter,
intervalString: params.interval,
groups: groupItems(items, datesOf(interval)?.min, session.groupOptions || {}),
},
};
}
@@ -41,7 +40,6 @@
import ItemListContext from "$lib/components/contexts/ItemListContext.svelte";
import HistoryGroupList from "$lib/components/history/HistoryGroupList.svelte";
import ToggleIcon from "$lib/components/history/ToggleIcon.svelte";
import Status from "$lib/models/status";
export let intervalString: string
export let items: Item[]
@@ -87,7 +85,7 @@
<ToggleIcon inverse name="calendar" bind:value={$session.groupOptions.hideScheduled} on:click={triggerReload} />
{/if}
</HistorySelector>
<HistoryGroupList />
<HistoryGroupList intervalString={intervalString} />
</Main>
<ItemCreateModal />
@@ -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: parseInterval("today", now),
acquiredTime: morningInterval(parseInterval("last:15d", now)),
},
looseFilter: {
loose: true,
@@ -17,24 +18,28 @@
}
}
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" },
props: {scheduledItems, acquiredItems, looseItems, sprints}
};
}
</script>
<script lang="ts">
import { sl3 } from "$lib/clients/sl3";
import { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
import Column from "$lib/components/layout/Column.svelte";
import Columns from "$lib/components/layout/Columns.svelte";
@@ -44,8 +49,7 @@
import StatSubSection from "$lib/components/scope/StatSubSection.svelte";
import DeletionModal from "$lib/modals/DeletionModal.svelte";
import StatCreateEditModal from "$lib/modals/StatCreateEditModal.svelte";
import { sl3 } from "$lib/clients/sl3";
import parseInterval, { datesOf } from "$lib/utils/timeinterval";
import parseInterval, { datesOf, morningInterval } from "$lib/utils/timeinterval";
import type Item from "$lib/models/item";
import ItemCreateModal from "$lib/modals/ItemCreateModal.svelte";
import Card from "$lib/components/common/Card.svelte";
@@ -100,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>
@@ -10,6 +10,7 @@
const project = await sl3(fetch).findProject(scopeId, projectId);
return {
stuff: { title: project.name },
props: {project}
};
}
@@ -23,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>
@@ -34,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());
@@ -14,7 +14,10 @@
const sprints = await sl3(fetch).listSprints(scopeId, interval);
return {
props: {
stuff: {
title: "Sprints",
},
props: {
sprints,
intervalString: params.interval,
},
+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;
}
+26 -12
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: parseInterval("today", now),
acquiredTime: morningInterval(parseInterval("last:15d", now)),
},
looseFilter: {
loose: true,
@@ -18,15 +19,19 @@
}
}
export const load: Load = async({ fetch, session }) => {
const scopes = await sl3(fetch).listScopes();
export const load: Load = async({ fetch }) => {
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 }
@@ -45,7 +50,7 @@
import OptionsRow from "$lib/components/layout/OptionsRow.svelte";
import Option from "$lib/components/layout/Option.svelte";
import ScopeListContext from "$lib/components/contexts/ScopeListContext.svelte";
import parseInterval, { datesOf } from "$lib/utils/timeinterval";
import parseInterval, { datesOf, morningInterval } from "$lib/utils/timeinterval";
import ItemMultiListContext from "$lib/components/contexts/ItemMultiListContext.svelte";
import type Item from "$lib/models/item";
import DeletionModal from "$lib/modals/DeletionModal.svelte";
@@ -58,6 +63,9 @@
import SprintCreateUpdateModal from "$lib/modals/SprintCreateUpdateModal.svelte";
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[];
@@ -70,6 +78,10 @@
<Header fullwidth wide subtitle="Logging your stuff">Stufflog</Header>
<svelte:head>
<title>Stufflog 3</title>
</svelte:head>
<ItemMultiListContext global lists={{acquiredItems, scheduledItems, looseItems}} filters={generateItemFilters($now)}>
<ScopeListContext scopes={scopes}>
<SprintListContext global sprints={sprints} intervalString="">
@@ -77,12 +89,12 @@
<Column>
<Row title="Scopes">
<OptionsRow slot="right">
<Option open={{name: "scope.create"}}>Create</Option>
<Option open={{name: "scope.create"}}><Icon name="plus" /></Option>
</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>
@@ -97,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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 495 KiB

+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"
}
+2 -1
View File
@@ -4,17 +4,18 @@ 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
github.com/gin-gonic/gin v1.7.7
github.com/go-playground/assert/v2 v2.0.1
github.com/go-sql-driver/mysql v1.6.0
github.com/lestrrat-go/jwx v1.2.25
golang.org/x/sync v0.0.0-20220513210516-0976fa681c29
)
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
+9
View File
@@ -8,3 +8,12 @@ func Map[I any, O any](arr []I, cb func(a I) O) []O {
return res
}
func MapValues[K comparable, V any](m map[K]V) []V {
res := make([]V, 0, len(m))
for _, v := range m {
res = append(res, v)
}
return res
}
+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
}
+8
View File
@@ -46,6 +46,14 @@ func (d Date) ToTime() time.Time {
return time.Date(d[0], time.Month(d[1]), d[2], 0, 0, 0, 0, time.UTC)
}
func (d Date) WithTime(hh, mm, ss int) time.Time {
return d.WithTimeAndLocation(hh, mm, ss, time.UTC)
}
func (d Date) WithTimeAndLocation(hh, mm, ss int, loc *time.Location) time.Time {
return time.Date(d[0], time.Month(d[1]), d[2], hh, mm, ss, 0, loc)
}
func (d *Date) UnmarshalJSON(b []byte) error {
var str string
err := json.Unmarshal(b, &str)
+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 -8
View File
@@ -1,15 +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"`
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)

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