Browse Source

add more stuff

master
Gisle Aune 2 years ago
parent
commit
3ef0cff6de
  1. 6
      frontend/src/lib/clients/sl3.ts
  2. 6
      frontend/src/lib/components/layout/Columns.svelte
  3. 24
      frontend/src/lib/components/project/ItemSubSection.svelte
  4. 5
      frontend/src/lib/models/common.ts
  5. 34
      frontend/src/lib/models/item.ts
  6. 119
      frontend/src/lib/utils/date.ts
  7. 53
      frontend/src/lib/utils/items.ts
  8. 78
      frontend/src/lib/utils/timeinterval.ts
  9. 2
      frontend/src/routes/[scope=prettyid]/__layout.svelte
  10. 10
      frontend/src/routes/[scope=prettyid]/history.svelte
  11. 50
      frontend/src/routes/[scope=prettyid]/history/[interval].svelte
  12. 64
      frontend/src/routes/[scope=prettyid]/index.svelte
  13. 4
      frontend/src/routes/api/[...any].ts
  14. 2
      models/item.go
  15. 34
      ports/httpapi/items.go

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

@ -1,5 +1,5 @@
import type Item from "$lib/models/item";
import type { ItemInput } 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 Scope from "$lib/models/scope";
@ -53,6 +53,10 @@ export default class SL3APIClient {
return this.fetch<{project: Project}>("GET", `scopes/${scopeId}/projects/${projectId}`).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);
}
async createItem(scopeId: number, input: ItemInput): Promise<Item> {
return this.fetch<{item: Item}>("POST", `scopes/${scopeId}/items`, input).then(r => r.item);
}

6
frontend/src/lib/components/layout/Columns.svelte

@ -1,9 +1,10 @@
<script lang="ts">
export let wide = false;
export let noPadding = false;
export let fullwidth = false;
</script>
<div class="columns" class:wide class:noPadding>
<div class="columns" class:wide class:noPadding class:fullwidth>
<slot></slot>
</div>
@ -21,6 +22,9 @@
&.noPadding
max-width: 100%
&.fullwidth
width: 100%
@media screen and (max-width: 800px)
width: 100%
flex-direction: column

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

