1
0
mirror of https://github.com/XFox111/bonch-calendar.git synced 2026-06-30 10:52:41 +03:00

31 Commits

Author SHA1 Message Date
xfox111 1adeff73e4 docs: copyright dates 2026-05-24 08:30:01 +00:00
xfox111 bb6f9d493d docs: more code comments and minor style refactoring 2026-05-24 08:28:09 +00:00
xfox111 d5f5f54eb7 fix: use URL to construct API requests 2026-05-24 08:27:00 +00:00
xfox111 cebd38698f feat!: native AOT for api app #26 2026-05-23 07:04:29 +00:00
xfox111 a3458b825e feat: docker healthcheck #25 2026-05-22 09:53:27 +00:00
xfox111 7f88891429 feat!: active users stats and improved logging and healthcheck 2026-05-22 09:40:19 +00:00
xfox111 6a2b6980f9 build: suppress CA1873 2026-05-22 09:02:44 +00:00
xfox111 4d4c3adde6 feat(api): show log scopes by default 2026-05-22 09:00:30 +00:00
xfox111 734c43548a fix: hidden fields still accessible via keyboard 2026-05-22 08:59:46 +00:00
xfox111 b03a05b89f build(deps): npm audit fix 2026-05-15 20:31:26 +00:00
dependabot[bot] d765ab0269 build(deps): Bump the deps group in /app with 7 updates (#33)
Bumps the deps group in /app with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [@fluentui/react-icons](https://github.com/microsoft/fluentui-system-icons) | `2.0.325` | `2.0.326` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.6.0` | `25.8.0` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.0.1` | `6.0.2` |
| [eslint](https://github.com/eslint/eslint) | `10.2.1` | `10.4.0` |
| [globals](https://github.com/sindresorhus/globals) | `17.5.0` | `17.6.0` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.59.1` | `8.59.3` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.10` | `8.0.13` |


Updates `@fluentui/react-icons` from 2.0.325 to 2.0.326
- [Changelog](https://github.com/microsoft/fluentui-system-icons/blob/main/docs/releases.md)
- [Commits](https://github.com/microsoft/fluentui-system-icons/commits)

Updates `@types/node` from 25.6.0 to 25.8.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@vitejs/plugin-react` from 6.0.1 to 6.0.2
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.2/packages/plugin-react)

Updates `eslint` from 10.2.1 to 10.4.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.2.1...v10.4.0)

Updates `globals` from 17.5.0 to 17.6.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.5.0...v17.6.0)

Updates `typescript-eslint` from 8.59.1 to 8.59.3
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.3/packages/typescript-eslint)

Updates `vite` from 8.0.10 to 8.0.13
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.13/packages/vite)

---
updated-dependencies:
- dependency-name: "@fluentui/react-icons"
  dependency-version: 2.0.326
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: deps
- dependency-name: "@types/node"
  dependency-version: 25.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: deps
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: deps
- dependency-name: eslint
  dependency-version: 10.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: deps
- dependency-name: globals
  dependency-version: 17.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: deps
- dependency-name: typescript-eslint
  dependency-version: 8.59.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: deps
- dependency-name: vite
  dependency-version: 8.0.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: deps
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-16 08:24:46 +12:00
dependabot[bot] 462dab9e3e build(deps): Bump the all group with 2 updates (#32)
Bumps Ical.Net from 5.2.1 to 5.2.2
Bumps Microsoft.AspNetCore.OpenApi from 10.0.7 to 10.0.8

---
updated-dependencies:
- dependency-name: Ical.Net
  dependency-version: 5.2.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: Microsoft.AspNetCore.OpenApi
  dependency-version: 10.0.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-16 08:24:26 +12:00
dependabot[bot] b03ff5c61c build(deps): Bump the react group across 1 directory with 3 updates (#17)
Bumps the react group with 3 updates in the /app directory: [react](https://github.com/facebook/react/tree/HEAD/packages/react), [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) and [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom).


Updates `react` from 19.2.3 to 19.2.6
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.6/packages/react)

Updates `@types/react` from 19.2.7 to 19.2.14
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `react-dom` from 19.2.3 to 19.2.6
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.6/packages/react-dom)

Updates `@types/react` from 19.2.7 to 19.2.14
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-version: 19.2.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: react
- dependency-name: "@types/react"
  dependency-version: 19.2.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: react
- dependency-name: react
  dependency-version: 19.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: react
- dependency-name: react-dom
  dependency-version: 19.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: react
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-16 08:17:28 +12:00
dependabot[bot] e33acd4fc4 build(deps): Bump ghcr.io/devcontainers/features/node in the all group (#28)
Bumps the all group with 1 update: ghcr.io/devcontainers/features/node.


Updates `ghcr.io/devcontainers/features/node` from 1.7.1 to 2.0.0

---
updated-dependencies:
- dependency-name: ghcr.io/devcontainers/features/node
  dependency-version: 2.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: all
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-16 08:14:22 +12:00
dependabot[bot] 916c7bcb22 build(deps): Bump the deps group across 1 directory with 13 updates (#31)
Bumps the deps group with 13 updates in the /app directory:

| Package | From | To |
| --- | --- | --- |
| [@fluentui/react-components](https://github.com/microsoft/fluentui) | `9.72.9` | `9.73.8` |
| [@fluentui/react-icons](https://github.com/microsoft/fluentui-system-icons) | `2.0.316` | `2.0.325` |
| [@fluentui/react-motion-components-preview](https://github.com/microsoft/fluentui) | `0.14.2` | `0.15.4` |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.2` | `10.0.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.0.3` | `25.6.0` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `5.1.2` | `6.0.1` |
| [eslint](https://github.com/eslint/eslint) | `9.39.2` | `10.2.1` |
| [eslint-plugin-react-hooks](https://github.com/facebook/react/tree/HEAD/packages/eslint-plugin-react-hooks) | `7.0.1` | `7.1.1` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.4.26` | `0.5.2` |
| [globals](https://github.com/sindresorhus/globals) | `16.5.0` | `17.5.0` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.9.3` | `6.0.3` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.51.0` | `8.59.1` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `7.3.0` | `8.0.10` |



Updates `@fluentui/react-components` from 9.72.9 to 9.73.8
- [Release notes](https://github.com/microsoft/fluentui/releases)
- [Commits](https://github.com/microsoft/fluentui/commits)

Updates `@fluentui/react-icons` from 2.0.316 to 2.0.325
- [Changelog](https://github.com/microsoft/fluentui-system-icons/blob/main/docs/releases.md)
- [Commits](https://github.com/microsoft/fluentui-system-icons/commits)

Updates `@fluentui/react-motion-components-preview` from 0.14.2 to 0.15.4
- [Release notes](https://github.com/microsoft/fluentui/releases)
- [Commits](https://github.com/microsoft/fluentui/compare/@fluentui/react-motion-components-preview_v0.14.2...@uifabric/styling_v0.15.4)

Updates `@eslint/js` from 9.39.2 to 10.0.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js)

Updates `@types/node` from 25.0.3 to 25.6.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@vitejs/plugin-react` from 5.1.2 to 6.0.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.1/packages/plugin-react)

Updates `eslint` from 9.39.2 to 10.2.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.2...v10.2.1)

Updates `eslint-plugin-react-hooks` from 7.0.1 to 7.1.1
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/eslint-plugin-react-hooks@7.1.1/packages/eslint-plugin-react-hooks)

Updates `eslint-plugin-react-refresh` from 0.4.26 to 0.5.2
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.4.26...v0.5.2)

Updates `globals` from 16.5.0 to 17.5.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v16.5.0...v17.5.0)

Updates `typescript` from 5.9.3 to 6.0.3
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.3)

Updates `typescript-eslint` from 8.51.0 to 8.59.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.1/packages/typescript-eslint)

Updates `vite` from 7.3.0 to 8.0.10
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.10/packages/vite)

---
updated-dependencies:
- dependency-name: "@fluentui/react-components"
  dependency-version: 9.73.8
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: deps
- dependency-name: "@fluentui/react-icons"
  dependency-version: 2.0.325
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: deps
- dependency-name: "@fluentui/react-motion-components-preview"
  dependency-version: 0.15.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: deps
- dependency-name: "@eslint/js"
  dependency-version: 10.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: deps
- dependency-name: "@types/node"
  dependency-version: 25.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: deps
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: deps
- dependency-name: eslint
  dependency-version: 10.2.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: deps
- dependency-name: eslint-plugin-react-hooks
  dependency-version: 7.1.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: deps
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: deps
- dependency-name: globals
  dependency-version: 17.5.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: deps
- dependency-name: typescript
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: deps
- dependency-name: typescript-eslint
  dependency-version: 8.59.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: deps
- dependency-name: vite
  dependency-version: 8.0.10
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: deps
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-16 08:13:48 +12:00
dependabot[bot] 882e196ea8 build(deps): Bump the all group across 1 directory with 7 updates (#29)
Bumps the all group with 7 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `6` | `7` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `6` | `7` |
| [docker/metadata-action](https://github.com/docker/metadata-action) | `5` | `6` |
| [docker/login-action](https://github.com/docker/login-action) | `3` | `4` |
| [actions/configure-pages](https://github.com/actions/configure-pages) | `5` | `6` |
| [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) | `4` | `5` |
| [actions/deploy-pages](https://github.com/actions/deploy-pages) | `4` | `5` |



Updates `docker/build-push-action` from 6 to 7
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

Updates `actions/upload-artifact` from 6 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

Updates `docker/metadata-action` from 5 to 6
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

Updates `docker/login-action` from 3 to 4
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

Updates `actions/configure-pages` from 5 to 6
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v5...v6)

Updates `actions/upload-pages-artifact` from 4 to 5
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v4...v5)

Updates `actions/deploy-pages` from 4 to 5
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: all
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: all
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: all
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: all
- dependency-name: actions/configure-pages
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: all
- dependency-name: actions/upload-pages-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: all
- dependency-name: actions/deploy-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: all
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-16 08:13:24 +12:00
dependabot[bot] fa39e8d26c build(deps): Bump the all group with 2 updates (#30)
Bumps Ical.Net from 5.2.0 to 5.2.1
Bumps Microsoft.AspNetCore.OpenApi from 10.0.1 to 10.0.7

---
updated-dependencies:
- dependency-name: Ical.Net
  dependency-version: 5.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all
- dependency-name: Microsoft.AspNetCore.OpenApi
  dependency-version: 10.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-16 08:12:52 +12:00
dependabot[bot] f4d1d4e983 build(deps): Bump the all group with 1 update (#12)
Bumps Ical.Net from 5.1.3 to 5.2.0

---
updated-dependencies:
- dependency-name: Ical.Net
  dependency-version: 5.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-03 20:13:26 +03:00
dependabot[bot] c0e6ced376 build(deps): Bump actions/upload-artifact from 5 to 6 in the all group
Bumps the all group with 1 update: [actions/upload-artifact](https://github.com/actions/upload-artifact).


Updates `actions/upload-artifact` from 5 to 6
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: all
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-03 20:11:32 +03:00
dependabot[bot] 452e6d51b2 build(deps): Bump the deps group in /app with 8 updates
Bumps the deps group in /app with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [@fluentui/react-components](https://github.com/microsoft/fluentui) | `9.72.8` | `9.72.9` |
| [@fluentui/react-motion-components-preview](https://github.com/microsoft/fluentui) | `0.14.1` | `0.14.2` |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.1` | `9.39.2` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.0.0` | `25.0.3` |
| [eslint](https://github.com/eslint/eslint) | `9.39.1` | `9.39.2` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.4.24` | `0.4.26` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.49.0` | `8.51.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `7.2.7` | `7.3.0` |


Updates `@fluentui/react-components` from 9.72.8 to 9.72.9
- [Release notes](https://github.com/microsoft/fluentui/releases)
- [Commits](https://github.com/microsoft/fluentui/compare/@fluentui/react-components_v9.72.8...@fluentui/react-components_v9.72.9)

Updates `@fluentui/react-motion-components-preview` from 0.14.1 to 0.14.2
- [Release notes](https://github.com/microsoft/fluentui/releases)
- [Commits](https://github.com/microsoft/fluentui/compare/@fluentui/react-motion-components-preview_v0.14.1...@fluentui/react-motion-components-preview_v0.14.2)

Updates `@eslint/js` from 9.39.1 to 9.39.2
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v9.39.2/packages/js)

Updates `@types/node` from 25.0.0 to 25.0.3
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint` from 9.39.1 to 9.39.2
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.1...v9.39.2)

Updates `eslint-plugin-react-refresh` from 0.4.24 to 0.4.26
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.4.24...v0.4.26)

Updates `typescript-eslint` from 8.49.0 to 8.51.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.51.0/packages/typescript-eslint)

Updates `vite` from 7.2.7 to 7.3.0
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.0/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.0/packages/vite)

---
updated-dependencies:
- dependency-name: "@fluentui/react-components"
  dependency-version: 9.72.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: deps
- dependency-name: "@fluentui/react-motion-components-preview"
  dependency-version: 0.14.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: deps
- dependency-name: "@eslint/js"
  dependency-version: 9.39.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: deps
- dependency-name: "@types/node"
  dependency-version: 25.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: deps
- dependency-name: eslint
  dependency-version: 9.39.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: deps
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.4.26
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: deps
- dependency-name: typescript-eslint
  dependency-version: 8.51.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: deps
- dependency-name: vite
  dependency-version: 7.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: deps
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-03 20:11:01 +03:00
dependabot[bot] 9b74bb63c5 build(deps): Bump the react group in /app with 2 updates
Bumps the react group in /app with 2 updates: [react](https://github.com/facebook/react/tree/HEAD/packages/react) and [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom).


Updates `react` from 19.2.1 to 19.2.3
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.3/packages/react)

Updates `react-dom` from 19.2.1 to 19.2.3
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.3/packages/react-dom)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: react
- dependency-name: react-dom
  dependency-version: 19.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: react
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-03 20:10:34 +03:00
xfox111 ea6dbf2d8f feat(dev): improved logging for failed calendar generation 2025-12-12 18:16:18 +00:00
xfox111 c6d91d7020 fix: added handling for missing professors field 2025-12-12 18:15:47 +00:00
xfox111 610b7909cd chore(dev): migrate to slnx 2025-12-11 03:02:37 +00:00
xfox111 2560f124f1 chore(ci): tweak dependabot 2025-12-11 03:02:19 +00:00
dependabot[bot] f1324ce1d1 build(deps): Bump Microsoft.AspNetCore.OpenApi from 10.0.0 to 10.0.1 ([#9](https://github.com/XFox111/bonch-calendar/issues/9))
---
updated-dependencies:
- dependency-name: Microsoft.AspNetCore.OpenApi
  dependency-version: 10.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-11 03:00:54 +00:00
dependabot[bot] 97751cf20b build(deps): Bump the react group across 1 directory with 3 updates (#7)
Bumps the react group with 3 updates in the /app directory: [react](https://github.com/facebook/react/tree/HEAD/packages/react), [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) and [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom).


Updates `react` from 19.2.0 to 19.2.1
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.1/packages/react)

Updates `@types/react` from 19.2.6 to 19.2.7
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `react-dom` from 19.2.0 to 19.2.1
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.1/packages/react-dom)

Updates `@types/react` from 19.2.6 to 19.2.7
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: react
- dependency-name: "@types/react"
  dependency-version: 19.2.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: react
- dependency-name: react-dom
  dependency-version: 19.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: react
- dependency-name: "@types/react"
  dependency-version: 19.2.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: react
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-11 05:58:24 +03:00
dependabot[bot] 14f1b4e7b5 build(deps): Bump actions/checkout from 5 to 6 (#6)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-11 05:57:01 +03:00
dependabot[bot] b932b49b65 Bump Ical.Net from 5.1.2 to 5.1.3 (#8)
---
updated-dependencies:
- dependency-name: Ical.Net
  dependency-version: 5.1.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-11 05:56:23 +03:00
dependabot[bot] 52d980534e build(deps): Bump the deps group across 1 directory with 6 updates (#10)
Bumps the deps group with 6 updates in the /app directory:

| Package | From | To |
| --- | --- | --- |
| [@fluentui/react-components](https://github.com/microsoft/fluentui) | `9.72.7` | `9.72.8` |
| [@fluentui/react-icons](https://github.com/microsoft/fluentui-system-icons) | `2.0.315` | `2.0.316` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `24.10.1` | `25.0.0` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `5.1.1` | `5.1.2` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.47.0` | `8.49.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `7.2.2` | `7.2.7` |



Updates `@fluentui/react-components` from 9.72.7 to 9.72.8
- [Release notes](https://github.com/microsoft/fluentui/releases)
- [Commits](https://github.com/microsoft/fluentui/compare/@fluentui/react-components_v9.72.7...@fluentui/react-components_v9.72.8)

Updates `@fluentui/react-icons` from 2.0.315 to 2.0.316
- [Commits](https://github.com/microsoft/fluentui-system-icons/commits)

Updates `@types/node` from 24.10.1 to 25.0.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@vitejs/plugin-react` from 5.1.1 to 5.1.2
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@5.1.2/packages/plugin-react)

Updates `typescript-eslint` from 8.47.0 to 8.49.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.49.0/packages/typescript-eslint)

Updates `vite` from 7.2.2 to 7.2.7
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.2.7/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.2.7/packages/vite)

---
updated-dependencies:
- dependency-name: "@fluentui/react-components"
  dependency-version: 9.72.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: deps
- dependency-name: "@fluentui/react-icons"
  dependency-version: 2.0.316
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: deps
- dependency-name: "@types/node"
  dependency-version: 25.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: deps
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 5.1.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: deps
- dependency-name: typescript-eslint
  dependency-version: 8.49.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: deps
- dependency-name: vite
  dependency-version: 7.2.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: deps
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-11 05:55:35 +03:00
xfox111 9d0f6a31d8 docs: add demo to readme 2025-11-19 09:01:46 +00:00
42 changed files with 2627 additions and 2487 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"image": "mcr.microsoft.com/devcontainers/dotnet:1-10.0",
// Features to add to the dev container. More info: https://containers.dev/features.
"features": {
"ghcr.io/devcontainers/features/node:1": {
"ghcr.io/devcontainers/features/node:2": {
"version": "lts",
"pnpmVersion": "none",
"nvmVersion": "latest"
+16 -4
View File
@@ -48,7 +48,10 @@ updates:
schedule:
interval: monthly
rebase-strategy: disabled
open-pull-requests-limit: 20
groups:
all:
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
@@ -58,7 +61,10 @@ updates:
schedule:
interval: monthly
rebase-strategy: disabled
open-pull-requests-limit: 20
groups:
all:
patterns:
- "*"
- package-ecosystem: "devcontainers"
directory: "/"
@@ -68,7 +74,10 @@ updates:
schedule:
interval: monthly
rebase-strategy: disabled
open-pull-requests-limit: 20
groups:
all:
patterns:
- "*"
- package-ecosystem: "docker"
directories:
@@ -80,4 +89,7 @@ updates:
schedule:
interval: monthly
rebase-strategy: disabled
open-pull-requests-limit: 20
groups:
all:
patterns:
- "*"
+8 -8
View File
@@ -31,16 +31,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: docker/build-push-action@v6
- uses: docker/build-push-action@v7
with:
context: ./api
tags: ${{ github.repository }}-api:ci
- run: docker save ${{ github.repository }}:ci | gzip > api_image.tar.gz
- uses: actions/upload-artifact@v5
- uses: actions/upload-artifact@v7
with:
name: api-image
path: api_image.tar.gz
@@ -49,16 +49,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: docker/build-push-action@v6
- uses: docker/build-push-action@v7
with:
context: ./app
tags: ${{ github.repository }}-app:ci
- run: docker save ${{ github.repository }}:ci | gzip > app_image.tar.gz
- uses: actions/upload-artifact@v5
- uses: actions/upload-artifact@v7
with:
name: app-image
path: app_image.tar.gz
@@ -68,7 +68,7 @@ jobs:
container: node:latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- run: npm install
working-directory: ./app
@@ -82,7 +82,7 @@ jobs:
- run: npm audit --audit-level=moderate --json > audit_report.json
working-directory: ./app
- uses: actions/upload-artifact@v5
- uses: actions/upload-artifact@v7
with:
name: app-audit-report
path: ./app/audit_report.json
+14 -14
View File
@@ -13,9 +13,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: docker/metadata-action@v5
- uses: docker/metadata-action@v6
id: meta
with:
images: |
@@ -26,19 +26,19 @@ jobs:
${{ github.ref_name }}
- name: "Login to Docker Hub"
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
- uses: docker/build-push-action@v7
with:
context: ./api
push: true
@@ -48,9 +48,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: docker/metadata-action@v5
- uses: docker/metadata-action@v6
id: meta
with:
images: |
@@ -61,19 +61,19 @@ jobs:
${{ github.ref_name }}
- name: "Login to Docker Hub"
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
- uses: docker/build-push-action@v7
with:
context: ./app
push: true
@@ -93,7 +93,7 @@ jobs:
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- run: npm install
working-directory: ./app
@@ -104,12 +104,12 @@ jobs:
working-directory: ./app
- name: Setup Pages
uses: actions/configure-pages@v5
uses: actions/configure-pages@v6
- uses: actions/upload-pages-artifact@v4
- uses: actions/upload-pages-artifact@v5
with:
path: "./app/dist"
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 Eugene Fox
Copyright (c) 2026 Eugene Fox
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+2 -2
View File
@@ -14,7 +14,7 @@ This is my farewell gift to the university.
## Demo
<!-- TODO: put demo here -->
![Demo](/assets/demo.gif)
## Q&A
@@ -45,4 +45,4 @@ If you're interested in becoming a maintainer, please reach out to me via email
[![GitHub](https://img.shields.io/badge/%40xfox111-GitHub?logo=github&logoColor=%23181717&label=GitHub&labelColor=white&color=%23181717)](https://github.com/xfox111)
[![Buy Me a Coffee](https://img.shields.io/badge/%40xfox111-BMC?logo=buymeacoffee&logoColor=black&label=Buy%20me%20a%20coffee&labelColor=white&color=%23FFDD00)](https://buymeacoffee.com/xfox111)
> ©2025 Eugene Fox. Licensed under [MIT license](https://github.com/XFox111/bonch-calendar/blob/main/LICENSE)
> ©2026 Eugene Fox. Licensed under [MIT license](https://github.com/XFox111/bonch-calendar/blob/main/LICENSE)
+4
View File
@@ -3,6 +3,10 @@
##
## Get latest from `dotnet new gitignore`
# Solution files
*.sln
*.slnx
# dotenv files
.env
+2
View File
@@ -480,3 +480,5 @@ $RECYCLE.BIN/
# Vim temporary swap files
*.swp
core
+18
View File
@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
using BonchCalendar.Health;
namespace BonchCalendar;
/// <summary>
/// Custom JSON serializer context for static serialization/deserialization of requests and responses
/// </summary>
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(string[]))]
[JsonSerializable(typeof(int))]
[JsonSerializable(typeof(bool))]
[JsonSerializable(typeof(StatsResponse))]
[JsonSerializable(typeof(Dictionary<int, string>))]
[JsonSerializable(typeof(HealthResponse))]
internal partial class AppJsonSerializerContext : JsonSerializerContext
{
}
+3 -3
View File
@@ -4,13 +4,13 @@
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PublishAot>true</PublishAot>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.4.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
<PackageReference Include="Ical.Net" Version="5.1.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="Ical.Net" Version="5.2.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
</ItemGroup>
</Project>
+12 -1
View File
@@ -5,6 +5,11 @@ Accept: application/json
###
GET {{Host}}/stats
Accept: application/json
###
GET {{Host}}/faculties
Accept: application/json
@@ -12,7 +17,7 @@ Accept: application/json
GET {{Host}}/groups
?facultyId=56682
&course=2
&year=2
Accept: application/json
###
@@ -20,4 +25,10 @@ Accept: application/json
@groupId = 56606
@facultyId = 50029
GET {{Host}}/timetable/{{facultyId}}/{{groupId}}
?id=download
Accept: text/calendar
# id parameter changes behavior for the calendar:
# - If set to "download", nothing changes
# - If not present, an additional event will be appended to the calendar (see Program.cs)
# - If set to any other value, this request will be counted in active users stats (only once per ID until the next restart)
-34
View File
@@ -1,34 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BonchCalendar", "BonchCalendar.csproj", "{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Debug|x64.ActiveCfg = Debug|Any CPU
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Debug|x64.Build.0 = Debug|Any CPU
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Debug|x86.ActiveCfg = Debug|Any CPU
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Debug|x86.Build.0 = Debug|Any CPU
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Release|Any CPU.Build.0 = Release|Any CPU
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Release|x64.ActiveCfg = Release|Any CPU
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Release|x64.Build.0 = Release|Any CPU
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Release|x86.ActiveCfg = Release|Any CPU
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
+3
View File
@@ -0,0 +1,3 @@
<Solution>
<Project Path="BonchCalendar.csproj" />
</Solution>
+15 -9
View File
@@ -1,15 +1,21 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine-aot AS build
WORKDIR /build
ADD *.csproj .
RUN dotnet restore
ADD --link . .
RUN --mount=type=cache,target=/root/.nuget \
--mount=type=cache,target=/source/bin \
--mount=type=cache,target=/source/obj \
dotnet publish --output /out BonchCalendar.csproj \
&& rm /out/*.dbg /out/*.Development.json
ADD . ./
RUN dotnet publish --no-restore --configuration Release --output /out
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS prod
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine AS prod
WORKDIR /app
COPY --from=build /out/* .
COPY --link --from=build /out/* .
USER $APP_UID
ENTRYPOINT [ "dotnet", "BonchCalendar.dll" ]
HEALTHCHECK --interval=60s --retries=3 --start-period=5s --timeout=10s \
CMD wget --no-verbose --tries 1 --spider http://localhost:8080/health || exit 1
EXPOSE 8080
ENTRYPOINT [ "./BonchCalendar" ]
+8
View File
@@ -0,0 +1,8 @@
// This file is used by Code Analysis to maintain SuppressMessage
// attributes that are applied to this project.
// Project-level suppressions either have no target or are given
// a specific target and scoped to a namespace, type, member, etc.
using System.Diagnostics.CodeAnalysis;
[assembly: SuppressMessage("Performance", "CA1873:Avoid potentially expensive logging", Justification = "The cost is negligible on current setup.")]
+12 -12
View File
@@ -3,24 +3,24 @@ using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace BonchCalendar.Health;
public class ApiHealthCheck(ApiService groupService) : IHealthCheck
/// <summary>
/// Healthcheck service for sut.ru API.
/// </summary>
public class ApiHealthCheck(IssueTrackingService trackingService) : IHealthCheck
{
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken cancellationToken = default
)
{
try
{
Dictionary<int, string> faculties = await groupService.GetFacultiesListAsync();
Dictionary<string, object> report = trackingService.GetReport();
if (faculties.Count > 0)
return HealthCheckResult.Healthy();
// We deem service "unhealthy" if any of the last requests to the API were unsuccessful.
if (report.Count > 0)
return HealthCheckResult.Unhealthy(
description: "We're having issues with fetching data from the timetable website.",
data: report
);
return HealthCheckResult.Degraded(description: "Timetable website looks to be up, but returned an empty list of faculties.");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy(description: "Timetable website appears to be down.", exception: ex);
}
return HealthCheckResult.Healthy();
}
}
+80
View File
@@ -0,0 +1,80 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace BonchCalendar.Health;
public static class HealthCheckWriter
{
private static readonly byte[] _emptyResponse = [ (byte)'{', (byte)'}' ];
private static readonly JsonSerializerContext _jsonContext = CreateSerializerContext();
public static async Task WriteHealthCheckResponse(HttpContext context, HealthReport report)
{
if (report is null)
{
// Just in case, but this should not ever happen.
await context.Response.BodyWriter.WriteAsync(_emptyResponse).ConfigureAwait(false);
return;
}
// Create DTO from the report.
HealthResponse response = new(
Status: report.Status,
TotalDuration: report.TotalDuration,
Entries: report.Entries.ToDictionary(
e => e.Key,
e => new HealthResponseEntry(
Status: e.Value.Status,
Description: e.Value.Description,
Duration: e.Value.Duration,
Data: e.Value.Data
)
)
);
// Write DTO to the response body.
context.Response.ContentType = "application/json; charset=utf-8";
await JsonSerializer.SerializeAsync(context.Response.Body, response, typeof(HealthResponse), _jsonContext).ConfigureAwait(false);
}
private static AppJsonSerializerContext CreateSerializerContext()
{
JsonSerializerOptions options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
options.Converters.Add(new JsonStringEnumConverter<HealthStatus>(options.PropertyNamingPolicy));
options.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
return new AppJsonSerializerContext(options);
}
}
/// <summary>
/// Response body for /health endpoint.
/// </summary>
/// <param name="Status">Overall status of the application.</param>
/// <param name="TotalDuration">Total time it took to complete the health check.</param>
/// <param name="Entries">List of subcomponent healthcheck reports.</param>
public record HealthResponse(
HealthStatus Status,
TimeSpan TotalDuration,
IReadOnlyDictionary<string, HealthResponseEntry> Entries
);
/// <summary>
/// Healthcheck report for a subcomponent.
/// </summary>
/// <param name="Status">Status of the subcomponent.</param>
/// <param name="Description">Report remarks.</param>
/// <param name="Duration">Time it took to complete health check for this subcomponent.</param>
/// <param name="Data">Addtional report data.</param>
public record HealthResponseEntry(
HealthStatus Status,
string? Description,
TimeSpan Duration,
IReadOnlyDictionary<string, object> Data
);
+163 -58
View File
@@ -4,19 +4,23 @@ using BonchCalendar;
using BonchCalendar.Health;
using BonchCalendar.Services;
using BonchCalendar.Utils;
using HealthChecks.UI.Client;
using Ical.Net;
using Ical.Net.CalendarComponents;
using Ical.Net.Serialization;
using Ical.Net.DataTypes;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.Mvc;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args);
// Adding static JSON serializer since we're running in Native AOT
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default)
);
// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
builder.Services.AddValidation();
builder.Services.AddOpenApi(); // OpenAPI specification generator
builder.Services.AddValidation(); // Request validation
// Customizing non-200 responses to include trace identifier and a no-as-a-service reason
builder.Services.AddProblemDetails(configure =>
{
configure.CustomizeProblemDetails = context =>
@@ -27,104 +31,205 @@ builder.Services.AddProblemDetails(configure =>
});
builder.Services
.AddScoped<ApiService>()
.AddScoped<ParsingService>();
.AddSingleton<IssueTrackingService>() // Service for tracking latest API request results
.AddScoped<TimetableService>() // Service for generating a timetable
.AddScoped<ApiService>() // Service for making API calls to sut.ru API
.AddScoped<ParsingService>(); // Service for parsing timetable from sut.ru
builder.Services.AddHealthChecks()
.AddCheck<ApiHealthCheck>("timetable_website");
.AddCheck<ApiHealthCheck>("timetable_website"); // Healthcheck service
// Get ORIGIN_DOMAIN environmental variable
string? corsDomain = Environment.GetEnvironmentVariable("ORIGIN_DOMAIN");
// Configure defautl CORS policy
builder.Services.AddCors(options =>
options.AddDefaultPolicy(policy =>
{
// Allow only GET requests with any headers
policy
.WithMethods(["GET"])
.AllowAnyOrigin()
.AllowAnyHeader()
)
.AllowAnyHeader();
// If ORIGIN_DOMAIN environmental variable is set, allow request only from this domain,
// otherwise allow request from any domains.
if (string.IsNullOrWhiteSpace(corsDomain))
policy.AllowAnyOrigin();
else
policy.WithOrigins(corsDomain);
})
);
WebApplication app = builder.Build();
// Configure the HTTP request pipeline.
app.UseCors();
app.UseStatusCodePages();
app.MapOpenApi();
// Configure the HTTP request pipeline
app.UseCors(); // Enable CORS
app.UseStatusCodePages(); // Enable default JSON response body for non-200 responses.
app.MapOpenApi(); // Map OpenAPI sepcification. Available at /openapi/v1.json
// Map healthcheck endpoint with custom response writer
// Remark: /health and /openapi/v1.json endpoints are not present in OpenAPI specification.
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
ResponseWriter = HealthCheckWriter.WriteHealthCheckResponse
});
// Request singleton services which will be used in subsequent endpoints
ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>();
IssueTrackingService tracker = app.Services.GetRequiredService<IssueTrackingService>();
// List of identifiers for tracking unique visits to /timetable endpoint (essentially, for unique user counting).
List<string> ids = [];
// Statistics endpoint. Shows number of active users.
app.MapGet("/stats", () => Results.Ok(new StatsResponse(ids.Count)))
.WithName("GetStats")
.WithDescription("Get basic usage statistics.")
.Produces<StatsResponse>(StatusCodes.Status200OK);
// Retrieve list of faculties and their IDs from sut.ru API
app.MapGet("/faculties", async ([FromServices] ApiService apiService) =>
{
logger.LogInformation("Fetching faculties list.");
Dictionary<int, string> faculties = await apiService.GetFacultiesListAsync();
return Results.Ok(faculties);
try
{
Dictionary<int, string> faculties = await apiService.GetFacultiesListAsync();
logger.LogInformation("Fetched {Count} faculties.", faculties.Count);
tracker.TrackFacultyFetch(true); // Record that last attempt to retrieve faculties was successful
return Results.Ok(faculties);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch faculties list.");
tracker.TrackFacultyFetch(false); // Record that last attempt to retrieve faculties was unsuccessful
return Results.Problem("Failed to fetch faculties list.", statusCode: StatusCodes.Status500InternalServerError);
}
})
.WithName("GetFaculties")
.WithDescription("Gets the list of faculties.")
.ProducesProblem(StatusCodes.Status500InternalServerError)
.Produces<Dictionary<int, string>>(StatusCodes.Status200OK);
app.MapGet("/groups", async ([FromServices] ApiService apiService, int facultyId, [Range(1, 5)] int course) =>
// Retrieve list of groups for a chosen faculty and year.
// Year can be in range from 1 to 5.
// If year is not specified, all groups for chosen faculty will be retrieved.
app.MapGet("/groups", async ([FromServices] ApiService apiService, int facultyId, [Range(1, 5)] int? year) =>
{
logger.LogInformation("Fetching groups list for faculty {FacultyId} and course {Course}.", facultyId, course);
Dictionary<int, string> groups = await apiService.GetGroupsListAsync(facultyId, course);
return Results.Ok(groups);
year ??= 0; // Setting year to 0 (show all groups) if not specified in the request.
try
{
Dictionary<int, string> groups = await apiService.GetGroupsListAsync(facultyId, year.Value);
logger.LogInformation("Fetched {Count} groups (facultyId: {FacultyId}, year: {Year}).", groups.Count, facultyId, year);
tracker.TrackGroupFetch(facultyId, year.Value, true); // Track whether retrieving groups for this specific faculty and year was successul or not.
return Results.Ok(groups);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch groups list (facultyId: {FacultyId}, year: {Year}).", facultyId, year);
tracker.TrackGroupFetch(facultyId, year.Value, false); // Track whether retrieving groups for this specific faculty and year was successul or not.
return Results.Problem(
"Failed to fetch groups list.",
statusCode: StatusCodes.Status500InternalServerError,
extensions: new Dictionary<string, object?>
{
["facultyId"] = facultyId,
["year"] = year
}
);
}
})
.WithName("GetGroups")
.WithDescription("Gets the list of groups for the specified faculty and course.")
.Produces<Dictionary<int, string>>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status500InternalServerError)
.ProducesValidationProblem();
// Retrieve timetable for specified group in form of iCal.
app.MapGet("/timetable/{facultyId}/{groupId}", async (
int facultyId, int groupId,
[FromServices] ApiService apiService,
[FromServices] ParsingService parsingService
int facultyId, int groupId, string? id,
[FromServices] TimetableService timetableService
) =>
{
logger.LogInformation("Generating timetable for group {GroupId} of faculty {FacultyId}.", groupId, facultyId);
string cacheFile = Path.Combine(Path.GetTempPath(), $"bonch_cal_{groupId}.ics");
string? content = await timetableService.TryGetTimetableFromCacheAsync(groupId);
bool hasId = !string.IsNullOrEmpty(id);
if (File.Exists(cacheFile) && (DateTime.UtcNow - File.GetLastWriteTimeUtc(cacheFile)).TotalHours < 6)
// If this is the first request with given id, we record it.
// "download" is a special "ID" that is used solely for downloading timetable as file.
if (hasId && id is not "download" && !ids.Contains(id!))
ids.Add(id!);
// If we have a valid cache, we serve it, instead of retrieving new timetable from sut.ru API
// We don't serve cache for requests with no ID, since they have a special behavior.
if (content is not null && hasId)
{
if (args.Contains("--no-cache"))
logger.LogWarning("Cache disabled via --no-cache, regenerating timetable for group {GroupId}.", groupId);
logger.LogInformation("Serving timetable for {FacultyId}/{GroupId} from cache.", facultyId, groupId);
return Results.Text(content, contentType: "text/calendar");
}
try
{
logger.LogInformation("Begin generating timetable for {FacultyId}/{GroupId}.", facultyId, groupId);
if (hasId)
content = await timetableService.GetTimetableAsync(facultyId, groupId, saveToCache: true);
else
{
logger.LogInformation("Serving timetable for group {GroupId} from cache.", groupId);
return Results.Text(await File.ReadAllTextAsync(cacheFile), contentType: "text/calendar");
// For requests with no ID we append an event at 7pm that asks users to update their calendar URL,
// since now all /timetable requests must have one.
// This is a temporary behavior that will be changed to just sending 4xx response instead later.
content = await timetableService.GetTimetableAsync(facultyId, groupId, saveToCache: false, transform: calendar =>
calendar.Events.Add(new()
{
Summary = "Важно: обновите календарь расписания",
Description = """
Ваша ссылка на календарь устарела. Пожалуйста, обновите ее чтобы продолжить пользоватся сервисом.
Новая ссылка позволит нам собирать статистику о количестве активных пользователей. Важно: мы НЕ собираем какие-либо персональные данные! Новая ссылка лишь позволит нам узнать точное количесво пользователей, что очень важно для продолжения работы сервиса.
Для того чтобы обновить ссылку:
1. Перейдите на сайт https://bonch.xfox111.net/
2. Повторите все действия что и при создании календаря
3. Удалите старый календарь
Просим прощения за доставленные неудобства.
Если возникнут вопросы обращайтесь на почту feedback@xfox111.net
Это событие будет появляться каждый день в 19:00 до тех пор, пока ссылка не будет обновлена.
""",
Location = "https://bonch.xfox111.net",
Start = new CalDateTime((DateTime.Today + TimeSpan.FromHours(16)).ToUniversalTime()),
End = new CalDateTime((DateTime.Today + TimeSpan.FromHours(16) + TimeSpan.FromMinutes(15)).ToUniversalTime()),
})
);
logger.LogInformation("Deprecation notice appended to calendar {FacultyId}/{GroupId}.", facultyId, groupId);
}
logger.LogInformation("Fetched timetable for {FacultyId}/{GroupId}.", facultyId, groupId);
tracker.TrackTimetableFetch(facultyId, groupId, true); // Track whether retrieving timetable for this specific group was successul or not.
return Results.Text(content, contentType: "text/calendar");
}
DateTime semesterStartDate = await apiService.GetSemesterStartDateAsync(groupId);
string groupName = (await apiService.GetGroupsListAsync(facultyId, 0))[groupId];
string classesRaw = await apiService.GetScheduleDocumentAsync(groupId, TimetableType.Classes);
List<CalendarEvent> timetable = [.. parsingService.ParseGeneralTimetable(classesRaw, semesterStartDate, groupName)];
TimetableType[] types = [TimetableType.Attestations, TimetableType.Exams, TimetableType.ExamsForExtramural];
foreach (TimetableType type in types)
catch (Exception ex)
{
classesRaw = await apiService.GetScheduleDocumentAsync(groupId, type);
timetable.AddRange(parsingService.ParseExamTimetable(classesRaw, groupName));
logger.LogError(ex, "Failed to generate timetable for {FacultyId}/{GroupId}.", facultyId, groupId);
tracker.TrackTimetableFetch(facultyId, groupId, false); // Track whether retrieving timetable for this specific group was successul or not.
return Results.Problem(
"Failed to fetch timetable",
statusCode: StatusCodes.Status500InternalServerError,
extensions: new Dictionary<string, object?>
{
["facultyId"] = facultyId,
["groupId"] = groupId
}
);
}
Calendar calendar = new();
calendar.Properties.Add(new CalendarProperty("X-WR-CALNAME", groupName));
calendar.Properties.Add(new CalendarProperty("X-WR-TIMEZONE", "Europe/Moscow"));
calendar.Properties.Add(new CalendarProperty("REFRESH-INTERVAL;VALUE=DURATION", "PT6H"));
calendar.Events.AddRange(timetable);
calendar.AddTimeZone(new VTimeZone("Europe/Moscow"));
string serialized = new CalendarSerializer().SerializeToString(calendar)!;
await File.WriteAllTextAsync(cacheFile, serialized);
logger.LogInformation("Cached timetable for group {GroupId} to {CacheFile}.", groupId, cacheFile);
return Results.Text(serialized, contentType: "text/calendar");
})
.WithName("GetTimetable")
.WithDescription("Gets the iCal timetable for the specified group.")
.Produces<string>(StatusCodes.Status200OK, "text/calendar")
.ProducesProblem(StatusCodes.Status500InternalServerError)
.ProducesValidationProblem();
// Start the application.
app.Run();
+58 -7
View File
@@ -4,8 +4,19 @@ using BonchCalendar.Utils;
namespace BonchCalendar.Services;
// For better understanding of what is happening here,
// I recommend visiting https://cabinet.sut.ru/raspisanie_all_new.php
// and trying to send requests yourself.
/// <summary>
/// Service for calling sut.ru API.
/// </summary>
public class ApiService
{
/// <summary>
/// Retrieve list of faculties and their IDs.
/// </summary>
/// <returns>A dictionary, where the key is faculty's ID and the value is faculty's name.</returns>
public async Task<Dictionary<int, string>> GetFacultiesListAsync() =>
ParseListResponse(await SendRequestAsync(new()
{
@@ -13,15 +24,30 @@ public class ApiService
["schet"] = GetCurrentSemesterId()
}));
public async Task<Dictionary<int, string>> GetGroupsListAsync(int facultyId, int course) =>
/// <summary>
/// Retrieve list of groups for specified faculty and year.
/// </summary>
/// <param name="facultyId">ID of selected faculty.</param>
/// <param name="year">An academic year. Should be from 0 to 5.</param>
/// <returns>A dictionary, where the key is group's ID and the value is group's name.</returns>
/// <remarks>
/// If <paramref name="year"/> is set to 0, all groups for the specified faculty will be retrieved instead.
/// </remarks>
public async Task<Dictionary<int, string>> GetGroupsListAsync(int facultyId, int year) =>
ParseListResponse(await SendRequestAsync(new()
{
["choice"] = "1",
["schet"] = GetCurrentSemesterId(),
["faculty"] = facultyId.ToString(), // Specifying faculty ID returns a list of groups
["kurs"] = course.ToString() // Course number is actually optional, but filters out other groups. Can be set 0 to get all groups of the faculty.
["kurs"] = year.ToString() // Course number is actually optional, but filters out other groups. Can be set 0 to get all groups of the faculty.
}));
/// <summary>
/// Retrieve timetable document for the specified group.
/// </summary>
/// <param name="groupId">ID of selected group.</param>
/// <param name="timetableType">Type of a timetable to retrieve.</param>
/// <returns>A string, represeting raw HTML document, that contains the timetable.</returns>
public async Task<string> GetScheduleDocumentAsync(int groupId, TimetableType timetableType) =>
await SendRequestAsync(new()
{
@@ -30,14 +56,34 @@ public class ApiService
["group"] = groupId.ToString()
});
/// <summary>
/// Retrieve current semester start date.
/// </summary>
/// <param name="groupId">ID of a group.</param>
/// <returns>A <see cref="DateTime"/> object, representing the first day of current semester.</returns>
/// <remarks>
/// <paramref name="groupId"/> can be any valid group ID. We only need it for retrieving a correct HTML document.
/// </remarks>
public async Task<DateTime> GetSemesterStartDateAsync(int groupId)
{
using HttpClient client = new();
// We go to this URL, since it has to contain current week number,
// which we can use to calculate the first day of the semester.
// If we don't specify group, we'll get a page listing all available groups,
// which doesn't contain current week number, thus, rendering it useless for us.
string content = await client.GetStringAsync($"https://www.sut.ru/studentu/raspisanie/raspisanie-zanyatiy-studentov-ochnoy-i-vecherney-form-obucheniya?group={groupId}");
using IHtmlDocument doc = new HtmlParser().ParseDocument(content);
// 1. Get <a> tag with id "rasp-prev"
// 2. Get it's neighbor <div> tag that is the second child of their parent tag
// 3. Get <span> tag inside the <div>
string labelText = doc.QuerySelector("a#rasp-prev + div:nth-child(2) > span")!.TextContent;
// Content of the <span> tag is supposed to be something like "Нечетная неделя (15)"
// So, we can use regular expressions to get the "15" part and parse it to an integer.
int weekNumber = int.Parse(ParserUtils.NumberRegex().Match(labelText).Value);
DateTime currentDate = DateTime.Today;
currentDate = currentDate
.AddDays(-(int)currentDate.DayOfWeek + 1) // Move to Monday
@@ -46,6 +92,8 @@ public class ApiService
return currentDate;
}
// Utility method that converts faculty or group list response into a dictionary.
// It expected the reponse to be in format: "1,Group 1;2,Group2;..."
private static Dictionary<int, string> ParseListResponse(string responseContent) =>
responseContent
.Split(';', StringSplitOptions.RemoveEmptyEntries)
@@ -55,7 +103,8 @@ public class ApiService
parts => parts[1]
);
public async Task<string> SendRequestAsync(Dictionary<string, string> formData)
// Utility method for sending request to sut.ru API.
private static async Task<string> SendRequestAsync(Dictionary<string, string> formData)
{
HttpRequestMessage request = new(HttpMethod.Post, "https://cabinet.sut.ru/raspisanie_all_new.php")
{
@@ -64,7 +113,8 @@ public class ApiService
using HttpClient client = new(new HttpClientHandler
{
// Sometimes Bonch being Bonch just doesn't renew its SSL certificates properly
// Sometimes Bonch being Bonch just doesn't renew its SSL certificates properly,
// so we just assume that we're in the right place.
ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) => true
});
@@ -81,11 +131,12 @@ public class ApiService
? 1 // August through January - first semester
: 2; // Everything else - second
int termStartYear = now.Year - 2000; // We need only last two digits (e.g. 25 for 2025)
int academicYearStartYear = now.Year - 2000; // We need only last two digits (e.g. 25 for 2025)
// P.S. I am not a fun of this variable name either.
if (now.Month < 8) // Before August means we are in the second semester of the previous academic year
termStartYear--;
academicYearStartYear--;
return $"205.{termStartYear}{termStartYear + 1}/{currentSemester}";
return $"205.{academicYearStartYear}{academicYearStartYear + 1}/{currentSemester}";
}
}
+101
View File
@@ -0,0 +1,101 @@
namespace BonchCalendar.Services;
/// <summary>
/// Service that tracks results of the most recent requests.
/// </summary>
public class IssueTrackingService
{
private bool _isLastFacultyFetchSuccessful = true;
private readonly List<string> _unsuccessfulGroupFetches = [];
private readonly List<string> _unsuccessfulTimetableFetches = [];
/// <summary>
/// Record whether the last attempt to retrieve faculty list was successful.
/// </summary>
/// <param name="isSuccessful">Set <c>true</c> if the attempt was successful. Otherwise, set <c>false</c></param>
public void TrackFacultyFetch(bool isSuccessful) =>
_isLastFacultyFetchSuccessful = isSuccessful;
/// <summary>
/// Record whether the last attempt to retrieve groups for provided faculty and term year was successful.
/// </summary>
/// <param name="facultyId">ID of a faculty which was used to retrieve the group list.</param>
/// <param name="termYear">Term year which was used to retrieve the group list.</param>
/// <param name="isSuccessful">Set <c>true</c> if the attempt was successful. Otherwise, set <c>false</c></param>
public void TrackGroupFetch(int facultyId, int termYear, bool isSuccessful)
{
string key = $"{facultyId}/{termYear}";
if (!isSuccessful)
{
if (!_unsuccessfulGroupFetches.Contains(key))
_unsuccessfulGroupFetches.Add(key);
}
else
_unsuccessfulGroupFetches.Remove(key);
}
/// <summary>
/// Record whether the last attempt to retrieve timetable for provided group was successful.
/// </summary>
/// <param name="facultyId">ID of a faculty the group belongs to.</param>
/// <param name="groupId">ID of a group the timetable was retrieved for.</param>
/// <param name="isSuccessful">Set <c>true</c> if the attempt was successful. Otherwise, set <c>false</c></param>
public void TrackTimetableFetch(int facultyId, int groupId, bool isSuccessful)
{
string key = $"{facultyId}/{groupId}";
if (!isSuccessful)
{
if (!_unsuccessfulTimetableFetches.Contains(key))
_unsuccessfulTimetableFetches.Add(key);
}
else
_unsuccessfulTimetableFetches.Remove(key);
}
/// <summary>
/// Get report on the success of latest retrieval attempts.
/// </summary>
/// <returns>A dictionary for each of the tracked groups.</returns>
/// <remarks>
/// If the dictionary is empty, that means that there're no known issues.
/// </remarks>
public Dictionary<string, object> GetReport()
{
Dictionary<string, object> report = [];
if (!_isLastFacultyFetchSuccessful)
report.Add("/faculties", false);
if (_unsuccessfulGroupFetches.Count > 0)
report.Add("/groups", _unsuccessfulGroupFetches.ToArray());
if (_unsuccessfulTimetableFetches.Count > 0)
report.Add("/timetable", _unsuccessfulTimetableFetches.ToArray());
// No issues example:
/*
* { }
*/
// Report example with issues:
/*
* {
* "/faculties": false,
* "/groups": [
* "123/1",
* "321/3"
* ],
* "/timetable": [
* "123/321",
* "456/654"
* ],
* }
*/
return report;
}
}
+48 -19
View File
@@ -11,6 +11,13 @@ namespace BonchCalendar.Services;
public partial class ParsingService
{
/// <summary>
/// Parse general timetable document.
/// </summary>
/// <param name="rawHtml">HTML document retrieved from the API.</param>
/// <param name="semesterStartDate"><see cref="DateTime"/> that represents the first day of current semester.</param>
/// <param name="groupName">Name of a group this timetable is for.</param>
/// <returns>An array of <see cref="CalendarEvent"/>s</returns>
public CalendarEvent[] ParseGeneralTimetable(string rawHtml, DateTime semesterStartDate, string groupName)
{
using IHtmlDocument doc = new HtmlParser().ParseDocument(rawHtml);
@@ -27,7 +34,7 @@ public partial class ParsingService
Match timeMatch = ParserUtils.TimeLabelRegex().Match(timeLabelText);
string number = timeMatch.Success ? timeMatch.Groups["number"].Value : timeLabelText;
(TimeSpan startTime, TimeSpan endTime) = !timeMatch.Success ?
ParserUtils.GetTimesFromLabel(timeLabelText) :
ParserUtils.GetTimesFromLabel(timeLabelText) : // If the label for some reason doesn't contain start and end time, we can infer it from class' number
(
TimeSpan.Parse(timeMatch.Groups["start"].Value),
TimeSpan.Parse(timeMatch.Groups["end"].Value)
@@ -44,16 +51,26 @@ public partial class ParsingService
.AddDays((week - 1) * 7) // Move to the correct week
.AddDays(weekday - 1); // Move to the correct weekday
classes.Add(GetEvent(
$"{number}. {className} ({classType})", auditorium,
GetDescription(groupName, professors, auditorium, weeks),
classDate, startTime, endTime));
classes.Add(CreateEvent(
title: $"{number}. {className} ({classType})",
location: auditorium,
description: CreateDescription(groupName, professors, auditorium, weeks),
date: classDate,
startTime,
endTime
));
}
}
return [.. classes];
}
/// <summary>
/// Parse exam timetable document.
/// </summary>
/// <param name="rawHtml">HTML document, retrieved from the API.</param>
/// <param name="groupName">Name of a group this timetable is for.</param>
/// <returns>An array of <see cref="CalendarEvent"/>s</returns>
public CalendarEvent[] ParseExamTimetable(string rawHtml, string groupName)
{
using IHtmlDocument doc = new HtmlParser().ParseDocument(rawHtml);
@@ -77,26 +94,32 @@ public partial class ParsingService
TimeSpan startTime = TimeSpan.Parse(timeMatch.Groups["start"].Value.Replace('.', ':'));
TimeSpan endTime = TimeSpan.Parse(timeMatch.Groups["end"].Value.Replace('.', ':'));
classes.Add(GetEvent(
$"{number}{className} ({classType})", auditorium,
GetDescription(groupName, professors, auditorium),
classDate, startTime, endTime));
classes.Add(CreateEvent(
title: $"{number}{className} ({classType})",
location: auditorium,
description: CreateDescription(groupName, professors, auditorium),
date: classDate,
startTime,
endTime
));
}
return [.. classes];
}
private static CalendarEvent GetEvent(string title, string auditorium, string description, DateTime date, TimeSpan startTime, TimeSpan endTime) =>
// Create a calendar event
private static CalendarEvent CreateEvent(string title, string location, string description, DateTime date, TimeSpan startTime, TimeSpan endTime) =>
new()
{
Summary = title,
Description = description,
Start = new CalDateTime(date.Add(startTime - TimeSpan.FromHours(3)).ToUniversalTime()),
End = new CalDateTime(date.Add(endTime - TimeSpan.FromHours(3)).ToUniversalTime()),
Location = auditorium
Location = location
};
private static string GetDescription(string groupName, string[] professors, string auditorium, int[]? weeks = null)
// Create event description
private static string CreateDescription(string groupName, string[] professors, string auditorium, int[]? weeks = null)
{
string str = $"""
Группа: {groupName}
@@ -107,31 +130,37 @@ public partial class ParsingService
if (weeks is not null && weeks.Length > 0)
str += $"\nНедели: {string.Join(", ", weeks)}";
// Attempt to recognize wing and room number
Match auditoriumMatch = ParserUtils.AuditoriumRegex().Match(auditorium);
if (!auditoriumMatch.Success)
auditoriumMatch = ParserUtils.AuditoriumAltRegex().Match(auditorium);
// If successful, we can add a nav.sut.ru map link
if (auditoriumMatch.Success)
str += "\n\n" + $"""
ГУТ.Навигатор:
https://nav.sut.ru/?cab=k{auditoriumMatch.Groups["wing"].Value}-{auditoriumMatch.Groups["room"].Value}
""";
// Some shameless self-promotion
str += "\n\n" + "Создано при помощи сервиса Бонч.Календарь: https://bonch.xfox111.net";
return str;
}
// Parse basic info for a class
private static (string className, string classType, string[] professors, string auditorium) ParseBaseInfo(IElement classElement)
{
string className = classElement.QuerySelector(".subect")!.TextContent;
string classType = classElement.QuerySelector(".type")!.TextContent
.Replace("(", string.Empty).Replace(")", string.Empty).Trim();
string className = classElement.QuerySelector(".subect")?.TextContent ?? string.Empty;
string classType = classElement.QuerySelector(".type")?.TextContent
.Replace("(", string.Empty).Replace(")", string.Empty).Trim() ?? string.Empty;
string[] professors = classElement.QuerySelector(".teacher[title]")!.GetAttribute("title")
!.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
string[] professors = classElement.QuerySelector(".teacher[title]")?.GetAttribute("title")
?.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [];
string auditorium = classElement.QuerySelector(".aud")!.TextContent
.Replace("ауд.:", string.Empty).Replace(';', ',').Trim();
string auditorium = classElement.QuerySelector(".aud")?.TextContent
.Replace("ауд.:", string.Empty).Replace(';', ',').Trim() ?? string.Empty;
return (className, classType, professors, auditorium);
}
+89
View File
@@ -0,0 +1,89 @@
using Ical.Net;
using Ical.Net.CalendarComponents;
using Ical.Net.Serialization;
namespace BonchCalendar.Services;
/// <summary>
/// Service for retrieving timetable.
/// </summary>
public class TimetableService(
ApiService apiService,
ParsingService parsingService,
ILogger<TimetableService> logger,
IHostEnvironment environment
)
{
/// <summary>
/// Try to retrieve timetable from application's cache.
/// </summary>
/// <param name="groupId">ID of a group to retrieve timetable for.</param>
/// <returns><c>null</c> if cache for this timetable is not present, or is older than 6 hours. Otherwise, timetable content in iCal format.</returns>
public async Task<string?> TryGetTimetableFromCacheAsync(int groupId)
{
string cacheFile = GetCachePath(groupId);
if (!File.Exists(cacheFile) || (DateTime.UtcNow - File.GetLastWriteTimeUtc(cacheFile)).TotalHours >= 6)
return null;
if (environment.IsDevelopment())
{
logger.LogWarning("Caching is disabled for development environment.");
return null;
}
logger.LogInformation("Calendar for group {GroupId} is present in cache ({CacheFile}).", groupId, cacheFile);
return await File.ReadAllTextAsync(cacheFile);
}
/// <summary>
/// Retrieve timetable for specified group from sut.ru API.
/// </summary>
/// <param name="saveToCache">If set to <c>true</c>, result timetable will be wirtten to cache for that group.</param>
/// <param name="transform">Action delegate that can be used to manipulate the result <see cref="Calendar"/> object, before converting it to iCal.</param>
/// <returns>A string that contains timetable in iCal format.</returns>
public async Task<string> GetTimetableAsync(int facultyId, int groupId, bool saveToCache = true, Action<Calendar>? transform = null)
{
// We need semester start date, since the regular timetable is represented on sut.ru semester week numbers.
DateTime semesterStartDate = await apiService.GetSemesterStartDateAsync(groupId);
string groupName = (await apiService.GetGroupsListAsync(facultyId, 0))[groupId];
// Retrieve and parse regular timetable first, since it's the only timetable that has different structure.
string classesRaw = await apiService.GetScheduleDocumentAsync(groupId, TimetableType.Classes);
List<CalendarEvent> timetable = [.. parsingService.ParseGeneralTimetable(classesRaw, semesterStartDate, groupName)];
// Retrieve and parse other timetables using a loop, since they all can be parsed using the same parser.
TimetableType[] types = [TimetableType.Attestations, TimetableType.Exams, TimetableType.ExamsForExtramural];
foreach (TimetableType type in types)
{
classesRaw = await apiService.GetScheduleDocumentAsync(groupId, type);
timetable.AddRange(parsingService.ParseExamTimetable(classesRaw, groupName));
}
// Create and configure the calendar.
Calendar calendar = new();
calendar.AddTimeZone(new VTimeZone("Europe/Moscow"));
calendar.Properties.Add(new CalendarProperty("X-WR-CALNAME", groupName));
calendar.Properties.Add(new CalendarProperty("X-WR-TIMEZONE", "Europe/Moscow"));
calendar.Properties.Add(new CalendarProperty("REFRESH-INTERVAL;VALUE=DURATION", "PT6H")); // Specifies how often calendar client should poll for new timetable.
calendar.Events.AddRange(timetable);
transform?.Invoke(calendar); // If transform delegate is not null, invoke it.
// Serialize calendar to iCal format.
string content = new CalendarSerializer().SerializeToString(calendar)!;
if (saveToCache)
{
string cacheFile = GetCachePath(groupId);
await File.WriteAllTextAsync(cacheFile, content);
logger.LogInformation("Cache updated: {CacheFile}", cacheFile);
}
return content;
}
private static string GetCachePath(int groupId) =>
Path.Combine(Path.GetTempPath(), $"bonch_cal_{groupId}.ics");
}
+7
View File
@@ -0,0 +1,7 @@
namespace BonchCalendar;
/// <summary>
/// Response body object for /stats endpoint.
/// </summary>
/// <param name="ActiveUsers">Number of active users.</param>
public record StatsResponse(int ActiveUsers);
+18
View File
@@ -1,9 +1,27 @@
namespace BonchCalendar;
/// <summary>
/// Types of timetable documents retrieved from sut.ru API.
/// </summary>
public enum TimetableType
{
/// <summary>
/// Regular timetable document (Занятия).
/// </summary>
Classes = 1,
/// <summary>
/// Exams timetable document (Экзаменационная сессия).
/// </summary>
Exams = 2,
/// <summary>
/// Exams timetable for extramural students document (Сессия для заочников).
/// </summary>
ExamsForExtramural = 4,
/// <summary>
/// Attestations timetable document (Зачеты).
/// </summary>
Attestations = 14
}
+12
View File
@@ -2,8 +2,20 @@ using System.Text.RegularExpressions;
namespace BonchCalendar.Utils;
/// <summary>
/// Utility methods for timetable parser.
/// </summary>
public static partial class ParserUtils
{
/// <summary>
/// Get class start and end times from class' number label.
/// </summary>
/// <param name="label">Class' number label.</param>
/// <returns>A tuple value of start and end times.</returns>
/// <remarks>
/// This method is only supposed to be used as a fallback method of determening class' time
/// </remarks>
/// <exception cref="NotImplementedException">Unknown label encountered.</exception>
public static (TimeSpan startTime, TimeSpan endTime) GetTimesFromLabel(string label)
{
(string startTime, string endTime) = label switch
-8
View File
@@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+7 -7
View File
@@ -1,9 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
+69
View File
@@ -0,0 +1,69 @@
### This file contains exampels of HTTP requests to sut.ru API.
# You can use them for reference and better understanding of what I have to deal with.
# You can use a vscode extension (like REST Client) to make this file interactive.
# Current semester "ID" (must be updated before sending requests)
@schet=205.2526/2
# Breakdown:
# "205." part is static
# "2526" represents current academic year (2025-2026 in this case)
# "/2" represents current semester. Can be either "/1" (first semester) or "/2" (second semester)
# When making requests this ID must always point to current semester, otherwise you may get a broken response.
# Tip:
# From August through January is considered to be the first semester
# Other months (February-July) are considered to be the second semester
###
# Get list of faculties
POST https://cabinet.sut.ru/raspisanie_all_new.php
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
choice=1&schet={{schet}}
###
# Get list of groups for faculty
# Year filters out groups by the term year they are at.
# Year should be an integer from 0 to 5, inclusive.
# If year is set to 0, all groups for the chosen faculty will be received instead.
@facultyId=50029
@year=0
POST https://cabinet.sut.ru/raspisanie_all_new.php
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
choice=1&schet={{schet}}&faculty={{facultyId}}&kurs={{year}}
###
# Get timetable for selected group
# Type here can be on of the following:
# 1 - for regular timetable (Занятия)
# 2 - for exams timetable (Экзаменационная сессия)
# 4 - for exams timetable for extramural students (Сессия для заочников)
# 14 - for attestations timetable (Зачеты)
@type=1
@groupId=55512
POST https://cabinet.sut.ru/raspisanie_all_new.php
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
schet={{schet}}&type_z={{type}}&group={{groupId}}
###
# Get page that contains current week number
# We use this page because it's the only known page that contains publicly accessible semester week number.
# Since regular timetable doesn't show normal dates for classes, and instead uses week numbers,
# we need to know a date of the first day of current semester to calculate dates for them
# (e.g. 3 week tuesday is = first day + 3 * 7 + 1)
# Since we know current date and weekday, by knowing week number we can calculate date for the first day.
GET https://www.sut.ru/studentu/raspisanie/raspisanie-zanyatiy-studentov-ochnoy-i-vecherney-form-obucheniya?group={{groupId}}
+1
View File
@@ -1 +1,2 @@
VITE_BACKEND_HOST=https://api.bonch.xfox111.net
VITE_BACKEND_HOST=http://localhost:8080
+1480 -2264
View File
File diff suppressed because it is too large Load Diff
+18 -17
View File
@@ -10,25 +10,26 @@
"preview": "vite preview"
},
"dependencies": {
"@fluentui/react-components": "^9.72.7",
"@fluentui/react-icons": "^2.0.315",
"@fluentui/react-motion-components-preview": "^0.14.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-localization": "^2.0.6"
"@fluentui/react-components": "^9.73.8",
"@fluentui/react-icons": "^2.0.326",
"@fluentui/react-motion-components-preview": "^0.15.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-localization": "^2.0.6",
"uuid": "^14.0.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.6",
"@eslint/js": "^10.0.1",
"@types/node": "^25.8.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.47.0",
"vite": "^7.2.2"
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^10.4.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"typescript": "~6.0.3",
"typescript-eslint": "^8.59.3",
"vite": "^8.0.13"
}
}
+2
View File
@@ -5,6 +5,7 @@ import MainView from "./views/MainView";
import FaqView from "./views/FaqView";
import DedicatedView from "./views/DedicatedView";
import FooterView from "./views/FooterView";
import StatsView from "./views/StatsView";
export default function App(): ReactElement
{
@@ -15,6 +16,7 @@ export default function App(): ReactElement
<FluentProvider theme={ theme }>
<main className={ cls.root }>
<MainView />
<StatsView />
<FaqView />
<DedicatedView />
<FooterView />
+1
View File
@@ -16,6 +16,7 @@ const baseTheme: Partial<Theme> =
colorBrandBackground: "#f68b1f",
colorBrandBackgroundHover: "#c36e18",
colorNeutralForeground2BrandHover: "#c36e18",
colorNeutralForeground2BrandPressed: "#a95f15",
colorBrandBackgroundPressed: "#a95f15",
colorCompoundBrandStroke: "#f68b1f",
colorCompoundBrandStrokePressed: "#a95f15"
+61 -9
View File
@@ -1,11 +1,63 @@
export const fetchFaculties = async (): Promise<[string, string][]> =>
{
const res = await fetch(import.meta.env.VITE_BACKEND_HOST + "/faculties");
return Object.entries(await res.json());
};
const timeout: number = 5000;
export const fetchGroups = async (facultyId: string, course: number): Promise<[string, string][]> =>
export const fetchFaculties = (): Promise<Record<string, string>> =>
fetchApi("/faculties", {});
export const fetchGroups = (facultyId: string, year: number): Promise<Record<string, string>> =>
fetchApi(`/groups?facultyId=${facultyId}&year=${year}`, {});
export const fetchStats = async (): Promise<StatsResponse> =>
fetchApi("/stats", {
activeUsers: 0
});
export const fetchHealth = async (): Promise<HealthResponse> =>
fetchApi("/health", {} as HealthResponse, true);
async function fetchApi<T>(path: string, defaultValue: T, alwaysReturnResponse: boolean = false): Promise<T>
{
const res = await fetch(`${import.meta.env.VITE_BACKEND_HOST}/groups?facultyId=${facultyId}&course=${course}`);
return Object.entries(await res.json());
};
try
{
const res = await fetch(new URL(path, import.meta.env.VITE_BACKEND_HOST), {
signal: AbortSignal.timeout(timeout)
});
if (!res.ok && !alwaysReturnResponse)
return defaultValue;
return await res.json();
}
catch
{
return defaultValue;
}
}
export type StatsResponse =
{
activeUsers: number;
};
export type HealthResponse =
{
status: HealthStatus;
totalDuration: string;
entries: {
["timetable_website"]: TimetableHealthResponseEntry;
};
};
export type HealthStatus = "healthy" | "unhealthy" | "degraded";
export type TimetableHealthResponseEntry =
{
status: HealthStatus;
description?: string;
duration: string;
data:
{
"/faculties"?: false,
"/groups"?: string[],
"/timetable"?: string[];
};
};
+38 -2
View File
@@ -9,15 +9,33 @@ const strings = new LocalizedStrings({
subtitle_p1: "Check your SPbSUT classes in {0} calendar",
subtitle_p2: "your",
pickFaculty: "1. Pick your faculty",
pickCourse: "2. Pick your course",
pickCourse: "2. Pick your year",
pickGroup: "3. Pick your group",
pickGroup_empty: "No groups are available for the selected course",
pickGroup_empty: "No groups are available for the selected year",
subscribe: "4. Subscribe to the calendar",
copy: "Copy link",
or: "or",
download: "Download .ics file",
cta: "Like the service? Tell your classmates!",
// StatsView.tsx
users: "Active users: {0}",
status_ok: "Status: Operational",
status_unhealthy: "Status: Degraded",
report_title: "Service status report",
report_close: "Close",
report_subtitle_ok: "Service operates normally",
report_subtitle_unhealthy: "Active issues: {0}",
report_issue_backend: "Unable to connect to service's backend application.",
report_issue_faculties: "Last attempt to fetch faculties list resulted in an error.",
report_issue_groups: "Last attempt to fetch groups for following faculties resulted in an error:",
report_issue_groups_item: "{0} ({1}), {2} year",
report_issue_groups_item_alt: "Faculty ID: {0}, {1} year",
report_issue_timetable: "Last attempt to fetch timetable for following groups resulted in an error:",
report_issue_timetable_item_alt1: "Group ID: {0}, {1} ({2})",
report_issue_timetable_item_alt2: "{0} ({1}), Faculty ID: {2}",
report_issue_timetable_item_alt3: "Group ID: {0}, Faculty ID: {1}",
// FaqView.tsx
faq_h2: "Frequently asked questions",
question1_h3: "How do I save timetable to my Outlook/Google calendar?",
@@ -77,6 +95,24 @@ const strings = new LocalizedStrings({
download: "Скачай .ics файл",
cta: "Понравился сервис? Расскажи одногруппникам!",
// StatsView.tsx
users: "Пользователей: {0}",
status_ok: "Статус сервиса",
status_unhealthy: "Статус сервиса",
report_title: "Состояние сервиса",
report_close: "Закрыть",
report_subtitle_ok: "Сервис работает в нормальном режиме",
report_subtitle_unhealthy: "Известных проблем: {0}",
report_issue_backend: "Ошибка при подключении к серверу приложения.",
report_issue_faculties: "Ошибка при попытке получить список факультетов.",
report_issue_groups: "Ошибка при попытке получить список групп для следующих факультетов:",
report_issue_groups_item: "{0} ({1}), {2} курс",
report_issue_groups_item_alt: "ID факультета: {0}, {1} курс",
report_issue_timetable: "Ошибка при попытке получить расписание для следующих групп:",
report_issue_timetable_item_alt1: "ID группы: {0}, {1} ({2})",
report_issue_timetable_item_alt2: "{0} ({1}), ID факультета: {2}",
report_issue_timetable_item_alt3: "ID группы: {0}, ID факультета: {1}",
// FaqView.tsx
faq_h2: "Часто задаваемые вопросы",
question1_h3: "Как сохранить расписание в Outlook/Google календарь?",
+67
View File
@@ -0,0 +1,67 @@
import { type TimetableHealthResponseEntry, fetchFaculties, fetchGroups } from "./api";
import strings from "./strings";
export async function tryFormatNamesForReport(report?: TimetableHealthResponseEntry): Promise<TimetableHealthResponseEntry | undefined>
{
if (report === undefined)
return report;
if (report.status === "healthy")
return report;
const isGroupsDown: boolean = report.data["/groups"] !== undefined;
const isTimetableDown: boolean = report.data["/timetable"] !== undefined;
if (!isGroupsDown && !isTimetableDown)
return report;
let faculties: Record<string, string> | undefined = undefined;
try { faculties = await fetchFaculties(); }
catch { /* empty */ }
const facultiesFormatted: string[] = [];
if (report.data["/groups"] !== undefined)
for (const faculty of report.data["/groups"])
{
const [facultyId, course] = faculty.split("/");
if (faculties?.[facultyId] === undefined)
facultiesFormatted.push(strings.formatString(strings.report_issue_groups_item_alt, facultyId, course) as string);
else
facultiesFormatted.push(strings.formatString(strings.report_issue_groups_item, faculties[facultyId], facultyId, course) as string);
}
const groups: Record<string, Record<string, string>> = {};
const groupsFormatted: string[] = [];
if (report.data["/timetable"] !== undefined)
for (const group of report.data["/timetable"])
{
const [facultyId, groupId] = group.split("/");
if (groups[facultyId] === undefined)
try { groups[facultyId] = await fetchGroups(facultyId, 0); }
catch { /* empty */ }
if (groups[facultyId]?.[groupId] !== undefined && faculties?.[facultyId] !== undefined)
groupsFormatted.push(`${groups[facultyId][groupId]} (${groupId}), ${faculties[facultyId]} (${facultyId})`);
else if (faculties?.[facultyId] !== undefined)
groupsFormatted.push(strings.formatString(strings.report_issue_timetable_item_alt1, groupId, faculties[facultyId], facultyId) as string)
else if (groups[facultyId]?.[groupId] !== undefined)
groupsFormatted.push(strings.formatString(strings.report_issue_timetable_item_alt2, groups[facultyId][groupId], groupId, facultyId) as string)
else
groupsFormatted.push(strings.formatString(strings.report_issue_timetable_item_alt3, groupId, facultyId) as string)
}
return {
...report,
data: {
...report.data,
["/groups"]: facultiesFormatted.length > 0 ? facultiesFormatted : undefined,
["/timetable"]: groupsFormatted.length > 0 ? groupsFormatted : undefined
}
};
}
+1 -1
View File
@@ -8,7 +8,7 @@ const useStyles_MainView = makeStyles({
flexFlow: "column",
gap: tokens.spacingVerticalXXXL,
justifyContent: "center",
minHeight: "90vh",
minHeight: "85vh",
alignItems: "center",
padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalL}`,
+12 -6
View File
@@ -8,8 +8,9 @@ import useTimeout from "../hooks/useTimeout";
import useStyles_MainView from "./MainView.styles";
import { fetchFaculties, fetchGroups } from "../utils/api";
import strings from "../utils/strings";
import { v7 as uuid7 } from "uuid";
const facultiesPromise = fetchFaculties();
const facultiesPromise = fetchFaculties().then(Object.entries);
const getEntryOrEmpty = (entries: [string, string][], key: string): string =>
entries.find(i => i[0] === key)?.[1] ?? "";
@@ -25,6 +26,7 @@ export default function MainView(): ReactElement
const [groups, setGroups] = useState<[string, string][] | null>(null);
const [groupId, setGroupId] = useState<string>("");
const id = uuid7();
const icalUrl = useMemo(() => `${import.meta.env.VITE_BACKEND_HOST}/timetable/${facultyId}/${groupId}`, [groupId, facultyId]);
const [showCta, setShowCta] = useState<boolean>(false);
@@ -35,10 +37,10 @@ export default function MainView(): ReactElement
const copyLink = useCallback((): void =>
{
navigator.clipboard.writeText(icalUrl);
navigator.clipboard.writeText(icalUrl + "?id=" + id);
triggerCopy();
setShowCta(true);
}, [icalUrl, triggerCopy]);
}, [icalUrl, triggerCopy, id]);
const onFacultySelect = useCallback((_: SelectionEvents, data: OptionOnSelectData): void =>
{
@@ -59,7 +61,7 @@ export default function MainView(): ReactElement
setCourse(courseNumber);
setGroupId("");
setGroups(null);
fetchGroups(facultyId, courseNumber).then(setGroups);
fetchGroups(facultyId, courseNumber).then(Object.entries).then(setGroups);
}, [course, facultyId]);
return (
@@ -99,6 +101,7 @@ export default function MainView(): ReactElement
<Button key={ i }
className={ cls.courseButton }
appearance={ course === i ? "primary" : "secondary" }
disabled={ facultyId === "" }
onClick={ () => onCourseSelect(i) }>
{ i }
@@ -114,6 +117,7 @@ export default function MainView(): ReactElement
className={ cls.field }
positioning={ { pinned: true, position: "below" } }
value={ getEntryOrEmpty(groups ?? [], groupId) }
disabled={ course === 0 || groups === null }
onOptionSelect={ (_, e) => setGroupId(e.optionValue!) }>
{ groups?.map(([id, name]) =>
@@ -136,12 +140,13 @@ export default function MainView(): ReactElement
className={ mergeClasses(cls.field, copyActive && cls.copiedStyle) }
iconPosition="after"
title={ strings.copy }
disabled={ groupId === "" }
icon={ copyActive
? <Checkmark24Regular className={ cls.copyIcon } />
: <Copy24Regular className={ cls.copyIcon } />
}>
<span className={ cls.truncatedText }>{ icalUrl }</span>
<span className={ cls.truncatedText }>{ icalUrl + "?id=" + id }</span>
</Button>
</div>
</Slide>
@@ -151,7 +156,8 @@ export default function MainView(): ReactElement
<Button as="a"
appearance="subtle" icon={ <ArrowDownload24Regular /> }
onClick={ () => setShowCta(true) }
href={ icalUrl }>
disabled={ groupId === "" }
href={ icalUrl + "?id=download" }>
{ strings.download }
</Button>
+44
View File
@@ -0,0 +1,44 @@
import { makeStyles, tokens } from "@fluentui/react-components";
export const useStyles = makeStyles({
root:
{
display: "flex",
flexFlow: "column",
marginBottom: "80px"
},
container:
{
display: "flex",
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalMNudge}`,
gap: tokens.spacingHorizontalMNudge,
boxShadow: tokens.shadow4,
borderRadius: tokens.borderRadiusMedium
},
statsButton:
{
pointerEvents: "none"
},
statsButtonIcon:
{
color: tokens.colorBrandForeground1
},
statusIconHealthy:
{
color: tokens.colorStatusSuccessBorderActive,
},
statusIconUnhealthy:
{
color: tokens.colorStatusDangerBorderActive,
},
reportSubtitle:
{
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalS
},
reportContent:
{
userSelect: "text"
}
});
+129
View File
@@ -0,0 +1,129 @@
import { Button, Dialog, DialogBody, DialogContent, DialogSurface, DialogTitle, DialogTrigger, Divider, Subtitle2 } from "@fluentui/react-components";
import { ArrowTrendingLinesFilled, CheckmarkCircleFilled, Dismiss24Regular, WarningFilled } from "@fluentui/react-icons";
import { use, useMemo, type ReactElement } from "react";
import { fetchHealth, fetchStats, type StatsResponse, type TimetableHealthResponseEntry } from "../utils/api";
import strings from "../utils/strings";
import { tryFormatNamesForReport } from "../utils/tryFormatNamesForReport";
import { useStyles } from "./StatsView.styles";
const healthPromise = fetchHealth().then(i => i.entries?.["timetable_website"]).then(tryFormatNamesForReport);
const statsPromise = fetchStats();
export default function StatsView(): ReactElement
{
const cls = useStyles();
const health: TimetableHealthResponseEntry | undefined = use(healthPromise);
const stats: StatsResponse = use(statsPromise);
const issueCounter: number = useMemo(() =>
{
let counter: number = 0;
if (health === undefined)
return 1;
if (health.data["/faculties"] !== undefined)
counter++;
counter += health.data["/groups"]?.length ?? 0;
counter += health.data["/timetable"]?.length ?? 0;
return counter;
}, [health]);
return (
<div className={ cls.root }>
<div className={ cls.container }>
{ stats.activeUsers > 3 &&
<>
<Button
className={ cls.statsButton } tabIndex={ -1 }
icon={ <ArrowTrendingLinesFilled className={ cls.statsButtonIcon } /> }
appearance="subtle"
>
{ strings.formatString(strings.users, stats.activeUsers) }
</Button>
<Divider vertical />
</>
}
<Dialog>
<DialogTrigger>
{ health?.status === "healthy" ?
<Button icon={ <CheckmarkCircleFilled className={ cls.statusIconHealthy } /> } appearance="subtle">
{ strings.status_ok }
</Button>
:
<Button icon={ <WarningFilled className={ cls.statusIconUnhealthy } /> } appearance="subtle">
{ strings.status_unhealthy }
</Button>
}
</DialogTrigger>
<DialogSurface>
<DialogBody>
<DialogTitle
action={
<DialogTrigger action="close">
<Button
appearance="subtle"
aria-label={ strings.report_close }
icon={ <Dismiss24Regular /> }
/>
</DialogTrigger>
}
>
{ strings.report_title }
</DialogTitle>
<DialogContent className={ cls.reportContent }>
{ health?.status === "healthy" ?
<div className={ cls.reportSubtitle }>
<CheckmarkCircleFilled className={ cls.statusIconHealthy } fontSize={ 24 } />
<Subtitle2>{ strings.report_subtitle_ok }</Subtitle2>
</div>
:
<div className={ cls.reportSubtitle }>
<WarningFilled className={ cls.statusIconUnhealthy } fontSize={ 24 } />
<Subtitle2>
{ strings.formatString(strings.report_subtitle_unhealthy, issueCounter) }
</Subtitle2>
</div>
}
{ health?.status !== "healthy" &&
<ul>
{ health === undefined &&
<li>{ strings.report_issue_backend }</li>
}
{ health?.data["/faculties"] !== undefined &&
<li>{ strings.report_issue_faculties }</li>
}
{ health?.data["/groups"] !== undefined &&
<li>
{ strings.report_issue_groups }
<ul>
{ health.data["/groups"].map((i, index) =>
<li key={ index }>{ i }</li>
) }
</ul>
</li>
}
{ health?.data["/timetable"] !== undefined &&
<li>
{ strings.report_issue_timetable }
<ul>
{ health.data["/timetable"].map((i, index) =>
<li key={ index }>{ i }</li>
) }
</ul>
</li>
}
</ul>
}
</DialogContent>
</DialogBody>
</DialogSurface>
</Dialog>
</div>
</div>
);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

+2
View File
@@ -3,6 +3,8 @@ services:
image: xfox111/bonch-calendar-api:latest
build:
context: ./api
environment:
- ORIGIN_DOMAIN=localhost:8000
ports:
- 8080:8080