mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-11 20:27:43 +02:00
Support for rating system (#10124)
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: './tsconfig.json'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
*
|
||||
!/lib/**
|
||||
!CHANGELOG.md
|
||||
/lib/**/__tests__/
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
|
||||
"rigPackageName": "@hcengineering/platform-rig"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
|
||||
roots: ["./src"],
|
||||
coverageReporters: ["text-summary", "html"]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@hcengineering/rating",
|
||||
"version": "0.7.0",
|
||||
"main": "lib/index.js",
|
||||
"svelte": "src/index.ts",
|
||||
"types": "types/index.d.ts",
|
||||
"files": [
|
||||
"lib/**/*",
|
||||
"types/**/*",
|
||||
"tsconfig.json"
|
||||
],
|
||||
"author": "Copyright © Hardcore Engineering Inc.",
|
||||
"license": "EPL-2.0",
|
||||
"scripts": {
|
||||
"build": "compile",
|
||||
"build:watch": "compile",
|
||||
"format": "format src",
|
||||
"test": "jest --passWithNoTests --silent",
|
||||
"_phase:build": "compile transpile src",
|
||||
"_phase:test": "jest --passWithNoTests --silent",
|
||||
"_phase:format": "format src",
|
||||
"_phase:validate": "compile validate"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hcengineering/platform-rig": "^0.7.19",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-promise": "^6.1.1",
|
||||
"eslint-plugin-n": "^15.4.0",
|
||||
"eslint": "^8.54.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"eslint-config-standard-with-typescript": "^40.0.0",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.9.3",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"@types/jest": "^29.5.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hcengineering/core": "^0.7.10",
|
||||
"@hcengineering/platform": "^0.7.5",
|
||||
"@hcengineering/ui": "^0.7.0"
|
||||
},
|
||||
"repository": "https://github.com/hcengineering/platform",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import {
|
||||
Class,
|
||||
Domain,
|
||||
Ref,
|
||||
type AccountUuid,
|
||||
type AttachedDoc,
|
||||
type Blob,
|
||||
type Doc,
|
||||
type PersonId
|
||||
} from '@hcengineering/core'
|
||||
import { Asset, IntlString, plugin, Plugin } from '@hcengineering/platform'
|
||||
import { type AnyComponent } from '@hcengineering/ui/src/types'
|
||||
|
||||
// [date, created, updated, deleted, messages, todos, hours]
|
||||
export type DateCUD = [/* date */ number, /* created */ number, /* updated */ number, /* deleted */ number]
|
||||
|
||||
// Automatically calculated document rating.
|
||||
export interface DocRating extends AttachedDoc {
|
||||
// A current document rating
|
||||
rating: number
|
||||
|
||||
updates: number // Total number of document updates
|
||||
messages: number // Total number of child messages updates.
|
||||
children: number // A child updates.
|
||||
|
||||
stars: number // A number of stars earned
|
||||
reactions: number // A number of reactions earned
|
||||
}
|
||||
|
||||
export enum ReactionKind {
|
||||
Emoji = 0, // Just an emoji reaction, ❤️, 📌, 😊, 😕, 😠, 🤔
|
||||
Star = 1, // A paid ⭐ 🌟, or paid negative star ✭ ✭, value = 1 => star visible for anyone and used for starred, value = 0, visible for anyone, but not shown in starred.
|
||||
RateValue = 3 // A rating value from 0 to 10.
|
||||
}
|
||||
|
||||
export interface DocReaction extends AttachedDoc {
|
||||
// A reaction type identifier
|
||||
reactionType: ReactionKind
|
||||
value: number // +1, -1, 0..10 etc.
|
||||
|
||||
emoji?: string
|
||||
image?: Ref<Blob>
|
||||
}
|
||||
|
||||
export interface PersonRating extends Doc {
|
||||
accountId: AccountUuid
|
||||
|
||||
// A current person rating
|
||||
rating: number // Could be re calculated from values.
|
||||
|
||||
// Document authoring statistics
|
||||
months: DateCUD[] // Hold per month authoring stats
|
||||
|
||||
// create/update/delete Hold per domain authoring stats
|
||||
stats: Record<string, [number, number, number]>
|
||||
|
||||
socialIds: Record<PersonId, number>
|
||||
|
||||
// A special stats, stars on account and stars on documents, commentas authored by person
|
||||
starsEarned: number // All author document stars.
|
||||
reactionsEarned: number // All author document reactions
|
||||
|
||||
stars: number // Stars spend
|
||||
reactions: number // Reactions created
|
||||
|
||||
rageOperations?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const ratingId = 'rating' as Plugin
|
||||
|
||||
export const DOMAIN_RATING_REACTION = 'rating_reaction' as Domain
|
||||
|
||||
export const DOMAIN_PERSON_RATING = 'rating_person' as Domain
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
const ratingPlugin = plugin(ratingId, {
|
||||
class: {
|
||||
DocRating: '' as Ref<Class<DocRating>>,
|
||||
PersonRating: '' as Ref<Class<PersonRating>>,
|
||||
DocReaction: '' as Ref<Class<DocReaction>>
|
||||
},
|
||||
icon: {
|
||||
Rating: '' as Asset,
|
||||
StarYellow: '' as Asset,
|
||||
StarRed: '' as Asset,
|
||||
StarGreen: '' as Asset,
|
||||
StarBlue: '' as Asset
|
||||
},
|
||||
string: {
|
||||
Rating: '' as IntlString,
|
||||
RatingWidget: '' as IntlString,
|
||||
Level: '' as IntlString,
|
||||
MonthOps: '' as IntlString,
|
||||
MonthNoOps: '' as IntlString,
|
||||
// Shown when the Rating widget has no items to display (empty state)
|
||||
RatingWidgetEmpty: '' as IntlString,
|
||||
AddStar: '' as IntlString,
|
||||
AddStarAria: '' as IntlString
|
||||
},
|
||||
component: {
|
||||
RatingRing: '' as AnyComponent,
|
||||
RatingWidget: '' as AnyComponent,
|
||||
DocReactionPresenter: '' as AnyComponent,
|
||||
RatingActivities: '' as AnyComponent
|
||||
},
|
||||
ids: {
|
||||
RatingWidget: '' as any
|
||||
}
|
||||
})
|
||||
|
||||
export default ratingPlugin
|
||||
|
||||
export * from './utils'
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { PluginConfiguration } from '@hcengineering/core'
|
||||
import type { PersonRating } from '.'
|
||||
|
||||
export interface LevelInfo {
|
||||
level: number
|
||||
progress: number // 0-100 percentage to next level
|
||||
currentThreshold: number
|
||||
nextThreshold: number
|
||||
}
|
||||
|
||||
const BASE_MULTIPLIER = 1.5
|
||||
export function ratingToLevel (rating: number): number {
|
||||
// Convert experience rating to level (1-99) using logarithmic progression
|
||||
// Base: level 1 at rating 0, multiplier 1.5 per level
|
||||
// Level = 1 + log(rating + 1) / log(1.5)
|
||||
|
||||
const level = 1 + Math.log(Math.max(rating, 0) + 1) / Math.log(BASE_MULTIPLIER)
|
||||
|
||||
// Clamp to valid range 1-99
|
||||
return Math.max(1, Math.min(99, Math.floor(level)))
|
||||
}
|
||||
|
||||
export function getLevelInfo (rating: number): LevelInfo {
|
||||
// Calculate current level
|
||||
const currentLevel = ratingToLevel(rating)
|
||||
|
||||
// Calculate rating thresholds for current and next level
|
||||
// Inverse formula: rating = multiplier^(level - 1) - 1
|
||||
const currentThreshold = Math.round((Math.pow(BASE_MULTIPLIER, currentLevel - 1) - 1) * 100) / 100
|
||||
const nextThreshold = Math.round((Math.pow(BASE_MULTIPLIER, currentLevel) - 1) * 100) / 100
|
||||
|
||||
// Calculate progress within current level (0-100)
|
||||
const levelRange = nextThreshold - currentThreshold
|
||||
const progressInLevel = rating - currentThreshold
|
||||
const progress = levelRange > 0 ? Math.min(100, Math.max(0, (progressInLevel / levelRange) * 100)) : 0
|
||||
|
||||
return {
|
||||
level: currentLevel,
|
||||
progress,
|
||||
currentThreshold,
|
||||
nextThreshold
|
||||
}
|
||||
}
|
||||
|
||||
export function getRaiting (total: number, rating: PersonRating | undefined, list: PluginConfiguration[]): number {
|
||||
if (rating?.stats == null || total === 0) return 0
|
||||
let result = 0
|
||||
for (const name of list) {
|
||||
const st = rating.stats[name.pluginId]
|
||||
if (st === undefined) {
|
||||
continue
|
||||
}
|
||||
result += ((st[0] + st[1] + st[2]) / total) * 100
|
||||
}
|
||||
return Math.round(result * 100) / 100
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json",
|
||||
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./lib",
|
||||
"declarationDir": "./types",
|
||||
"tsBuildInfoFile": ".build/build.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "lib", "dist", "types", "bundle"]
|
||||
}
|
||||
Reference in New Issue
Block a user