├── src ├── assets │ ├── .gitkeep │ ├── icons │ │ ├── play_arrow.svg │ │ ├── pause.svg │ │ ├── add.svg │ │ ├── arrow_back.svg │ │ ├── filter_list.svg │ │ ├── close_small.svg │ │ ├── swap_vert.svg │ │ ├── delete.svg │ │ ├── search.svg │ │ ├── brightness_medium.svg │ │ ├── more_vert.svg │ │ ├── play_circle.svg │ │ ├── dark_mode.svg │ │ ├── pause_circle.svg │ │ ├── check_circle.svg │ │ ├── light_mode.svg │ │ └── date_range.svg │ ├── favicon.svg │ └── favicon-active.svg ├── app │ ├── dialog-create-task │ │ ├── dialog-create-task.scss │ │ ├── dialog-create-task.html │ │ └── dialog-create-task.ts │ ├── dialog-rename-task │ │ ├── dialog-rename-task.scss │ │ ├── dialog-rename-task.html │ │ └── dialog-rename-task.ts │ ├── utils │ │ ├── string.ts │ │ ├── number.ts │ │ ├── router.ts │ │ └── assert.ts │ ├── dialog-split-session │ │ ├── dialog-split-session.scss │ │ ├── dialog-split-session.html │ │ └── dialog-split-session.ts │ ├── screen-task │ │ ├── button-session-actions │ │ │ ├── button-session-actions.scss │ │ │ ├── button-session-actions.html │ │ │ └── button-session-actions.ts │ │ ├── type-safe-virtual-for.ts │ │ ├── screen-task.scss │ │ ├── sticky.ts │ │ ├── screen-task.html │ │ ├── mat-table.scss │ │ └── screen-task.ts │ ├── dialog-edit-session │ │ ├── dialog-edit-session.scss │ │ ├── dialog-edit-session.html │ │ └── dialog-edit-session.ts │ ├── button-task-actions │ │ ├── button-task-actions.scss │ │ ├── button-task-actions.ts │ │ └── button-task-actions.html │ ├── button-theme-switcher │ │ ├── button-theme-switcher.scss │ │ ├── button-theme-switcher.html │ │ └── button-theme-switcher.ts │ ├── dialog-hotkeys-cheatsheet │ │ ├── dialog-hotkeys-cheatsheet.scss │ │ ├── dialog-hotkeys-cheatsheet.html │ │ └── dialog-hotkeys-cheatsheet.ts │ ├── pipes │ │ ├── map.ts │ │ ├── task-state.ts │ │ └── task-state-icon.ts │ ├── providers │ │ ├── favicon.ts │ │ ├── import-export.ts │ │ └── routed-dialogs.ts │ ├── directives │ │ ├── template-context-type.ts │ │ ├── button-reset-input.ts │ │ ├── toolbar-width-sync.ts │ │ ├── datetime-local.ts │ │ └── duration.ts │ ├── screen-tasks │ │ ├── empty-state │ │ │ ├── empty-state.html │ │ │ ├── empty-state.ts │ │ │ └── empty-state.scss │ │ ├── checkViewportSizeWhenValueChanges.ts │ │ ├── tasks-filter │ │ │ ├── tasks-filter.scss │ │ │ ├── tasks-filter.html │ │ │ ├── tasks-filter.ts │ │ │ └── timeline-chart-uplot.ts │ │ ├── scrollToIndex.ts │ │ ├── screen-tasks.scss │ │ ├── screen-tasks.html │ │ └── screen-tasks.ts │ ├── guards │ │ └── game-state.ts │ ├── app.scss │ ├── domain │ │ ├── date-time.ts │ │ ├── hotkeys.ts │ │ ├── router.ts │ │ ├── chart.ts │ │ ├── storage.ts │ │ └── task.ts │ ├── app.html │ └── app.ts ├── environments │ ├── environment.ts │ └── environment.prod.ts ├── manifest.webmanifest ├── index.html ├── styles.scss ├── m3-theme.scss └── main.ts ├── .dockerignore ├── .prettierignore ├── social.png ├── screenshot.png ├── Dockerfile ├── e2e ├── page-objects │ ├── duration.ts │ ├── tooltip.ts │ ├── dialog-hotkeys-cheatsheet.ts │ ├── dialog-create-task.ts │ ├── dialog-rename-task.ts │ ├── menu-task-actions.ts │ ├── app.ts │ ├── dialog-split-session.ts │ ├── screen-tasks.ts │ ├── screen-task.ts │ └── dialog-edit-session.ts ├── tsconfig.json ├── visual-regression-screenshots │ ├── linux │ │ ├── [Filters] Name - cleared.reference.png │ │ ├── [Filters] Name - empty filter.reference.png │ │ ├── [Filters] Name - name filled.reference.png │ │ ├── [Tasks] Adding a task - invalid.reference.png │ │ ├── [General] Hotkey help - only tasks.reference.png │ │ ├── [Tasks] Export and import - menu.reference.png │ │ ├── [Tasks] Adding a task - just opened.reference.png │ │ ├── [Tasks] Adding a task - task created.reference.png │ │ ├── [Tasks] Editing a session - editing.reference.png │ │ ├── [Tasks] Editing a session - running.reference.png │ │ ├── [Tasks] Session splitting - default.reference.png │ │ ├── [Tasks] Session splitting - invalid.reference.png │ │ ├── [General] Hotkey help - tasks and task.reference.png │ │ ├── [General] Theme switcher - dark theme.reference.png │ │ ├── [General] Theme switcher - light theme.reference.png │ │ ├── [General] Theme switcher - themes menu.reference.png │ │ ├── [Tasks] Changing task status - active.reference.png │ │ ├── [Tasks] Changing task status - dropped.reference.png │ │ ├── [Tasks] Changing task status - finished.reference.png │ │ ├── [Tasks] Renaming the task - just opened.reference.png │ │ ├── [Tasks] Renaming the task - validation.reference.png │ │ ├── [Tasks] Changing task status - is dropped.reference.png │ │ ├── [Tasks] Renaming the task - context menu.reference.png │ │ ├── [General] Theme switcher - system theme dark.reference.png │ │ ├── [Tasks] Changing task status - is finished.reference.png │ │ ├── [General] Theme switcher - system theme light.reference.png │ │ ├── [Tasks] Changing task status - finished tasks.reference.png │ │ ├── [Tasks] Starting and stopping the task - active menu.reference.png │ │ ├── [Tasks] Starting and stopping the task - single panel.reference.png │ │ └── [Tasks] Starting and stopping the task - start tooltip.reference.png │ └── windows │ │ ├── [Filters] Name - cleared.reference.png │ │ ├── [Filters] Name - empty filter.reference.png │ │ ├── [Filters] Name - name filled.reference.png │ │ ├── [Tasks] Adding a task - invalid.reference.png │ │ ├── [Tasks] Export and import - menu.reference.png │ │ ├── [General] Hotkey help - only tasks.reference.png │ │ ├── [General] Theme switcher - dark theme.reference.png │ │ ├── [Tasks] Adding a task - just opened.reference.png │ │ ├── [Tasks] Adding a task - task created.reference.png │ │ ├── [Tasks] Changing task status - active.reference.png │ │ ├── [Tasks] Editing a session - editing.reference.png │ │ ├── [Tasks] Editing a session - running.reference.png │ │ ├── [Tasks] Session splitting - default.reference.png │ │ ├── [Tasks] Session splitting - invalid.reference.png │ │ ├── [General] Hotkey help - tasks and task.reference.png │ │ ├── [General] Theme switcher - light theme.reference.png │ │ ├── [General] Theme switcher - themes menu.reference.png │ │ ├── [Tasks] Changing task status - dropped.reference.png │ │ ├── [Tasks] Changing task status - finished.reference.png │ │ ├── [Tasks] Renaming the task - just opened.reference.png │ │ ├── [Tasks] Renaming the task - validation.reference.png │ │ ├── [Tasks] Changing task status - is dropped.reference.png │ │ ├── [Tasks] Changing task status - is finished.reference.png │ │ ├── [Tasks] Renaming the task - context menu.reference.png │ │ ├── [General] Theme switcher - system theme dark.reference.png │ │ ├── [General] Theme switcher - system theme light.reference.png │ │ ├── [Tasks] Changing task status - finished tasks.reference.png │ │ ├── [Tasks] Starting and stopping the task - active menu.reference.png │ │ ├── [Tasks] Starting and stopping the task - single panel.reference.png │ │ └── [Tasks] Starting and stopping the task - start tooltip.reference.png ├── utils.ts ├── fixtures │ ├── filters.ts │ └── general.ts └── visual-regression.ts ├── .prettierrc ├── tsconfig.app.json ├── .editorconfig ├── .browserslistrc ├── scripts ├── gh-pages-before-add.js └── generate-screenshots.ts ├── ngsw-config.json ├── .testcaferc.cjs ├── tsconfig.json ├── .gitignore ├── LICENSE ├── README.md ├── .github └── workflows │ └── cicd.yaml ├── angular.json └── package.json /src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .angular 3 | -------------------------------------------------------------------------------- /social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/social.png -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/screenshot.png -------------------------------------------------------------------------------- /src/app/dialog-create-task/dialog-create-task.scss: -------------------------------------------------------------------------------- 1 | mat-form-field { 2 | width: 100%; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/dialog-rename-task/dialog-rename-task.scss: -------------------------------------------------------------------------------- 1 | mat-form-field { 2 | width: 100%; 3 | } 4 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: false, 3 | }; 4 | -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | }; 4 | -------------------------------------------------------------------------------- /src/app/utils/string.ts: -------------------------------------------------------------------------------- 1 | export const hasCyrillics = (value: string): boolean => !!value.match(/[а-я]/); 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM timbru31/node-chrome:22 2 | COPY package*.json ./ 3 | RUN npm ci 4 | ENTRYPOINT [] 5 | CMD [] 6 | -------------------------------------------------------------------------------- /src/app/dialog-split-session/dialog-split-session.scss: -------------------------------------------------------------------------------- 1 | mat-slider { 2 | width: calc(100% - 15px); 3 | } 4 | -------------------------------------------------------------------------------- /src/app/screen-task/button-session-actions/button-session-actions.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: inline-block; 3 | } 4 | -------------------------------------------------------------------------------- /e2e/page-objects/duration.ts: -------------------------------------------------------------------------------- 1 | import { Selector } from 'testcafe'; 2 | 3 | export const duration = Selector('duration'); 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 120, 4 | "plugins": ["prettier-plugin-organize-imports"] 5 | } 6 | -------------------------------------------------------------------------------- /e2e/page-objects/tooltip.ts: -------------------------------------------------------------------------------- 1 | import { Selector } from 'testcafe'; 2 | 3 | export const tooltip = Selector('mat-tooltip-component'); 4 | -------------------------------------------------------------------------------- /src/app/dialog-edit-session/dialog-edit-session.scss: -------------------------------------------------------------------------------- 1 | form { 2 | display: grid; 3 | grid-template-columns: 1fr; 4 | gap: 10px; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/utils/number.ts: -------------------------------------------------------------------------------- 1 | export const pad2 = (value: number) => ((value || 0).toString().length === 1 ? '0' + value : value.toString()); 2 | -------------------------------------------------------------------------------- /e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "base": "./", 3 | "compilerOptions": { 4 | "esModuleInterop": true, 5 | "downlevelIteration": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/app/button-task-actions/button-task-actions.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: inline-block; 3 | flex: 0 0 auto; 4 | } 5 | mat-select { 6 | width: 140px; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/button-theme-switcher/button-theme-switcher.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | position: relative; 3 | overflow: hidden; 4 | width: 100%; 5 | height: 48px; 6 | display: block; 7 | } 8 | -------------------------------------------------------------------------------- /src/assets/icons/play_arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[Filters] Name - cleared.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[Filters] Name - cleared.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[Filters] Name - cleared.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[Filters] Name - cleared.reference.png -------------------------------------------------------------------------------- /src/assets/icons/pause.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[Filters] Name - empty filter.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[Filters] Name - empty filter.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[Filters] Name - name filled.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[Filters] Name - name filled.reference.png -------------------------------------------------------------------------------- /src/assets/icons/add.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[Tasks] Adding a task - invalid.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[Tasks] Adding a task - invalid.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[Filters] Name - empty filter.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[Filters] Name - empty filter.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[Filters] Name - name filled.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[Filters] Name - name filled.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[General] Hotkey help - only tasks.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[General] Hotkey help - only tasks.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[Tasks] Export and import - menu.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[Tasks] Export and import - menu.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[Tasks] Adding a task - invalid.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[Tasks] Adding a task - invalid.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[Tasks] Export and import - menu.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[Tasks] Export and import - menu.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[Tasks] Adding a task - just opened.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[Tasks] Adding a task - just opened.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[Tasks] Adding a task - task created.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[Tasks] Adding a task - task created.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[Tasks] Editing a session - editing.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[Tasks] Editing a session - editing.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[Tasks] Editing a session - running.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[Tasks] Editing a session - running.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[Tasks] Session splitting - default.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[Tasks] Session splitting - default.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[Tasks] Session splitting - invalid.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[Tasks] Session splitting - invalid.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[General] Hotkey help - only tasks.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[General] Hotkey help - only tasks.reference.png -------------------------------------------------------------------------------- /src/assets/icons/arrow_back.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/app", 5 | "types": [] 6 | }, 7 | "files": ["src/main.ts"], 8 | "include": ["src/**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[General] Hotkey help - tasks and task.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[General] Hotkey help - tasks and task.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[General] Theme switcher - dark theme.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[General] Theme switcher - dark theme.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[General] Theme switcher - light theme.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[General] Theme switcher - light theme.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[General] Theme switcher - themes menu.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[General] Theme switcher - themes menu.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[Tasks] Changing task status - active.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[Tasks] Changing task status - active.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[Tasks] Changing task status - dropped.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[Tasks] Changing task status - dropped.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[Tasks] Changing task status - finished.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[Tasks] Changing task status - finished.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[Tasks] Renaming the task - just opened.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[Tasks] Renaming the task - just opened.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[Tasks] Renaming the task - validation.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[Tasks] Renaming the task - validation.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[General] Theme switcher - dark theme.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[General] Theme switcher - dark theme.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[Tasks] Adding a task - just opened.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[Tasks] Adding a task - just opened.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[Tasks] Adding a task - task created.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[Tasks] Adding a task - task created.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[Tasks] Changing task status - active.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[Tasks] Changing task status - active.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[Tasks] Editing a session - editing.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[Tasks] Editing a session - editing.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[Tasks] Editing a session - running.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[Tasks] Editing a session - running.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[Tasks] Session splitting - default.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[Tasks] Session splitting - default.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[Tasks] Session splitting - invalid.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[Tasks] Session splitting - invalid.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[Tasks] Changing task status - is dropped.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[Tasks] Changing task status - is dropped.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[Tasks] Renaming the task - context menu.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[Tasks] Renaming the task - context menu.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[General] Hotkey help - tasks and task.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[General] Hotkey help - tasks and task.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[General] Theme switcher - light theme.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[General] Theme switcher - light theme.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[General] Theme switcher - themes menu.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[General] Theme switcher - themes menu.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[Tasks] Changing task status - dropped.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[Tasks] Changing task status - dropped.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[Tasks] Changing task status - finished.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[Tasks] Changing task status - finished.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[Tasks] Renaming the task - just opened.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[Tasks] Renaming the task - just opened.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[Tasks] Renaming the task - validation.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[Tasks] Renaming the task - validation.reference.png -------------------------------------------------------------------------------- /src/assets/icons/filter_list.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[General] Theme switcher - system theme dark.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[General] Theme switcher - system theme dark.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[Tasks] Changing task status - is finished.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[Tasks] Changing task status - is finished.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[Tasks] Changing task status - is dropped.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[Tasks] Changing task status - is dropped.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[Tasks] Changing task status - is finished.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[Tasks] Changing task status - is finished.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[Tasks] Renaming the task - context menu.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[Tasks] Renaming the task - context menu.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[General] Theme switcher - system theme light.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[General] Theme switcher - system theme light.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[Tasks] Changing task status - finished tasks.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[Tasks] Changing task status - finished tasks.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[General] Theme switcher - system theme dark.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[General] Theme switcher - system theme dark.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[General] Theme switcher - system theme light.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[General] Theme switcher - system theme light.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[Tasks] Changing task status - finished tasks.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[Tasks] Changing task status - finished tasks.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[Tasks] Starting and stopping the task - active menu.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[Tasks] Starting and stopping the task - active menu.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[Tasks] Starting and stopping the task - single panel.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[Tasks] Starting and stopping the task - single panel.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/linux/[Tasks] Starting and stopping the task - start tooltip.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/linux/[Tasks] Starting and stopping the task - start tooltip.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[Tasks] Starting and stopping the task - active menu.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[Tasks] Starting and stopping the task - active menu.reference.png -------------------------------------------------------------------------------- /src/assets/icons/close_small.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[Tasks] Starting and stopping the task - single panel.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[Tasks] Starting and stopping the task - single panel.reference.png -------------------------------------------------------------------------------- /e2e/visual-regression-screenshots/windows/[Tasks] Starting and stopping the task - start tooltip.reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klaster1/timer-5/HEAD/e2e/visual-regression-screenshots/windows/[Tasks] Starting and stopping the task - start tooltip.reference.png -------------------------------------------------------------------------------- /src/assets/icons/swap_vert.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/app/dialog-hotkeys-cheatsheet/dialog-hotkeys-cheatsheet.scss: -------------------------------------------------------------------------------- 1 | dl { 2 | display: grid; 3 | grid-template-columns: auto 1fr; 4 | width: max-content; 5 | margin: auto; 6 | } 7 | kbd { 8 | font-family: inherit; 9 | } 10 | kbd:not(:last-child)::after { 11 | content: ', '; 12 | } 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /src/app/pipes/map.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | @Pipe({ 4 | name: 'map', 5 | }) 6 | export class MapPipe implements PipeTransform { 7 | transform(value: Value, fn: (value: Value, ...rest: Args[]) => Result, ...rest: Args[]): Result { 8 | return fn(value, ...rest); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/app/providers/favicon.ts: -------------------------------------------------------------------------------- 1 | import { DOCUMENT, Injectable, inject } from '@angular/core'; 2 | 3 | @Injectable({ providedIn: 'root' }) 4 | export class FaviconService { 5 | private document = inject(DOCUMENT); 6 | setIcon(href: string) { 7 | this.document.querySelector('link[rel="icon"]')?.setAttribute('href', href); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/assets/icons/delete.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /e2e/page-objects/dialog-hotkeys-cheatsheet.ts: -------------------------------------------------------------------------------- 1 | import { Selector } from 'testcafe'; 2 | 3 | const dialog = Selector('dialog-hotkeys-cheatsheet'); 4 | 5 | export const dialogHotkeyCheatsheet = { 6 | dialog, 7 | keylist: dialog.find('dl'), 8 | descriptions: dialog.find('dd'), 9 | buttonDismiss: dialog.find('button').withText('Close'), 10 | }; 11 | -------------------------------------------------------------------------------- /src/app/directives/template-context-type.ts: -------------------------------------------------------------------------------- 1 | import { Directive, input } from '@angular/core'; 2 | 3 | @Directive({ selector: 'ng-template[contextType]' }) 4 | export class TemplateContextTypeDirective { 5 | contextType = input.required(); 6 | 7 | public static ngTemplateContextGuard(dir: TemplateContextTypeDirective, ctx: unknown): ctx is T { 8 | return true; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/assets/icons/search.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /e2e/page-objects/dialog-create-task.ts: -------------------------------------------------------------------------------- 1 | import { e2e } from '../utils'; 2 | 3 | export const dialogCreateTask = { 4 | title: e2e('dialog-create-task__title'), 5 | input: e2e('dialog-create-task__input'), 6 | validationError: e2e('dialog-create-task__validation-error'), 7 | buttonSubmit: e2e('dialog-create-task__button-submit'), 8 | buttonDismiss: e2e('dialog-create-task__button-dismiss'), 9 | }; 10 | -------------------------------------------------------------------------------- /e2e/page-objects/dialog-rename-task.ts: -------------------------------------------------------------------------------- 1 | import { e2e } from '../utils'; 2 | 3 | export const dialogRenameTask = { 4 | title: e2e('dialog-rename-task__title'), 5 | input: e2e('dialog-rename-task__input'), 6 | validationError: e2e('dialog-rename-task__validation-error'), 7 | buttonSubmit: e2e('dialog-rename-task__button-submit'), 8 | buttonDismiss: e2e('dialog-rename-task__button-dismiss'), 9 | }; 10 | -------------------------------------------------------------------------------- /src/app/screen-tasks/empty-state/empty-state.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | 6 |
7 |
8 | 9 |
10 |
11 | 12 |
13 | -------------------------------------------------------------------------------- /src/app/screen-tasks/empty-state/empty-state.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core'; 2 | 3 | @Component({ 4 | templateUrl: './empty-state.html', 5 | styleUrls: ['./empty-state.scss'], 6 | selector: 'empty-state', 7 | encapsulation: ViewEncapsulation.None, 8 | changeDetection: ChangeDetectionStrategy.OnPush, 9 | }) 10 | export class EmptyStateComponent {} 11 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # You can see what browsers were selected by your queries by running: 6 | # npx browserslist 7 | 8 | last 1 Chrome versions 9 | last 1 Firefox versions 10 | -------------------------------------------------------------------------------- /src/assets/icons/brightness_medium.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Timer", 3 | "short_name": "Timer", 4 | "theme_color": "#82bd17", 5 | "background_color": "#2c2c2c", 6 | "display": "standalone", 7 | "scope": ".", 8 | "start_url": "./", 9 | "icons": [ 10 | { 11 | "src": "assets/favicon.svg", 12 | "sizes": "48x48 72x72 96x96 128x128 256x256", 13 | "type": "image/svg+xml", 14 | "purpose": "any" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /src/assets/icons/more_vert.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/app/utils/router.ts: -------------------------------------------------------------------------------- 1 | import { ActivatedRouteSnapshot } from '@angular/router'; 2 | 3 | export const getAllChildren = (route: ActivatedRouteSnapshot): ActivatedRouteSnapshot[] => { 4 | return route.children.flatMap((child) => [child, ...getAllChildren(child)]); 5 | }; 6 | 7 | export const getAllParents = (route: ActivatedRouteSnapshot): ActivatedRouteSnapshot[] => { 8 | return route.parent ? [route.parent, ...getAllParents(route.parent)] : []; 9 | }; 10 | -------------------------------------------------------------------------------- /src/assets/icons/play_circle.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/assets/icons/dark_mode.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/assets/icons/pause_circle.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/assets/icons/check_circle.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /e2e/page-objects/menu-task-actions.ts: -------------------------------------------------------------------------------- 1 | import { e2e } from '../utils'; 2 | 3 | export const menuTaskActions = { 4 | selectorState: e2e('button-task-actions__selector-state'), 5 | optionActive: e2e('button-task-actions__state-option-active'), 6 | optionFinished: e2e('button-task-actions__state-option-finished'), 7 | optionDropped: e2e('button-task-actions__state-option-dropped'), 8 | buttonRename: e2e('button-task-actions__button-rename'), 9 | buttonDelete: e2e('button-task-actions__button-delete'), 10 | }; 11 | -------------------------------------------------------------------------------- /src/app/dialog-hotkeys-cheatsheet/dialog-hotkeys-cheatsheet.html: -------------------------------------------------------------------------------- 1 |

Keyboard shortcuts

2 |
3 |
4 | @for (key of keys; track key.action) { 5 |
6 | @for (formatted of key.formatted | map: withoutCyrillics; track formatted) { 7 | {{ formatted }} 8 | } 9 |
10 |
{{ key.description }}
11 | } 12 |
13 |
14 |
15 | 16 |
17 | -------------------------------------------------------------------------------- /src/app/guards/game-state.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@angular/core'; 2 | import { ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot } from '@angular/router'; 3 | import { isValidTaskState } from '@app/domain/task'; 4 | 5 | export const gameStateGuard: CanActivateFn = (next: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { 6 | const tasksState = next.params.state; 7 | return (tasksState && isValidTaskState(tasksState)) || tasksState === 'all' 8 | ? true 9 | : inject(Router).navigate(['/active']); 10 | }; 11 | -------------------------------------------------------------------------------- /src/assets/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/light_mode.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/app/screen-task/type-safe-virtual-for.ts: -------------------------------------------------------------------------------- 1 | import { CdkVirtualForOfContext } from '@angular/cdk/scrolling'; 2 | import { Directive, input } from '@angular/core'; 3 | 4 | // https://github.com/angular/components/issues/26609 5 | @Directive({ 6 | selector: '[cdkVirtualFor]', 7 | }) 8 | export class TypeSafeCdkVirtualForDirective { 9 | cdkVirtualForTypes = input(); 10 | 11 | static ngTemplateContextGuard( 12 | _dir: TypeSafeCdkVirtualForDirective, 13 | ctx: unknown, 14 | ): ctx is CdkVirtualForOfContext { 15 | return true; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/app/utils/assert.ts: -------------------------------------------------------------------------------- 1 | export const isTruthy = (value: T): value is NonNullable => !!value; 2 | 3 | export const assertNever = (value: never): never => { 4 | throw new Error(`This should never happen: ${value}`); 5 | }; 6 | 7 | export const deepEquals = (a: T, b: T): boolean => JSON.stringify(a) === JSON.stringify(b); 8 | 9 | export const isNumber = (value: any): value is number => typeof value === 'number'; 10 | 11 | export const option = (value: T | null | undefined) => ({ 12 | map: (fn: (value: T) => U) => (!!value ? fn(value) : null), 13 | }); 14 | -------------------------------------------------------------------------------- /src/app/button-theme-switcher/button-theme-switcher.html: -------------------------------------------------------------------------------- 1 | 10 | 11 | @for (option of options; track $index) { 12 | 16 | } 17 | 18 | -------------------------------------------------------------------------------- /src/app/screen-tasks/empty-state/empty-state.scss: -------------------------------------------------------------------------------- 1 | empty-state { 2 | display: flex; 3 | flex-direction: column; 4 | align-content: center; 5 | justify-content: center; 6 | gap: 10px; 7 | 8 | .title { 9 | font-size: 1.5em; 10 | } 11 | 12 | .title, 13 | .subtitle { 14 | text-align: center; 15 | } 16 | .actions:not(:empty) { 17 | margin: auto; 18 | margin-top: 10px; 19 | } 20 | .illustration:not(:empty) { 21 | margin: auto; 22 | margin-bottom: 10px; 23 | [illustration] { 24 | width: 128px; 25 | height: 128px; 26 | font-size: 128px; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/screen-tasks/checkViewportSizeWhenValueChanges.ts: -------------------------------------------------------------------------------- 1 | import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; 2 | import { Directive, effect, inject, input } from '@angular/core'; 3 | 4 | @Directive({ 5 | selector: '[checkViewportSizeWhenValueChanges]', 6 | }) 7 | export class CheckViewportSizeWhenValueChangesDirective { 8 | private viewport = inject(CdkVirtualScrollViewport); 9 | public checkViewportSizeWhenValueChanges = input(); 10 | constructor() { 11 | effect(() => { 12 | this.checkViewportSizeWhenValueChanges(); 13 | this.viewport.checkViewportSize(); 14 | }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /e2e/page-objects/app.ts: -------------------------------------------------------------------------------- 1 | import { Selector } from 'testcafe'; 2 | import { e2e } from '../utils'; 3 | 4 | export const app = { 5 | favicon: Selector('link[rel="icon"]'), 6 | buttonActiveTasks: e2e('navigation__button-tasks-active'), 7 | buttonFinishedTasks: e2e('navigation__button-tasks-finished'), 8 | buttonDroppedTasks: e2e('navigation__button-tasks-dropped'), 9 | buttonImportExport: e2e('navigation__button-import-export'), 10 | buttonExport: e2e('navigation__button-export'), 11 | inputImport: e2e('navigation__input-import'), 12 | buttonSwitchTheme: e2e('navigation__button-theme'), 13 | buttonTheme: e2e('theme-button'), 14 | }; 15 | -------------------------------------------------------------------------------- /src/assets/icons/date_range.svg: -------------------------------------------------------------------------------- 1 | 3 | -------------------------------------------------------------------------------- /src/assets/favicon-active.svg: -------------------------------------------------------------------------------- 1 | 5 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /scripts/gh-pages-before-add.js: -------------------------------------------------------------------------------- 1 | import { copyFile, writeFile } from 'node:fs/promises'; 2 | 3 | await copyFile('dist/timer-5/index.html', 'dist/timer-5/404.html'); 4 | console.log('404 created'); 5 | 6 | const ngswConfig = (await import('../dist/timer-5/ngsw.json', { with: { type: 'json' } })).default; 7 | const indexHtmlAt = ngswConfig.assetGroups.at(0).urls.indexOf(`/timer-5/index.html`); 8 | ngswConfig.assetGroups.at(0).urls.splice(indexHtmlAt, 0, '/timer-5/404.html'); 9 | const indexHtmlHash = ngswConfig.hashTable['/timer-5/index.html']; 10 | ngswConfig.hashTable['/timer-5/404.html'] = indexHtmlHash; 11 | await writeFile('dist/timer-5/ngsw.json', JSON.stringify(ngswConfig, null, ' ')); 12 | console.log('ngsw.json updated'); 13 | -------------------------------------------------------------------------------- /ngsw-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/service-worker/config/schema.json", 3 | "index": "/index.html", 4 | "assetGroups": [ 5 | { 6 | "name": "app", 7 | "installMode": "prefetch", 8 | "resources": { 9 | "files": ["/assets/**", "/index.html", "/404.html", "/manifest.webmanifest", "/*.css", "/*.js"], 10 | "urls": ["https://fonts.googleapis.com/**", "https://fonts.gstatic.com/**"] 11 | } 12 | }, 13 | { 14 | "name": "assets", 15 | "installMode": "lazy", 16 | "updateMode": "prefetch", 17 | "resources": { 18 | "files": ["/assets/**", "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"] 19 | } 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /src/app/pipes/task-state.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | import { RouteTaskState, TaskState } from '@app/domain/task'; 3 | import { assertNever } from '@app/utils/assert'; 4 | 5 | @Pipe({ 6 | name: 'taskState', 7 | }) 8 | export class TaskStatePipe implements PipeTransform { 9 | transform(value?: RouteTaskState) { 10 | switch (value) { 11 | case TaskState.active: 12 | return 'Active'; 13 | case TaskState.dropped: 14 | return 'Dropped'; 15 | case TaskState.finished: 16 | return 'Finished'; 17 | case 'all': 18 | return 'All'; 19 | case undefined: 20 | return '😵'; 21 | default: 22 | return assertNever(value); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/dialog-create-task/dialog-create-task.html: -------------------------------------------------------------------------------- 1 |

Create task

2 |
3 | 4 | Task name 5 | 6 | Value is required 7 | 8 |
9 |
10 | 11 | 12 |
13 | -------------------------------------------------------------------------------- /src/app/dialog-rename-task/dialog-rename-task.html: -------------------------------------------------------------------------------- 1 |

Rename task

2 |
3 | 4 | Task name 5 | 6 | Value is required 7 | 8 |
9 |
10 | 11 | 12 |
13 | -------------------------------------------------------------------------------- /.testcaferc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {TestCafeConfigurationOptions} */ 2 | module.exports = { 3 | src: './e2e/fixtures/', 4 | baseUrl: 'http://localhost:4200/', 5 | screenshots: { 6 | path: './e2e/screenshots', 7 | takeOnFails: true, 8 | }, 9 | compilerOptions: { 10 | typescript: { 11 | options: { 12 | esModuleInterop: true, 13 | }, 14 | }, 15 | }, 16 | reporter: [ 17 | { 18 | name: 'spec-plus', 19 | filter: ['It has just been rewritten with a recent screenshot', 'The browser window was resized'], 20 | }, 21 | ], 22 | hooks: { 23 | test: { 24 | before: async (t) => { 25 | const client = await t.getCurrentCDPSession(); 26 | client.Animation.setPlaybackRate({ playbackRate: 100000 }); 27 | await t.resizeWindow(1920, 1080); 28 | }, 29 | }, 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /e2e/page-objects/dialog-split-session.ts: -------------------------------------------------------------------------------- 1 | import { Selector, t } from 'testcafe'; 2 | import { e2e } from '../utils'; 3 | 4 | const sliderInput = Selector('.mdc-slider__track'); 5 | export const dialogSplitSession = { 6 | sessionStart: e2e('dialog-split-session__session-start'), 7 | sessionEnd: e2e('dialog-split-session__session-end'), 8 | sessionDuration: e2e('dialog-split-session__session-duration'), 9 | sliderInput, 10 | sliderTrack: Selector('.mdc-slider__track'), 11 | sliderThumb: Selector('mat-slider-visual-thumb'), 12 | buttonSubmit: e2e('dialog-split-session__button-submit'), 13 | async setSliderValue(ratio: number) { 14 | const trackWidth = await sliderInput.getBoundingClientRectProperty('width'); 15 | const moveTo = Math.floor(trackWidth * ratio); 16 | await t.click(Selector('mat-slider'), { offsetX: moveTo }); 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /src/app/app.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | flex: 1; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | .link-all img { 7 | width: 32px; 8 | height: 32px; 9 | vertical-align: middle; 10 | } 11 | mat-drawer { 12 | border-right: 1px solid var(--mat-menu-divider-color); 13 | } 14 | mat-drawer-container { 15 | flex: 1; 16 | } 17 | mat-drawer-content { 18 | display: flex; 19 | } 20 | .drawer-container { 21 | height: 100%; 22 | display: flex; 23 | flex-direction: column; 24 | padding-bottom: 1rem; 25 | box-sizing: border-box; 26 | overflow-x: hidden; 27 | } 28 | .states { 29 | margin-top: auto; 30 | margin-bottom: auto; 31 | } 32 | .import-label { 33 | position: relative; 34 | cursor: pointer; 35 | 36 | input { 37 | width: 100%; 38 | height: 100%; 39 | position: absolute; 40 | opacity: 0; 41 | z-index: -1; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/app/domain/date-time.ts: -------------------------------------------------------------------------------- 1 | import { assertNever } from '@app/utils/assert'; 2 | import { pad2 } from '@app/utils/number'; 3 | 4 | export type Duration = Milliseconds; 5 | export type Seconds = number; 6 | export type Milliseconds = number; 7 | export type DurationFn = (now: Milliseconds) => Duration; 8 | 9 | export const formatHours = (value: Duration): string => { 10 | if (value <= 0) return '00:00'; 11 | 12 | return (['h', 'm'] as const) 13 | .map((part) => { 14 | switch (part) { 15 | case 'h': 16 | return pad2(~~(value / 3600000)); 17 | case 'm': 18 | return pad2(~~((value % 3600000) / 60000)); 19 | default: 20 | return assertNever(part); 21 | } 22 | }) 23 | .join(':'); 24 | }; 25 | 26 | export const daysToMilliseconds = (days: number): Milliseconds => days * 24 * 60 * 60 * 1000; 27 | -------------------------------------------------------------------------------- /src/app/providers/import-export.ts: -------------------------------------------------------------------------------- 1 | import { inject, Injectable } from '@angular/core'; 2 | import { fromStoredTasks } from '@app/domain/storage'; 3 | import { AppStore } from './state'; 4 | 5 | @Injectable({ providedIn: 'root' }) 6 | export class ImportExportService { 7 | private store = inject(AppStore); 8 | import(event: Event) { 9 | const target = event.target; 10 | if (!(target instanceof HTMLInputElement)) return; 11 | const file = target?.files?.[0]; 12 | if (!file) return; 13 | const fileReader = new FileReader(); 14 | fileReader.addEventListener( 15 | 'load', 16 | () => { 17 | if (typeof fileReader.result !== 'string') return; 18 | this.store.loadTasks(fromStoredTasks(JSON.parse(fileReader.result))); 19 | }, 20 | { once: true }, 21 | ); 22 | fileReader.readAsText(file); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/app/screen-tasks/tasks-filter/tasks-filter.scss: -------------------------------------------------------------------------------- 1 | form { 2 | display: grid; 3 | gap: 10px; 4 | padding: 10px; 5 | grid-template-columns: 1fr 1fr auto; 6 | } 7 | 8 | .name { 9 | grid-row: 1; 10 | grid-column: span 1; 11 | } 12 | 13 | .duration { 14 | grid-row: 1; 15 | grid-column: 2 / span 2; 16 | } 17 | 18 | .from { 19 | grid-row: 2; 20 | grid-column: 1; 21 | } 22 | .to { 23 | grid-row: 2; 24 | grid-column: 2; 25 | } 26 | 27 | .dates-menu { 28 | grid-row: 2; 29 | grid-column: 3; 30 | position: relative; 31 | top: 7px; 32 | } 33 | 34 | @media (max-width: 530px) { 35 | form { 36 | grid-template-columns: 1fr; 37 | } 38 | .name { 39 | grid-column: span 1; 40 | } 41 | 42 | .dates-menu { 43 | display: none; 44 | } 45 | } 46 | 47 | timeline-chart-uplot { 48 | height: 300px; 49 | } 50 | 51 | input[type='search']::-webkit-search-cancel-button { 52 | -webkit-appearance: none; 53 | } 54 | -------------------------------------------------------------------------------- /src/app/pipes/task-state-icon.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | import { Task, TaskState, isTask, isTaskRunning } from '@app/domain/task'; 3 | import { assertNever } from '@app/utils/assert'; 4 | 5 | @Pipe({ 6 | name: 'taskStateIcon', 7 | }) 8 | export class TaskStateIconPipe implements PipeTransform { 9 | transform(stateOrTask?: Task | TaskState) { 10 | const task = isTask(stateOrTask); 11 | const inputState = typeof stateOrTask === 'string' ? stateOrTask : null; 12 | const state = task?.state ?? inputState; 13 | 14 | if (task && isTaskRunning(task)) { 15 | return 'pause_circle'; 16 | } 17 | if (!state) { 18 | return 'timer-logo'; 19 | } 20 | switch (state) { 21 | case TaskState.active: 22 | return 'play_circle'; 23 | case TaskState.finished: 24 | return 'check_circle'; 25 | case TaskState.dropped: 26 | return 'delete'; 27 | default: 28 | return assertNever(state); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/directives/button-reset-input.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; 2 | import { MatIconButton } from '@angular/material/button'; 3 | import { MatIcon } from '@angular/material/icon'; 4 | import { MatTooltip } from '@angular/material/tooltip'; 5 | 6 | @Component({ 7 | selector: 'button-reset-input-control', 8 | changeDetection: ChangeDetectionStrategy.OnPush, 9 | template: ` 10 | @if (showButton()) { 11 | 14 | } 15 | `, 16 | styles: [ 17 | ` 18 | :host { 19 | display: contents; 20 | } 21 | `, 22 | ], 23 | imports: [MatIconButton, MatIcon, MatTooltip], 24 | }) 25 | export class ButtonResetInputComponent { 26 | value = input(); 27 | reset = output(); 28 | public showButton = computed(() => { 29 | return this.value() !== null; 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "noImplicitOverride": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "noUncheckedIndexedAccess": true, 12 | "esModuleInterop": true, 13 | "sourceMap": true, 14 | "declaration": false, 15 | "experimentalDecorators": false, 16 | "moduleResolution": "bundler", 17 | "importHelpers": true, 18 | "target": "ES2022", 19 | "module": "ES2022", 20 | "lib": [ 21 | "ES2022", 22 | "dom" 23 | ], 24 | "paths": { 25 | "@app/*": [ 26 | "src/app/*" 27 | ] 28 | }, 29 | "strictNullChecks": true 30 | }, 31 | "angularCompilerOptions": { 32 | "enableI18nLegacyMessageIdFormat": false, 33 | "strictInjectionParameters": true, 34 | "strictInputAccessModifiers": true, 35 | "strictTemplates": true 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events*.json 15 | speed-measure-plugin*.json 16 | 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # IDE - VSCode 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | .history/* 33 | 34 | # misc 35 | /.angular/cache 36 | /.sass-cache 37 | /connect.lock 38 | /coverage 39 | /libpeerconnection.log 40 | npm-debug.log 41 | yarn-error.log 42 | testem.log 43 | /typings 44 | /e2e/screenshots 45 | /e2e/downloads 46 | /e2e/videos 47 | 48 | # System Files 49 | .DS_Store 50 | Thumbs.db 51 | 52 | /data 53 | /e2e/visual-regression-screenshots/**/*.current.png 54 | /e2e/visual-regression-screenshots/**/*.diff.png 55 | -------------------------------------------------------------------------------- /e2e/page-objects/screen-tasks.ts: -------------------------------------------------------------------------------- 1 | import { Selector, t } from 'testcafe'; 2 | import { e2e } from '../utils'; 3 | import { dialogCreateTask } from './dialog-create-task'; 4 | 5 | export const screenTasks = { 6 | addTaskButton: e2e('button-add-task'), 7 | emptyStateAddTaskButton: e2e('screen-tasks__button-add-task-empty-state'), 8 | taskItem: e2e('screen-tasks__task-item'), 9 | taskStateIcon: e2e('screen-tasks__task-state-icon'), 10 | taskName: e2e('screen-tasks__task-name'), 11 | buttonTaskAction: Selector('screen-tasks [data-e2e="button-task-actions__trigger"]'), 12 | total: e2e('screen-tasks__total'), 13 | durationSelector: '[data-e2e="screen-tasks__task-duration"]', 14 | addTask: async (name: string) => { 15 | await t.pressKey('a').typeText(dialogCreateTask.input, name).click(dialogCreateTask.buttonSubmit); 16 | await t.expect(screenTasks.taskName.withExactText(name).exists).ok(); 17 | }, 18 | filter: { 19 | name: { 20 | input: Selector('tasks-filter .name input'), 21 | buttonClear: Selector('tasks-filter .name button-reset-input-control button'), 22 | }, 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /e2e/page-objects/screen-task.ts: -------------------------------------------------------------------------------- 1 | import { Selector } from 'testcafe'; 2 | import { e2e } from '../utils'; 3 | 4 | export const screenTask = { 5 | name: e2e('screen-task__name'), 6 | stateIcon: e2e('screen-task__state-icon'), 7 | buttonTaskAction: Selector('screen-task [data-e2e="button-task-actions__trigger"]'), 8 | buttonStart: e2e('screen-task__button-start'), 9 | buttonStop: e2e('screen-task__button-stop'), 10 | sessionRow: e2e('screen-task__session-row'), 11 | sessionStart: e2e('screen-task__session-start'), 12 | sessionEnd: e2e('screen-task__session-end'), 13 | sessionDuration: e2e('screen-task__session-duration'), 14 | screen: Selector('screen-task'), 15 | buttonSessionAction: e2e('screen-task__button-session-action'), 16 | taskDuration: e2e('screen-task__task-duration'), 17 | menuSession: { 18 | buttonEdit: e2e('menu-session__button-edit'), 19 | buttonDelete: e2e('menu-session__button-delete'), 20 | buttonSkipAfter: e2e('menu-session__button-skip-after'), 21 | buttonSkipBefore: e2e('menu-session__button-skip-before'), 22 | buttonSplit: e2e('menu-session__button-split'), 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ilya Borisov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/app/screen-tasks/scrollToIndex.ts: -------------------------------------------------------------------------------- 1 | import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; 2 | import { Directive, effect, inject, input } from '@angular/core'; 3 | import { isNumber } from '@app/utils/assert'; 4 | 5 | @Directive({ 6 | selector: '[scrollToIndex]', 7 | }) 8 | export class ScrollToIndexDirective { 9 | private viewport = inject(CdkVirtualScrollViewport); 10 | 11 | public itemSize = input(); 12 | public scrollToIndex = input(); 13 | 14 | private previousIndex?: number; 15 | constructor() { 16 | effect(() => { 17 | const index = this.scrollToIndex(); 18 | const itemSize = this.itemSize(); 19 | if (!isNumber(index) || index === -1 || !itemSize) { 20 | this.previousIndex = index; 21 | return; 22 | } 23 | const offsetTop = index * itemSize; 24 | const behavior: ScrollBehavior | undefined = !isNumber(this.previousIndex) ? 'smooth' : undefined; 25 | setTimeout(() => { 26 | this.viewport.scrollToOffset(offsetTop - this.viewport.getViewportSize() / 2, behavior); 27 | this.previousIndex = index; 28 | }); 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/screen-task/screen-task.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | position: relative; 3 | flex: 1; 4 | flex-direction: column; 5 | max-height: 100vh; 6 | display: grid; 7 | grid-template-rows: auto 1fr; 8 | } 9 | h2 { 10 | display: grid; 11 | grid-template-columns: auto 1fr; 12 | 13 | & > span { 14 | overflow: hidden; 15 | text-overflow: ellipsis; 16 | } 17 | 18 | mat-icon { 19 | position: relative; 20 | top: 2px; 21 | margin-right: 0.25em; 22 | } 23 | } 24 | [mat-fab] { 25 | position: absolute; 26 | bottom: var(--fab-offset); 27 | right: var(--fab-offset); 28 | left: 50%; 29 | } 30 | .session-tabular-data { 31 | display: grid; 32 | grid-template-columns: 1fr 1fr 1fr; 33 | } 34 | mat-toolbar { 35 | grid-row: 1; 36 | display: grid; 37 | grid-template-columns: auto 1fr auto; 38 | } 39 | .header-start, 40 | .header-end { 41 | width: 50%; 42 | } 43 | .header-duration { 44 | max-width: 95px; 45 | min-width: 95px; 46 | } 47 | .header-action { 48 | max-width: 40px; 49 | min-width: 40px; 50 | } 51 | cdk-virtual-scroll-viewport { 52 | grid-row: 2; 53 | padding-right: 2px; 54 | } 55 | duration { 56 | --unit-font-size: 10px; 57 | } 58 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Timer 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/app/dialog-hotkeys-cheatsheet/dialog-hotkeys-cheatsheet.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; 2 | import { MatButton } from '@angular/material/button'; 3 | import { 4 | MatDialogActions, 5 | MatDialogClose, 6 | MatDialogConfig, 7 | MatDialogContent, 8 | MatDialogTitle, 9 | } from '@angular/material/dialog'; 10 | import { MapPipe } from '@app/pipes/map'; 11 | import { hasCyrillics } from '@app/utils/string'; 12 | import { HotkeysService } from 'angular2-hotkeys'; 13 | 14 | @Component({ 15 | selector: 'dialog-hotkeys-cheatsheet', 16 | templateUrl: './dialog-hotkeys-cheatsheet.html', 17 | styleUrls: ['./dialog-hotkeys-cheatsheet.scss'], 18 | changeDetection: ChangeDetectionStrategy.OnPush, 19 | imports: [MatDialogTitle, MatDialogContent, MatDialogActions, MatDialogClose, MatButton, MapPipe], 20 | }) 21 | export default class DialogHotkeysCheatsheetComponent { 22 | static dialogConfig: MatDialogConfig = { width: undefined }; 23 | private hotkeysService = inject(HotkeysService); 24 | get keys() { 25 | return this.hotkeysService.hotkeys.filter((key) => key.description); 26 | } 27 | withoutCyrillics = (values: string[]): string[] => [...values].filter((value) => !hasCyrillics(value)); 28 | } 29 | -------------------------------------------------------------------------------- /e2e/utils.ts: -------------------------------------------------------------------------------- 1 | import { ClientFunction, Selector, t } from 'testcafe'; 2 | 3 | export const e2e = (id: string) => Selector(`[data-e2e="${id}"]`); 4 | export const getLocationPathname = ClientFunction(() => window.location.pathname); 5 | export const getLocationSearch = ClientFunction(() => window.location.search); 6 | export const reload = async () => t.getCurrentCDPSession().then((client) => client.Page.reload({ ignoreCache: true })); 7 | 8 | // Date mocks 9 | 10 | export const mockDate = (date: Date) => 11 | t.eval( 12 | () => { 13 | Date.now = () => date.valueOf(); 14 | }, 15 | { 16 | dependencies: { date }, 17 | }, 18 | ); 19 | 20 | export const advanceDate = (ms: number) => 21 | t.eval( 22 | () => { 23 | const oldNow = Date.now(); 24 | Date.now = () => oldNow + ms; 25 | }, 26 | { dependencies: { ms } }, 27 | ); 28 | 29 | export const restoreDate = () => 30 | t.eval(() => { 31 | const iframe = document.createElement('iframe'); 32 | iframe.style.display = 'none'; 33 | document.body.appendChild(iframe); 34 | const originalDateNow = (iframe.contentWindow as unknown as typeof globalThis).Date.now; 35 | document.body.removeChild(iframe); 36 | Date.now = originalDateNow; 37 | }); 38 | -------------------------------------------------------------------------------- /src/app/directives/toolbar-width-sync.ts: -------------------------------------------------------------------------------- 1 | import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; 2 | import { DestroyRef, Directive, ElementRef, afterNextRender, inject, input } from '@angular/core'; 3 | 4 | @Directive({ 5 | selector: 'mat-toolbar', 6 | }) 7 | export class ToolbarWidthSyncDirective { 8 | public syncWidthTo = input(); 9 | private elementRef = inject>(ElementRef); 10 | private destroyRef = inject(DestroyRef); 11 | constructor() { 12 | afterNextRender({ 13 | read: () => { 14 | const viewport = this.syncWidthTo(); 15 | if (!viewport) return; 16 | const observer = new ResizeObserver((entries) => { 17 | const viewportParentWidth = entries.at(0)?.target.parentElement?.clientWidth; 18 | const viewportWidth = entries.at(0)?.contentRect.width; 19 | if (!viewportParentWidth || !viewportWidth) return; 20 | const scrollbarWidth = viewportParentWidth - viewportWidth; 21 | this.elementRef.nativeElement.style.paddingRight = `${16 + scrollbarWidth}px`; 22 | }); 23 | observer.observe(viewport.getElementRef().nativeElement); 24 | this.destroyRef.onDestroy(() => observer.disconnect()); 25 | }, 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/app/dialog-edit-session/dialog-edit-session.html: -------------------------------------------------------------------------------- 1 |

Edit session

2 |
3 | 4 | Start 5 | 6 | 11 | @if (form.controls.start.errors?.required) { 12 | Start is required 13 | } 14 | 15 | 16 | End 17 | 18 | 23 | 24 |
25 |
26 | 27 | 28 |
29 | -------------------------------------------------------------------------------- /src/app/screen-task/button-session-actions/button-session-actions.html: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 12 | Edit 13 | 14 | 21 | Split 22 | 23 | 24 | Skip everything after 33 | Skip everything before 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/app/dialog-create-task/dialog-create-task.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; 2 | import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; 3 | import { MatButton } from '@angular/material/button'; 4 | import { MatDialogActions, MatDialogClose, MatDialogContent, MatDialogTitle } from '@angular/material/dialog'; 5 | import { MatError, MatFormField, MatLabel } from '@angular/material/form-field'; 6 | import { MatInput } from '@angular/material/input'; 7 | import { AppStore } from '@app/providers/state'; 8 | 9 | @Component({ 10 | selector: 'dialog-create-task', 11 | templateUrl: './dialog-create-task.html', 12 | styleUrl: './dialog-create-task.scss', 13 | changeDetection: ChangeDetectionStrategy.OnPush, 14 | imports: [ 15 | MatDialogTitle, 16 | MatDialogContent, 17 | MatDialogActions, 18 | MatDialogClose, 19 | MatButton, 20 | MatFormField, 21 | MatLabel, 22 | MatError, 23 | MatInput, 24 | ReactiveFormsModule, 25 | ], 26 | }) 27 | export default class DialogCreateTaskComponent { 28 | private state = inject(AppStore); 29 | form = new FormGroup({ 30 | value: new FormControl(null, [Validators.required]), 31 | }); 32 | onSubmit() { 33 | const { value } = this.form.value; 34 | if (this.form.valid && typeof value === 'string') { 35 | this.state.createTask(value); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/app/screen-task/sticky.ts: -------------------------------------------------------------------------------- 1 | import { DestroyRef, Directive, ElementRef, afterNextRender, inject } from '@angular/core'; 2 | 3 | @Directive({ 4 | selector: '[sticky]', 5 | }) 6 | export class VirtualScrollStickyTable { 7 | private elementRef = inject>(ElementRef); 8 | private destroyRef = inject(DestroyRef); 9 | 10 | constructor() { 11 | afterNextRender(() => { 12 | const thead = this.elementRef.nativeElement.querySelector('thead'); 13 | const tfoot = this.elementRef.nativeElement.querySelector('tfoot'); 14 | const parent = this.elementRef.nativeElement.parentElement; 15 | 16 | if (!parent || !thead || !tfoot) return; 17 | 18 | thead.style.position = 'sticky'; 19 | thead.style.zIndex = '10'; 20 | tfoot.style.position = 'sticky'; 21 | tfoot.style.zIndex = '10'; 22 | 23 | const observer = new MutationObserver((changes) => { 24 | const { transform } = (changes.at(0)?.target as HTMLElement).style; 25 | const transformPx = transform.replace('translateY(', '').replace('px)', ''); 26 | const theadTop = `-${transformPx}px`; 27 | const tfootBottom = `${transformPx}px`; 28 | if (thead.style.top !== theadTop) thead.style.top = theadTop; 29 | if (tfoot.style.bottom !== tfootBottom) tfoot.style.bottom = tfootBottom; 30 | }); 31 | observer.observe(parent, { attributes: true, attributeFilter: ['style'] }); 32 | this.destroyRef.onDestroy(() => observer.disconnect()); 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/app/dialog-rename-task/dialog-rename-task.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/core'; 2 | import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; 3 | import { MatButton } from '@angular/material/button'; 4 | import { MatDialogActions, MatDialogClose, MatDialogContent, MatDialogTitle } from '@angular/material/dialog'; 5 | import { MatError, MatFormField, MatLabel } from '@angular/material/form-field'; 6 | import { MatInput } from '@angular/material/input'; 7 | import { AppStore } from '@app/providers/state'; 8 | 9 | @Component({ 10 | selector: 'dialog-rename-task', 11 | templateUrl: './dialog-rename-task.html', 12 | styleUrl: './dialog-rename-task.scss', 13 | changeDetection: ChangeDetectionStrategy.OnPush, 14 | imports: [ 15 | MatDialogTitle, 16 | MatDialogContent, 17 | MatDialogActions, 18 | MatDialogClose, 19 | MatButton, 20 | MatFormField, 21 | MatLabel, 22 | MatError, 23 | MatInput, 24 | ReactiveFormsModule, 25 | ], 26 | }) 27 | export default class DialogRenameTaskComponent { 28 | private state = inject(AppStore); 29 | form = new FormGroup({ 30 | value: new FormControl(null, [Validators.required]), 31 | }); 32 | private assignValues = effect(() => { 33 | this.form.reset({ value: this.state.dialogTask()?.name }); 34 | }); 35 | onSubmit() { 36 | const { value } = this.form.value; 37 | if (this.form.valid && typeof value === 'string') { 38 | this.state.renameTask(value); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/app/button-task-actions/button-task-actions.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; 2 | import { MatIconButton } from '@angular/material/button'; 3 | import { MatIcon } from '@angular/material/icon'; 4 | import { MatMenu, MatMenuContent, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; 5 | import { RouterLink } from '@angular/router'; 6 | import { Task, TaskState } from '@app/domain/task'; 7 | import { TaskStatePipe } from '@app/pipes/task-state'; 8 | import { TaskStateIconPipe } from '@app/pipes/task-state-icon'; 9 | import { DialogLinkDirective } from '@app/providers/routed-dialogs'; 10 | import { AppStore } from '@app/providers/state'; 11 | 12 | @Component({ 13 | templateUrl: './button-task-actions.html', 14 | styleUrls: ['./button-task-actions.scss'], 15 | selector: 'button-task-actions', 16 | changeDetection: ChangeDetectionStrategy.OnPush, 17 | imports: [ 18 | MatMenu, 19 | MatMenuTrigger, 20 | MatMenuContent, 21 | MatMenuItem, 22 | MatIconButton, 23 | MatIcon, 24 | TaskStateIconPipe, 25 | TaskStatePipe, 26 | RouterLink, 27 | DialogLinkDirective, 28 | ], 29 | }) 30 | export class ButtonTaskActionsComponent { 31 | private store = inject(AppStore); 32 | task = input(); 33 | taskState = TaskState; 34 | 35 | deleteTask() { 36 | const task = this.task(); 37 | if (task) this.store.deleteTask(task.id); 38 | } 39 | changeTaskState(state: TaskState) { 40 | const task = this.task(); 41 | if (task) this.store.updateTaskState(task.id, state); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/app/button-theme-switcher/button-theme-switcher.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; 2 | import { MatIcon } from '@angular/material/icon'; 3 | import { MatListItem } from '@angular/material/list'; 4 | import { MatMenuModule } from '@angular/material/menu'; 5 | import { MatTooltip } from '@angular/material/tooltip'; 6 | import { AppStore, Theme } from '@app/providers/state'; 7 | import { deepEquals } from '@app/utils/assert'; 8 | 9 | type ThemeOption = { theme: Theme; label: string; icon: string }; 10 | 11 | @Component({ 12 | selector: 'button-theme-switcher', 13 | templateUrl: './button-theme-switcher.html', 14 | styleUrl: './button-theme-switcher.scss', 15 | changeDetection: ChangeDetectionStrategy.OnPush, 16 | imports: [MatTooltip, MatIcon, MatListItem, MatMenuModule], 17 | }) 18 | export class ButtonThemeSwitcherComponent { 19 | public store = inject(AppStore); 20 | public options: ThemeOption[] = [ 21 | { theme: { mode: 'manual', variant: 'light' }, label: 'Light', icon: 'light_mode' }, 22 | { theme: { mode: 'manual', variant: 'dark' }, label: 'Dark', icon: 'dark_mode' }, 23 | { 24 | theme: { mode: 'auto', variant: 'light' }, 25 | label: 'System', 26 | icon: 'brightness_medium', 27 | }, 28 | ]; 29 | public currentOption = computed(() => { 30 | const theme = this.store.theme(); 31 | return this.options.find((option) => 32 | theme?.mode === 'manual' ? deepEquals(option.theme, theme) : option.theme.mode === 'auto', 33 | ); 34 | }); 35 | public currentThemeTooltip = computed(() => `Current theme: ${this.currentOption()?.label.toLowerCase()}`); 36 | } 37 | -------------------------------------------------------------------------------- /e2e/page-objects/dialog-edit-session.ts: -------------------------------------------------------------------------------- 1 | import { Selector, t } from 'testcafe'; 2 | import { e2e } from '../utils'; 3 | 4 | export const dialogEditSession = { 5 | buttonStartDatepickerToggle: e2e('dialog-edit-session__button-start-datepicker-toggle'), 6 | buttonEndDatepickerToggle: e2e('dialog-edit-session__button-end-datepicker-toggle'), 7 | inputStart: e2e('dialog-edit-session__input-start'), 8 | buttonResetStart: Selector('dialog-edit-session button-reset-input-control button').nth(0), 9 | validationErrorStart: e2e('dialog-edit-session__start-validation-error'), 10 | inputEnd: e2e('dialog-edit-session__input-end'), 11 | buttonResetEnd: Selector('dialog-edit-session button-reset-input-control button').nth(1), 12 | buttonSubmit: e2e('dialog-edit-session__button-submit'), 13 | /** 14 | * Example: "2017-06-01T08:30" 15 | */ 16 | async setStart(value: string) { 17 | const input = this.inputStart; 18 | await t.click(this.buttonResetStart); 19 | await t.eval( 20 | () => { 21 | const el = (input as any)() as HTMLInputElement; 22 | el.value = value; 23 | el.dispatchEvent(new Event('input')); 24 | }, 25 | { dependencies: { input, value } }, 26 | ); 27 | }, 28 | /** 29 | * Example: "2017-06-01T08:30" 30 | */ 31 | async setEnd(value: string) { 32 | const input = this.inputEnd; 33 | if (await this.buttonResetEnd.exists) { 34 | await t.click(this.buttonResetEnd); 35 | } 36 | await t.eval( 37 | () => { 38 | const el = (input as any)() as HTMLInputElement; 39 | el.value = value; 40 | el.dispatchEvent(new Event('input')); 41 | }, 42 | { dependencies: { input, value } }, 43 | ); 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /src/app/directives/datetime-local.ts: -------------------------------------------------------------------------------- 1 | import { Directive, ElementRef, forwardRef, inject } from '@angular/core'; 2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; 3 | 4 | const utcDateToLocalDate = (date: Date): Date => { 5 | const utcDate = new Date(date); 6 | return new Date(utcDate.getTime() - utcDate.getTimezoneOffset() * 60000); 7 | }; 8 | 9 | const toDateTimeLocalValue = (date: Date): string => { 10 | return date.toISOString().replace('Z', ''); 11 | }; 12 | 13 | @Directive({ 14 | selector: 'input[type="datetime-local"]', 15 | host: { 16 | '(input)': 'this._handleInput($event.target.value)', 17 | '(blur)': 'onTouched()', 18 | }, 19 | providers: [ 20 | { 21 | provide: NG_VALUE_ACCESSOR, 22 | useExisting: forwardRef(() => DatetimeLocalDirective), 23 | multi: true, 24 | }, 25 | ], 26 | }) 27 | export class DatetimeLocalDirective implements ControlValueAccessor { 28 | private _elementRef = inject>(ElementRef); 29 | 30 | writeValue(value: unknown): void { 31 | const normalizedValue = value instanceof Date ? toDateTimeLocalValue(utcDateToLocalDate(value)) : null; 32 | this._elementRef.nativeElement.value = normalizedValue ?? ''; 33 | } 34 | 35 | _handleInput(value: string | undefined): void { 36 | this.onChange(value?.length ? new Date(value) : value); 37 | } 38 | 39 | onChange = (_: any) => {}; 40 | 41 | onTouched = () => {}; 42 | 43 | registerOnTouched(fn: () => void): void { 44 | this.onTouched = fn; 45 | } 46 | 47 | registerOnChange(fn: (_: any) => {}): void { 48 | this.onChange = fn; 49 | } 50 | 51 | setDisabledState(isDisabled: boolean): void { 52 | this._elementRef.nativeElement.disabled = isDisabled; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/app/screen-task/button-session-actions/button-session-actions.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; 2 | import { MatIconButton } from '@angular/material/button'; 3 | import { MatIcon } from '@angular/material/icon'; 4 | import { MatMenu, MatMenuContent, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; 5 | import { RouterLink } from '@angular/router'; 6 | import { encodeFilterParams } from '@app/domain/router'; 7 | import { Session, Task, getSessionId, isSessionRunning } from '@app/domain/task'; 8 | import { DialogLinkDirective } from '@app/providers/routed-dialogs'; 9 | import { AppStore } from '@app/providers/state'; 10 | 11 | @Component({ 12 | templateUrl: './button-session-actions.html', 13 | styleUrls: ['./button-session-actions.scss'], 14 | selector: 'button-session-actions', 15 | changeDetection: ChangeDetectionStrategy.OnPush, 16 | imports: [ 17 | MatMenu, 18 | MatMenuContent, 19 | MatMenuItem, 20 | MatMenuTrigger, 21 | MatIconButton, 22 | MatIcon, 23 | RouterLink, 24 | DialogLinkDirective, 25 | ], 26 | }) 27 | export class ButtonSessionActionsComponent { 28 | private store = inject(AppStore); 29 | 30 | public task = input.required(); 31 | public session = input.required(); 32 | public sessionIndex = computed(() => this.task().sessions.indexOf(this.session())); 33 | 34 | public isSplitDisabled = computed(() => isSessionRunning(this.session())); 35 | 36 | remove() { 37 | this.store.deleteSession(this.task().id, getSessionId(this.session())); 38 | } 39 | get skipBeforeParams() { 40 | return encodeFilterParams({ from: new Date(this.session().start) }); 41 | } 42 | get skipAfterParams() { 43 | return encodeFilterParams({ to: new Date(this.session()?.end ?? new Date()) }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner2-direct.svg)](https://vshymanskyy.github.io/StandWithUkraine) 2 | [![Support Ukraine Badge](https://bit.ly/support-ukraine-now)](https://github.com/support-ukraine/support-ukraine) 3 | 4 |
5 | 6 | # Timer 7 | 8 | [Timer](https://klaster1.github.io/timer-5) is a simple time tracking tool. Create a task, click "Start"/"Stop", see how much the task took, repeat. 9 | 10 | All the data is kept in the `localStorage`, it does not leave your device, ever. To manage the data, use "Export" (to JSON) and "Import" features. A test data set can be found [here](https://gist.githubusercontent.com/Klaster1/a456beaf5384924fa960790160286d8a/raw/179c67dad43c66d48fb7c766f1e19b58df4c64cf/games.json). 11 | 12 | 13 | 14 | Originally created back in 2011 to track my time spent on video games, it eventually turned into sorts of Todo MVC, the project gets rewritten from scracth every several years as a learning excercise. This is the fifth iteration. Some of previous Timer iterations: 15 | 16 | - https://github.com/Klaster1/timer 17 | - https://github.com/Klaster1/timer2 18 | 19 | # Development 20 | 21 | You will need Node.js 22 or newer. 22 | 23 | ## Running the app for development locally 24 | 25 | 1. Install packages with `npm i`. 26 | 2. Run `npx ng serve`. The app will start at [http://localhost:4200](http://localhost:4200). 27 | 3. Change the Angular app, the web page will live reload. 28 | 29 | ## Building the app 30 | 31 | 1. Install packages with `npm i` if you haven't already. 32 | 2. Run `npm run build`. Static files will be placed in the `dist` directory. 33 | -------------------------------------------------------------------------------- /e2e/fixtures/filters.ts: -------------------------------------------------------------------------------- 1 | import { screenTask } from '../page-objects/screen-task'; 2 | import { screenTasks } from '../page-objects/screen-tasks'; 3 | import { getLocationSearch, reload } from '../utils'; 4 | import { VISUAL_REGRESSION_OK, comparePageScreenshot } from '../visual-regression'; 5 | 6 | fixture('Filters'); 7 | 8 | test('Name', async (t) => { 9 | // Have some tasks with uppercase names 10 | await screenTasks.addTask('GAME'); 11 | await screenTasks.addTask('FOO'); 12 | await screenTasks.addTask('BAR'); 13 | await screenTasks.addTask('BAZ'); 14 | // Send "ctrl+f" keys 15 | await t.pressKey('ctrl+f'); 16 | 17 | await t.expect(await comparePageScreenshot('empty filter')).eql(VISUAL_REGRESSION_OK); 18 | // Fill in the "Name filter", in lowercase 19 | await t.typeText(screenTasks.filter.name.input, 'game', { paste: true }); 20 | // Assert current task is still opened 21 | await t.expect(screenTask.name.textContent).eql('BAZ'); 22 | // Assert the displayed tasks match the filter 23 | await t.expect(screenTasks.taskItem.count).eql(1); 24 | await t.expect(screenTasks.taskName.nth(0).textContent).eql('GAME'); 25 | await t.expect(await comparePageScreenshot('name filled')).eql(VISUAL_REGRESSION_OK); 26 | // Assert the URL ends with "?search=${value}" 27 | await t.expect(getLocationSearch()).eql('?search=game'); 28 | // Reload the page, assert the filter is still open and applied 29 | await reload(); 30 | await t.expect(screenTasks.taskItem.count).eql(1); 31 | await t.expect(screenTasks.taskName.nth(0).textContent).eql('GAME'); 32 | // Clear the filter value with "Clear" button in the form control 33 | await t.click(screenTasks.filter.name.buttonClear); 34 | await t.expect(await comparePageScreenshot('cleared')).eql(VISUAL_REGRESSION_OK); 35 | // Assert all tasks are displayed 36 | await t.expect(screenTasks.taskItem.count).eql(4); 37 | await t.expect(getLocationSearch()).eql(''); 38 | }); 39 | -------------------------------------------------------------------------------- /src/app/domain/hotkeys.ts: -------------------------------------------------------------------------------- 1 | import { ExtendedKeyboardEvent, Hotkey } from 'angular2-hotkeys'; 2 | 3 | const KEY_NEXT_EN = 'j'; 4 | const KEY_NEXT_RU = 'о'; 5 | export const KEYS_NEXT = [KEY_NEXT_EN, KEY_NEXT_RU]; 6 | 7 | const KEY_PREV_EN = 'k'; 8 | const KEY_PREV_RU = 'л'; 9 | export const KEYS_PREV = [KEY_PREV_EN, KEY_PREV_RU]; 10 | 11 | const KEY_ADD_EN = 'a'; 12 | const KEY_ADD_RU = 'ф'; 13 | export const KEYS_ADD = [KEY_ADD_EN, KEY_ADD_RU]; 14 | 15 | const KEY_SEARCH_EN = 'ctrl+f'; 16 | const KEY_SEARCH_RU = 'ctrl+а'; 17 | export const KEYS_SEARCH = [KEY_SEARCH_EN, KEY_SEARCH_RU]; 18 | 19 | const KEY_GO_ALL_EN = 'g t'; 20 | const KEY_GO_ALL_RU = 'п е'; 21 | export const KEYS_GO_ALL = [KEY_GO_ALL_EN, KEY_GO_ALL_RU]; 22 | 23 | const KEY_GO_ACTIVE_EN = 'g a'; 24 | const KEY_GO_ACTIVE_RU = 'п ф'; 25 | export const KEYS_GO_ACTIVE = [KEY_GO_ACTIVE_EN, KEY_GO_ACTIVE_RU]; 26 | 27 | const KEY_GO_FINISHED_EN = 'g f'; 28 | const KEY_GO_FINISHED_RU = 'п а'; 29 | export const KEYS_GO_FINISHED = [KEY_GO_FINISHED_EN, KEY_GO_FINISHED_RU]; 30 | 31 | const KEY_START_STOP_EN = 's'; 32 | const KEY_START_STOP_RU = 'ы'; 33 | export const KEYS_START_STOP = [KEY_START_STOP_EN, KEY_START_STOP_RU]; 34 | 35 | const KEY_MARK_FINISHED_EN = 'm f'; 36 | const KEY_MARK_FINISHED_RU = 'ь а'; 37 | export const KEYS_MARK_FINISHED = [KEY_MARK_FINISHED_EN, KEY_MARK_FINISHED_RU]; 38 | 39 | const KEY_MARK_ACTIVE_EN = 'm a'; 40 | const KEY_MARK_ACTIVE_RU = 'ь ф'; 41 | export const KEYS_MARK_ACTIVE = [KEY_MARK_ACTIVE_EN, KEY_MARK_ACTIVE_RU]; 42 | 43 | const KEY_RENAME_EN = 'r t'; 44 | const KEY_RENAME_RU = 'к е'; 45 | const KEY_RENAME_ALT = 'f2'; 46 | export const KEYS_RENAME = [KEY_RENAME_EN, KEY_RENAME_RU, KEY_RENAME_ALT]; 47 | 48 | const KEY_DELETE_TASK_EN = 'd t'; 49 | const KEY_DELETE_TASK_RU = 'в е'; 50 | export const KEYS_DELETE_TASK = [KEY_DELETE_TASK_EN, KEY_DELETE_TASK_RU]; 51 | 52 | export const hotkey = (keys: string | string[], description: string, cb: (e: ExtendedKeyboardEvent) => any) => 53 | new Hotkey(keys, (e) => (setTimeout(() => cb(e), 0), e), [], description); 54 | -------------------------------------------------------------------------------- /src/app/button-task-actions/button-task-actions.html: -------------------------------------------------------------------------------- 1 | @if (task(); as task) { 2 | 11 | 12 | 13 | 17 | 23 | Rename 24 | 25 | 28 | 29 | 30 | 31 | 40 | 49 | 58 | 59 | } 60 | -------------------------------------------------------------------------------- /.github/workflows/cicd.yaml: -------------------------------------------------------------------------------- 1 | name: CI/CD Pipeline 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build-test-deploy: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Adding Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 22.14.0 22 | cache: 'npm' 23 | 24 | - name: Install Dependencies 25 | run: npm install 26 | 27 | - name: Cache Angular build output 28 | id: cache-ng-build 29 | uses: actions/cache@v4 30 | with: 31 | path: dist 32 | key: ng-build-${{ hashFiles('package-lock.json', 'src/**', 'angular.json', 'scripts/gh-pages-before-add.js') }} 33 | restore-keys: | 34 | ng-build-${{ hashFiles('package-lock.json', 'src/**', 'angular.json', 'scripts/gh-pages-before-add.js') }} 35 | 36 | - name: Build Application 37 | run: npm run build 38 | if: steps.cache-ng-build.outputs.cache-hit != 'true' 39 | 40 | - name: Run TestCafe tests in container 41 | run: | 42 | docker run -w /opt/tests -v ${{ github.workspace }}:/opt/tests timbru31/node-chrome:22 npx testcafe chrome:headless ./e2e/fixtures --app="npm run serve:ci" -c 4 43 | 44 | - name: Upload Artifacts 45 | uses: actions/upload-artifact@v4 46 | if: failure() 47 | with: 48 | path: | 49 | e2e/screenshots 50 | e2e/visual-regression-screenshots 51 | if-no-files-found: ignore 52 | retention-days: 1 53 | compression-level: 0 54 | overwrite: true 55 | 56 | - name: Deploy to Github Pages 57 | if: github.event_name == 'push' && github.ref == 'refs/heads/master' && success() 58 | env: 59 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 60 | run: | 61 | git remote set-url origin https://git:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git 62 | npm run deploy -- -u "github-actions-bot " 63 | -------------------------------------------------------------------------------- /src/app/domain/router.ts: -------------------------------------------------------------------------------- 1 | import { isValid } from 'date-fns/isValid'; 2 | import { parseISO } from 'date-fns/parseISO'; 3 | import { RouteTaskState, TaskId, TaskState } from './task'; 4 | 5 | type EncodedParams = Partial>; 6 | 7 | export type FilterMatrixParams = Partial<{ 8 | search: string; 9 | from: Date; 10 | to: Date; 11 | durationSort: 'longestFirst' | 'shortestFirst'; 12 | }>; 13 | export type RouteFragmentParams = Partial<{ taskId: TaskId; state: RouteTaskState }>; 14 | 15 | export type Decoder = (value: any) => Decoded; 16 | export type Encoder = (value: Decoded) => EncodedParams; 17 | 18 | export const decodeFilterMatrixParams: Decoder = (value) => { 19 | const result = {} as FilterMatrixParams; 20 | if (value.search) result.search = decodeURIComponent(value.search); 21 | if (value.from) { 22 | const parsedFrom = parseISO(decodeURIComponent(value.from)); 23 | if (isValid(parsedFrom)) result.from = parsedFrom; 24 | } 25 | if (value.to) { 26 | const parsedTo = parseISO(decodeURIComponent(value.to)); 27 | if (isValid(parsedTo)) result.to = parsedTo; 28 | } 29 | if (value.durationSort === 'longestFirst' || value.durationSort === 'shortestFirst') 30 | result.durationSort = value.durationSort; 31 | return result; 32 | }; 33 | 34 | export const decodeRouteParams: Decoder = (value) => { 35 | const result = {} as RouteFragmentParams; 36 | if (value.taskId) result.taskId = value.taskId; 37 | if (value.state) { 38 | for (const i of [TaskState.finished, TaskState.active, TaskState.dropped, 'all'] as const) { 39 | if (value.state === i) result.state = i; 40 | } 41 | } 42 | return result; 43 | }; 44 | 45 | export const encodeFilterParams: Encoder = (value) => { 46 | const result = {} as Record; 47 | if (value.search) result.search = encodeURIComponent(value.search); 48 | if (value.from) result.from = encodeURIComponent(value.from.toISOString()); 49 | if (value.to) result.to = encodeURIComponent(value.to?.toISOString()); 50 | if (value.durationSort) result.durationSort = value.durationSort; 51 | return result; 52 | }; 53 | -------------------------------------------------------------------------------- /src/app/dialog-edit-session/dialog-edit-session.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/core'; 2 | import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; 3 | import { MatButton } from '@angular/material/button'; 4 | import { MatDialogActions, MatDialogClose, MatDialogContent, MatDialogTitle } from '@angular/material/dialog'; 5 | import { MatError, MatFormField, MatLabel, MatSuffix } from '@angular/material/form-field'; 6 | import { MatInput } from '@angular/material/input'; 7 | import { ButtonResetInputComponent } from '@app/directives/button-reset-input'; 8 | import { DatetimeLocalDirective } from '@app/directives/datetime-local'; 9 | import { AppStore } from '@app/providers/state'; 10 | import { option } from '@app/utils/assert'; 11 | 12 | @Component({ 13 | selector: 'dialog-edit-session', 14 | templateUrl: './dialog-edit-session.html', 15 | styleUrl: './dialog-edit-session.scss', 16 | changeDetection: ChangeDetectionStrategy.OnPush, 17 | imports: [ 18 | MatDialogTitle, 19 | MatDialogContent, 20 | MatDialogActions, 21 | MatDialogClose, 22 | MatButton, 23 | MatFormField, 24 | MatError, 25 | MatLabel, 26 | MatInput, 27 | MatSuffix, 28 | ReactiveFormsModule, 29 | DatetimeLocalDirective, 30 | ButtonResetInputComponent, 31 | ], 32 | }) 33 | export default class DialogEditSessionComponent { 34 | private state = inject(AppStore); 35 | 36 | form = new FormGroup<{ start: FormControl; end: FormControl }>({ 37 | start: new FormControl(null, { 38 | validators: [Validators.required], 39 | nonNullable: true, 40 | }), 41 | end: new FormControl(null), 42 | }); 43 | 44 | private assignValues = effect(() => { 45 | this.form.reset({ 46 | start: option(this.state.dialogSession()?.start).map((value) => new Date(value)), 47 | end: option(this.state.dialogSession()?.end).map((value) => new Date(value)), 48 | }); 49 | }); 50 | 51 | onSubmit() { 52 | const { start, end } = this.form.value; 53 | if (this.form.valid && start) { 54 | this.state.editSession({ 55 | start: start.valueOf(), 56 | end: end?.valueOf(), 57 | }); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/app/dialog-split-session/dialog-split-session.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | @for (session of sessions; track $index) { 12 | 13 | 16 | 19 | 22 | 23 | } 24 | 25 |
StartEndDuration
14 | {{ session.start | date: 'yyyy-MM-dd H:mm' }} 15 | 17 | {{ session.end | date: 'yyyy-MM-dd H:mm' }} 18 | 20 | 21 |
26 |
27 | 28 |

Split session

29 |
30 | 33 | 34 | 35 | 42 | 43 | 44 | 47 |
48 |
49 | 50 | 60 |
61 | -------------------------------------------------------------------------------- /src/app/screen-tasks/screen-tasks.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: contents; 3 | } 4 | .container { 5 | display: grid; 6 | grid-template-rows: 1fr; 7 | flex: 1; 8 | --fab-offset: 1rem; 9 | 10 | @media (min-width: 900px) { 11 | &:not(.task-opened) { 12 | grid-template-areas: 'left tasks right'; 13 | grid-template-columns: 1fr 50% 1fr; 14 | } 15 | &.task-opened { 16 | grid-template-areas: 'tasks task'; 17 | grid-template-columns: 1fr 1fr; 18 | } 19 | } 20 | @media (max-width: 900px) { 21 | grid-template-columns: [tasks-start task-start] 1fr; 22 | grid-template-rows: [tasks-start task-start] 1fr; 23 | 24 | .task { 25 | z-index: 1; 26 | } 27 | } 28 | } 29 | mat-icon.running { 30 | background: var(--timer-logo-color) !important; 31 | color: var(--mat-app-background-color) !important; 32 | border-radius: 100%; 33 | } 34 | [matlistitemtitle] { 35 | display: grid; 36 | grid-template-columns: 1fr auto; 37 | } 38 | .name { 39 | overflow: hidden; 40 | text-overflow: ellipsis; 41 | } 42 | .tasks { 43 | grid-area: tasks; 44 | position: relative; 45 | max-height: 100vh; 46 | display: flex; 47 | flex-direction: column; 48 | } 49 | .task { 50 | grid-area: task; 51 | display: flex; 52 | flex-direction: column; 53 | overflow: hidden; 54 | } 55 | mat-toolbar { 56 | flex: 0 0 auto; 57 | } 58 | [mat-fab] { 59 | position: absolute; 60 | bottom: var(--fab-offset); 61 | right: var(--fab-offset); 62 | left: 50%; 63 | } 64 | h1 { 65 | width: 100%; 66 | } 67 | .search-form, 68 | h1 { 69 | display: flex; 70 | align-items: center; 71 | } 72 | .range-filter { 73 | margin-left: auto; 74 | flex: 0 0 42.5px; 75 | } 76 | .search-form { 77 | margin-left: auto; 78 | } 79 | .state-duration { 80 | position: relative; 81 | top: 1px; 82 | } 83 | .filter-trigger-icon { 84 | position: relative; 85 | top: 3px; 86 | } 87 | cdk-virtual-scroll-viewport { 88 | flex: 1; 89 | } 90 | empty-state { 91 | margin: auto; 92 | } 93 | .cdk-drop-list-dragging { 94 | position: relative; 95 | 96 | &:after { 97 | display: block; 98 | content: ''; 99 | position: absolute; 100 | top: 0; 101 | right: 0; 102 | bottom: 0; 103 | left: 0; 104 | border: dotted 2px grey; 105 | } 106 | } 107 | .item-content { 108 | display: flex; 109 | flex-direction: row; 110 | align-items: center; 111 | } 112 | mat-nav-list { 113 | padding: 0; 114 | } 115 | .item-title { 116 | position: relative; 117 | top: 1px; 118 | } 119 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | @use 'pkg:@angular/material' as mat; 2 | @use 'pkg:@angular/material/core/theming/inspection' as inspection; 3 | @use 'pkg:@angular/material/core/theming/all-theme' as all-theme; 4 | @use 'pkg:@angular/material/core/theming/theming' as theming; 5 | @use 'm3-theme' as theme; 6 | 7 | a { 8 | color: var(--primary-darker-color); 9 | text-decoration: none; 10 | } 11 | 12 | @include mat.elevation-classes(); 13 | @include mat.app-background(); 14 | 15 | @mixin used-components-theme($theme) { 16 | $dedupe-key: 'angular-material-theme'; 17 | @include theming.private-check-duplicate-theme-styles($theme, $dedupe-key) { 18 | @include mat.elevation-classes(); 19 | @include mat.app-background(); 20 | @include mat.tooltip-theme($theme); 21 | @include mat.form-field-theme($theme); 22 | @include mat.input-theme($theme); 23 | @include mat.select-theme($theme); 24 | @include mat.dialog-theme($theme); 25 | @include mat.menu-theme($theme); 26 | @include mat.list-theme($theme); 27 | @include mat.button-theme($theme); 28 | @include mat.icon-button-theme($theme); 29 | @include mat.fab-theme($theme); 30 | @include mat.icon-theme($theme); 31 | @include mat.sidenav-theme($theme); 32 | @include mat.toolbar-theme($theme); 33 | @include mat.table-theme($theme); 34 | @include mat.snack-bar-theme($theme); 35 | @include mat.slider-theme($theme); 36 | } 37 | } 38 | 39 | @mixin used-components-colors($theme) { 40 | @include all-theme.all-component-themes(inspection.theme-remove($theme, base, typography, density)); 41 | } 42 | 43 | @mixin overrides() { 44 | & { 45 | --mat-sidenav-container-width: 65px; 46 | --mat-icon-color: var(--mat-form-field-leading-icon-color); 47 | --mat-sidenav-container-shape: 0; 48 | --mat-list-active-indicator-shape: 0; 49 | --mat-toolbar-container-background-color: var(--mat-tooltip-supporting-text-color); 50 | --mat-sidenav-container-background-color: var(--mat-tooltip-supporting-text-color); 51 | --mat-table-row-item-outline-color: var(--mat-sidenav-container-background-color); 52 | } 53 | } 54 | 55 | :root { 56 | @include used-components-theme(theme.$light-theme); 57 | @include overrides(); 58 | 59 | & { 60 | --scrollbar-active-color: var(--mat-option-selected-state-layer-color); 61 | --scrollbar-radius: 4px; 62 | --timer-logo-color: #82bd17; 63 | } 64 | } 65 | :root:has(.theme-dark) { 66 | @include used-components-colors(theme.$dark-theme); 67 | @include overrides(); 68 | 69 | & { 70 | color-scheme: dark; 71 | } 72 | } 73 | 74 | html, 75 | body { 76 | height: 100%; 77 | } 78 | body { 79 | padding: 0; 80 | margin: 0; 81 | font-family: Roboto, 'Helvetica Neue', sans-serif; 82 | min-height: 100vh; 83 | display: flex; 84 | } 85 | 86 | .ng-scroll-content { 87 | max-width: 100%; 88 | } 89 | 90 | .list-centered-icons { 91 | .mat-mdc-list-item { 92 | text-align: center; 93 | } 94 | mat-icon { 95 | vertical-align: middle; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/app/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 13 | Timer 14 | 15 | 16 | 17 | 27 | 28 | 29 | 40 | 51 | 52 | 53 | 61 | 62 | 66 | Export 74 | 75 | 76 | 77 |
78 |
79 | 80 | 81 | 82 |
83 | -------------------------------------------------------------------------------- /e2e/fixtures/general.ts: -------------------------------------------------------------------------------- 1 | import { Selector } from 'testcafe'; 2 | import { app } from '../page-objects/app'; 3 | import { dialogHotkeyCheatsheet } from '../page-objects/dialog-hotkeys-cheatsheet'; 4 | import { screenTasks } from '../page-objects/screen-tasks'; 5 | import { reload } from '../utils'; 6 | import { VISUAL_REGRESSION_OK, comparePageScreenshot } from '../visual-regression'; 7 | 8 | fixture('General'); 9 | 10 | test('Hotkey help', async (t) => { 11 | // Send "shift+?", assert the hotkey cheatsheet is opened 12 | await t.pressKey('shift+?'); 13 | // Assert it displays all hotkeys, in english only 14 | await t.expect(dialogHotkeyCheatsheet.descriptions.count).eql(7); 15 | await t.expect(await comparePageScreenshot('only tasks')).eql(VISUAL_REGRESSION_OK); 16 | // Assert "Close" closes the dialog 17 | await t.click(dialogHotkeyCheatsheet.buttonDismiss); 18 | await t.expect(dialogHotkeyCheatsheet.dialog.exists).notOk(); 19 | // Open a session, assert session hotkeys are displayed 20 | await screenTasks.addTask('Keys'); 21 | await t.pressKey('shift+?'); 22 | await t.expect(dialogHotkeyCheatsheet.descriptions.count).eql(12); 23 | await t.expect(await comparePageScreenshot('tasks and task')).eql(VISUAL_REGRESSION_OK); 24 | // Open the dialog again, assert "Esc" closes the dialog 25 | await t.pressKey('esc'); 26 | await t.expect(dialogHotkeyCheatsheet.dialog.exists).notOk(); 27 | }); 28 | 29 | test('Theme switcher', async (t) => { 30 | // Switch to dark theme 31 | await t.click(app.buttonSwitchTheme); 32 | await t.expect(await comparePageScreenshot('themes menu')).eql(VISUAL_REGRESSION_OK); 33 | await t.click(app.buttonTheme.withText('Dark')); 34 | // Assert the theme is applied 35 | await t.expect(await comparePageScreenshot('dark theme', { theme: 'preserve' })).eql(VISUAL_REGRESSION_OK); 36 | // Switch to light theme 37 | await t.click(app.buttonSwitchTheme); 38 | await t.click(app.buttonTheme.withText('Light')); 39 | // Assert the theme is applied 40 | await t.expect(await comparePageScreenshot('light theme', { theme: 'preserve' })).eql(VISUAL_REGRESSION_OK); 41 | // Switch to system theme 42 | await t.click(app.buttonSwitchTheme); 43 | await t.click(app.buttonTheme.withText('System')); 44 | // Override the system theme to dark 45 | const client = await t.getCurrentCDPSession(); 46 | await client.Emulation.setEmulatedMedia({ 47 | media: 'screen', 48 | features: [{ name: 'prefers-color-scheme', value: 'dark' }], 49 | }); 50 | // Assert the theme is applied 51 | await t.expect(await comparePageScreenshot('system theme dark', { theme: 'preserve' })).eql(VISUAL_REGRESSION_OK); 52 | // Override the system theme to light 53 | await client.Emulation.setEmulatedMedia({ 54 | media: 'screen', 55 | features: [{ name: 'prefers-color-scheme', value: 'light' }], 56 | }); 57 | // Assert the theme is applied 58 | await t.click('body', { offsetX: 0, offsetY: 0 }); // Close the tooltip 59 | await t.expect(await comparePageScreenshot('system theme light', { theme: 'preserve' })).eql(VISUAL_REGRESSION_OK); 60 | // Reload the page 61 | await reload(); 62 | // Assert the theme is preserved 63 | await t.expect(Selector('body').hasClass('theme-dark')).notOk(); 64 | }); 65 | -------------------------------------------------------------------------------- /src/app/screen-tasks/tasks-filter/tasks-filter.html: -------------------------------------------------------------------------------- 1 |
8 | 9 | Name 10 | 20 | 25 | 26 | 27 | Sort by duration 28 | 29 | Default 30 | Longest first 31 | Shortest first 32 | 33 | 34 | 35 | From 36 | 37 | 42 | 43 | 44 | To 45 | 46 | 51 | 52 | 55 |
56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | @if (dataRange(); as dataRange) { @defer { 68 | 75 | } } @else { 76 | 77 | } 78 | 79 | 80 | 85 | 86 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "timer": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss" 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "src", 15 | "prefix": "app", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular/build:application", 19 | "options": { 20 | "outputPath": { 21 | "base": "dist/timer-5", 22 | "browser": "" 23 | }, 24 | "index": "src/index.html", 25 | "browser": "src/main.ts", 26 | "tsConfig": "tsconfig.app.json", 27 | "inlineStyleLanguage": "scss", 28 | "assets": ["src/favicon.svg", "src/assets", "src/manifest.webmanifest"], 29 | "styles": ["src/styles.scss"], 30 | "scripts": [], 31 | "serviceWorker": "ngsw-config.json", 32 | "allowedCommonJsDependencies": ["mousetrap"], 33 | "sourceMap": true 34 | }, 35 | "configurations": { 36 | "production": { 37 | "outputHashing": "all", 38 | "serviceWorker": "ngsw-config.json", 39 | "baseHref": "/timer-5/" 40 | }, 41 | "development": { 42 | "optimization": false, 43 | "extractLicenses": false, 44 | "sourceMap": true 45 | } 46 | }, 47 | "defaultConfiguration": "production" 48 | }, 49 | "serve": { 50 | "builder": "@angular/build:dev-server", 51 | "configurations": { 52 | "production": { 53 | "buildTarget": "timer:build:production" 54 | }, 55 | "development": { 56 | "buildTarget": "timer:build:development" 57 | } 58 | }, 59 | "defaultConfiguration": "development", 60 | "options": { 61 | "allowedHosts": ["localhost", "0.0.0.0", "host.docker.internal"], 62 | "host": "0.0.0.0" 63 | } 64 | }, 65 | "e2e": { 66 | "builder": "angular-testcafe:testcafe", 67 | "options": { 68 | "src": [] 69 | } 70 | } 71 | } 72 | } 73 | }, 74 | "cli": { 75 | "analytics": "bad8acbf-7c45-4e20-8aba-8bdd3deb1175" 76 | }, 77 | "schematics": { 78 | "@schematics/angular:component": { 79 | "type": "component" 80 | }, 81 | "@schematics/angular:directive": { 82 | "type": "directive" 83 | }, 84 | "@schematics/angular:service": { 85 | "type": "service" 86 | }, 87 | "@schematics/angular:guard": { 88 | "typeSeparator": "." 89 | }, 90 | "@schematics/angular:interceptor": { 91 | "typeSeparator": "." 92 | }, 93 | "@schematics/angular:module": { 94 | "typeSeparator": "." 95 | }, 96 | "@schematics/angular:pipe": { 97 | "typeSeparator": "." 98 | }, 99 | "@schematics/angular:resolver": { 100 | "typeSeparator": "." 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "timer", 3 | "version": "5.0.0", 4 | "type": "module", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/Klaster1/timer-5.git" 8 | }, 9 | "author": { 10 | "name": "Klaster_1", 11 | "email": "klaster1@gmail.com" 12 | }, 13 | "license": "MIT", 14 | "homepage": "https://klaster1.github.io/timer-5", 15 | "scripts": { 16 | "ng": "ng", 17 | "start": "ng serve", 18 | "build": "ng build --configuration production && node ./scripts/gh-pages-before-add.js", 19 | "serve:ci": "npx local-web-server --port 4200 --directory ./dist --spa timer-5/404.html", 20 | "docker:build": "docker build -t timer-5-tests-ubuntu .", 21 | "e2e": "testcafe chromium:headless -c 5", 22 | "e2e-vr-create": "$env:VR_MODE=\"create\"; npm run e2e", 23 | "e2e:ci": "testcafe chrome:headless -c 4 -q --app=\"npm run serve:ci\" --base-url=\"http://localhost:4200/timer-5/\"", 24 | "e2e:docker": "docker run --rm -w /opt/tests -v %INIT_CWD%:/opt/tests -v /opt/tests/node_modules -it timer-5-tests-ubuntu npx testcafe chrome:headless --base-url=http://host.docker.internal:4200 -c 5 --video /opt/tests/e2e/videos --video-options failedOnly=true", 25 | "e2e-vr-create:docker": "docker run --rm -e VR_MODE=create -w /opt/tests -v %INIT_CWD%:/opt/tests -v /opt/tests/node_modules -it timer-5-tests-ubuntu npx testcafe chrome:headless --base-url=http://host.docker.internal:4200 -c 5 --video /opt/tests/e2e/videos --video-options failedOnly=true", 26 | "e2e:all": "npm run e2e && npm run e2e:docker", 27 | "deploy": "gh-pages --nojekyll --dotfiles --dist dist/timer-5", 28 | "generate-screenshots": "npx testcafe \"chromium:headless --force-device-scale-factor=2\" .\\scripts\\generate-screenshots.ts" 29 | }, 30 | "private": true, 31 | "dependencies": { 32 | "@angular/animations": "^20.0.3", 33 | "@angular/cdk": "^20.0.3", 34 | "@angular/common": "^20.0.3", 35 | "@angular/compiler": "^20.0.3", 36 | "@angular/core": "^20.0.3", 37 | "@angular/forms": "^20.0.3", 38 | "@angular/material": "^20.0.3", 39 | "@angular/platform-browser": "^20.0.3", 40 | "@angular/platform-browser-dynamic": "^20.0.3", 41 | "@angular/router": "^20.0.3", 42 | "@angular/service-worker": "^20.0.3", 43 | "@ngrx/signals": "19.2.1", 44 | "angular2-hotkeys": "^16.0.1", 45 | "date-fns": "^4.1.0", 46 | "immer": "^10.1.1", 47 | "nanoid": "^5.1.5", 48 | "rxjs": "^7.8.2", 49 | "tslib": "^2.8.1", 50 | "uplot": "^1.6.32" 51 | }, 52 | "devDependencies": { 53 | "@angular/build": "^20.0.2", 54 | "@angular/cli": "^20.0.2", 55 | "@angular/compiler-cli": "^20.0.3", 56 | "@angular/language-service": "^20.0.3", 57 | "@ffmpeg-installer/ffmpeg": "^1.1.0", 58 | "@klaster_1/testcafe-repeat-test": "^1.0.2", 59 | "@napi-rs/canvas": "^0.1.71", 60 | "@types/node": "^24.0.1", 61 | "angular-testcafe": "^4.0.0", 62 | "gh-pages": "^6.3.0", 63 | "local-web-server": "^5.4.0", 64 | "looks-same": "^9.0.1", 65 | "prettier": "^3.5.3", 66 | "prettier-plugin-organize-imports": "^4.1.0", 67 | "testcafe": "^3.7.2", 68 | "testcafe-reporter-spec-plus": "^2.3.5", 69 | "typescript": "^5.8.3" 70 | }, 71 | "engines": { 72 | "node": ">=22.14.0" 73 | }, 74 | "overrides": { 75 | "@ngrx/signals": { 76 | "@angular/common": "$@angular/common", 77 | "@angular/core": "$@angular/core" 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/app/dialog-split-session/dialog-split-session.ts: -------------------------------------------------------------------------------- 1 | import { DatePipe, NgTemplateOutlet } from '@angular/common'; 2 | import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core'; 3 | import { MatButton } from '@angular/material/button'; 4 | import { 5 | MatDialogActions, 6 | MatDialogClose, 7 | MatDialogConfig, 8 | MatDialogContent, 9 | MatDialogTitle, 10 | } from '@angular/material/dialog'; 11 | import { MatSlider, MatSliderThumb } from '@angular/material/slider'; 12 | import { DurationComponent } from '@app/directives/duration'; 13 | import { TemplateContextTypeDirective } from '@app/directives/template-context-type'; 14 | import { Milliseconds } from '@app/domain/date-time'; 15 | import { Session, sessionDuration } from '@app/domain/task'; 16 | import { MapPipe } from '@app/pipes/map'; 17 | import { AppStore } from '@app/providers/state'; 18 | 19 | export interface DialogSplitSessionData { 20 | start: number; 21 | end: number; 22 | } 23 | 24 | @Component({ 25 | selector: 'dialog-split-session', 26 | templateUrl: './dialog-split-session.html', 27 | styleUrls: ['./dialog-split-session.scss', '../screen-task/mat-table.scss'], 28 | changeDetection: ChangeDetectionStrategy.OnPush, 29 | imports: [ 30 | MatDialogTitle, 31 | MatDialogContent, 32 | MatDialogActions, 33 | MatDialogClose, 34 | MatButton, 35 | MatSlider, 36 | MatSliderThumb, 37 | DatePipe, 38 | DurationComponent, 39 | MapPipe, 40 | TemplateContextTypeDirective, 41 | NgTemplateOutlet, 42 | ], 43 | }) 44 | export default class DialogSplitSessionComponent { 45 | static dialogConfig: MatDialogConfig = { 46 | autoFocus: false, 47 | }; 48 | 49 | public readonly state = inject(AppStore); 50 | 51 | public value = signal(null); 52 | 53 | constructor() { 54 | effect( 55 | () => { 56 | const session = this.state.dialogSession(); 57 | if (!session) return; 58 | const middle = session.start + (session.end! - session.start) / 2; 59 | this.value.set(middle); 60 | }, 61 | { 62 | allowSignalWrites: true, 63 | }, 64 | ); 65 | } 66 | 67 | public beforeSessions = computed(() => { 68 | const session = this.state.dialogSession(); 69 | return session ? [session] : []; 70 | }); 71 | public afterSessions = computed(() => { 72 | const value = this.value(); 73 | const session = this.state.dialogSession(); 74 | if (!session || value === null) return []; 75 | const before: Session = { ...session, end: value }; 76 | const after: Session = { ...session, start: value }; 77 | return [before, after]; 78 | }); 79 | public min = computed(() => { 80 | const session = this.state.dialogSession(); 81 | return session?.start; 82 | }); 83 | public max = computed(() => { 84 | const session = this.state.dialogSession(); 85 | return session?.end; 86 | }); 87 | public submitDisabled = computed(() => { 88 | return this.value() === this.state.dialogSession()?.start || this.value() === this.state.dialogSession()?.end; 89 | }); 90 | sessionDuration = sessionDuration; 91 | 92 | sessionsContext!: { 93 | sessions: Session[]; 94 | }; 95 | 96 | submit() { 97 | const disabled = this.submitDisabled(); 98 | const result = this.afterSessions(); 99 | if (disabled || !result) return; 100 | this.state.splitSession(result); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/app/directives/duration.ts: -------------------------------------------------------------------------------- 1 | import { AsyncPipe, NgClass } from '@angular/common'; 2 | import { ChangeDetectionStrategy, Component, ElementRef, inject, input } from '@angular/core'; 3 | import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; 4 | import { DurationFn } from '@app/domain/date-time'; 5 | import { pad2 } from '@app/utils/number'; 6 | import { secondsToMilliseconds } from 'date-fns'; 7 | import { combineLatest, interval, map, shareReplay, startWith } from 'rxjs'; 8 | 9 | enum DimMode { 10 | All, 11 | First, 12 | None, 13 | } 14 | 15 | type Fragment = { 16 | value: string; 17 | unit: Unit; 18 | dimmed: DimMode; 19 | }; 20 | 21 | enum Unit { 22 | Hours = 'h', 23 | Minutes = 'm', 24 | Seconds = 's', 25 | } 26 | 27 | const EVERY_SECOND_INTERVAL = interval(secondsToMilliseconds(1)).pipe( 28 | startWith(0), 29 | map(() => Date.now()), 30 | shareReplay({ refCount: true, bufferSize: 1 }), 31 | ); 32 | 33 | @Component({ 34 | selector: 'duration', 35 | // prettier-ignore 36 | template: `@for(fragment of durationFragments | async; track fragment.unit){@if(fragment.dimmed === DimMode.First){{{ fragment.value.at(0) }}{{ fragment.value.slice(1) }}}@else{{{ fragment.value }}}{{ fragment.unit }}}`, 37 | styles: [ 38 | ` 39 | :host { 40 | display: inline-flex; 41 | gap: 0.35em; 42 | --unit-font-size: 0.6em; 43 | } 44 | .unit { 45 | font-size: var(--unit-font-size); 46 | } 47 | .value { 48 | display: inline-flex; 49 | align-items: baseline; 50 | } 51 | .dimmed { 52 | opacity: 0.35; 53 | } 54 | `, 55 | ], 56 | changeDetection: ChangeDetectionStrategy.OnPush, 57 | imports: [NgClass, AsyncPipe], 58 | }) 59 | export class DurationComponent { 60 | private elementRef = inject(ElementRef); 61 | public readonly value = input.required(); 62 | public readonly DimMode = DimMode; 63 | private readonly duration$ = combineLatest([toObservable(this.value), EVERY_SECOND_INTERVAL]).pipe( 64 | map(([value, now]) => ({ 65 | hours: ~~(value(now) / 3600000), 66 | minutes: ~~((value(now) % 3600000) / 60000), 67 | seconds: ~~((value(now) % 60000) / 1000), 68 | })), 69 | shareReplay({ refCount: true, bufferSize: 1 }), 70 | takeUntilDestroyed(), 71 | ); 72 | public readonly durationFragments = this.duration$.pipe( 73 | map(({ hours, minutes, seconds }): Fragment[] => { 74 | if (hours === 0 && minutes === 0) { 75 | return [{ value: seconds.toString(), unit: Unit.Seconds, dimmed: DimMode.None }]; 76 | } 77 | return [ 78 | { value: hours.toString(), unit: Unit.Hours, dimmed: hours === 0 ? DimMode.All : DimMode.None }, 79 | { 80 | value: pad2(minutes), 81 | unit: Unit.Minutes, 82 | dimmed: hours === 0 && minutes === 0 ? DimMode.All : minutes < 10 ? DimMode.First : DimMode.None, 83 | }, 84 | ]; 85 | }), 86 | takeUntilDestroyed(), 87 | ); 88 | private textValue$ = this.duration$.pipe(map(({ hours, minutes, seconds }) => `${hours}h ${minutes}m ${seconds}s`)); 89 | constructor() { 90 | this.textValue$.pipe(takeUntilDestroyed()).subscribe((value) => { 91 | this.elementRef.nativeElement.setAttribute('aria-label', value); 92 | this.elementRef.nativeElement.setAttribute('title', value); 93 | }); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/app/screen-task/screen-task.html: -------------------------------------------------------------------------------- 1 | @if (store.currentTask(); as task) { 2 | 3 | 6 |

7 | {{ task.name }} 9 |

10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 30 |
31 | 34 | 37 | 40 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 57 | 58 | 59 | 60 |
StartEndDuration
32 | {{ item.start | date: 'yyyy-MM-dd H:mm' }} 33 | 35 | {{ item.end | date: 'yyyy-MM-dd H:mm' }} 36 | 38 | 39 | 41 | 46 |
61 |
62 | @if (taskIsInProgress() === false) { 63 | 66 | } @if (taskIsInProgress() === true) { 67 | 70 | } } 71 | -------------------------------------------------------------------------------- /src/app/screen-task/mat-table.scss: -------------------------------------------------------------------------------- 1 | .mat-mdc-table { 2 | min-width: 100%; 3 | border: 0; 4 | border-spacing: 0; 5 | table-layout: auto; 6 | white-space: normal; 7 | background-color: var(--mat-table-background-color); 8 | } 9 | .mdc-data-table__cell { 10 | box-sizing: border-box; 11 | overflow: hidden; 12 | text-align: left; 13 | text-overflow: ellipsis; 14 | } 15 | .mdc-data-table__cell, 16 | .mdc-data-table__header-cell { 17 | padding: 0 16px; 18 | } 19 | .mat-mdc-header-row { 20 | -moz-osx-font-smoothing: grayscale; 21 | -webkit-font-smoothing: antialiased; 22 | height: var(--mat-table-header-container-height, 56px); 23 | color: var(--mat-table-header-headline-color, rgba(0, 0, 0, 0.87)); 24 | font-family: var(--mat-table-header-headline-font, Roboto, sans-serif); 25 | line-height: var(--mat-table-header-headline-line-height); 26 | font-size: var(--mat-table-header-headline-size, 14px); 27 | font-weight: var(--mat-table-header-headline-weight, 500); 28 | } 29 | .mat-mdc-row { 30 | height: var(--mat-table-row-item-container-height, 52px); 31 | color: var(--mat-table-row-item-label-text-color, rgba(0, 0, 0, 0.87)); 32 | } 33 | .mat-mdc-row, 34 | .mdc-data-table__content { 35 | -moz-osx-font-smoothing: grayscale; 36 | -webkit-font-smoothing: antialiased; 37 | font-family: var(--mat-table-row-item-label-text-font, Roboto, sans-serif); 38 | line-height: var(--mat-table-row-item-label-text-line-height); 39 | font-size: var(--mat-table-row-item-label-text-size, 14px); 40 | font-weight: var(--mat-table-row-item-label-text-weight); 41 | } 42 | .mat-mdc-footer-row { 43 | -moz-osx-font-smoothing: grayscale; 44 | -webkit-font-smoothing: antialiased; 45 | height: var(--mat-table-footer-container-height, 52px); 46 | color: var(--mat-table-row-item-label-text-color, rgba(0, 0, 0, 0.87)); 47 | font-family: var(--mat-table-footer-supporting-text-font, Roboto, sans-serif); 48 | line-height: var(--mat-table-footer-supporting-text-line-height); 49 | font-size: var(--mat-table-footer-supporting-text-size, 14px); 50 | font-weight: var(--mat-table-footer-supporting-text-weight); 51 | letter-spacing: var(--mat-table-footer-supporting-text-tracking); 52 | } 53 | .mat-mdc-header-cell { 54 | border-bottom-color: var(--mat-table-row-item-outline-color, rgba(0, 0, 0, 0.12)); 55 | border-bottom-width: var(--mat-table-row-item-outline-width, 1px); 56 | border-bottom-style: solid; 57 | letter-spacing: var(--mat-table-header-headline-tracking); 58 | font-weight: inherit; 59 | line-height: inherit; 60 | box-sizing: border-box; 61 | text-overflow: ellipsis; 62 | overflow: hidden; 63 | outline: none; 64 | text-align: left; 65 | } 66 | .mat-mdc-cell { 67 | border-bottom-color: var(--mat-table-row-item-outline-color, rgba(0, 0, 0, 0.12)); 68 | border-bottom-width: var(--mat-table-row-item-outline-width, 1px); 69 | border-bottom-style: solid; 70 | letter-spacing: var(--mat-table-row-item-label-text-tracking); 71 | line-height: inherit; 72 | } 73 | .mdc-data-table__row:last-child .mat-mdc-cell { 74 | border-bottom: none; 75 | } 76 | .mat-mdc-footer-cell { 77 | letter-spacing: var(--mat-table-row-item-label-text-tracking); 78 | } 79 | .mat-mdc-table tbody, 80 | .mat-mdc-table tfoot, 81 | .mat-mdc-table thead, 82 | .mat-mdc-cell, 83 | .mat-mdc-footer-cell, 84 | .mat-mdc-header-row, 85 | .mat-mdc-row, 86 | .mat-mdc-footer-row, 87 | .mat-mdc-table .mat-mdc-header-cell { 88 | background: inherit; 89 | } 90 | .mat-mdc-table mat-header-row.mat-mdc-header-row, 91 | .mat-mdc-table mat-row.mat-mdc-row, 92 | .mat-mdc-table mat-footer-row.mat-mdc-footer-cell { 93 | height: unset; 94 | } 95 | -------------------------------------------------------------------------------- /src/m3-theme.scss: -------------------------------------------------------------------------------- 1 | // This file was generated by running 'ng generate @angular/material:m3-theme'. 2 | // Proceed with caution if making changes to this file. 3 | 4 | @use 'sass:map'; 5 | @use 'pkg:@angular/material' as mat; 6 | 7 | // Note: Color palettes are generated from primary: #b2ff59, secondary: #607d8b 8 | $_palettes: ( 9 | primary: ( 10 | 0: #000000, 11 | 10: #0f2000, 12 | 20: #1e3700, 13 | 25: #264300, 14 | 30: #2e4f00, 15 | 35: #365c00, 16 | 40: #3e6a00, 17 | 50: #4f8500, 18 | 60: #61a100, 19 | 70: #77be17, 20 | 80: #91db37, 21 | 90: #abf853, 22 | 95: #d2ff9d, 23 | 98: #f0ffd8, 24 | 99: #f8ffe9, 25 | 100: #ffffff, 26 | ), 27 | secondary: ( 28 | 0: #000000, 29 | 10: #0f2000, 30 | 20: #1e3700, 31 | 25: #264300, 32 | 30: #2e4f00, 33 | 35: #365c00, 34 | 40: #3e6a00, 35 | 50: #4f8500, 36 | 60: #61a100, 37 | 70: #77be17, 38 | 80: #91db37, 39 | 90: #abf853, 40 | 95: #d2ff9d, 41 | 98: #f0ffd8, 42 | 99: #f8ffe9, 43 | 100: #ffffff, 44 | ), 45 | tertiary: ( 46 | 0: #000000, 47 | 10: #00201e, 48 | 20: #003735, 49 | 25: #104240, 50 | 30: #1f4e4b, 51 | 35: #2c5a57, 52 | 40: #386663, 53 | 50: #517f7c, 54 | 60: #6b9996, 55 | 70: #85b4b0, 56 | 80: #a0cfcc, 57 | 90: #bbece8, 58 | 95: #c9faf6, 59 | 98: #e3fffc, 60 | 99: #f2fffd, 61 | 100: #ffffff, 62 | ), 63 | neutral: ( 64 | 0: #000000, 65 | 10: #1b1c18, 66 | 20: #30312c, 67 | 25: #3b3c37, 68 | 30: #464742, 69 | 35: #52534d, 70 | 40: #5e5f59, 71 | 50: #777771, 72 | 60: #91918b, 73 | 70: #abaca5, 74 | 80: #c7c7c0, 75 | 90: #e3e3db, 76 | 95: #f2f1e9, 77 | 98: #fafaf2, 78 | 99: #fdfcf5, 79 | 100: #ffffff, 80 | 4: #0d0f0b, 81 | 6: #121410, 82 | 12: #1f201c, 83 | 17: #292a26, 84 | 22: #343530, 85 | 24: #383a35, 86 | 87: #dbdad3, 87 | 92: #e9e8e1, 88 | 94: #efeee7, 89 | 96: #f5f4ec, 90 | ), 91 | neutral-variant: ( 92 | 0: #000000, 93 | 10: #191d14, 94 | 20: #2e3228, 95 | 25: #393d32, 96 | 30: #44483d, 97 | 35: #505449, 98 | 40: #5c6054, 99 | 50: #75796c, 100 | 60: #8e9285, 101 | 70: #a9ad9f, 102 | 80: #c5c8ba, 103 | 90: #e1e4d5, 104 | 95: #eff2e3, 105 | 98: #f8fbec, 106 | 99: #fbfeee, 107 | 100: #ffffff, 108 | ), 109 | error: ( 110 | 0: #000000, 111 | 10: #410002, 112 | 20: #690005, 113 | 25: #7e0007, 114 | 30: #93000a, 115 | 35: #a80710, 116 | 40: #ba1a1a, 117 | 50: #de3730, 118 | 60: #ff5449, 119 | 70: #ff897d, 120 | 80: #ffb4ab, 121 | 90: #ffdad6, 122 | 95: #ffedea, 123 | 98: #fff8f7, 124 | 99: #fffbff, 125 | 100: #ffffff, 126 | ), 127 | ); 128 | 129 | $_rest: ( 130 | secondary: map.get($_palettes, secondary), 131 | neutral: map.get($_palettes, neutral), 132 | neutral-variant: map.get($_palettes, neutral-variant), 133 | error: map.get($_palettes, error), 134 | ); 135 | $_primary: map.merge(map.get($_palettes, primary), $_rest); 136 | $_tertiary: map.merge(map.get($_palettes, tertiary), $_rest); 137 | 138 | $light-theme: mat.define-theme( 139 | ( 140 | color: ( 141 | theme-type: light, 142 | primary: $_primary, 143 | tertiary: $_tertiary, 144 | ), 145 | ) 146 | ); 147 | $dark-theme: mat.define-theme( 148 | ( 149 | color: ( 150 | theme-type: dark, 151 | primary: $_primary, 152 | tertiary: $_tertiary, 153 | ), 154 | ) 155 | ); 156 | -------------------------------------------------------------------------------- /src/app/domain/chart.ts: -------------------------------------------------------------------------------- 1 | import { Session, sessionDurationPure, Task } from '@app/domain/task'; 2 | import { endOfDay } from 'date-fns/endOfDay'; 3 | import { millisecondsToSeconds } from 'date-fns/millisecondsToSeconds'; 4 | import { startOfDay } from 'date-fns/startOfDay'; 5 | 6 | const clampSession = (session: Session, start: number, end: number, now: number): Session => ({ 7 | ...session, 8 | start: Math.max(start, session.start), 9 | end: Math.min(end, session.end ?? now), 10 | }); 11 | 12 | export type ScaleRange = readonly [Date | null, Date | null]; 13 | type DateRange = [Date, Date]; 14 | export type ChartData = [number[], number[], number[]]; 15 | 16 | const generateRanges = (start: Date, end: Date): DateRange[] => { 17 | let rangeStart: Date = start; 18 | const result: DateRange[] = []; 19 | while (rangeStart.valueOf() < end.valueOf()) { 20 | const range: DateRange = [startOfDay(rangeStart), endOfDay(rangeStart)]; 21 | result.push(range); 22 | rangeStart = new Date(range[1].valueOf() + 1); 23 | } 24 | return result; 25 | }; 26 | 27 | const getSessionRangeId = (session: Session): number => startOfDay(new Date(session.start)).valueOf(); 28 | 29 | const getEarliestSessionStart = (tasks: Task[]): number | undefined => 30 | tasks.flatMap((t) => t.sessions.map((s) => s.start)).sort((a, b) => a - b)[0]; 31 | 32 | type Bar = { start: Date; end: Date; tasks: Set; duration: number; includesCurrentTasks: boolean }; 33 | type Bars = Map; 34 | 35 | const tasksToBars = (allTasks: Task[], currentTasks: Task[]): Bars => { 36 | const currentTasksIds = new Set(currentTasks.map((t) => t.id)); 37 | const now = new Date(); 38 | const earliestStart = getEarliestSessionStart(allTasks); 39 | if (earliestStart === undefined) { 40 | return new Map(); 41 | } 42 | const result: Bars = new Map( 43 | generateRanges(new Date(earliestStart), now).map(([s, e]): [number, Bar] => [ 44 | s.valueOf(), 45 | { 46 | start: s, 47 | end: e, 48 | tasks: new Set(), 49 | duration: 0, 50 | includesCurrentTasks: false, 51 | }, 52 | ]), 53 | ); 54 | return allTasks.reduce((bars: Bars, task: Task) => { 55 | task.sessions.forEach((s) => { 56 | let duration = sessionDurationPure(s); 57 | while (duration >= 10) { 58 | const bar = bars.get(getSessionRangeId(s)); 59 | if (!bar) { 60 | break; 61 | } 62 | const sessionSlice = clampSession(s, bar.start.valueOf(), bar.end.valueOf(), now.valueOf()); 63 | const sliceDuration = sessionDurationPure(sessionSlice); 64 | if (!bar.includesCurrentTasks) bar.includesCurrentTasks = currentTasksIds.has(task.id); 65 | bar.tasks.add(task.id); 66 | bar.duration += sliceDuration; 67 | duration = duration - sliceDuration; 68 | s = { ...s, start: bar.end.valueOf() + 1 }; 69 | } 70 | }); 71 | return bars; 72 | }, result); 73 | }; 74 | 75 | export const chartSeries = (allTasks: Task[], currentTasks: Task[]): ChartData => { 76 | const bars = tasksToBars(allTasks, currentTasks); 77 | const currentBars: Bars = new Map( 78 | [...bars.entries()].map(([key, bar]) => [key, bar.includesCurrentTasks ? bar : { ...bar, duration: 0 }]), 79 | ); 80 | const otherBars: Bars = new Map( 81 | [...bars.entries()].map(([key, bar]) => [key, !bar.includesCurrentTasks ? bar : { ...bar, duration: 0 }]), 82 | ); 83 | return [ 84 | [...bars.values()].flatMap((b) => [ 85 | millisecondsToSeconds(b.start.valueOf()), 86 | millisecondsToSeconds(b.end.valueOf()), 87 | ]), 88 | [...currentBars.values()].flatMap((b) => [b.duration, b.duration]), 89 | [...otherBars.values()].flatMap((b) => [b.duration, b.duration]), 90 | ]; 91 | }; 92 | 93 | export const hasChartData = (data: ChartData): boolean => !!data[0]?.length; 94 | -------------------------------------------------------------------------------- /src/app/screen-tasks/screen-tasks.html: -------------------------------------------------------------------------------- 1 |
2 | @if (store.currentTasks(); as tasks) { 3 |
4 | 5 |

6 | {{ store.currentTaskState() | taskState }} 7 | () 9 |

10 | 13 |
14 | @defer { @if (searchOpened()) { 15 | 16 | } } @if (tasks.length) { 17 | 23 | 24 | 36 | 42 |
43 | {{ task.name }} 44 | 45 |
46 | 47 |
48 |
49 |
50 | 59 | } @else { @defer { @if (searchOpened() === false) { 60 | 61 | Timer 62 | No tasks 63 | 64 | @switch (store.currentTaskState()) { @case ('all') { Create a task and it will show up here } @case 65 | (taskState.active) { Create a task and it will show up here } @case (taskState.finished) { You didn't finish any 66 | tasks yet } @case (taskState.dropped) { You didn't abandon any tasks yet } } 67 | 68 | 78 | 79 | } @else { 80 | 81 | 82 | Nothing found 83 | Could not find tasks matching the criteria 84 | 85 | 86 | } } } 87 |
88 | } @if (store.isCurrentTaskOpened()) { 89 |
90 | 91 |
92 | } 93 |
94 | -------------------------------------------------------------------------------- /src/app/app.ts: -------------------------------------------------------------------------------- 1 | import { DragDropModule } from '@angular/cdk/drag-drop'; 2 | import { ChangeDetectionStrategy, Component, DestroyRef, computed, effect, inject } from '@angular/core'; 3 | import { MatIcon } from '@angular/material/icon'; 4 | import { MatActionList, MatListItem, MatNavList } from '@angular/material/list'; 5 | import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; 6 | import { MatDrawer, MatDrawerContainer, MatDrawerContent } from '@angular/material/sidenav'; 7 | import { MatTooltip } from '@angular/material/tooltip'; 8 | import { DomSanitizer } from '@angular/platform-browser'; 9 | import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; 10 | import { HotkeysService } from 'angular2-hotkeys'; 11 | import { ButtonThemeSwitcherComponent } from './button-theme-switcher/button-theme-switcher'; 12 | import { KEYS_GO_ACTIVE, KEYS_GO_ALL, KEYS_GO_FINISHED, hotkey } from './domain/hotkeys'; 13 | import { toStoredTasks } from './domain/storage'; 14 | import { TaskState } from './domain/task'; 15 | import { TaskStateIconPipe } from './pipes/task-state-icon'; 16 | import { FaviconService } from './providers/favicon'; 17 | import { ImportExportService } from './providers/import-export'; 18 | import { RoutedDialogs } from './providers/routed-dialogs'; 19 | import { AppStore } from './providers/state'; 20 | 21 | @Component({ 22 | selector: 'app-root', 23 | templateUrl: './app.html', 24 | styleUrls: ['./app.scss'], 25 | changeDetection: ChangeDetectionStrategy.OnPush, 26 | imports: [ 27 | MatDrawerContainer, 28 | MatDrawer, 29 | MatDrawerContent, 30 | MatMenuTrigger, 31 | MatMenu, 32 | MatMenuItem, 33 | MatNavList, 34 | MatListItem, 35 | MatActionList, 36 | MatIcon, 37 | MatTooltip, 38 | TaskStateIconPipe, 39 | DragDropModule, 40 | RouterLink, 41 | RouterLinkActive, 42 | RouterOutlet, 43 | ButtonThemeSwitcherComponent, 44 | ], 45 | }) 46 | export class AppComponent { 47 | public keys = inject(HotkeysService); 48 | public router = inject(Router); 49 | private importExport = inject(ImportExportService); 50 | private favicon = inject(FaviconService); 51 | private hotkeysService = inject(HotkeysService); 52 | private destroyRef = inject(DestroyRef); 53 | public store = inject(AppStore); 54 | private sanitizer = inject(DomSanitizer); 55 | private routedDialogs = inject(RoutedDialogs); 56 | 57 | public exportUrl = computed(() => { 58 | const tasks = this.store.tasks(); 59 | const url = URL.createObjectURL( 60 | new Blob([JSON.stringify(toStoredTasks(tasks), null, ' ')], { type: 'application/json;charset=utf-8;' }), 61 | ); 62 | return this.sanitizer.bypassSecurityTrustResourceUrl(url); 63 | }); 64 | 65 | taskState = TaskState; 66 | 67 | constructor() { 68 | this.hotkeysService.cheatSheetToggle.subscribe(() => { 69 | this.routedDialogs.navigate(['hotkeys']); 70 | }); 71 | this.keys.add([ 72 | hotkey(KEYS_GO_ALL, 'Go to all tasks', () => this.router.navigate(['all'], { queryParamsHandling: 'merge' })), 73 | hotkey(KEYS_GO_ACTIVE, 'Go to active tasks', () => 74 | this.router.navigate([TaskState.active], { queryParamsHandling: 'merge' }), 75 | ), 76 | hotkey(KEYS_GO_FINISHED, 'Go to finished tasks', () => 77 | this.router.navigate([TaskState.finished], { queryParamsHandling: 'merge' }), 78 | ), 79 | ]); 80 | effect(() => { 81 | const anyTaskActive = this.store.isAnyTaskActive(); 82 | if (anyTaskActive) { 83 | this.favicon.setIcon('assets/favicon-active.svg'); 84 | } else { 85 | this.favicon.setIcon('assets/favicon.svg'); 86 | } 87 | }); 88 | this.destroyRef.onDestroy(() => { 89 | const exportUrl = this.exportUrl(); 90 | if (exportUrl) URL.revokeObjectURL(exportUrl.toString()); 91 | }); 92 | } 93 | import(event: Event) { 94 | this.importExport.import(event); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /scripts/generate-screenshots.ts: -------------------------------------------------------------------------------- 1 | import { createCanvas, loadImage } from '@napi-rs/canvas'; 2 | import { existsSync } from 'node:fs'; 3 | import { writeFile } from 'node:fs/promises'; 4 | import { fixture, Selector, test } from 'testcafe'; 5 | import { app } from '../e2e/page-objects/app'; 6 | import { mockDate } from '../e2e/utils'; 7 | 8 | const TEST_DATA_URL = 9 | 'https://gist.github.com/Klaster1/a456beaf5384924fa960790160286d8a/raw/83483f6179dfd13a63eca7afb62b5571ba6bf6e9/games.json'; 10 | 11 | fixture`Promo`.beforeEach(async (t) => { 12 | await t.eval(() => { 13 | const style = document.createElement('style'); 14 | style.id = 'visual-regression'; 15 | style.innerHTML = '.root-hammerhead-shadow-ui { display: none !important; }'; 16 | document.head.appendChild(style); 17 | }); 18 | if (!existsSync('e2e/downloads/screenshot-data.json')) { 19 | const data = await fetch(TEST_DATA_URL).then((res) => res.text()); 20 | await writeFile('./e2e/downloads/screenshot-data.json', data, { encoding: 'utf-8' }); 21 | } 22 | await t.click(app.buttonImportExport); 23 | await t.setFilesToUpload(app.inputImport, ['../e2e/downloads/screenshot-data.json']); 24 | await mockDate(new Date('2024-07-13T13:41:05')); 25 | await t.pressKey('g t j s'); 26 | await mockDate(new Date('2024-07-13T15:33:44')); 27 | await t.pressKey('esc'); 28 | }); 29 | 30 | test('Readme screenshot', async (t) => { 31 | const client = await t.getCurrentCDPSession(); 32 | const screenshot = async (theme: 'light' | 'dark') => { 33 | await t.click(app.buttonSwitchTheme); 34 | await t.click(app.buttonTheme.withText(theme === 'dark' ? 'Dark' : 'Light')); 35 | await t.click(Selector('body'), { offsetX: 0, offsetY: 0 }); 36 | await t.click(Selector('body'), { offsetX: 0, offsetY: 0 }); 37 | 38 | const scaling = 0.5; 39 | await t.resizeWindow(2152 * scaling, 1278 * scaling); 40 | const screenshot = await client.Page.captureScreenshot({ 41 | format: 'png', 42 | }); 43 | return Buffer.from(screenshot.data, 'base64'); 44 | }; 45 | const dark = await loadImage(await screenshot('dark')); 46 | const light = await loadImage(await screenshot('light')); 47 | const canvas = createCanvas(dark.width, dark.height); 48 | const ctx = canvas.getContext('2d'); 49 | 50 | ctx.drawImage(dark, 0, 0); 51 | 52 | const angle = -66 * (Math.PI / 180); 53 | const lineLength = canvas.height / Math.sin(angle); 54 | const startX = canvas.width / 1.9 - (lineLength * Math.cos(angle)) / 2; 55 | const startY = 0; 56 | const endX = startX + lineLength * Math.cos(angle); 57 | const endY = startY + lineLength * Math.sin(angle); 58 | 59 | ctx.save(); 60 | ctx.beginPath(); 61 | ctx.moveTo(startX, startY); 62 | ctx.lineTo(endX, endY); 63 | ctx.lineTo(canvas.width, canvas.height); 64 | ctx.lineTo(canvas.width, 0); 65 | ctx.closePath(); 66 | ctx.clip(); 67 | ctx.drawImage(light, 0, 0); 68 | ctx.restore(); 69 | 70 | const buffer = canvas.toBuffer('image/png'); 71 | await writeFile(`./screenshot.png`, buffer); 72 | }); 73 | 74 | test('Open graph', async (t) => { 75 | const padding = 40; 76 | const width = 640; 77 | const height = 320; 78 | const scale = 2; 79 | const background = '#343434'; 80 | 81 | await t.resizeWindow(width * scale - padding * 2 * scale, height * scale - padding * 2 * scale); 82 | await t.click(Selector('body'), { offsetX: 0, offsetY: 0 }); 83 | await t.click(Selector('body'), { offsetX: 0, offsetY: 0 }); 84 | const client = await t.getCurrentCDPSession(); 85 | const screenshot = await client.Page.captureScreenshot({ 86 | format: 'png', 87 | }); 88 | 89 | const canvas = createCanvas(width * scale, height * scale); 90 | const ctx = canvas.getContext('2d'); 91 | 92 | ctx.fillStyle = background; 93 | ctx.fillRect(0, 0, canvas.width, canvas.height); 94 | ctx.drawImage( 95 | await loadImage(Buffer.from(screenshot.data, 'base64')), 96 | padding * scale, 97 | padding * scale, 98 | canvas.width - padding * 2 * scale, 99 | canvas.height - padding * 2 * scale, 100 | ); 101 | 102 | const buffer = canvas.toBuffer('image/png'); 103 | await writeFile(`./social.png`, buffer); 104 | }); 105 | -------------------------------------------------------------------------------- /src/app/screen-task/screen-task.ts: -------------------------------------------------------------------------------- 1 | import { CdkDrag, CdkDragPlaceholder, CdkDropList } from '@angular/cdk/drag-drop'; 2 | import { CdkVirtualScrollViewport, ScrollingModule } from '@angular/cdk/scrolling'; 3 | import { DatePipe, NgStyle } from '@angular/common'; 4 | import { ChangeDetectionStrategy, Component, DestroyRef, computed, effect, inject, viewChild } from '@angular/core'; 5 | import { MatFabButton, MatIconButton } from '@angular/material/button'; 6 | import { MatIcon } from '@angular/material/icon'; 7 | import { MatToolbarModule } from '@angular/material/toolbar'; 8 | import { MatTooltip } from '@angular/material/tooltip'; 9 | import { RouterLink } from '@angular/router'; 10 | import { ButtonTaskActionsComponent } from '@app/button-task-actions/button-task-actions'; 11 | import { DurationComponent } from '@app/directives/duration'; 12 | import { ToolbarWidthSyncDirective } from '@app/directives/toolbar-width-sync'; 13 | import { 14 | KEYS_DELETE_TASK, 15 | KEYS_MARK_ACTIVE, 16 | KEYS_MARK_FINISHED, 17 | KEYS_RENAME, 18 | KEYS_START_STOP, 19 | hotkey, 20 | } from '@app/domain/hotkeys'; 21 | import { Task, TaskState, isTaskRunning, sessionDuration, sortSessions, taskDuration } from '@app/domain/task'; 22 | import { MapPipe } from '@app/pipes/map'; 23 | import { TaskStateIconPipe } from '@app/pipes/task-state-icon'; 24 | import { RoutedDialogs } from '@app/providers/routed-dialogs'; 25 | import { AppStore } from '@app/providers/state'; 26 | import { HotkeysService } from 'angular2-hotkeys'; 27 | import { ButtonSessionActionsComponent } from './button-session-actions/button-session-actions'; 28 | import { VirtualScrollStickyTable } from './sticky'; 29 | import { TypeSafeCdkVirtualForDirective } from './type-safe-virtual-for'; 30 | 31 | @Component({ 32 | selector: 'screen-task', 33 | templateUrl: './screen-task.html', 34 | styleUrls: ['./screen-task.scss', './mat-table.scss'], 35 | changeDetection: ChangeDetectionStrategy.OnPush, 36 | imports: [ 37 | TaskStateIconPipe, 38 | MatToolbarModule, 39 | MatIcon, 40 | MatTooltip, 41 | ButtonTaskActionsComponent, 42 | ButtonSessionActionsComponent, 43 | MapPipe, 44 | CdkDrag, 45 | CdkDragPlaceholder, 46 | CdkDropList, 47 | DatePipe, 48 | RouterLink, 49 | MatIconButton, 50 | MatFabButton, 51 | ScrollingModule, 52 | NgStyle, 53 | VirtualScrollStickyTable, 54 | TypeSafeCdkVirtualForDirective, 55 | DurationComponent, 56 | ToolbarWidthSyncDirective, 57 | ], 58 | }) 59 | export default class ScreenTaskComponent { 60 | public store = inject(AppStore); 61 | private keys = inject(HotkeysService); 62 | private destroyRef = inject(DestroyRef); 63 | private routedDialogs = inject(RoutedDialogs); 64 | 65 | taskIsInProgress = computed(() => isTaskRunning(this.store.currentTask())); 66 | viewport = viewChild(CdkVirtualScrollViewport); 67 | taskDuration = taskDuration; 68 | sessionDuration = sessionDuration; 69 | sortSessions = sortSessions; 70 | 71 | hotkeys = [ 72 | hotkey(KEYS_START_STOP, 'Start/stop task', (e) => { 73 | const task = this.store.currentTask(); 74 | const inProgress = this.taskIsInProgress(); 75 | if (!task) return; 76 | if (inProgress) { 77 | this.stop(task.id); 78 | } else { 79 | this.start(task.id); 80 | } 81 | }), 82 | hotkey(KEYS_MARK_FINISHED, `Mark as finished`, (e) => { 83 | const task = this.store.currentTask(); 84 | if (task) this.store.updateTaskState(task.id, TaskState.finished); 85 | }), 86 | hotkey(KEYS_MARK_ACTIVE, `Mark as active`, (e) => { 87 | const task = this.store.currentTask(); 88 | if (task) this.store.updateTaskState(task.id, TaskState.active); 89 | }), 90 | hotkey(KEYS_RENAME, 'Rename task', () => { 91 | const task = this.store.currentTask(); 92 | if (task) this.routedDialogs.navigate(['tasks', task.id, 'rename']); 93 | }), 94 | hotkey(KEYS_DELETE_TASK, 'Delete task', () => { 95 | const task = this.store.currentTask(); 96 | if (task) this.store.deleteTask(task.id); 97 | }), 98 | ]; 99 | displayedColumns = ['start', 'end', 'duration', 'action']; 100 | 101 | constructor() { 102 | this.keys.add(this.hotkeys); 103 | this.destroyRef.onDestroy(() => { 104 | this.keys.remove(this.hotkeys); 105 | }); 106 | effect(() => { 107 | this.store.currentTaskId(); 108 | this.viewport()?.scrollToIndex(0); 109 | }); 110 | } 111 | start(taskId: string) { 112 | this.store.startTask(taskId, Date.now()); 113 | } 114 | stop(taskId: string) { 115 | this.store.stopTask(taskId, Date.now()); 116 | } 117 | deleteTask(task: Task) { 118 | this.store.deleteTask(task.id); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/app/providers/routed-dialogs.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Directive, 3 | ENVIRONMENT_INITIALIZER, 4 | Injectable, 5 | Injector, 6 | Provider, 7 | SimpleChange, 8 | effect, 9 | inject, 10 | input, 11 | } from '@angular/core'; 12 | import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; 13 | import { MatDialog, MatDialogConfig } from '@angular/material/dialog'; 14 | import { ActivatedRouteSnapshot, ActivationEnd, Router, RouterLink, Routes } from '@angular/router'; 15 | import { getAllChildren, getAllParents } from '@app/utils/router'; 16 | import { filter, map } from 'rxjs'; 17 | 18 | export const DIALOG_OUTLET_NAME = 'dialog'; 19 | 20 | const toDialogRoute = (route: any[]): any[] => ['/', { outlets: { [DIALOG_OUTLET_NAME]: route } }]; 21 | 22 | @Directive({ 23 | selector: '[dialogLink]', 24 | hostDirectives: [RouterLink], 25 | }) 26 | export class DialogLinkDirective { 27 | public dialogLink = input.required(); 28 | private routerLink = inject(RouterLink); 29 | private toChange = (value: any): SimpleChange => ({ 30 | currentValue: value, 31 | previousValue: undefined, 32 | firstChange: false, 33 | isFirstChange: () => false, 34 | }); 35 | constructor() { 36 | this.routerLink.queryParamsHandling = 'preserve'; 37 | this.routerLink.ngOnChanges({ 38 | queryParamsHandling: this.toChange('preserve'), 39 | }); 40 | effect(() => { 41 | const dialogLink = this.dialogLink(); 42 | const link = toDialogRoute(dialogLink); 43 | this.routerLink.routerLink = link; 44 | this.routerLink.ngOnChanges({ 45 | routerLink: this.toChange(link), 46 | }); 47 | }); 48 | } 49 | } 50 | 51 | @Injectable({ 52 | providedIn: 'root', 53 | }) 54 | export class RoutedDialogs { 55 | private router = inject(Router); 56 | public navigate(route: any[]) { 57 | return this.router.navigate(toDialogRoute(route), { 58 | queryParamsHandling: 'preserve', 59 | }); 60 | } 61 | close() { 62 | return this.router.navigate(['.', { outlets: { [DIALOG_OUTLET_NAME]: null } }], { 63 | queryParamsHandling: 'preserve', 64 | }); 65 | } 66 | } 67 | 68 | export const provideDialogRoutes = (routes: Routes): Provider[] => { 69 | const rootRoute = { 70 | outlet: DIALOG_OUTLET_NAME, 71 | path: '', 72 | children: routes, 73 | }; 74 | const isDialogRoute = (route: ActivatedRouteSnapshot): boolean => { 75 | return !!route.component && getAllParents(route).some((parent) => parent.outlet === DIALOG_OUTLET_NAME); 76 | }; 77 | return [ 78 | { 79 | provide: ENVIRONMENT_INITIALIZER, 80 | multi: true, 81 | useFactory: () => { 82 | const injector = inject(Injector); 83 | const router = inject(Router); 84 | const matDialog = inject(MatDialog); 85 | router.resetConfig([...router.config, rootRoute]); 86 | return () => { 87 | router.events 88 | .pipe( 89 | filter((event) => event instanceof ActivationEnd), 90 | map((event) => event as ActivationEnd), 91 | takeUntilDestroyed(), 92 | ) 93 | .subscribe(async (event) => { 94 | const openDialogSnapshots = getAllChildren(event.snapshot.root).filter(isDialogRoute); 95 | matDialog.openDialogs.forEach((dialogRef) => { 96 | if ( 97 | !openDialogSnapshots.some( 98 | (snapshot) => snapshot.component === dialogRef.componentInstance.constructor, 99 | ) 100 | ) { 101 | dialogRef.close(); 102 | } 103 | }); 104 | 105 | const component = event.snapshot.component; 106 | 107 | if (!component || !isDialogRoute(event.snapshot)) return; 108 | 109 | const dialogInjector = Injector.create({ 110 | parent: injector, 111 | providers: [ 112 | { 113 | provide: ActivatedRouteSnapshot, 114 | useFactory: () => event.snapshot, 115 | }, 116 | ], 117 | }); 118 | 119 | const dialogRef = matDialog.open(component, { 120 | closeOnNavigation: false, 121 | injector: dialogInjector, 122 | ...('dialogConfig' in component ? (component.dialogConfig as MatDialogConfig) : {}), 123 | }); 124 | dialogRef.afterClosed().subscribe(() => { 125 | router.navigate(['..', { outlets: { [DIALOG_OUTLET_NAME]: null } }], { 126 | queryParamsHandling: 'preserve', 127 | }); 128 | }); 129 | }); 130 | }; 131 | }, 132 | }, 133 | ]; 134 | }; 135 | -------------------------------------------------------------------------------- /src/app/domain/storage.ts: -------------------------------------------------------------------------------- 1 | import { makeTaskId, Session, Task, TaskState } from '@app/domain/task'; 2 | import { NormalizedTasks } from '@app/providers/state'; 3 | import { assertNever, isNumber, isTruthy } from '@app/utils/assert'; 4 | import { millisecondsToSeconds } from 'date-fns/millisecondsToSeconds'; 5 | import { secondsToMilliseconds } from 'date-fns/secondsToMilliseconds'; 6 | import { Seconds } from './date-time'; 7 | 8 | type AppTasks = NormalizedTasks; 9 | type StoredTasks = LegacyGames | StoredTasksV1; 10 | type LatestStoredTasks = StoredTasksV1; 11 | 12 | // Legacy 13 | type LegacyGame = { 14 | id: string; 15 | state: 'active' | 'finished' | 'dropped' | 'hold' | 'wish'; 16 | title: string; 17 | sessions: { start: number; stop: number }[]; 18 | }; 19 | type LegacyGames = LegacyGame[]; 20 | const fromLegacyGames = (data: LegacyGames): NormalizedTasks => { 21 | const game = data[0]; 22 | if ( 23 | game && 24 | (typeof game.id !== 'string' || 25 | !['active', 'finished', 'dropped', 'hold', 'wish'].includes(game.state) || 26 | typeof game.title !== 'string') 27 | ) { 28 | throw new Error('Invalid legacy format'); 29 | } else { 30 | const tasks: Task[] = data.map((game) => ({ 31 | id: makeTaskId(), 32 | name: game.title, 33 | state: ( 34 | { 35 | active: TaskState.active, 36 | finished: TaskState.finished, 37 | dropped: TaskState.dropped, 38 | hold: TaskState.active, 39 | wish: TaskState.active, 40 | } as const 41 | )[game.state], 42 | sessions: game.sessions.map( 43 | (session): Session => ({ 44 | start: session.start, 45 | end: isNumber(session.stop) ? session.stop : undefined, 46 | }), 47 | ), 48 | })); 49 | return Object.fromEntries(tasks.map((task) => [task.id, task] as const)); 50 | } 51 | }; 52 | 53 | // V1 54 | type StoredSessionV1 = [Seconds, Seconds] | [Seconds, null]; 55 | enum StoredTaskStateV1 { 56 | active, 57 | finished, 58 | dropped, 59 | } 60 | type StoredTaskV1 = { 61 | id: string; 62 | name: string; 63 | state: StoredTaskStateV1; 64 | sessions: StoredSessionV1[]; 65 | }; 66 | export type StoredTasksV1 = { 67 | version: 1; 68 | value: StoredTaskV1[]; 69 | }; 70 | const appTaskStateToStoredTaskStateV1 = (state: TaskState): StoredTaskStateV1 => { 71 | switch (state) { 72 | case TaskState.active: 73 | return StoredTaskStateV1.active; 74 | case TaskState.dropped: 75 | return StoredTaskStateV1.dropped; 76 | case TaskState.finished: 77 | return StoredTaskStateV1.finished; 78 | default: 79 | return assertNever(state); 80 | } 81 | }; 82 | const storedTaskStateToAppTaskStateV1 = (state: StoredTaskStateV1): TaskState => { 83 | switch (state) { 84 | case StoredTaskStateV1.active: 85 | return TaskState.active; 86 | case StoredTaskStateV1.dropped: 87 | return TaskState.dropped; 88 | case StoredTaskStateV1.finished: 89 | return TaskState.finished; 90 | default: 91 | return assertNever(state); 92 | } 93 | }; 94 | 95 | const appSessionToStoredSession = (session: Session): StoredSessionV1 => [ 96 | millisecondsToSeconds(session.start), 97 | isNumber(session.end) ? millisecondsToSeconds(session.end) : null, 98 | ]; 99 | const storedSessionToAppSession = (storedSession: StoredSessionV1): Session => 100 | storedSession[1] === null 101 | ? { start: secondsToMilliseconds(storedSession[0]) } 102 | : { start: secondsToMilliseconds(storedSession[0]), end: secondsToMilliseconds(storedSession[1]) }; 103 | 104 | export const toStoredTasks = (appTasks: AppTasks): LatestStoredTasks => ({ 105 | version: 1, 106 | value: Object.keys(appTasks) 107 | .map((id) => { 108 | const task = appTasks[id]; 109 | if (!task) return null; 110 | return { 111 | id, 112 | name: task.name, 113 | state: appTaskStateToStoredTaskStateV1(task.state), 114 | sessions: task.sessions.map(appSessionToStoredSession), 115 | }; 116 | }) 117 | .filter(isTruthy), 118 | }); 119 | 120 | const fromStoredTasksV1 = (storedTasks: StoredTasksV1): AppTasks => { 121 | const tasks: Task[] = storedTasks.value.map((task) => ({ 122 | id: task.id, 123 | name: task.name, 124 | state: storedTaskStateToAppTaskStateV1(task.state), 125 | sessions: task.sessions.map(storedSessionToAppSession), 126 | })); 127 | return Object.fromEntries(tasks.map((task) => [task.id, task] as const)); 128 | }; 129 | 130 | // Public 131 | 132 | export const fromStoredTasks = (storedTasks: StoredTasks): AppTasks => { 133 | if (Array.isArray(storedTasks)) { 134 | return fromLegacyGames(storedTasks); 135 | } 136 | switch (storedTasks.version) { 137 | case 1: 138 | return fromStoredTasksV1(storedTasks); 139 | default: 140 | return assertNever(storedTasks.version); 141 | } 142 | }; 143 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { provideHttpClient } from '@angular/common/http'; 2 | import { 3 | APP_INITIALIZER, 4 | DestroyRef, 5 | enableProdMode, 6 | importProvidersFrom, 7 | inject, 8 | isDevMode, 9 | provideZonelessChangeDetection, 10 | } from '@angular/core'; 11 | import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; 12 | import { MAT_DIALOG_DEFAULT_OPTIONS, MatDialogConfig } from '@angular/material/dialog'; 13 | import { MatIconRegistry } from '@angular/material/icon'; 14 | import { MatSnackBar } from '@angular/material/snack-bar'; 15 | import { MAT_TOOLTIP_DEFAULT_OPTIONS, MatTooltipDefaultOptions } from '@angular/material/tooltip'; 16 | import { DomSanitizer, bootstrapApplication } from '@angular/platform-browser'; 17 | import { provideAnimations } from '@angular/platform-browser/animations'; 18 | import { provideRouter, withRouterConfig } from '@angular/router'; 19 | import { SwUpdate, provideServiceWorker } from '@angular/service-worker'; 20 | import { AppComponent } from '@app/app'; 21 | import { gameStateGuard } from '@app/guards/game-state'; 22 | import { provideDialogRoutes } from '@app/providers/routed-dialogs'; 23 | import { HotkeyModule } from 'angular2-hotkeys'; 24 | import { secondsToMilliseconds } from 'date-fns/secondsToMilliseconds'; 25 | import { interval } from 'rxjs'; 26 | import ScreenTaskComponent from './app/screen-task/screen-task'; 27 | import ScreenTasksComponent from './app/screen-tasks/screen-tasks'; 28 | import { environment } from './environments/environment'; 29 | 30 | if (environment.production) { 31 | enableProdMode(); 32 | } 33 | 34 | bootstrapApplication(AppComponent, { 35 | providers: [ 36 | provideZonelessChangeDetection(), 37 | provideAnimations(), 38 | importProvidersFrom(HotkeyModule.forRoot({ cheatSheetCloseEsc: false, disableCheatSheet: false })), 39 | provideRouter( 40 | [ 41 | { path: '', redirectTo: 'active', pathMatch: 'full' }, 42 | { 43 | path: ':state', 44 | component: ScreenTasksComponent, 45 | canActivate: [gameStateGuard], 46 | children: [ 47 | { 48 | path: ':taskId', 49 | component: ScreenTaskComponent, 50 | }, 51 | ], 52 | }, 53 | ], 54 | withRouterConfig({ paramsInheritanceStrategy: 'always' }), 55 | ), 56 | provideDialogRoutes([ 57 | { 58 | path: 'tasks', 59 | children: [ 60 | { 61 | path: 'create', 62 | loadComponent: () => import('./app/dialog-create-task/dialog-create-task'), 63 | }, 64 | { 65 | path: ':taskId', 66 | children: [ 67 | { 68 | path: 'rename', 69 | loadComponent: () => import('./app/dialog-rename-task/dialog-rename-task'), 70 | }, 71 | { 72 | path: 'sessions/:sessionIndex', 73 | children: [ 74 | { 75 | path: 'split', 76 | loadComponent: () => import('./app/dialog-split-session/dialog-split-session'), 77 | }, 78 | { 79 | path: 'edit', 80 | loadComponent: () => import('./app/dialog-edit-session/dialog-edit-session'), 81 | }, 82 | ], 83 | }, 84 | ], 85 | }, 86 | ], 87 | }, 88 | { 89 | path: 'hotkeys', 90 | loadComponent: () => import('./app/dialog-hotkeys-cheatsheet/dialog-hotkeys-cheatsheet'), 91 | }, 92 | ]), 93 | { 94 | provide: MAT_DIALOG_DEFAULT_OPTIONS, 95 | useFactory(): MatDialogConfig { 96 | return { width: '600px', autoFocus: true }; 97 | }, 98 | }, 99 | { 100 | provide: MAT_TOOLTIP_DEFAULT_OPTIONS, 101 | useValue: { disableTooltipInteractivity: true } as MatTooltipDefaultOptions, 102 | }, 103 | provideServiceWorker('ngsw-worker.js', { 104 | enabled: !isDevMode(), 105 | registrationStrategy: 'registerWhenStable:30000', 106 | }), 107 | provideHttpClient(), 108 | { 109 | provide: APP_INITIALIZER, 110 | multi: true, 111 | useFactory: () => { 112 | const sw = inject(SwUpdate); 113 | const destroyRef = inject(DestroyRef); 114 | const snackbar = inject(MatSnackBar); 115 | return () => { 116 | if (!environment.production) return; 117 | 118 | interval(secondsToMilliseconds(60)) 119 | .pipe(takeUntilDestroyed(destroyRef)) 120 | .subscribe(() => sw.checkForUpdate()); 121 | sw.versionUpdates.pipe(takeUntilDestroyed(destroyRef)).subscribe((evt) => { 122 | if (evt.type === 'VERSION_READY') { 123 | snackbar 124 | .open('New version available', 'Reload') 125 | .onAction() 126 | .subscribe(() => { 127 | location.reload(); 128 | }); 129 | } 130 | }); 131 | }; 132 | }, 133 | }, 134 | { 135 | provide: APP_INITIALIZER, 136 | multi: true, 137 | useFactory: () => { 138 | const iconRegistry = inject(MatIconRegistry); 139 | const sanitizer = inject(DomSanitizer); 140 | return () => { 141 | iconRegistry.addSvgIconResolver((name) => { 142 | return sanitizer.bypassSecurityTrustResourceUrl(`assets/icons/${name}.svg`); 143 | }); 144 | }; 145 | }, 146 | }, 147 | ], 148 | }); 149 | -------------------------------------------------------------------------------- /e2e/visual-regression.ts: -------------------------------------------------------------------------------- 1 | import { createCanvas, loadImage } from '@napi-rs/canvas'; 2 | import looksSame from 'looks-same'; 3 | import { existsSync } from 'node:fs'; 4 | import { mkdir, unlink, writeFile } from 'node:fs/promises'; 5 | import { dirname, join, resolve } from 'node:path'; 6 | import { t } from 'testcafe'; 7 | const os = require('os'); 8 | 9 | export const VISUAL_REGRESSION_OK = { match: true } as const; 10 | const BASE_DIR = 'visual-regression-screenshots'; 11 | 12 | type VisualRegressionMode = 'create' | 'compare'; 13 | type ScreenshotPathName = 'reference' | 'current' | 'diff'; 14 | type ScreenshotPaths = Record; 15 | type ColorScheme = 'light' | 'dark' | 'preserve'; 16 | type Platform = 'windows' | 'linux' | 'unknown'; 17 | 18 | const getPaths = (name: string): ScreenshotPaths => { 19 | const platform: Platform = os.platform() === 'win32' ? 'windows' : os.platform() === 'linux' ? 'linux' : 'unknown'; 20 | 21 | const BASE_PATH = [BASE_DIR, platform]; 22 | const commonFileName = `[${t.fixture.name}] ${t.test.name} - ${name}`; 23 | 24 | return { 25 | reference: resolve(__dirname, join(...BASE_PATH, `${commonFileName}.reference.png`)), 26 | current: resolve(__dirname, join(...BASE_PATH, `${commonFileName}.current.png`)), 27 | diff: resolve(__dirname, join(...BASE_PATH, `${commonFileName}.diff.png`)), 28 | }; 29 | }; 30 | 31 | const prepare = async (colorScheme: ColorScheme) => { 32 | const restoreTheme = await forceTheme(colorScheme); 33 | await t.eval(() => { 34 | const style = document.createElement('style'); 35 | style.id = 'visual-regression'; 36 | style.innerHTML = '.root-hammerhead-shadow-ui { display: none !important; }'; 37 | document.head.appendChild(style); 38 | }); 39 | 40 | return async () => { 41 | await t.eval(() => document.querySelector('#visual-regression')?.remove()); 42 | await restoreTheme(); 43 | }; 44 | }; 45 | 46 | const captureScreenshot = async (path: string) => { 47 | const client = await t.getCurrentCDPSession(); 48 | const screenshot = await client.Page.captureScreenshot({ format: 'png' }); 49 | await mkdir(dirname(path), { recursive: true }); 50 | return Buffer.from(screenshot.data, 'base64'); 51 | }; 52 | 53 | type Rect = { x: number; y: number; width: number; height: number }; 54 | 55 | const getSelectorsBounds = async (selectors: Selector[]): Promise => { 56 | const result: Rect[] = []; 57 | for (const selector of selectors) { 58 | for (let i = 0; i < (await selector.count); i++) { 59 | const bounds = await selector.nth(i).boundingClientRect; 60 | result.push({ 61 | x: bounds.left, 62 | y: bounds.top, 63 | width: bounds.width, 64 | height: bounds.height, 65 | }); 66 | } 67 | } 68 | return result; 69 | }; 70 | 71 | const maskImage = async (source: string | Buffer, bounds: Rect[]) => { 72 | const image = await loadImage(source); 73 | const canvas = createCanvas(image.width, image.height); 74 | canvas.getContext('2d').fillStyle = 'cyan'; 75 | canvas.getContext('2d').drawImage(image, 0, 0); 76 | for (const bound of bounds) { 77 | canvas.getContext('2d').fillRect(bound.x, bound.y, bound.width, bound.height); 78 | } 79 | return canvas.toBuffer('image/png'); 80 | }; 81 | 82 | const forceTheme = async (colorScheme: ColorScheme) => { 83 | if (colorScheme === 'preserve') return () => {}; 84 | const client = await t.getCurrentCDPSession(); 85 | await client.Emulation.setEmulatedMedia({ 86 | media: 'screen', 87 | features: [ 88 | { 89 | name: 'prefers-color-scheme', 90 | value: colorScheme, 91 | }, 92 | ], 93 | }); 94 | return async () => { 95 | await client.Emulation.setEmulatedMedia({ 96 | media: 'screen', 97 | features: [ 98 | { 99 | name: 'prefers-color-scheme', 100 | value: '', 101 | }, 102 | ], 103 | }); 104 | }; 105 | }; 106 | const getEnvMode = (): VisualRegressionMode | undefined => { 107 | const validValues = new Set(['create', 'compare']); 108 | const raw = process.env['VR_MODE']; 109 | return validValues.has(raw as any) ? (raw as VisualRegressionMode) : undefined; 110 | }; 111 | 112 | export async function comparePageScreenshot( 113 | name: string, 114 | options?: { 115 | ignore?: Selector[]; 116 | theme?: ColorScheme; 117 | looksSame?: Omit; 118 | }, 119 | ) { 120 | const paths = getPaths(name); 121 | const mode: VisualRegressionMode = getEnvMode() ?? (existsSync(paths.reference) ? 'compare' : 'create'); 122 | const colorScheme = options?.theme ?? 'dark'; 123 | 124 | const cleanup = await prepare(colorScheme); 125 | const diffClusters = await getSelectorsBounds(options?.ignore ?? []); 126 | await Promise.all([unlink(paths.diff), unlink(paths.current)]).catch((e) => {}); 127 | const screenshot = await captureScreenshot(mode === 'compare' ? paths.current : paths.reference); 128 | await cleanup(); 129 | 130 | if (mode === 'create') { 131 | await writeFile(paths.reference, screenshot); 132 | return VISUAL_REGRESSION_OK; 133 | } else if (mode === 'compare') { 134 | const maskedReference = await maskImage(paths.reference, diffClusters); 135 | const maskedCurrent = await maskImage(screenshot, diffClusters); 136 | const { equal, diffImage, ...rest } = await looksSame(maskedReference, maskedCurrent, { 137 | createDiffImage: true, 138 | ignoreCaret: true, 139 | shouldCluster: true, 140 | ...options?.looksSame, 141 | }); 142 | if (equal) { 143 | return VISUAL_REGRESSION_OK; 144 | } else { 145 | await diffImage.save(paths.diff); 146 | await writeFile(paths.current, screenshot); 147 | return { match: false, diff: paths.diff }; 148 | } 149 | } else { 150 | return VISUAL_REGRESSION_OK; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/app/screen-tasks/screen-tasks.ts: -------------------------------------------------------------------------------- 1 | import { DragDropModule } from '@angular/cdk/drag-drop'; 2 | import { CdkVirtualScrollViewport, ScrollingModule } from '@angular/cdk/scrolling'; 3 | import { NgClass } from '@angular/common'; 4 | import { 5 | ChangeDetectionStrategy, 6 | Component, 7 | DestroyRef, 8 | Injector, 9 | TrackByFunction, 10 | afterNextRender, 11 | computed, 12 | effect, 13 | inject, 14 | signal, 15 | viewChild, 16 | } from '@angular/core'; 17 | import { MatButton, MatFabButton, MatIconButton } from '@angular/material/button'; 18 | import { MatIcon } from '@angular/material/icon'; 19 | import { MatListItem, MatListItemIcon, MatListItemMeta, MatListItemTitle, MatNavList } from '@angular/material/list'; 20 | import { MatToolbar } from '@angular/material/toolbar'; 21 | import { MatTooltip } from '@angular/material/tooltip'; 22 | import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; 23 | import { ButtonTaskActionsComponent } from '@app/button-task-actions/button-task-actions'; 24 | import { DurationComponent } from '@app/directives/duration'; 25 | import { ToolbarWidthSyncDirective } from '@app/directives/toolbar-width-sync'; 26 | import { KEYS_ADD, KEYS_NEXT, KEYS_PREV, KEYS_SEARCH, hotkey } from '@app/domain/hotkeys'; 27 | import { SessionDragEvent, Task, TaskState, isTaskRunning, taskDuration, tasksDuration } from '@app/domain/task'; 28 | import { MapPipe } from '@app/pipes/map'; 29 | import { TaskStatePipe } from '@app/pipes/task-state'; 30 | import { TaskStateIconPipe } from '@app/pipes/task-state-icon'; 31 | import { DialogLinkDirective, RoutedDialogs } from '@app/providers/routed-dialogs'; 32 | import { AppStore } from '@app/providers/state'; 33 | import { TypeSafeCdkVirtualForDirective } from '@app/screen-task/type-safe-virtual-for'; 34 | import { Hotkey, HotkeysService } from 'angular2-hotkeys'; 35 | import { CheckViewportSizeWhenValueChangesDirective } from './checkViewportSizeWhenValueChanges'; 36 | import { EmptyStateComponent } from './empty-state/empty-state'; 37 | import { ScrollToIndexDirective } from './scrollToIndex'; 38 | import { TasksFilterComponent } from './tasks-filter/tasks-filter'; 39 | 40 | @Component({ 41 | selector: 'screen-tasks', 42 | templateUrl: './screen-tasks.html', 43 | styleUrls: ['./screen-tasks.scss'], 44 | changeDetection: ChangeDetectionStrategy.OnPush, 45 | imports: [ 46 | EmptyStateComponent, 47 | TasksFilterComponent, 48 | TaskStatePipe, 49 | TaskStateIconPipe, 50 | DialogLinkDirective, 51 | RouterLink, 52 | RouterOutlet, 53 | RouterLinkActive, 54 | ScrollingModule, 55 | MatToolbar, 56 | MatIcon, 57 | MatButton, 58 | MatIconButton, 59 | MatFabButton, 60 | MatNavList, 61 | MatListItemIcon, 62 | MatListItemTitle, 63 | MatListItemMeta, 64 | MatListItem, 65 | MatTooltip, 66 | ButtonTaskActionsComponent, 67 | MapPipe, 68 | DragDropModule, 69 | CheckViewportSizeWhenValueChangesDirective, 70 | ScrollToIndexDirective, 71 | NgClass, 72 | TypeSafeCdkVirtualForDirective, 73 | DurationComponent, 74 | ToolbarWidthSyncDirective, 75 | ], 76 | }) 77 | export default class ScreenTasksComponent { 78 | public store = inject(AppStore); 79 | private keys = inject(HotkeysService); 80 | private router = inject(Router); 81 | private routedDialogs = inject(RoutedDialogs); 82 | private destroyRef = inject(DestroyRef); 83 | public viewport = viewChild(CdkVirtualScrollViewport); 84 | private injector = inject(Injector); 85 | 86 | private filterPresent = computed(() => !!Object.keys(this.store.decodedFilterParams()).length); 87 | private filterToggles = signal(undefined); 88 | public searchOpened = computed(() => { 89 | const filterPresent = this.filterPresent(); 90 | const filterToggles = this.filterToggles(); 91 | return filterToggles !== undefined ? filterToggles : filterPresent; 92 | }); 93 | 94 | constructor() { 95 | afterNextRender(() => { 96 | this.keys.add(this.hotkeys); 97 | }); 98 | this.destroyRef.onDestroy(() => { 99 | this.keys.remove(this.hotkeys); 100 | }); 101 | // Scroll to top when tasks are loaded 102 | effect(() => { 103 | this.store.currentTasks(); 104 | afterNextRender( 105 | { 106 | read: () => { 107 | this.viewport()?.scrollToIndex(0); 108 | }, 109 | }, 110 | { 111 | injector: this.injector, 112 | }, 113 | ); 114 | }); 115 | } 116 | 117 | taskDuration = taskDuration; 118 | tasksDuration = tasksDuration; 119 | taskState = TaskState; 120 | isTaskRunning = isTaskRunning; 121 | taskId: TrackByFunction = (_, task) => task.id; 122 | 123 | hotkeys = [ 124 | hotkey(KEYS_ADD, 'Add task', () => this.routedDialogs.navigate(['tasks', 'create'])), 125 | hotkey([...KEYS_NEXT, ...KEYS_PREV], 'Next/prev task', (e) => { 126 | const state = this.store.currentTaskState(); 127 | const taskId = KEYS_NEXT.includes(e.key) 128 | ? this.store.nextTaskId() 129 | : KEYS_PREV.includes(e.key) 130 | ? this.store.prevTaskId() 131 | : null; 132 | if (state && taskId) this.router.navigate([state, taskId], { queryParamsHandling: 'merge' }); 133 | }), 134 | new Hotkey( 135 | KEYS_SEARCH, 136 | (e) => { 137 | e.preventDefault(); 138 | this.toggleFilter(); 139 | return true; 140 | }, 141 | ['INPUT'], 142 | 'Toggle search', 143 | ), 144 | ]; 145 | 146 | toggleFilter(opened?: boolean) { 147 | this.filterToggles.update(() => opened ?? !this.searchOpened()); 148 | } 149 | onDrop( 150 | { 151 | item: { 152 | data: [session, fromTaskid], 153 | }, 154 | }: SessionDragEvent, 155 | item: Task, 156 | ) { 157 | if (session && fromTaskid) this.store.moveSessionToTask(fromTaskid, item.id, session); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/app/screen-tasks/tasks-filter/tasks-filter.ts: -------------------------------------------------------------------------------- 1 | import { animate, style, transition, trigger } from '@angular/animations'; 2 | import { A11yModule } from '@angular/cdk/a11y'; 3 | import { ChangeDetectionStrategy, Component, DestroyRef, computed, effect, inject } from '@angular/core'; 4 | import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; 5 | import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; 6 | import { MatIconButton } from '@angular/material/button'; 7 | import { MatFormField, MatLabel, MatSuffix } from '@angular/material/form-field'; 8 | import { MatIcon } from '@angular/material/icon'; 9 | import { MatInput } from '@angular/material/input'; 10 | import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; 11 | import { MatOption, MatSelect } from '@angular/material/select'; 12 | import { MatTooltip } from '@angular/material/tooltip'; 13 | import { Router } from '@angular/router'; 14 | import { DatetimeLocalDirective } from '@app/directives/datetime-local'; 15 | import { ScaleRange, hasChartData } from '@app/domain/chart'; 16 | import { FilterMatrixParams, encodeFilterParams } from '@app/domain/router'; 17 | import { AppStore } from '@app/providers/state'; 18 | import { deepEquals } from '@app/utils/assert'; 19 | import { endOfDay } from 'date-fns/endOfDay'; 20 | import { endOfMonth } from 'date-fns/endOfMonth'; 21 | import { endOfWeek } from 'date-fns/endOfWeek'; 22 | import { endOfYear } from 'date-fns/endOfYear'; 23 | import { startOfDay } from 'date-fns/startOfDay'; 24 | import { startOfMonth } from 'date-fns/startOfMonth'; 25 | import { startOfWeek } from 'date-fns/startOfWeek'; 26 | import { startOfYear } from 'date-fns/startOfYear'; 27 | import { subDays } from 'date-fns/subDays'; 28 | import { subMonths } from 'date-fns/subMonths'; 29 | import { subWeeks } from 'date-fns/subWeeks'; 30 | import { subYears } from 'date-fns/subYears'; 31 | import { debounceTime, distinctUntilChanged } from 'rxjs'; 32 | import { ButtonResetInputComponent } from '../../directives/button-reset-input'; 33 | import { TimelineChartUplotComponent } from './timeline-chart-uplot'; 34 | 35 | type Wrap = Required<{ [Key in keyof T]: FormControl }>; 36 | 37 | @Component({ 38 | selector: 'tasks-filter', 39 | templateUrl: './tasks-filter.html', 40 | styleUrls: ['./tasks-filter.scss'], 41 | changeDetection: ChangeDetectionStrategy.OnPush, 42 | animations: [ 43 | trigger('inOutAnimation', [ 44 | transition(':enter', [style({ opacity: 0 }), animate('300ms ease-out', style({ opacity: 1 }))]), 45 | ]), 46 | ], 47 | imports: [ 48 | MatIconButton, 49 | MatSelect, 50 | MatOption, 51 | MatIcon, 52 | MatMenu, 53 | MatMenuTrigger, 54 | MatMenuItem, 55 | MatFormField, 56 | MatSuffix, 57 | MatLabel, 58 | MatInput, 59 | ReactiveFormsModule, 60 | A11yModule, 61 | TimelineChartUplotComponent, 62 | ButtonResetInputComponent, 63 | DatetimeLocalDirective, 64 | MatTooltip, 65 | ], 66 | }) 67 | export class TasksFilterComponent { 68 | private store = inject(AppStore); 69 | private router = inject(Router); 70 | private destroyRef = inject(DestroyRef); 71 | 72 | public dataRange = computed(() => { 73 | const data = this.store.filterChartData(); 74 | const range = this.store.filterRange(); 75 | if (!hasChartData(data) || !range) return undefined; 76 | return { data, range } as const; 77 | }); 78 | 79 | constructor() { 80 | this.destroyRef.onDestroy(() => { 81 | this.router.navigate([], { queryParams: {} }); 82 | }); 83 | effect(() => { 84 | const decodedFilterParams = this.store.decodedFilterParams(); 85 | this.form.patchValue(decodedFilterParams); 86 | }); 87 | this.form.valueChanges 88 | .pipe(debounceTime(10), distinctUntilChanged(deepEquals), takeUntilDestroyed()) 89 | .subscribe((value) => this.router.navigate([], { queryParams: encodeFilterParams(value) })); 90 | } 91 | 92 | hasChartData = hasChartData; 93 | form = new FormGroup>({ 94 | search: new FormControl(), 95 | from: new FormControl(), 96 | to: new FormControl(), 97 | durationSort: new FormControl(), 98 | }); 99 | 100 | onChartRangeChange(e: ScaleRange) { 101 | this.form.patchValue({ 102 | from: e[0] ?? undefined, 103 | to: e[1] ?? undefined, 104 | }); 105 | } 106 | 107 | setAnyTime() { 108 | this.form.patchValue({ 109 | from: undefined, 110 | to: undefined, 111 | }); 112 | } 113 | setToday() { 114 | this.form.patchValue({ 115 | from: startOfDay(new Date()), 116 | to: endOfDay(new Date()), 117 | }); 118 | } 119 | setYesterday() { 120 | this.form.patchValue({ 121 | from: startOfDay(subDays(new Date(), 1)), 122 | to: endOfDay(subDays(new Date(), 1)), 123 | }); 124 | } 125 | setThisWeek() { 126 | this.form.patchValue({ 127 | from: startOfWeek(new Date(), { weekStartsOn: 1 }), 128 | to: endOfWeek(new Date(), { weekStartsOn: 1 }), 129 | }); 130 | } 131 | setPreviousWeek() { 132 | this.form.patchValue({ 133 | from: startOfWeek(subWeeks(new Date(), 1), { weekStartsOn: 1 }), 134 | to: endOfWeek(subWeeks(new Date(), 1), { weekStartsOn: 1 }), 135 | }); 136 | } 137 | setThisMonth() { 138 | this.form.patchValue({ 139 | from: startOfMonth(new Date()), 140 | to: endOfMonth(new Date()), 141 | }); 142 | } 143 | setPreviousMonth() { 144 | this.form.patchValue({ 145 | from: startOfMonth(subMonths(new Date(), 1)), 146 | to: endOfMonth(subMonths(new Date(), 1)), 147 | }); 148 | } 149 | setThisYear() { 150 | this.form.patchValue({ 151 | from: startOfYear(new Date()), 152 | to: endOfYear(new Date()), 153 | }); 154 | } 155 | setPreviousYear() { 156 | this.form.patchValue({ 157 | from: startOfYear(subYears(new Date(), 1)), 158 | to: endOfYear(subYears(new Date(), 1)), 159 | }); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/app/domain/task.ts: -------------------------------------------------------------------------------- 1 | import { CdkDragDrop } from '@angular/cdk/drag-drop'; 2 | import { deepEquals, isNumber } from '@app/utils/assert'; 3 | import { nanoid } from 'nanoid'; 4 | import { DurationFn, Milliseconds } from './date-time'; 5 | import { FilterMatrixParams, RouteFragmentParams } from './router'; 6 | 7 | export enum TaskState { 8 | active = 'active', 9 | finished = 'finished', 10 | dropped = 'dropped', 11 | } 12 | 13 | export type RouteTaskState = TaskState | 'all'; 14 | 15 | export type TaskId = string; 16 | 17 | export type Task = { 18 | id: TaskId; 19 | name: string; 20 | state: TaskState; 21 | sessions: Session[]; 22 | }; 23 | 24 | export type Session = { 25 | start: Milliseconds; 26 | end?: Milliseconds; 27 | }; 28 | 29 | export const isTask = (v: any) => { 30 | return typeof v === 'object' && v.id && v.name && v.state && Array.isArray(v.sessions) ? (v as Task) : null; 31 | }; 32 | export const isTaskRunning = (t?: Task): boolean => !!t && !!t.sessions && t.sessions.some((s) => !s.end); 33 | export const isValidTaskState = (state: string): boolean => 34 | (new Set([TaskState.active, TaskState.finished, TaskState.dropped]) as Set).has(state); 35 | export const isSessionRunning = (session: Session): session is { start: number; end: number } => !isNumber(session.end); 36 | export const getTaskRunningSession = (t?: Task) => t?.sessions.find(isSessionRunning); 37 | const compareSessions = (a: Session, b: Session) => b.start - a.start; 38 | export const sortSessions = (sessions: Session[]): Session[] => [...sessions].sort(compareSessions); 39 | export const compareTasks = (a: Task, b: Task): number => { 40 | const as = a.sessions.at(-1); 41 | const bs = b.sessions.at(-1); 42 | if (!as && bs) { 43 | return -1; 44 | } 45 | if (as && !bs) { 46 | return 1; 47 | } 48 | if (!as && !bs) { 49 | return 0; 50 | } 51 | if (as && bs) { 52 | if (!as.end && !bs.end) { 53 | return bs.start - as.start; 54 | } 55 | if (as.end && bs.end) { 56 | return bs.start - as.start; 57 | } 58 | if (!as.end && bs.end) { 59 | return -1; 60 | } 61 | if (as.end && !bs.end) { 62 | return 1; 63 | } 64 | } 65 | return 0; 66 | }; 67 | export const sessionIsOver = (s?: Session): s is Session & { end: number } => !!s && isNumber(s.end); 68 | export const sessionDurationPure = (s: Session): number => (s.end ? s.end - s.start : 0); 69 | export const completeTaskDuration = (task?: Task): number => 70 | task ? task.sessions.reduce((t, s) => t + sessionDurationPure(s), 0) : 0; 71 | 72 | export type SessionId = [Session['start'], Session['end']]; 73 | export const getSessionId = (session: Session): SessionId => [session.start, session.end]; 74 | export const isSessionWithId = (id: SessionId) => (session: Session) => deepEquals(getSessionId(session), id); 75 | export const getTaskSession = (task: Task, id: SessionId) => task.sessions.find(isSessionWithId(id)); 76 | 77 | export const makeTaskId = (): TaskId => nanoid(4); 78 | 79 | export const sessionDuration = (session?: Session): DurationFn => { 80 | if (!session) { 81 | return () => 0; 82 | } 83 | return (now: Milliseconds) => (session.end ? session.end - session.start : now - session.start); 84 | }; 85 | 86 | const runningTaskDuration = (task?: Task): DurationFn | void => { 87 | const sessionInProgress = getTaskRunningSession(task); 88 | return sessionInProgress ? sessionDuration(sessionInProgress) : undefined; 89 | }; 90 | 91 | export const taskDuration = (task?: Task): DurationFn => { 92 | const completeDuration = completeTaskDuration(task); 93 | const runningDuration = runningTaskDuration(task); 94 | return runningDuration ? (now: Milliseconds) => completeDuration + runningDuration(now) : () => completeDuration; 95 | }; 96 | 97 | export const tasksDuration = 98 | (tasks: Task[]): DurationFn => 99 | (now: Milliseconds) => 100 | tasks.map(runningTaskDuration).reduce( 101 | (acc, runningDuration) => (runningDuration ? acc + runningDuration(now) : acc), 102 | tasks.reduce((acc, task) => acc + completeTaskDuration(task), 0), 103 | ); 104 | 105 | type Nullable = T | null; 106 | 107 | type FilterParams = Partial>; 108 | 109 | type Filter = (filter: FilterParams, task: Nullable) => Nullable; 110 | 111 | const filterByState: Filter = (filter, t) => { 112 | if (!t) return t; 113 | const { state } = filter; 114 | if (state && state !== 'all') { 115 | return t.state === state ? t : null; 116 | } else { 117 | return t; 118 | } 119 | }; 120 | 121 | const filterByName: Filter = (filter, t) => { 122 | if (!t) return t; 123 | const { search } = filter; 124 | if (typeof search === 'string' && search.length) { 125 | return t.name.toLowerCase().includes(search.toLowerCase()) ? t : null; 126 | } else { 127 | return t; 128 | } 129 | }; 130 | const filterByFrom: Filter = (filter, t) => { 131 | if (!t) return t; 132 | const { from } = filter; 133 | if (from instanceof Date && !Number.isNaN(from.valueOf())) { 134 | const sessions = t.sessions.filter((s) => s.start >= from.valueOf()); 135 | return sessions.length ? { ...t, sessions } : null; 136 | } else { 137 | return t; 138 | } 139 | }; 140 | const filterByTo: Filter = (filter, t) => { 141 | if (!t) return t; 142 | const { to } = filter; 143 | if (to instanceof Date && !Number.isNaN(to.valueOf())) { 144 | const sessions = t.sessions.filter((s) => (isNumber(s.end) ? s.end <= to.valueOf() : true)); 145 | return sessions.length ? { ...t, sessions } : null; 146 | } else { 147 | return t; 148 | } 149 | }; 150 | 151 | const sortByDuration = (filter: FilterParams, tasks: Task[]): Task[] => { 152 | const now = (filter.to ?? new Date()).valueOf(); 153 | if (filter.durationSort) { 154 | return [...tasks].sort((a, b) => 155 | filter.durationSort === 'shortestFirst' 156 | ? taskDuration(a)(now) - taskDuration(b)(now) 157 | : taskDuration(b)(now) - taskDuration(a)(now), 158 | ); 159 | } else { 160 | return [...tasks].sort(compareTasks); 161 | } 162 | }; 163 | 164 | const composedPredicate = (filter: FilterParams, t: Nullable): Nullable => 165 | filterByTo(filter, filterByFrom(filter, filterByName(filter, filterByState(filter, t)))); 166 | 167 | export const filterTasks = (filter: FilterParams, values: Task[]): Task[] => 168 | sortByDuration( 169 | filter, 170 | values.reduce((acc: Task[], task) => { 171 | const result = composedPredicate(filter, task); 172 | if (result) { 173 | acc.push(result); 174 | } 175 | return acc; 176 | }, []), 177 | ); 178 | 179 | export const filterTaskSessions = (task: Task, params: Pick): Task => { 180 | const sessions = filterByTo(params, filterByFrom(params, task))?.sessions; 181 | return sessions ? { ...task, sessions } : task; 182 | }; 183 | 184 | export type SessionDragEvent = CdkDragDrop; 185 | -------------------------------------------------------------------------------- /src/app/screen-tasks/tasks-filter/timeline-chart-uplot.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectionStrategy, 3 | Component, 4 | DestroyRef, 5 | ElementRef, 6 | ViewEncapsulation, 7 | afterNextRender, 8 | effect, 9 | inject, 10 | input, 11 | output, 12 | } from '@angular/core'; 13 | import { ScaleRange } from '@app/domain/chart'; 14 | import { Milliseconds, Seconds, daysToMilliseconds, formatHours } from '@app/domain/date-time'; 15 | import { AppStore } from '@app/providers/state'; 16 | import { isNumber } from '@app/utils/assert'; 17 | import { format } from 'date-fns/format'; 18 | import { millisecondsToSeconds } from 'date-fns/millisecondsToSeconds'; 19 | import { secondsToMilliseconds } from 'date-fns/secondsToMilliseconds'; 20 | import uPlot, { AlignedData, Plugin } from 'uplot'; 21 | 22 | const barChartPlugin = (params: { colors: (string | null)[]; minRangeInMs: Milliseconds }): Plugin => { 23 | const minRangeInSeconds: Seconds = millisecondsToSeconds(params.minRangeInMs); 24 | 25 | const getVisibleRangeLength = (u: uPlot): number => { 26 | const visibleRangeMin = u.scales.x?.min; 27 | const visibleRangeMax = u.scales.x?.max; 28 | if (visibleRangeMin === undefined || visibleRangeMax === undefined) return Number.POSITIVE_INFINITY; 29 | return visibleRangeMax - visibleRangeMin; 30 | }; 31 | 32 | const drawBarChart: uPlot.Series.Points.Show = (self: uPlot, seriesIndex: number, i0: number, i1: number) => { 33 | let { ctx } = self; 34 | let scale = self.series[seriesIndex]?.scale; 35 | if (!scale) return; 36 | const maxY = self.valToPos(0, scale, true); 37 | const color = params.colors[seriesIndex]; 38 | if (!color) return false; 39 | ctx.fillStyle = color; 40 | 41 | let j = i0; 42 | 43 | while (j <= i1) { 44 | const data0J = self.data[0][j]; 45 | const dataIJ = self.data[seriesIndex]?.[j]; 46 | if (typeof data0J !== 'number' || typeof dataIJ !== 'number') continue; 47 | let cx = Math.round(self.valToPos(data0J, 'x', true)); 48 | let cy = Math.round(self.valToPos(dataIJ, scale, true)); 49 | const range = getVisibleRangeLength(self); 50 | if (cy < maxY && range <= minRangeInSeconds) { 51 | ctx.beginPath(); 52 | ctx.rect(cx, cy, 1, maxY - cy); 53 | ctx.fill(); 54 | } 55 | j++; 56 | } 57 | 58 | ctx.restore(); 59 | return false; 60 | }; 61 | 62 | return { 63 | hooks: {}, 64 | opts: (u: uPlot, opts: uPlot.Options) => { 65 | opts.series 66 | .filter((s) => s.scale === 'm') 67 | .forEach((s) => { 68 | s.points = { 69 | show: drawBarChart, 70 | }; 71 | }); 72 | }, 73 | }; 74 | }; 75 | 76 | @Component({ 77 | selector: 'timeline-chart-uplot', 78 | template: ``, 79 | styleUrl: '../../../../node_modules/uplot/dist/uPlot.min.css', 80 | encapsulation: ViewEncapsulation.None, 81 | changeDetection: ChangeDetectionStrategy.OnPush, 82 | styles: [ 83 | ` 84 | timeline-chart-uplot { 85 | display: block; 86 | width: 100%; 87 | height: 100%; 88 | position: relative; 89 | overflow: hidden; 90 | 91 | .uplot { 92 | position: absolute; 93 | font-family: Roboto, 'Helvetica Neue', sans-serif !important; 94 | 95 | & th { 96 | font-weight: 400 !important; 97 | } 98 | .u-value { 99 | vertical-align: -1px; 100 | } 101 | } 102 | .hidden { 103 | display: none; 104 | } 105 | } 106 | `, 107 | ], 108 | }) 109 | export class TimelineChartUplotComponent { 110 | private elementRef = inject>(ElementRef); 111 | private destroyRef = inject(DestroyRef); 112 | private store = inject(AppStore); 113 | 114 | public chartData = input(); 115 | public range = input(); 116 | public rangeChange = output(); 117 | 118 | private setRange(value: ScaleRange | undefined) { 119 | if (!value) return; 120 | const chartData = this.chartData(); 121 | const oldMin = Math.round(this.uplot?.scales.x?.min ?? -1); 122 | const oldMax = Math.round(this.uplot?.scales.x?.max ?? -1); 123 | const dataMin = chartData?.[0]?.[0]; 124 | const dataMax = chartData?.[0][chartData?.[0].length - 1] ?? []; 125 | const newMin = 126 | value[0] instanceof Date 127 | ? millisecondsToSeconds(value[0].valueOf()) 128 | : isNumber(dataMin) 129 | ? dataMin 130 | : millisecondsToSeconds(Date.now()); 131 | const newMax = 132 | value[1] instanceof Date 133 | ? millisecondsToSeconds(value[1].valueOf()) 134 | : isNumber(dataMax) 135 | ? dataMax 136 | : millisecondsToSeconds(Date.now()); 137 | if (oldMin === newMin && oldMax === newMax) return; 138 | // Wait until visible 139 | this.uplot?.setScale('x', { min: newMin, max: newMax }); 140 | } 141 | private firstRangeChangeSkipped = false; 142 | private uplot?: uPlot; 143 | private readonly headerHeight = 31; 144 | private resizeObserver?: ResizeObserver; 145 | private updateColors = () => { 146 | this.store.theme(); 147 | 148 | const stroke = window.getComputedStyle(this.elementRef.nativeElement).color; 149 | const primaryColor = window 150 | .getComputedStyle(this.elementRef.nativeElement) 151 | .getPropertyValue('--mat-form-field-outlined-focus-label-text-color'); 152 | const secondaryColor = window 153 | .getComputedStyle(this.elementRef.nativeElement) 154 | .getPropertyValue('--mat-form-field-filled-container-color'); 155 | 156 | this.uplot?.batch((uPlot: uPlot) => { 157 | uPlot?.axes.forEach((a) => (a.stroke = () => stroke)); 158 | uPlot?.series.forEach((s, i) => { 159 | if (i === 1) s.fill = () => primaryColor; 160 | if (i === 2) s.fill = () => secondaryColor; 161 | }); 162 | uPlot?.redraw(true); 163 | }); 164 | }; 165 | constructor() { 166 | effect(this.updateColors); 167 | 168 | effect(() => { 169 | const chartData = this.chartData(); 170 | if (chartData) { 171 | this.uplot?.batch((uPlot: uPlot) => { 172 | uPlot.setData(chartData); 173 | }); 174 | } 175 | }); 176 | effect(() => { 177 | const range = this.range(); 178 | this.setRange(range); 179 | }); 180 | this.destroyRef.onDestroy(() => { 181 | this.resizeObserver?.unobserve(this.elementRef.nativeElement); 182 | this.uplot?.destroy(); 183 | }); 184 | afterNextRender(() => { 185 | this.resizeObserver = new ResizeObserver(([entry]) => { 186 | if (entry) 187 | this.uplot?.setSize({ 188 | width: Math.floor(entry.contentRect.width), 189 | height: Math.floor(entry.contentRect.height - this.headerHeight), 190 | }); 191 | }); 192 | this.resizeObserver.observe(this.elementRef.nativeElement); 193 | this.uplot = new uPlot( 194 | { 195 | width: this.elementRef.nativeElement.offsetWidth, 196 | height: this.elementRef.nativeElement.offsetHeight - this.headerHeight, 197 | plugins: [ 198 | barChartPlugin({ 199 | colors: [null, 'hsl(87, 74%, 40%)', 'hsl(87, 0%, 40%)'], 200 | minRangeInMs: daysToMilliseconds(365), 201 | }), 202 | ], 203 | hooks: { 204 | setScale: [ 205 | (self: uPlot, key: string) => { 206 | if (!this.firstRangeChangeSkipped) { 207 | this.firstRangeChangeSkipped = true; 208 | return; 209 | } 210 | const scale = self.scales[key]; 211 | if (key !== 'x' || !scale || !isNumber(scale.min) || !isNumber(scale.max)) { 212 | return; 213 | } 214 | const dataMin = self.data[0][0]; 215 | const dataMax = self.data[0][self.data[0].length - 1]; 216 | const min = scale.min === dataMin ? null : new Date(secondsToMilliseconds(scale.min)); 217 | const max = scale.max === dataMax ? null : new Date(secondsToMilliseconds(scale.max)); 218 | const event = [min, max] as const; 219 | this.rangeChange.emit(event); 220 | }, 221 | ], 222 | }, 223 | scales: { 224 | m: { 225 | auto: true, 226 | }, 227 | x: { 228 | time: true, 229 | auto: true, 230 | range(self, min, max) { 231 | const currentMin = self.scales.x!.min!; 232 | const currentMax = self.scales.x!.max!; 233 | const minRangeWidthInSeconds: Seconds = millisecondsToSeconds(daysToMilliseconds(0.99)); 234 | const missingRange: Seconds = max - min; 235 | if (missingRange <= minRangeWidthInSeconds) { 236 | return [currentMin - missingRange * 0.5, currentMax + missingRange * 0.5]; 237 | } else { 238 | return [min, max]; 239 | } 240 | }, 241 | }, 242 | }, 243 | series: [ 244 | { 245 | value: (_, value) => (!value ? '-' : format(new Date(secondsToMilliseconds(value)), 'yyyy-MM-dd')), 246 | label: 'Day', 247 | }, 248 | { 249 | show: true, 250 | label: this.getLegendLabel(), 251 | scale: 'm', 252 | value: (self, rawValue, seriesIndex, idx) => { 253 | const otherBarValue = typeof idx === 'number' ? self.data[seriesIndex + 1]?.[idx] : undefined; 254 | const valueToShow = 255 | rawValue === 0 && otherBarValue !== 0 && typeof otherBarValue === 'number' ? otherBarValue : rawValue; 256 | return this.getLegendValue(valueToShow); 257 | }, 258 | }, 259 | { 260 | show: true, 261 | scale: 'm', 262 | class: 'hidden', 263 | }, 264 | ], 265 | axes: [ 266 | {}, 267 | { 268 | scale: 'm', 269 | size: 50, 270 | label: this.getLegendLabel(), 271 | values: (_, ticks) => ticks.map((raw) => this.getLegendValue(raw)), 272 | }, 273 | ], 274 | }, 275 | this.chartData(), 276 | this.elementRef.nativeElement, 277 | ); 278 | setTimeout(this.updateColors); 279 | }); 280 | } 281 | getLegendValue(value: number) { 282 | return value ? formatHours(value) : '--:--'; 283 | } 284 | 285 | getLegendLabel() { 286 | return 'Hours'; 287 | } 288 | } 289 | --------------------------------------------------------------------------------