Browse Source

add project groups to frontend.

main
Gisle Aune 3 years ago
parent
commit
83eb9862fc
  1. 4
      api/project.go
  2. 2
      database/postgres/projectgroups.go
  3. 5
      models/project.go
  4. 2
      services/loader.go
  5. 12
      svelte-ui/src/App.svelte
  6. 17
      svelte-ui/src/clients/stufflog.ts
  7. 6
      svelte-ui/src/components/ChildEntry.svelte
  8. 17
      svelte-ui/src/components/ColoredNumber.svelte
  9. 6
      svelte-ui/src/components/FocusHandler.svelte
  10. 3
      svelte-ui/src/components/Modal.svelte
  11. 11
      svelte-ui/src/components/OptionRow.svelte
  12. 111
      svelte-ui/src/components/ProjectGroupMenu.svelte
  13. 19
      svelte-ui/src/components/ProjectGroupSelect.svelte
  14. 1
      svelte-ui/src/components/ProjectSelect.svelte
  15. 3
      svelte-ui/src/components/QLList.svelte
  16. 14
      svelte-ui/src/components/QLListItem.svelte
  17. 84
      svelte-ui/src/components/QuestLog.svelte
  18. 2
      svelte-ui/src/components/StatusColor.svelte
  19. 2
      svelte-ui/src/components/Tag.svelte
  20. 19
      svelte-ui/src/forms/ProjectForm.svelte
  21. 105
      svelte-ui/src/forms/ProjectGroupForm.svelte
  22. 3
      svelte-ui/src/models/project.ts
  23. 18
      svelte-ui/src/models/projectgroup.ts
  24. 2
      svelte-ui/src/pages/ProjectPage.svelte
  25. 36
      svelte-ui/src/pages/QLPage.svelte
  26. 2
      svelte-ui/src/stores/markStale.ts
  27. 6
      svelte-ui/src/stores/modal.ts
  28. 48
      svelte-ui/src/stores/projectGroup.ts
  29. 28
      svelte-ui/src/utils/sorters.ts

4
api/project.go

