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