First commit
This commit is contained in:
Generated
+14
@@ -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>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="JavaScriptSettings">
|
||||||
|
<option name="languageLevel" value="ES6" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+8
@@ -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>
|
||||||
Generated
+8
@@ -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>
|
||||||
Generated
+29
@@ -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>
|
||||||
Generated
+768
@@ -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>
|
||||||
+22
@@ -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/"]
|
||||||
+137
@@ -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})
|
||||||
|
})
|
||||||
|
}
|
||||||
+178
@@ -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})
|
||||||
|
})
|
||||||
|
}
|
||||||
+75
@@ -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
|
||||||
|
```
|
||||||
Generated
+6465
File diff suppressed because it is too large
Load Diff
@@ -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": {}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | 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>
|
||||||
Vendored
+61
@@ -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."));
|
||||||
|
}
|
||||||
|
};
|
||||||
Vendored
+128
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user