@ -62,6 +62,10 @@ func Project(g *gin.RouterGroup, db database.Database) {
project.UserID = auth.UserID(c) project.UserID = auth.UserID(c)
project.CreatedTime = time.Now().UTC() project.CreatedTime = time.Now().UTC()
if project.GroupID != nil && *project.GroupID == "" {
project.GroupID = nil
}
err = db.Projects().Insert(c.Request.Context(), project) err = db.Projects().Insert(c.Request.Context(), project)
if err != nil { if err != nil {
return nil, err return nil, err

2
database/postgres/projectgroups.go

@ -31,7 +31,7 @@ func (r *projectGroupRepository) Find(ctx context.Context, id string) (*models.P
func (r *projectGroupRepository) List(ctx context.Context, filter models.ProjectGroupFilter) ([]*models.ProjectGroup, error) { func (r *projectGroupRepository) List(ctx context.Context, filter models.ProjectGroupFilter) ([]*models.ProjectGroup, error) {
res := make([]*projectGroupDBO, 0, 16) res := make([]*projectGroupDBO, 0, 16)
err := r.db.SelectContext(ctx, &res, "SELECT * FROM project_group WHERE user_id=$1", filter.UserID)
err := r.db.SelectContext(ctx, &res, "SELECT * FROM project_group WHERE user_id=$1 ORDER BY abbreviation", filter.UserID)
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return []*models.ProjectGroup{}, nil return []*models.ProjectGroup{}, nil

5
models/project.go

@ -24,9 +24,10 @@ type Project struct {
func (project *Project) Update(update ProjectUpdate) { func (project *Project) Update(update ProjectUpdate) {
if update.GroupID != nil { if update.GroupID != nil {
project.GroupID = update.GroupID
if *project.GroupID == "" {
if *update.GroupID == "" {
project.GroupID = nil project.GroupID = nil
} else {
project.GroupID = update.GroupID
} }
} }
if update.Name != nil { if update.Name != nil {

2
services/loader.go

@ -442,7 +442,7 @@ func (l *Loader) ListProjectGroups(ctx context.Context) ([]*models.ProjectGroupR
result := &models.ProjectGroupResult{ result := &models.ProjectGroupResult{
ProjectGroup: models.ProjectGroup{ ProjectGroup: models.ProjectGroup{
ID: "META_UNGROUPED", ID: "META_UNGROUPED",
Name: "Ungrouped",
Name: "Ungrouped Projects",
Abbreviation: "OTHER", Abbreviation: "OTHER",
CategoryNames: map[string]string{}, CategoryNames: map[string]string{},
}, },

12
svelte-ui/src/App.svelte

@ -17,6 +17,7 @@
import GoalForm from "./forms/GoalForm.svelte"; import GoalForm from "./forms/GoalForm.svelte";
import LogForm from "./forms/LogForm.svelte"; import LogForm from "./forms/LogForm.svelte";
import ProjectForm from "./forms/ProjectForm.svelte"; import ProjectForm from "./forms/ProjectForm.svelte";
import ProjectGroupForm from "./forms/ProjectGroupForm.svelte";
import ItemForm from "./forms/ItemForm.svelte"; import ItemForm from "./forms/ItemForm.svelte";
import TaskForm from "./forms/TaskForm.svelte"; import TaskForm from "./forms/TaskForm.svelte";
import TaskLinkForm from "./forms/TaskLinkForm.svelte"; import TaskLinkForm from "./forms/TaskLinkForm.svelte";
@ -25,10 +26,12 @@
import ModalRoute from "./components/ModalRoute.svelte"; import ModalRoute from "./components/ModalRoute.svelte";
import FocusHandler from "./components/FocusHandler.svelte"; import FocusHandler from "./components/FocusHandler.svelte";
import Menu from "./components/Menu.svelte"; import Menu from "./components/Menu.svelte";
import markStale from "./stores/markStale";
async function logout() { async function logout() {
await signOut(); await signOut();
await authStore.check(); await authStore.check();
markStale("*");
} }
onMount(() => { onMount(() => {
@ -45,6 +48,12 @@
<Route path="/" component={FrontPage} /> <Route path="/" component={FrontPage} />
<Route path="/goals/" component={GoalPage} /> <Route path="/goals/" component={GoalPage} />
<Route path="/projects/" component={ProjectPage} /> <Route path="/projects/" component={ProjectPage} />
<Route path="/questlog/:gid/:pid" let:params >
<QlPage groupId={params.gid} projectId={params.pid} />
</Route>
<Route path="/questlog/:gid" let:params >
<QlPage groupId={params.gid} />
</Route>
<Route path="/questlog/" component={QlPage} /> <Route path="/questlog/" component={QlPage} />
<Route path="/logs/" component={LogsPage} /> <Route path="/logs/" component={LogsPage} />
<Route path="/items/" component={GroupPage} /> <Route path="/items/" component={GroupPage} />
@ -63,6 +72,9 @@
<ModalRoute name="project.add"> <ProjectForm creation /></ModalRoute> <ModalRoute name="project.add"> <ProjectForm creation /></ModalRoute>
<ModalRoute name="project.edit"> <ProjectForm /></ModalRoute> <ModalRoute name="project.edit"> <ProjectForm /></ModalRoute>
<ModalRoute name="project.delete"> <ProjectForm deletion /></ModalRoute> <ModalRoute name="project.delete"> <ProjectForm deletion /></ModalRoute>
<ModalRoute name="projectgroup.add"> <ProjectGroupForm creation /></ModalRoute>
<ModalRoute name="projectgroup.edit"> <ProjectGroupForm /></ModalRoute>
<ModalRoute name="projectgroup.delete"> <ProjectGroupForm deletion /></ModalRoute>
<ModalRoute name="item.add"> <ItemForm creation /></ModalRoute> <ModalRoute name="item.add"> <ItemForm creation /></ModalRoute>
<ModalRoute name="item.edit"> <ItemForm /></ModalRoute> <ModalRoute name="item.edit"> <ItemForm /></ModalRoute>
<ModalRoute name="item.delete"> <ItemForm deletion /></ModalRoute> <ModalRoute name="item.delete"> <ItemForm deletion /></ModalRoute>

17
svelte-ui/src/clients/stufflog.ts

@ -5,7 +5,7 @@ import type { TaskFilter, TaskInput, TaskLink, TaskResult, TaskUpdate } from "..
import type { LogFilter, LogInput, LogResult, LogUpdate } from "../models/log"; import type { LogFilter, LogInput, LogResult, LogUpdate } from "../models/log";
import type { GroupInput, GroupResult, GroupUpdate } from "../models/group"; import type { GroupInput, GroupResult, GroupUpdate } from "../models/group";
import type { ItemInput, ItemResult, ItemUpdate } from "../models/item"; import type { ItemInput, ItemResult, ItemUpdate } from "../models/item";
import type { ProjectGroupResult } from "../models/projectgroup";
import type { ProjectGroupInput, ProjectGroupResult, ProjectGroupUpdate } from "../models/projectgroup";
export class StufflogClient { export class StufflogClient {
private root: string; private root: string;
@ -109,6 +109,21 @@ export class StufflogClient {
return data.projectGroups; return data.projectGroups;
} }
async createProjectGroup(input: ProjectGroupInput): Promise<ProjectGroupResult> {
const data = await this.fetch("POST", "/api/projectgroup/", input);
return data.projectGroup;
}
async updateProjectGroup(id: string, input: ProjectGroupUpdate): Promise<ProjectGroupResult> {
const data = await this.fetch("PUT", `/api/projectgroup/${id}`, input);
return data.projectGroup;
}
async deleteProjectGroup(id: string): Promise<ProjectGroupResult> {
const data = await this.fetch("DELETE", `/api/projectgroup/${id}`);
return data.project;
}
async findLog(id: string): Promise<LogResult> { async findLog(id: string): Promise<LogResult> {

6
svelte-ui/src/components/ChildEntry.svelte

@ -8,6 +8,7 @@
interface ActualParent { interface ActualParent {
id: string id: string
groupId: string
name: string name: string
} }
@ -30,6 +31,7 @@
interface EntryCommonSub { interface EntryCommonSub {
id?: string id?: string
groupId?: string
name: string name: string
icon: IconName icon: IconName
project?: EntryCommonSub project?: EntryCommonSub
@ -64,12 +66,12 @@
<Icon block name="link" /> <Icon block name="link" />
</div> </div>
<div class="actual-parent"> <div class="actual-parent">
<a href={`/questlog#${actualParent.id}`}>{actualParent.name}</a>
<a href={`/questlog/${actualParent.groupId}/${actualParent.id}`}>{actualParent.name}</a>
</div> </div>
{/if} {/if}
{:else if (entry.task && entry.task.project != null)} {:else if (entry.task && entry.task.project != null)}
<div class="actual-parent"> <div class="actual-parent">
<a href={`/questlog#${entry.task.project.id}`}>{entry.task.project.name}</a>
<a href={`/questlog/${entry.task.project.groupId}/${entry.task.project.id}`}>{entry.task.project.name}</a>
</div> </div>
{/if} {/if}
<div class="name">{displayName}</div> <div class="name">{displayName}</div>

17
svelte-ui/src/components/ColoredNumber.svelte

@ -0,0 +1,17 @@
<script lang="ts">
import StatusColor from "./StatusColor.svelte";
export let selected: boolean = false;
export let status: string = "to do";
export let number: number | null | undefined = void(0);
let entry: {statusTag: string}
$: entry = {statusTag: status};
</script>
{#if (!!number)}
<StatusColor selected={selected} affects="project" entry={entry}>
<div class="sccfg">{number}</div>
</StatusColor>
{/if}

6
svelte-ui/src/components/FocusHandler.svelte

@ -1,9 +1,13 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import markStale from "../stores/markStale"; import markStale from "../stores/markStale";
let lastFocus = Date.now();
function handleFocus() { function handleFocus() {
markStale("*");
if ((Date.now() - lastFocus) > 60000) {
markStale("*");
lastFocus = Date.now();
}
} }
onMount(() => { onMount(() => {

3
svelte-ui/src/components/Modal.svelte

@ -169,6 +169,9 @@ div.modal :global(input), div.modal :global(select), div.modal :global(textarea)
div.modal :global(select) { div.modal :global(select) {
padding-left: 0.5ch; padding-left: 0.5ch;
} }
div.modal :global(input)::placeholder {
opacity: 0.5;
}
div.modal :global(select:disabled) { div.modal :global(select:disabled) {
background: #4a4a4a; background: #4a4a4a;
color: #fff; color: #fff;

11
svelte-ui/src/components/OptionRow.svelte

@ -1,4 +1,8 @@
<div class="option-row">
<script lang="ts">
export let centered = false;
</script>
<div class="option-row" class:centered>
<slot></slot> <slot></slot>
</div> </div>
@ -7,4 +11,9 @@
margin-left: -0.5ch; margin-left: -0.5ch;
margin-right: -0.5ch; margin-right: -0.5ch;
} }
div.option-row.centered {
text-align: center;
padding-bottom: 0.5em;
}
</style> </style>

111
svelte-ui/src/components/ProjectGroupMenu.svelte

@ -0,0 +1,111 @@
<script lang="ts">
import { tick } from "svelte";
import { navigate } from "svelte-routing";
import type { ProjectGroupResult } from "../models/projectgroup";
import modalStore from "../stores/modal";
import projectGroupStore from "../stores/projectGroup";
import { sortProjects } from "../utils/sorters";
import ColoredNumber from "./ColoredNumber.svelte";
export let groups: ProjectGroupResult[] = [];
export let selected: string;
let failedCount = 0;
function onClickAdd() {
modalStore.set({name: "projectgroup.add"});
}
function onNavigate(group: ProjectGroupResult) {
if (group.projects.length === 0) {
navigate(`/questlog/${group.id}`);
} else {
const projetcs = [...group.projects].sort(sortProjects);
navigate(`/questlog/${group.id}/${projetcs[0].id}`);
// There's some weirdness with navigate. This hack will just do a
// groups = [...groups] in the sttore to get the page to update.
tick().then(() => {
projectGroupStore.fakeRefresh();
})
}
}
</script>
<div class="group-menu">
{#each groups as group (group.id)}
<div class="group-entry" on:click={() => onNavigate(group)} class:selected={selected === group.id}>
<div class="name">{group.abbreviation}</div>
<div class="counts">
<ColoredNumber selected={selected === group.id} status="active" number={group.projectCounts["active"]} />
<ColoredNumber selected={selected === group.id} status="background" number={group.projectCounts["background"]} />
<ColoredNumber selected={selected === group.id} status="progress" number={group.projectCounts["progress"]} />
<ColoredNumber selected={selected === group.id} status="to do" number={group.projectCounts["to do"]} />
<ColoredNumber selected={selected === group.id} status="on hold" number={group.projectCounts["on hold"]} />
</div>
</div>
{/each}
<div class="group-entry add" on:click={onClickAdd}>
<div>+</div>
</div>
</div>
<style>
div.group-menu {
display: flex;
flex-direction: row;
padding: 0 2ch;
border-bottom: 1px solid #333;
margin-bottom: 0.5em;
-webkit-user-select: none;
-moz-user-select: none;
}
div.group-entry {
padding: 0.05em 1ch;
padding-bottom: 0.15em;
margin-right: 2ch;
border: 1px solid #333;
border-bottom: none;
border-top-left-radius: 0.5em;
border-top-right-radius: 0.5em;
cursor: pointer;
color: #777;
font-weight: 300;
}
div.group-entry.add {
color: #333;
}
div.group-entry.add > div {
font-size: 2em;
line-height: 1em;
}
div.group-entry.selected {
background-color: #191919;
color: #AAA;
}
div.group-entry:hover {
color: #CCC;
background-color: #222222;
}
div.name {
text-align: center;
padding: 0em 0.4ch;
}
div.counts {
font-size: 0.666em;
font-weight: 400;
text-align: center;
}
div.counts :global(div) {
display: inline-block;
padding: 0em 0.2ch;
}
</style>

19
svelte-ui/src/components/ProjectGroupSelect.svelte

@ -0,0 +1,19 @@
<script lang="ts">
import projectGroupStore from "../stores/projectGroup";
export let value = "";
export let name = "";
export let disabled = false;
export let optional = false;
</script>
<select name={name} bind:value={value} disabled={disabled || $projectGroupStore.loading}>
{#if optional}
<option value={""} selected={"" === value}>None</option>
{/if}
{#each $projectGroupStore.groups as group (group.id)}
{#if group.id !== "META_UNGROUPED"}
<option value={group.id} selected={group.id === value}>{group.name}</option>
{/if}
{/each}
</select>

1
svelte-ui/src/components/ProjectSelect.svelte

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import type Project from "../models/project"; import type Project from "../models/project";
import projectStore from "../stores/project"; import projectStore from "../stores/project";
interface OptGroup { interface OptGroup {
status: string status: string
projects: Project[] projects: Project[]

3
svelte-ui/src/components/QLList.svelte

@ -4,13 +4,14 @@
export let projects: ProjectResult[]; export let projects: ProjectResult[];
export let label: string = ""; export let label: string = "";
export let selected: string | null = null;
</script> </script>
{#if projects.length > 0} {#if projects.length > 0}
<div class="ql-list"> <div class="ql-list">
<h2>{label}</h2> <h2>{label}</h2>
{#each projects as project (project.id)} {#each projects as project (project.id)}
<QlListItem project={project} />
<QlListItem selected={project.id === selected} project={project} />
{/each} {/each}
</div> </div>
{/if} {/if}

14
svelte-ui/src/components/QLListItem.svelte

@ -1,26 +1,24 @@
<script lang="ts"> <script lang="ts">
import App from "../App.svelte";
import type { ProjectResult } from "../models/project";
import selectionStore from "../stores/selection";
import { navigate } from "svelte-routing";
import type { ProjectResult } from "../models/project";
import DaysLeft from "./DaysLeft.svelte"; import DaysLeft from "./DaysLeft.svelte";
import Icon from "./Icon.svelte"; import Icon from "./Icon.svelte";
import ProjectIcon from "./ProjectIcon.svelte";
import ProjectProgress from "./ProjectProgress.svelte"; import ProjectProgress from "./ProjectProgress.svelte";
import StatusColor from "./StatusColor.svelte"; import StatusColor from "./StatusColor.svelte";
import Tag from "./Tag.svelte";
import Tag from "./Tag.svelte";
import TimeProgress from "./TimeProgress.svelte"; import TimeProgress from "./TimeProgress.svelte";
export let project: ProjectResult; export let project: ProjectResult;
export let selected: boolean;
let selected: boolean;
let completed: boolean; let completed: boolean;
let deadline: boolean; let deadline: boolean;
function handleClick() { function handleClick() {
selectionStore.change("hash", project.id);
navigate(`/questlog/${project.groupId || "META_UNGROUPED"}/${project.id}`);
} }
$: selected = $selectionStore.hash === project.id;
$: completed = !project.active; $: completed = !project.active;
$: deadline = !!project.endTime; $: deadline = !!project.endTime;
</script> </script>

84
svelte-ui/src/components/QuestLog.svelte

@ -9,11 +9,21 @@
import QlList from "./QLList.svelte"; import QlList from "./QLList.svelte";
import Boi from "../components/Boi.svelte"; import Boi from "../components/Boi.svelte";
import type { ModalData } from "../stores/modal"; import type { ModalData } from "../stores/modal";
import type { ProjectGroupResult } from "../models/projectgroup";
import ProjectGroupMenu from "./ProjectGroupMenu.svelte";
import OptionRow from "./OptionRow.svelte";
import Option from "./Option.svelte";
import type ProjectGroup from "../models/projectgroup";
export let projects: ProjectResult[];
export let groups: ProjectGroupResult[];
export let projectId = "";
export let groupId = "";
const mdProjectAdd: ModalData = {name: "project.add"};
let mdProjectAdd: ModalData = {name: "project.add", groupId: null};
let mdGroupEdit: ModalData = {name: "projectgroup.edit", projectGroup: {} as ProjectGroup};
let mdGroupDelete: ModalData = {name: "projectgroup.delete", projectGroup: {} as ProjectGroup};
let projects: ProjectResult[] = [];
let expiringProjects: ProjectResult[]; let expiringProjects: ProjectResult[];
let activeProjects: ProjectResult[]; let activeProjects: ProjectResult[];
let inactiveProjects: ProjectResult[]; let inactiveProjects: ProjectResult[];
@ -22,16 +32,29 @@
let onholdProjects: ProjectResult[]; let onholdProjects: ProjectResult[];
let ideaProjects: ProjectResult[]; let ideaProjects: ProjectResult[];
let project: ProjectResult = null; let project: ProjectResult = null;
let selectedGroup: ProjectGroupResult | null = null;
function sortProjects(a: ProjectResult, b: ProjectResult) { function sortProjects(a: ProjectResult, b: ProjectResult) {
const aName = `${a.tags.slice(0, 1).map(t => t+":").join("")} ${a.name}`.trim(); const aName = `${a.tags.slice(0, 1).map(t => t+":").join("")} ${a.name}`.trim();
const bName = `${b.tags.slice(0, 1).map(t => t+":").join("")} ${b.name}`.trim(); const bName = `${b.tags.slice(0, 1).map(t => t+":").join("")} ${b.name}`.trim();
console.log(aName, bName)
return aName.localeCompare(bName); return aName.localeCompare(bName);
} }
// Ensure a selection.
$: {
if (groupId == "" && groups.length > 0) {
groupId = groups[0].id;
}
}
$: selectedGroup = groups.find(g => g.id === groupId)
$: projects = selectedGroup?.projects || [];
$: mdProjectAdd = { name: "project.add", groupId: groupId }
$: mdGroupEdit = { name: "projectgroup.edit", projectGroup: selectedGroup }
$: mdGroupDelete = { name: "projectgroup.delete", projectGroup: selectedGroup }
$: project = $selectionStore.hash.startsWith("P") ? projects.find(p => p.id === $selectionStore.hash) : null; $: project = $selectionStore.hash.startsWith("P") ? projects.find(p => p.id === $selectionStore.hash) : null;
$: expiringProjects = projects.filter(p => p.active && p.endTime).sort((a,b) => Date.parse(a.endTime) - Date.parse(b.endTime)); $: expiringProjects = projects.filter(p => p.active && p.endTime).sort((a,b) => Date.parse(a.endTime) - Date.parse(b.endTime));
$: activeProjects = projects.filter(p => p.active && !p.endTime).sort(sortProjects); $: activeProjects = projects.filter(p => p.active && !p.endTime).sort(sortProjects);
@ -43,40 +66,28 @@
$: backgroundProjects = inactiveProjects.filter(p => p.statusTag === "background").sort(sortProjects); $: backgroundProjects = inactiveProjects.filter(p => p.statusTag === "background").sort(sortProjects);
$: progressProjects = inactiveProjects.filter(p => p.statusTag === "progress").sort(sortProjects); $: progressProjects = inactiveProjects.filter(p => p.statusTag === "progress").sort(sortProjects);
$: {
if (project === null && projects.length > 0) {
if (lastQuest !== "") {
project = projects.find(p => p.id === lastQuest) || null;
}
if (project === null) {
project = expiringProjects[0] || activeProjects[0] || completedProjects[0] || null;
}
if (project !== null) {
selectionStore.change("hash", project.id);
}
}
}
$: {
if ($selectionStore.hash.startsWith("P")) {
lastQuest = $selectionStore.hash;
}
}
$: project = selectedGroup?.projects.find(p => p.id === projectId) || null;
</script> </script>
<ProjectGroupMenu selected={groupId} groups={groups} />
<div class="quest-log"> <div class="quest-log">
<div class="list"> <div class="list">
<h2>{selectedGroup?.name || ""}</h2>
{#if !!groupId && groupId !== "META_UNGROUPED"}
<OptionRow centered>
<Option open={mdGroupEdit}>Edit Group</Option>
<Option open={mdGroupDelete}>Delete Group</Option>
</OptionRow>
{/if}
<Boi compacter open={mdProjectAdd}>Add Project</Boi> <Boi compacter open={mdProjectAdd}>Add Project</Boi>
<QlList label="Deadlines" projects={expiringProjects} />
<QlList label="Active" projects={activeProjects} />
<QlList label="Background" projects={backgroundProjects} />
<QlList label="Progress" projects={progressProjects} />
<QlList label="To Do" projects={ideaProjects} />
<QlList label="On Hold" projects={onholdProjects} />
<QlList label="Completed" projects={completedProjects} />
<QlList label="Failed" projects={failedProjects} />
<QlList selected={project?.id} label={selectedGroup?.categoryNames["deadlines"] || "Deadlines"} projects={expiringProjects} />
<QlList selected={project?.id} label={selectedGroup?.categoryNames["active"] || "Active"} projects={activeProjects} />
<QlList selected={project?.id} label={selectedGroup?.categoryNames["background"] || "Background"} projects={backgroundProjects} />
<QlList selected={project?.id} label={selectedGroup?.categoryNames["progress"] || "Progress"} projects={progressProjects} />
<QlList selected={project?.id} label={selectedGroup?.categoryNames["to do"] || "To Do"} projects={ideaProjects} />
<QlList selected={project?.id} label={selectedGroup?.categoryNames["on hold"] || "On Hold"} projects={onholdProjects} />
<QlList selected={project?.id} label={selectedGroup?.categoryNames["completed"] || "Completed"} projects={completedProjects} />
<QlList selected={project?.id} label={selectedGroup?.categoryNames["failed"] || "Failed"} projects={failedProjects} />
</div> </div>
<div class="body"> <div class="body">
{#if project != null} {#if project != null}
@ -96,6 +107,13 @@
width: 32ch; width: 32ch;
} }
h2 {
font-weight: 200;
margin: 0;
padding-bottom: 0.2em;
text-align: center;
}
div.body { div.body {
flex-grow: 1; flex-grow: 1;
margin: 1em 1ch; margin: 1em 1ch;

2
svelte-ui/src/components/StatusColor.svelte

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
interface EntryCommon { interface EntryCommon {
active: boolean
active?: boolean
statusTag?: string statusTag?: string
} }

2
svelte-ui/src/components/Tag.svelte

@ -10,8 +10,6 @@
hash = value.charCodeAt(i) + ((hash << 5) - hash); hash = value.charCodeAt(i) + ((hash << 5) - hash);
hash = hash & hash; hash = hash & hash;
} }
console.log(value, hash);
style = `color: hsl(${(hash << 4) % 360}, ${40 + (hash % 8)}%, ${50+(hash % 4)}%)` style = `color: hsl(${(hash << 4) % 360}, ${40 + (hash % 8)}%, ${50+(hash % 4)}%)`
} }

19
svelte-ui/src/forms/ProjectForm.svelte

@ -1,9 +1,12 @@
<script lang="ts"> <script lang="ts">
import { navigate } from "svelte-routing";
import stuffLogClient from "../clients/stufflog"; import stuffLogClient from "../clients/stufflog";
import Checkbox from "../components/Checkbox.svelte"; import Checkbox from "../components/Checkbox.svelte";
import DeadlineSelect from "../components/DeadlineSelect.svelte"; import DeadlineSelect from "../components/DeadlineSelect.svelte";
import IconSelect from "../components/IconSelect.svelte"; import IconSelect from "../components/IconSelect.svelte";
import Modal from "../components/Modal.svelte"; import Modal from "../components/Modal.svelte";
import ProjectGroupSelect from "../components/ProjectGroupSelect.svelte";
import StartTimeSelect from "../components/StartTimeSelect.svelte"; import StartTimeSelect from "../components/StartTimeSelect.svelte";
import StatusTagSelect from "../components/StatusTagSelect.svelte"; import StatusTagSelect from "../components/StatusTagSelect.svelte";
import { DEFAULT_ICON } from "../external/icons"; import { DEFAULT_ICON } from "../external/icons";
@ -18,6 +21,7 @@
const md = $modalStore; const md = $modalStore;
let project: ProjectResult = { let project: ProjectResult = {
id: "", id: "",
groupId: null,
name: "", name: "",
description: "", description: "",
icon: DEFAULT_ICON, icon: DEFAULT_ICON,
@ -33,7 +37,9 @@
if (md.name === "project.edit" || md.name === "project.delete") { if (md.name === "project.edit" || md.name === "project.delete") {
project = md.project; project = md.project;
verb = (md.name === "project.edit") ? "Edit" : "Delete"; verb = (md.name === "project.edit") ? "Edit" : "Delete";
} else if (md.name !== "project.add") {
} else if (md.name === "project.add") {
project.groupId = md.groupId;
} else {
throw new Error("Wrong form") throw new Error("Wrong form")
} }
@ -45,6 +51,7 @@
let icon = project.icon; let icon = project.icon;
let favorite = project.favorite; let favorite = project.favorite;
let subtractAmount = project.subtractAmount; let subtractAmount = project.subtractAmount;
let groupId = project.groupId || "";
let error = null; let error = null;
let loading = false; let loading = false;
let tags = project.tags.join(", "); let tags = project.tags.join(", ");
@ -58,6 +65,7 @@
if (creation) { if (creation) {
stuffLogClient.createProject({ stuffLogClient.createProject({
active: statusTag === "", active: statusTag === "",
groupId: groupId || void(0),
startTime: ( startTime == "" ) ? null : new Date(startTime), startTime: ( startTime == "" ) ? null : new Date(startTime),
endTime: ( endTime == "" ) ? null : new Date(endTime), endTime: ( endTime == "" ) ? null : new Date(endTime),
statusTag: statusTag !== "" ? statusTag : null, statusTag: statusTag !== "" ? statusTag : null,
@ -65,9 +73,11 @@
tags: tags.length > 0 ? tags.split(",").map(t => t.trim()) : [], tags: tags.length > 0 ? tags.split(",").map(t => t.trim()) : [],
name, description, icon, favorite, name, description, icon, favorite,
}).then(() => {
}).then(newProject => {
markStale("project", "task"); markStale("project", "task");
modalStore.close(); modalStore.close();
navigate(`/questlog/${newProject.groupId || "META_UNGROUPED"}/${newProject.id}`);
}).catch(err => { }).catch(err => {
error = err.message ? err.message : err.toString(); error = err.message ? err.message : err.toString();
}).finally(() => { }).finally(() => {
@ -84,6 +94,7 @@
}) })
} else { } else {
stuffLogClient.updateProject(project.id, { stuffLogClient.updateProject(project.id, {
groupId: groupId || "",
endTime: ( endTime == "" ) ? null : new Date(endTime), endTime: ( endTime == "" ) ? null : new Date(endTime),
clearEndTime: ( endTime == "" ), clearEndTime: ( endTime == "" ),
startTime: ( startTime == "" ) ? null : new Date(startTime), startTime: ( startTime == "" ) ? null : new Date(startTime),
@ -101,6 +112,8 @@
markStale("log"); markStale("log");
} }
modalStore.close(); modalStore.close();
navigate(`/questlog/${groupId || "META_UNGROUPED"}/${project.id}`);
}).catch(err => { }).catch(err => {
error = err.message ? err.message : err.toString(); error = err.message ? err.message : err.toString();
}).finally(() => { }).finally(() => {
@ -116,6 +129,8 @@
<Modal show title="{verb} Project" error={error} closable on:close={onClose}> <Modal show title="{verb} Project" error={error} closable on:close={onClose}>
<form on:submit|preventDefault={onSubmit}> <form on:submit|preventDefault={onSubmit}>
<label for="group">Project Group</label>
<ProjectGroupSelect optional name="group" disabled={deletion} bind:value={groupId} />
<label for="name">Name</label> <label for="name">Name</label>
<input disabled={deletion} name="name" type="text" bind:value={name} /> <input disabled={deletion} name="name" type="text" bind:value={name} />
<label for="description">Description</label> <label for="description">Description</label>

105
svelte-ui/src/forms/ProjectGroupForm.svelte

@ -0,0 +1,105 @@
<script lang="ts">
import { navigate } from "svelte-routing";
import stuffLogClient from "../clients/stufflog";
import Modal from "../components/Modal.svelte";
import type ProjectGroup from "../models/projectgroup";
import markStale from "../stores/markStale";
import modalStore from "../stores/modal";
export let deletion = false;
export let creation = false;
const md = $modalStore;
let group: ProjectGroup = {
id: "",
name: "",
description: "",
abbreviation: "",
categoryNames: {},
}
let verb = "Add";
if (md.name === "projectgroup.edit" || md.name === "projectgroup.delete") {
group = md.projectGroup;
verb = (md.name === "projectgroup.edit") ? "Edit" : "Delete";
} else if (md.name !== "projectgroup.add") {
throw new Error("Wrong form")
}
let name = group.name;
let description = group.description;
let abbreviation = group.abbreviation;
let categoryNames = group.categoryNames || {};
let error = null;
let loading = false;
function onSubmit() {
loading = true;
error = null;
if (creation) {
stuffLogClient.createProjectGroup({
name, abbreviation, description, categoryNames
}).then(newGroup => {
markStale("project");
modalStore.close();
navigate(`/questlog/${newGroup.id}`);
}).catch(err => {
error = err.message ? err.message : err.toString();
}).finally(() => {
loading = false;
})
} else if (deletion) {
stuffLogClient.deleteProjectGroup(group.id).then(() => {
markStale("project");
modalStore.close();
navigate("/questlog/");
}).catch(err => {
error = err.message ? err.message : err.toString();
}).finally(() => {
loading = false;
})
} else {
stuffLogClient.updateProjectGroup(group.id, {
name, abbreviation, description,
setCategoryNames: categoryNames,
}).then(() => {
markStale("project");
modalStore.close();
}).catch(err => {
error = err.message ? err.message : err.toString();
}).finally(() => {
loading = false;
})
}
}
function onClose() {
modalStore.close();
}
</script>
<Modal show title="{verb} Project Group" error={error} closable on:close={onClose}>
<form on:submit|preventDefault={onSubmit}>
<label for="name">Name</label>
<input disabled={deletion} name="name" type="text" bind:value={name} />
<label for="abbreviation">Abbreviation</label>
<input disabled={deletion} name="abbreviation" bind:value={abbreviation} />
<label for="description">Description</label>
<textarea disabled={deletion} name="description" bind:value={description} />
<label for="name">Custom Labels</label>
<input disabled={deletion} name="name" type="text" placeholder="Deadlines" bind:value={categoryNames["deadlines"]} />
<input disabled={deletion} name="name" type="text" placeholder="Active" bind:value={categoryNames["active"]} />
<input disabled={deletion} name="name" type="text" placeholder="Background" bind:value={categoryNames["background"]} />
<input disabled={deletion} name="name" type="text" placeholder="Progress" bind:value={categoryNames["progress"]} />
<input disabled={deletion} name="name" type="text" placeholder="To Do" bind:value={categoryNames["to do"]} />
<input disabled={deletion} name="name" type="text" placeholder="On Hold" bind:value={categoryNames["on hold"]} />
<input disabled={deletion} name="name" type="text" placeholder="Completed" bind:value={categoryNames["completed"]} />
<input disabled={deletion} name="name" type="text" placeholder="Failed" bind:value={categoryNames["failed"]} />
<hr />
<button disabled={loading} type="submit">{verb} Project Group</button>
</form>
</Modal>

3
svelte-ui/src/models/project.ts

@ -3,6 +3,7 @@ import type { TaskResult } from "./task";
export default interface Project { export default interface Project {
id: string id: string
groupId: string | null
name: string name: string
description: string description: string
icon: IconName icon: IconName
@ -29,6 +30,7 @@ export interface ProjectFilter {
export interface ProjectInput { export interface ProjectInput {
name: string name: string
groupId?: string
description: string description: string
icon: IconName icon: IconName
active: boolean active: boolean
@ -43,6 +45,7 @@ export interface ProjectInput {
export interface ProjectUpdate { export interface ProjectUpdate {
name?: string name?: string
description?: string description?: string
groupId?: string
icon?: string icon?: string
active?: boolean active?: boolean
startTime?: string | Date startTime?: string | Date

18
svelte-ui/src/models/projectgroup.ts

@ -1,4 +1,4 @@
import type Project from "./project";
import type { ProjectResult } from "./project";
export default interface ProjectGroup { export default interface ProjectGroup {
id: string id: string
@ -8,12 +8,28 @@ export default interface ProjectGroup {
categoryNames: ProjectGroupCategoryMap categoryNames: ProjectGroupCategoryMap
} }
export interface ProjectGroupInput {
name: string
abbreviation: string
description?: string
categoryNames?: ProjectGroupCategoryMap
}
export interface ProjectGroupUpdate {
name?: string
abbreviation?: string
description?: string
setCategoryNames?: ProjectGroupCategoryMap
}
export interface ProjectGroupResult extends ProjectGroup { export interface ProjectGroupResult extends ProjectGroup {
projects: ProjectResult[]
projectCounts: ProjectGroupCountMap projectCounts: ProjectGroupCountMap
taskCounts: ProjectGroupCountMap taskCounts: ProjectGroupCountMap
} }
export interface ProjectGroupCategoryMap { export interface ProjectGroupCategoryMap {
"deadlines"?: string
"completed"?: string "completed"?: string
"failed"?: string "failed"?: string
"on hold"?: string "on hold"?: string

2
svelte-ui/src/pages/ProjectPage.svelte

@ -7,7 +7,7 @@
import type { ModalData } from "../stores/modal"; import type { ModalData } from "../stores/modal";
import projectStore from "../stores/project"; import projectStore from "../stores/project";
const mdProjectAdd: ModalData = {name: "project.add"};
const mdProjectAdd: ModalData = {name: "project.add", groupId: null};
let showInactive = ($projectStore.filter.active === null); let showInactive = ($projectStore.filter.active === null);
$: { $: {

36
svelte-ui/src/pages/QLPage.svelte

@ -1,17 +1,45 @@
<script lang="ts"> <script lang="ts">
import { tick } from "svelte";
import { navigate } from "svelte-routing";
import QuestLog from "../components/QuestLog.svelte"; import QuestLog from "../components/QuestLog.svelte";
import RefreshSelection from "../components/RefreshSelection.svelte"; import RefreshSelection from "../components/RefreshSelection.svelte";
import projectStore from "../stores/project";
import projectGroupStore from "../stores/projectGroup";
import { sortProjects } from "../utils/sorters";
export let groupId = "";
export let projectId = "";
$: {
if ($projectGroupStore.stale && !$projectGroupStore.loading) {
projectGroupStore.load();
}
}
$: { $: {
if ($projectStore.stale && !$projectStore.loading) {
projectStore.load({});
if ($projectGroupStore.groups.length > 0 && groupId === "") {
const group = $projectGroupStore.groups[0];
if (group.projects.length > 0) {
const projects = [...group.projects].sort(sortProjects);
navigate(`/questlog/${group.id}/${projects[0].id}`, {replace: true});
// There's some weirdness with navigate. This hack will just do a
// groups = [...groups] in the sttore to get the page to update.
tick().then(() => {
projectGroupStore.fakeRefresh();
})
} else {
navigate(`/questlog/${group.id}`);
}
} }
} }
</script> </script>
<div class="page"> <div class="page">
<QuestLog projects={$projectStore.projects} />
<QuestLog groups={$projectGroupStore.groups} groupId={groupId} projectId={projectId} />
</div> </div>
<RefreshSelection /> <RefreshSelection />

2
svelte-ui/src/stores/markStale.ts

@ -3,6 +3,7 @@ import groupStore from "./group";
import logStore, { fpLogStore } from "./logs"; import logStore, { fpLogStore } from "./logs";
import projectStore, { fpProjectStore, fpProjectStore2 } from "./project"; import projectStore, { fpProjectStore, fpProjectStore2 } from "./project";
import taskStore, { fpTaskStore } from "./tasks"; import taskStore, { fpTaskStore } from "./tasks";
import projectGroupStore from "./projectGroup";
type ModelName = "goal" | "project" | "task" | "group" | "item" | "log" | "*" type ModelName = "goal" | "project" | "task" | "group" | "item" | "log" | "*"
@ -17,6 +18,7 @@ export default function markStale(...models: ModelName[]) {
projectStore.markStale(); projectStore.markStale();
fpProjectStore.markStale(); fpProjectStore.markStale();
fpProjectStore2.markStale(); fpProjectStore2.markStale();
projectGroupStore.markStale();
} }
if (markAll || models.includes("task")) { if (markAll || models.includes("task")) {
taskStore.markStale(); taskStore.markStale();

6
svelte-ui/src/stores/modal.ts

@ -7,6 +7,7 @@ import type {ProjectResult} from "../models/project";
import type {TaskResult} from "../models/task"; import type {TaskResult} from "../models/task";
import type Project from "../models/project"; import type Project from "../models/project";
import type Task from "../models/task"; import type Task from "../models/task";
import type ProjectGroup from "../models/projectgroup";
export type ModalData = export type ModalData =
| { name: "none" } | { name: "none" }
@ -16,9 +17,12 @@ export type ModalData =
| { name: "task.add", project: ProjectResult } | { name: "task.add", project: ProjectResult }
| { name: "task.edit", task: TaskResult } | { name: "task.edit", task: TaskResult }
| { name: "task.delete", task: TaskResult } | { name: "task.delete", task: TaskResult }
| { name: "project.add" }
| { name: "project.add", groupId: string | null }
| { name: "project.edit", project: ProjectResult } | { name: "project.edit", project: ProjectResult }
| { name: "project.delete", project: ProjectResult } | { name: "project.delete", project: ProjectResult }
| { name: "projectgroup.add" }
| { name: "projectgroup.edit", projectGroup: ProjectGroup }
| { name: "projectgroup.delete", projectGroup: ProjectGroup }
| { name: "group.add" } | { name: "group.add" }
| { name: "group.edit", group: GroupResult } | { name: "group.edit", group: GroupResult }
| { name: "group.delete", group: GroupResult } | { name: "group.delete", group: GroupResult }

48
svelte-ui/src/stores/projectGroup.ts

@ -0,0 +1,48 @@
import { writable } from "svelte/store";
import stuffLogClient from "../clients/stufflog";
import type { ProjectGroupResult } from "../models/projectgroup";
interface ProjectStoreData {
loading: boolean
stale: boolean
groups: ProjectGroupResult[]
}
function createProjectGroupStore() {
const {update, subscribe} = writable<ProjectStoreData>({
loading: false,
stale: true,
groups: [],
});
return {
subscribe,
markStale() {
update(v => ({...v, stale: true}));
},
async loadOne(id: string) {
update(v => ({...v, loading: true}));
const group = await stuffLogClient.findProjectGroup(id);
update(v => ({...v, loading: false, groups: [
...v.groups.filter(g => g.id === id),
group,
]}));
},
fakeRefresh() {
update(v => ({...v, groups: [...v.groups]}))
},
async load() {
update(v => ({...v, loading: true, stale: false}));
const groups = await stuffLogClient.listProjectGroups();
update(v => ({...v, loading: false, groups: groups}));
},
}
}
const projectGroupStore = createProjectGroupStore();
export default projectGroupStore;

28
svelte-ui/src/utils/sorters.ts

@ -0,0 +1,28 @@
import type { ProjectResult } from "../models/project";
const STATUS_ORDER: (string | undefined | null)[] = [
"deadlines",
null,
void(0),
"active",
"background",
"progress",
"to do",
"on hold",
"completed",
"failed",
"declined",
]
export function sortProjects(a: ProjectResult, b: ProjectResult) {
const as = STATUS_ORDER.indexOf(a.statusTag);
const bs = STATUS_ORDER.indexOf(b.statusTag);
if (as !== bs) {
return as - bs;
}
const aName = `${a.tags.slice(0, 1).map(t => t+":").join("")} ${a.name}`.trim();
const bName = `${b.tags.slice(0, 1).map(t => t+":").join("")} ${b.name}`.trim();
return aName.localeCompare(bName);
}
Loading…
Cancel
Save