@ -7,11 +7,33 @@
import Status from "$lib/models/status";
import Amount from "../common/Amount.svelte";
import AmountRow from "../common/AmountRow.svelte";
import type { IconName } from "../layout/Icon.svelte";
export let item: Item;
export let event: "created" | "scheduled" | "acquired" | "none" = "none";
let icon: IconName
let status: Status
$: {
if (event === "none" || event === "acquired") {
if (item.acquiredTime) {
icon = "check";
status = Status.Completed;
}
} else if (event === "scheduled") {
if (item.acquiredTime) {
icon = "hourglass";
status = Status.Blocked;
}
} else if (event === "created") {
icon = "lightbulb";
status = Status.Available;
}
}
</script>
<SubSection small noProgress title={item.name} icon={item.acquiredTime ? "check" : null} status={Status.Completed}>
<SubSection small noProgress title={item.name} icon={icon} status={status}>
<Markdown source={item.description} />
<OptionsRow slot="right">
{#if item.acquiredTime == null}

5
frontend/src/lib/models/common.ts

@ -0,0 +1,5 @@
export interface TimeInterval<T> {
min: T
max: T
display?: "none" | "days" | "weeks" | "month" | "months" | "year"
}

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

@ -1,3 +1,4 @@
import type { TimeInterval } from "./common";
import type { StatValueInput, StatProgress } from "./stat";
export default interface Item {
@ -21,4 +22,35 @@ export interface ItemInput {
acquiredTime?: string
scheduledDate?: string
stats: StatValueInput[]
}
}
export interface ItemFilter {
ownerId?: string
ids?: number[]
scopeIds?: number[]
projectIds?: number[]
requirementIds?: number[]
statIds?: number[]
scheduledDate?: TimeInterval<string>
createdTime?: TimeInterval<Date | string>
acquiredTime?: TimeInterval<Date | string>
loose?: boolean
}
export interface ItemGroup {
label: string,
list: ItemGroupReference[]
}
export interface ItemGroupReference {
idx: number
event: "created" | "acquired" | "scheduled"
sortKey?: string
}
// this_week
// this_month
// all_time
// week:2022-15
// month:2022-16
// year:2022

119
frontend/src/lib/utils/date.ts

@ -1,3 +1,6 @@
const weekDay = ["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"];
const monthNames = ["January","February","March","April","June","july","August","September","October","November","December"]
export function formatFormTime(time: Date | string): string {
if (!time) {
return "";
@ -15,3 +18,119 @@ export function formatFormTime(time: Date | string): string {
function pad(n:number) {
return n < 10 ? '0'+n : n.toString()
};
export function formatTime(time: Date | string): string {
if (!(time instanceof Date)) {
time = new Date(time);
}
return `${pad(time.getHours())}:${pad(time.getMinutes())}`;
}
export function formatDate(time: Date | string | number): string {
if (!(time instanceof Date)) {
time = new Date(time);
}
return `${time.getFullYear()}-${pad(time.getMonth()+1)}-${pad(time.getDate())}`;
}
export function formatDateTime(time: Date | string | number): string {
if (!(time instanceof Date)) {
time = new Date(time);
}
return `${formatDate(time)} ${formatTime(time)}`
}
export function formatWeekdayDate(time: Date | string | number): string {
if (!(time instanceof Date)) {
time = new Date(time);
}
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 startOfYear(now: Date): Date {
return new Date(now.getFullYear(), 0, 1);
}
export function endOfYear(now: Date): Date {
return new Date(new Date(now.getFullYear() + 1, 0, 1).getTime() - 60000);
}
export function endOfMonth(now: Date): Date {
return new Date(nextMonth(now).getTime() - 60000)
}
export function nextYear(now: Date, n?: number): Date {
return new Date(now.getFullYear() + (n || 1), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes(), now.getSeconds(), now.getMilliseconds());
}
export function lastYear(now: Date): Date {
return new Date(now.getFullYear() - 1, now.getMonth(), now.getDate(), now.getHours(), now.getMinutes(), now.getSeconds(), now.getMilliseconds());
}
export function nextMonth(now: Date): Date {
let result: Date;
if (now.getMonth() == 11) {
result = new Date(now.getFullYear() + 1, 0, 1);
} else {
result = new Date(now.getFullYear(), now.getMonth() + 1, 1);
}
return new Date(result.getTime());
}
export function lastMonth(now: Date): Date {
let result: Date;
if (now.getMonth() == 0) {
result = new Date(now.getFullYear() - 1, 11, 1);
} else {
result = new Date(now.getFullYear(), now.getMonth() - 1, 1);
}
return new Date(result.getTime());
}
export function startOfWeek(now: Date): Date {
return new Date(now.getTime() - ((now.getTimezoneOffset() * -60000) + ((now.getTime() + (86400000 * 3)) % (86400000 * 7))));
}
export function endOfWeek(now: Date): Date {
return new Date(startOfWeek(now).getTime() + (86400000 * 6) + (3600000 * 23) + (60000 * 59))
}
export function startOfDay(now: Date): Date {
return new Date(Math.floor(now.getTime() / 86400000) * 86400000 - (now.getTimezoneOffset() * -60000));
}
export function endOfDay(now: Date): Date {
return new Date(startOfDay(now).getTime() + (3600000 * 23) + (60000 * 59))
}
export function monthName(date: Date): string {
return monthNames[date.getMonth()]
}
export function addDays(date: Date, days: number): Date {
const newDate = new Date(date.getTime());
newDate.setDate(newDate.getDate() + days);
return newDate;
}
export function addMonths(date: Date, months: number): Date {
const newDate = new Date(date.getTime());
newDate.setMonth(newDate.getMonth() + months);
return newDate;
}
export function addYears(date: Date, years: number): Date {
const newDate = new Date(date.getTime());
newDate.setFullYear(newDate.getFullYear() + years);
return newDate;
}

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

@ -0,0 +1,53 @@
import type { ItemGroup, ItemGroupReference } from "$lib/models/item";
import type Item from "$lib/models/item";
import { formatDate, formatDateTime, formatTime, formatWeekdayDate } from "./date";
export function groupItems(items: Item[]): 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)}`
addItem(groups, createKey, i, createSortKey, item, "created");
if (item.scheduledDate) {
addItem(groups, item.scheduledDate, i, `${item.scheduledDate} 23:59:59`, item, "scheduled");
}
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)})),
}));
}
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;
}
}

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

@ -0,0 +1,78 @@
import type { TimeInterval } from "../models/common";
import { addDays, addMonths, addYears, formatDate, startOfDay, startOfMonth, startOfWeek, startOfYear } from "./date";
export default function parseInterval(s: string, date: Date): TimeInterval<Date> {
const [verb, args] = s.split(":")
switch (verb) {
case "":
case "all_time":
return null;
case "next": {
const [amount, unit] = unitNumber(args);
switch (unit) {
case "d": case "days":
return {display: "days", min: startOfDay(date), max: startOfDay(addDays(date, amount))}
case "w": case "weeks":
return {display: "days", min: startOfDay(date), max: startOfDay(addDays(date, amount * 7))}
case "m": case "months":
return {display: "days", min: startOfDay(date), max: startOfDay(addMonths(date, amount))}
case "y": case "years":
return {display: "none", min: startOfDay(date), max: startOfDay(addYears(date, amount))}
}
}
case "last": {
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))}
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))}
case "y": case "years":
return {display: "none", min: startOfDay(addYears(date, -amount)), max: startOfDay(addDays(date, 1))}
}
}
case "today":
return {display: "none", min: startOfDay(date), max: startOfDay(addDays(date, 1))}
case "next_week":
return {display: "weeks", min: startOfWeek(addDays(date, 7)), max: startOfWeek(addDays(date, 14))}
case "this_week":
return {display: "weeks", min: startOfWeek(date), max: startOfWeek(addDays(date, 7))}
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))}
case "this_month":
return {display: "month", min: startOfMonth(date), max: startOfMonth(addMonths(date, 1))}
case "last_month":
return {display: "month", min: startOfMonth(addMonths(date, -1)), max: startOfMonth(date)}
case "next_year":
return {display: "year", min: startOfYear(addYears(date, 1)), max: startOfYear(addYears(date, 2))}
case "this_year":
return {display: "year", min: startOfYear(date), max: startOfYear(addYears(date, 1))}
case "last_year":
return {display: "year", min: startOfYear(addYears(date, -1)), max: startOfYear(date)}
}
}
export function datesOf(interval: TimeInterval<string | Date>): TimeInterval<string> {
if (interval == null) {
return void(0)
}
return {min: formatDate(interval.min), max: formatDate(interval.max)}
}
function unitNumber(s: string): [number, string] {
for (let i = 0; i < s.length; ++i) {
const ch = s.charAt(i);
if (ch < '0' || ch > '9') {
return [parseInt(s.slice(0, i)), s.slice(i)]
}
}
return [parseInt(s), ""];
}

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

@ -26,7 +26,7 @@
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 ProjectListContext from "$lib/components/contexts/ProjectListContext.svelte";
export let scope: Scope;
export let projects: ProjectEntry[];

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

@ -0,0 +1,10 @@
<script lang="ts" context="module">
import type { Load } from "@sveltejs/kit/types/internal";
export const load: Load = async({}) => {
return {
status: 303,
redirect: "history/this_week",
};
}
</script>

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

@ -0,0 +1,50 @@
<script lang="ts" context="module">
import type { Load } from "@sveltejs/kit/types/internal";
import { sl3 } from "$lib/clients/sl3";
import parseInterval, { datesOf } from "$lib/utils/timeinterval";
import { groupItems } from "$lib/utils/items";
import type Item from "$lib/models/item";
import type { 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, {
createdTime: interval,
acquiredTime: interval,
scheduledDate: datesOf(interval),
});
return {
props: { items, groups: groupItems(items), },
};
}
</script>
<script lang="ts">
import ItemSubSection from "$lib/components/project/ItemSubSection.svelte"
export let items: Item[]
export let groups: ItemGroup[]
</script>
<h1>History</h1>
{#each groups as group (group.label)}
<h2>{group.label}</h2>
{#each group.list as ref (ref.idx)}
<ItemSubSection item={items[ref.idx]} event={ref.event} />
{/each}
{/each}
<style lang="sass">
h1
margin-top: 1.5em
h2
text-align: center
font-weight: 100
</style>

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

@ -1,35 +1,64 @@
<script lang="ts" context="module">
import Column from "$lib/components/layout/Column.svelte";
import Columns from "$lib/components/layout/Columns.svelte";
import type { Load } from "@sveltejs/kit/types/internal";
import Row from "$lib/components/layout/Row.svelte";
export const load: Load = async({params, url, fetch}) => {
const scopeId = parseInt(params.scope.split("-")[0]);
import type { Load } from "@sveltejs/kit/types/internal";
console.log(
datesOf(parseInterval("next:7d", new Date)),
parseInterval("today", new Date),
)
export const load: Load = async({}) => {
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: {}
props: {scheduledItems, acquiredItems}
};
}
</script>
<script lang="ts">
<script lang="ts">
import { getScopeContext } from "$lib/components/contexts/ScopeContext.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 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 wide>
<Columns fullwidth>
<Column>
<Row title="Schedule">
</Row>
<Row title="Today">
</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">
@ -45,5 +74,6 @@ import StatCreateEditModal from "$lib/modals/StatCreateEditModal.svelte";
</Column>
</Columns>
<ItemCreateModal />
<StatCreateEditModal />
<DeletionModal />

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

@ -1,7 +1,7 @@
import type { RequestHandler } from "@sveltejs/kit";
export const get: RequestHandler = async({ request, params, locals }) => {
const proxyUrl = `${process.env.STUFFLOG3_API}/api/${params.any}`;
export const get: RequestHandler = async({ request, url, params, locals }) => {
const proxyUrl = `${process.env.STUFFLOG3_API}/api/${params.any}${url.search}`;
const headers = {};
if (locals.idToken != null) {

2
models/item.go

@ -19,7 +19,7 @@ type ItemFilter struct {
AcquiredTime *TimeInterval[time.Time] `json:"acquiredTime,omitempty"`
CreatedTime *TimeInterval[time.Time] `json:"createdTime,omitempty"`
ScheduledDate *TimeInterval[Date] `json:"scheduledDate,omitempty"`
IDs []int `json:"ids"`
IDs []int `json:"ids,omitempty"`
ScopeIDs []int `json:"scopeIds,omitempty"`
ProjectIDs []int `json:"projectIds,omitempty"`
RequirementIDs []int `json:"requirementIds,omitempty"`

34
ports/httpapi/items.go

@ -1,6 +1,7 @@
package httpapi
import (
"encoding/json"
"fmt"
"git.aiterp.net/stufflog3/stufflog3/entities"
"git.aiterp.net/stufflog3/stufflog3/models"
@ -24,6 +25,17 @@ func Items(g *gin.RouterGroup, items *items.Service) {
g.GET("", handler("items", func(c *gin.Context) (interface{}, error) {
filter := models.ItemFilter{}
if rawFilter := c.Query("filter"); rawFilter != "" {
err := json.Unmarshal([]byte(rawFilter), &filter)
if err != nil {
return nil, models.BadInputError{
Object: "Query",
Field: "filter",
Problem: "Invalid raw filter: " + err.Error(),
}
}
}
if queryOwnerId := c.Query("ownerId"); queryOwnerId != "" {
filter.OwnerID = &queryOwnerId
}
@ -180,6 +192,28 @@ func Items(g *gin.RouterGroup, items *items.Service) {
}
}
if filter.ScheduledDate != nil && !filter.ScheduledDate.Valid() {
return nil, models.BadInputError{
Object: "Filter",
Field: "scheduledDate",
Problem: "Invalid scheduled date range",
}
}
if filter.CreatedTime != nil && !filter.CreatedTime.Valid() {
return nil, models.BadInputError{
Object: "Filter",
Field: "createdTime",
Problem: "Invalid created time range",
}
}
if filter.AcquiredTime != nil && !filter.AcquiredTime.Valid() {
return nil, models.BadInputError{
Object: "Filter",
Field: "acquiredTime",
Problem: "Invalid created time range",
}
}
return items.ListScoped(c.Request.Context(), filter)
}))

Loading…
Cancel
Save