Gisle Aune
3 years ago
29 changed files with 524 additions and 64 deletions
-
4api/project.go
-
2database/postgres/projectgroups.go
-
5models/project.go
-
2services/loader.go
-
12svelte-ui/src/App.svelte
-
17svelte-ui/src/clients/stufflog.ts
-
6svelte-ui/src/components/ChildEntry.svelte
-
17svelte-ui/src/components/ColoredNumber.svelte
-
4svelte-ui/src/components/FocusHandler.svelte
-
3svelte-ui/src/components/Modal.svelte
-
11svelte-ui/src/components/OptionRow.svelte
-
111svelte-ui/src/components/ProjectGroupMenu.svelte
-
19svelte-ui/src/components/ProjectGroupSelect.svelte
-
1svelte-ui/src/components/ProjectSelect.svelte
-
3svelte-ui/src/components/QLList.svelte
-
14svelte-ui/src/components/QLListItem.svelte
-
84svelte-ui/src/components/QuestLog.svelte
-
2svelte-ui/src/components/StatusColor.svelte
-
2svelte-ui/src/components/Tag.svelte
-
19svelte-ui/src/forms/ProjectForm.svelte
-
105svelte-ui/src/forms/ProjectGroupForm.svelte
-
3svelte-ui/src/models/project.ts
-
18svelte-ui/src/models/projectgroup.ts
-
2svelte-ui/src/pages/ProjectPage.svelte
-
36svelte-ui/src/pages/QLPage.svelte
-
2svelte-ui/src/stores/markStale.ts
-
6svelte-ui/src/stores/modal.ts
-
48svelte-ui/src/stores/projectGroup.ts
-
28svelte-ui/src/utils/sorters.ts
@ -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} |
@ -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> |
@ -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> |
@ -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> |
@ -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; |
@ -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); |
||||
|
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue