Browse Source

stufflog3 is ready for M(V-ish)P.

master
Gisle Aune 2 years ago
parent
commit
e7aefa31d2
  1. 3
      entities/sprint.go
  2. 3
      entities/stat.go
  3. 53
      frontend/src/lib/clients/sl3.ts
  4. 22
      frontend/src/lib/components/common/Card.svelte
  5. 61
      frontend/src/lib/components/common/HistorySelector.svelte
  6. 20
      frontend/src/lib/components/common/LabeledProgress.svelte
  7. 5
      frontend/src/lib/components/common/Progress.svelte
  8. 70
      frontend/src/lib/components/contexts/ItemListContext.svelte
  9. 14
      frontend/src/lib/components/contexts/ModalContext.svelte
  10. 5
      frontend/src/lib/components/contexts/ProjectContext.svelte
  11. 37
      frontend/src/lib/components/contexts/ScopeContext.svelte
  12. 58
      frontend/src/lib/components/contexts/ScopeListContext.svelte
  13. 64
      frontend/src/lib/components/contexts/SprintListContext.svelte
  14. 153
      frontend/src/lib/components/controls/PartInput.svelte
  15. 13
      frontend/src/lib/components/controls/SprintKindSelect.svelte
  16. 46
      frontend/src/lib/components/controls/TimeRangeInput.svelte
  17. 23
      frontend/src/lib/components/frontpage/ScopeLink.svelte
  18. 10
      frontend/src/lib/components/frontpage/ScopeLinkList.svelte
  19. 10
      frontend/src/lib/components/history/HistoryGroupList.svelte
  20. 4
      frontend/src/lib/components/layout/Checkbox.svelte
  21. 7
      frontend/src/lib/components/layout/Column.svelte
  22. 4
      frontend/src/lib/components/layout/Main.svelte
  23. 22
      frontend/src/lib/components/layout/SubSection.svelte
  24. 11
      frontend/src/lib/components/project/ItemSubSection.svelte
  25. 2
      frontend/src/lib/components/project/ProjectMain.svelte
  26. 8
      frontend/src/lib/components/scope/ScopeHeader.svelte
  27. 15
      frontend/src/lib/components/scope/ScopeMenu.svelte
  28. 45
      frontend/src/lib/components/scope/SprintBody.svelte
  29. 14
      frontend/src/lib/components/scope/SprintList.svelte
  30. 27
      frontend/src/lib/components/scope/SprintSection.svelte
  31. 10
      frontend/src/lib/components/scope/SprintSectionList.svelte
  32. 27
      frontend/src/lib/components/scope/SprintSubSection.svelte
  33. 33
      frontend/src/lib/modals/DeletionModal.svelte
  34. 14
      frontend/src/lib/modals/ItemAcquireModal.svelte
  35. 34
      frontend/src/lib/modals/ItemCreateModal.svelte
  36. 117
      frontend/src/lib/modals/ProjectCreateEditModal.svelte
  37. 105
      frontend/src/lib/modals/ScopeCreateUpdateModal.svelte
  38. 167
      frontend/src/lib/modals/SprintCreateUpdateModal.svelte
  39. 6
      frontend/src/lib/modals/StatCreateEditModal.svelte
  40. 1
      frontend/src/lib/models/item.ts
  41. 2
      frontend/src/lib/models/project.ts
  42. 7
      frontend/src/lib/models/scope.ts
  43. 57
      frontend/src/lib/models/sprint.ts
  44. 2
      frontend/src/lib/models/stat.ts
  45. 4
      frontend/src/lib/utils/items.ts
  46. 13
      frontend/src/lib/utils/sprint.ts
  47. 44
      frontend/src/lib/utils/timeinterval.ts
  48. 22
      frontend/src/routes/[scope=prettyid]/__layout.svelte
  49. 2
      frontend/src/routes/[scope=prettyid]/history.svelte
  50. 62
      frontend/src/routes/[scope=prettyid]/history/[interval].svelte
  51. 79
      frontend/src/routes/[scope=prettyid]/index.svelte
  52. 125
      frontend/src/routes/[scope=prettyid]/overview.svelte
  53. 2
      frontend/src/routes/[scope=prettyid]/projects/[project=prettyid].svelte
  54. 62
      frontend/src/routes/[scope=prettyid]/sprints/[interval].svelte
  55. 14
      frontend/src/routes/__layout.svelte
  56. 2
      frontend/src/routes/api/[...any].ts
  57. 42
      frontend/src/routes/index.svelte
  58. 55
      frontend/src/routes/login.svelte
  59. 1
      models/sprint.go
  60. 1
      models/stat.go
  61. 27
      ports/httpapi/scopes.go
  62. 2
      ports/httpapi/sprints.go
  63. 5
      ports/mysql/mysqlcore/sprint.sql.go
  64. 3
      ports/mysql/queries/sprint.sql
  65. 1
      ports/mysql/sprint.go
  66. 2
      usecases/scopes/result.go
  67. 61
      usecases/scopes/service.go
  68. 2
      usecases/sprints/result.go
  69. 4
      usecases/sprints/service.go

3
entities/sprint.go

@ -49,6 +49,9 @@ func (sprint *Sprint) ApplyUpdate(update models.SprintUpdate) {
if update.IsCoarse != nil {
sprint.IsCoarse = *update.IsCoarse
}
if update.IsUnweighted != nil {
sprint.IsUnweighted = *update.IsUnweighted
}
if update.AggregateName != nil {
sprint.AggregateName = *update.AggregateName
}

3
entities/stat.go

@ -19,6 +19,9 @@ func (stat *Stat) Update(update models.StatUpdate) {
if update.Weight != nil {
stat.Weight = *update.Weight
}
if update.Primary != nil {
stat.Primary = *update.Primary
}
if update.Description != nil {
stat.Description = *update.Description
}

53
frontend/src/lib/clients/sl3.ts

@ -1,8 +1,12 @@
import type { TimeInterval } from "$lib/models/common";
import type Item from "$lib/models/item";
import type { ItemFilter, ItemInput } from "$lib/models/item";
import type Project from "$lib/models/project";
import type { ProjectEntry, Requirement, RequirementInput } from "$lib/models/project";
import type { ProjectEntry, ProjectInput, Requirement, RequirementInput } 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";
import type Sprint from "$lib/models/sprint";
import type Stat from "$lib/models/stat";
import type { StatInput } from "$lib/models/stat";
import type User from "$lib/models/user";
@ -37,6 +41,14 @@ export default class SL3APIClient {
return this.fetch<{scopes: Scope[]}>("GET", "scopes").then(r => r.scopes);
}
async createScope(scope: ScopeInput): Promise<Scope> {
return this.fetch<{scope: Scope}>("POST", "scopes", scope).then(r => r.scope);
}
async updateScope(id: number, scope: Partial<ScopeInput>): Promise<Scope> {
return this.fetch<{scope: Scope}>("PUT", `scopes/${id}`, scope).then(r => r.scope);
}
async listProjects(scopeId: number): Promise<ProjectEntry[]> {
return this.fetch<{projects: ProjectEntry[]}>("GET", `scopes/${scopeId}/projects`).then(r => r.projects);
}
@ -53,6 +65,14 @@ export default class SL3APIClient {
return this.fetch<{project: Project}>("GET", `scopes/${scopeId}/projects/${projectId}`).then(r => r.project);
}
async createProject(scopeId: number, input: ProjectInput): Promise<Project> {
return this.fetch<{project: Project}>("POST", `scopes/${scopeId}/projects`, input).then(r => r.project);
}
async updateProject(scopeId: number, projectId: number, input: Partial<ProjectInput>): Promise<Project> {
return this.fetch<{project: Project}>("PUT", `scopes/${scopeId}/projects/${projectId}`, input).then(r => r.project);
}
async listItems(scopeId: number, filter: ItemFilter): Promise<Item[]> {
return this.fetch<{items: Item[]}>("GET", `scopes/${scopeId}/items?filter=${encodeURIComponent(JSON.stringify(filter))}`).then(r => r.items);
}
@ -73,6 +93,37 @@ export default class SL3APIClient {
return this.fetch<{stat: Stat}>("PUT", `scopes/${scopeId}/stats/${statId}`, input).then(r => r.stat);
}
async findSprint(scopeId: number, sprintId: number): Promise<Sprint> {
return this.fetch<{sprint: Sprint}>("GET", `scopes/${scopeId}/sprints/${sprintId}`).then(r => r.sprint);
}
async listSprints(scopeId: number, interval?: TimeInterval<Date | string>): Promise<Sprint[]> {
let qs = "";
if (interval != null) {
const minStr = (interval.min instanceof Date) ? interval.min.toISOString() : interval.min;
const maxStr = (interval.max instanceof Date) ? interval.max.toISOString() : interval.max;
qs = `?from=${encodeURIComponent(minStr)}&to=${encodeURIComponent(maxStr)}`
}
return this.fetch<{sprints: Sprint[]}>("GET", `scopes/${scopeId}/sprints${qs}`).then(r => r.sprints);
}
async createSprint(scopeId: number, input: SprintInput): Promise<Sprint> {
return this.fetch<{sprint: Sprint}>("POST", `scopes/${scopeId}/sprints`, input).then(r => r.sprint);
}
async updateSprint(scopeId: number, sprintId: number, input: Partial<SprintInput>): Promise<Sprint> {
input = {...input, parts: void(0)};
return this.fetch<{sprint: Sprint}>("PUT", `scopes/${scopeId}/sprints/${sprintId}`, input).then(r => r.sprint);
}
async upsertSprintPart(scopeId: number, sprintId: number, part: SprintInputPart): Promise<Sprint> {
return this.fetch<{sprint: Sprint}>("PUT", `scopes/${scopeId}/sprints/${sprintId}/parts/${part.partId}`, part).then(r => r.sprint);
}
async deleteSprintPart(scopeId: number, sprintId: number, part: SprintInputPart): Promise<Sprint> {
return this.fetch<{sprint: Sprint}>("DELETE", `scopes/${scopeId}/sprints/${sprintId}/parts/${part.partId}`).then(r => r.sprint);
}
async autchCheck(): Promise<User> {
return this.fetch<{user: User}>("GET", "auth").then(r => r.user);

22
frontend/src/lib/components/common/Card.svelte

@ -1,11 +1,15 @@
<div class="entry">
<script lang="ts">
export let pointerCursor = false;
</script>
<div on:click class="card" class:pointerCursor>
<slot></slot>
</div>
<style lang="scss">
@import "../../css/colors.sass";
div.entry {
div.card {
display: block;
text-decoration: none;
color: $color-entry9;
@ -16,23 +20,27 @@
transition: 250ms;
overflow-y: hidden;
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3);
&.pointerCursor {
cursor: pointer;
}
}
div.entry:hover {
div.card:hover {
background-color: $color-entry2-transparent;
color: $color-entry11;
}
:global(div.entry div.entry) {
:global(div.card div.card) {
margin: 0.25em 0;
}
:global(div.entry div.entry) {
:global(div.card div.card) {
border-bottom-right-radius: 0.5em;
}
:global(div.entry div.entry:hover) {
:global(div.card div.card:hover) {
background-color: $color-entry3;
}
div.entry:last-of-type {
div.card:last-of-type {
margin-bottom: 0.1em;
}
</style>

61
frontend/src/lib/components/common/HistorySelector.svelte

@ -0,0 +1,61 @@
<script lang="ts">
export let label: string = "History"
export let value: string
</script>
<div class="selector">
<div class="label">{label}</div>
<div class="par">(</div>
<div class="selector">
<select value={value} on:change>
<option value="last:7d">Last 7 days</option>
<option value="last:14d">Last 14 days</option>
<option value="last:30d">Last 30 days</option>
<option value="last:90d">Last 90 days</option>
<option value="last:1m">Last 1 month</option>
<option value="last:3m">Last 3 months</option>
<option value="last:6m">Last 6 months</option>
<option value="last:1y">Last 1 year</option>
<option value="this_week">This week</option>
<option value="next_week">Next week</option>
<option value="last_week">Last week</option>
<option value="this_month">This month</option>
<option value="next_month">Next month</option>
<option value="last_month">Last month</option>
<option value="all_time">All time</option>
</select>
</div>
<div class="par">)</div>
</div>
<style lang="sass">
@import "../../css/colors"
div.selector
margin-top: 1em
font-size: 2em
margin: 0
color: $color-entry10
display: flex
flex-direction: row
div.label
margin-right: 0.5ch
select
color: $color-entry5
font-size: 0.5em
background: none
outline: none
border: none
line-height: 1em
margin-top: 0.025em
> option
background: #000
font-size: 0.666em
div.par
color: $color-entry5
</style>

20
frontend/src/lib/components/common/LabeledProgress.svelte

@ -4,6 +4,7 @@
export let green = false;
export let alwaysSmooth = false;
export let percentageOnly = false;
export let compact = false;
export let fullwidth = false;
export let name: string;
@ -18,13 +19,11 @@
const {now} = getTimeContext();
$: {
if (timeRange != null) {
if (timeRange != null && target > 0) {
const [from, to] = timeRange;
boat = (from.getTime() - $now.getTime()) / (to.getTime() - from.getTime());
if (boat < 0 || boat > 1) {
boatEnabled = false;
}
boat = ($now.getTime() - from.getTime()) / (to.getTime() - from.getTime());
boatEnabled = (boat > 0 && boat < 1)
} else {
boatEnabled = false;
boat = 0;
@ -32,10 +31,10 @@
}
</script>
<div class="progress" class:fullwidth>
<div class="progress" class:compact class:fullwidth>
<div class="header">
<span>{name}</span>
{#if target > 0}
{#if target > 0 && !compact}
{#if percentageOnly}
<span class="ackreq">({((count / target) * 100).toFixed(0)}%)</span>
{:else}
@ -50,12 +49,13 @@
alwaysSmooth={alwaysSmooth}
count={count}
target={target}
thin={compact}
/>
{:else}
<div class="number">{count}</div>
{/if}
{#if boatEnabled}
<Progress titlePercentageOnly alwaysSmooth thinner count={boat} target={1} />
<Progress gray titlePercentageOnly alwaysSmooth thinner count={boat} target={1} />
{/if}
</div>
@ -68,6 +68,10 @@
font-size: 0.9em;
flex-basis: calc(25% - 1ch);
&.compact {
flex-basis: calc(33.3% - 1ch);
}
@media screen and (max-width: 1000px) {
flex-basis: calc(33.3% - 1ch);
}

5
frontend/src/lib/components/common/Progress.svelte

@ -128,7 +128,7 @@
margin: 0;
box-sizing: border-box;
width: 100%;
height: 12px;
height: 10px;
margin: 0.5px 0;
transform: skew(-11.25deg, 0);
}
@ -171,10 +171,11 @@
}
div.bar.thin {
height: 3px;
height: 4px;
}
div.bar.thinner {
height: 1px;
margin-left: -0.95px;
}
div.bar.thinner div {
border-bottom: none;

70
frontend/src/lib/components/contexts/ItemListContext.svelte

@ -0,0 +1,70 @@
<script lang="ts" context="module">
const contextKey = {ctx: "projectListCtx"};
interface ItemListContextData {
items: Readable<Item[]>,
groups: Readable<ItemGroup[]>,
reloadItemList(): Promise<void>,
};
const fallback: ItemListContextData = {
items: readable([]),
groups: readable([]),
reloadItemList: () => Promise.resolve()
};
export function getItemListContext() {
return getContext(contextKey) as ItemListContextData || fallback
}
</script>
<script lang="ts">
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 Item from "$lib/models/item";
import type { ItemFilter, ItemGroup } from "$lib/models/item";
import { groupItems } from "$lib/utils/items";
export let items: Item[];
export let groups: ItemGroup[];
export let filter: ItemFilter;
const {scope} = getScopeContext();
let itemsWritable = writable<Item[]>(items);
let groupsWritable = writable<ItemGroup[]>(groups);
let loading = false;
let lastSet = {items, groups};
setContext<ItemListContextData>(contextKey, {
items: {subscribe: itemsWritable.subscribe},
groups: {subscribe: groupsWritable.subscribe},
reloadItemList,
});
async function reloadItemList() {
if (loading) {
return
}
try {
const newItems = await sl3(fetch).listItems($scope.id, filter)
itemsWritable.set(newItems);
groupsWritable.set(groupItems(newItems, filter.scheduledDate.min))
} catch(_) {}
loading = false;
}
$: {
if (lastSet.items !== items || lastSet.groups !== groups) {
itemsWritable.set(items);
groupsWritable.set(groups);
lastSet = {items, groups};
}
}
</script>
<slot></slot>

14
frontend/src/lib/components/contexts/ModalContext.svelte

@ -3,6 +3,9 @@
export type ModalData =
| { name: "closed" }
| { name: "scope.create" }
| { name: "scope.edit", scope: Scope }
| { name: "scope.delete", scope: Scope }
| { name: "item.create", requirement?: Requirement }
| { name: "item.edit", item: Item }
| { name: "item.acquire", item: Item }
@ -10,10 +13,15 @@
| { name: "requirement.create", project: ProjectEntry }
| { name: "requirement.edit", requirement: Requirement }
| { name: "requirement.delete", requirement: Requirement }
| { name: "project.create" }
| { name: "project.edit", project: Project }
| { name: "project.delete", project: Project }
| { name: "stat.create" }
| { name: "stat.edit", stat: Stat }
| { name: "stat.delete", stat: Stat }
| { name: "sprint.create" }
| { name: "sprint.edit", sprint: Sprint }
| { name: "sprint.delete", sprint: Sprint }
interface ModalContextData {
currentModal: Readable<ModalData>
@ -31,8 +39,10 @@
import { getContext, onMount, setContext } from "svelte";
import type { ProjectEntry, Requirement } 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 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";
let store = writable<ModalData>({name: "closed"});

5
frontend/src/lib/components/contexts/ProjectContext.svelte

@ -28,6 +28,7 @@
const {scope} = getScopeContext();
let projectWritable = writable<Project>(project);
let lastSet = project;
let loading = false;
setContext<ProjectContextData>(contextKey, {
@ -48,7 +49,9 @@
loading = false;
}
$: projectWritable.set(project);
$: if (project !== lastSet) {
projectWritable.set(project);
}
</script>
<slot></slot>

37
frontend/src/lib/components/contexts/ScopeContext.svelte

@ -3,37 +3,55 @@
interface ScopeContextData {
scope: Readable<Scope>
lastHistoryRange: Writable<string>
upsertStat(stat: Stat): void
deleteStat(stat: Stat): void
updateScope(scope: Scope): void
};
const fallback: ScopeContextData = {
scope: readable({} as Scope),
lastHistoryRange: writable(""),
upsertStat: () => {},
deleteStat: () => {},
updateScope: () => {},
}
export function getScopeContext() {
return getContext(contextKey) as ScopeContextData
return getContext(contextKey) as ScopeContextData || fallback
}
</script>
<script lang="ts">
import type Scope from "$lib/models/scope";
import { writable, type Readable } from "svelte/store";
import { readable, writable, type Readable, type Writable } from "svelte/store";
import { getContext, setContext } from "svelte";
import type Stat from "$lib/models/stat";
export let scope: Scope;
let scopeWritable = writable<Scope>(scope);
let lastHistoryRange = writable<string>(global?.localStorage?.getItem("sl3.history.last_range") || "last:14d");
setContext<ScopeContextData>(contextKey, {
scope: {subscribe: scopeWritable.subscribe},
upsertStat, deleteStat,
lastHistoryRange,
upsertStat, deleteStat, updateScope,
});
function updateScope(scope: Scope) {
scopeWritable.set(scope);
}
function upsertStat(stat: Stat) {
scopeWritable.update(s => ({
...s,
stats: [
...s.stats.filter(s => s.id != stat.id),
{...stat},
].sort((a,b) => a.name.localeCompare(b.name))
].sort((a,b) => (
a.primary == b.primary ? a.name.localeCompare(b.name) : Number(b.primary) - Number(a.primary)
))
}));
}
@ -44,7 +62,16 @@
}));
}
$: $scopeWritable = scope;
let lastWritten: Scope;
$: if (lastWritten !== scope) {
$scopeWritable = scope
lastWritten = scope
}
$: if (global?.localStorage) {
localStorage.setItem("sl3.history.last_range", $lastHistoryRange)
}
</script>
<slot></slot>

58
frontend/src/lib/components/contexts/ScopeListContext.svelte

@ -0,0 +1,58 @@
<script lang="ts" context="module">
const contextKey = {ctx: "scopeListCtx"};
interface ScopeListContextData {
scopes: Readable<Scope[]>,
reloadScopeList(): Promise<void>,
};
const fallback: ScopeListContextData = {
scopes: readable([]),
reloadScopeList: () => Promise.resolve()
};
export function getScopeListContext() {
return getContext(contextKey) as ScopeListContextData || fallback
}
</script>
<script lang="ts">
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";
export let scopes: Scope[];
let scopesWritable = writable<Scope[]>(scopes);
let loading = false;
let lastSet = scopes;
setContext<ScopeListContextData>(contextKey, {
scopes: {subscribe: scopesWritable.subscribe},
reloadScopeList,
});
async function reloadScopeList() {
if (loading) {
return
}
try {
const newScopes = await sl3(fetch).listScopes()
scopesWritable.set(newScopes);
} catch(_) {}
loading = false;
}
$: {
if (lastSet !== scopes) {
scopesWritable.set(scopes);
lastSet = scopes;
}
}
</script>
<slot></slot>

64
frontend/src/lib/components/contexts/SprintListContext.svelte

@ -0,0 +1,64 @@
<script lang="ts" context="module">
const contextKey = {ctx: "projectListCtx"};
interface SprintListContextData {
sprints: Readable<Sprint[]>,
reloadSprintList(): Promise<void>,
};
const fallback: SprintListContextData = {
sprints: readable([]),
reloadSprintList: () => Promise.resolve()
};
export function getSprintListContext() {
return getContext(contextKey) as SprintListContextData || fallback
}
</script>
<script lang="ts">
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 Sprint from "$lib/models/sprint";
import parseInterval from "$lib/utils/timeinterval";
import { getTimeContext } from "./TimeContext.svelte";
export let sprints: Sprint[];
export let intervalString: string;
const {scope} = getScopeContext();
const {now} = getTimeContext();
let sprintsWritable = writable<Sprint[]>(sprints);
let loading = false;
let lastSet = sprints;
setContext<SprintListContextData>(contextKey, {
sprints: {subscribe: sprintsWritable.subscribe},
reloadSprintList,
});
async function reloadSprintList() {
if (loading) {
return
}
try {
const newSprints = await sl3(fetch).listSprints($scope.id, parseInterval(intervalString, $now));
sprintsWritable.set(newSprints);
} catch(_) {}
loading = false;
}
$: {
if (lastSet !== sprints) {
sprintsWritable.set(sprints);
lastSet = sprints;
}
}
</script>
<slot></slot>

153
frontend/src/lib/components/controls/PartInput.svelte

@ -0,0 +1,153 @@
<script lang="ts" context="module">
export interface PartOption {
partId: number
name: string
enabled: boolean
required: number
}
</script>
<script lang="ts">
import { browser } from "$app/env";
import { sl3 } from "$lib/clients/sl3";
import { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
import { SprintKind, type SprintInputPart } from "$lib/models/sprint";
import Checkbox from "../layout/Checkbox.svelte";
export let value: SprintInputPart[] = [];
export let kind: SprintKind;
const {scope} = getScopeContext();
let error = "";
let options: PartOption[] = [];
let showRequired = false;
let loading = false;
let loaded: SprintKind = SprintKind.Invalid
async function load(kind: SprintKind) {
loading = true;
try {
switch (kind) {
case SprintKind.Items: {
const items = await sl3(fetch).listItems($scope.id, {});
options = items.map(i => ({
enabled: false,
name: `${i.name}${i.acquiredTime?" ✓":""}`,
partId: i.id,
required: 0,
}))
showRequired = false;
break;
}
case SprintKind.Requirements: {
options = [];
throw "Requirement selection is not yet supported the UI."
}
case SprintKind.Stats: {
options = $scope.stats.map(s => ({partId: s.id, name: s.name, enabled: false, required: 0}));
showRequired = true;
break;
}
case SprintKind.Scope: {
options = [];
showRequired = false;
break;
}
}
error = "";
for (const opt of options) {
const val = value.find(v => v.partId === opt.partId);
if (val != null) {
opt.enabled = true;
opt.required = val.required;
}
}
} catch(err) {
error = err?.toString() || err;
}
loaded = kind;
loading = false;
}
$: if (loaded === kind && error === "") {
value = options.filter(o => o.enabled).map(o => ({partId: o.partId, required: o.required}))
}
$: if (browser && !loading && loaded !== kind) {
load(kind);
}
</script>
<div class="part-input">
<div class="error">{error}</div>
{#each options as option (option.partId)}
<div class="part">
<Checkbox noLabel bind:checked={option.enabled} />
<div class="name" class:disabled={!option.enabled}>{option.name}</div>
{#if showRequired && option.enabled}
<input type="number" class="required" bind:value={option.required} />
{/if}
</div>
{/each}
</div>
<style lang="scss">
@import "../../css/colors";
div.part {
display: flex;
margin: 0;
padding: 0;
border-top: 1px solid $color-entry1;
&:first-of-type {
border-top: none;
}
input:first-of-type {
margin-left: auto;
}
input {
font-size: 1em;
width: fit-content;
max-width: 7ch;
padding: 0.3em 0;
margin: 0;
text-align: center;
}
div {
padding: 0.3em 0.5ch;
&.disabled {
color: $color-entry2;
}
}
}
div.part-input {
width: calc(100% - 2ch);
margin-bottom: 1em;
margin-top: 0.20em;
height: 12em;
overflow-y: auto;
background: $color-entryhalf;
color: $color-entry8;
border: none;
outline: none;
resize: vertical;
padding: 0.5em 1ch;
div.error {
font-size: 0.9em;
color: $sc-5;
}
}
</style>

13
frontend/src/lib/components/controls/SprintKindSelect.svelte

@ -0,0 +1,13 @@
<script lang="ts">
import { SprintKind } from "$lib/models/sprint";
export let kind: SprintKind;
export let disabled: boolean = false;
</script>
<select disabled={disabled} bind:value={kind}>
<option value={SprintKind.Items}>Items</option>
<option value={SprintKind.Requirements}>Requirements</option>
<option value={SprintKind.Stats}>Stats</option>
<option value={SprintKind.Scope}>Scope</option>
</select>

46
frontend/src/lib/components/controls/TimeRangeInput.svelte

@ -0,0 +1,46 @@
<script lang="ts">
import { formatFormTime } from "$lib/utils/date";
import parseInterval from "$lib/utils/timeinterval";
export let from: string;
export let to: string;
export let openDate: Date;
export let intervalName: string;
$: {
if (intervalName !== "specific_dates") {
const int = parseInterval(intervalName, openDate);
from = formatFormTime(int.min);
to = formatFormTime(int.max);
}
}
</script>
<select bind:value={intervalName}>
<option value="this_week">This week</option>
<option value="next_week">Next week</option>
<option value="last_week">Last week</option>
<option value="next:7d">7 days starting from today</option>
<option value="this_month">This month</option>
<option value="next_month">Next month</option>
<option value="last_month">Last month</option>
<option value="next:30d">30 days starting from today</option>
<option value="this_year">This year</option>
<option value="next_year">Next year</option>
<option value="last_year">Last year</option>
<option value="next:1y">One year starting from today</option>
<option value="specific_dates">Specific Date</option>
</select>
<input class="top-date" type="datetime-local" disabled={intervalName !== "specific_dates"} bind:value={from} />
<input type="datetime-local" disabled={intervalName !== "specific_dates"} bind:value={to} />
<style>
select, input.top-date {
margin-bottom: 0.25em !important;
}
input {
margin-top: 0 !important;
resize: none !important;
}
</style>

23
frontend/src/lib/components/frontpage/ScopeLink.svelte

@ -6,6 +6,23 @@
export let scope: Scope;
</script>
<LinkCard href="/{scope.id}-{scope.abbreviation.toLocaleLowerCase()}/">
<CardHeader subtitle={scope.abbreviation}>{scope.name}</CardHeader>
</LinkCard>
<div class="mobile">
<LinkCard href="/{scope.id}-{scope.abbreviation.toLocaleLowerCase()}/">
<CardHeader subtitle={scope.abbreviation}>{scope.name}</CardHeader>
</LinkCard>
</div>
<div class="desktop">
<LinkCard href="/{scope.id}-{scope.abbreviation.toLocaleLowerCase()}/overview">
<CardHeader subtitle={scope.abbreviation}>{scope.name}</CardHeader>
</LinkCard>
</div>
<style lang="sass">
div.desktop
@media screen and (max-width: 800px)
display: none
div.mobile
@media screen and (min-width: 800px)
display: none
</style>

10
frontend/src/lib/components/frontpage/ScopeLinkList.svelte

@ -0,0 +1,10 @@
<script lang="ts">
import { getScopeListContext } from "../contexts/ScopeListContext.svelte";
import ScopeLink from "./ScopeLink.svelte";
const {scopes} = getScopeListContext();
</script>
{#each $scopes as scope (scope.id)}
<ScopeLink scope={scope} />
{/each}

10
frontend/src/lib/components/history/HistoryGroupList.svelte

@ -0,0 +1,10 @@
<script lang="ts">
import { getItemListContext } from "../contexts/ItemListContext.svelte";
import HistoryGroupSection from "./HistoryGroupSection.svelte";
const {groups, items} = getItemListContext();
</script>
{#each $groups as group (group.label)}
<HistoryGroupSection group={group} items={$items} />
{/each}

4
frontend/src/lib/components/layout/Checkbox.svelte

@ -49,6 +49,10 @@
margin: auto
}
:global(div.checkbox + div.checkbox) {
margin-top: -0.75em;
}
div.box {
border: 0.5px solid;
padding: 0.05em 0.3ch;

7
frontend/src/lib/components/layout/Column.svelte

@ -1,8 +1,9 @@
<script lang="ts">
export let span = 1;
export let hideMobile = false;
</script>
<div class="column" style={`flex: ${span}`}>
<div class="column" style={`flex: ${span}`} class:hideMobile>
<slot></slot>
</div>
@ -11,4 +12,8 @@
display: flex
flex-direction: column
flex-basis: 50
&.hideMobile
@media screen and (max-width: 800px)
display: none
</style>

4
frontend/src/lib/components/layout/Main.svelte

@ -22,7 +22,9 @@
</StatusColor>
{/if}
</div>
<Title value={title} big={big} small={small} />
{#if title != ""}
<Title value={title} big={big} small={small} />
{/if}
<div class="right" class:noProgress>
<slot name="right"></slot>
</div>

22
frontend/src/lib/components/layout/SubSection.svelte

@ -19,7 +19,7 @@
$: blankEvent = !event || event === "none";
</script>
<div class="sl3-entry">
<div class="sub-section">
<div class="header">
<div class="entry-icon" class:blankEvent>
{#if icon != null}
@ -40,13 +40,16 @@
<style lang="sass">
@import "../../css/colors.sass"
div.sl3-entry
div.sub-section
margin: 0.5em 0.4ch
padding: 0.33em 0.75ch
background: $color-entry1-transparent
border-radius: 0.125em
border-bottom-right-radius: 0.5em
:global(div.sub-section)
background: $color-entry2-transparent
div.entry-icon
opacity: 0.6
margin-right: 1.5ch
@ -69,21 +72,6 @@
div.header
display: flex
h2
font-size: 1.25em
margin: 0
color: $color-entry10
span.sub
color: $color-entry5
&.big
margin-top: 1em
font-size: 2em
&.small
font-size: 1em
div.right
color: $color-entry2-transparent
margin-left: auto

11
frontend/src/lib/components/project/ItemSubSection.svelte

@ -11,6 +11,7 @@
export let item: Item;
export let event: "created" | "scheduled" | "acquired" | "none" = "none";
export let compact = false;
let icon: IconName
let status: Status
@ -34,14 +35,18 @@
</script>
<SubSection small noProgress title={item.name} icon={icon} status={status} event={event}>
<Markdown source={item.description} />
<OptionsRow slot="right">
{#if item.acquiredTime == null}
<Option open={{name: "item.acquire", item}} color="green">Acquire</Option>
{/if}
<Option open={{name: "item.edit", item}}>Edit</Option>
<Option open={{name: "item.delete", item}} color="red">Delete</Option>
{#if !compact}
<Option open={{name: "item.edit", item}}>Edit</Option>
<Option open={{name: "item.delete", item}} color="red">Delete</Option>
{/if}
</OptionsRow>
{#if !compact}
<Markdown source={item.description} />
{/if}
<AmountRow>
{#if item.acquiredTime != null}
{#each item.stats as stat (stat.id)}

2
frontend/src/lib/components/project/ProjectMain.svelte

@ -16,7 +16,7 @@
<Markdown source={$project.description} />
<OptionsRow slot="right">
<Option open={{name: "requirement.create", project: $project}}>Add Requirement</Option>
<Option>Edit</Option>
<Option open={{name: "project.edit", project: $project}}>Edit</Option>
<Option open={{name: "project.delete", project: $project}} color="red">Delete</Option>
</OptionsRow>
{#each $project.requirements as requirement (requirement.id)}

8
frontend/src/lib/components/scope/ScopeHeader.svelte

@ -0,0 +1,8 @@
<script lang="ts">
import { getScopeContext } from "../contexts/ScopeContext.svelte";
import Header from "../layout/Header.svelte";
const {scope} = getScopeContext();
</script>
<Header subtitle={$scope.name}>{$scope.abbreviation}</Header>

15
frontend/src/lib/components/scope/ScopeMenu.svelte

@ -0,0 +1,15 @@
<script lang="ts">
import { scopePrettyId } from "$lib/utils/prettyIds";
import MenuCategory from "../common/MenuCategory.svelte";
import MenuItem from "../common/MenuItem.svelte";
import { getScopeContext } from "../contexts/ScopeContext.svelte";
const {scope, lastHistoryRange} = getScopeContext();
</script>
<MenuCategory>
<MenuItem href={`/`}>Home</MenuItem>
<MenuItem href={`/${scopePrettyId($scope)}/overview`}>Overview</MenuItem>
<MenuItem href={`/${scopePrettyId($scope)}/sprints/${$lastHistoryRange}`}>Sprints</MenuItem>
<MenuItem href={`/${scopePrettyId($scope)}/history/${$lastHistoryRange}`}>History</MenuItem>
</MenuCategory>

45
frontend/src/lib/components/scope/SprintBody.svelte

@ -0,0 +1,45 @@
<script lang="ts">
import type Sprint from "$lib/models/sprint";
import { SprintKind } from "$lib/models/sprint";
import Amount from "../common/Amount.svelte";
import AmountRow from "../common/AmountRow.svelte";
import LabeledProgress from "../common/LabeledProgress.svelte";
import LabeledProgressRow from "../common/LabeledProgressRow.svelte";
import Markdown from "../common/Markdown.svelte";
import ItemSubSection from "../project/ItemSubSection.svelte";
export let sprint: Sprint
let timeRange: [Date, Date];
$: if (sprint.isTimed) {
timeRange = [new Date(sprint.fromTime), new Date(sprint.toTime)];
} else {
timeRange = void(0);
}
</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} />
{/if}
{#if !sprint.isCoarse}
<LabeledProgressRow>
{#each (sprint.progress||[]) as stat (stat.id)}
<LabeledProgress compact timeRange={timeRange} name={stat.name} count={stat.acquired} target={stat.required} />
{/each}
</LabeledProgressRow>
{/if}
{#if sprint.kind === SprintKind.Items}
{#each sprint.items as item (item.id)}
{#if !item.acquiredTime}
<ItemSubSection compact item={item} />
{/if}
{/each}
{:else}
<AmountRow>
<Amount label="Acquired Items" value={(sprint.items||[]).length} />
</AmountRow>
{/if}

14
frontend/src/lib/components/scope/SprintList.svelte

@ -0,0 +1,14 @@
<script lang="ts">
import { getSprintListContext } from "../contexts/SprintListContext.svelte";
import SprintSubSection from "./SprintSubSection.svelte";
export let sub = false;
const {sprints} = getSprintListContext();
</script>
{#if sub}
{#each $sprints as sprint (sprint.id)}
<SprintSubSection sprint={sprint} />
{/each}
{/if}

27
frontend/src/lib/components/scope/SprintSection.svelte

@ -0,0 +1,27 @@
<script lang="ts">
import { goto } from "$app/navigation";
import type Sprint from "$lib/models/sprint";
import Option from "../layout/Option.svelte";
import OptionsRow from "../layout/OptionsRow.svelte";
import SprintBody from "./SprintBody.svelte";
import { projectPrettyId, scopePrettyId } from "$lib/utils/prettyIds";
import { getScopeContext } from "../contexts/ScopeContext.svelte";
import Section from "../layout/Section.svelte";
export let sprint: Sprint;
const {scope} = getScopeContext();
function gotoSprint() {
goto(`/${scopePrettyId($scope)}/sprints#${projectPrettyId(sprint)}`)
}
</script>
<Section noProgress title={sprint.name}>
<OptionsRow slot="right">
<Option on:click={gotoSprint}>View</Option>
<Option open={{name: "sprint.edit", sprint}}>Edit</Option>
<Option open={{name: "sprint.delete", sprint}} color="red">Delete</Option>
</OptionsRow>
<SprintBody sprint={sprint} />
</Section>

10
frontend/src/lib/components/scope/SprintSectionList.svelte

@ -0,0 +1,10 @@
<script lang="ts">
import { getSprintListContext } from "../contexts/SprintListContext.svelte";
import SprintSection from "./SprintSection.svelte";
const {sprints} = getSprintListContext();
</script>
{#each $sprints as sprint (sprint.id)}
<SprintSection sprint={sprint} />
{/each}

27
frontend/src/lib/components/scope/SprintSubSection.svelte

@ -0,0 +1,27 @@
<script lang="ts">
import { goto } from "$app/navigation";
import type Sprint from "$lib/models/sprint";
import Option from "../layout/Option.svelte";
import OptionsRow from "../layout/OptionsRow.svelte";
import SubSection from "../layout/SubSection.svelte";
import SprintBody from "./SprintBody.svelte";
import { projectPrettyId, scopePrettyId } from "$lib/utils/prettyIds";
import { getScopeContext } from "../contexts/ScopeContext.svelte";
export let sprint: Sprint;
const {scope} = getScopeContext();
function gotoSprint() {
goto(`/${scopePrettyId($scope)}/sprints#${projectPrettyId(sprint)}`)
}
</script>
<SubSection noProgress title={sprint.name}>
<OptionsRow slot="right">
<Option on:click={gotoSprint}>View</Option>
<Option open={{name: "sprint.edit", sprint}}>Edit</Option>
<Option open={{name: "sprint.delete", sprint}} color="red">Delete</Option>
</OptionsRow>
<SprintBody sprint={sprint} />
</SubSection>

33
frontend/src/lib/modals/DeletionModal.svelte

@ -11,17 +11,21 @@
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 { getModalContext } from "$lib/components/contexts/ModalContext.svelte";
import { getProjectContext } from "$lib/components/contexts/ProjectContext.svelte";
import { getProjectListContext } from "$lib/components/contexts/ProjectListContext.svelte";
import { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
import type Stat from "$lib/models/stat";
import { getSprintListContext } from "$lib/components/contexts/SprintListContext.svelte";
import type Stat from "$lib/models/stat";
import { scopePrettyId } from "$lib/utils/prettyIds";
const {currentModal, closeModal} = getModalContext();
const {scope, deleteStat} = getScopeContext();
const {project, reloadProject} = getProjectContext();
const {reloadProjectList} = getProjectListContext();
const {reloadSprintList} = getSprintListContext();
const {reloadItemList} = getItemListContext();
let endpoint: string;
@ -36,6 +40,14 @@ import type Stat from "$lib/models/stat";
let navigate: string;
$: switch ($currentModal.name) {
case "scope.delete":
init("", [{op: "Delete", name: "everything under the scope."}])
noun = "Scope";
name = $currentModal.scope.name;
dangerZone = true;
navigate = "/";
break;
case "item.delete":
init(`items/${$currentModal.item.id}`)
noun = "Item";
@ -49,6 +61,12 @@ import type Stat from "$lib/models/stat";
dangerZone = true;
break;
case "sprint.delete":
init(`sprints/${$currentModal.sprint.id}`)
noun = "Sprint";
name = $currentModal.sprint.name;
break;
case "requirement.delete":
init(`projects/${$project.id}/requirements/${$currentModal.requirement.id}`,
$currentModal.requirement.items.map(i => ({
@ -84,6 +102,9 @@ import type Stat from "$lib/models/stat";
function init(newEndpoint: string, newSideEffects: DeletionSideEffect[] = []) {
endpoint = `scopes/${$scope.id}/${newEndpoint}`;
if (endpoint.endsWith("/")) {
endpoint = endpoint.slice(0, -1);
}
sideEffects = newSideEffects;
show = true;
error = null;
@ -112,6 +133,14 @@ import type Stat from "$lib/models/stat";
if (noun === "Stat") {
deleteStat((res as {stat: Stat}).stat);
}
if (noun === "Sprint") {
await reloadSprintList();
}
if (noun === "Item") {
await reloadItemList();
}
// TODO: History context upsert
}
@ -138,7 +167,7 @@ import type Stat from "$lib/models/stat";
<ModalBody>
<p>Are you sure you want to delete this {noun.toLocaleLowerCase()}?</p>
{#if sideEffects.length > 0}
<p>There are specific side-effects to this deletion.</p>
<p>There are side-effects to this deletion.</p>
<ul>
{#each sideEffects as sideEffect}
<li>{sideEffect.op} {sideEffect.name}</li>

14
frontend/src/lib/modals/ItemAcquireModal.svelte

@ -3,19 +3,22 @@
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 { getModalContext } from "$lib/components/contexts/ModalContext.svelte";
import { getProjectContext } from "$lib/components/contexts/ProjectContext.svelte";
import { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
import { getSprintListContext } from "$lib/components/contexts/SprintListContext.svelte";
import AcquiredTimeInput from "$lib/components/controls/AcquiredTimeInput.svelte";
import StatInput from "$lib/components/controls/StatInput.svelte";
import type Item from "$lib/models/item";
import type { ItemInput } from "$lib/models/item";
import type Scope from "$lib/models/scope";
import { formatFormTime } from "$lib/utils/date";
const {currentModal, closeModal} = getModalContext();
const {scope} = getScopeContext();
const projectCtx = getProjectContext();
const {reloadProject} = getProjectContext();
const {reloadItemList} = getItemListContext();
const {reloadSprintList} = getSprintListContext();
let item: Partial<ItemInput>
let itemId: number
@ -62,11 +65,12 @@ import { formatFormTime } from "$lib/utils/date";
});
// Wait for project reload if it's updating a project
if (projectCtx != null && requirementId != null) {
await projectCtx.reloadProject();
if (requirementId != null) {
await reloadProject();
}
// TODO: History context upsert
await reloadItemList();
await reloadSprintList();
closeModal();
} catch(err) {

34
frontend/src/lib/modals/ItemCreateModal.svelte

@ -3,9 +3,11 @@
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 { getModalContext } from "$lib/components/contexts/ModalContext.svelte";
import { getProjectContext } from "$lib/components/contexts/ProjectContext.svelte";
import { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
import { getSprintListContext } from "$lib/components/contexts/SprintListContext.svelte";
import AcquiredTimeInput from "$lib/components/controls/AcquiredTimeInput.svelte";
import StatInput from "$lib/components/controls/StatInput.svelte";
import type Item from "$lib/models/item";
@ -13,11 +15,13 @@
import type { Requirement } from "$lib/models/project";
import type Scope from "$lib/models/scope";
import { formatFormTime } from "$lib/utils/date";
import { statDiff } from "$lib/utils/stat";
import { statDiff } from "$lib/utils/stat";
const {currentModal, closeModal} = getModalContext();
const {scope} = getScopeContext();
const {project, reloadProject} = getProjectContext();
const {reloadItemList} = getItemListContext();
const {reloadSprintList} = getSprintListContext();
let item: ItemInput
let itemId: number
@ -92,21 +96,32 @@ import { statDiff } from "$lib/utils/stat";
const submission: ItemInput = {
...item,
acquiredTime: item.acquiredTime ? new Date(item.acquiredTime).toISOString() : void(0),
stats: item.stats.filter(s => s.required > 0).map(s => {
if (!!item.acquiredTime) {
return s
} else {
return {...s, acquired: 0}
}
})
stats: item.stats.filter(s => s.required > 0),
}
try {
switch (op) {
case "Create":
if (!!submission.acquiredTime) {
for (const stat of submission.stats) {
stat.acquired = stat.required
}
} else {
for (const stat of submission.stats) {
stat.acquired = 0
}
}
await sl3(fetch).createItem($scope.id, submission);
break;
case "Edit":
if (!submission.acquiredTime) {
for (const stat of submission.stats) {
stat.acquired = 0
}
submission.clearAcquiredTime = true;
}
submission.stats = statDiff(currentStats, submission.stats)
await sl3(fetch).updateItem($scope.id, itemId, submission);
break;
@ -117,7 +132,8 @@ import { statDiff } from "$lib/utils/stat";
await reloadProject();
}
// TODO: History context upsert
await reloadItemList();
await reloadSprintList();
closeModal();
} catch(err) {

117
frontend/src/lib/modals/ProjectCreateEditModal.svelte

@ -0,0 +1,117 @@
<script lang="ts">
import { goto } from "$app/navigation";
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 { getProjectListContext } from "$lib/components/contexts/ProjectListContext.svelte";
import { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
import StatusSelect from "$lib/components/controls/StatusSelect.svelte";
import type Project from "$lib/models/project";
import type { ProjectEntry, ProjectInput, Requirement, RequirementInput } from "$lib/models/project";
import Status from "$lib/models/status";
import { projectPrettyId, scopePrettyId } from "$lib/utils/prettyIds";
const {currentModal, closeModal} = getModalContext();
const {scope} = getScopeContext();
const {reloadProject} = getProjectContext();
const {reloadProjectList} = getProjectListContext();
let project: ProjectInput
let projectId: number
let oldStats: RequirementInput["stats"]
let error: string
let loading: boolean
let show: boolean
let op: "Create" | "Edit" | "Delete"
$: switch ($currentModal.name) {
case "project.create":
initCreate();
op = "Create";
break;
case "project.edit":
initEdit($currentModal.project);
op = "Edit";
break;
default:
loading = false;
error = null;
show = false;
}
function initCreate() {
project = {
name: "",
description: "",
status: Status.Available,
createdTime: void(0),
}
show = true;
}
function initEdit(current: Project) {
project = {
name: current.name,
description: current.description,
status: current.status,
}
projectId = current.id;
show = true;
}
async function submit() {
error = null;
loading = true;
try {
switch (op) {
case "Create":
const newProject = await sl3(fetch).createProject($scope.id, project);
projectId = newProject.id
break;
case "Edit":
await sl3(fetch).updateProject($scope.id, projectId, project);
break;
}
// Wait for project to reload
await reloadProject();
await reloadProjectList();
goto(`/${scopePrettyId($scope)}/projects/${projectPrettyId({id: projectId, name: project.name})}`, {replaceState: op === "Edit"})
closeModal();
} catch(err) {
if (err.statusCode != null) {
error = err.statusMessage;
} else {
error = err
}
} finally {
loading = false;
}
}
</script>
<form on:submit|preventDefault={submit}>
<Modal show={show} closable verb={op} noun="Project" disabled={loading} error={error}>
<ModalBody>
<label for="name">Name</label>
<input name="name" type="text" bind:value={project.name} />
<label for="description">Description</label>
<textarea name="description" bind:value={project.description} />
<label for="stats">Status</label>
<StatusSelect bind:status={project.status} />
</ModalBody>
</Modal>
</form>

105
frontend/src/lib/modals/ScopeCreateUpdateModal.svelte

@ -0,0 +1,105 @@
<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 { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
import { getScopeListContext } from "$lib/components/contexts/ScopeListContext.svelte";
import type { ScopeInput } from "$lib/models/scope";
import type Scope from "$lib/models/scope";
const {currentModal, closeModal} = getModalContext();
const {updateScope} = getScopeContext();
const {reloadScopeList} = getScopeListContext();
let scope: ScopeInput
let scopeId: number
let op: string
let error: string
let loading: boolean
let show: boolean
$: switch ($currentModal.name) {
case "scope.create":
initCreate()
break;
case "scope.edit":
initEdit($currentModal.scope)
break;
default:
loading = false;
error = null;
show = false;
}
function initCreate() {
scope = {
name: "",
abbreviation: "",
ownerName: "",
customLabels: {},
}
op = "Create"
show = true;
}
function initEdit(current: Scope) {
scope = {
name: current.name,
abbreviation: current.abbreviation,
ownerName: "",
customLabels: current.customLabels,
};
scopeId = current.id;
op = "Edit"
show = true;
}
async function submit() {
error = null;
loading = true;
try {
switch (op) {
case "Create":
await sl3(fetch).createScope(scope);
await reloadScopeList();
break;
case "Edit":
const res = await sl3(fetch).updateScope(scopeId, scope);
updateScope(res);
break;
}
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={op} noun="scope" disabled={loading} error={error}>
<ModalBody>
<label for="name">Name</label>
<input name="name" type="text" bind:value={scope.name} />
<label for="abbreviation">Abbreviation</label>
<input name="abbreviation" type="text" bind:value={scope.abbreviation} />
{#if op === "Create"}
<label for="ownerName">Owner Name</label>
<input name="ownerName" type="text" bind:value={scope.ownerName} />
{/if}
</ModalBody>
</Modal>
</form>

167
frontend/src/lib/modals/SprintCreateUpdateModal.svelte

@ -0,0 +1,167 @@
<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 { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
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 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";
const {currentModal, closeModal} = getModalContext();
const {scope} = getScopeContext();
const {reloadSprintList} = getSprintListContext();
let sprint: SprintInput
let sprintId: number
let oldParts: SprintInputPart[]
let intervalName: string;
let openedDate: Date
let op: string
let error: string
let loading: boolean
let show: boolean
$: switch ($currentModal.name) {
case "sprint.create":
initCreate($scope)
break;
case "sprint.edit":
initEdit($currentModal.sprint)
break;
default:
loading = false;
error = null;
show = false;
}
function initCreate(scope: Scope) {
sprint = {
name: "",
description: "",
fromTime: "",
toTime: "",
kind: SprintKind.Stats,
aggregateName: "",
aggregateRequired: 0,
isCoarse: false,
isTimed: false,
isUnweighted: false,
parts: [],
}
intervalName = "this_month";
op = "Create"
openedDate = new Date();
show = true;
}
function initEdit(current: Sprint) {
sprint = {
name: current.name,
description: current.description,
fromTime: formatFormTime(current.fromTime),
toTime: formatFormTime(current.toTime),
kind: current.kind,
aggregateName: current.aggregateName,
aggregateRequired: current.aggregateRequired,
isCoarse: current.isCoarse,
isTimed: current.isTimed,
isUnweighted: current.isUnweighted,
};
sprintId = current.id;
intervalName = "specific_dates";
if (sprint.kind === SprintKind.Stats) {
sprint.parts = current.progress.map(p => ({partId: p.id, required: p.required}));
} else {
sprint.parts = current.partIds.map(p => ({partId: p, required: 0}));
}
oldParts = [...sprint.parts];
op = "Edit"
openedDate = new Date();
show = true;
}
async function submit() {
error = null;
loading = true;
const submission: SprintInput = {
...sprint,
fromTime: new Date(sprint.fromTime).toISOString(),
toTime: new Date(sprint.toTime).toISOString(),
}
try {
switch (op) {
case "Create":
await sl3(fetch).createSprint($scope.id, submission);
break;
case "Edit":
await sl3(fetch).updateSprint($scope.id, sprintId, submission);
const {added, removed} = partsDiff(oldParts, submission.parts)
for (const part of added) {
await sl3(fetch).upsertSprintPart($scope.id, sprintId, part).catch(() => {});
}
for (const part of removed) {
await sl3(fetch).deleteSprintPart($scope.id, sprintId, part).catch(() => {});
}
break;
}
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 wide closable show={show} verb={op} noun="sprint" disabled={loading} error={error}>
<ModalBody>
<label for="name">Name</label>
<input name="name" type="text" bind:value={sprint.name} />
<label for="description">Description</label>
<textarea name="description" bind:value={sprint.description} />
<label for="time">Time</label>
<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} />
{#if sprint.kind != SprintKind.Items}
<label for="aggregateValue">Aggregate Goal</label>
<input name="aggregateValue" type="number" bind:value={sprint.aggregateRequired} />
{/if}
</ModalBody>
<ModalBody>
<label for="kind">Kind</label>
<SprintKindSelect disabled={op === "Edit"} bind:kind={sprint.kind} />
<label for="parts">Parts</label>
<PartInput bind:value={sprint.parts} kind={sprint.kind} />
<Checkbox bind:checked={sprint.isTimed} label="Sprint is timed" />
<Checkbox bind:checked={sprint.isCoarse} label="Show only aggregate" />
<Checkbox bind:checked={sprint.isUnweighted} label="Aggregate is unweighted" />
</ModalBody>
</Modal>
</form>

6
frontend/src/lib/modals/StatCreateEditModal.svelte

@ -5,11 +5,12 @@
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 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";
import { getAllContexts } from "svelte";
const {currentModal, closeModal} = getModalContext();
const {scope, upsertStat} = getScopeContext();
@ -45,6 +46,7 @@ import { getAllContexts } from "svelte";
stat = {
name: "",
weight: 1,
primary: false,
description: "",
allowedAmounts: null,
}
@ -57,6 +59,7 @@ import { getAllContexts } from "svelte";
stat = {
name: current.name,
weight: current.weight,
primary: current.primary,
description: current.description,
allowedAmounts: current.allowedAmounts ? current.allowedAmounts.slice() : void(0),
};
@ -108,6 +111,7 @@ import { getAllContexts } from "svelte";
<input name="weight" type="number" min={0} step={0.001} bind:value={stat.weight} />
<label for="description">Description</label>
<textarea name="description" bind:value={stat.description} />
<Checkbox bind:checked={stat.primary} label="Primary" />
</ModalBody>
</Modal>
</form>

1
frontend/src/lib/models/item.ts

@ -21,6 +21,7 @@ export interface ItemInput {
description: string
acquiredTime?: string
scheduledDate?: string
clearAcquiredTime?: boolean
stats: StatValueInput[]
}

2
frontend/src/lib/models/project.ts

@ -46,4 +46,4 @@ export interface RequirementInput {
description: string
status: Status
stats: StatValueInput[]
}
}

7
frontend/src/lib/models/scope.ts

@ -9,6 +9,13 @@ export default interface Scope {
stats: Stat[]
}
export interface ScopeInput {
name: string
abbreviation: string
ownerName: string
customLabels?: Record<string, string>
}
export interface ScopeMember {
id: string
name: string

57
frontend/src/lib/models/sprint.ts

@ -0,0 +1,57 @@
import type Item from "./item";
import type { StatProgress } from "./stat";
export default interface Sprint {
id: number
scopeId: number
name: string
description: string
kind: SprintKind
fromTime: string
toTime: string
isTimed: boolean
isCoarse: boolean
isUnweighted: boolean
aggregateName: string
aggregateRequired: number
aggregateAcquired: number
aggregatePlanned: number
itemsAcquired?: number
itemsRequired?: number
partIds: number[]
items: Item[]
progress: StatProgress[]
}
export enum SprintKind {
Items = 0,
Requirements = 1,
Stats = 2,
Scope = 3,
Invalid = -1,
}
export interface SprintInput {
name: string
description: string
kind: SprintKind
fromTime: string
toTime: string
isTimed?: boolean
isCoarse?: boolean
isUnweighted?: boolean
aggregateName?: string
aggregateRequired?: number
parts?: SprintInputPart[]
}
export interface SprintInputPart {
partId: number
required?: number
}

2
frontend/src/lib/models/stat.ts

@ -3,6 +3,7 @@ export default interface Stat {
name: string
weight: number
description: string
primary: boolean
allowedAmounts?: StatAllowedAmount[]
}
@ -32,6 +33,7 @@ export interface StatValueInput {
export interface StatInput {
name: string
weight: number
primary?: boolean
description?: string
allowedAmounts?: StatAllowedAmount[]
}

4
frontend/src/lib/utils/items.ts

@ -10,7 +10,9 @@ export function groupItems(items: Item[], minDate?: string): ItemGroup[] {
const createKey = formatDate(item.createdTime)
const createSortKey = `${createKey} ${formatTime(item.createdTime)}`
addItem(groups, createKey, i, createSortKey, item, "created");
if (!item.acquiredTime || item.acquiredTime >= item.createdTime) {
addItem(groups, createKey, i, createSortKey, item, "created");
}
if (item.scheduledDate) {
addItem(groups, item.scheduledDate, i, `${item.scheduledDate} 23:59:59`, item, "scheduled");

13
frontend/src/lib/utils/sprint.ts

@ -0,0 +1,13 @@
import type { SprintInputPart } from "$lib/models/sprint";
interface PartDiff {
added: SprintInputPart[]
removed: SprintInputPart[]
}
export function partsDiff(before: SprintInputPart[], after: SprintInputPart[]): PartDiff {
return {
added: after.filter(p => !before.find(p2 => p2.partId === p.partId && p2.required === p.required)),
removed: before.filter(p => !after.find(p2 => p2.partId === p.partId)),
}
}

44
frontend/src/lib/utils/timeinterval.ts

@ -25,11 +25,11 @@ 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 - 1))), max: startOfDay(addDays(date, 1))}
return {display: "days", min: startOfDay(addDays(date, -amount)), max: startOfDay(addDays(date, 1))}
case "w": case "weeks":
return {display: "weeks", min: startOfDay(addDays(date, -amount * 7)), max: startOfDay(addDays(date, 1))}
case "m": case "months":
return {display: "days", min: startOfDay(addMonths(date, -amount - 1)), max: startOfDay(addDays(date, 1))}
return {display: "days", min: startOfDay(addMonths(date, -amount)), max: startOfDay(addDays(date, 1))}
case "y": case "years":
return {display: "none", min: startOfDay(addYears(date, -amount)), max: startOfDay(addDays(date, 1))}
}
@ -57,6 +57,42 @@ export default function parseInterval(s: string, date: Date): TimeInterval<Date>
}
}
export function intervalLabel(s: string): string {
const [verb, args] = s.split(":")
switch (verb) {
case "":
return "All time";
case "next":
case "last": {
const [amount, unit] = unitNumber(args);
switch (unit) {
case "d": case "days":
return `${capitalize(verb)} ${amount} ${amount !== 1 ? "days" : "day"}`
case "w": case "weeks":
return `${capitalize(verb)} ${amount} ${amount !== 1 ? "weeks" : "week"}`
case "m": case "months":
return `${capitalize(verb)} ${amount} ${amount !== 1 ? "months" : "month"}`
case "y": case "years":
return `${capitalize(verb)} ${amount} ${amount !== 1 ? "years" : "year"}`
}
}
case "today":
return verb;
case "all_time":
case "next_week":
case "this_week":
case "last_week":
case "next_month":
case "this_month":
case "last_month":
case "next_year":
case "this_year":
case "last_year":
return capitalize(verb.replace("_", " "));
}
}
export function datesOf(interval: TimeInterval<string | Date>): TimeInterval<string> {
if (interval == null) {
return void(0)
@ -65,6 +101,10 @@ export function datesOf(interval: TimeInterval<string | Date>): TimeInterval<str
return {min: formatDate(interval.min), max: formatDate(interval.max)}
}
function capitalize(s: string) {
return `${s.charAt(0).toLocaleUpperCase()}${s.slice(1)}`
}
function unitNumber(s: string): [number, string] {
for (let i = 0; i < s.length; ++i) {
const ch = s.charAt(i);

22
frontend/src/routes/[scope=prettyid]/__layout.svelte

@ -17,32 +17,30 @@
</script>
<script lang="ts">
import {page} from "$app/stores"
import Columns from "$lib/components/layout/Columns.svelte";
import Column from "$lib/components/layout/Column.svelte";
import ScopeContext from "$lib/components/contexts/ScopeContext.svelte";
import type Scope from "$lib/models/scope";
import ProjectMenu from "$lib/components/scope/ProjectMenu.svelte";
import Header from "$lib/components/layout/Header.svelte";
import MenuCategory from "$lib/components/common/MenuCategory.svelte";
import MenuItem from "$lib/components/common/MenuItem.svelte";
import { scopePrettyId } from "$lib/utils/prettyIds";
import ProjectListContext from "$lib/components/contexts/ProjectListContext.svelte";
import ScopeMenu from "$lib/components/scope/ScopeMenu.svelte";
import { scopePrettyId } from "$lib/utils/prettyIds";
import ScopeHeader from "$lib/components/scope/ScopeHeader.svelte";
export let scope: Scope;
export let projects: ProjectEntry[];
let hideMobile;
$: hideMobile = $page.url.pathname !== `/${scopePrettyId(scope)}`
</script>
<ScopeContext scope={scope}>
<ProjectListContext projects={projects}>
<Columns wide>
<Column>
<Header subtitle={scope.name}>{scope.abbreviation}</Header>
<MenuCategory>
<MenuItem href={`/`}>Home</MenuItem>
<MenuItem href={`/${scopePrettyId(scope)}`}>Overview</MenuItem>
<MenuItem href={`/${scopePrettyId(scope)}/sprints`}>Sprints</MenuItem>
<MenuItem href={`/${scopePrettyId(scope)}/history`}>History</MenuItem>
</MenuCategory>
<Column hideMobile={hideMobile} >
<ScopeHeader />
<ScopeMenu />
<ProjectMenu />
</Column>
<Column span={4}>

2
frontend/src/routes/[scope=prettyid]/history.svelte

@ -4,7 +4,7 @@
export const load: Load = async({}) => {
return {
status: 303,
redirect: "history/this_week",
redirect: "history/last:14d",
};
}
</script>

62
frontend/src/routes/[scope=prettyid]/history/[interval].svelte

@ -2,44 +2,66 @@
import type { Load } from "@sveltejs/kit/types/internal";
import { sl3 } from "$lib/clients/sl3";
import parseInterval, { datesOf } from "$lib/utils/timeinterval";
import parseInterval, { datesOf, intervalLabel } from "$lib/utils/timeinterval";
import { groupItems } from "$lib/utils/items";
import type Item from "$lib/models/item";
import type { ItemGroup } from "$lib/models/item";
import type Item from "$lib/models/item";
import type { ItemFilter, ItemGroup } from "$lib/models/item";
export const load: Load = async({ fetch, params }) => {
const scopeId = parseInt(params.scope.split("-")[0]);
const interval = parseInterval(params.interval, new Date());
const items = await sl3(fetch).listItems(scopeId, {
const filter = {
createdTime: interval,
acquiredTime: interval,
scheduledDate: datesOf(interval),
});
};
const items = await sl3(fetch).listItems(scopeId, filter);
return {
props: { items, groups: groupItems(items, datesOf(interval)?.min), },
props: {
items, filter,
intervalString: params.interval,
groups: groupItems(items, datesOf(interval)?.min),
},
};
}
</script>
<script lang="ts">
import ItemSubSection from "$lib/components/project/ItemSubSection.svelte"
import HistoryGroupSection from "$lib/components/history/HistoryGroupSection.svelte";
import Main from "$lib/components/layout/Main.svelte";
import DeletionModal from "$lib/modals/DeletionModal.svelte";
import ItemAcquireModal from "$lib/modals/ItemAcquireModal.svelte";
import ItemCreateModal from "$lib/modals/ItemCreateModal.svelte";
import { goto } from "$app/navigation";
import HistorySelector from "$lib/components/common/HistorySelector.svelte";
import { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
import ItemListContext from "$lib/components/contexts/ItemListContext.svelte";
import HistoryGroupList from "$lib/components/history/HistoryGroupList.svelte";
export let intervalString: string
export let items: Item[]
export let groups: ItemGroup[]
export let filter: ItemFilter;
const {lastHistoryRange} = getScopeContext();
function changeInterval(e: Event) {
const target = e.target as HTMLSelectElement;
lastHistoryRange.set(target.value);
goto(`./${target.value}`);
}
</script>
<h1>History</h1>
<div>
{#each groups as group (group.label)}
<HistoryGroupSection group={group} items={items} />
{/each}
</div>
<style lang="sass">
h1
margin-top: 1.5em
</style>
<ItemListContext filter={filter} groups={groups} items={items}>
<Main big>
<HistorySelector value={intervalString} on:change={changeInterval} />
<HistoryGroupList />
</Main>
<ItemCreateModal />
<ItemAcquireModal />
<DeletionModal />
</ItemListContext>

79
frontend/src/routes/[scope=prettyid]/index.svelte

@ -1,79 +0,0 @@
<script lang="ts" context="module">
import type { Load } from "@sveltejs/kit/types/internal";
export const load: Load = async({params, url, fetch}) => {
const scopeId = parseInt(params.scope.split("-")[0]);
console.log(
datesOf(parseInterval("next:7d", new Date)),
parseInterval("today", new Date),
)
const scheduledItems = (await sl3(fetch).listItems(scopeId, {
scheduledDate: datesOf(parseInterval("next:7d", new Date)),
}))
const acquiredItems = (await sl3(fetch).listItems(scopeId, {
acquiredTime: parseInterval("today", new Date),
}))
return {
props: {scheduledItems, acquiredItems}
};
}
</script>
<script lang="ts">
import { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
import Column from "$lib/components/layout/Column.svelte";
import Columns from "$lib/components/layout/Columns.svelte";
import Row from "$lib/components/layout/Row.svelte";
import Option from "$lib/components/layout/Option.svelte";
import OptionsRow from "$lib/components/layout/OptionsRow.svelte";
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 type Item from "$lib/models/item";
import ItemSubSection from "$lib/components/project/ItemSubSection.svelte";
import ItemCreateModal from "$lib/modals/ItemCreateModal.svelte";
const {scope} = getScopeContext();
export let acquiredItems: Item[];
export let scheduledItems: Item[];
</script>
<Columns fullwidth>
<Column>
{#if scheduledItems.length > 0}
<Row title="Schedule">
{#each scheduledItems as item (item.id)}
<ItemSubSection item={item} />
{/each}
</Row>
{/if}
{#if acquiredItems.length > 0}
<Row title="Today">
{#each acquiredItems as item (item.id)}
<ItemSubSection item={item} />
{/each}
</Row>
{/if}
</Column>
<Column>
<Row title="Sprints">
</Row>
<Row title="Stats">
<OptionsRow slot="right">
<Option open={{name: "stat.create"}}>Create</Option>
</OptionsRow>
{#each $scope.stats as stat (stat.id)}
<StatSubSection stat={stat} />
{/each}
</Row>
</Column>
</Columns>
<ItemCreateModal />
<StatCreateEditModal />
<DeletionModal />

125
frontend/src/routes/[scope=prettyid]/overview.svelte

@ -0,0 +1,125 @@
<script lang="ts" context="module">
import type { Load } from "@sveltejs/kit/types/internal";
export const load: Load = async({params, url, fetch}) => {
const scopeId = parseInt(params.scope.split("-")[0]);
console.log(
datesOf(parseInterval("next:7d", new Date)),
parseInterval("today", new Date),
)
const scheduledItems = (await sl3(fetch).listItems(scopeId, {
scheduledDate: datesOf(parseInterval("next:7d", new Date)),
}))
const acquiredItems = (await sl3(fetch).listItems(scopeId, {
acquiredTime: parseInterval("today", new Date),
}))
const sprints = await sl3(fetch).listSprints(scopeId);
return {
props: {scheduledItems, acquiredItems, sprints}
};
}
</script>
<script lang="ts">
import { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
import Column from "$lib/components/layout/Column.svelte";
import Columns from "$lib/components/layout/Columns.svelte";
import Row from "$lib/components/layout/Row.svelte";
import Option from "$lib/components/layout/Option.svelte";
import OptionsRow from "$lib/components/layout/OptionsRow.svelte";
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 type Item from "$lib/models/item";
import ItemSubSection from "$lib/components/project/ItemSubSection.svelte";
import ItemCreateModal from "$lib/modals/ItemCreateModal.svelte";
import Card from "$lib/components/common/Card.svelte";
import CardHeader from "$lib/components/common/CardHeader.svelte";
import SprintCreateUpdateModal from "$lib/modals/SprintCreateUpdateModal.svelte";
import type Sprint from "$lib/models/sprint";
import SprintListContext from "$lib/components/contexts/SprintListContext.svelte";
import SprintList from "$lib/components/scope/SprintList.svelte";
import ItemAcquireModal from "$lib/modals/ItemAcquireModal.svelte";
import { getModalContext } from "$lib/components/contexts/ModalContext.svelte";
import ProjectCreateEditModal from "$lib/modals/ProjectCreateEditModal.svelte";
import ScopeCreateUpdateModal from "$lib/modals/ScopeCreateUpdateModal.svelte";
export let acquiredItems: Item[];
export let scheduledItems: Item[];
export let sprints: Sprint[];
const {scope} = getScopeContext();
const {openModal} = getModalContext();
function openCreateProject() {
openModal({name: "project.create"});
}
function openPostItem() {
openModal({name: "item.create"});
}
function openEditScope() {
openModal({name: "scope.edit", scope: $scope});
}
function openDeleteScope() {
openModal({name: "scope.delete", scope: $scope});
}
</script>
<SprintListContext sprints={sprints} intervalString="">
<Columns fullwidth>
<Column>
<Row title="Options">
<Card on:click={openCreateProject} pointerCursor><CardHeader>Create Project</CardHeader></Card>
<Card on:click={openPostItem} pointerCursor><CardHeader>Post Item</CardHeader></Card>
<Card on:click={openEditScope} pointerCursor><CardHeader>Edit Scope</CardHeader></Card>
<Card on:click={openDeleteScope} pointerCursor><CardHeader>Delete Scope</CardHeader></Card>
</Row>
{#if scheduledItems.length > 0}
<Row title="Schedule">
{#each scheduledItems as item (item.id)}
<ItemSubSection item={item} />
{/each}
</Row>
{/if}
{#if acquiredItems.length > 0}
<Row title="Today">
{#each acquiredItems as item (item.id)}
<ItemSubSection item={item} />
{/each}
</Row>
{/if}
</Column>
<Column>
<Row title="Sprints">
<OptionsRow slot="right">
<Option open={{name: "sprint.create"}}>Create</Option>
</OptionsRow>
<SprintList sub />
</Row>
<Row title="Stats">
<OptionsRow slot="right">
<Option open={{name: "stat.create"}}>Create</Option>
</OptionsRow>
{#each $scope.stats as stat (stat.id)}
<StatSubSection stat={stat} />
{/each}
</Row>
</Column>
</Columns>
<ItemCreateModal />
<ItemAcquireModal />
<StatCreateEditModal />
<DeletionModal />
<SprintCreateUpdateModal />
<ProjectCreateEditModal />
<ScopeCreateUpdateModal />
</SprintListContext>

2
frontend/src/routes/[scope=prettyid]/projects/[project=prettyid].svelte

@ -22,6 +22,7 @@
import RequirementCreateModal from "$lib/modals/RequirementCreateModal.svelte";
import ItemAcquireModal from "$lib/modals/ItemAcquireModal.svelte";
import DeletionModal from "$lib/modals/DeletionModal.svelte";
import ProjectCreateEditModal from "$lib/modals/ProjectCreateEditModal.svelte";
export let project: Project;
</script>
@ -32,4 +33,5 @@
<ItemAcquireModal />
<RequirementCreateModal />
<DeletionModal />
<ProjectCreateEditModal />
</ProjectContext>

62
frontend/src/routes/[scope=prettyid]/sprints/[interval].svelte

@ -0,0 +1,62 @@
<script lang="ts" context="module">
import type { Load } from "@sveltejs/kit/types/internal";
import { sl3 } from "$lib/clients/sl3";
export const load: Load = async({ fetch, params }) => {
const scopeId = parseInt(params.scope.split("-")[0]);
let interval = parseInterval(params.interval, new Date());
if (interval == null) {
// The Reapers are done by then anyway...
interval = {min: new Date("2000-01-01T00:00:00Z"), max: new Date("2187-01-01T00:00:00Z")}
}
const sprints = await sl3(fetch).listSprints(scopeId, interval);
return {
props: {
sprints,
intervalString: params.interval,
},
};
}
</script>
<script lang="ts">
import { goto } from "$app/navigation";
import Main from "$lib/components/layout/Main.svelte";
import DeletionModal from "$lib/modals/DeletionModal.svelte";
import ItemAcquireModal from "$lib/modals/ItemAcquireModal.svelte";
import ItemCreateModal from "$lib/modals/ItemCreateModal.svelte";
import HistorySelector from "$lib/components/common/HistorySelector.svelte";
import { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
import parseInterval from "$lib/utils/timeinterval";
import type Sprint from "$lib/models/sprint";
import SprintListContext from "$lib/components/contexts/SprintListContext.svelte";
import SprintCreateUpdateModal from "$lib/modals/SprintCreateUpdateModal.svelte";
import SprintSectionList from "$lib/components/scope/SprintSectionList.svelte";
export let intervalString: string
export let sprints: Sprint[]
const {lastHistoryRange} = getScopeContext();
function changeInterval(e: Event) {
const target = e.target as HTMLSelectElement;
lastHistoryRange.set(target.value);
goto(`./${target.value}`);
}
</script>
<SprintListContext sprints={sprints} intervalString={intervalString}>
<Main big>
<HistorySelector label="Sprints" value={intervalString} on:change={changeInterval} />
<SprintSectionList />
</Main>
<ItemCreateModal />
<ItemAcquireModal />
<DeletionModal />
<SprintCreateUpdateModal />
</SprintListContext>

14
frontend/src/routes/__layout.svelte

@ -11,21 +11,13 @@
</script>
<script lang="ts">
import { page, session } from "$app/stores";
import { goto } from "$app/navigation";
import { browser } from "$app/env";
import { page } from "$app/stores";
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 TimeContext from "$lib/components/contexts/TimeContext.svelte";
import ModalContext from "$lib/components/contexts/ModalContext.svelte";
let opacity = 0.175;
$: if (browser) {
if ($session.user == null && !$page.url.pathname.startsWith("/login")) {
goto(`/login?redirect=${encodeURIComponent($page.url.pathname)}`, {replaceState: false})
}
}
$: opacity = $page.url.pathname === "/" ? 0.3 : 0.2;
</script>

2
frontend/src/routes/api/[...any].ts

@ -17,8 +17,6 @@ export const get: RequestHandler = async({ request, url, params, locals }) => {
throw err;
});
console.warn(res.status);
const result = {
status: res.status,
body: await res.text(),

42
frontend/src/routes/index.svelte

@ -17,30 +17,30 @@
import Header from "$lib/components/layout/Header.svelte";
import Row from "$lib/components/layout/Row.svelte";
import type Scope from "$lib/models/scope";
import ScopeLink from "$lib/components/frontpage/ScopeLink.svelte";
import ScopeLinkList from "$lib/components/frontpage/ScopeLinkList.svelte";
import ScopeCreateUpdateModal from "$lib/modals/ScopeCreateUpdateModal.svelte";
import OptionsRow from "$lib/components/layout/OptionsRow.svelte";
import Option from "$lib/components/layout/Option.svelte";
import ScopeListContext from "$lib/components/contexts/ScopeListContext.svelte";
export let scopes: Scope[] = [];
</script>
<Header fullwidth wide subtitle="Logging your stuff">Stufflog</Header>
<Columns wide>
<Column>
<Row title="Scopes">
{#each scopes as scope (scope.id)}
<ScopeLink scope={scope} />
{/each}
</Row>
<Row title="Schedule">
</Row>
<Row title="Today">
</Row>
</Column>
<Column>
<Row title="Sprints">
<p>Stuff</p>
</Row>
</Column>
</Columns>
<ScopeListContext scopes={scopes}>
<Columns wide>
<Column>
<Row title="Scopes">
<OptionsRow slot="right">
<Option open={{name: "scope.create"}}>Create</Option>
</OptionsRow>
<ScopeLinkList />
</Row>
</Column>
<Column>
</Column>
</Columns>
<ScopeCreateUpdateModal />
</ScopeListContext>

55
frontend/src/routes/login.svelte

@ -1,6 +1,22 @@
<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 { 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: {
@ -13,30 +29,39 @@
let username = '';
let password = '';
let error: string;
let loading = false;
async function handleSignIn() {
error = void(0);
loading = true;
try {
const res = await Auth.signIn(username, password);
} catch (error) {
console.log(error);
$session.user = {
id: res.attributes.sub,
name: res.username,
};
$session.idToken = res.signInUserSession.idToken.jwtToken;
goto('/', {replaceState: true});
} catch (err) {
error = err?.toString() || err;
}
goto('/');
loading = false;
}
</script>
<form on:submit|preventDefault={handleSignIn}>
<input
type="text"
bind:value={username}
/>
<input
id="Password"
label="Password"
type="password"
bind:value={password}
/>
<button type="submit">Sign In</button>
<Modal disabled={loading} show verb="Login" noun="" error={error}>
<ModalBody>
<label for="name">Username</label>
<input type="text" bind:value={username} />
<label for="name">Password</label>
<input type="password" bind:value={password} />
</ModalBody>
</Modal>
</form>

1
models/sprint.go

@ -9,6 +9,7 @@ type SprintUpdate struct {
ToTime *time.Time `json:"toTime"`
IsTimed *bool `json:"isTimed"`
IsCoarse *bool `json:"isCoarse"`
IsUnweighted *bool `json:"isUnweighted"`
AggregateName *string `json:"aggregateName"`
AggregateRequired *int `json:"aggregateRequired"`
}

1
models/stat.go

@ -3,6 +3,7 @@ package models
type StatUpdate struct {
Name *string `json:"name"`
Weight *float64 `json:"weight"`
Primary *bool `json:"primary"`
Description *string `json:"description"`
AllowedAmounts []StatAllowedAmount `json:"allowedAmounts"`
}

27
ports/httpapi/scopes.go

@ -96,4 +96,31 @@ func Scopes(g *gin.RouterGroup, scopes *scopes.Service) {
return scopes.Create(c.Request.Context(), input.Scope, input.OwnerName)
}))
g.PUT("/:scope_id", handler("scope", func(c *gin.Context) (interface{}, error) {
input := models.ScopeUpdate{}
err := c.BindJSON(&input)
if err != nil {
return nil, models.BadInputError{
Object: "Scope",
Problem: "Invalid JSON: " + err.Error(),
}
}
id, err := reqInt(c, "scope_id")
if err != nil {
return nil, err
}
return scopes.Update(c.Request.Context(), id, input)
}))
g.DELETE("/:scope_id", handler("scope", func(c *gin.Context) (interface{}, error) {
id, err := reqInt(c, "scope_id")
if err != nil {
return nil, err
}
return scopes.Delete(c.Request.Context(), id)
}))
}

2
ports/httpapi/sprints.go

@ -93,7 +93,7 @@ func Sprints(g *gin.RouterGroup, sprintsService *sprints.Service) {
input.SprintID = sprintID
input.PartID = partID
if input.Required > 0 {
if input.Required >= 0 {
return sprintsService.AddPart(c.Request.Context(), input)
} else {
return sprintsService.RemovePart(c.Request.Context(), input)

5
ports/mysql/mysqlcore/sprint.sql.go

@ -198,7 +198,7 @@ AND (
-- or to time is within from..to
OR (from_time <= ? AND to_time > ?)
-- or from and to time are on each their side of from..to
OR (from_time < ? AND to_time >= ?)
OR (from_time >= ? AND to_time < ?)
)
`
@ -280,6 +280,7 @@ SET name = ?,
to_time = ?,
is_timed = ?,
is_coarse = ?,
is_unweighted = ?,
aggregate_name = ?,
aggregate_required = ?
WHERE id = ?
@ -292,6 +293,7 @@ type UpdateSprintParams struct {
ToTime time.Time
IsTimed bool
IsCoarse bool
IsUnweighted bool
AggregateName string
AggregateRequired int
ID int
@ -305,6 +307,7 @@ func (q *Queries) UpdateSprint(ctx context.Context, arg UpdateSprintParams) erro
arg.ToTime,
arg.IsTimed,
arg.IsCoarse,
arg.IsUnweighted,
arg.AggregateName,
arg.AggregateRequired,
arg.ID,

3
ports/mysql/queries/sprint.sql

@ -9,7 +9,7 @@ AND (
-- or to time is within from..to
OR (from_time <= ? AND to_time > ?)
-- or from and to time are on each their side of from..to
OR (from_time < ? AND to_time >= ?)
OR (from_time >= ? AND to_time < ?)
);
@ -30,6 +30,7 @@ SET name = ?,
to_time = ?,
is_timed = ?,
is_coarse = ?,
is_unweighted = ?,
aggregate_name = ?,
aggregate_required = ?
WHERE id = ?;

1
ports/mysql/sprint.go

@ -148,6 +148,7 @@ func (r *sprintRepository) Update(ctx context.Context, sprint entities.Sprint, u
ToTime: sprint.ToTime,
IsTimed: sprint.IsTimed,
IsCoarse: sprint.IsCoarse,
IsUnweighted: sprint.IsUnweighted,
AggregateName: sprint.AggregateName,
AggregateRequired: sprint.AggregateRequired,

2
usecases/scopes/result.go

@ -50,6 +50,7 @@ type ResultStat struct {
ID int `json:"id"`
Name string `json:"name"`
Weight float64 `json:"weight"`
Primary bool `json:"primary"`
Description string `json:"description"`
AllowedAmounts []models.StatAllowedAmount `json:"allowedAmounts,omitempty"`
}
@ -78,6 +79,7 @@ func generateResult(scope entities.Scope, members []entities.ScopeMember, stats
ID: stat.ID,
Name: stat.Name,
Weight: stat.Weight,
Primary: stat.Primary,
Description: stat.Description,
AllowedAmounts: stat.AllowedAmounts,
})

61
usecases/scopes/service.go

@ -156,3 +156,64 @@ func (s *Service) Create(ctx context.Context, scope entities.Scope, ownerName st
return generateResult(*newScope, []entities.ScopeMember{owner}, []entities.Stat{}), nil
}
func (s *Service) Update(ctx context.Context, scopeId int, update models.ScopeUpdate) (*Result, error) {
user := s.Auth.GetUser(ctx)
if user == nil {
return nil, models.PermissionDeniedError{}
}
scope, err := s.Find(ctx, scopeId)
if err != nil {
return nil, err
}
if !scope.Members[user.ID].Owner {
return nil, models.ForbiddenError("You do not own the scope")
}
if update.Name != nil && *update.Name == "" {
return nil, models.BadInputError{
Object: "ScopeUpdate",
Field: "name",
Problem: "Empty name provided",
}
}
if update.Abbreviation != nil && *update.Abbreviation == "" {
return nil, models.BadInputError{
Object: "ScopeUpdate",
Field: "name",
Problem: "Empty name provided",
}
}
err = s.Repository.Update(ctx, scope.Scope, update)
if err != nil {
return nil, err
}
scope.ApplyUpdate(update)
return scope, nil
}
func (s *Service) Delete(ctx context.Context, scopeId int) (*Result, error) {
user := s.Auth.GetUser(ctx)
if user == nil {
return nil, models.PermissionDeniedError{}
}
scope, err := s.Find(ctx, scopeId)
if err != nil {
return nil, err
}
if !scope.Members[user.ID].Owner {
return nil, models.ForbiddenError("You do not own the scope")
}
err = s.Repository.Delete(ctx, scope.Scope)
if err != nil {
return nil, err
}
return scope, nil
}

2
usecases/sprints/result.go

@ -10,7 +10,7 @@ type Result struct {
entities.Sprint
AggregateAcquired int `json:"aggregateAcquired"`
AggregatePlanned int `json:"aggregateTotal"`
AggregatePlanned int `json:"aggregatePlanned"`
ItemsAcquired *int `json:"itemsAcquired,omitempty"`
ItemsRequired *int `json:"itemsRequired,omitempty"`

4
usecases/sprints/service.go

@ -486,5 +486,9 @@ func (s *Service) fill(ctx context.Context, sprint entities.Sprint, parts []enti
res.AggregateAcquired = int(math.Round(aggregateAcquired))
res.AggregatePlanned = int(math.Round(aggregatePlanned))
if res.AggregateRequired < 0 {
res.AggregateRequired = res.AggregatePlanned
}
return &res, nil
}
Loading…
Cancel
Save