Gisle Aune
5 years ago
commit
190e1eb01d
91 changed files with 13411 additions and 0 deletions
-
14.idea/dictionaries/gisle.xml
-
6.idea/misc.xml
-
8.idea/modules.xml
-
8.idea/stufflog.iml
-
29.idea/watcherTasks.xml
-
768.idea/workspace.xml
-
22Dockerfile
-
137api/activity.go
-
178api/period.go
-
75api/user.go
-
28config/config.go
-
6config/database.go
-
6config/server.go
-
5config/users.go
-
26database/database.go
-
229database/drivers/bolt/activity.go
-
123database/drivers/bolt/activity_test.go
-
76database/drivers/bolt/db.go
-
24database/drivers/bolt/db_test.go
-
339database/drivers/bolt/idx.go
-
215database/drivers/bolt/idx_test.go
-
288database/drivers/bolt/period.go
-
91database/drivers/bolt/user.go
-
80database/drivers/bolt/user_test.go
-
197database/drivers/bolt/usersession.go
-
15database/repositories/activity.go
-
16database/repositories/period.go
-
14database/repositories/user.go
-
16database/repositories/usersessions.go
-
17go.mod
-
84go.sum
-
40internal/generate/id.go
-
87main.go
-
119models/activity.go
-
356models/period.go
-
44models/user.go
-
221services/auth.go
-
98services/scoring.go
-
82slerrors/slerrors.go
-
BINstufflog
-
4svelte-ui/.gitignore
-
64svelte-ui/README.md
-
6465svelte-ui/package-lock.json
-
29svelte-ui/package.json
-
BINsvelte-ui/public/favicon.png
-
63svelte-ui/public/global.css
-
17svelte-ui/public/index.html
-
64svelte-ui/src/App.svelte
-
22svelte-ui/src/LoginCheck.svelte
-
139svelte-ui/src/api/stufflog.js
-
45svelte-ui/src/components/ActivityIcon.svelte
-
42svelte-ui/src/components/ActivityIconSelect.svelte
-
40svelte-ui/src/components/AddBoi.svelte
-
41svelte-ui/src/components/Boi.svelte
-
51svelte-ui/src/components/Col.svelte
-
16svelte-ui/src/components/Link.svelte
-
20svelte-ui/src/components/Menu.svelte
-
28svelte-ui/src/components/MenuItem.svelte
-
168svelte-ui/src/components/ModalFrame.svelte
-
117svelte-ui/src/components/PointsBar.svelte
-
28svelte-ui/src/components/Property.svelte
-
8svelte-ui/src/components/Row.svelte
-
43svelte-ui/src/components/SubGoalInput.svelte
-
10svelte-ui/src/hooks/Modal.svelte
-
10svelte-ui/src/main.js
-
116svelte-ui/src/modals/AddPeriodGoalModal.svelte
-
111svelte-ui/src/modals/AddPeriodLogModal.svelte
-
55svelte-ui/src/modals/AddSubActivityModal.svelte
-
35svelte-ui/src/modals/CreateActivityModal.svelte
-
53svelte-ui/src/modals/CreatePeriodModal.svelte
-
32svelte-ui/src/modals/DeleteActivityModal.svelte
-
32svelte-ui/src/modals/DeletePeriodModal.svelte
-
37svelte-ui/src/modals/EditActivityModal.svelte
-
51svelte-ui/src/modals/EditPeriodModal.svelte
-
57svelte-ui/src/modals/EditSubActivityModal.svelte
-
62svelte-ui/src/modals/InfoPeriodLogModal.svelte
-
40svelte-ui/src/modals/LoginModal.svelte
-
34svelte-ui/src/modals/RemovePeriodGoalModal.svelte
-
38svelte-ui/src/modals/RemovePeriodLogModal.svelte
-
34svelte-ui/src/modals/RemoveSubActivityModal.svelte
-
61svelte-ui/src/models/activity.d.ts
-
25svelte-ui/src/models/activity.js
-
128svelte-ui/src/models/period.d.ts
-
77svelte-ui/src/models/period.js
-
91svelte-ui/src/routes/ActivitiesPage.svelte
-
168svelte-ui/src/routes/LogPage.svelte
-
49svelte-ui/src/stores/auth.js
-
19svelte-ui/src/stores/modal.js
-
203svelte-ui/src/stores/stufflog.js
-
16svelte-ui/src/utils/dateStr.js
-
66svelte-ui/webpack.config.js
@ -0,0 +1,14 @@ |
|||
<component name="ProjectDictionaryState"> |
|||
<dictionary name="gisle"> |
|||
<words> |
|||
<w>gctx</w> |
|||
<w>msgpack</w> |
|||
<w>roleplay</w> |
|||
<w>serr</w> |
|||
<w>sessid</w> |
|||
<w>slerr</w> |
|||
<w>slerror</w> |
|||
<w>stufflog</w> |
|||
</words> |
|||
</dictionary> |
|||
</component> |
@ -0,0 +1,6 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<project version="4"> |
|||
<component name="JavaScriptSettings"> |
|||
<option name="languageLevel" value="ES6" /> |
|||
</component> |
|||
</project> |
@ -0,0 +1,8 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<project version="4"> |
|||
<component name="ProjectModuleManager"> |
|||
<modules> |
|||
<module fileurl="file://$PROJECT_DIR$/.idea/stufflog.iml" filepath="$PROJECT_DIR$/.idea/stufflog.iml" /> |
|||
</modules> |
|||
</component> |
|||
</project> |
@ -0,0 +1,8 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<module type="WEB_MODULE" version="4"> |
|||
<component name="NewModuleRootManager"> |
|||
<content url="file://$MODULE_DIR$" /> |
|||
<orderEntry type="inheritedJdk" /> |
|||
<orderEntry type="sourceFolder" forTests="false" /> |
|||
</component> |
|||
</module> |
@ -0,0 +1,29 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<project version="4"> |
|||
<component name="ProjectTasksOptions"> |
|||
<TaskOptions isEnabled="true"> |
|||
<option name="arguments" value="fmt $FilePath$" /> |
|||
<option name="checkSyntaxErrors" value="true" /> |
|||
<option name="description" /> |
|||
<option name="exitCodeBehavior" value="ERROR" /> |
|||
<option name="fileExtension" value="go" /> |
|||
<option name="immediateSync" value="false" /> |
|||
<option name="name" value="go fmt" /> |
|||
<option name="output" value="$FilePath$" /> |
|||
<option name="outputFilters"> |
|||
<array /> |
|||
</option> |
|||
<option name="outputFromStdout" value="false" /> |
|||
<option name="program" value="$GoExecPath$" /> |
|||
<option name="runOnExternalChanges" value="false" /> |
|||
<option name="scopeName" value="Project Files" /> |
|||
<option name="trackOnlyRoot" value="true" /> |
|||
<option name="workingDir" value="$ProjectFileDir$" /> |
|||
<envs> |
|||
<env name="GOROOT" value="$GOROOT$" /> |
|||
<env name="GOPATH" value="$GOPATH$" /> |
|||
<env name="PATH" value="$GoBinDirs$" /> |
|||
</envs> |
|||
</TaskOptions> |
|||
</component> |
|||
</project> |
@ -0,0 +1,768 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<project version="4"> |
|||
<component name="BookmarkManager"> |
|||
<bookmark url="file://$PROJECT_DIR$/slerrors/slerrors.go" line="38" /> |
|||
</component> |
|||
<component name="ChangeListManager"> |
|||
<list default="true" id="ee4ecc12-436b-456a-8502-29eb7cf43ef4" name="Default Changelist" comment="" /> |
|||
<option name="EXCLUDED_CONVERTED_TO_IGNORED" value="true" /> |
|||
<option name="SHOW_DIALOG" value="false" /> |
|||
<option name="HIGHLIGHT_CONFLICTS" value="true" /> |
|||
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" /> |
|||
<option name="LAST_RESOLUTION" value="IGNORE" /> |
|||
</component> |
|||
<component name="CoverageViewManager"> |
|||
<option name="myElementSize" value="1111" /> |
|||
</component> |
|||
<component name="FileEditorManager"> |
|||
<leaf SIDE_TABS_SIZE_LIMIT_KEY="450"> |
|||
<file pinned="false" current-in-tab="false"> |
|||
<entry file="file://$PROJECT_DIR$/database/drivers/bolt/period.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="8910"> |
|||
<caret line="172" column="18" selection-start-line="172" selection-start-column="18" selection-end-line="172" selection-end-column="18" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
</file> |
|||
<file pinned="false" current-in-tab="false"> |
|||
<entry file="file://$PROJECT_DIR$/models/user.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="486"> |
|||
<caret line="14" column="1" selection-start-line="14" selection-start-column="1" selection-end-line="14" selection-end-column="1" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
</file> |
|||
<file pinned="false" current-in-tab="true"> |
|||
<entry file="file://$PROJECT_DIR$/main.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="1386"> |
|||
<caret line="81" lean-forward="true" selection-start-line="81" selection-end-line="81" /> |
|||
<folding> |
|||
<element signature="e#14#242#0" expanded="true" /> |
|||
</folding> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
</file> |
|||
<file pinned="false" current-in-tab="false"> |
|||
<entry file="file:///usr/lib/go/src/net/http/fs.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="609"> |
|||
<caret line="94" column="5" selection-start-line="94" selection-start-column="5" selection-end-line="94" selection-end-column="5" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
</file> |
|||
<file pinned="false" current-in-tab="false"> |
|||
<entry file="file://$PROJECT_DIR$/models/activity.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="2700"> |
|||
<caret line="53" column="32" selection-start-line="53" selection-start-column="32" selection-end-line="53" selection-end-column="32" /> |
|||
<folding> |
|||
<element signature="e#16#111#0" expanded="true" /> |
|||
</folding> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
</file> |
|||
<file pinned="false" current-in-tab="false"> |
|||
<entry file="file://$PROJECT_DIR$/Dockerfile"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="1134"> |
|||
<caret line="21" column="76" selection-start-line="21" selection-start-column="76" selection-end-line="21" selection-end-column="76" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
</file> |
|||
<file pinned="false" current-in-tab="false"> |
|||
<entry file="file://$PROJECT_DIR$/database/repositories/period.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="540"> |
|||
<caret line="13" column="2" selection-start-line="13" selection-start-column="2" selection-end-line="13" selection-end-column="2" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
</file> |
|||
<file pinned="false" current-in-tab="false"> |
|||
<entry file="file://$PROJECT_DIR$/database/repositories/activity.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="378"> |
|||
<caret line="10" column="2" selection-start-line="10" selection-start-column="2" selection-end-line="10" selection-end-column="2" /> |
|||
<folding> |
|||
<element signature="e#22#79#0" expanded="true" /> |
|||
</folding> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
</file> |
|||
<file pinned="false" current-in-tab="false"> |
|||
<entry file="file://$PROJECT_DIR$/models/period.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="16308"> |
|||
<caret line="306" column="41" selection-start-line="306" selection-start-column="41" selection-end-line="306" selection-end-column="41" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
</file> |
|||
<file pinned="false" current-in-tab="false"> |
|||
<entry file="file://$PROJECT_DIR$/services/scoring.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="3456"> |
|||
<caret line="70" column="2" selection-start-line="70" selection-start-column="2" selection-end-line="70" selection-end-column="2" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
</file> |
|||
</leaf> |
|||
</component> |
|||
<component name="FileTemplateManagerImpl"> |
|||
<option name="RECENT_TEMPLATES"> |
|||
<list> |
|||
<option value="Go File" /> |
|||
</list> |
|||
</option> |
|||
</component> |
|||
<component name="FindInProjectRecents"> |
|||
<findStrings> |
|||
<find>User</find> |
|||
<find>bnAct</find> |
|||
<find>WithContext</find> |
|||
<find>Activity</find> |
|||
<find>an Period</find> |
|||
<find>bn</find> |
|||
<find>key required</find> |
|||
<find>duplicate value</find> |
|||
<find>PeriodLog</find> |
|||
</findStrings> |
|||
</component> |
|||
<component name="GOROOT" path="/usr/lib/go" /> |
|||
<component name="GoLibraries"> |
|||
<option name="indexEntireGoPath" value="false" /> |
|||
</component> |
|||
<component name="IdeDocumentHistory"> |
|||
<option name="CHANGED_PATHS"> |
|||
<list> |
|||
<option value="$PROJECT_DIR$/models/Interval.go" /> |
|||
<option value="$PROJECT_DIR$/database/repositories.go" /> |
|||
<option value="$PROJECT_DIR$/config/database.go" /> |
|||
<option value="$PROJECT_DIR$/database/bolt/user.go" /> |
|||
<option value="$PROJECT_DIR$/database/bolt/db.go" /> |
|||
<option value="$PROJECT_DIR$/database/repos/user.go" /> |
|||
<option value="$PROJECT_DIR$/database/repositories/errors.go" /> |
|||
<option value="$PROJECT_DIR$/go.mod" /> |
|||
<option value="$PROJECT_DIR$/database/generate/id.go" /> |
|||
<option value="$PROJECT_DIR$/database/repositories/usersessions.go" /> |
|||
<option value="$PROJECT_DIR$/database/repositories/goal.go" /> |
|||
<option value="$PROJECT_DIR$/slerrors.go" /> |
|||
<option value="$PROJECT_DIR$/database/drivers/bolt/user.go" /> |
|||
<option value="$PROJECT_DIR$/database/drivers/bolt/usersession.go" /> |
|||
<option value="$PROJECT_DIR$/database/repositories/user.go" /> |
|||
<option value="$PROJECT_DIR$/database/repositories/activity.go" /> |
|||
<option value="$PROJECT_DIR$/database/drivers/bolt/db.go" /> |
|||
<option value="$PROJECT_DIR$/database/drivers/bolt/idx_test.go" /> |
|||
<option value="$PROJECT_DIR$/database/drivers/bolt/db_test.go" /> |
|||
<option value="$PROJECT_DIR$/database/drivers/bolt/user_test.go" /> |
|||
<option value="$PROJECT_DIR$/database/drivers/bolt/activity_test.go" /> |
|||
<option value="$PROJECT_DIR$/models/user.go" /> |
|||
<option value="$PROJECT_DIR$/config/users.go" /> |
|||
<option value="$PROJECT_DIR$/config/server.go" /> |
|||
<option value="$PROJECT_DIR$/config/config.go" /> |
|||
<option value="$PROJECT_DIR$/database/database.go" /> |
|||
<option value="$PROJECT_DIR$/services/period.go" /> |
|||
<option value="$PROJECT_DIR$/services/scorer.go" /> |
|||
<option value="$PROJECT_DIR$/database/drivers/bolt/idx.go" /> |
|||
<option value="$PROJECT_DIR$/api/period.go" /> |
|||
<option value="$PROJECT_DIR$/services/auth.go" /> |
|||
<option value="$PROJECT_DIR$/database/repositories/period.go" /> |
|||
<option value="$PROJECT_DIR$/database/drivers/bolt/activity.go" /> |
|||
<option value="$PROJECT_DIR$/slerrors/slerrors.go" /> |
|||
<option value="$PROJECT_DIR$/api/activity.go" /> |
|||
<option value="$PROJECT_DIR$/api/user.go" /> |
|||
<option value="$PROJECT_DIR$/models/activity.go" /> |
|||
<option value="$PROJECT_DIR$/services/scoring.go" /> |
|||
<option value="$PROJECT_DIR$/database/drivers/bolt/period.go" /> |
|||
<option value="$PROJECT_DIR$/models/period.go" /> |
|||
<option value="$PROJECT_DIR$/Dockerfile" /> |
|||
<option value="$PROJECT_DIR$/main.go" /> |
|||
</list> |
|||
</option> |
|||
</component> |
|||
<component name="MacroExpansionManager"> |
|||
<option name="directoryName" value="ISqLKAuO" /> |
|||
</component> |
|||
<component name="ProjectFrameBounds"> |
|||
<option name="y" value="41" /> |
|||
<option name="width" value="3840" /> |
|||
<option name="height" value="2119" /> |
|||
</component> |
|||
<component name="ProjectView"> |
|||
<navigator proportions="" version="1"> |
|||
<foldersAlwaysOnTop value="true" /> |
|||
</navigator> |
|||
<panes> |
|||
<pane id="ProjectPane"> |
|||
<subPane> |
|||
<expand> |
|||
<path> |
|||
<item name="stufflog" type="b2602c69:ProjectViewProjectNode" /> |
|||
<item name="stufflog" type="462c0819:PsiDirectoryNode" /> |
|||
</path> |
|||
<path> |
|||
<item name="stufflog" type="b2602c69:ProjectViewProjectNode" /> |
|||
<item name="stufflog" type="462c0819:PsiDirectoryNode" /> |
|||
<item name="api" type="462c0819:PsiDirectoryNode" /> |
|||
</path> |
|||
</expand> |
|||
<select /> |
|||
</subPane> |
|||
</pane> |
|||
<pane id="Scope" /> |
|||
</panes> |
|||
</component> |
|||
<component name="PropertiesComponent"> |
|||
<property name="DefaultGoTemplateProperty" value="Go File" /> |
|||
<property name="WebServerToolWindowFactoryState" value="false" /> |
|||
<property name="go.gopath.indexing.explicitly.defined" value="true" /> |
|||
<property name="go.import.settings.migrated" value="true" /> |
|||
<property name="go.sdk.automatically.set" value="true" /> |
|||
<property name="last_opened_file_path" value="$PROJECT_DIR$/../rpdata-api" /> |
|||
<property name="nodejs_interpreter_path.stuck_in_default_project" value="undefined stuck path" /> |
|||
<property name="nodejs_npm_path_reset_for_default_project" value="true" /> |
|||
<property name="nodejs_package_manager_path" value="npm" /> |
|||
<property name="settings.editor.selected.configurable" value="preferences.pluginManager" /> |
|||
</component> |
|||
<component name="RecentsManager"> |
|||
<key name="CopyFile.RECENT_KEYS"> |
|||
<recent name="$PROJECT_DIR$/api" /> |
|||
<recent name="$PROJECT_DIR$/database/drivers/bolt" /> |
|||
<recent name="$PROJECT_DIR$/database/repositories" /> |
|||
<recent name="$PROJECT_DIR$/database" /> |
|||
</key> |
|||
<key name="MoveFile.RECENT_KEYS"> |
|||
<recent name="$PROJECT_DIR$" /> |
|||
<recent name="$PROJECT_DIR$/internal" /> |
|||
<recent name="$PROJECT_DIR$/database/drivers" /> |
|||
<recent name="$PROJECT_DIR$/database/repos" /> |
|||
</key> |
|||
</component> |
|||
<component name="RunDashboard"> |
|||
<option name="ruleStates"> |
|||
<list> |
|||
<RuleState> |
|||
<option name="name" value="ConfigurationTypeDashboardGroupingRule" /> |
|||
</RuleState> |
|||
<RuleState> |
|||
<option name="name" value="StatusDashboardGroupingRule" /> |
|||
</RuleState> |
|||
</list> |
|||
</option> |
|||
</component> |
|||
<component name="RunManager" selected="Go Test.go test github.com/gisle/stufflog/database/drivers/bolt"> |
|||
<configuration name="TestActivityRepository in github.com/gisle/stufflog/database/drivers/bolt" type="GoTestRunConfiguration" factoryName="Go Test" temporary="true" nameIsGenerated="true"> |
|||
<module name="stufflog" /> |
|||
<working_directory value="$PROJECT_DIR$/database/drivers/bolt" /> |
|||
<framework value="gotest" /> |
|||
<kind value="PACKAGE" /> |
|||
<package value="github.com/gisle/stufflog/database/drivers/bolt" /> |
|||
<directory value="$PROJECT_DIR$/" /> |
|||
<filePath value="$PROJECT_DIR$/" /> |
|||
<pattern value="^TestActivityRepository$" /> |
|||
<method v="2" /> |
|||
</configuration> |
|||
<configuration name="TestIndex in github.com/gisle/stufflog/database/drivers/bolt" type="GoTestRunConfiguration" factoryName="Go Test" temporary="true" nameIsGenerated="true"> |
|||
<module name="stufflog" /> |
|||
<working_directory value="$PROJECT_DIR$/database/drivers/bolt" /> |
|||
<framework value="gotest" /> |
|||
<kind value="PACKAGE" /> |
|||
<package value="github.com/gisle/stufflog/database/drivers/bolt" /> |
|||
<directory value="$PROJECT_DIR$/" /> |
|||
<filePath value="$PROJECT_DIR$/" /> |
|||
<pattern value="^TestIndex$" /> |
|||
<method v="2" /> |
|||
</configuration> |
|||
<configuration name="TestIndex/Check_Initial in github.com/gisle/stufflog/database/drivers/bolt" type="GoTestRunConfiguration" factoryName="Go Test" temporary="true" nameIsGenerated="true"> |
|||
<module name="stufflog" /> |
|||
<working_directory value="$PROJECT_DIR$/database/drivers/bolt" /> |
|||
<framework value="gotest" /> |
|||
<kind value="PACKAGE" /> |
|||
<package value="github.com/gisle/stufflog/database/drivers/bolt" /> |
|||
<directory value="$PROJECT_DIR$/" /> |
|||
<filePath value="$PROJECT_DIR$/" /> |
|||
<pattern value="^TestIndex$/^Check_Initial$" /> |
|||
<method v="2" /> |
|||
</configuration> |
|||
<configuration name="TestUserRepository in github.com/gisle/stufflog/database/drivers/bolt" type="GoTestRunConfiguration" factoryName="Go Test" temporary="true" nameIsGenerated="true"> |
|||
<module name="stufflog" /> |
|||
<working_directory value="$PROJECT_DIR$/database/drivers/bolt" /> |
|||
<framework value="gotest" /> |
|||
<kind value="PACKAGE" /> |
|||
<package value="github.com/gisle/stufflog/database/drivers/bolt" /> |
|||
<directory value="$PROJECT_DIR$/" /> |
|||
<filePath value="$PROJECT_DIR$/" /> |
|||
<pattern value="^TestUserRepository$" /> |
|||
<method v="2" /> |
|||
</configuration> |
|||
<configuration name="go test github.com/gisle/stufflog/database/drivers/bolt" type="GoTestRunConfiguration" factoryName="Go Test" temporary="true" nameIsGenerated="true"> |
|||
<module name="stufflog" /> |
|||
<working_directory value="$PROJECT_DIR$/database/drivers/bolt" /> |
|||
<framework value="gotest" /> |
|||
<kind value="PACKAGE" /> |
|||
<package value="github.com/gisle/stufflog/database/drivers/bolt" /> |
|||
<directory value="$PROJECT_DIR$/" /> |
|||
<filePath value="$PROJECT_DIR$/" /> |
|||
<method v="2" /> |
|||
</configuration> |
|||
<recent_temporary> |
|||
<list> |
|||
<item itemvalue="Go Test.go test github.com/gisle/stufflog/database/drivers/bolt" /> |
|||
<item itemvalue="Go Test.TestActivityRepository in github.com/gisle/stufflog/database/drivers/bolt" /> |
|||
<item itemvalue="Go Test.TestUserRepository in github.com/gisle/stufflog/database/drivers/bolt" /> |
|||
<item itemvalue="Go Test.TestIndex in github.com/gisle/stufflog/database/drivers/bolt" /> |
|||
<item itemvalue="Go Test.TestIndex/Check_Initial in github.com/gisle/stufflog/database/drivers/bolt" /> |
|||
</list> |
|||
</recent_temporary> |
|||
</component> |
|||
<component name="TestHistory"> |
|||
<history-entry file="TestActivityRepository_in_github_com_gisle_stufflog_database_drivers_bolt - 2019.09.28 at 17h 07m 39s.xml"> |
|||
<configuration name="TestActivityRepository in github.com/gisle/stufflog/database/drivers/bolt" configurationId="GoTestRunConfiguration" /> |
|||
</history-entry> |
|||
<history-entry file="TestActivityRepository_in_github_com_gisle_stufflog_database_drivers_bolt - 2019.09.28 at 17h 10m 45s.xml"> |
|||
<configuration name="TestActivityRepository in github.com/gisle/stufflog/database/drivers/bolt" configurationId="GoTestRunConfiguration" /> |
|||
</history-entry> |
|||
<history-entry file="TestActivityRepository_in_github_com_gisle_stufflog_database_drivers_bolt - 2019.09.28 at 17h 11m 21s.xml"> |
|||
<configuration name="TestActivityRepository in github.com/gisle/stufflog/database/drivers/bolt" configurationId="GoTestRunConfiguration" /> |
|||
</history-entry> |
|||
<history-entry file="go_test_github_com_gisle_stufflog_database_drivers_bolt - 2019.09.28 at 17h 11m 33s.xml"> |
|||
<configuration name="go test github.com/gisle/stufflog/database/drivers/bolt" configurationId="GoTestRunConfiguration" /> |
|||
</history-entry> |
|||
<history-entry file="TestActivityRepository_in_github_com_gisle_stufflog_database_drivers_bolt - 2019.09.28 at 17h 15m 06s.xml"> |
|||
<configuration name="TestActivityRepository in github.com/gisle/stufflog/database/drivers/bolt" configurationId="GoTestRunConfiguration" /> |
|||
</history-entry> |
|||
<history-entry file="TestActivityRepository_in_github_com_gisle_stufflog_database_drivers_bolt - 2019.09.28 at 17h 16m 12s.xml"> |
|||
<configuration name="TestActivityRepository in github.com/gisle/stufflog/database/drivers/bolt" configurationId="GoTestRunConfiguration" /> |
|||
</history-entry> |
|||
<history-entry file="TestActivityRepository_in_github_com_gisle_stufflog_database_drivers_bolt - 2019.09.28 at 17h 18m 32s.xml"> |
|||
<configuration name="TestActivityRepository in github.com/gisle/stufflog/database/drivers/bolt" configurationId="GoTestRunConfiguration" /> |
|||
</history-entry> |
|||
<history-entry file="go_test_github_com_gisle_stufflog_database_drivers_bolt - 2019.09.28 at 17h 19m 26s.xml"> |
|||
<configuration name="go test github.com/gisle/stufflog/database/drivers/bolt" configurationId="GoTestRunConfiguration" /> |
|||
</history-entry> |
|||
<history-entry file="TestActivityRepository_in_github_com_gisle_stufflog_database_drivers_bolt - 2019.09.28 at 17h 21m 32s.xml"> |
|||
<configuration name="TestActivityRepository in github.com/gisle/stufflog/database/drivers/bolt" configurationId="GoTestRunConfiguration" /> |
|||
</history-entry> |
|||
<history-entry file="go_test_github_com_gisle_stufflog_database_drivers_bolt - 2019.09.28 at 17h 21m 40s.xml"> |
|||
<configuration name="go test github.com/gisle/stufflog/database/drivers/bolt" configurationId="GoTestRunConfiguration" /> |
|||
</history-entry> |
|||
</component> |
|||
<component name="ToolWindowManager"> |
|||
<frame x="0" y="41" width="3840" height="2119" extended-state="0" /> |
|||
<editor active="true" /> |
|||
<layout> |
|||
<window_info active="true" content_ui="combo" id="Project" order="0" sideWeight="0.49605885" visible="true" weight="0.13777421" /> |
|||
<window_info id="Structure" order="1" sideWeight="0.5039411" side_tool="true" visible="true" weight="0.13777421" /> |
|||
<window_info id="Favorites" order="2" side_tool="true" /> |
|||
<window_info anchor="bottom" id="Message" order="0" /> |
|||
<window_info anchor="bottom" id="Find" order="1" weight="0.32947975" /> |
|||
<window_info anchor="bottom" id="Run" order="2" weight="0.32947975" /> |
|||
<window_info anchor="bottom" id="Debug" order="3" weight="0.3998949" /> |
|||
<window_info anchor="bottom" id="Cvs" order="4" weight="0.25" /> |
|||
<window_info anchor="bottom" id="Inspection" order="5" weight="0.4" /> |
|||
<window_info anchor="bottom" id="TODO" order="6" /> |
|||
<window_info anchor="bottom" id="Docker" order="7" show_stripe_button="false" /> |
|||
<window_info anchor="bottom" id="Version Control" order="8" /> |
|||
<window_info anchor="bottom" id="GraphQL" order="9" /> |
|||
<window_info anchor="bottom" id="Database Changes" order="10" /> |
|||
<window_info anchor="bottom" id="Terminal" order="11" /> |
|||
<window_info anchor="bottom" id="Event Log" order="12" side_tool="true" /> |
|||
<window_info anchor="right" id="Commander" internal_type="SLIDING" order="0" type="SLIDING" weight="0.4" /> |
|||
<window_info anchor="right" id="Ant Build" order="1" weight="0.25" /> |
|||
<window_info anchor="right" content_ui="combo" id="Hierarchy" order="2" weight="0.25" /> |
|||
<window_info anchor="right" id="Cargo" order="3" /> |
|||
<window_info anchor="right" id="Database" order="4" /> |
|||
<window_info anchor="right" id="Coverage" order="5" side_tool="true" weight="0.32985553" /> |
|||
</layout> |
|||
</component> |
|||
<component name="TypeScriptGeneratedFilesManager"> |
|||
<option name="version" value="1" /> |
|||
</component> |
|||
<component name="VgoProject"> |
|||
<integration-enabled>true</integration-enabled> |
|||
</component> |
|||
<component name="com.intellij.coverage.CoverageDataManagerImpl"> |
|||
<SUITE FILE_PATH="coverage/stufflog$TestIndex_Insert_Initial_in_github_com_gisle_stufflog_database_drivers_bolt.out" NAME="TestIndex/Insert_Initial in github.com/gisle/stufflog/database/drivers/bolt Coverage Results" MODIFIED="1569258865601" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="GoCoverage" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" /> |
|||
<SUITE FILE_PATH="coverage/stufflog$TestIndex_in_github_com_gisle_stufflog_database_drivers_bolt.out" NAME="TestIndex in github.com/gisle/stufflog/database/drivers/bolt Coverage Results" MODIFIED="1569258979795" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="GoCoverage" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" /> |
|||
<SUITE FILE_PATH="coverage/stufflog$go_test_github_com_gisle_stufflog_database_drivers_bolt.out" NAME="go test github.com/gisle/stufflog/database/drivers/bolt Coverage Results" MODIFIED="1569684100617" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="GoCoverage" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" /> |
|||
</component> |
|||
<component name="editorHistoryManager"> |
|||
<entry file="file:///usr/lib/go/src/strings/builder.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="648"> |
|||
<caret line="22" column="37" lean-forward="true" selection-start-line="22" selection-start-column="37" selection-end-line="22" selection-end-column="37" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/go.sum"> |
|||
<provider selected="true" editor-type-id="text-editor" /> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/go.mod"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="216"> |
|||
<caret line="4" column="9" selection-start-line="4" selection-start-column="9" selection-end-line="4" selection-end-column="9" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/database/generate/logid.go" /> |
|||
<entry file="file://$PROJECT_DIR$/slerrors.go" /> |
|||
<entry file="file:///usr/lib/go/src/strconv/atoi.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="562"> |
|||
<caret line="23" column="13" selection-start-line="23" selection-start-column="5" selection-end-line="23" selection-end-column="13" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/internal/generate/id.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="378"> |
|||
<caret line="13" column="7" selection-start-line="13" selection-start-column="5" selection-end-line="13" selection-end-column="7" /> |
|||
<folding> |
|||
<element signature="e#18#103#0" expanded="true" /> |
|||
</folding> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/database/repositories/usersessions.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="594"> |
|||
<caret line="14" column="7" selection-start-line="14" selection-start-column="7" selection-end-line="14" selection-end-column="7" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$USER_HOME$/go/pkg/mod/go.etcd.io/bbolt@v1.3.3/errors.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="3456"> |
|||
<caret line="64" column="2" selection-start-line="64" selection-start-column="2" selection-end-line="64" selection-end-column="2" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/database/repositories/errors.go" /> |
|||
<entry file="file://$USER_HOME$/go/pkg/mod/go.etcd.io/bbolt@v1.3.3/cursor.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="588"> |
|||
<caret line="116" column="17" selection-start-line="116" selection-start-column="17" selection-end-line="116" selection-end-column="17" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file:///usr/lib/go/src/bytes/bytes.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="609"> |
|||
<caret line="25" column="5" selection-start-line="25" selection-start-column="5" selection-end-line="25" selection-end-column="5" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file:///usr/lib/go/src/time/format.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="588"> |
|||
<caret line="81" column="17" lean-forward="true" selection-start-line="81" selection-start-column="17" selection-end-line="81" selection-end-column="27" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file:///usr/lib/go/src/builtin/builtin.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="1473"> |
|||
<caret line="149" column="20" selection-start-line="149" selection-start-column="15" selection-end-line="149" selection-end-column="20" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/database/drivers/bolt/usersession.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="630"> |
|||
<caret line="116" lean-forward="true" selection-start-line="116" selection-end-line="128" selection-end-column="4" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/database/drivers/bolt/db_test.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="702"> |
|||
<caret line="13" column="29" selection-start-line="13" selection-start-column="29" selection-end-line="13" selection-end-column="29" /> |
|||
<folding> |
|||
<element signature="e#14#50#0" expanded="true" /> |
|||
</folding> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/database/drivers/bolt/user.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="480"> |
|||
<caret line="20" column="20" selection-start-line="20" selection-start-column="20" selection-end-line="20" selection-end-column="20" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/database/drivers/bolt/idx_test.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="432"> |
|||
<caret line="11" selection-start-line="11" selection-end-line="13" /> |
|||
<folding> |
|||
<element signature="e#14#108#0" expanded="true" /> |
|||
</folding> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/database/drivers/bolt/user_test.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="540"> |
|||
<caret line="40" selection-start-line="40" selection-end-line="44" selection-end-column="4" /> |
|||
<folding> |
|||
<element signature="e#14#46#0" expanded="true" /> |
|||
</folding> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/database/drivers/bolt/activity_test.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="594"> |
|||
<caret line="11" column="18" selection-start-line="11" selection-start-column="18" selection-end-line="11" selection-end-column="18" /> |
|||
<folding> |
|||
<element signature="e#14#120#0" expanded="true" /> |
|||
</folding> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file:///usr/lib/go/src/net/http/cookie.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="609"> |
|||
<caret line="30" column="2" selection-start-line="30" selection-start-column="2" selection-end-line="30" selection-end-column="2" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file:///usr/lib/go/src/net/http/request.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="879"> |
|||
<caret line="357" column="8" lean-forward="true" selection-start-line="357" selection-start-column="8" selection-end-line="357" selection-end-column="8" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$USER_HOME$/go/pkg/mod/github.com/gin-gonic/gin@v1.4.0/context.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="588"> |
|||
<caret line="120" column="18" selection-start-line="120" selection-start-column="18" selection-end-line="120" selection-end-column="18" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$USER_HOME$/go/pkg/mod/github.com/gin-gonic/gin@v1.4.0/routergroup.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="609"> |
|||
<caret line="57" column="26" selection-start-line="57" selection-start-column="26" selection-end-line="57" selection-end-column="26" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/config/server.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="324"> |
|||
<caret line="6" lean-forward="true" selection-start-line="6" selection-end-line="6" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/config/database.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="216"> |
|||
<caret line="4" column="27" selection-start-line="4" selection-start-column="27" selection-end-line="4" selection-end-column="27" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/config/config.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="576"> |
|||
<caret line="12" column="33" selection-start-line="12" selection-start-column="33" selection-end-line="12" selection-end-column="33" /> |
|||
<folding> |
|||
<element signature="e#16#52#0" expanded="true" /> |
|||
</folding> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/database/database.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="486"> |
|||
<caret line="14" column="31" selection-start-line="14" selection-start-column="31" selection-end-line="14" selection-end-column="31" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/config/users.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="108"> |
|||
<caret line="2" column="5" selection-start-line="2" selection-start-column="5" selection-end-line="2" selection-end-column="5" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file:///usr/lib/go/src/strings/compare.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="588"> |
|||
<caret line="12" column="5" selection-start-line="12" selection-start-column="5" selection-end-line="12" selection-end-column="5" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file:///usr/lib/go/src/sort/sort.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="987"> |
|||
<caret line="99" column="22" lean-forward="true" selection-start-line="99" selection-start-column="22" selection-end-line="99" selection-end-column="22" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/database/repositories/user.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="540"> |
|||
<caret line="10" selection-start-line="10" selection-end-line="10" /> |
|||
<folding> |
|||
<element signature="e#22#79#0" expanded="true" /> |
|||
</folding> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file:///usr/lib/go/src/time/time.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="717"> |
|||
<caret line="1269" lean-forward="true" selection-start-line="1269" selection-end-line="1269" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/database/drivers/bolt/idx.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="8964"> |
|||
<caret line="181" column="11" selection-start-line="181" selection-start-column="11" selection-end-line="181" selection-end-column="11" /> |
|||
<folding> |
|||
<element signature="e#14#106#0" expanded="true" /> |
|||
</folding> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/services/auth.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="306"> |
|||
<caret line="169" column="48" selection-start-line="169" selection-start-column="48" selection-end-line="169" selection-end-column="48" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/database/drivers/bolt/db.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="1836"> |
|||
<caret line="50" column="3" selection-start-line="50" selection-start-column="3" selection-end-line="50" selection-end-column="3" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/database/drivers/bolt/activity.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="4050"> |
|||
<caret line="82" column="7" selection-start-line="82" selection-start-column="7" selection-end-line="82" selection-end-column="7" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/api/activity.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="2538"> |
|||
<caret line="54" column="28" selection-start-line="54" selection-start-column="28" selection-end-line="54" selection-end-column="28" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/slerrors/slerrors.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="3402"> |
|||
<caret line="69" column="1" selection-start-line="69" selection-start-column="1" selection-end-line="69" selection-end-column="1" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/api/user.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="648"> |
|||
<caret line="17" selection-start-line="17" selection-end-line="17" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/api/period.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="7074"> |
|||
<caret line="138" column="36" selection-start-line="138" selection-start-column="36" selection-end-line="138" selection-end-column="36" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/database/drivers/bolt/period.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="8910"> |
|||
<caret line="172" column="18" selection-start-line="172" selection-start-column="18" selection-end-line="172" selection-end-column="18" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/models/user.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="486"> |
|||
<caret line="14" column="1" selection-start-line="14" selection-start-column="1" selection-end-line="14" selection-end-column="1" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/models/activity.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="2700"> |
|||
<caret line="53" column="32" selection-start-line="53" selection-start-column="32" selection-end-line="53" selection-end-column="32" /> |
|||
<folding> |
|||
<element signature="e#16#111#0" expanded="true" /> |
|||
</folding> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/database/repositories/period.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="540"> |
|||
<caret line="13" column="2" selection-start-line="13" selection-start-column="2" selection-end-line="13" selection-end-column="2" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/database/repositories/activity.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="378"> |
|||
<caret line="10" column="2" selection-start-line="10" selection-start-column="2" selection-end-line="10" selection-end-column="2" /> |
|||
<folding> |
|||
<element signature="e#22#79#0" expanded="true" /> |
|||
</folding> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/models/period.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="16308"> |
|||
<caret line="306" column="41" selection-start-line="306" selection-start-column="41" selection-end-line="306" selection-end-column="41" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/services/scoring.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="3456"> |
|||
<caret line="70" column="2" selection-start-line="70" selection-start-column="2" selection-end-line="70" selection-end-column="2" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/Dockerfile"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="1134"> |
|||
<caret line="21" column="76" selection-start-line="21" selection-start-column="76" selection-end-line="21" selection-end-column="76" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file:///usr/lib/go/src/net/http/fs.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="609"> |
|||
<caret line="94" column="5" selection-start-line="94" selection-start-column="5" selection-end-line="94" selection-end-column="5" /> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
<entry file="file://$PROJECT_DIR$/main.go"> |
|||
<provider selected="true" editor-type-id="text-editor"> |
|||
<state relative-caret-position="1386"> |
|||
<caret line="81" lean-forward="true" selection-start-line="81" selection-end-line="81" /> |
|||
<folding> |
|||
<element signature="e#14#242#0" expanded="true" /> |
|||
</folding> |
|||
</state> |
|||
</provider> |
|||
</entry> |
|||
</component> |
|||
</project> |
@ -0,0 +1,22 @@ |
|||
# 1. Backend |
|||
FROM golang:1.13 AS build-backend |
|||
COPY . /project |
|||
WORKDIR /project |
|||
RUN go get |
|||
RUN CGO_ENABLED=0 go build -ldflags "-w -s" . |
|||
|
|||
# 2. Frontend |
|||
FROM node:10-alpine AS build-frontend |
|||
COPY svelte-ui /project |
|||
WORKDIR project |
|||
RUN npm install |
|||
RUN npm run build |
|||
|
|||
# 3. Release |
|||
FROM alpine:3.9.5 AS release |
|||
RUN apk add --no-cache ca-certificates |
|||
|
|||
COPY --from=build-backend /project/stufflog /usr/local/bin/stufflog |
|||
COPY --from=build-frontend /project/public /usr/share/stufflog-ui |
|||
|
|||
CMD ["/usr/local/bin/stufflog", "-conf", "/etc/stufflog/stufflog.yaml", "-ui", "/usr/share/stufflog-ui/"] |
@ -0,0 +1,137 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"github.com/gin-gonic/gin" |
|||
"github.com/gisle/stufflog/database" |
|||
"github.com/gisle/stufflog/models" |
|||
"github.com/gisle/stufflog/services" |
|||
"github.com/gisle/stufflog/slerrors" |
|||
"sort" |
|||
) |
|||
|
|||
func Activity(g *gin.RouterGroup, db database.Database, auth *services.AuthService) { |
|||
type resObj struct { |
|||
Activity *models.Activity `json:"activity,omitempty"` |
|||
Activities []*models.Activity `json:"activities,omitempty"` |
|||
} |
|||
|
|||
g.Use(auth.GinSessionMiddleware(true)) |
|||
|
|||
// GET / – List own activities.
|
|||
g.GET("/", func(c *gin.Context) { |
|||
user := auth.UserFromContext(c) |
|||
|
|||
activities, err := db.Activities().ListUser(c.Request.Context(), *user) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, err) |
|||
return |
|||
} |
|||
|
|||
sort.Sort(models.ActivitiesByName(activities)) |
|||
|
|||
c.JSON(200, resObj{Activities: activities}) |
|||
}) |
|||
|
|||
// GET / — Find an activity
|
|||
g.GET("/:id", func(c *gin.Context) { |
|||
user := auth.UserFromContext(c) |
|||
|
|||
activity, err := db.Activities().FindID(c.Request.Context(), c.Param("id")) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, err) |
|||
return |
|||
} |
|||
if activity.UserID != user.ID { |
|||
slerrors.GinRespond(c, slerrors.NotFound("Activity")) |
|||
return |
|||
} |
|||
|
|||
c.JSON(200, resObj{Activity: activity}) |
|||
}) |
|||
|
|||
// POST / – Create an activity
|
|||
g.POST("/", func(c *gin.Context) { |
|||
user := auth.UserFromContext(c) |
|||
activity := models.Activity{} |
|||
|
|||
err := c.BindJSON(&activity) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, &slerrors.SLError{Code: 400, Text: err.Error()}) |
|||
return |
|||
} |
|||
|
|||
activity.UserID = user.ID |
|||
activity.GenerateIDs() |
|||
|
|||
err = db.Activities().Insert(c.Request.Context(), activity) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, err) |
|||
return |
|||
} |
|||
|
|||
c.JSON(200, resObj{Activity: &activity}) |
|||
}) |
|||
|
|||
// PATCH /:id – Update one activity
|
|||
g.PATCH("/:id", func(c *gin.Context) { |
|||
user := auth.UserFromContext(c) |
|||
updates := make([]*models.ActivityUpdate, 0, 8) |
|||
|
|||
err := c.BindJSON(&updates) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, &slerrors.SLError{Code: 400, Text: err.Error()}) |
|||
return |
|||
} |
|||
|
|||
activity, err := db.Activities().FindID(c.Request.Context(), c.Param("id")) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, err) |
|||
return |
|||
} |
|||
if activity.UserID != user.ID { |
|||
slerrors.GinRespond(c, slerrors.NotFound("Activity")) |
|||
return |
|||
} |
|||
|
|||
activity, err = db.Activities().Update(c.Request.Context(), *activity, updates) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, err) |
|||
return |
|||
} |
|||
|
|||
c.JSON(200, resObj{Activity: activity}) |
|||
}) |
|||
|
|||
// DELETE /:id – Delete an activity
|
|||
g.DELETE("/:id", func(c *gin.Context) { |
|||
user := auth.UserFromContext(c) |
|||
|
|||
activity, err := db.Activities().FindID(c.Request.Context(), c.Param("id")) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, err) |
|||
return |
|||
} |
|||
if activity.UserID != user.ID { |
|||
slerrors.GinRespond(c, slerrors.NotFound("Activity")) |
|||
return |
|||
} |
|||
|
|||
periods, err := db.Periods().ListActivity(c.Request.Context(), *activity) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, slerrors.Wrap(err)) |
|||
return |
|||
} |
|||
if len(periods) > 0 { |
|||
slerrors.GinRespond(c, slerrors.PreconditionFailed("You cannot delete an activity that's in use.")) |
|||
return |
|||
} |
|||
|
|||
err = db.Activities().Remove(c.Request.Context(), *activity) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, err) |
|||
return |
|||
} |
|||
|
|||
c.JSON(200, resObj{Activity: activity}) |
|||
}) |
|||
} |
@ -0,0 +1,178 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"github.com/gin-gonic/gin" |
|||
"github.com/gisle/stufflog/database" |
|||
"github.com/gisle/stufflog/models" |
|||
"github.com/gisle/stufflog/services" |
|||
"github.com/gisle/stufflog/slerrors" |
|||
"sort" |
|||
) |
|||
|
|||
func Period(g *gin.RouterGroup, db database.Database, scoring *services.ScoringService, auth *services.AuthService) { |
|||
type resObj struct { |
|||
Period *models.Period `json:"period,omitempty"` |
|||
Periods []*models.Period `json:"periods,omitempty"` |
|||
Activities []*models.Activity `json:"activities,omitempty"` |
|||
} |
|||
|
|||
g.Use(auth.GinSessionMiddleware(true)) |
|||
|
|||
// GET / – List own periods.
|
|||
g.GET("/", func(c *gin.Context) { |
|||
user := auth.UserFromContext(c) |
|||
|
|||
periods, err := db.Periods().ListUser(c.Request.Context(), *user) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, err) |
|||
return |
|||
} |
|||
|
|||
sort.Sort(models.PeriodsByFrom(periods)) |
|||
|
|||
c.JSON(200, resObj{Periods: periods}) |
|||
}) |
|||
|
|||
// GET / — Find a period
|
|||
g.GET("/:id", func(c *gin.Context) { |
|||
user := auth.UserFromContext(c) |
|||
|
|||
period, err := db.Periods().FindID(c.Request.Context(), c.Param("id")) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, err) |
|||
return |
|||
} |
|||
if period.UserID != user.ID { |
|||
slerrors.GinRespond(c, slerrors.NotFound("Period")) |
|||
return |
|||
} |
|||
|
|||
activities := make([]*models.Activity, 0, 8) |
|||
activityAdded := make(map[string]bool) |
|||
for _, goal := range period.Goals { |
|||
if !activityAdded[goal.ActivityID] { |
|||
activityAdded[goal.ActivityID] = true |
|||
|
|||
activity, err := db.Activities().FindID(c.Request.Context(), goal.ActivityID) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, err) |
|||
return |
|||
} |
|||
|
|||
activities = append(activities, activity) |
|||
} |
|||
} |
|||
|
|||
c.JSON(200, resObj{Period: period, Activities: activities}) |
|||
}) |
|||
|
|||
// POST / – Create a period
|
|||
g.POST("/", func(c *gin.Context) { |
|||
user := auth.UserFromContext(c) |
|||
period := models.Period{} |
|||
|
|||
err := c.BindJSON(&period) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, &slerrors.SLError{Code: 400, Text: err.Error()}) |
|||
return |
|||
} |
|||
|
|||
period.GenerateIDs() |
|||
period.RemoveDuplicateTags() |
|||
period.UserID = user.ID |
|||
period.Logs = make([]models.PeriodLog, 0) |
|||
period.ShouldReScore = false |
|||
|
|||
if period.Tags == nil { |
|||
period.Tags = make([]string, 0) |
|||
} |
|||
if period.Goals == nil { |
|||
period.Goals = make([]models.PeriodGoal, 0) |
|||
} |
|||
for i := range period.Goals { |
|||
if period.Goals[i].SubGoals == nil { |
|||
period.Goals[i].SubGoals = make([]models.PeriodSubGoal, 0) |
|||
} |
|||
} |
|||
|
|||
err = db.Periods().Insert(c.Request.Context(), period) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, err) |
|||
return |
|||
} |
|||
|
|||
c.JSON(200, resObj{Period: &period}) |
|||
}) |
|||
|
|||
// PATCH /:id – Update one Period
|
|||
g.PATCH("/:id", func(c *gin.Context) { |
|||
user := auth.UserFromContext(c) |
|||
updates := make([]*models.PeriodUpdate, 0, 8) |
|||
|
|||
err := c.BindJSON(&updates) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, &slerrors.SLError{Code: 400, Text: err.Error()}) |
|||
return |
|||
} |
|||
|
|||
period, err := db.Periods().FindID(c.Request.Context(), c.Param("id")) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, err) |
|||
return |
|||
} |
|||
if period.UserID != user.ID { |
|||
slerrors.GinRespond(c, slerrors.NotFound("Period")) |
|||
return |
|||
} |
|||
|
|||
for _, update := range updates { |
|||
if update.AddLog != nil { |
|||
score, err := scoring.ScoreOne(c.Request.Context(), *period, *update.AddLog) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, err) |
|||
return |
|||
} |
|||
update.AddLog.Score = score |
|||
} |
|||
} |
|||
|
|||
period, err = db.Periods().Update(c.Request.Context(), *period, updates) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, err) |
|||
return |
|||
} |
|||
|
|||
if period.ShouldReScore { |
|||
period, err = scoring.ScoreAll(c.Request.Context(), *period) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, err) |
|||
return |
|||
} |
|||
} |
|||
|
|||
c.JSON(200, resObj{Period: period}) |
|||
}) |
|||
|
|||
// DELETE /:id – Delete a period
|
|||
g.DELETE("/:id", func(c *gin.Context) { |
|||
user := auth.UserFromContext(c) |
|||
|
|||
period, err := db.Periods().FindID(c.Request.Context(), c.Param("id")) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, err) |
|||
return |
|||
} |
|||
if period.UserID != user.ID { |
|||
slerrors.GinRespond(c, slerrors.NotFound("Period")) |
|||
return |
|||
} |
|||
|
|||
err = db.Periods().Remove(c.Request.Context(), *period) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, err) |
|||
return |
|||
} |
|||
|
|||
c.JSON(200, resObj{Period: period}) |
|||
}) |
|||
} |
@ -0,0 +1,75 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"github.com/gin-gonic/gin" |
|||
"github.com/gisle/stufflog/models" |
|||
"github.com/gisle/stufflog/services" |
|||
"github.com/gisle/stufflog/slerrors" |
|||
) |
|||
|
|||
func User(g *gin.RouterGroup, auth *services.AuthService) { |
|||
type loginRegisterRequest struct { |
|||
Username string `json:"username"` |
|||
Password string `json:"password"` |
|||
} |
|||
type resObj struct { |
|||
User *models.User `json:"user,omitempty"` |
|||
} |
|||
|
|||
g.Use(auth.GinSessionMiddleware(false)) |
|||
|
|||
// GET / – Check session
|
|||
g.GET("/", func(c *gin.Context) { |
|||
c.JSON(200, resObj{ |
|||
User: auth.UserFromContext(c.Request.Context()), |
|||
}) |
|||
}) |
|||
|
|||
g.POST("/register", func(c *gin.Context) { |
|||
data := loginRegisterRequest{} |
|||
err := c.BindJSON(&data) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, &slerrors.SLError{Code: 400, Text: err.Error()}) |
|||
return |
|||
} |
|||
|
|||
user, err := auth.Register(c.Request.Context(), data.Username, data.Password) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, err) |
|||
return |
|||
} |
|||
|
|||
c.JSON(200, resObj{User: user}) |
|||
}) |
|||
|
|||
g.POST("/login", func(c *gin.Context) { |
|||
data := loginRegisterRequest{} |
|||
err := c.BindJSON(&data) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, &slerrors.SLError{Code: 400, Text: err.Error()}) |
|||
return |
|||
} |
|||
|
|||
user, session, err := auth.Login(c.Request.Context(), data.Username, data.Password) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, err) |
|||
return |
|||
} |
|||
|
|||
auth.GinSaveSession(c, *session) |
|||
|
|||
c.JSON(200, resObj{User: user}) |
|||
}) |
|||
|
|||
g.POST("/logout", func(c *gin.Context) { |
|||
err := auth.Logout(c.Request.Context()) |
|||
if err != nil { |
|||
slerrors.GinRespond(c, err) |
|||
return |
|||
} |
|||
|
|||
auth.GinClearSession(c) |
|||
|
|||
c.JSON(200, resObj{}) |
|||
}) |
|||
} |
@ -0,0 +1,28 @@ |
|||
package config |
|||
|
|||
import ( |
|||
"gopkg.in/yaml.v2" |
|||
"os" |
|||
) |
|||
|
|||
var root Config |
|||
|
|||
type Config struct { |
|||
Database Database `yaml:"database"` |
|||
Users Users `yaml:"users"` |
|||
Server Server `yaml:"server"` |
|||
} |
|||
|
|||
func Get() Config { |
|||
return root |
|||
} |
|||
|
|||
func Load(path string) (Config, error) { |
|||
f, err := os.Open(path) |
|||
if err != nil { |
|||
return Config{}, err |
|||
} |
|||
|
|||
err = yaml.NewDecoder(f).Decode(&root) |
|||
return root, err |
|||
} |
@ -0,0 +1,6 @@ |
|||
package config |
|||
|
|||
type Database struct { |
|||
Driver string `yaml:"driver"` |
|||
Path string `yaml:"path"` |
|||
} |
@ -0,0 +1,6 @@ |
|||
package config |
|||
|
|||
type Server struct { |
|||
Listen string |
|||
Debug bool |
|||
} |
@ -0,0 +1,5 @@ |
|||
package config |
|||
|
|||
type Users struct { |
|||
AllowRegister bool `yaml:"allow_register"` |
|||
} |
@ -0,0 +1,26 @@ |
|||
package database |
|||
|
|||
import ( |
|||
"github.com/gisle/stufflog/config" |
|||
"github.com/gisle/stufflog/database/drivers/bolt" |
|||
"github.com/gisle/stufflog/database/repositories" |
|||
"github.com/gisle/stufflog/slerrors" |
|||
) |
|||
|
|||
// Database is a collections of repositories.
|
|||
type Database interface { |
|||
Users() repositories.UserRepository |
|||
UserSessions() repositories.UserSessionRepository |
|||
Activities() repositories.ActivityRepository |
|||
Periods() repositories.PeriodRepository |
|||
} |
|||
|
|||
// Init gets you database based on the configuration provided.
|
|||
func Init(cfg config.Database) (Database, error) { |
|||
switch cfg.Driver { |
|||
case "bolt", "boltdb": |
|||
return bolt.Init(cfg) |
|||
default: |
|||
return nil, &slerrors.SLError{Code: 500, Text: "Database driver " + cfg.Driver + " not recognized."} |
|||
} |
|||
} |
@ -0,0 +1,229 @@ |
|||
package bolt |
|||
|
|||
import ( |
|||
"context" |
|||
"github.com/gisle/stufflog/database/repositories" |
|||
"github.com/gisle/stufflog/models" |
|||
"github.com/gisle/stufflog/slerrors" |
|||
"github.com/vmihailenco/msgpack/v4" |
|||
"go.etcd.io/bbolt" |
|||
) |
|||
|
|||
var bnActivities = []byte("Activity") |
|||
|
|||
type activityRepository struct { |
|||
db *bbolt.DB |
|||
userIdIdx *index |
|||
} |
|||
|
|||
func (r *activityRepository) FindID(ctx context.Context, id string) (*models.Activity, error) { |
|||
activity := new(models.Activity) |
|||
err := r.db.View(func(tx *bbolt.Tx) error { |
|||
value := tx.Bucket(bnActivities).Get(unsafeStringToBytes(id)) |
|||
if value == nil { |
|||
return slerrors.NotFound("Activity") |
|||
} |
|||
err := msgpack.Unmarshal(value, activity) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
return nil |
|||
}) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return activity, nil |
|||
} |
|||
|
|||
func (r *activityRepository) List(ctx context.Context) ([]*models.Activity, error) { |
|||
activities := make([]*models.Activity, 0, 16) |
|||
err := r.db.View(func(tx *bbolt.Tx) error { |
|||
cursor := tx.Bucket(bnActivities).Cursor() |
|||
|
|||
for key, value := cursor.First(); key != nil; key, value = cursor.Next() { |
|||
activity := new(models.Activity) |
|||
err := msgpack.Unmarshal(value, activity) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
activities = append(activities, activity) |
|||
} |
|||
|
|||
return nil |
|||
}) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return activities, nil |
|||
} |
|||
|
|||
func (r *activityRepository) ListUser(ctx context.Context, user models.User) ([]*models.Activity, error) { |
|||
activities := make([]*models.Activity, 0, 16) |
|||
err := r.db.View(func(tx *bbolt.Tx) error { |
|||
bucket := tx.Bucket(bnActivities) |
|||
|
|||
ids, err := r.userIdIdx.WithTx(tx).Get(user.ID) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
for _, id := range ids { |
|||
value := bucket.Get(id) |
|||
if value == nil { |
|||
continue |
|||
} |
|||
|
|||
activity := new(models.Activity) |
|||
err := msgpack.Unmarshal(value, activity) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
activities = append(activities, activity) |
|||
} |
|||
|
|||
return nil |
|||
}) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return activities, nil |
|||
} |
|||
|
|||
func (r *activityRepository) Insert(ctx context.Context, activity models.Activity) error { |
|||
value, err := msgpack.Marshal(&activity) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
return r.db.Update(func(tx *bbolt.Tx) error { |
|||
err := tx.Bucket(bnActivities).Put(unsafeStringToBytes(activity.ID), value) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
err = r.userIdIdx.WithTx(tx).Set(unsafeStringToBytes(activity.ID), activity.UserID) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
return nil |
|||
}) |
|||
} |
|||
|
|||
func (r *activityRepository) Update(ctx context.Context, activity models.Activity, updates []*models.ActivityUpdate) (*models.Activity, error) { |
|||
err := r.db.Update(func(tx *bbolt.Tx) error { |
|||
bucket := tx.Bucket(bnActivities) |
|||
|
|||
// Re-Get to guarantee consistency.
|
|||
value := bucket.Get(unsafeStringToBytes(activity.ID)) |
|||
if value == nil { |
|||
return slerrors.NotFound("Activity") |
|||
} |
|||
err := msgpack.Unmarshal(value, &activity) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
// Perform updates
|
|||
didChange := false |
|||
for _, update := range updates { |
|||
changed, err := activity.ApplyUpdate(*update) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
if changed { |
|||
didChange = true |
|||
} |
|||
} |
|||
|
|||
// Put back into bucket
|
|||
if didChange { |
|||
value, err := msgpack.Marshal(&activity) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
err = tx.Bucket(bnActivities).Put(unsafeStringToBytes(activity.ID), value) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
} else { |
|||
return errUnchanged |
|||
} |
|||
|
|||
return nil |
|||
}) |
|||
if err != nil && err != errUnchanged { |
|||
return nil, err |
|||
} |
|||
|
|||
return &activity, nil |
|||
} |
|||
|
|||
func (r *activityRepository) Remove(ctx context.Context, activity models.Activity) error { |
|||
return r.db.Update(func(tx *bbolt.Tx) error { |
|||
err := tx.Bucket(bnActivities).Delete(unsafeStringToBytes(activity.ID)) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
err = r.userIdIdx.WithTx(tx).Set(unsafeStringToBytes(activity.ID)) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
return nil |
|||
}) |
|||
} |
|||
|
|||
func (r *activityRepository) reindex() error { |
|||
return r.db.Update(func(tx *bbolt.Tx) error { |
|||
cursor := tx.Bucket(bnActivities).Cursor() |
|||
|
|||
err := r.userIdIdx.Reset(tx) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
userIdIdxTx := r.userIdIdx.WithTx(tx) |
|||
|
|||
for key, value := cursor.First(); key != nil; key, value = cursor.Next() { |
|||
activity := new(models.Activity) |
|||
err := msgpack.Unmarshal(value, activity) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
err = userIdIdxTx.Set(unsafeStringToBytes(activity.ID), activity.UserID) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
} |
|||
|
|||
return nil |
|||
}) |
|||
} |
|||
|
|||
func newActivityRepository(db *bbolt.DB) (repositories.ActivityRepository, error) { |
|||
err := db.Update(func(tx *bbolt.Tx) error { |
|||
_, err := tx.CreateBucketIfNotExists(bnActivities) |
|||
return err |
|||
}) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
userIdIdx, err := newModelIndex(db, "Activity", "UserID") |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return &activityRepository{ |
|||
db: db, |
|||
userIdIdx: userIdIdx, |
|||
}, nil |
|||
} |
@ -0,0 +1,123 @@ |
|||
package bolt |
|||
|
|||
import ( |
|||
"context" |
|||
"github.com/gisle/stufflog/models" |
|||
"github.com/stretchr/testify/assert" |
|||
"testing" |
|||
) |
|||
|
|||
func TestActivityRepository(t *testing.T) { |
|||
if dbErr != nil { |
|||
t.Fatal(dbErr) |
|||
} |
|||
|
|||
strPtr := func(s string) *string { return &s } |
|||
|
|||
ctx := context.Background() |
|||
|
|||
testUser1 := models.User{ |
|||
ID: "U1234", |
|||
Name: "Stuffer", |
|||
} |
|||
testUser2 := models.User{ |
|||
ID: "U4321", |
|||
Name: "Logger", |
|||
} |
|||
testActivity1 := models.Activity{ |
|||
ID: "A01", |
|||
Name: "3D Modelling", |
|||
UserID: testUser1.ID, |
|||
Icon: "cube", |
|||
SubActivities: []models.SubActivity{ |
|||
{ID: "S1001", Name: "Practice", UnitName: "minute", Multiplier: 15}, |
|||
{ID: "S1002", Name: "Tutorial", UnitName: "minute", Multiplier: 20}, |
|||
{ID: "S1003", Name: "Scene", UnitName: "minute", Multiplier: 30}, |
|||
}, |
|||
} |
|||
testActivity2 := models.Activity{ |
|||
ID: "A02", |
|||
Name: "Roleplay", |
|||
UserID: testUser2.ID, |
|||
Icon: "dice-d20", |
|||
SubActivities: []models.SubActivity{ |
|||
{ID: "S2001", Name: "Writing", UnitName: "word", Multiplier: 1}, |
|||
{ID: "S2002", Name: "Planning", UnitName: "word", Multiplier: 2}, |
|||
{ID: "S2003", Name: "Publish", UnitName: "word", Multiplier: 1}, |
|||
{ID: "S2003", Name: "Write & Publish", UnitName: "word", Multiplier: 3}, |
|||
{ID: "S2004", Name: "IRC RP", UnitName: "minute", Multiplier: 6.6}, |
|||
}, |
|||
} |
|||
testActivity3 := models.Activity{ |
|||
ID: "A03", |
|||
Name: "Coding", |
|||
UserID: testUser1.ID, |
|||
Icon: "code", |
|||
SubActivities: []models.SubActivity{ |
|||
{ID: "S3001", Name: "Rust NameGen", UnitName: "minute", Multiplier: 20}, |
|||
{ID: "S3002", Name: "Learn GoLang", UnitName: "minute", Multiplier: 25}, |
|||
}, |
|||
} |
|||
testActivities := []*models.Activity{&testActivity1, &testActivity2, &testActivity3} |
|||
testActivity3Update := models.ActivityUpdate{ |
|||
SetName: strPtr("Programming"), |
|||
AddSub: &models.SubActivity{Name: "Website Rewrite", UnitName: "minute", Multiplier: 15}, |
|||
EditSub: &models.SubActivity{ID: "S3001", Name: "Rust SetName Generator", UnitName: "minute", Multiplier: 22}, |
|||
RemoveSub: strPtr("S3002"), |
|||
} |
|||
testActivity3Updated := models.Activity{ |
|||
ID: "A03", |
|||
Name: "Programming", |
|||
UserID: testUser1.ID, |
|||
Icon: "code", |
|||
SubActivities: []models.SubActivity{ |
|||
{ID: "S3001", Name: "Rust SetName Generator", UnitName: "minute", Multiplier: 22}, |
|||
{Name: "Website Rewrite", UnitName: "minute", Multiplier: 15}, |
|||
}, |
|||
} |
|||
|
|||
t.Run("Insert", func(t *testing.T) { |
|||
assert.NoError(t, db.Activities().Insert(ctx, testActivity1), "Save testActivity1") |
|||
assert.NoError(t, db.Activities().Insert(ctx, testActivity2), "Save testActivity2") |
|||
assert.NoError(t, db.Activities().Insert(ctx, testActivity3), "Save testActivity3") |
|||
}) |
|||
|
|||
t.Run("Find", func(t *testing.T) { |
|||
activity, err := db.Activities().FindID(ctx, testActivity1.ID) |
|||
assert.NoError(t, err, "Find testActivity1") |
|||
assert.Equal(t, &testActivity1, activity, "Find testActivity1") |
|||
activity, err = db.Activities().FindID(ctx, testActivity2.ID) |
|||
assert.NoError(t, err, "Find testActivity2") |
|||
assert.Equal(t, &testActivity2, activity, "Find testActivity2") |
|||
activity, err = db.Activities().FindID(ctx, testActivity3.ID) |
|||
assert.NoError(t, err, "Find testActivity3") |
|||
assert.Equal(t, &testActivity3, activity, "Find testActivity3") |
|||
}) |
|||
|
|||
t.Run("List", func(t *testing.T) { |
|||
activities, err := db.Activities().List(ctx) |
|||
assert.Equal(t, testActivities, activities, "List 3 activities") |
|||
assert.NoError(t, err, "List 3 activities") |
|||
|
|||
activities, err = db.Activities().ListUser(ctx, testUser1) |
|||
assert.Equal(t, []*models.Activity{&testActivity1, &testActivity3}, activities, "List 2 activities for testUser1") |
|||
assert.NoError(t, err, "List 2 activities for testUser1") |
|||
}) |
|||
|
|||
t.Run("Update", func(t *testing.T) { |
|||
activity, err := db.Activities().Update(ctx, testActivity3, []*models.ActivityUpdate{&testActivity3Update}) |
|||
if activity != nil && len(activity.SubActivities) > 1 { |
|||
testActivity3Updated.SubActivities[1].ID = activity.SubActivities[1].ID |
|||
} |
|||
assert.Equal(t, &testActivity3Updated, activity, "Update activity") |
|||
assert.NoError(t, err, "Update activity") |
|||
}) |
|||
|
|||
t.Run("Remove", func(t *testing.T) { |
|||
assert.NoError(t, db.Activities().Remove(ctx, testActivity2), "Delete testActivity2") |
|||
|
|||
activities, err := db.Activities().List(ctx) |
|||
assert.Equal(t, []*models.Activity{&testActivity1, &testActivity3Updated}, activities, "List 2 remaining activities") |
|||
assert.NoError(t, err, "List 2 remaining activities") |
|||
}) |
|||
} |
@ -0,0 +1,76 @@ |
|||
package bolt |
|||
|
|||
import ( |
|||
"errors" |
|||
"github.com/gisle/stufflog/config" |
|||
"github.com/gisle/stufflog/database/repositories" |
|||
"go.etcd.io/bbolt" |
|||
"time" |
|||
"unsafe" |
|||
) |
|||
|
|||
// A Database is a database.Database implementation using a bolt backend.
|
|||
type Database struct { |
|||
users repositories.UserRepository |
|||
userSessions repositories.UserSessionRepository |
|||
activities repositories.ActivityRepository |
|||
periods repositories.PeriodRepository |
|||
} |
|||
|
|||
func (database *Database) Users() repositories.UserRepository { |
|||
return database.users |
|||
} |
|||
|
|||
func (database *Database) UserSessions() repositories.UserSessionRepository { |
|||
return database.userSessions |
|||
} |
|||
|
|||
func (database *Database) Activities() repositories.ActivityRepository { |
|||
return database.activities |
|||
} |
|||
|
|||
func (database *Database) Periods() repositories.PeriodRepository { |
|||
return database.periods |
|||
} |
|||
|
|||
func Init(cfg config.Database) (*Database, error) { |
|||
opts := *bbolt.DefaultOptions |
|||
opts.Timeout = time.Second * 5 |
|||
db, err := bbolt.Open(cfg.Path, 0700, &opts) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
users, err := newUserRepository(db) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
userSessions, err := newUserSessionRepository(db) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
periods, err := newPeriodRepository(db) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
activities, err := newActivityRepository(db) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
database := &Database{ |
|||
users: users, |
|||
userSessions: userSessions, |
|||
periods: periods, |
|||
activities: activities, |
|||
} |
|||
|
|||
return database, nil |
|||
} |
|||
|
|||
// unsafeStringToBytes makes a byte array, mutation is punishable by segfault.
|
|||
func unsafeStringToBytes(s string) []byte { |
|||
return *(*[]byte)(unsafe.Pointer(&s)) |
|||
} |
|||
|
|||
var errUnchanged = errors.New("database/bolt: unchanged") |
@ -0,0 +1,24 @@ |
|||
package bolt |
|||
|
|||
import ( |
|||
"fmt" |
|||
"github.com/gisle/stufflog/config" |
|||
"os" |
|||
"testing" |
|||
"time" |
|||
) |
|||
|
|||
var db *Database |
|||
var dbErr error |
|||
|
|||
func TestMain(m *testing.M) { |
|||
ns := time.Now().Nanosecond() |
|||
path := fmt.Sprintf("/tmp/stufflog_repos_test_%s_%d.db", time.Now().Format("20060102150405"), ns) |
|||
|
|||
db, dbErr = Init(config.Database{ |
|||
Driver: "bolt", |
|||
Path: path, |
|||
}) |
|||
|
|||
os.Exit(m.Run()) |
|||
} |
@ -0,0 +1,339 @@ |
|||
package bolt |
|||
|
|||
import ( |
|||
"bytes" |
|||
"errors" |
|||
"go.etcd.io/bbolt" |
|||
) |
|||
import "github.com/vmihailenco/msgpack/v4" |
|||
|
|||
var bnMap = []byte("map") |
|||
var bnRev = []byte("rev") |
|||
|
|||
type index struct { |
|||
bucketNames [][]byte |
|||
} |
|||
|
|||
// newModelIndex creates an index with the naming convention of `_idx.Model.Field`.
|
|||
func newModelIndex(db *bbolt.DB, model, field string) (*index, error) { |
|||
return newIndex(db, "_idx", model, field) |
|||
} |
|||
|
|||
// newIndex creates a new index and ensures the bucket chain is in order.
|
|||
func newIndex(db *bbolt.DB, buckets ...string) (*index, error) { |
|||
if len(buckets) == 0 { |
|||
panic("no buckets") |
|||
} |
|||
|
|||
bucketNames := make([][]byte, len(buckets)) |
|||
for i := range buckets { |
|||
bucketNames[i] = []byte(buckets[i]) |
|||
} |
|||
|
|||
err := db.Update(func(tx *bbolt.Tx) error { |
|||
bucket, err := tx.CreateBucketIfNotExists(bucketNames[0]) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
for _, bucketName := range bucketNames[1:] { |
|||
bucket, err = bucket.CreateBucketIfNotExists(bucketName) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
} |
|||
_, err = bucket.CreateBucketIfNotExists(bnMap) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
_, err = bucket.CreateBucketIfNotExists(bnRev) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
return nil |
|||
}) |
|||
|
|||
return &index{bucketNames: bucketNames}, err |
|||
} |
|||
|
|||
func (idx *index) Reset(tx *bbolt.Tx) error { |
|||
rootBucket := tx.Bucket(idx.bucketNames[0]) |
|||
for _, name := range idx.bucketNames[1:] { |
|||
rootBucket = rootBucket.Bucket(name) |
|||
} |
|||
|
|||
err := rootBucket.DeleteBucket(bnRev) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
err = rootBucket.DeleteBucket(bnMap) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
_, err = rootBucket.CreateBucket(bnRev) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
_, err = rootBucket.CreateBucket(bnMap) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
|
|||
func (idx *index) buckets(tx *bbolt.Tx) (mapBucket, revBucket *bbolt.Bucket) { |
|||
rootBucket := tx.Bucket(idx.bucketNames[0]) |
|||
for _, name := range idx.bucketNames[1:] { |
|||
rootBucket = rootBucket.Bucket(name) |
|||
} |
|||
|
|||
return rootBucket.Bucket(bnMap), rootBucket.Bucket(bnRev) |
|||
} |
|||
|
|||
func (idx *index) WithTx(tx *bbolt.Tx) *indexTx { |
|||
mapBucket, revBucket := idx.buckets(tx) |
|||
|
|||
return &indexTx{ |
|||
tx: tx, |
|||
mapBucket: mapBucket, |
|||
revBucket: revBucket, |
|||
} |
|||
} |
|||
|
|||
type indexTx struct { |
|||
tx *bbolt.Tx |
|||
mapBucket *bbolt.Bucket |
|||
revBucket *bbolt.Bucket |
|||
} |
|||
|
|||
func (itx *indexTx) Get(value string) (ids [][]byte, err error) { |
|||
entry := itx.mapBucket.Get(unsafeStringToBytes(value)) |
|||
if entry == nil { |
|||
return |
|||
} |
|||
|
|||
ids = make([][]byte, 0, 8) |
|||
err = msgpack.Unmarshal(entry, &ids) |
|||
|
|||
return |
|||
} |
|||
|
|||
// Reverse gets all values associated with the ID
|
|||
func (itx *indexTx) Reverse(id []byte) (values []string, err error) { |
|||
value := itx.revBucket.Get(id) |
|||
if value == nil { |
|||
return |
|||
} |
|||
|
|||
values = make([]string, 0, 8) |
|||
err = msgpack.Unmarshal(value, &values) |
|||
|
|||
return |
|||
} |
|||
|
|||
func (itx *indexTx) Before(value string) (ids [][]byte, err error) { |
|||
cursor := itx.mapBucket.Cursor() |
|||
ids = make([][]byte, 0, 16) |
|||
valueBytes := unsafeStringToBytes(value) |
|||
|
|||
cursor.Seek(valueBytes) |
|||
key, entryValue := cursor.Prev() |
|||
if key == nil { |
|||
return |
|||
} |
|||
|
|||
entry := itx.mapBucket.Get([]byte(entryValue)) |
|||
if entry == nil { |
|||
return |
|||
} |
|||
|
|||
err = msgpack.Unmarshal(entry, &ids) |
|||
return |
|||
} |
|||
|
|||
func (itx *indexTx) Between(a, b string) (ids [][]byte, err error) { |
|||
cursor := itx.mapBucket.Cursor() |
|||
ids = make([][]byte, 0, 16) |
|||
aBytes := unsafeStringToBytes(a) |
|||
bBytes := unsafeStringToBytes(b) |
|||
|
|||
for key, value := cursor.Seek(aBytes); key != nil && bytes.Compare(key, bBytes) < 1; key, value = cursor.Next() { |
|||
entry := itx.mapBucket.Get([]byte(value)) |
|||
if entry == nil { |
|||
return |
|||
} |
|||
|
|||
err = msgpack.Unmarshal(entry, &ids) |
|||
if err != nil { |
|||
return |
|||
} |
|||
} |
|||
|
|||
return |
|||
} |
|||
|
|||
// Set sets the index to the given values. This removes any values not in the given list.
|
|||
func (itx *indexTx) Set(id []byte, values ...string) error { |
|||
oldValues := make([]string, 0, len(values)) |
|||
newValues := make([]string, 0, len(values)) |
|||
|
|||
// Check for duplicates
|
|||
for i, value := range values { |
|||
for _, value2 := range values[i+1:] { |
|||
if value == value2 { |
|||
return errors.New("Duplicate value for index: " + value) |
|||
} |
|||
} |
|||
} |
|||
|
|||
if value := itx.revBucket.Get(id); value != nil { |
|||
// Existing ID
|
|||
existingValues := make([]string, 0, 16) |
|||
err := msgpack.Unmarshal(value, &existingValues) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
// Record new and old values if there are any.
|
|||
if len(values) > 0 { |
|||
// Find old values
|
|||
OldValueLoop: |
|||
for _, existingValue := range existingValues { |
|||
for _, value := range values { |
|||
if value == existingValue { |
|||
continue OldValueLoop |
|||
} |
|||
} |
|||
|
|||
oldValues = append(oldValues, existingValue) |
|||
} |
|||
|
|||
// Find new values
|
|||
NewValueLoop: |
|||
for _, value := range values { |
|||
if value == "" { |
|||
continue |
|||
} |
|||
|
|||
for _, existingValue := range existingValues { |
|||
if value == existingValue { |
|||
continue NewValueLoop |
|||
} |
|||
} |
|||
|
|||
newValues = append(newValues, value) |
|||
} |
|||
} else { |
|||
// There aren't any values, all values should be considered old.
|
|||
oldValues = existingValues |
|||
newValues = newValues[:0] |
|||
} |
|||
} else { |
|||
// New ID, can be skipped if this is clearing the itx operation
|
|||
if len(values) == 0 { |
|||
return nil |
|||
} |
|||
|
|||
// Otherwise, all values are newValues.
|
|||
for _, value := range values { |
|||
if value == "" { |
|||
continue |
|||
} |
|||
|
|||
newValues = append(newValues, value) |
|||
} |
|||
} |
|||
|
|||
// Put new reverse lookup entry
|
|||
if len(values) > 0 { |
|||
revData, err := msgpack.Marshal(values) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
err = itx.revBucket.Put(id, revData) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
} |
|||
|
|||
// Remove old values
|
|||
for _, oldValue := range oldValues { |
|||
ov := []byte(oldValue) |
|||
|
|||
value := itx.mapBucket.Get(ov) |
|||
if value == nil { |
|||
return errors.New("oldValue expected, but not found. itx probably corrupt") |
|||
} |
|||
ids := make([][]byte, 0, 8) |
|||
err := msgpack.Unmarshal(value, &ids) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
for i, existingId := range ids { |
|||
if bytes.Equal(existingId, id) { |
|||
ids = append(ids[:i], ids[i+1:]...) |
|||
break |
|||
} |
|||
} |
|||
|
|||
if len(ids) == 0 { |
|||
err = itx.mapBucket.Delete(ov) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
} else { |
|||
newValue, err := msgpack.Marshal(ids) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
err = itx.mapBucket.Put(ov, newValue) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Add new values
|
|||
for _, newValue := range newValues { |
|||
nv := []byte(newValue) |
|||
|
|||
if existing := itx.mapBucket.Get(nv); existing != nil { |
|||
ids := make([][]byte, 0, 8) |
|||
err := msgpack.Unmarshal(existing, &ids) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
newValue, err := msgpack.Marshal(append(ids, id)) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
err = itx.mapBucket.Put(nv, newValue) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
} else { |
|||
newValue, err := msgpack.Marshal([][]byte{id}) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
err = itx.mapBucket.Put(nv, newValue) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
} |
|||
} |
|||
|
|||
// If this will delete all values, then delete this entry
|
|||
if len(values) == 0 { |
|||
err := itx.revBucket.Delete(id) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
} |
|||
|
|||
return nil |
|||
} |
@ -0,0 +1,215 @@ |
|||
package bolt |
|||
|
|||
import ( |
|||
"fmt" |
|||
"github.com/stretchr/testify/assert" |
|||
"go.etcd.io/bbolt" |
|||
"testing" |
|||
"time" |
|||
) |
|||
|
|||
func TestIndex(t *testing.T) { |
|||
ns := time.Now().Nanosecond() |
|||
path := fmt.Sprintf("/tmp/stufflog_idx_test_%s_%d.db", time.Now().Format("20060102150405"), ns) |
|||
db, err := bbolt.Open(path, 0744, nil) |
|||
if err != nil { |
|||
t.Fatal("Failed to open db:", err) |
|||
} |
|||
|
|||
idx, err := newIndex(db, "idx", "stuff", "things") |
|||
idx2, err := newIndex(db, "idx", "stuff2", "things") |
|||
|
|||
testId := []byte("Test") |
|||
test2Id := []byte("Test2") |
|||
test3Id := []byte("Test3") |
|||
test4Id := []byte("Test4") |
|||
|
|||
t.Run("Insert Initial", func(t *testing.T) { |
|||
_ = db.Update(func(tx *bbolt.Tx) error { |
|||
itx := idx.WithTx(tx) |
|||
itx2 := idx2.WithTx(tx) |
|||
|
|||
assert.NoError(t, itx.Set(testId, "A", "B", "C", "D", "E")) |
|||
assert.NoError(t, itx.Set(test2Id, "B", "C")) |
|||
assert.NoError(t, itx.Set(test3Id, "A", "C", "D")) |
|||
assert.NoError(t, itx.Set(test4Id, "A", "C", "D")) |
|||
assert.NoError(t, itx2.Set(testId, "user_0")) |
|||
assert.NoError(t, itx2.Set(test2Id, "user_1")) |
|||
assert.NoError(t, itx2.Set(test3Id, "user_2")) |
|||
|
|||
assert.NoError(t, itx.Set(testId, "A", "B", "C")) |
|||
assert.NoError(t, itx.Set(testId, "A", "C")) |
|||
assert.NoError(t, itx.Set(test4Id)) |
|||
|
|||
return nil |
|||
}) |
|||
}) |
|||
if t.Failed() { |
|||
return |
|||
} |
|||
|
|||
t.Run("Check Initial", func(t *testing.T) { |
|||
_ = db.View(func(tx *bbolt.Tx) error { |
|||
itx := idx.WithTx(tx) |
|||
itx2 := idx2.WithTx(tx) |
|||
|
|||
ids, err := itx.Get("A") |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, [][]byte{testId, test3Id}, ids) |
|||
ids, err = itx.Get("B") |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, [][]byte{test2Id}, ids) |
|||
ids, err = itx.Get("C") |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, [][]byte{testId, test2Id, test3Id}, ids) |
|||
ids, err = itx.Get("D") |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, [][]byte{test3Id}, ids) |
|||
|
|||
ids, err = itx2.Get("user_0") |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, [][]byte{testId}, ids) |
|||
ids, err = itx2.Get("user_1") |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, [][]byte{test2Id}, ids) |
|||
ids, err = itx2.Get("user_2") |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, [][]byte{test3Id}, ids) |
|||
|
|||
return nil |
|||
}) |
|||
}) |
|||
if t.Failed() { |
|||
return |
|||
} |
|||
|
|||
t.Run("Check Initial Reverse", func(t *testing.T) { |
|||
_ = db.View(func(tx *bbolt.Tx) error { |
|||
itx := idx.WithTx(tx) |
|||
itx2 := idx2.WithTx(tx) |
|||
|
|||
values, err := itx.Reverse(testId) |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, []string{"A", "C"}, values) |
|||
values, err = itx.Reverse(test2Id) |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, []string{"B", "C"}, values) |
|||
values, err = itx.Reverse(test3Id) |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, []string{"A", "C", "D"}, values) |
|||
values, err = itx.Reverse(test4Id) |
|||
assert.NoError(t, err) |
|||
assert.Empty(t, values) |
|||
|
|||
values, err = itx2.Reverse(testId) |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, []string{"user_0"}, values) |
|||
values, err = itx2.Reverse(test2Id) |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, []string{"user_1"}, values) |
|||
values, err = itx2.Reverse(test3Id) |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, []string{"user_2"}, values) |
|||
|
|||
return nil |
|||
}) |
|||
}) |
|||
if t.Failed() { |
|||
return |
|||
} |
|||
|
|||
t.Run("Nuke 'n Pave", func(t *testing.T) { |
|||
_ = db.Update(func(tx *bbolt.Tx) error { |
|||
assert.NoError(t, idx.Reset(tx)) |
|||
assert.NoError(t, idx2.Reset(tx)) |
|||
|
|||
itx := idx.WithTx(tx) |
|||
itx2 := idx2.WithTx(tx) |
|||
|
|||
assert.NoError(t, itx.Set(testId, "pave")) |
|||
assert.NoError(t, itx.Set(test2Id, "pave")) |
|||
assert.NoError(t, itx.Set(test3Id, "pave")) |
|||
assert.NoError(t, itx2.Set(testId, "pave2")) |
|||
assert.NoError(t, itx2.Set(test3Id, "pave2")) |
|||
|
|||
return nil |
|||
}) |
|||
}) |
|||
if t.Failed() { |
|||
return |
|||
} |
|||
|
|||
t.Run("Check Nuke 'n Pave", func(t *testing.T) { |
|||
_ = db.View(func(tx *bbolt.Tx) error { |
|||
itx := idx.WithTx(tx) |
|||
itx2 := idx2.WithTx(tx) |
|||
|
|||
ids, err := itx.Get("A") |
|||
assert.NoError(t, err) |
|||
assert.Empty(t, ids) |
|||
ids, err = itx.Get("B") |
|||
assert.NoError(t, err) |
|||
assert.Empty(t, ids) |
|||
ids, err = itx.Get("C") |
|||
assert.NoError(t, err) |
|||
assert.Empty(t, ids) |
|||
ids, err = itx.Get("D") |
|||
assert.NoError(t, err) |
|||
assert.Empty(t, ids) |
|||
|
|||
ids, err = itx2.Get("user_0") |
|||
assert.NoError(t, err) |
|||
assert.Empty(t, ids) |
|||
ids, err = itx2.Get("user_1") |
|||
assert.NoError(t, err) |
|||
assert.Empty(t, ids) |
|||
ids, err = itx2.Get("user_2") |
|||
assert.NoError(t, err) |
|||
assert.Empty(t, ids) |
|||
|
|||
ids, err = itx.Get("pave") |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, [][]byte{testId, test2Id, test3Id}, ids) |
|||
ids, err = itx2.Get("pave2") |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, [][]byte{testId, test3Id}, ids) |
|||
|
|||
return nil |
|||
}) |
|||
}) |
|||
if t.Failed() { |
|||
return |
|||
} |
|||
|
|||
t.Run("Check Nuke 'n Pave Reverse", func(t *testing.T) { |
|||
_ = db.View(func(tx *bbolt.Tx) error { |
|||
itx := idx.WithTx(tx) |
|||
itx2 := idx2.WithTx(tx) |
|||
|
|||
values, err := itx.Reverse(testId) |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, []string{"pave"}, values) |
|||
values, err = itx.Reverse(test2Id) |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, []string{"pave"}, values) |
|||
values, err = itx.Reverse(test3Id) |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, []string{"pave"}, values) |
|||
|
|||
values, err = itx2.Reverse(testId) |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, []string{"pave2"}, values) |
|||
values, err = itx2.Reverse(test2Id) |
|||
assert.NoError(t, err) |
|||
assert.Empty(t, values) |
|||
values, err = itx2.Reverse(test3Id) |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, []string{"pave2"}, values) |
|||
|
|||
return nil |
|||
}) |
|||
}) |
|||
if t.Failed() { |
|||
return |
|||
} |
|||
} |
@ -0,0 +1,288 @@ |
|||
package bolt |
|||
|
|||
import ( |
|||
"context" |
|||
"github.com/gisle/stufflog/database/repositories" |
|||
"github.com/gisle/stufflog/models" |
|||
"github.com/gisle/stufflog/slerrors" |
|||
"github.com/vmihailenco/msgpack/v4" |
|||
"go.etcd.io/bbolt" |
|||
) |
|||
|
|||
var bnPeriods = []byte("Period") |
|||
|
|||
type periodRepository struct { |
|||
db *bbolt.DB |
|||
userIdIdx *index |
|||
activityIdIdx *index |
|||
} |
|||
|
|||
func (r *periodRepository) FindID(ctx context.Context, id string) (*models.Period, error) { |
|||
period := new(models.Period) |
|||
err := r.db.View(func(tx *bbolt.Tx) error { |
|||
value := tx.Bucket(bnPeriods).Get(unsafeStringToBytes(id)) |
|||
if value == nil { |
|||
return slerrors.NotFound("Period") |
|||
} |
|||
err := msgpack.Unmarshal(value, period) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
return nil |
|||
}) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return period, nil |
|||
} |
|||
|
|||
func (r *periodRepository) List(ctx context.Context) ([]*models.Period, error) { |
|||
periods := make([]*models.Period, 0, 16) |
|||
err := r.db.View(func(tx *bbolt.Tx) error { |
|||
cursor := tx.Bucket(bnPeriods).Cursor() |
|||
|
|||
for key, value := cursor.First(); key != nil; key, value = cursor.Next() { |
|||
period := new(models.Period) |
|||
err := msgpack.Unmarshal(value, period) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
periods = append(periods, period) |
|||
} |
|||
|
|||
return nil |
|||
}) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return periods, nil |
|||
} |
|||
|
|||
func (r *periodRepository) ListUser(ctx context.Context, user models.User) ([]*models.Period, error) { |
|||
periods := make([]*models.Period, 0, 16) |
|||
err := r.db.View(func(tx *bbolt.Tx) error { |
|||
bucket := tx.Bucket(bnPeriods) |
|||
|
|||
ids, err := r.userIdIdx.WithTx(tx).Get(user.ID) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
for _, id := range ids { |
|||
value := bucket.Get(id) |
|||
if value == nil { |
|||
continue |
|||
} |
|||
|
|||
period := new(models.Period) |
|||
err := msgpack.Unmarshal(value, period) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
periods = append(periods, period) |
|||
} |
|||
|
|||
return nil |
|||
}) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return periods, nil |
|||
} |
|||
|
|||
func (r *periodRepository) ListActivity(ctx context.Context, activity models.Activity) ([]*models.Period, error) { |
|||
periods := make([]*models.Period, 0, 16) |
|||
err := r.db.View(func(tx *bbolt.Tx) error { |
|||
bucket := tx.Bucket(bnPeriods) |
|||
|
|||
ids, err := r.activityIdIdx.WithTx(tx).Get(activity.ID) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
for _, id := range ids { |
|||
value := bucket.Get(id) |
|||
if value == nil { |
|||
continue |
|||
} |
|||
|
|||
period := new(models.Period) |
|||
err := msgpack.Unmarshal(value, period) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
periods = append(periods, period) |
|||
} |
|||
|
|||
return nil |
|||
}) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return periods, nil |
|||
|
|||
} |
|||
|
|||
func (r *periodRepository) Insert(ctx context.Context, period models.Period) error { |
|||
value, err := msgpack.Marshal(&period) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
return r.db.Update(func(tx *bbolt.Tx) error { |
|||
err := tx.Bucket(bnPeriods).Put(unsafeStringToBytes(period.ID), value) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
err = r.index(tx, &period) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
return nil |
|||
}) |
|||
} |
|||
|
|||
func (r *periodRepository) Update(ctx context.Context, period models.Period, updates []*models.PeriodUpdate) (*models.Period, error) { |
|||
err := r.db.Update(func(tx *bbolt.Tx) error { |
|||
bucket := tx.Bucket(bnPeriods) |
|||
|
|||
// Re-Get to guarantee consistency.
|
|||
value := bucket.Get(unsafeStringToBytes(period.ID)) |
|||
if value == nil { |
|||
return slerrors.NotFound("Activity") |
|||
} |
|||
err := msgpack.Unmarshal(value, &period) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
// Perform updates
|
|||
didChange := false |
|||
for _, update := range updates { |
|||
changed, err := period.ApplyUpdate(*update) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
if changed { |
|||
didChange = true |
|||
} |
|||
} |
|||
|
|||
// Put back into bucket
|
|||
if didChange { |
|||
value, err := msgpack.Marshal(&period) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
err = bucket.Put(unsafeStringToBytes(period.ID), value) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
err = r.index(tx, &period) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
} else { |
|||
return errUnchanged |
|||
} |
|||
|
|||
return nil |
|||
}) |
|||
if err != nil && err != errUnchanged { |
|||
return nil, err |
|||
} |
|||
|
|||
return &period, nil |
|||
} |
|||
|
|||
func (r *periodRepository) Remove(ctx context.Context, period models.Period) error { |
|||
return r.db.Update(func(tx *bbolt.Tx) error { |
|||
err := tx.Bucket(bnPeriods).Delete(unsafeStringToBytes(period.ID)) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
err = r.unIndex(tx, &period) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
return nil |
|||
}) |
|||
} |
|||
|
|||
func (r *periodRepository) index(tx *bbolt.Tx, period *models.Period) error { |
|||
idBytes := unsafeStringToBytes(period.ID) |
|||
|
|||
err := r.userIdIdx.WithTx(tx).Set(idBytes, period.UserID) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
activityIDs := make([]string, 0, len(period.Goals)) |
|||
added := make(map[string]bool) |
|||
for _, goal := range period.Goals { |
|||
if !added[goal.ActivityID] { |
|||
added[goal.ActivityID] = true |
|||
activityIDs = append(activityIDs, goal.ActivityID) |
|||
} |
|||
} |
|||
|
|||
err = r.activityIdIdx.WithTx(tx).Set(idBytes, activityIDs...) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
|
|||
func (r *periodRepository) unIndex(tx *bbolt.Tx, period *models.Period) error { |
|||
idBytes := unsafeStringToBytes(period.ID) |
|||
|
|||
err := r.userIdIdx.WithTx(tx).Set(idBytes) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
err = r.activityIdIdx.WithTx(tx).Set(idBytes) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
|
|||
func newPeriodRepository(db *bbolt.DB) (repositories.PeriodRepository, error) { |
|||
err := db.Update(func(tx *bbolt.Tx) error { |
|||
_, err := tx.CreateBucketIfNotExists(bnPeriods) |
|||
return err |
|||
}) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
userIdIdx, err := newModelIndex(db, "Period", "UserID") |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
activityIdIdx, err := newModelIndex(db, "Period", "Goals.ActivityID") |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return &periodRepository{ |
|||
db: db, |
|||
userIdIdx: userIdIdx, |
|||
activityIdIdx: activityIdIdx, |
|||
}, nil |
|||
} |
@ -0,0 +1,91 @@ |
|||
package bolt |
|||
|
|||
import ( |
|||
"context" |
|||
"github.com/gisle/stufflog/database/repositories" |
|||
"github.com/gisle/stufflog/models" |
|||
"github.com/gisle/stufflog/slerrors" |
|||
"github.com/vmihailenco/msgpack/v4" |
|||
"go.etcd.io/bbolt" |
|||
) |
|||
|
|||
var bnUsers = []byte("User") |
|||
|
|||
type userRepository struct { |
|||
db *bbolt.DB |
|||
} |
|||
|
|||
func (r *userRepository) FindID(ctx context.Context, id string) (*models.User, error) { |
|||
user := new(models.User) |
|||
err := r.db.View(func(tx *bbolt.Tx) error { |
|||
value := tx.Bucket(bnUsers).Get(unsafeStringToBytes(id)) |
|||
if value == nil { |
|||
return slerrors.NotFound("User") |
|||
} |
|||
err := msgpack.Unmarshal(value, user) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
return nil |
|||
}) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return user, nil |
|||
} |
|||
|
|||
func (r *userRepository) List(ctx context.Context) ([]*models.User, error) { |
|||
users := make([]*models.User, 0, 16) |
|||
err := r.db.View(func(tx *bbolt.Tx) error { |
|||
cursor := tx.Bucket(bnUsers).Cursor() |
|||
|
|||
for key, value := cursor.First(); key != nil; key, value = cursor.Next() { |
|||
user := new(models.User) |
|||
err := msgpack.Unmarshal(value, user) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
users = append(users, user) |
|||
} |
|||
|
|||
return nil |
|||
}) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return users, nil |
|||
} |
|||
|
|||
func (r *userRepository) Save(ctx context.Context, user models.User) error { |
|||
value, err := msgpack.Marshal(&user) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
return r.db.Update(func(tx *bbolt.Tx) error { |
|||
return tx.Bucket(bnUsers).Put(unsafeStringToBytes(user.ID), value) |
|||
}) |
|||
} |
|||
|
|||
func (r *userRepository) Remove(ctx context.Context, user models.User) error { |
|||
return r.db.Update(func(tx *bbolt.Tx) error { |
|||
return tx.Bucket(bnUsers).Delete(unsafeStringToBytes(user.ID)) |
|||
}) |
|||
} |
|||
|
|||
func newUserRepository(db *bbolt.DB) (repositories.UserRepository, error) { |
|||
err := db.Update(func(tx *bbolt.Tx) error { |
|||
_, err := tx.CreateBucketIfNotExists(bnUsers) |
|||
return err |
|||
}) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return &userRepository{ |
|||
db: db, |
|||
}, nil |
|||
} |
@ -0,0 +1,80 @@ |
|||
package bolt |
|||
|
|||
import ( |
|||
"context" |
|||
"github.com/gisle/stufflog/models" |
|||
"github.com/stretchr/testify/assert" |
|||
"testing" |
|||
) |
|||
|
|||
func TestUserRepository(t *testing.T) { |
|||
if dbErr != nil { |
|||
t.Fatal(dbErr) |
|||
} |
|||
|
|||
ctx := context.Background() |
|||
|
|||
testUser1 := models.User{ |
|||
ID: "U1234", |
|||
Name: "Stuffer", |
|||
} |
|||
testUser2 := models.User{ |
|||
ID: "U4321", |
|||
Name: "Logger", |
|||
} |
|||
testUser3 := models.User{ |
|||
ID: "U2396", |
|||
Name: "Temper", |
|||
} |
|||
testUsers := []*models.User{ |
|||
&testUser1, |
|||
&testUser3, |
|||
&testUser2, |
|||
} |
|||
|
|||
t.Run("Save", func(t *testing.T) { |
|||
assert.NoError(t, db.Users().Save(ctx, testUser1), "Save testUser1") |
|||
assert.NoError(t, db.Users().Save(ctx, testUser2), "Save testUser2") |
|||
assert.NoError(t, db.Users().Save(ctx, testUser3), "Save testUser3") |
|||
}) |
|||
|
|||
t.Run("List", func(t *testing.T) { |
|||
users, err := db.Users().List(ctx) |
|||
assert.Equal(t, testUsers, users, "List 3 users") |
|||
assert.NoError(t, err, "List 3 users") |
|||
}) |
|||
|
|||
t.Run("Find", func(t *testing.T) { |
|||
user, err := db.Users().FindID(ctx, testUser1.ID) |
|||
assert.Equal(t, &testUser1, user, "Find testUser1") |
|||
assert.NoError(t, err, "Find testUser1") |
|||
user, err = db.Users().FindID(ctx, testUser2.ID) |
|||
assert.Equal(t, &testUser2, user, "Find testUser2") |
|||
assert.NoError(t, err, "Find testUser2") |
|||
user, err = db.Users().FindID(ctx, testUser3.ID) |
|||
assert.Equal(t, &testUser3, user, "Find testUser3") |
|||
assert.NoError(t, err, "Find testUser3") |
|||
|
|||
user, err = db.Users().FindID(ctx, "UNonExistent") |
|||
assert.Nil(t, user, "Find UNonExistent") |
|||
assert.Error(t, err, "Find UNonExistent") |
|||
}) |
|||
|
|||
t.Run("Remove", func(t *testing.T) { |
|||
assert.NoError(t, db.Users().Remove(ctx, testUser3), "Remove testUser3") |
|||
|
|||
user, err := db.Users().FindID(ctx, testUser1.ID) |
|||
assert.Equal(t, &testUser1, user, "Find non-deleted testUser1") |
|||
assert.NoError(t, err, "Find non-deleted testUser1") |
|||
user, err = db.Users().FindID(ctx, testUser2.ID) |
|||
assert.Equal(t, &testUser2, user, "Find non-deleted testUser2") |
|||
assert.NoError(t, err, "Find non-deleted testUser2") |
|||
user, err = db.Users().FindID(ctx, testUser3.ID) |
|||
assert.Nil(t, user, "Find deleted testUser3") |
|||
assert.Error(t, err, "Find deleted testUser3") |
|||
|
|||
users, err := db.Users().List(ctx) |
|||
assert.Equal(t, []*models.User{&testUser1, &testUser2}, users, "List 2 remaining users") |
|||
assert.NoError(t, err, "List 2 remaining users") |
|||
}) |
|||
} |
@ -0,0 +1,197 @@ |
|||
package bolt |
|||
|
|||
import ( |
|||
"context" |
|||
"github.com/gisle/stufflog/database/repositories" |
|||
"github.com/gisle/stufflog/models" |
|||
"github.com/gisle/stufflog/slerrors" |
|||
"github.com/vmihailenco/msgpack/v4" |
|||
"go.etcd.io/bbolt" |
|||
) |
|||
|
|||
var bnUserSessions = []byte("UserSession") |
|||
|
|||
type userSessionRepository struct { |
|||
userIdIdx *index |
|||
db *bbolt.DB |
|||
} |
|||
|
|||
func (r *userSessionRepository) FindID(ctx context.Context, id string) (*models.UserSession, error) { |
|||
session := new(models.UserSession) |
|||
err := r.db.View(func(tx *bbolt.Tx) error { |
|||
value := tx.Bucket(bnUserSessions).Get(unsafeStringToBytes(id)) |
|||
if value == nil { |
|||
return slerrors.NotFound("Session") |
|||
} |
|||
err := msgpack.Unmarshal(value, session) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
return nil |
|||
}) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return session, nil |
|||
} |
|||
|
|||
func (r *userSessionRepository) List(ctx context.Context) ([]*models.UserSession, error) { |
|||
sessions := make([]*models.UserSession, 0, 16) |
|||
err := r.db.View(func(tx *bbolt.Tx) error { |
|||
cursor := tx.Bucket(bnUserSessions).Cursor() |
|||
|
|||
for key, value := cursor.First(); key != nil; key, value = cursor.Next() { |
|||
session := new(models.UserSession) |
|||
err := msgpack.Unmarshal(value, session) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
sessions = append(sessions, session) |
|||
} |
|||
|
|||
return nil |
|||
}) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return sessions, nil |
|||
} |
|||
|
|||
func (r *userSessionRepository) ListUser(ctx context.Context, user models.User) ([]*models.UserSession, error) { |
|||
var sessions []*models.UserSession |
|||
err := r.db.View(func(tx *bbolt.Tx) error { |
|||
bucket := tx.Bucket(bnUserSessions) |
|||
|
|||
ids, err := r.userIdIdx.WithTx(tx).Get(user.ID) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
sessions = make([]*models.UserSession, len(ids)) |
|||
|
|||
for i, id := range ids { |
|||
value := bucket.Get(id) |
|||
|
|||
session := new(models.UserSession) |
|||
err := msgpack.Unmarshal(value, session) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
sessions[i] = session |
|||
} |
|||
|
|||
return nil |
|||
}) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return sessions, nil |
|||
} |
|||
|
|||
func (r *userSessionRepository) Save(ctx context.Context, session models.UserSession) error { |
|||
value, err := msgpack.Marshal(&session) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
return r.db.Update(func(tx *bbolt.Tx) error { |
|||
err := tx.Bucket(bnUserSessions).Put(unsafeStringToBytes(session.ID), value) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
err = r.userIdIdx.WithTx(tx).Set(unsafeStringToBytes(session.ID), session.UserID) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
return nil |
|||
}) |
|||
} |
|||
|
|||
func (r *userSessionRepository) Remove(ctx context.Context, session models.UserSession) error { |
|||
return r.db.Update(func(tx *bbolt.Tx) error { |
|||
err := tx.Bucket(bnUserSessions).Delete(unsafeStringToBytes(session.ID)) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
err = r.userIdIdx.WithTx(tx).Set(unsafeStringToBytes(session.ID)) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
return nil |
|||
}) |
|||
} |
|||
|
|||
func (r *userSessionRepository) RemoveUser(ctx context.Context, user models.User) error { |
|||
return r.db.Update(func(tx *bbolt.Tx) error { |
|||
bucket := tx.Bucket(bnUserSessions) |
|||
|
|||
ids, err := r.userIdIdx.WithTx(tx).Get(user.ID) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
for _, id := range ids { |
|||
err := bucket.Delete(id) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
} |
|||
|
|||
return nil |
|||
}) |
|||
} |
|||
|
|||
func (r *userSessionRepository) reindex() error { |
|||
return r.db.Update(func(tx *bbolt.Tx) error { |
|||
cursor := tx.Bucket(bnUserSessions).Cursor() |
|||
|
|||
err := r.userIdIdx.Reset(tx) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
userIdIdxTx := r.userIdIdx.WithTx(tx) |
|||
|
|||
for key, value := cursor.First(); key != nil; key, value = cursor.Next() { |
|||
session := new(models.UserSession) |
|||
err := msgpack.Unmarshal(value, session) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
err = userIdIdxTx.Set(unsafeStringToBytes(session.ID), session.UserID) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
} |
|||
|
|||
return nil |
|||
}) |
|||
} |
|||
|
|||
func newUserSessionRepository(db *bbolt.DB) (repositories.UserSessionRepository, error) { |
|||
err := db.Update(func(tx *bbolt.Tx) error { |
|||
_, err := tx.CreateBucketIfNotExists(bnUserSessions) |
|||
return err |
|||
}) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
idx, err := newModelIndex(db, "UserSession", "UserID") |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return &userSessionRepository{ |
|||
db: db, |
|||
userIdIdx: idx, |
|||
}, nil |
|||
} |
@ -0,0 +1,15 @@ |
|||
package repositories |
|||
|
|||
import ( |
|||
"context" |
|||
"github.com/gisle/stufflog/models" |
|||
) |
|||
|
|||
type ActivityRepository interface { |
|||
FindID(ctx context.Context, id string) (*models.Activity, error) |
|||
List(ctx context.Context) ([]*models.Activity, error) |
|||
ListUser(ctx context.Context, user models.User) ([]*models.Activity, error) |
|||
Insert(ctx context.Context, activity models.Activity) error |
|||
Update(ctx context.Context, activity models.Activity, updates []*models.ActivityUpdate) (*models.Activity, error) |
|||
Remove(ctx context.Context, activity models.Activity) error |
|||
} |
@ -0,0 +1,16 @@ |
|||
package repositories |
|||
|
|||
import ( |
|||
"context" |
|||
"github.com/gisle/stufflog/models" |
|||
) |
|||
|
|||
type PeriodRepository interface { |
|||
FindID(ctx context.Context, id string) (*models.Period, error) |
|||
List(ctx context.Context) ([]*models.Period, error) |
|||
ListUser(ctx context.Context, user models.User) ([]*models.Period, error) |
|||
ListActivity(ctx context.Context, activity models.Activity) ([]*models.Period, error) |
|||
Insert(ctx context.Context, period models.Period) error |
|||
Update(ctx context.Context, period models.Period, updates []*models.PeriodUpdate) (*models.Period, error) |
|||
Remove(ctx context.Context, period models.Period) error |
|||
} |
@ -0,0 +1,14 @@ |
|||
package repositories |
|||
|
|||
import ( |
|||
"context" |
|||
"github.com/gisle/stufflog/models" |
|||
) |
|||
|
|||
// UserRepository is a repository for database operations for users.
|
|||
type UserRepository interface { |
|||
FindID(ctx context.Context, id string) (*models.User, error) |
|||
List(ctx context.Context) ([]*models.User, error) |
|||
Save(ctx context.Context, user models.User) error |
|||
Remove(ctx context.Context, user models.User) error |
|||
} |
@ -0,0 +1,16 @@ |
|||
package repositories |
|||
|
|||
import ( |
|||
"context" |
|||
"github.com/gisle/stufflog/models" |
|||
) |
|||
|
|||
// UserSessionRepository is a repository for database operations for userSessions.
|
|||
type UserSessionRepository interface { |
|||
FindID(ctx context.Context, id string) (*models.UserSession, error) |
|||
List(ctx context.Context) ([]*models.UserSession, error) |
|||
ListUser(ctx context.Context, user models.User) ([]*models.UserSession, error) |
|||
Save(ctx context.Context, session models.UserSession) error |
|||
Remove(ctx context.Context, session models.UserSession) error |
|||
RemoveUser(ctx context.Context, user models.User) error |
|||
} |
@ -0,0 +1,17 @@ |
|||
module github.com/gisle/stufflog |
|||
|
|||
go 1.13 |
|||
|
|||
require ( |
|||
github.com/davecgh/go-spew v1.1.1 // indirect |
|||
github.com/gin-gonic/gin v1.4.0 |
|||
github.com/stretchr/testify v1.4.0 |
|||
github.com/urfave/cli v1.22.1 |
|||
github.com/vmihailenco/msgpack/v4 v4.2.0 |
|||
go.etcd.io/bbolt v1.3.3 |
|||
golang.org/x/crypto v0.0.0-20190927123631-a832865fa7ad |
|||
golang.org/x/net v0.0.0-20190921015927-1a5e07d1ff72 // indirect |
|||
golang.org/x/sys v0.0.0-20190922100055-0a153f010e69 // indirect |
|||
google.golang.org/appengine v1.6.3 // indirect |
|||
gopkg.in/yaml.v2 v2.2.2 |
|||
) |
@ -0,0 +1,84 @@ |
|||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= |
|||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= |
|||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= |
|||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= |
|||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
|||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= |
|||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
|||
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3 h1:t8FVkw33L+wilf2QiWkw0UV77qRpcH/JHPKGpKa2E8g= |
|||
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= |
|||
github.com/gin-gonic/gin v1.4.0 h1:3tMoCCfM7ppqsR0ptz/wi1impNpT7/9wQtMZ8lr1mCQ= |
|||
github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM= |
|||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= |
|||
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= |
|||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= |
|||
github.com/json-iterator/go v1.1.6 h1:MrUvLMLTMxbqFJ9kzlvat/rYZqZnW3u4wkLzWTaFwKs= |
|||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= |
|||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= |
|||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= |
|||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= |
|||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= |
|||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= |
|||
github.com/mattn/go-isatty v0.0.7 h1:UvyT9uN+3r7yLEYSlJsbQGdsaB/a0DlgWP3pql6iwOc= |
|||
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= |
|||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= |
|||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= |
|||
github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= |
|||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= |
|||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= |
|||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= |
|||
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= |
|||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= |
|||
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= |
|||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= |
|||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= |
|||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= |
|||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= |
|||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= |
|||
github.com/ugorji/go v1.1.4 h1:j4s+tAvLfL3bZyefP2SEWmhBzmuIlH/eqNuPdFPgngw= |
|||
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= |
|||
github.com/urfave/cli v1.22.1 h1:+mkCCcOFKPnCmVYVcURKps1Xe+3zP90gSYGNfRkjoIY= |
|||
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= |
|||
github.com/vmihailenco/msgpack/v4 v4.2.0 h1:c4L4gd938BvSjSsfr9YahJcvasEf5JZ9W7rcEXfgyys= |
|||
github.com/vmihailenco/msgpack/v4 v4.2.0/go.mod h1:Mu3B7ZwLd5nNOLVOKt9DecVl7IVg0xkDiEjk6CwMrww= |
|||
github.com/vmihailenco/tagparser v0.1.0 h1:u6yzKTY6gW/KxL/K2NTEQUOSXZipyGiIRarGjJKmQzU= |
|||
github.com/vmihailenco/tagparser v0.1.0/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= |
|||
go.etcd.io/bbolt v1.3.3 h1:MUGmc65QhB3pIlaQ5bB4LwqSj6GIonVJXpZiaKNyaKk= |
|||
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= |
|||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= |
|||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5 h1:58fnuSXlxZmFdJyvtTFVmVhcMLU6v5fEb/ok4wyqtNU= |
|||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= |
|||
golang.org/x/crypto v0.0.0-20190927123631-a832865fa7ad h1:5E5raQxcv+6CZ11RrBYQe5WRbUIWpScjh0kvHZkZIrQ= |
|||
golang.org/x/crypto v0.0.0-20190927123631-a832865fa7ad/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= |
|||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= |
|||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= |
|||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= |
|||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= |
|||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80 h1:Ao/3l156eZf2AW5wK8a7/smtodRU+gha3+BeqJ69lRk= |
|||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= |
|||
golang.org/x/net v0.0.0-20190921015927-1a5e07d1ff72 h1:PdU68SuVQNpTFEyGl0zoQOMysY+E0innv/QbAqV853w= |
|||
golang.org/x/net v0.0.0-20190921015927-1a5e07d1ff72/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= |
|||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= |
|||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= |
|||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= |
|||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
|||
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
|||
golang.org/x/sys v0.0.0-20190922100055-0a153f010e69 h1:rOhMmluY6kLMhdnrivzec6lLgaVbMHMn2ISQXJeJ5EM= |
|||
golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
|||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= |
|||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= |
|||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= |
|||
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= |
|||
google.golang.org/appengine v1.6.1 h1:QzqyMA1tlu6CgqCDUtU9V+ZKhLFT2dkJuANu5QaxI3I= |
|||
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= |
|||
google.golang.org/appengine v1.6.3 h1:hvZejVcIxAKHR8Pq2gXaDggf6CWT1QEqO+JEBeOKCG8= |
|||
google.golang.org/appengine v1.6.3/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= |
|||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |
|||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= |
|||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |
|||
gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= |
|||
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= |
|||
gopkg.in/go-playground/validator.v8 v8.18.2 h1:lFB4DoMU6B626w8ny76MV7VX6W2VHct2GVOI3xgiMrQ= |
|||
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= |
|||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= |
|||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= |
@ -0,0 +1,40 @@ |
|||
package generate |
|||
|
|||
import ( |
|||
"crypto/rand" |
|||
"encoding/binary" |
|||
mrand "math/rand" |
|||
"strconv" |
|||
"strings" |
|||
) |
|||
|
|||
// ID generates an ID using crypto-random, falling back to math random when that fails
|
|||
// to avoid disrupting operation because of a faulty RNG. Database implementations does
|
|||
// not need to use this if some other scheme improves storage.
|
|||
func ID(prefix string, length int) string { |
|||
var data [32]byte |
|||
|
|||
result := strings.Builder{} |
|||
result.Grow(length + 32) |
|||
result.WriteString(prefix) |
|||
|
|||
pos := 0 |
|||
for result.Len() < length { |
|||
if pos == 0 { |
|||
randRead(data[:]) |
|||
} |
|||
|
|||
result.WriteString(strconv.FormatUint(binary.BigEndian.Uint64(data[pos:pos+8]), 36)) |
|||
|
|||
pos = (pos + 8) % 32 |
|||
} |
|||
|
|||
return result.String()[:length] |
|||
} |
|||
|
|||
func randRead(data []byte) { |
|||
n, err := rand.Read(data) |
|||
if err != nil { |
|||
mrand.Read(data[n:]) |
|||
} |
|||
} |
@ -0,0 +1,87 @@ |
|||
package main |
|||
|
|||
import ( |
|||
"fmt" |
|||
"github.com/gin-gonic/gin" |
|||
"github.com/gisle/stufflog/api" |
|||
"github.com/gisle/stufflog/config" |
|||
"github.com/gisle/stufflog/database" |
|||
"github.com/gisle/stufflog/services" |
|||
"github.com/urfave/cli" |
|||
"log" |
|||
"net/http" |
|||
"os" |
|||
"path" |
|||
) |
|||
|
|||
func main() { |
|||
configPath := "/etc/stufflog.yaml" |
|||
uiRoot := "" |
|||
|
|||
app := cli.NewApp() |
|||
app.Name = "stufflog" |
|||
app.Usage = "Prioritize stuff, do stuff, log stuff" |
|||
|
|||
app.Flags = []cli.Flag{ |
|||
cli.StringFlag{ |
|||
Name: "conf, c", |
|||
Value: configPath, |
|||
Usage: "Configuration file locations", |
|||
Destination: &configPath, |
|||
}, |
|||
cli.StringFlag{ |
|||
Name: "ui", |
|||
Value: uiRoot, |
|||
Usage: "UI Root (blank for no UI)", |
|||
Destination: &uiRoot, |
|||
}, |
|||
} |
|||
|
|||
app.Action = func(c *cli.Context) error { |
|||
// Load configuration
|
|||
conf, err := config.Load(configPath) |
|||
if err != nil { |
|||
return fmt.Errorf("failed to load config: %s", err) |
|||
} |
|||
|
|||
// Load database
|
|||
db, err := database.Init(conf.Database) |
|||
if err != nil { |
|||
return fmt.Errorf("failed to init database: %s", err) |
|||
} |
|||
|
|||
// Setup gin
|
|||
if !conf.Server.Debug { |
|||
gin.SetMode(gin.ReleaseMode) |
|||
} |
|||
router := gin.New() |
|||
|
|||
// Setup services
|
|||
auth := services.NewAuthService(db) |
|||
scoring := services.NewScoringService(db) |
|||
|
|||
// Setup APIs
|
|||
api.User(router.Group("/api/user"), auth) |
|||
api.Activity(router.Group("/api/activity"), db, auth) |
|||
api.Period(router.Group("/api/period"), db, scoring, auth) |
|||
|
|||
// Setup UI
|
|||
if uiRoot != "" { |
|||
router.NoRoute(gin.WrapH(http.FileServer(http.Dir(uiRoot)))) |
|||
router.GET("/activities/", func(c *gin.Context) { |
|||
http.ServeFile(c.Writer, c.Request, path.Join(uiRoot, "index.html")) |
|||
}) |
|||
} |
|||
|
|||
// Start listening
|
|||
log.Println("Starting server on", conf.Server.Listen) |
|||
return router.Run(conf.Server.Listen) |
|||
|
|||
// TODO: Handle interrupts because docker and sigint aren't the best of friends.
|
|||
} |
|||
|
|||
err := app.Run(os.Args) |
|||
if err != nil { |
|||
log.Fatal(err) |
|||
} |
|||
} |
@ -0,0 +1,119 @@ |
|||
package models |
|||
|
|||
import ( |
|||
"github.com/gisle/stufflog/internal/generate" |
|||
"github.com/gisle/stufflog/slerrors" |
|||
) |
|||
|
|||
// An Activity is a general hobby or group of related related sub-activities.
|
|||
type Activity struct { |
|||
ID string `json:"id"` |
|||
UserID string `json:"userId"` |
|||
Name string `json:"name"` |
|||
Icon string `json:"icon"` |
|||
DailyBonus int `json:"dailyBonus"` |
|||
|
|||
SubActivities []SubActivity `json:"subActivities"` |
|||
} |
|||
|
|||
func (activity *Activity) GenerateIDs() { |
|||
activity.ID = generate.ID("A", 12) |
|||
for i := range activity.SubActivities { |
|||
activity.SubActivities[i].ID = generate.ID("AS", 16) |
|||
} |
|||
} |
|||
|
|||
func (activity *Activity) SubActivity(id string) *SubActivity { |
|||
for i := range activity.SubActivities { |
|||
if activity.SubActivities[i].ID == id { |
|||
return &activity.SubActivities[i] |
|||
} |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
|
|||
func (activity *Activity) ApplyUpdate(update ActivityUpdate) (changed bool, err error) { |
|||
if update.SetName != nil { |
|||
activity.Name = *update.SetName |
|||
changed = true |
|||
} |
|||
if update.SetIcon != nil { |
|||
activity.Icon = *update.SetIcon |
|||
changed = true |
|||
} |
|||
if update.SetDailyBonus != nil { |
|||
activity.DailyBonus = *update.SetDailyBonus |
|||
changed = true |
|||
} |
|||
|
|||
if update.AddSub != nil { |
|||
sub := *update.AddSub |
|||
sub.ID = generate.ID("SA", 16) |
|||
|
|||
activity.SubActivities = append(activity.SubActivities, sub) |
|||
changed = true |
|||
} |
|||
if update.EditSub != nil { |
|||
sub := activity.SubActivity(update.EditSub.ID) |
|||
if sub == nil { |
|||
err = slerrors.NotFound("Sub-activity you're trying to edit") |
|||
return |
|||
} |
|||
|
|||
*sub = *update.EditSub |
|||
changed = true |
|||
} |
|||
if update.RemoveSub != nil { |
|||
found := false |
|||
for i, sub := range activity.SubActivities { |
|||
if sub.ID == *update.RemoveSub { |
|||
activity.SubActivities = append(activity.SubActivities[:i], activity.SubActivities[i+1:]...) |
|||
|
|||
found = true |
|||
changed = true |
|||
break |
|||
} |
|||
} |
|||
if !found { |
|||
err = slerrors.NotFound("Sub-activity you're trying to remove") |
|||
return |
|||
} |
|||
} |
|||
|
|||
return |
|||
} |
|||
|
|||
// A SubActivity is a part of the activity, i.e. tutorial, writing, practice. This is what determines
|
|||
// how many points should be awarded. The baseline multiplier should amount to 1000 points per hour,
|
|||
// while activities that take longer (i.e. a published piece of writing vs writing for your eyes only).
|
|||
type SubActivity struct { |
|||
ID string `json:"id"` |
|||
Name string `json:"name"` |
|||
UnitName string `json:"unitName"` |
|||
Value float64 `json:"value"` |
|||
} |
|||
|
|||
// ActivityUpdate describes a change to an activity
|
|||
type ActivityUpdate struct { |
|||
AddSub *SubActivity `json:"addSub"` |
|||
EditSub *SubActivity `json:"editSub"` |
|||
RemoveSub *string `json:"removeSub"` |
|||
SetName *string `json:"setName"` |
|||
SetIcon *string `json:"setIcon"` |
|||
SetDailyBonus *int `json:"setDailyBonus"` |
|||
} |
|||
|
|||
type ActivitiesByName []*Activity |
|||
|
|||
func (activities ActivitiesByName) Len() int { |
|||
return len(activities) |
|||
} |
|||
|
|||
func (activities ActivitiesByName) Less(i, j int) bool { |
|||
return activities[i].Name < activities[j].Name |
|||
} |
|||
|
|||
func (activities ActivitiesByName) Swap(i, j int) { |
|||
activities[i], activities[j] = activities[j], activities[i] |
|||
} |
@ -0,0 +1,356 @@ |
|||
package models |
|||
|
|||
import ( |
|||
"github.com/gisle/stufflog/internal/generate" |
|||
"github.com/gisle/stufflog/slerrors" |
|||
"time" |
|||
) |
|||
|
|||
// A Period is a chunk of time in which activities should be performed. They should always be created manually.
|
|||
type Period struct { |
|||
ID string `json:"id"` |
|||
UserID string `json:"userId"` |
|||
From time.Time `json:"from"` |
|||
To time.Time `json:"to"` |
|||
Name string `json:"name"` |
|||
|
|||
ShouldReScore bool `json:"-" msgpack:"-"` |
|||
|
|||
Tags []string `json:"tags"` |
|||
Goals []PeriodGoal `json:"goals"` |
|||
Logs []PeriodLog `json:"logs"` |
|||
} |
|||
|
|||
// GenerateIDs generates IDs. It should only be used initially.
|
|||
func (period *Period) GenerateIDs() { |
|||
period.ID = generate.ID("P", 12) |
|||
for i := range period.Goals { |
|||
period.Goals[i].ID = generate.ID("PG", 16) |
|||
|
|||
for j := range period.Goals[i].SubGoals { |
|||
period.Goals[i].SubGoals[j].ID = generate.ID("PGS", 20) |
|||
} |
|||
} |
|||
for i := range period.Logs { |
|||
period.Logs[i].ID = generate.ID("PL", 16) |
|||
} |
|||
} |
|||
|
|||
// Clear clears the period, but doesn't re-alloc the slices if they're set.
|
|||
func (period *Period) Clear() { |
|||
period.ID = "" |
|||
period.UserID = "" |
|||
period.From = time.Time{} |
|||
period.To = time.Time{} |
|||
period.Name = "" |
|||
|
|||
period.ShouldReScore = false |
|||
|
|||
period.Tags = period.Tags[:0] |
|||
period.Goals = period.Goals[:0] |
|||
period.Logs = period.Logs[:0] |
|||
} |
|||
|
|||
// Copy makes a deep copy of the period.
|
|||
func (period *Period) Copy() *Period { |
|||
periodCopy := *period |
|||
|
|||
periodCopy.Tags = make([]string, len(period.Tags)) |
|||
copy(periodCopy.Tags, period.Tags) |
|||
periodCopy.Goals = make([]PeriodGoal, len(period.Goals)) |
|||
copy(periodCopy.Goals, period.Goals) |
|||
periodCopy.Logs = make([]PeriodLog, len(period.Logs)) |
|||
copy(periodCopy.Logs, period.Logs) |
|||
|
|||
return &periodCopy |
|||
} |
|||
|
|||
// IncludesDate returns true if the date is within the interval of SetFrom..SetTo.
|
|||
func (period *Period) IncludesDate(date time.Time) bool { |
|||
return (date == period.From || date == period.To) || (date.After(period.From) && date.Before(period.To)) |
|||
} |
|||
|
|||
// ApplyUpdate applies an update. It does not calculate/validate the scores or remote IDs.
|
|||
//
|
|||
// It may still have been changed even if it errors.
|
|||
func (period *Period) ApplyUpdate(update PeriodUpdate) (changed bool, err error) { |
|||
if update.SetFrom != nil { |
|||
changed = true |
|||
period.From = *update.SetFrom |
|||
} |
|||
if update.SetTo != nil { |
|||
changed = true |
|||
period.To = *update.SetTo |
|||
} |
|||
if update.SetName != nil { |
|||
changed = true |
|||
period.Name = *update.SetName |
|||
} |
|||
|
|||
if update.RemoveTag != nil { |
|||
for i, existingTag := range period.Tags { |
|||
if *update.AddTag == existingTag { |
|||
period.Tags = append(period.Tags[:i], period.Tags[i+1:]...) |
|||
changed = true |
|||
break |
|||
} |
|||
} |
|||
} |
|||
if update.AddTag != nil { |
|||
found := false |
|||
for _, existingTag := range period.Tags { |
|||
if *update.AddTag == existingTag { |
|||
found = true |
|||
break |
|||
} |
|||
} |
|||
if !found { |
|||
changed = true |
|||
period.Tags = append(period.Tags, *update.AddTag) |
|||
} |
|||
} |
|||
|
|||
if update.AddGoal != nil { |
|||
goal := *update.AddGoal |
|||
goal.ID = generate.ID("PG", 16) |
|||
|
|||
for _, existingGoal := range period.Goals { |
|||
if existingGoal.ActivityID == goal.ActivityID { |
|||
return false, slerrors.PreconditionFailed("Activity already exists in another goal.") |
|||
} |
|||
} |
|||
|
|||
period.Goals = append(period.Goals, goal) |
|||
|
|||
if update.AddLog != nil && update.AddLog.GoalID == "NEW" { |
|||
update.AddLog.GoalID = goal.ID |
|||
} |
|||
for i := range goal.SubGoals { |
|||
goal.SubGoals[i].ID = generate.ID("PGS", 20) |
|||
} |
|||
|
|||
changed = true |
|||
} |
|||
if update.ReplaceGoal != nil { |
|||
goal := period.Goal(update.ReplaceGoal.ID) |
|||
if goal == nil { |
|||
err = slerrors.NotFound("Goal") |
|||
return |
|||
} |
|||
|
|||
goal.PointCount = update.ReplaceGoal.PointCount |
|||
goal.SubGoals = update.ReplaceGoal.SubGoals |
|||
|
|||
changed = true |
|||
period.ShouldReScore = true |
|||
} |
|||
if update.RemoveGoal != nil { |
|||
found := false |
|||
for i, goal := range period.Goals { |
|||
if goal.ID == *update.RemoveGoal { |
|||
period.Goals = append(period.Goals[:i], period.Goals[i+1:]...) |
|||
found = true |
|||
break |
|||
} |
|||
} |
|||
if !found { |
|||
err = slerrors.NotFound("Goal you're trying to remove") |
|||
return |
|||
} |
|||
|
|||
changed = true |
|||
period.ShouldReScore = true |
|||
} |
|||
|
|||
if update.AddLog != nil { |
|||
log := *update.AddLog |
|||
log.ID = generate.ID("L", 16) |
|||
log.SubmitTime = time.Now() |
|||
|
|||
goal := period.Goal(log.GoalID) |
|||
if goal == nil { |
|||
err = slerrors.NotFound("Goal") |
|||
return |
|||
} |
|||
|
|||
period.Logs = append(period.Logs, log) |
|||
changed = true |
|||
} |
|||
if update.RemoveLog != nil { |
|||
found := false |
|||
for i, log := range period.Logs { |
|||
if log.ID == *update.RemoveLog { |
|||
found = true |
|||
changed = true |
|||
period.Logs = append(period.Logs[:i], period.Logs[i+1:]...) |
|||
|
|||
break |
|||
} |
|||
} |
|||
if !found { |
|||
err = slerrors.NotFound("Log you're trying to remove") |
|||
return |
|||
} |
|||
} |
|||
|
|||
return |
|||
} |
|||
|
|||
func (period *Period) RemoveBrokenLogs() { |
|||
goodLogs := make([]PeriodLog, 0, len(period.Logs)) |
|||
|
|||
for _, log := range period.Logs { |
|||
goal := period.Goal(log.GoalID) |
|||
if goal == nil { |
|||
continue |
|||
} |
|||
|
|||
if log.SubGoalID != "" { |
|||
if goal.SubGoal(log.SubGoalID) == nil { |
|||
continue |
|||
} |
|||
} |
|||
|
|||
goodLogs = append(goodLogs, log) |
|||
} |
|||
|
|||
period.Logs = goodLogs |
|||
} |
|||
|
|||
func (period *Period) RemoveDuplicateTags() { |
|||
deleteList := make([]int, 0, 2) |
|||
|
|||
found := make(map[string]bool) |
|||
for i, tag := range period.Tags { |
|||
if found[tag] { |
|||
deleteList = append(deleteList, i-len(deleteList)) |
|||
} |
|||
found[tag] = true |
|||
} |
|||
for _, index := range deleteList { |
|||
period.Tags = append(period.Tags[:index], period.Tags[index+1:]...) |
|||
} |
|||
} |
|||
|
|||
func (period *Period) Goal(id string) *PeriodGoal { |
|||
for i, goal := range period.Goals { |
|||
if goal.ID == id { |
|||
return &period.Goals[i] |
|||
} |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
|
|||
func (period *Period) DayHadActivity(date time.Time, activityID string) bool { |
|||
date = date.UTC().Truncate(time.Hour * 24) |
|||
|
|||
if !period.IncludesDate(date) { |
|||
return false |
|||
} |
|||
|
|||
for _, log := range period.Logs { |
|||
goal := period.Goal(log.GoalID) |
|||
if goal == nil || goal.ActivityID != activityID { |
|||
continue |
|||
} |
|||
|
|||
if date.Equal(log.Date.UTC().Truncate(time.Hour * 24)) { |
|||
return true |
|||
} |
|||
} |
|||
|
|||
return false |
|||
} |
|||
|
|||
// A PeriodGoal is a declared investment into the goal.
|
|||
type PeriodGoal struct { |
|||
ID string `json:"id"` |
|||
ActivityID string `json:"activityId"` |
|||
PointCount int `json:"pointCount"` |
|||
|
|||
SubGoals []PeriodSubGoal `json:"subGoals"` |
|||
} |
|||
|
|||
func (goal *PeriodGoal) SubGoal(id string) *PeriodSubGoal { |
|||
if goal == nil { |
|||
return nil |
|||
} |
|||
|
|||
for i, subGoal := range goal.SubGoals { |
|||
if subGoal.ID == id { |
|||
return &goal.SubGoals[i] |
|||
} |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
|
|||
// A PeriodSubGoal is a specific sub-goal that should either punish or boost performance. For example, it could represent
|
|||
// a project or technique that should take priority, or a guilty pleasure that should definitely not be.
|
|||
type PeriodSubGoal struct { |
|||
ID string `json:"id"` |
|||
Name string `json:"name"` |
|||
Multiplier float64 `json:"multiplier"` |
|||
} |
|||
|
|||
// PeriodLog is a logged performance of an activity during the period. SubGoalID is optional, but GoalID is not. The
|
|||
// points is the points calculated on the time of submission and does not need to reflect the latest.
|
|||
type PeriodLog struct { |
|||
Date time.Time `json:"date"` |
|||
ID string `json:"id"` |
|||
SubActivityID string `json:"subActivityId"` |
|||
GoalID string `json:"goalId"` |
|||
SubGoalID string `json:"subGoalId"` |
|||
Description string `json:"description"` |
|||
SubmitTime time.Time `json:"submitTime"` |
|||
Amount int `json:"amount"` |
|||
Score *Score `json:"score"` |
|||
} |
|||
|
|||
// PeriodUpdate describes a change to a period
|
|||
type PeriodUpdate struct { |
|||
SetFrom *time.Time `json:"setFrom"` |
|||
SetTo *time.Time `json:"setTo"` |
|||
SetName *string `json:"setName"` |
|||
|
|||
AddLog *PeriodLog `json:"addLog"` |
|||
RemoveLog *string `json:"removeLog"` |
|||
AddGoal *PeriodGoal `json:"addGoal"` |
|||
ReplaceGoal *PeriodGoal `json:"replaceGoal"` |
|||
RemoveGoal *string `json:"removeGoal"` |
|||
AddTag *string `json:"addTag"` |
|||
RemoveTag *string `json:"removeTag"` |
|||
} |
|||
|
|||
type Score struct { |
|||
ActivityScore float64 `json:"activityScore"` |
|||
Amount int `json:"amount"` |
|||
SubGoalMultiplier *float64 `json:"subGoalMultiplier"` |
|||
DailyBonus int `json:"dailyBonus"` |
|||
|
|||
Total int64 `json:"total"` |
|||
} |
|||
|
|||
func (s *Score) Calc() { |
|||
subGoalMultiplier := 1.0 |
|||
if s.SubGoalMultiplier != nil { |
|||
subGoalMultiplier = *s.SubGoalMultiplier |
|||
} |
|||
|
|||
s.Total = int64(s.DailyBonus) + int64(s.ActivityScore*float64(s.Amount)*subGoalMultiplier) |
|||
} |
|||
|
|||
type PeriodsByFrom []*Period |
|||
|
|||
func (periods PeriodsByFrom) Len() int { |
|||
return len(periods) |
|||
} |
|||
|
|||
func (periods PeriodsByFrom) Less(i, j int) bool { |
|||
return periods[i].From.Before(periods[j].From) |
|||
} |
|||
|
|||
func (periods PeriodsByFrom) Swap(i, j int) { |
|||
periods[i], periods[j] = periods[j], periods[i] |
|||
} |
@ -0,0 +1,44 @@ |
|||
package models |
|||
|
|||
import ( |
|||
"github.com/gisle/stufflog/slerrors" |
|||
"golang.org/x/crypto/bcrypt" |
|||
"net/http" |
|||
"time" |
|||
) |
|||
|
|||
// A User is a user.
|
|||
type User struct { |
|||
ID string `json:"id"` |
|||
Name string `json:"name"` |
|||
Hash []byte `json:"-"` |
|||
} |
|||
|
|||
func (user *User) SetPassword(password string) error { |
|||
if len(password) < 6 { |
|||
return &slerrors.SLError{Code: http.StatusBadRequest, Text: "Password is too short"} |
|||
} |
|||
|
|||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
user.Hash = hash |
|||
return nil |
|||
} |
|||
|
|||
func (user *User) CheckPassword(password string) error { |
|||
err := bcrypt.CompareHashAndPassword(user.Hash, []byte(password)) |
|||
if err != nil { |
|||
return slerrors.LoginFailed() |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
|
|||
type UserSession struct { |
|||
ID string `json:"id"` |
|||
UserID string `json:"userId"` |
|||
Expires time.Time `json:"expires"` |
|||
} |
@ -0,0 +1,221 @@ |
|||
package services |
|||
|
|||
import ( |
|||
"context" |
|||
"github.com/gin-gonic/gin" |
|||
"github.com/gisle/stufflog/config" |
|||
"github.com/gisle/stufflog/database" |
|||
"github.com/gisle/stufflog/internal/generate" |
|||
"github.com/gisle/stufflog/models" |
|||
"github.com/gisle/stufflog/slerrors" |
|||
"golang.org/x/crypto/bcrypt" |
|||
"time" |
|||
) |
|||
|
|||
var dummyPassword []byte |
|||
|
|||
type ctxKey string |
|||
|
|||
const sessionCookie = "stufflog_sessid" |
|||
const sessionCtxKey = ctxKey("stufflog_session") |
|||
const userCtxKey = ctxKey("stufflog_user") |
|||
|
|||
type AuthService struct { |
|||
db database.Database |
|||
} |
|||
|
|||
// UserFromContext gets the User object stored in the context.
|
|||
func (s *AuthService) UserFromContext(ctx context.Context) *models.User { |
|||
if gctx, ok := ctx.(*gin.Context); ok { |
|||
ctx = gctx.Request.Context() |
|||
} |
|||
|
|||
session := s.SessionFromContext(ctx) |
|||
if session == nil { |
|||
return nil |
|||
} |
|||
|
|||
user, _ := ctx.Value(userCtxKey).(*models.User) |
|||
return user |
|||
} |
|||
|
|||
// SessionFromContext gets the Session object stored in the context.
|
|||
func (s *AuthService) SessionFromContext(ctx context.Context) *models.UserSession { |
|||
if gctx, ok := ctx.(*gin.Context); ok { |
|||
ctx = gctx.Request.Context() |
|||
} |
|||
|
|||
session, _ := ctx.Value(sessionCtxKey).(*models.UserSession) |
|||
if session == nil || time.Now().After(session.Expires) { |
|||
return nil |
|||
} |
|||
|
|||
return session |
|||
} |
|||
|
|||
// Register registers a new user.
|
|||
func (s *AuthService) Register(ctx context.Context, username, password string) (*models.User, error) { |
|||
if !config.Get().Users.AllowRegister { |
|||
return nil, &slerrors.SLError{Code: 403, Text: "Registration is disabled"} |
|||
} |
|||
|
|||
if len(password) < 6 { |
|||
return nil, &slerrors.SLError{Code: 400, Text: "Password is too short"} |
|||
} |
|||
if len(username) == 0 { |
|||
return nil, &slerrors.SLError{Code: 400, Text: "No username provided"} |
|||
} |
|||
|
|||
if _, err := s.db.Users().FindID(ctx, username); !slerrors.IsNotFound(err) { |
|||
return nil, &slerrors.SLError{Code: 409, Text: "Username is not available"} |
|||
} |
|||
|
|||
user := models.User{ |
|||
ID: username, |
|||
Name: username, |
|||
} |
|||
err := user.SetPassword(password) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
err = s.db.Users().Save(ctx, user) |
|||
|
|||
return &user, nil |
|||
} |
|||
|
|||
// Login logs in the user and creates the session. The session is not saved to the request. For that you need to use
|
|||
// GinSaveSession on the returned session.
|
|||
func (s *AuthService) Login(ctx context.Context, userID, password string) (*models.User, *models.UserSession, error) { |
|||
user, err := s.db.Users().FindID(ctx, userID) |
|||
if err != nil { |
|||
_ = bcrypt.CompareHashAndPassword(dummyPassword, []byte("u r a dummy too")) |
|||
return nil, nil, slerrors.LoginFailed() |
|||
} |
|||
|
|||
err = user.CheckPassword(password) |
|||
if err != nil { |
|||
return nil, nil, slerrors.LoginFailed() |
|||
} |
|||
|
|||
session := models.UserSession{ |
|||
ID: generate.ID("S", 64), |
|||
Expires: time.Now().Add(time.Hour * 24 * 30), |
|||
UserID: user.ID, |
|||
} |
|||
err = s.db.UserSessions().Save(ctx, session) |
|||
if err != nil { |
|||
return nil, nil, err |
|||
} |
|||
|
|||
return user, &session, nil |
|||
} |
|||
|
|||
// Logout deletes the session associated with the context.
|
|||
func (s *AuthService) Logout(ctx context.Context) error { |
|||
session := s.SessionFromContext(ctx) |
|||
if session == nil { |
|||
return slerrors.Unauthorized().WithText("You're already logged out.") |
|||
} |
|||
|
|||
session.Expires = time.Time{} |
|||
|
|||
return s.db.UserSessions().Remove(ctx, *session) |
|||
} |
|||
|
|||
// SaveSession saves a session to gin.
|
|||
func (s *AuthService) GinSaveSession(c *gin.Context, session models.UserSession) { |
|||
c.SetCookie(sessionCookie, session.ID, 336*3600, "", "", false, true) |
|||
} |
|||
|
|||
// SaveSession saves a session to gin.
|
|||
func (s *AuthService) GinClearSession(c *gin.Context) { |
|||
c.SetCookie(sessionCookie, "", 0, "", "", false, true) |
|||
} |
|||
|
|||
// Middleware is a middleware that adds a session to the context, or
|
|||
// gives a 403 Unauthorized error if there is none.
|
|||
func (s *AuthService) GinSessionMiddleware(required bool) gin.HandlerFunc { |
|||
return func(c *gin.Context) { |
|||
ctx := c.Request.Context() |
|||
|
|||
// Find cookie
|
|||
cookie, err := c.Cookie(sessionCookie) |
|||
if err != nil || cookie == "" { |
|||
if required { |
|||
slerrors.GinRespond(c, slerrors.Unauthorized()) |
|||
c.Abort() |
|||
return |
|||
} |
|||
|
|||
c.Next() |
|||
return |
|||
} |
|||
|
|||
// Check session existence
|
|||
session, err := s.db.UserSessions().FindID(ctx, cookie) |
|||
if err != nil { |
|||
c.SetCookie(sessionCookie, "", 0, "", "", false, true) |
|||
|
|||
if required { |
|||
slerrors.GinRespond(c, slerrors.Unauthorized()) |
|||
c.Abort() |
|||
return |
|||
} |
|||
|
|||
c.Next() |
|||
return |
|||
} |
|||
ctx = context.WithValue(ctx, sessionCtxKey, session) |
|||
|
|||
// Find the user associated with the session
|
|||
user, err := s.db.Users().FindID(ctx, session.UserID) |
|||
if err != nil { |
|||
c.SetCookie(sessionCookie, "", 0, "", "", false, true) |
|||
_ = s.db.UserSessions().Remove(ctx, *session) |
|||
|
|||
if required { |
|||
slerrors.GinRespond(c, slerrors.Unauthorized()) |
|||
c.Abort() |
|||
return |
|||
} |
|||
|
|||
c.Next() |
|||
return |
|||
} |
|||
ctx = context.WithValue(ctx, userCtxKey, user) |
|||
|
|||
// Check if session has expired
|
|||
if time.Now().After(session.Expires) { |
|||
c.SetCookie(sessionCookie, "", 0, "", "", false, true) |
|||
_ = s.db.UserSessions().Remove(ctx, *session) |
|||
|
|||
if required { |
|||
slerrors.GinRespond(c, slerrors.Unauthorized()) |
|||
c.Abort() |
|||
return |
|||
} |
|||
|
|||
c.Next() |
|||
return |
|||
} |
|||
|
|||
// If there's less than 27.75 days, reset expiry.
|
|||
if time.Until(session.Expires) < time.Hour*666 { |
|||
session.Expires = time.Now().Add(time.Hour * 672) |
|||
s.GinSaveSession(c, *session) |
|||
_ = s.db.UserSessions().Save(c.Request.Context(), *session) |
|||
} |
|||
|
|||
// Proceed.
|
|||
c.Request = c.Request.WithContext(ctx) |
|||
c.Next() |
|||
} |
|||
} |
|||
|
|||
func NewAuthService(db database.Database) *AuthService { |
|||
return &AuthService{db: db} |
|||
} |
|||
|
|||
func init() { |
|||
dummyPassword, _ = bcrypt.GenerateFromPassword([]byte("u r a dummy"), bcrypt.DefaultCost) |
|||
} |
@ -0,0 +1,98 @@ |
|||
package services |
|||
|
|||
import ( |
|||
"context" |
|||
"github.com/gisle/stufflog/database" |
|||
"github.com/gisle/stufflog/models" |
|||
"github.com/gisle/stufflog/slerrors" |
|||
"time" |
|||
) |
|||
|
|||
// ScoringService scores activities. It cares not for users.
|
|||
type ScoringService struct { |
|||
db database.Database |
|||
} |
|||
|
|||
func (s *ScoringService) ScoreOne(ctx context.Context, period models.Period, log models.PeriodLog) (*models.Score, error) { |
|||
return s.scoreOne(ctx, period, log) |
|||
} |
|||
|
|||
func (s *ScoringService) scoreOne(ctx context.Context, period models.Period, log models.PeriodLog) (*models.Score, error) { |
|||
goal := period.Goal(log.GoalID) |
|||
if goal == nil { |
|||
return nil, slerrors.NotFound("Goal") |
|||
} |
|||
|
|||
activity, err := s.db.Activities().FindID(ctx, goal.ActivityID) |
|||
if err != nil || activity.UserID != period.UserID { |
|||
return nil, slerrors.NotFound("Goal's Activity") |
|||
} |
|||
subActivity := activity.SubActivity(log.SubActivityID) |
|||
if subActivity == nil { |
|||
return nil, slerrors.NotFound("Goal's Sub-Activity") |
|||
} |
|||
|
|||
score := models.Score{ |
|||
ActivityScore: subActivity.Value, |
|||
Amount: log.Amount, |
|||
} |
|||
|
|||
if log.SubGoalID != "" { |
|||
subGoal := goal.SubGoal(log.SubGoalID) |
|||
if subGoal == nil { |
|||
return nil, slerrors.NotFound("Sub-Goal") |
|||
} |
|||
|
|||
multiplier := subGoal.Multiplier |
|||
score.SubGoalMultiplier = &multiplier |
|||
} |
|||
|
|||
if activity.DailyBonus > 0 { |
|||
isFirst := true |
|||
for _, log2 := range period.Logs { |
|||
if log2.Date.Local().Truncate(time.Hour*24) != log.Date.Local().Truncate(time.Hour*24) { |
|||
continue |
|||
} |
|||
|
|||
if log2.ID == log.ID { |
|||
break |
|||
} |
|||
if log2.GoalID == log.GoalID { |
|||
isFirst = false |
|||
break |
|||
} |
|||
} |
|||
|
|||
if isFirst { |
|||
score.DailyBonus = activity.DailyBonus |
|||
} |
|||
} |
|||
|
|||
score.Calc() |
|||
|
|||
return &score, nil |
|||
} |
|||
|
|||
func (s *ScoringService) ScoreAll(ctx context.Context, period models.Period) (*models.Period, error) { |
|||
period = *period.Copy() |
|||
|
|||
for i, log := range period.Logs { |
|||
score, err := s.scoreOne(ctx, period, log) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
period.Logs[i].Score = score |
|||
} |
|||
|
|||
err := s.db.Periods().Insert(ctx, period) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return &period, nil |
|||
} |
|||
|
|||
func NewScoringService(db database.Database) *ScoringService { |
|||
return &ScoringService{db: db} |
|||
} |
@ -0,0 +1,82 @@ |
|||
package slerrors |
|||
|
|||
import ( |
|||
"github.com/gin-gonic/gin" |
|||
"github.com/gisle/stufflog/internal/generate" |
|||
"log" |
|||
"net/http" |
|||
"strconv" |
|||
) |
|||
|
|||
type SLError struct { |
|||
Code int `json:"code"` |
|||
Text string `json:"text"` |
|||
} |
|||
|
|||
func (err *SLError) Error() string { |
|||
return "slerror: " + err.Text + " (Suggested status code: " + strconv.Itoa(err.Code) + ")" |
|||
} |
|||
|
|||
func (err *SLError) WithText(text string) *SLError { |
|||
return &SLError{Code: err.Code, Text: text} |
|||
} |
|||
|
|||
// GinRespond responds to a gin request.
|
|||
func GinRespond(c *gin.Context, err error) { |
|||
serr := Wrap(err) |
|||
|
|||
type errResponse struct { |
|||
Error *SLError `json:"error"` |
|||
} |
|||
|
|||
if serr.Code == 500 { |
|||
errId := generate.ID("E", 16) |
|||
|
|||
log.Println("Internal Server Error", errId, "--", err.Error()) |
|||
|
|||
c.JSON(500, errResponse{&SLError{ |
|||
Code: 500, |
|||
Text: "Internal server error: ref " + errId, |
|||
}}) |
|||
return |
|||
} |
|||
|
|||
c.JSON(serr.Code, errResponse{serr}) |
|||
} |
|||
|
|||
// Wrap wraps an error
|
|||
func Wrap(err error) *SLError { |
|||
if slerr, ok := err.(*SLError); ok { |
|||
return slerr |
|||
} |
|||
|
|||
if _, ok := err.(*strconv.NumError); ok { |
|||
return &SLError{Code: http.StatusBadRequest, Text: err.Error()} |
|||
} |
|||
|
|||
return &SLError{Code: http.StatusInternalServerError, Text: err.Error()} |
|||
} |
|||
|
|||
func NotFound(noun string) *SLError { |
|||
return &SLError{Code: http.StatusNotFound, Text: noun + " not found"} |
|||
} |
|||
|
|||
func PreconditionFailed(message string) *SLError { |
|||
return &SLError{Code: http.StatusPreconditionFailed, Text: message} |
|||
} |
|||
|
|||
func LoginFailed() *SLError { |
|||
return &SLError{Code: http.StatusForbidden, Text: "Login Failed"} |
|||
} |
|||
|
|||
func Unauthorized() *SLError { |
|||
return &SLError{Code: http.StatusUnauthorized, Text: "Unauthorized"} |
|||
} |
|||
|
|||
func IsNotFound(err error) bool { |
|||
if serr, ok := err.(*SLError); ok && serr.Code == 404 { |
|||
return true |
|||
} |
|||
|
|||
return false |
|||
} |
@ -0,0 +1,4 @@ |
|||
.DS_Store |
|||
node_modules |
|||
public/bundle.* |
|||
yarn.lock |
@ -0,0 +1,64 @@ |
|||
# svelte app |
|||
|
|||
This is a project template for [Svelte](https://svelte.dev) apps. It lives at https://github.com/sveltejs/template-webpack. |
|||
|
|||
To create a new project based on this template using [degit](https://github.com/Rich-Harris/degit): |
|||
|
|||
```bash |
|||
npx degit sveltejs/template-webpack svelte-app |
|||
cd svelte-app |
|||
``` |
|||
|
|||
*Note that you will need to have [Node.js](https://nodejs.org) installed.* |
|||
|
|||
|
|||
## Get started |
|||
|
|||
Install the dependencies... |
|||
|
|||
```bash |
|||
cd svelte-app |
|||
npm install |
|||
``` |
|||
|
|||
...then start webpack: |
|||
|
|||
```bash |
|||
npm run dev |
|||
``` |
|||
|
|||
Navigate to [localhost:8080](http://localhost:8080). You should see your app running. Edit a component file in `src`, save it, and the page should reload with your changes. |
|||
|
|||
|
|||
## Deploying to the web |
|||
|
|||
### With [now](https://zeit.co/now) |
|||
|
|||
Install `now` if you haven't already: |
|||
|
|||
```bash |
|||
npm install -g now |
|||
``` |
|||
|
|||
Then, from within your project folder: |
|||
|
|||
```bash |
|||
now |
|||
``` |
|||
|
|||
As an alternative, use the [Now desktop client](https://zeit.co/download) and simply drag the unzipped project folder to the taskbar icon. |
|||
|
|||
### With [surge](https://surge.sh/) |
|||
|
|||
Install `surge` if you haven't already: |
|||
|
|||
```bash |
|||
npm install -g surge |
|||
``` |
|||
|
|||
Then, from within your project folder: |
|||
|
|||
```bash |
|||
npm run build |
|||
surge public |
|||
``` |
6465
svelte-ui/package-lock.json
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,29 @@ |
|||
{ |
|||
"name": "stufflog-svelte-ui", |
|||
"version": "1.0.0", |
|||
"devDependencies": { |
|||
"@fortawesome/free-solid-svg-icons": "^5.12.0", |
|||
"capitalize": "^2.0.1", |
|||
"cross-env": "^5.2.0", |
|||
"css-loader": "^2.1.1", |
|||
"fa-svelte": "^3.1.0", |
|||
"html-minifier": "^4.0.0", |
|||
"ky": "^0.17.0", |
|||
"mini-css-extract-plugin": "^0.6.0", |
|||
"minify-html-webpack-plugin": "0.0.5", |
|||
"pluralize": "^8.0.0", |
|||
"serve": "^11.0.0", |
|||
"style-loader": "^0.23.1", |
|||
"svelte": "^3.0.0", |
|||
"svelte-loader": "2.13.3", |
|||
"svelte-routing": "^1.4.0", |
|||
"webpack": "^4.30.0", |
|||
"webpack-cli": "^3.3.0", |
|||
"webpack-dev-server": "^3.8.2" |
|||
}, |
|||
"scripts": { |
|||
"build": "cross-env NODE_ENV=production webpack", |
|||
"dev": "webpack-dev-server --content-base public" |
|||
}, |
|||
"dependencies": {} |
|||
} |
After Width: 128 | Height: 128 | Size: 3.1 KiB |
@ -0,0 +1,63 @@ |
|||
html, body { |
|||
position: relative; |
|||
width: 100%; |
|||
height: 100%; |
|||
} |
|||
|
|||
body { |
|||
background-color: #111; |
|||
color: #999; |
|||
margin: 0; |
|||
padding: 0; |
|||
box-sizing: border-box; |
|||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; |
|||
font-size: 1.5em; |
|||
} |
|||
|
|||
a { |
|||
color: rgb(0,100,200); |
|||
text-decoration: none; |
|||
} |
|||
|
|||
a:hover { |
|||
text-decoration: underline; |
|||
} |
|||
|
|||
a:visited { |
|||
color: rgb(0,80,160); |
|||
} |
|||
|
|||
label { |
|||
display: block; |
|||
} |
|||
|
|||
input, button, select, textarea { |
|||
font-family: inherit; |
|||
font-size: inherit; |
|||
padding: 0.4em; |
|||
margin: 0 0 0.5em 0; |
|||
box-sizing: border-box; |
|||
border: 1px solid #ccc; |
|||
border-radius: 2px; |
|||
} |
|||
|
|||
input:disabled { |
|||
color: #ccc; |
|||
} |
|||
|
|||
input[type="range"] { |
|||
height: 0; |
|||
} |
|||
|
|||
button { |
|||
background-color: #f4f4f4; |
|||
outline: none; |
|||
} |
|||
|
|||
button:active { |
|||
background-color: #ddd; |
|||
} |
|||
|
|||
button:focus { |
|||
border-color: #666; |
|||
} |
@ -0,0 +1,17 @@ |
|||
<!doctype html> |
|||
<html> |
|||
<head> |
|||
<meta charset="utf8"> |
|||
<meta name="viewport" content="width=device-width"> |
|||
|
|||
<title>Stufflog</title> |
|||
|
|||
<link rel="icon" type="image/png" href="favicon.png"> |
|||
<link rel="stylesheet" href="/global.css"> |
|||
<link rel="stylesheet" href="/bundle.css"> |
|||
</head> |
|||
|
|||
<body> |
|||
<script src="/bundle.js"></script> |
|||
</body> |
|||
</html> |
@ -0,0 +1,64 @@ |
|||
<script> |
|||
import { Router, Route } from "svelte-routing"; |
|||
|
|||
import LogPage from "./routes/LogPage"; |
|||
import ActivitiesPage from "./routes/ActivitiesPage"; |
|||
|
|||
import LoginModal from "./modals/LoginModal"; |
|||
import CreateActivityModal from "./modals/CreateActivityModal"; |
|||
import CreatePeriodModal from "./modals/CreatePeriodModal"; |
|||
import AddPeriodGoalModal from "./modals/AddPeriodGoalModal" |
|||
import AddPeriodLogModal from "./modals/AddPeriodLogModal" |
|||
import AddSubActivityModal from "./modals/AddSubActivityModal"; |
|||
import EditPeriodModal from "./modals/EditPeriodModal"; |
|||
import DeletePeriodModal from "./modals/DeletePeriodModal"; |
|||
import EditActivityModal from "./modals/EditActivityModal"; |
|||
import EditSubActivityModal from "./modals/EditSubActivityModal"; |
|||
import RemoveSubActivityModal from "./modals/RemoveSubActivityModal"; |
|||
import RemovePeriodGoalModal from "./modals/RemovePeriodGoalModal"; |
|||
import RemovePeriodLogModal from "./modals/RemovePeriodLogModal"; |
|||
import InfoPeriodLogModal from "./modals/InfoPeriodLogModal"; |
|||
import DeleteActivityModal from "./modals/DeleteActivityModal"; |
|||
|
|||
import Modal from "./hooks/Modal"; |
|||
|
|||
import Menu from "./components/Menu"; |
|||
import MenuItem from "./components/MenuItem"; |
|||
|
|||
import LoginCheck from "./LoginCheck"; |
|||
|
|||
import auth from "./stores/auth"; |
|||
</script> |
|||
|
|||
<Menu> |
|||
<MenuItem href="/">Stufflog</MenuItem><MenuItem href="/activities/">Activities</MenuItem> |
|||
|
|||
<div slot="right"> |
|||
{#if ($auth.checked && $auth.user !== null)} |
|||
<MenuItem on:click={() => auth.logout()}>Logout [{$auth.user.name}]</MenuItem> |
|||
{/if} |
|||
</div> |
|||
</Menu> |
|||
|
|||
<Router> |
|||
<Route path="/" component={LogPage} /> |
|||
<Route path="/activities/" component={ActivitiesPage} /> |
|||
</Router> |
|||
|
|||
<Modal name="login" component={LoginModal} /> |
|||
<Modal name="period.create" component={CreatePeriodModal} /> |
|||
<Modal name="period.edit" component={EditPeriodModal} /> |
|||
<Modal name="period.delete" component={DeletePeriodModal} /> |
|||
<Modal name="activity.create" component={CreateActivityModal} /> |
|||
<Modal name="activity.edit" component={EditActivityModal} /> |
|||
<Modal name="activity.delete" component={DeleteActivityModal} /> |
|||
<Modal name="subactivity.add" component={AddSubActivityModal} /> |
|||
<Modal name="subactivity.edit" component={EditSubActivityModal} /> |
|||
<Modal name="subactivity.remove" component={RemoveSubActivityModal} /> |
|||
<Modal name="periodgoal.add" component={AddPeriodGoalModal} /> |
|||
<Modal name="periodgoal.remove" component={RemovePeriodGoalModal} /> |
|||
<Modal name="periodlog.add" component={AddPeriodLogModal} /> |
|||
<Modal name="periodlog.remove" component={RemovePeriodLogModal} /> |
|||
<Modal name="periodlog.info" component={InfoPeriodLogModal} /> |
|||
|
|||
<LoginCheck /> |
@ -0,0 +1,22 @@ |
|||
<script> |
|||
import auth from "./stores/auth"; |
|||
import stufflog from "./stores/stufflog"; |
|||
import modal from "./stores/modal"; |
|||
|
|||
export let url = ""; |
|||
|
|||
let initialLoadDone = false; |
|||
|
|||
auth.check(); |
|||
|
|||
$: { |
|||
if ($auth.user !== null && !initialLoadDone) { |
|||
initialLoadDone = true; |
|||
stufflog.listActivities(); |
|||
stufflog.listPeriods(); |
|||
} else if ($auth.user === null && $auth.checked) { |
|||
initialLoadDone = false; |
|||
modal.open("login"); |
|||
} |
|||
} |
|||
</script> |
@ -0,0 +1,139 @@ |
|||
import ky from "ky"; |
|||
import Activity, {ActivityUpdate} from "../models/activity"; |
|||
import Period, {PeriodUpdate} from "../models/period"; |
|||
|
|||
function json(data) { |
|||
return { |
|||
body: JSON.stringify(data), |
|||
headers: { |
|||
"Content-Type": "application/json", |
|||
}, |
|||
credentials: "include", |
|||
} |
|||
} |
|||
|
|||
const k = ky.create({ |
|||
credentials: "include", |
|||
}) |
|||
|
|||
export class StuffLogAPI { |
|||
async checkSession() { |
|||
return await k.get("/api/user/").json(); |
|||
} |
|||
async login(username, password) { |
|||
return await k.post("/api/user/login", json({username,password})).json(); |
|||
} |
|||
async register(username, password) { |
|||
return await k.post("/api/user/register", json({username,password})).json(); |
|||
} |
|||
async logout() { |
|||
return await k.post("/api/user/logout").json(); |
|||
} |
|||
|
|||
/** |
|||
* List activities |
|||
* |
|||
* @returns {Activity[]} |
|||
*/ |
|||
async listActivities() { |
|||
const data = await k.get("/api/activity/").json(); |
|||
return data.activities.map(d => new Activity(d)); |
|||
} |
|||
/** |
|||
* List periods |
|||
* |
|||
* @returns {{periods: Period[], activities: Activity[]}} |
|||
*/ |
|||
async listPeriods() { |
|||
const data = await k.get("/api/period/").json(); |
|||
|
|||
return data.periods.map(d => new Period(d)); |
|||
} |
|||
/** |
|||
* Find activity |
|||
* |
|||
* @param {string} id |
|||
* @returns {Activity} |
|||
*/ |
|||
async findActivity(id) { |
|||
const data = await k.get(`/api/activity/${id}`).json(); |
|||
return new Activity(data.activity); |
|||
} |
|||
/** |
|||
* Find period |
|||
* |
|||
* @param {string} id |
|||
* @returns {Period} |
|||
*/ |
|||
async findPeriod(id) { |
|||
const data = await k.get(`/api/period/${id}`).json(); |
|||
return new Period(data.period); |
|||
} |
|||
/** |
|||
* Create a new activity |
|||
* |
|||
* @param {Activity} activity |
|||
* @returns {Activity} |
|||
*/ |
|||
async postActivity(activity) { |
|||
const data = await k.post(`/api/activity/`, json(activity)).json(); |
|||
return new Activity(data.activity); |
|||
} |
|||
/** |
|||
* Create a new period |
|||
* |
|||
* @param {Period} period |
|||
* @returns {Period} |
|||
*/ |
|||
async postPeriod(period) { |
|||
const data = await k.post(`/api/period/`, json(period)).json(); |
|||
return new Period(data.period); |
|||
} |
|||
/** |
|||
* Update an activity |
|||
* |
|||
* @param {string} id |
|||
* @param {ActivityUpdate[]} updates |
|||
* @returns {Activity} |
|||
*/ |
|||
async patchActivity(id, ...updates) { |
|||
const data = await k.patch(`/api/activity/${id}`, json(updates)).json(); |
|||
return new Activity(data.activity); |
|||
} |
|||
/** |
|||
* Update an period |
|||
* |
|||
* @param {string} id |
|||
* @param {PeriodUpdate[]} updates |
|||
* @returns {Period} |
|||
*/ |
|||
async patchPeriod(id, ...updates) { |
|||
const data = await k.patch(`/api/period/${id}`, json(updates)).json(); |
|||
return new Period(data.period); |
|||
} |
|||
/** |
|||
* Update an activity |
|||
* |
|||
* @param {string} id |
|||
* @returns {Activity} |
|||
*/ |
|||
async deleteActivity(id) { |
|||
const data = await k.delete(`/api/activity/${id}`).json(); |
|||
return new Activity(data.activity); |
|||
} |
|||
|
|||
/** |
|||
* Update an period |
|||
* |
|||
* @param {string} id |
|||
* @returns {Period} |
|||
*/ |
|||
async deletePeriod(id) { |
|||
const data = await k.delete(`/api/period/${id}`).json(); |
|||
return new Period(data.period); |
|||
} |
|||
} |
|||
|
|||
const slApi = new StuffLogAPI(); |
|||
|
|||
export default slApi; |
@ -0,0 +1,45 @@ |
|||
<script context="module"> |
|||
import { faQuestion } from "@fortawesome/free-solid-svg-icons/faQuestion"; |
|||
import { faPlus } from "@fortawesome/free-solid-svg-icons/faPlus"; |
|||
import { faCubes } from "@fortawesome/free-solid-svg-icons/faCubes"; |
|||
import { faBook } from "@fortawesome/free-solid-svg-icons/faBook"; |
|||
import { faBookDead } from "@fortawesome/free-solid-svg-icons/faBookDead"; |
|||
import { faPen } from "@fortawesome/free-solid-svg-icons/faPen"; |
|||
import { faDiceD20 } from "@fortawesome/free-solid-svg-icons/faDiceD20"; |
|||
import { faDiceD6 } from "@fortawesome/free-solid-svg-icons/faDiceD6"; |
|||
import { faDungeon } from "@fortawesome/free-solid-svg-icons/faDungeon"; |
|||
import { faGamepad } from "@fortawesome/free-solid-svg-icons/faGamepad"; |
|||
import { faLanguage } from "@fortawesome/free-solid-svg-icons/faLanguage"; |
|||
import { faCode } from "@fortawesome/free-solid-svg-icons/faCode"; |
|||
import { faCodeBranch } from "@fortawesome/free-solid-svg-icons/faCodeBranch"; |
|||
import { faGuitar } from "@fortawesome/free-solid-svg-icons/faGuitar"; |
|||
import { faMusic } from "@fortawesome/free-solid-svg-icons/faMusic"; |
|||
|
|||
const icons = { |
|||
"plus": faPlus, |
|||
"cubes": faCubes, |
|||
"book": faBook, |
|||
"book_dead": faBookDead, |
|||
"pen": faPen, |
|||
"dice_d20": faDiceD20, |
|||
"dice_d6": faDiceD6, |
|||
"dungeon": faDungeon, |
|||
"gamepad": faGamepad, |
|||
"language": faLanguage, |
|||
"code": faCode, |
|||
"code_branch": faCodeBranch, |
|||
"guitar": faGuitar, |
|||
}; |
|||
|
|||
export const iconNames = Object.keys(icons); |
|||
</script> |
|||
|
|||
<script> |
|||
import Icon from "fa-svelte" |
|||
|
|||
export let name = ""; |
|||
</script> |
|||
|
|||
<Icon class="activity-icon" icon={icons[name] || faQuestion} /> |
|||
|
|||
<style></style> |
@ -0,0 +1,42 @@ |
|||
<script> |
|||
import ActivityIcon, {iconNames} from "./ActivityIcon.svelte"; |
|||
|
|||
export let value; |
|||
</script> |
|||
|
|||
<div class="icon-select"> |
|||
{#each iconNames as iconName (iconName)} |
|||
<div class="icon-item" class:selected={value===iconName} on:click={() => value = iconName}> |
|||
<ActivityIcon name={iconName} /> |
|||
</div> |
|||
{/each} |
|||
</div> |
|||
|
|||
<style> |
|||
div.icon-select { |
|||
background: #222; |
|||
margin: 0; |
|||
padding: 0; |
|||
border-radius: 0.05em; |
|||
margin-bottom: 0.5em; |
|||
} |
|||
|
|||
div.icon-item { |
|||
display: inline-block; |
|||
box-sizing: border-box; |
|||
width: calc(100% / 8); |
|||
padding: 0.35em 0 0.25em 0; |
|||
text-align: center; |
|||
|
|||
cursor: pointer; |
|||
} |
|||
div.icon-item:hover { |
|||
background-color: #292929; |
|||
} |
|||
div.icon-item.selected { |
|||
background-color: rgb(18, 63, 75); |
|||
} |
|||
div.icon-item.selected:hover { |
|||
background-color: rgb(24, 83, 99); |
|||
} |
|||
</style> |
@ -0,0 +1,40 @@ |
|||
<script> |
|||
import Icon from 'fa-svelte' |
|||
import { faPlus } from '@fortawesome/free-solid-svg-icons/faPlus' |
|||
|
|||
export let header = ""; |
|||
export let icon = null; |
|||
</script> |
|||
|
|||
<div on:click class="addboi"> |
|||
<Icon class="addboi-icon" icon={faPlus} /> |
|||
<slot></slot> |
|||
</div> |
|||
|
|||
<style> |
|||
div.addboi { |
|||
margin: 0.5em 0 4em 0; |
|||
padding: 1em 0; |
|||
text-align: center; |
|||
border: 4px dotted; |
|||
|
|||
color: #333; |
|||
font-size: 2em; |
|||
font-weight: 100; |
|||
|
|||
cursor: pointer; |
|||
|
|||
transition: 250ms; |
|||
} |
|||
|
|||
div.addboi:hover { |
|||
color: #777; |
|||
|
|||
transition: 250ms; |
|||
} |
|||
|
|||
div.addboi > :global(.addboi-icon) { |
|||
position: relative; |
|||
top: 0.125em; |
|||
} |
|||
</style> |
@ -0,0 +1,41 @@ |
|||
<script> |
|||
import ActivityIcon from "./ActivityIcon" |
|||
|
|||
export let header = ""; |
|||
export let icon = null; |
|||
</script> |
|||
|
|||
<div class="boi"> |
|||
<div class="boi-header"> |
|||
{#if (icon !== null)}<ActivityIcon name={icon} />{/if}{header} |
|||
</div> |
|||
<div class="boi-content"> |
|||
<slot></slot> |
|||
</div> |
|||
</div> |
|||
|
|||
<style> |
|||
div.boi { |
|||
margin: auto; |
|||
max-width: 100%; |
|||
|
|||
background-color: #222; |
|||
margin: 1em 0; |
|||
|
|||
box-shadow: 0.1em 0.1em 0.05em #080808; |
|||
} |
|||
|
|||
div.boi > div.boi-header { |
|||
background-color: #292929; |
|||
color: #aaa; |
|||
text-align: center; |
|||
font-size: 1.25em; |
|||
padding: 0.25em 0; |
|||
} |
|||
|
|||
div.boi > div.boi-header > :global(.activity-icon) { |
|||
position: relative; |
|||
top: 0.125em; |
|||
margin-right: 0.5ch; |
|||
} |
|||
</style> |
@ -0,0 +1,51 @@ |
|||
<script> |
|||
export let size = 1; |
|||
</script> |
|||
|
|||
<div class="col col-{size}"><slot></slot></div> |
|||
|
|||
<style> |
|||
div.col { |
|||
display: inline-block; |
|||
box-sizing: border-box; |
|||
padding: 0; |
|||
margin: 0; |
|||
} |
|||
|
|||
div.col-1 { |
|||
width: calc((100% / 12) * 1) |
|||
} |
|||
div.col-2 { |
|||
width: calc((100% / 12) * 2) |
|||
} |
|||
div.col-3 { |
|||
width: calc((100% / 12) * 3) |
|||
} |
|||
div.col-4 { |
|||
width: calc((100% / 12) * 4) |
|||
} |
|||
div.col-5 { |
|||
width: calc((100% / 12) * 5) |
|||
} |
|||
div.col-6 { |
|||
width: calc((100% / 12) * 6) |
|||
} |
|||
div.col-7 { |
|||
width: calc((100% / 12) * 7) |
|||
} |
|||
div.col-8 { |
|||
width: calc((100% / 12) * 8) |
|||
} |
|||
div.col-9 { |
|||
width: calc((100% / 12) * 9) |
|||
} |
|||
div.col-10 { |
|||
width: calc((100% / 12) * 10) |
|||
} |
|||
div.col-11 { |
|||
width: calc((100% / 12) * 11) |
|||
} |
|||
div.col-12 { |
|||
width: 100% |
|||
} |
|||
</style> |
@ -0,0 +1,16 @@ |
|||
<span on:click><slot></slot></span> |
|||
|
|||
<style> |
|||
span { |
|||
color: #777; |
|||
cursor: pointer; |
|||
|
|||
transition: 250ms; |
|||
} |
|||
span:hover { |
|||
color: #FFF; |
|||
text-decoration: underline; |
|||
|
|||
transition: 250ms; |
|||
} |
|||
</style> |
@ -0,0 +1,20 @@ |
|||
<div class="menu"> |
|||
<div class="right"> |
|||
<slot name="right" /> |
|||
</div> |
|||
<slot /> |
|||
</div> |
|||
|
|||
<style> |
|||
div.right { |
|||
position: absolute; |
|||
right: 0; |
|||
text-align: right; |
|||
} |
|||
|
|||
div.menu { |
|||
background-color: #222; |
|||
margin: 0; |
|||
user-select: none; |
|||
} |
|||
</style> |
@ -0,0 +1,28 @@ |
|||
<script> |
|||
import { link } from "svelte-routing"; |
|||
|
|||
export let href = "/"; |
|||
</script> |
|||
|
|||
<a class="menu-item" href={href} on:click use:link><slot/></a> |
|||
|
|||
<style> |
|||
a.menu-item { |
|||
display: inline-block; |
|||
background-color: #222; |
|||
margin: 0; |
|||
padding: 0.25em 1ch; |
|||
|
|||
color: #777; |
|||
|
|||
transition: 250ms; |
|||
} |
|||
|
|||
a.menu-item:hover { |
|||
background-color: #111; |
|||
color: #ccc; |
|||
text-decoration: none; |
|||
|
|||
transition: 250ms; |
|||
} |
|||
</style> |
@ -0,0 +1,168 @@ |
|||
<script> |
|||
import { createEventDispatcher } from 'svelte'; |
|||
|
|||
export let title = ""; |
|||
export let wide = false; |
|||
export let error = null; |
|||
export let closable = false; |
|||
|
|||
const dispatch = createEventDispatcher(); |
|||
</script> |
|||
|
|||
<div class="modal-background" on:click|self={() => closable ? dispatch("close") : null}> |
|||
<div class="modal" class:wide> |
|||
<div class="header"> |
|||
<div class="title" class:noclose={!closable}>{title}</div> |
|||
{#if (closable)} |
|||
<div class="x"> |
|||
<div class="button" on:click|self={() => dispatch("close")}>X</div> |
|||
</div> |
|||
{/if} |
|||
</div> |
|||
<hr /> |
|||
{#if (error != null)} |
|||
<div class="error">{error}</div> |
|||
{/if} |
|||
<div class="body"> |
|||
<slot></slot> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<style> |
|||
div.modal-background { |
|||
position: fixed; |
|||
top: 0; |
|||
left: 0; |
|||
width: 100%; |
|||
height: 100%; |
|||
background: rgba(0,0,0,0.3); |
|||
} |
|||
|
|||
div.modal { |
|||
position: absolute; |
|||
left: 50%; |
|||
top: 50%; |
|||
width: calc(100vw - 4em); |
|||
max-width: 40ch; |
|||
max-height: calc(100vh - 4em); |
|||
overflow: auto; |
|||
transform: translate(-50%,-50%); |
|||
padding: 1em; |
|||
border-radius: 0.2em; |
|||
|
|||
background: #333; |
|||
} |
|||
div.modal.wide { |
|||
max-width: 60ch; |
|||
} |
|||
|
|||
div.modal :global(hr) { |
|||
border: 0.5px solid gray; |
|||
margin: 0; |
|||
} |
|||
|
|||
div.error { |
|||
margin: 0.5em; |
|||
padding: 0.5em; |
|||
|
|||
border: 1px solid rgb(204, 65, 65); |
|||
border-radius: 0.2em; |
|||
background-color: rgb(133, 39, 39); |
|||
color: rgb(211, 141, 141); |
|||
|
|||
animation: fadein 0.5s; |
|||
} |
|||
|
|||
div.body { |
|||
margin: 1em 0.25ch; |
|||
} |
|||
|
|||
div.title { |
|||
color: #CCC; |
|||
line-height: 1em; |
|||
} |
|||
|
|||
div.title.noclose { |
|||
margin-bottom: 1.2em; |
|||
} |
|||
|
|||
div.x { |
|||
position: relative; |
|||
line-height: 1em; |
|||
top: -1em; |
|||
text-align: right; |
|||
} |
|||
div.x div.button { |
|||
color: #CCC; |
|||
display: inline-block; |
|||
padding: 0em 0.5ch 0.1em 0.5ch; |
|||
line-height: 1em; |
|||
user-select: none; |
|||
cursor: pointer; |
|||
} |
|||
div.x div.button:hover { |
|||
background: #222; |
|||
color: #FFF; |
|||
} |
|||
|
|||
div.modal :global(button) { |
|||
display: inline-block; |
|||
padding: 0.25em 0.75ch 0.26em 0.75ch; |
|||
margin: 0.75em 0.25ch 0.25em 0.25ch; |
|||
|
|||
background: none; |
|||
border: none; |
|||
border-radius: 0.2em; |
|||
color: #CCC; |
|||
|
|||
cursor: pointer; |
|||
} |
|||
|
|||
div.modal :global(button:hover), div.modal :global(button:focus) { |
|||
background: #222; |
|||
color: #FFF; |
|||
} |
|||
|
|||
div.modal :global(label) { |
|||
padding: 0 0 0.125em 0.25ch; |
|||
font-size: 0.75em; |
|||
} |
|||
|
|||
div.modal :global(input), div.modal :global(select) { |
|||
width: 100%; |
|||
margin-bottom: 0.5em; |
|||
|
|||
background: #222; |
|||
color: #777; |
|||
border: none; |
|||
outline: none; |
|||
} |
|||
div.modal :global(select) { |
|||
padding-left: 0.5ch; |
|||
} |
|||
|
|||
div.modal :global(input:last-of-type) { |
|||
margin-bottom: 1em; |
|||
} |
|||
div.modal :global(input.nolast) { |
|||
margin-bottom: 0.5em; |
|||
} |
|||
|
|||
div.modal :global(input:focus), div.modal :global(select:focus) { |
|||
background: #111; |
|||
color: #CCC; |
|||
border: none; |
|||
outline: none; |
|||
} |
|||
|
|||
div.modal :global(p) { |
|||
margin: 0.25em 1ch 1em 1ch; |
|||
font-size: 0.9em; |
|||
} |
|||
|
|||
@keyframes fadein { |
|||
from { opacity: 0; } |
|||
to { opacity: 1; } |
|||
} |
|||
</style> |
@ -0,0 +1,117 @@ |
|||
<script> |
|||
import { onMount } from "svelte"; |
|||
|
|||
export let value; |
|||
export let goal; |
|||
|
|||
let smoothValue = value; |
|||
let bars = []; |
|||
let timeout = null; |
|||
|
|||
function update(value, goal) { |
|||
const bars = []; |
|||
let gold = (goal / 1000); |
|||
let red = gold + 3; |
|||
|
|||
for (let i = 0;; ++i) { |
|||
let v = value - (i * 1000); |
|||
if (v > 1000) { |
|||
v = 1000; |
|||
} else if (v < 0) { |
|||
v = 0; |
|||
} |
|||
|
|||
if (i >= red && v === 0) { |
|||
break; |
|||
} |
|||
|
|||
bars.push({ |
|||
green: i < gold, |
|||
gold: i >= gold && i < red, |
|||
red: i >= red, |
|||
value: v, |
|||
full: v === 1000, |
|||
}) |
|||
} |
|||
|
|||
return bars; |
|||
} |
|||
|
|||
onMount(() => { |
|||
return () => clearTimeout(timeout); |
|||
}) |
|||
|
|||
$: bars = update(smoothValue, goal); |
|||
|
|||
$: if (smoothValue !== value && timeout === null) { |
|||
timeout = setInterval(() => { |
|||
if (smoothValue < value) { |
|||
smoothValue += 100; |
|||
if (smoothValue > value) { |
|||
smoothValue = value; |
|||
} |
|||
} else if (smoothValue > value) { |
|||
smoothValue -= 100; |
|||
if (smoothValue < value) { |
|||
smoothValue = value; |
|||
} |
|||
} else { |
|||
clearTimeout(timeout); |
|||
timeout = null; |
|||
} |
|||
}, 1000/30); |
|||
} |
|||
</script> |
|||
|
|||
{#each bars as bar} |
|||
<div class="bar" class:red={bar.red} class:green={bar.green} class:gold={bar.gold} class:full={bar.full}> |
|||
<div class="content" style={`height: ${bar.value / 10}%; top: ${(1000 - bar.value) / 10}%`}> |
|||
</div> |
|||
</div> |
|||
{/each} |
|||
|
|||
<style> |
|||
div.bar { |
|||
display: inline-block; |
|||
background: #111; |
|||
width: 1.5ch; |
|||
height: 1em; |
|||
margin-right: 0.5ch; |
|||
} |
|||
|
|||
div.bar > div.content { |
|||
width: 100%; |
|||
position: relative; |
|||
} |
|||
|
|||
div.bar.green { |
|||
border: 0.1px solid #4da069; |
|||
} |
|||
div.bar.green > div.content { |
|||
background-color: #4da069; |
|||
} |
|||
div.bar.green.full > div.content { |
|||
background-color: #45fc82; |
|||
box-shadow: 0px 0px 2px #45fc82; |
|||
} |
|||
div.bar.gold { |
|||
border: 0.1px solid #9b7a1f; |
|||
} |
|||
div.bar.gold > div.content { |
|||
background-color: #9b7a1f; |
|||
} |
|||
div.bar.gold.full > div.content { |
|||
background-color: #ffc936; |
|||
box-shadow: 0px 0px 2px #ffc936; |
|||
} |
|||
div.bar.red { |
|||
border: 0.1px solid #880000; |
|||
} |
|||
div.bar.red > div.content { |
|||
background-color: #880000; |
|||
} |
|||
div.bar.red.full > div.content { |
|||
background-color: #bb0000; |
|||
box-shadow: 0px 0px 2px #ff0000; |
|||
} |
|||
</style> |
@ -0,0 +1,28 @@ |
|||
<script> |
|||
export let label = "Label"; |
|||
export let value = ""; |
|||
</script> |
|||
|
|||
<div class="property"> |
|||
<div class="property-label">{label}:</div> |
|||
<div class="property-value"><slot>{value}</slot></div> |
|||
</div> |
|||
|
|||
<style> |
|||
div.property { |
|||
padding: 0.5em 1ch; |
|||
} |
|||
|
|||
div.property-label { |
|||
font-size: 0.75em; |
|||
font-weight: 800; |
|||
} |
|||
|
|||
div.property-value { |
|||
font-size: 1em; |
|||
} |
|||
|
|||
:global(form) div.property:last-of-type { |
|||
margin-bottom: 1em; |
|||
} |
|||
</style> |
@ -0,0 +1,8 @@ |
|||
<div class="row"><slot></slot></div> |
|||
|
|||
<style> |
|||
div.row { |
|||
display: flex; |
|||
flex-wrap: wrap; |
|||
} |
|||
</style> |
@ -0,0 +1,43 @@ |
|||
<script> |
|||
export let name; |
|||
export let value; |
|||
</script> |
|||
|
|||
<div class="subgoal-input"> |
|||
<div><input type="text" bind:value={name} /></div> |
|||
<div><input type="text" bind:value={value} /></div> |
|||
</div> |
|||
|
|||
<style> |
|||
div.subgoal-input { |
|||
display: flex; |
|||
flex-direction: row; |
|||
justify-content: center; |
|||
} |
|||
|
|||
div.subgoal-input div { |
|||
line-height: 1em; |
|||
margin-bottom: 0 !important; |
|||
} |
|||
|
|||
div.subgoal div { |
|||
display: inline-block; |
|||
box-sizing: border-box; |
|||
|
|||
padding: 0.5em; |
|||
} |
|||
div.subgoal-input div:first-of-type { |
|||
width: 75%; |
|||
} |
|||
div.subgoal-input div:last-of-type { |
|||
width: 25%; |
|||
} |
|||
|
|||
div.subgoal-input input { |
|||
margin-bottom: 0 !important; |
|||
border-bottom: 1px solid #333; |
|||
} |
|||
div.subgoal-input div:first-of-type input { |
|||
border-right: 1px solid #333; |
|||
} |
|||
</style> |
@ -0,0 +1,10 @@ |
|||
<script> |
|||
import modal from "../stores/modal"; |
|||
|
|||
export let component; |
|||
export let name; |
|||
</script> |
|||
|
|||
{#if (name === $modal.name)} |
|||
<svelte:component this={component} {...$modal.data} /> |
|||
{/if} |
@ -0,0 +1,10 @@ |
|||
import App from './App.svelte'; |
|||
|
|||
const app = new App({ |
|||
target: document.body, |
|||
props: {}, |
|||
}); |
|||
|
|||
window.app = app; |
|||
|
|||
export default app; |
@ -0,0 +1,116 @@ |
|||
<script> |
|||
import pluralize from "pluralize"; |
|||
import ModalFrame from "../components/ModalFrame"; |
|||
import SubGoalInput from "../components/SubGoalInput"; |
|||
|
|||
import modal from "../stores/modal"; |
|||
import stufflog from "../stores/stufflog"; |
|||
|
|||
export let period = {}; |
|||
|
|||
let error = null; |
|||
let activityId = ""; |
|||
let pointCount = "1000"; |
|||
let subGoals = [{name:"",multiplier:"1.0"}]; |
|||
|
|||
function addPeriodGoal() { |
|||
error = null; |
|||
|
|||
const parsedPointCount = parseInt(pointCount) |
|||
if (Number.isNaN(parsedPointCount)) { |
|||
error = "Point count must be a number."; |
|||
return |
|||
} |
|||
if (parsedPointCount <= 0 || (parsedPointCount % 1000) !== 0) { |
|||
error = "Point must be a positive non-zero multiple of 1000."; |
|||
return |
|||
} |
|||
|
|||
const parsedSubGoals = []; |
|||
for (const subGoal of subGoals) { |
|||
if (subGoal.name === "") { |
|||
continue; |
|||
} |
|||
|
|||
const parsedMultiplier = parseFloat(subGoal.multiplier); |
|||
if (Number.isNaN(parsedMultiplier) || parsedMultiplier < 0) { |
|||
error = `Sub goal ${subGoal.name} needs a numeric multiplier.`; |
|||
return |
|||
} |
|||
|
|||
parsedSubGoals.push({ |
|||
name: subGoal.name, |
|||
multiplier: parsedMultiplier, |
|||
}) |
|||
} |
|||
|
|||
const addGoal = { |
|||
activityId: activityId, |
|||
pointCount: parsedPointCount, |
|||
subGoals: parsedSubGoals, |
|||
} |
|||
|
|||
stufflog.updatePeriod(period.id, {addGoal}).then(() => { |
|||
modal.close(); |
|||
}).catch(err => { |
|||
error = err.message || err; |
|||
console.warn(err); |
|||
}); |
|||
} |
|||
|
|||
function updateSubGoalForm(subGoals) { |
|||
let last = -1; |
|||
for (let i = 0; i < subGoals.length; ++i) { |
|||
if (subGoals[i].name != "") { |
|||
last = i; |
|||
} |
|||
} |
|||
|
|||
if (last == subGoals.length - 1) { |
|||
return [...subGoals, {name: "", value: "1.0"}]; |
|||
} else if (last + 2 < subGoals.length && subGoals.length > 1) { |
|||
return subGoals.slice(0, last + 2); |
|||
} else { |
|||
return subGoals; |
|||
} |
|||
} |
|||
|
|||
$: subGoals = updateSubGoalForm(subGoals); |
|||
$: if (activityId === "" && $stufflog.activities.length > 0) { |
|||
activityId = $stufflog.activities[0].id; |
|||
} |
|||
</script> |
|||
|
|||
<ModalFrame title={`Add ${period.name} Goal`} error={error} closable on:close={() => modal.close()}> |
|||
<form on:submit|preventDefault={() => addPeriodGoal()}> |
|||
<label>Activity</label> |
|||
<select bind:value={activityId}> |
|||
{#each $stufflog.activities as activity (activity.id)} |
|||
<option value={activity.id}>{activity.name}</option> |
|||
{/each} |
|||
</select> |
|||
<label>Points</label> |
|||
<input class="nolast" type="string" bind:value={pointCount} /> |
|||
<label>Subgoals</label> |
|||
{#each subGoals as subGoal} |
|||
<SubGoalInput bind:name={subGoal.name} bind:value={subGoal.multiplier} /> |
|||
{/each} |
|||
|
|||
<p> |
|||
The amount of points must be in an increment of 1000, which should be |
|||
equivalent to about an hour of baseline activity. Subgoal values are |
|||
multipliers. 1.05 means that the 5% more points are added. 0.5 means |
|||
the points are halved. |
|||
</p> |
|||
|
|||
<hr /> |
|||
|
|||
<button type="submit">Add Goal</button> |
|||
</form> |
|||
</ModalFrame> |
|||
|
|||
<style> |
|||
p { |
|||
margin-top: 1em; |
|||
} |
|||
</style> |
@ -0,0 +1,111 @@ |
|||
<script> |
|||
import pluralize from "pluralize"; |
|||
import capitalize from "capitalize"; |
|||
|
|||
import ModalFrame from "../components/ModalFrame"; |
|||
import SubGoalInput from "../components/SubGoalInput"; |
|||
|
|||
import modal from "../stores/modal"; |
|||
import stufflog from "../stores/stufflog"; |
|||
|
|||
import dateStr from "../utils/dateStr"; |
|||
|
|||
export let period = {}; |
|||
|
|||
let error = null; |
|||
let goal = null; |
|||
let activity = null; |
|||
let subActivity = null; |
|||
let dateInput = dateStr(period.from); |
|||
let goalId = ""; |
|||
let subGoalId = ""; |
|||
let subActivityId = ""; |
|||
let amount = "200"; |
|||
let description = ""; |
|||
|
|||
function addPeriodLog() { |
|||
error = null; |
|||
const addLog = { |
|||
date: new Date(`${dateInput} 12:00:00.000`), |
|||
goalId: goalId, |
|||
subGoalId: subGoalId, |
|||
subActivityId: subActivityId, |
|||
amount: parseInt(amount), |
|||
description: description, |
|||
} |
|||
|
|||
stufflog.updatePeriod(period.id, {addLog}).then(() => { |
|||
modal.close(); |
|||
}).catch(err => { |
|||
error = err.message || err; |
|||
console.warn(err); |
|||
}); |
|||
} |
|||
|
|||
$: goal = period.goals.find(g => g.id === goalId) || null; |
|||
$: activity = $stufflog.activities.find(a => a.id === goal.activityId) || null; |
|||
$: subActivity = activity.subActivities.find(sg => sg.id === subActivityId) || null; |
|||
|
|||
$: if (period.goals.length > 0 && goalId === "") { |
|||
goalId = period.goals[0].id; |
|||
} |
|||
$: if (activity != null && !activity.subActivities.find(s => s.id === subActivityId)) { |
|||
if (activity.subActivities.length > 0) { |
|||
subActivityId = activity.subActivities[0].id; |
|||
} else { |
|||
subActivityId = ""; |
|||
} |
|||
} |
|||
$: if (goal != null && !goal.subGoals.find(s => s.id === subGoalId)) { |
|||
subGoalId = ""; |
|||
} |
|||
</script> |
|||
|
|||
<ModalFrame title={`Add ${period.name} Log`} error={error} closable on:close={() => modal.close()}> |
|||
<form on:submit|preventDefault={() => addPeriodLog()}> |
|||
<label>Date</label> |
|||
<input type="date" bind:value={dateInput} /> |
|||
|
|||
<label>Goal</label> |
|||
<select bind:value={goalId}> |
|||
{#each period.goals as goal (goal.id)} |
|||
<option value={goal.id}>{$stufflog.activities.find(a => a.id === goal.activityId).name}</option> |
|||
{/each} |
|||
</select> |
|||
|
|||
{#if (activity != null)} |
|||
<label>Sub-Activity</label> |
|||
<select bind:value={subActivityId}> |
|||
{#each activity.subActivities as subActivity (subActivity.id)} |
|||
<option value={subActivity.id}>{subActivity.name}</option> |
|||
{/each} |
|||
</select> |
|||
{/if} |
|||
|
|||
{#if (goal != null && goal.subGoals.length > 0)} |
|||
<label>Sub-Goal</label> |
|||
<select bind:value={subGoalId}> |
|||
<option value="">None</option> |
|||
|
|||
{#each goal.subGoals as subGoal (subGoal.id)} |
|||
<option value={subGoal.id}>{subGoal.name} ({subGoal.multiplier.toFixed(2)})</option> |
|||
{/each} |
|||
</select> |
|||
{/if} |
|||
|
|||
{#if (subActivity != null)} |
|||
<label>{pluralize(capitalize(subActivity.unitName))}</label> |
|||
<input type="number" bind:value={amount} /> |
|||
{/if} |
|||
|
|||
<label>Description</label> |
|||
<input type="text" bind:value={description} /> |
|||
|
|||
<hr /> |
|||
|
|||
<button type="submit">Add Goal</button> |
|||
</form> |
|||
</ModalFrame> |
|||
|
|||
<style> |
|||
</style> |
@ -0,0 +1,55 @@ |
|||
<script> |
|||
import pluralize from "pluralize"; |
|||
import ModalFrame from "../components/ModalFrame"; |
|||
|
|||
import modal from "../stores/modal"; |
|||
import stufflog from "../stores/stufflog"; |
|||
|
|||
export let activity = {}; |
|||
|
|||
let error = null; |
|||
let name = ""; |
|||
let unitName = "minute"; |
|||
let value = "1"; |
|||
|
|||
function addSubActivity() { |
|||
value = parseFloat(value) |
|||
if (Number.isNaN(value)) { |
|||
error = "Invalid value, it should be a number." |
|||
value = "1"; |
|||
return |
|||
} |
|||
|
|||
stufflog.updateActivity(activity.id, {addSub: {name, unitName, value}}).then(() => { |
|||
modal.close(); |
|||
}).catch(err => { |
|||
error = err.message || err; |
|||
console.warn(err); |
|||
}); |
|||
} |
|||
</script> |
|||
|
|||
<ModalFrame title={`Add ${activity.name} Sub-Activity`} error={error} closable on:close={() => modal.close()}> |
|||
<form on:submit|preventDefault={() => addSubActivity()}> |
|||
<label>Name</label> |
|||
<input type="text" bind:value={name} /> |
|||
<label>Unit</label> |
|||
<select bind:value={unitName}> |
|||
<option value="minute">minute</option> |
|||
<option value="word">word</option> |
|||
<option value="hour">hour</option> |
|||
<option value="item">item</option> |
|||
</select> |
|||
<label>Value per {pluralize(unitName, 1)}</label> |
|||
<input type="string" bind:value={value} /> |
|||
|
|||
<p> |
|||
1000 points should be equivalent to about an hour of baseline activity. Beyond that, it's |
|||
about incentivitzing the right thing. |
|||
</p> |
|||
|
|||
<hr /> |
|||
|
|||
<button type="submit">Add Sub-Activity</button> |
|||
</form> |
|||
</ModalFrame> |
@ -0,0 +1,35 @@ |
|||
<script> |
|||
import ModalFrame from "../components/ModalFrame"; |
|||
import ActivityIconSelect from "../components/ActivityIconSelect"; |
|||
|
|||
import modal from "../stores/modal"; |
|||
import stufflog from "../stores/stufflog"; |
|||
|
|||
let error = null; |
|||
let name = ""; |
|||
let icon = "cubes"; |
|||
let dailyBonus = 100; |
|||
|
|||
function createActivity() { |
|||
stufflog.createActivity({name, icon, dailyBonus}).then(() => { |
|||
modal.close(); |
|||
}).catch(err => { |
|||
error = err.message || err; |
|||
console.warn(err); |
|||
}); |
|||
} |
|||
</script> |
|||
|
|||
<ModalFrame title="Create Activity" error={error} closable on:close={() => modal.close()}> |
|||
<form on:submit|preventDefault={() => createActivity()}> |
|||
<label>Name</label> |
|||
<input type="text" bind:value={name} /> |
|||
<label>Icon</label> |
|||
<ActivityIconSelect bind:value={icon} /> |
|||
<label>Daily Bonus</label> |
|||
<input type="number" bind:value={dailyBonus} /> |
|||
<hr /> |
|||
|
|||
<button type="submit">Create</button> |
|||
</form> |
|||
</ModalFrame> |
@ -0,0 +1,53 @@ |
|||
<script> |
|||
import ModalFrame from "../components/ModalFrame"; |
|||
import ActivityIconSelect from "../components/ActivityIconSelect"; |
|||
|
|||
import modal from "../stores/modal"; |
|||
import stufflog from "../stores/stufflog"; |
|||
|
|||
import dateStr from "../utils/dateStr"; |
|||
|
|||
let error = null; |
|||
let name = ""; |
|||
let fromStr = dateStr(new Date()) |
|||
let toStr = dateStr(new Date(Date.now() + (86400000 * 6))) |
|||
let tagsStr = ""; |
|||
|
|||
function createPeriod() { |
|||
const from = new Date(`${fromStr} 00:00:00.000`); |
|||
const to = new Date(`${toStr} 23:59:59.999`); |
|||
const tags = tagsStr.split(",").map(t => t.trim()).filter(t => t.length > 0); |
|||
|
|||
error = null; |
|||
|
|||
if (Number.isNaN(from.getTime())) { |
|||
error = "You must enter a from date."; |
|||
return; |
|||
} |
|||
if (Number.isNaN(from.getTime())) { |
|||
error = "You must enter a to date."; |
|||
return; |
|||
} |
|||
|
|||
stufflog.createPeriod({name, from, to, tags}).then(() => { |
|||
modal.close(); |
|||
}).catch(err => { |
|||
error = err.message || err; |
|||
console.warn(err); |
|||
}); |
|||
} |
|||
</script> |
|||
|
|||
<ModalFrame title="Create Period" error={error} closable on:close={() => modal.close()}> |
|||
<form on:submit|preventDefault={() => createPeriod()}> |
|||
<label>Name</label> |
|||
<input type="text" bind:value={name} /> |
|||
<label>From</label> |
|||
<input type="date" bind:value={fromStr} /> |
|||
<label>To</label> |
|||
<input type="date" bind:value={toStr} /> |
|||
<label>Tags (Comma separated)</label> |
|||
<input type="text" bind:value={tagsStr} /> |
|||
<button type="submit">Create</button> |
|||
</form> |
|||
</ModalFrame> |
@ -0,0 +1,32 @@ |
|||
<script> |
|||
import pluralize from "pluralize"; |
|||
import ModalFrame from "../components/ModalFrame"; |
|||
|
|||
import modal from "../stores/modal"; |
|||
import stufflog from "../stores/stufflog"; |
|||
|
|||
export let activity = {}; |
|||
|
|||
let error = null; |
|||
|
|||
function removeSubActivity() { |
|||
stufflog.deleteActivity(activity.id).then(() => { |
|||
modal.close(); |
|||
}).catch(err => { |
|||
error = err.message || err; |
|||
console.warn(err); |
|||
}); |
|||
} |
|||
</script> |
|||
|
|||
<ModalFrame title={`Remove ${activity.name}`} error={error} closable on:close={() => modal.close()}> |
|||
<form on:submit|preventDefault={() => removeSubActivity()}> |
|||
<p> |
|||
Are you sure you want to remove activity <b>{activity.name}</b>? |
|||
</p> |
|||
|
|||
<hr /> |
|||
|
|||
<button type="submit">Yes, do as I say</button> |
|||
</form> |
|||
</ModalFrame> |
@ -0,0 +1,32 @@ |
|||
<script> |
|||
import pluralize from "pluralize"; |
|||
import ModalFrame from "../components/ModalFrame"; |
|||
|
|||
import modal from "../stores/modal"; |
|||
import stufflog from "../stores/stufflog"; |
|||
|
|||
export let period = {}; |
|||
|
|||
let error = null; |
|||
|
|||
function deletePeriod() { |
|||
stufflog.deletePeriod(period.id).then(() => { |
|||
modal.close(); |
|||
}).catch(err => { |
|||
error = err.message || err; |
|||
console.warn(err); |
|||
}); |
|||
} |
|||
</script> |
|||
|
|||
<ModalFrame title={`Remove ${period.name}`} error={error} closable on:close={() => modal.close()}> |
|||
<form on:submit|preventDefault={() => deletePeriod()}> |
|||
<p> |
|||
Are you sure you want to remove period <b>{period.name}</b>? |
|||
</p> |
|||
|
|||
<hr /> |
|||
|
|||
<button type="submit">Yes, do as I say</button> |
|||
</form> |
|||
</ModalFrame> |
@ -0,0 +1,37 @@ |
|||
<script> |
|||
import ModalFrame from "../components/ModalFrame"; |
|||
import ActivityIconSelect from "../components/ActivityIconSelect"; |
|||
|
|||
import modal from "../stores/modal"; |
|||
import stufflog from "../stores/stufflog"; |
|||
|
|||
export let activity = {} |
|||
|
|||
let name = activity.name; |
|||
let icon = activity.icon; |
|||
let dailyBonus = activity.dailyBonus; |
|||
let error = null; |
|||
|
|||
function editActivity() { |
|||
stufflog.updateActivity(activity.id, {setName: name, setIcon: icon, setDailyBonus: dailyBonus}).then(() => { |
|||
modal.close(); |
|||
}).catch(err => { |
|||
error = err.message || err; |
|||
console.warn(err); |
|||
}); |
|||
} |
|||
</script> |
|||
|
|||
<ModalFrame title={`Update ${activity.name}`} error={error} closable on:close={() => modal.close()}> |
|||
<form on:submit|preventDefault={() => editActivity()}> |
|||
<label>Name</label> |
|||
<input type="text" bind:value={name} /> |
|||
<label>Icon</label> |
|||
<ActivityIconSelect bind:value={icon} /> |
|||
<label>Daily Bonus</label> |
|||
<input type="number" bind:value={dailyBonus} /> |
|||
<hr /> |
|||
|
|||
<button type="submit">Edit Activity</button> |
|||
</form> |
|||
</ModalFrame> |
@ -0,0 +1,51 @@ |
|||
<script> |
|||
import ModalFrame from "../components/ModalFrame"; |
|||
import ActivityIconSelect from "../components/ActivityIconSelect"; |
|||
|
|||
import modal from "../stores/modal"; |
|||
import stufflog from "../stores/stufflog"; |
|||
|
|||
import dateStr from "../utils/dateStr"; |
|||
|
|||
export let period = {}; |
|||
|
|||
let error = null; |
|||
let name = period.name; |
|||
let fromStr = dateStr(period.from) |
|||
let toStr = dateStr(period.to) |
|||
|
|||
function editPeriod() { |
|||
const from = new Date(`${fromStr} 00:00:00.000`); |
|||
const to = new Date(`${toStr} 23:59:59.999`); |
|||
|
|||
error = null; |
|||
|
|||
if (Number.isNaN(from.getTime())) { |
|||
error = "You must enter a from date."; |
|||
return; |
|||
} |
|||
if (Number.isNaN(from.getTime())) { |
|||
error = "You must enter a to date."; |
|||
return; |
|||
} |
|||
|
|||
stufflog.updatePeriod(period.id, {setName: name, setFrom: from, setTo: to}).then(() => { |
|||
modal.close(); |
|||
}).catch(err => { |
|||
error = err.message || err; |
|||
console.warn(err); |
|||
}); |
|||
} |
|||
</script> |
|||
|
|||
<ModalFrame title="Edit Period" error={error} closable on:close={() => modal.close()}> |
|||
<form on:submit|preventDefault={() => editPeriod()}> |
|||
<label>Name</label> |
|||
<input type="text" bind:value={name} /> |
|||
<label>From</label> |
|||
<input type="date" bind:value={fromStr} /> |
|||
<label>To</label> |
|||
<input type="date" bind:value={toStr} /> |
|||
<button type="submit">Save</button> |
|||
</form> |
|||
</ModalFrame> |
@ -0,0 +1,57 @@ |
|||
<script> |
|||
import pluralize from "pluralize"; |
|||
import ModalFrame from "../components/ModalFrame"; |
|||
|
|||
import modal from "../stores/modal"; |
|||
import stufflog from "../stores/stufflog"; |
|||
|
|||
export let activity = {}; |
|||
export let subActivity = {}; |
|||
|
|||
let error = null; |
|||
let id = subActivity.id; |
|||
let name = subActivity.name; |
|||
let unitName = subActivity.unitName; |
|||
let value = subActivity.value; |
|||
|
|||
function editSubActivity() { |
|||
value = parseFloat(value) |
|||
if (Number.isNaN(value)) { |
|||
error = "Value should be a number." |
|||
value = subActivity.value; |
|||
return |
|||
} |
|||
|
|||
stufflog.updateActivity(activity.id, {editSub: {id, name, unitName, value}}).then(() => { |
|||
modal.close(); |
|||
}).catch(err => { |
|||
error = err.message || err; |
|||
console.warn(err); |
|||
}); |
|||
} |
|||
</script> |
|||
|
|||
<ModalFrame title={`Edit ${activity.name} Sub-Activity`} error={error} closable on:close={() => modal.close()}> |
|||
<form on:submit|preventDefault={() => editSubActivity()}> |
|||
<label>Name</label> |
|||
<input type="text" bind:value={name} /> |
|||
<label>Unit</label> |
|||
<select bind:value={unitName}> |
|||
<option value="minute">minute</option> |
|||
<option value="word">word</option> |
|||
<option value="hour">hour</option> |
|||
<option value="item">item</option> |
|||
</select> |
|||
<label>Value per {pluralize(unitName, 1)}</label> |
|||
<input type="string" bind:value={value} /> |
|||
|
|||
<p> |
|||
1000 points should be equivalent to about an hour of baseline activity. Beyond that, it's |
|||
about incentivitzing the right thing. |
|||
</p> |
|||
|
|||
<hr /> |
|||
|
|||
<button type="submit">Edit Sub-Activity</button> |
|||
</form> |
|||
</ModalFrame> |
@ -0,0 +1,62 @@ |
|||
<script> |
|||
import pluralize from "pluralize"; |
|||
|
|||
import ModalFrame from "../components/ModalFrame"; |
|||
import SubGoalInput from "../components/SubGoalInput"; |
|||
import Property from "../components/Property"; |
|||
|
|||
import modal from "../stores/modal"; |
|||
import stufflog from "../stores/stufflog"; |
|||
|
|||
import dateStr from "../utils/dateStr"; |
|||
|
|||
export let period = {}; |
|||
export let log = {}; |
|||
export let activity = {}; |
|||
export let subActivity = {}; |
|||
export let subGoal = null; |
|||
|
|||
let activityPoints = 0; |
|||
let subGoalBonus = 0; |
|||
let roundingError = 0; |
|||
|
|||
$: activityPoints = log.score.amount * log.score.activityScore; |
|||
$: subGoalBonus = subGoal.multiplier ? activityPoints * (subGoal.multiplier - 1) : 0; |
|||
$: roundingError = (log.score.total) - (Math.floor(activityPoints) + Math.floor(subGoalBonus) + log.score.dailyBonus); |
|||
</script> |
|||
|
|||
<ModalFrame title={`${dateStr(log.date)} - ${activity.name} ${subActivity.name}`} closable on:close={() => modal.close()}> |
|||
<form on:submit|preventDefault={() => modal.close()}> |
|||
{#if log.description !== ""} |
|||
<Property label="Description"> |
|||
{log.description} |
|||
</Property> |
|||
{/if} |
|||
|
|||
<Property label="Activity Points"> |
|||
{Math.floor(activityPoints + roundingError)} points |
|||
({log.score.amount} {pluralize(subActivity.unitName, log.score.amount)}) |
|||
</Property> |
|||
{#if subGoal.multiplier != null} |
|||
<Property label="Sub-Goal"> |
|||
{Math.floor(subGoalBonus)} points |
|||
({subGoal.name}) |
|||
</Property> |
|||
{/if} |
|||
{#if log.score.dailyBonus != 0} |
|||
<Property label="Daily Bonus"> |
|||
{log.score.dailyBonus} points |
|||
</Property> |
|||
{/if} |
|||
<Property label="Total"> |
|||
{log.score.total} points |
|||
</Property> |
|||
|
|||
<hr /> |
|||
|
|||
<button type="submit">Add Goal</button> |
|||
</form> |
|||
</ModalFrame> |
|||
|
|||
<style> |
|||
</style> |
@ -0,0 +1,40 @@ |
|||
<script> |
|||
import ModalFrame from "../components/ModalFrame"; |
|||
|
|||
import auth from "../stores/auth"; |
|||
import modal from "../stores/modal"; |
|||
|
|||
function login(username, password) { |
|||
auth.login(username, password).then(() => { |
|||
modal.close(); |
|||
}).catch(err => { |
|||
error = "Incorrect username or password." |
|||
}); |
|||
} |
|||
|
|||
function register(username, password) { |
|||
auth.register(username, password).then(() => { |
|||
modal.close(); |
|||
}).catch(err => { |
|||
error = "Incorrect username or password." |
|||
}); |
|||
} |
|||
|
|||
let username = ""; |
|||
let password = ""; |
|||
let error = null; |
|||
</script> |
|||
|
|||
<ModalFrame title="Login" error={error}> |
|||
<form on:submit|preventDefault={() => {}}> |
|||
<label>Username</label> |
|||
<input type="text" bind:value={username} /> |
|||
<label>Password</label> |
|||
<input type="password" bind:value={password} /> |
|||
|
|||
<hr /> |
|||
|
|||
<button type="submit" on:click={() => login(username, password)}>Login</button> |
|||
<button type="submit" on:click={() => register(username, password)}>Register</button> |
|||
</form> |
|||
</ModalFrame> |
@ -0,0 +1,34 @@ |
|||
<script> |
|||
import pluralize from "pluralize"; |
|||
import ModalFrame from "../components/ModalFrame"; |
|||
|
|||
import modal from "../stores/modal"; |
|||
import stufflog from "../stores/stufflog"; |
|||
|
|||
export let period = {}; |
|||
export let goal = {}; |
|||
export let activity = {}; |
|||
let error = null; |
|||
|
|||
function removePeriodLog() { |
|||
stufflog.updatePeriod(period.id, {removeGoal: goal.id}).then(() => { |
|||
modal.close(); |
|||
}).catch(err => { |
|||
error = err.message || err; |
|||
console.warn(err); |
|||
}); |
|||
} |
|||
</script> |
|||
|
|||
<ModalFrame title={`Remove ${period.name} Goal`} error={error} closable on:close={() => modal.close()}> |
|||
<form on:submit|preventDefault={() => removePeriodLog()}> |
|||
<p> |
|||
Are you sure you want to remove <b>{activity.name}</b> goal from |
|||
period <b>{period.name}</b>? |
|||
</p> |
|||
|
|||
<hr /> |
|||
|
|||
<button type="submit">Yes, do as I say</button> |
|||
</form> |
|||
</ModalFrame> |
@ -0,0 +1,38 @@ |
|||
<script> |
|||
import pluralize from "pluralize"; |
|||
import ModalFrame from "../components/ModalFrame"; |
|||
|
|||
import modal from "../stores/modal"; |
|||
import stufflog from "../stores/stufflog"; |
|||
|
|||
import dateStr from "../utils/dateStr"; |
|||
|
|||
export let period = {}; |
|||
export let log = {}; |
|||
export let activity = {}; |
|||
|
|||
let error = null; |
|||
|
|||
function removePeriodLog() { |
|||
stufflog.updatePeriod(period.id, {removeLog: log.id}).then(() => { |
|||
modal.close(); |
|||
}).catch(err => { |
|||
error = err.message || err; |
|||
console.warn(err); |
|||
}); |
|||
} |
|||
</script> |
|||
|
|||
<ModalFrame title={`Remove ${period.name} Log`} error={error} closable on:close={() => modal.close()}> |
|||
<form on:submit|preventDefault={() => removePeriodLog()}> |
|||
<p> |
|||
Are you sure you want to remove log <b>{log.description}</b> |
|||
(<b>{dateStr(log.date)}</b>, <b>{activity.name}</b>) from |
|||
period <b>{period.name}</b>? |
|||
</p> |
|||
|
|||
<hr /> |
|||
|
|||
<button type="submit">Yes, do as I say</button> |
|||
</form> |
|||
</ModalFrame> |
@ -0,0 +1,34 @@ |
|||
<script> |
|||
import pluralize from "pluralize"; |
|||
import ModalFrame from "../components/ModalFrame"; |
|||
|
|||
import modal from "../stores/modal"; |
|||
import stufflog from "../stores/stufflog"; |
|||
|
|||
export let activity = {}; |
|||
export let subActivtiy = {}; |
|||
|
|||
let error = null; |
|||
|
|||
function removeSubActivity() { |
|||
stufflog.updateActivity(activity.id, {removeSub: subActivtiy.id}).then(() => { |
|||
modal.close(); |
|||
}).catch(err => { |
|||
error = err.message || err; |
|||
console.warn(err); |
|||
}); |
|||
} |
|||
</script> |
|||
|
|||
<ModalFrame title={`Remove ${activity.name} Sub-Activity`} error={error} closable on:close={() => modal.close()}> |
|||
<form on:submit|preventDefault={() => removeSubActivity()}> |
|||
<p> |
|||
Are you sure you want to remove sub-acitvity <b>{subActivtiy.name || "(unnamed)"}</b> from |
|||
activity <b>{activity.name}</b>? |
|||
</p> |
|||
|
|||
<hr /> |
|||
|
|||
<button type="submit">Yes, do as I say</button> |
|||
</form> |
|||
</ModalFrame> |
@ -0,0 +1,61 @@ |
|||
/** |
|||
* Data about an activity. |
|||
*/ |
|||
export default class Activity { |
|||
/** ID of the activity, ignored on input */ |
|||
public id : string; |
|||
|
|||
/** The user ID the activity is associated with. */ |
|||
public userId : string; |
|||
|
|||
/** A name for the activity */ |
|||
public name : string; |
|||
|
|||
/** Icon to use for it */ |
|||
public icon : string; |
|||
|
|||
/** Bonus awarded once daily. This is to reward consistency. */ |
|||
public dailyBonus : number; |
|||
|
|||
/** Sub activities */ |
|||
public subActivites : SubActivtiy[]; |
|||
} |
|||
|
|||
/** |
|||
* An activity udpate for activities |
|||
*/ |
|||
export class ActivityUpdate { |
|||
/** Add a sub activity */ |
|||
addSub? : SubActivtiy; |
|||
|
|||
/** Replace a sub activity */ |
|||
editSub? : SubActivtiy; |
|||
|
|||
/** Remove a sub activity by ID */ |
|||
removeSub? : string; |
|||
|
|||
/** Set activity name */ |
|||
setName? : string; |
|||
|
|||
/** Set activity icon */ |
|||
setIcon? : string; |
|||
} |
|||
|
|||
/** |
|||
* Sub-Activity of an activity. While an activity can be as general as "3D modeling", this |
|||
* is where the specific activiteis under that umbrella is. Practice, tutorial, or whatever fits |
|||
* the parent activity. |
|||
*/ |
|||
export class SubActivtiy { |
|||
/** ID of the sub-activtiy, ignored on input */ |
|||
public id : string; |
|||
|
|||
/** Sub-activity name */ |
|||
public name : string; |
|||
|
|||
/** Measured unit, always plural (e.g. minutes) */ |
|||
public unitName : string; |
|||
|
|||
/** Points awarded per unit. */ |
|||
public value : number; |
|||
} |
@ -0,0 +1,25 @@ |
|||
export default class Activity { |
|||
constructor(data) { |
|||
this.id = data.id || null; |
|||
this.userId = data.userId || null; |
|||
this.name = data.name || ""; |
|||
this.icon = data.icon || ""; |
|||
this.dailyBonus = data.dailyBonus || 0; |
|||
this.subActivities = (data.subActivities || []).map(s => new SubActivtiy(s)); |
|||
} |
|||
} |
|||
|
|||
export class SubActivtiy { |
|||
constructor(data) { |
|||
this.id = data.id || null; |
|||
this.name = data.name || ""; |
|||
this.unitName = data.unitName || "minutes"; |
|||
this.value = data.value || null; |
|||
} |
|||
} |
|||
|
|||
export const ActivityUpdate = class ActivityUpdateDummyForTyping{ |
|||
constructor() { |
|||
console.warn(new Error("ActivityUpdate is a dummy, don't instantiate.")); |
|||
} |
|||
}; |
@ -0,0 +1,128 @@ |
|||
/** |
|||
* Data about an time period. |
|||
*/ |
|||
export default class Period { |
|||
/** The ID of the Period. */ |
|||
id : string; |
|||
|
|||
/** The ID of the owning user. */ |
|||
userId : string; |
|||
|
|||
/** The inclusive start time. */ |
|||
from : Date; |
|||
|
|||
/** The exclusive end date. */ |
|||
to : Date; |
|||
|
|||
/** The name of the time period, usually generated.*/ |
|||
name : string; |
|||
|
|||
/** |
|||
* Tags used to explain the goals being out of the ordinary. For example, |
|||
* if leaving for a vacation where hobbies are best put on hold. |
|||
*/ |
|||
tags : string[]; |
|||
|
|||
/** The goals set for this time period. */ |
|||
goals : PeriodGoal[]; |
|||
|
|||
/** Logged activities during this time period. */ |
|||
logs : PeriodLog[]; |
|||
} |
|||
|
|||
/** |
|||
* A goal set during the period. |
|||
*/ |
|||
export class PeriodGoal { |
|||
/** The ID of the PeriodGoal. */ |
|||
id : string |
|||
|
|||
/** The activity ID associated with the goal. */ |
|||
activityId : string |
|||
|
|||
/** The point count for the goal. 1000 is usually considered an hour's work. */ |
|||
pointCount : number |
|||
|
|||
/** Optional sub-goals. */ |
|||
subGoals : PeriodSubGoal[]; |
|||
} |
|||
|
|||
/** |
|||
* Optional sub-goals so that the user can award more points on important |
|||
* activities and not use up all the points on time-sinking guilty pleasures. |
|||
*/ |
|||
export class PeriodSubGoal { |
|||
/** The ID of the PeriodSubGoal. */ |
|||
id : string; |
|||
|
|||
/** A short name of the sub goal. */ |
|||
name : string; |
|||
|
|||
/** The multiplier applied to the score. 1.10 = 10% better. */ |
|||
multiplier : number; |
|||
} |
|||
|
|||
/** |
|||
* A logged activity during this time period. |
|||
*/ |
|||
export class PeriodLog { |
|||
/** The ID of the PeriodLog. */ |
|||
id : string; |
|||
|
|||
/** The ID of the PeriodLog. */ |
|||
date : Date; |
|||
|
|||
/** The ID of the sub-activity performed (see goal for activity id) */ |
|||
subActivityId : string; |
|||
|
|||
/** The ID of the goal. */ |
|||
goalId : string; |
|||
|
|||
/** The applicable sub goal, or `null` if none. */ |
|||
subGoalId : string; |
|||
|
|||
/** A description of the activity performed. */ |
|||
description : string; |
|||
|
|||
/** The amount of units done. */ |
|||
amount : number |
|||
|
|||
/** The calculated score and its breakdown. */ |
|||
score : PeriodLogScore |
|||
} |
|||
|
|||
/** |
|||
* The PeriodLogScore is a score for the log. This may not reflect the |
|||
* latest numbers. |
|||
*/ |
|||
export class PeriodLogScore { |
|||
/** The amount of activity done (e.g number of words or minutes). */ |
|||
amount : number; |
|||
|
|||
/** The score that's worth. */ |
|||
activityScore : number; |
|||
|
|||
/** The subgoal multiplier in effect. */ |
|||
subGoalMultiplier : number; |
|||
|
|||
/** The daily bonus, or 0 if not applicable. */ |
|||
dailyBonus : number; |
|||
|
|||
/** The total score. */ |
|||
total : number; |
|||
} |
|||
|
|||
export interface PeriodUpdate { |
|||
setFrom?: Date |
|||
setTo?: Date |
|||
setName?: string |
|||
|
|||
addLog?: PeriodLog |
|||
removeLog?: string |
|||
addGoal?: PeriodGoal |
|||
replaceGoal?: PeriodGoal |
|||
removeGoal?: string |
|||
|
|||
addTag?: string |
|||
removeTag?: String |
|||
} |
@ -0,0 +1,77 @@ |
|||
export default class Period { |
|||
constructor(data) { |
|||
this.id = data.id || null; |
|||
this.userId = data.userId || null; |
|||
this.from = new Date(data.from); |
|||
this.to = new Date(data.to); |
|||
this.name = data.name || ""; |
|||
|
|||
this.tags = data.tags || []; |
|||
this.goals = (data.goals || []).map(d => new PeriodGoal(d)); |
|||
this.logs = (data.logs || []).map(d => new PeriodLog(d)); |
|||
} |
|||
|
|||
goal(id) { |
|||
return this.goals.find(g => g.id === id); |
|||
} |
|||
|
|||
subGoal(goalId, id) { |
|||
const goal = this.goal(goalId); |
|||
if (goal == null) { |
|||
return null; |
|||
} |
|||
|
|||
return goal.subGoal(id); |
|||
} |
|||
} |
|||
|
|||
export class PeriodGoal { |
|||
constructor(data) { |
|||
this.id = data.id || null; |
|||
this.activityId = data.activityId || null; |
|||
this.pointCount = data.pointCount || 0; |
|||
|
|||
this.subGoals = (data.subGoals || []).map(d => new PeriodSubGoal(d)); |
|||
} |
|||
|
|||
subGoal(id) { |
|||
return this.subGoals.find(s => s.id === id); |
|||
} |
|||
} |
|||
|
|||
export class PeriodSubGoal { |
|||
constructor(data) { |
|||
this.id = data.id || null; |
|||
this.name = data.name || ""; |
|||
this.multiplier = data.multiplier || 1; |
|||
} |
|||
} |
|||
|
|||
export class PeriodLog { |
|||
constructor(data) { |
|||
this.id = data.id || null; |
|||
this.date = new Date(data.date || "0000-00-00T00:00:00Z"); |
|||
this.subActivityId = data.subActivityId || null; |
|||
this.goalId = data.goalId || null; |
|||
this.subGoalId = data.subGoalId || null; |
|||
this.description = data.description || ""; |
|||
this.amount = data.amount || 0; |
|||
this.score = (data.score ? new PeriodLogScore(data.score) : null); |
|||
} |
|||
} |
|||
|
|||
export class PeriodLogScore { |
|||
constructor(data) { |
|||
this.amount = data.amount; |
|||
this.activityScore = data.activityScore; |
|||
this.subGoalMultiplier = data.subGoalMultiplier; |
|||
this.dailyBonus = data.dailyBonus; |
|||
this.total = data.total; |
|||
} |
|||
} |
|||
|
|||
export const PeriodUpdate = class PeriodUpdateDummyForTyping{ |
|||
constructor() { |
|||
console.warn(new Error("PeriodUpdate is a dummy, don't instantiate.")); |
|||
} |
|||
}; |
@ -0,0 +1,91 @@ |
|||
<script> |
|||
import {get} from "svelte/store"; |
|||
|
|||
import pluralize from "pluralize"; |
|||
import Icon from 'fa-svelte' |
|||
import { faCircle } from '@fortawesome/free-solid-svg-icons/faCircle' |
|||
|
|||
import stufflog from "../stores/stufflog"; |
|||
import modal from "../stores/modal"; |
|||
|
|||
import Boi from "../components/Boi.svelte"; |
|||
import AddBoi from "../components/AddBoi.svelte"; |
|||
import Property from "../components/Property.svelte"; |
|||
import Row from "../components/Row.svelte"; |
|||
import Col from "../components/Col.svelte"; |
|||
import Link from "../components/Link.svelte"; |
|||
|
|||
function openModal(modalName, id, subId) { |
|||
const activity = get(stufflog).activities.find(a => a.id === id); |
|||
const subActivity = (activity || {subActivities:[]}).subActivities.find(s => s.id === subId); |
|||
|
|||
modal.open(modalName, {activity, subActivity}) |
|||
} |
|||
</script> |
|||
|
|||
<div class="page"> |
|||
{#each $stufflog.activities as activity (activity.id)} |
|||
<Boi header={activity.name} icon={activity.icon}> |
|||
<Row> |
|||
<Col size=3><Property label="ID" value={activity.id} /></Col> |
|||
<Col size=3><Property label="Daily Bonus" value={activity.dailyBonus} /></Col> |
|||
<Col size=6> |
|||
<Property label="Options"> |
|||
<Link on:click={() => openModal("activity.edit", activity.id)}>Edit Activity</Link>, |
|||
<Link on:click={() => openModal("subactivity.add", activity.id)}>Add Sub-Activity</Link>, |
|||
<Link on:click={() => openModal("activity.delete", activity.id)}>Delete Activity</Link> |
|||
</Property> |
|||
</Col> |
|||
</Row> |
|||
<table> |
|||
<tr> |
|||
<th class="th-name">Sub-Activity</th> |
|||
<th class="th-value">Value</th> |
|||
<th class="th-options">Options</th> |
|||
</tr> |
|||
{#each activity.subActivities as subActivtiy (subActivtiy.id)} |
|||
<tr> |
|||
<td>{subActivtiy.name}</td> |
|||
<td>{subActivtiy.value} per {pluralize(subActivtiy.unitName, 1)}</td> |
|||
<td> |
|||
<Link on:click={() => openModal("subactivity.edit", activity.id, subActivtiy.id)}>Edit</Link>, |
|||
<Link on:click={() => openModal("subactivity.remove", activity.id, subActivtiy.id)}>Delete</Link> |
|||
</td> |
|||
</tr> |
|||
{/each} |
|||
</table> |
|||
</Boi> |
|||
{:else} |
|||
<div class="empty">No data.</div> |
|||
{/each} |
|||
<AddBoi on:click={() => modal.open("activity.create")}>Activity</AddBoi> |
|||
</div> |
|||
|
|||
<style> |
|||
div.page { |
|||
width: 100ch; |
|||
max-width: 90%; |
|||
margin: auto; |
|||
} |
|||
|
|||
table { |
|||
width: 100%; |
|||
padding: 0.5em 0; |
|||
} |
|||
th, td { |
|||
padding: 0em 1ch; |
|||
} |
|||
th { |
|||
text-align: left; |
|||
font-size: 0.75em; |
|||
} |
|||
th.th-name { |
|||
width: 50%; |
|||
} |
|||
th.th-value { |
|||
width: 25%; |
|||
} |
|||
th.th-options { |
|||
width: 25%; |
|||
} |
|||
</style> |
@ -0,0 +1,168 @@ |
|||
<script context="module"> |
|||
const UNKNOWN = (Object.freeze||(o => o))({ |
|||
name: "(Unknown)", |
|||
subGoals: [], |
|||
subActivities: [], |
|||
}); |
|||
</script> |
|||
|
|||
<script> |
|||
import { get } from "svelte/store"; |
|||
import pluralize from "pluralize"; |
|||
|
|||
import Boi from "../components/Boi.svelte"; |
|||
import AddBoi from "../components/AddBoi.svelte"; |
|||
import Property from "../components/Property.svelte"; |
|||
import Row from "../components/Row.svelte"; |
|||
import Col from "../components/Col.svelte"; |
|||
import Link from "../components/Link.svelte"; |
|||
import ActivityIcon from "../components/ActivityIcon.svelte"; |
|||
import PointsBar from "../components/PointsBar.svelte"; |
|||
|
|||
import stufflog from "../stores/stufflog"; |
|||
import modal from "../stores/modal"; |
|||
|
|||
import dateStr from "../utils/dateStr"; |
|||
|
|||
let activityMap = {}; |
|||
let scores = {}; |
|||
|
|||
function openGoalModal(modalName, id, goalId, subGoalId) { |
|||
const period = get(stufflog).periods.find(a => a.id === id); |
|||
const goal = period ? period.goals.find(g => g.id === goalId) : null; |
|||
const subGoal = goal ? goal.subGoals.find(s => s.id === subGoalId) : null; |
|||
const activity = goal ? get(stufflog).activities.find(a => a.id === goal.activityId) : null |
|||
|
|||
modal.open(modalName, {period, goal, subGoal, activity}) |
|||
} |
|||
|
|||
function openLogModal(modalName, id, logId) { |
|||
const period = get(stufflog).periods.find(a => a.id === id); |
|||
const row = period ? period.table.find(r => r.log.id === logId) : {}; |
|||
|
|||
modal.open(modalName, {period, ...row}) |
|||
} |
|||
|
|||
$: activityMap = $stufflog.activities.reduce((p, v) => ({...p, [v.id]: v}), {}); |
|||
</script> |
|||
|
|||
<div class="page"> |
|||
{#each $stufflog.periods as period (period.id)} |
|||
<Boi header={period.name}> |
|||
<Row> |
|||
<Col size=3><Property label="From" value={dateStr(period.from)} /></Col> |
|||
<Col size=3><Property label="To" value={dateStr(period.to)} /></Col> |
|||
<Col size=6> |
|||
<Property label="Options"> |
|||
<Link on:click={() => openGoalModal("period.edit", period.id)}>Edit Period</Link>, |
|||
<Link on:click={() => openGoalModal("periodgoal.add", period.id)}>Add Goal</Link>, |
|||
{#if period.goals.length > 0} |
|||
<Link on:click={() => openGoalModal("periodlog.add", period.id)}>Add Log</Link>, |
|||
{/if} |
|||
<Link on:click={() => openGoalModal("period.delete", period.id)}>Delete Period</Link> |
|||
</Property> |
|||
</Col> |
|||
</Row> |
|||
<table> |
|||
<tr> |
|||
<th class="th-name">Activity</th> |
|||
<th class="th-points">Points</th> |
|||
<th class="th-options">Options</th> |
|||
</tr> |
|||
{#each period.goals as goal (goal.id)} |
|||
<tr> |
|||
<td> |
|||
<div class="icon"><ActivityIcon name={(activityMap[goal.activityId] || {name: "(Unknown)"}).icon} /></div> |
|||
<div class="name">{(activityMap[goal.activityId] || {name: "(Unknown)"}).name}</div> |
|||
</td> |
|||
<td><PointsBar value={period.scores[goal.id]} goal={goal.pointCount} /></td> |
|||
<td class="td-options"> |
|||
<Link on:click={() => openGoalModal("periodgoal.remove", period.id, goal.id)}>Delete</Link> |
|||
</td> |
|||
</tr> |
|||
{/each} |
|||
</table> |
|||
<table> |
|||
<tr> |
|||
<th class="th-date">Date</th> |
|||
<th class="th-log">Goal</th> |
|||
<th class="th-log">Sub-Activity</th> |
|||
<th class="th-points2">Amount</th> |
|||
<th class="th-points2">Points</th> |
|||
<th class="th-options">Options</th> |
|||
</tr> |
|||
{#each period.table as row (row.log.id)} |
|||
<tr> |
|||
<td>{dateStr(row.log.date)}</td> |
|||
<td class:dark={row.activity.name.startsWith("(")}> |
|||
<div class="icon"><ActivityIcon name={row.activity.icon} /></div> |
|||
<div class="name">{row.activity.name} {row.subActivity.name}</div> |
|||
</td> |
|||
<td class:dark={row.subGoal.name.startsWith("(")}>{row.subGoal.name}</td> |
|||
<td>{row.log.amount} {pluralize(row.subActivity.unitName, row.log.amount)}</td> |
|||
<td> |
|||
<Link on:click={() => openLogModal("periodlog.info", period.id, row.log.id)}>{row.log.score.total}</Link> |
|||
</td> |
|||
<td class="td-options"> |
|||
<Link on:click={() => openLogModal("periodlog.remove", period.id, row.log.id)}>Delete</Link> |
|||
</td> |
|||
</tr> |
|||
{/each} |
|||
</table> |
|||
</Boi> |
|||
{/each} |
|||
<AddBoi on:click={() => modal.open("period.create")}>Period</AddBoi> |
|||
</div> |
|||
|
|||
<style> |
|||
div.page { |
|||
width: 100ch; |
|||
max-width: 90%; |
|||
margin: auto; |
|||
} |
|||
|
|||
table { |
|||
width: 100%; |
|||
padding: 0.5em 0; |
|||
} |
|||
|
|||
table th, table td { |
|||
padding: 0em 1ch; |
|||
} |
|||
table th { |
|||
text-align: left; |
|||
font-size: 0.75em; |
|||
} |
|||
table th.th-name { |
|||
width: 25%; |
|||
} |
|||
table th.th-date { |
|||
width: 25%; |
|||
} |
|||
table th.th-points { |
|||
width: 62.5%; |
|||
} |
|||
table th.th-options, table td.td-options { |
|||
width: 12.5%; |
|||
text-align: right; |
|||
} |
|||
table th.th-log { |
|||
width: calc(37.5%/2); |
|||
} |
|||
table th.th-points2 { |
|||
width: 12.5%; |
|||
} |
|||
|
|||
table td.dark { |
|||
color: #666; |
|||
} |
|||
|
|||
table td div.icon { |
|||
position: relative; |
|||
top: 0.175em; |
|||
display: inline-block; |
|||
} |
|||
table td div.name { |
|||
display: inline-block; |
|||
} |
|||
</style> |
@ -0,0 +1,49 @@ |
|||
import { writable } from "svelte/store"; |
|||
|
|||
import slApi from "../api/stufflog"; |
|||
|
|||
import stufflogStore from "./stufflog"; |
|||
|
|||
function createAuthStore() { |
|||
const {set, update, subscribe} = writable({checked: false, user: null}) |
|||
|
|||
return { |
|||
subscribe, |
|||
|
|||
async check() { |
|||
const data = await slApi.checkSession(); |
|||
|
|||
set({checked: true, user: data.user || null}); |
|||
return data; |
|||
}, |
|||
|
|||
async login(username, password) { |
|||
const data = await slApi.login(username, password); |
|||
|
|||
set({checked: true, user: data.user}); |
|||
return data; |
|||
}, |
|||
|
|||
async logout() { |
|||
const data = await slApi.logout(); |
|||
|
|||
stufflogStore.clearAll(); |
|||
|
|||
set({checked: true, user: data.user || null}); |
|||
return data; |
|||
}, |
|||
|
|||
async register(username, password) { |
|||
const data = await slApi.register(username, password); |
|||
|
|||
stufflogStore.clearAll(); |
|||
|
|||
set({checked: true, user: data.user}); |
|||
return data; |
|||
}, |
|||
} |
|||
} |
|||
|
|||
const session = createAuthStore(); |
|||
|
|||
export default session; |
@ -0,0 +1,19 @@ |
|||
import { writable } from "svelte/store"; |
|||
|
|||
function createModalStore() { |
|||
const {set, subscribe} = writable({name: null, data: null}); |
|||
|
|||
return { |
|||
subscribe, |
|||
|
|||
open(name, data = {}) { |
|||
set({name, data}); |
|||
}, |
|||
|
|||
close() { |
|||
set({name: null, data: null}) |
|||
}, |
|||
} |
|||
} |
|||
|
|||
export default createModalStore(); |
@ -0,0 +1,203 @@ |
|||
import { writable, derived } from "svelte/store"; |
|||
|
|||
import slApi from "../api/stufflog"; |
|||
import Activity, { ActivityUpdate } from "../models/activity"; |
|||
import Period, { PeriodUpdate } from "../models/period"; |
|||
|
|||
const UNKNOWN_GOAL = Object.freeze({name: "(Unknown)", subGoals: []}); |
|||
const NO_SUB_GOAL = Object.freeze({name: "(None)"}); |
|||
const UNKNOWN_SUB_GOAL = Object.freeze({name: "(Unknown)"}); |
|||
const UNKNOWN_ACTIVITY = Object.freeze({name: "(Unknown)", subActivities: []}); |
|||
const UNKNOWN_SUB_ACTIVITY = Object.freeze({name: "(Unknown)", unitName: "unit"}); |
|||
|
|||
function calculateTotalScore(period) { |
|||
return period.goals.reduce((o, g) => o = ({...o, [g.id]: ( |
|||
period.logs.filter(l => l.goalId === g.id) |
|||
.map(l => l.score.total) |
|||
.reduce((n, m) => n + m, 0) |
|||
)}), {}) |
|||
} |
|||
|
|||
function generateLogTable(activities, period) { |
|||
const newTable = []; |
|||
|
|||
for (const log of period.logs) { |
|||
const goal = period.goals.find(g => g.id === log.goalId) || UNKNOWN_GOAL; |
|||
const subGoal = log.subGoalId |
|||
? goal.subGoals.find(s => s.id === log.subGoalId) || UNKNOWN_SUB_GOAL |
|||
: NO_SUB_GOAL; |
|||
const activity = activities.find(a => a.id === goal.activityId) || UNKNOWN_ACTIVITY; |
|||
const subActivity = activity.subActivities.find(s => s.id === log.subActivityId) || UNKNOWN_SUB_ACTIVITY; |
|||
|
|||
newTable.push({log, goal, subGoal, activity, subActivity}) |
|||
} |
|||
|
|||
newTable.sort((a, b) => a.log.date - b.log.date); |
|||
|
|||
return newTable; |
|||
} |
|||
|
|||
function replaceActivity(d, activity) { |
|||
const data = { |
|||
...d, |
|||
activities: [ |
|||
...d.activities.filter(a => a.id !== activity.id), |
|||
activity, |
|||
].sort((a,b) => a.name.localeCompare(b.name)), |
|||
}; |
|||
|
|||
data.periods = data.periods.map(p => ({ |
|||
...p, |
|||
scores: calculateTotalScore(p), |
|||
table: generateLogTable(data.activities, p), |
|||
})); |
|||
|
|||
return data; |
|||
} |
|||
|
|||
function replacePeriod(d, period) { |
|||
return { |
|||
...d, |
|||
periods: [ |
|||
...d.periods.filter(a => a.id !== period.id), |
|||
{ |
|||
...period, |
|||
scores: calculateTotalScore(period), |
|||
table: generateLogTable(d.activities, period), |
|||
}, |
|||
].sort((a,b) => b.from - a.from), |
|||
}; |
|||
} |
|||
|
|||
function deleteActivity(d, id) { |
|||
return { |
|||
...d, |
|||
activities: d.activities.filter(a => a.id !== id), |
|||
}; |
|||
} |
|||
|
|||
function deletePeriod(d, id) { |
|||
return { |
|||
...d, |
|||
periods: d.periods.filter(a => a.id !== id), |
|||
}; |
|||
} |
|||
|
|||
function createStufflogStore() { |
|||
/** @type {{activities: Activity[], periods: Period[], logTables: {}}} */ |
|||
const initialData = {activities: [], periods: [], logTables: {}}; |
|||
|
|||
const {set, update, subscribe} = writable(initialData); |
|||
|
|||
return { |
|||
subscribe, |
|||
|
|||
async listActivities() { |
|||
const activities = await slApi.listActivities(); |
|||
update(d => ({ |
|||
...d, |
|||
activities: activities.sort((a,b) => a.name.localeCompare(b.name)), |
|||
periods: d.periods.map(p => ({ |
|||
...p, |
|||
scores: calculateTotalScore(p), |
|||
table: generateLogTable(activities, p), |
|||
})), |
|||
})); |
|||
}, |
|||
|
|||
async loadActivity(id) { |
|||
const activity = await slApi.findActivity(id); |
|||
update(d => replaceActivity(d, activity)); |
|||
}, |
|||
|
|||
async listPeriods() { |
|||
const periods = await slApi.listPeriods(); |
|||
update(d => ({ |
|||
...d, |
|||
periods: periods.sort((a,b) => b.from - a.from).map(p => ({ |
|||
...p, |
|||
scores: calculateTotalScore(p), |
|||
table: generateLogTable(d.activities, p), |
|||
})), |
|||
})); |
|||
}, |
|||
|
|||
async loadPeriods(id) { |
|||
const period = await slApi.findPeriod(id); |
|||
update(d => replacePeriod(d, period)); |
|||
}, |
|||
|
|||
/** |
|||
* Create period |
|||
* |
|||
* @param {Period} input |
|||
*/ |
|||
async createPeriod(input) { |
|||
const period = await slApi.postPeriod(input); |
|||
update(d => replacePeriod(d, period)); |
|||
}, |
|||
|
|||
/** |
|||
* Create an activity |
|||
* |
|||
* @param {Activity} input |
|||
*/ |
|||
async createActivity(input) { |
|||
const activity = await slApi.postActivity(input); |
|||
update(d => replaceActivity(d, activity)) |
|||
}, |
|||
|
|||
/** |
|||
* Update activities |
|||
* |
|||
* @param {string} id |
|||
* @param {...ActivityUpdate} updates |
|||
*/ |
|||
async updateActivity(id, ...updates) { |
|||
const activity = await slApi.patchActivity(id, ...updates); |
|||
update(d => replaceActivity(d, activity)); |
|||
}, |
|||
|
|||
/** |
|||
* Update periods |
|||
* |
|||
* @param {string} id |
|||
* @param {...PeriodUpdate} updates |
|||
*/ |
|||
async updatePeriod(id, ...updates) { |
|||
const period = await slApi.patchPeriod(id, ...updates); |
|||
update(d => replacePeriod(d, period)); |
|||
}, |
|||
|
|||
/** |
|||
* Delete an activity |
|||
* |
|||
* @param {string} id |
|||
*/ |
|||
async deleteActivity(id) { |
|||
const activity = await slApi.deleteActivity(id); |
|||
update(d => deleteActivity(d, activity.id)); |
|||
}, |
|||
|
|||
/** |
|||
* Delete a period |
|||
* |
|||
* @param {string} id |
|||
*/ |
|||
async deletePeriod(id) { |
|||
const period = await slApi.deletePeriod(id); |
|||
update(d => deletePeriod(d, period.id)); |
|||
}, |
|||
|
|||
/** |
|||
* Clear all data. This does not do any API call. |
|||
*/ |
|||
clearAll() { |
|||
set({activities: [], periods: []}); |
|||
}, |
|||
} |
|||
}; |
|||
|
|||
const stufflog = createStufflogStore(); |
|||
|
|||
export default stufflog; |
@ -0,0 +1,16 @@ |
|||
/** |
|||
* Get a `YYYY-MM-DD` string. |
|||
* |
|||
* @param {Date} date |
|||
*/ |
|||
export default function dateStr(date) { |
|||
function pad(n) { |
|||
return n < 10 ? "0" + n : n.toString(); |
|||
} |
|||
|
|||
if (!(date instanceof Date)) { |
|||
date = new Date(date); |
|||
} |
|||
|
|||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; |
|||
} |
@ -0,0 +1,66 @@ |
|||
const MiniCssExtractPlugin = require("mini-css-extract-plugin"); |
|||
|
|||
const path = require("path"); |
|||
|
|||
const mode = process.env.NODE_ENV || "development"; |
|||
const prod = mode === "production"; |
|||
|
|||
module.exports = { |
|||
entry: { |
|||
bundle: ["./src/main.js"] |
|||
}, |
|||
resolve: { |
|||
alias: { |
|||
svelte: path.resolve("node_modules", "svelte") |
|||
}, |
|||
extensions: [".mjs", ".js", ".svelte"], |
|||
mainFields: ["svelte", "browser", "module", "main"] |
|||
}, |
|||
output: { |
|||
path: __dirname + "/public", |
|||
filename: "[name].js", |
|||
chunkFilename: "[name].[id].js" |
|||
}, |
|||
module: { |
|||
rules: [ |
|||
{ |
|||
test: /\.svelte$/, |
|||
use: { |
|||
loader: "svelte-loader", |
|||
options: { |
|||
emitCss: true, |
|||
hotReload: true |
|||
} |
|||
} |
|||
}, |
|||
{ |
|||
test: /\.css$/, |
|||
use: [ |
|||
/** |
|||
* MiniCssExtractPlugin doesn"t support HMR. |
|||
* For developing, use "style-loader" instead. |
|||
* */ |
|||
prod ? MiniCssExtractPlugin.loader : "style-loader", |
|||
"css-loader" |
|||
] |
|||
} |
|||
] |
|||
}, |
|||
mode, |
|||
plugins: [ |
|||
new MiniCssExtractPlugin({ |
|||
filename: "[name].css" |
|||
}), |
|||
], |
|||
devtool: prod ? false: "source-map", |
|||
devServer: { |
|||
historyApiFallback: true, |
|||
proxy: { |
|||
"/api": { |
|||
"changeOrigin": true, |
|||
"cookieDomainRewrite": "localhost", |
|||
"target": "http://localhost:8001", |
|||
}, |
|||
}, |
|||
}, |
|||
}; |
Write
Preview
Loading…
Cancel
Save
Reference in new issue