Addition of project Metrics admin page.

This commit is contained in:
Nick Beckley 2026-06-03 15:55:21 +01:00
parent 244d02b277
commit 24dd787536
24 changed files with 1050 additions and 57 deletions

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUS2/bOBD+KwLPpkKKFCUK2IPhtFgjTWMk7u5ibxQ5jInIokFRToMi/31BWa6dooe9CaN5fY/hD9SrPaAGLQ+HT0fo44AWKLopVJBCYCIwYVvKGiYaUuY1KwVl5F+0QO4O3lCDKq411RIwAwGYG1LillmNjQFJCTWkKHnqqZ4H1PxAyuUGjk5D7oe/IAzO96hBf7ve+Nc0W7lcd340efAdrPshql6nZb6uV3f4y3KzfdicsgYYUnHuDGqQopSAtgozIBZzpi2uiRS44i2zoEsra3kqc32E0KsuH8zLZb7xsYeomyIvWE5wIdH7AhkVVVq5VQNs3w5pi4mi2xRfTOHps/mBjhBQUyzOZJ7a3ejO3eyHdnSduYkqPEO0Qe3h1YcXOKoOLdAh+AOE6GDi5uEpu6xESU7yQhSEoAW6V3rnesjWt9lDNwHWNeFgGJZgDeaSKiw5Y9iwUlWFsFVVWLRAdxB66K663jsd/OBtzGbKsw9zVr6Prh/9OGTrPsJzUPFU91l1A6AF2oxt54bdIyjztvWPY/rXj13CshpDgD5mGxV32Z9q2KEGSVVrqqSSSgmtLZQELDFECVIZEEaA4YWpDJfcCC6JFly1lBtV0tamCFqghzEexnjm//vVEtvg9nswlw22E8ebTkXrw35toI/OuiTNOWMyVNdNqLK556pz0McPJKMGEQqFYTVgW1CBeW0UViWTWAgmLFAtq4KeCLPuefzJE7HWcGbLuhatbCmBwlY1J5VtidGWaWnKFqqiIlUNtdSEtCWndSs1MUBLbhK64M2o469WYJNAj2OfrjNbJ9ivrsffBb8wsgk+eu27JJjr4OltiLD/nWorvz/4wcWE387Sfhvg3vfp73z/M2dz8dLHSwzSHWSn0wNmVNUybEpTYW5si6WWFhvZKq0Zg7JM3W+9foGQJYMp10+anD318JQtg965CDqOIU3+ZwJ1kvPz+WQufORfP21XPsDycFjM0T+OiaRTs7MBPrwr34aUvvNDvII8Q3uCzp4Xu7KTgePpqXIpKIypwQiKC05LzKWkuJZlhduqbmUraij1xa7ZIxgXQMep3zaMadrM7O98uQzRWaXjkI7ni9eTnWZ/zim/Lnkl28/q0/Ar6WYrzZ0oSZZ9Oj2ck4P+17v5/v7+/h8AAAD//wMA7SBOIigGAAA=

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUTW/cNhCG/4owZ1HmlyhRN2OdoAs39SJZpEFuFDmKCcvUgqScGIb/e0HtFo5PRS8EMeIM33nmpV4gmEeEAa5Ppw9PGHKCGrLfQpxyRagiVByZGIQamGwo5VqJ7jvU4G/xGQbopLXMaiQCFRLpaEtGMVniHGpGmaO8laWm+ZFgeAHjG4dP3mKzpK8Yk18CDPC3D275We42vrHzsromLjPuQ8om2CLmr/3ulvx5fTjeHc6nEqaS3HgHAxjGKNrJEIF0IlLYifRUK9LJUUxo20n3+pzmQ8YYzNwk9/B2v1tywGwH3nDRUMI1vNbgTDZF8mgSHp9PRcWG6KbE6y28bYcXeMIIA6//hXkud2Vnf/WYxtXP7uq8ZpMe0rZOxuYlPkMNp7icMGaPG5/rlPBxnJ+PJj18PJ8p2/ThF9o1o9sta8gwABM91LABmmeT/RKqi8bd7DHk7WOOPiRv/6uWgBruvlSH2eRpiY/v5vHljHn/Pyh/MvbeB6z2NzAAZcid6JFMnCkie2eIaYUmSgk1IbO64wxq2C0h+7Aua6r2IeOPuLUEA3w0c8Kzvuto731Gm9dY+vymiq8OcXGrzdXbLBltaCMoLUlrPq25+ozOR7QZSxPHuF7qfX2fwhXfknZrjBhydTD5vvrDpHsYQJveMqONNkZZO2FLcaKOGkU7h8opdJK7zkktnZKaWiXNyKQzLRunEoEasFin2tyqZTcq7pDQQk9ORpGxx46MTLuWW2w1ltbeOFZ388bf9lSiE0Tj5IjUzBAthSBOtKbjauo6Pv2G5OIHRgtgh0/nd7cpUM716BQjXLKWSK0Z6XXbkbHrRz2qHltbNN9iDDj/BuqTt3FJy5Sri0Oqd+g+r6H8O6rNLT99IL+2Gd0s9gFjVWZsfChP5TLW19fX138AAAD//wMAZhfta4IEAAA=

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTy27UMBT9lcjrcepXnDi7agpiVKAjGAFi5/heU6upM3KcAqrm35Ezg6Ds2ETRlY/vefmZRPuIpCfXx+OrJ4x5JhuSwzoSTGjKNGXywGUvdc9VzVjDtGm+kg0Jt/iT9KRVznFnkErUSBWwhg7SOwqAhjMOTDSq3Gm/zaR/JjbUgE/BYT3NnzDNYYqkJ59DhOl72W1D7cZpgTpNI+7inG10hcz73faWvr3eH+7251MzzgVcByA9sZwzdN5SicxTJZ2nHTOatmqQHl3jTWfOsBAzpmjHeoaHP/thyhGz60UtZM2oMOS0IWCzLZQHO+Ph57GwWC26KfPNOl5/+2fyhIn0YvPbzPN1V24MV4/zsIQRrs7fbOeHovKYpiOmHHD1ZJU5jjaHKVaXTdsxYMxkQ+4+VvvRZj+lxxdGfTzr3/2H/EPZ/uoHuiUjbKclZtITrjjZkHfW3YeI1e6G9IRxFCA7pF5wTVUHltpGGqq11B65M60omO0Uc4jLtMzVLmb8llYBpCev7Tjjmfp1cvcho8tLKqq+6NKFfZpgcbn64z9nNaslYwW05OOSqw8IIaHLWPQd0nK57x+I0GIFbZeUMOZqb/N99cbO96QnxnaOW2ONtdo5jw1Dz4BZzVpADRpBCWhBGQVaGea0sgNXYBs++DIhG4Il7mptmDIIojS84aKjqu2AdsJx2gnh7eChsQgvfKzuxjUa1zGFIKlBD1QZbqlRUlKQjW2F9m0r/F+WXNLnrBgM+HR+KysDDdAhaE6F4g1VxnDamaalQ9sNZtAdNq5wvsUUcfzLqHfBpWmefK4u5aleWPdhieW9V2uRvodIf6wZ3UzuAVNVMrYhlnpfYj2dTqdfAAAA//8DALKjFbI2BAAA

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSTTW/cOAyG/4qhs+Xoy7LlWzDZRQdpN4M2aBd7k0W6I9SRBrKctgjmvy/kmaJNb70JlEi+fF7qhQT7hGQgt6fTX88Y8kJqkv0WEkxoyjRl8pHLQeqBq4Zxxg3v/iM18ff4nQykU85xZ5BK1EgVsJaOcnIUAA1nHJhoValpPy9keCHWN4DP3mETl4+YFh8DGcgnHyB+Lb2tb9wcV2hSnHEflmyDK2L+2e/u6dvbw+PD4fJqwaUkNx7IQCznDN1kqUQ2USXdRHtmNO3UKCd07WR6c0nzIWMKdm4W+PKzP8QcMLtBNEI2jApDzjUBm22RPNoFH7+fiooN0V2J11t4Ow4v5BkTGUT9A+al3I2b/Y2LT082wM3kg1+OpCanFE+YsscNxzbhPNvsY6iuTXazx5BJTR4+VIfZ5immp1eMPlxG3//B5O+sO/qA1f6ODIRxFCB7pJPgmqoeLLWtNFRrqSfkznSCk5rsYsg+rHFdqn3I+DltKslA/rbzghd9t8kdfUaX11Sk/6uL1/jN512EEmCkJocUYXW5+ombs4Y1kpXLhzWf1ly9R/AJXcYy02Nar+V/SxFabEm7NSUMuTrYfKze2OVIBmJs77g11lirnZuwZTgxYFazDlCDRlACOlBGgVaGOa3syBXYlo9TiRTdxd1qWyjHOg0cBFUaJFUSWzpaFFSD7KwU3di67hXW6mHe7HA9UwiSGpyAKsMtNUpKCrK1ndBT14npFyRXxzkrvAGfL19jU6ABegTNqVC8pcoYTnvTdnTs+tGMusfWFc33mALOv4B6512KS5xydV2Y6hW692so37valuerD/TbZtlddF8wVcVy60PZ5qvL5/P5/D8AAAD//wMAYTXFVSUEAAA=

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxU227cNhD9FYGvFRXeRJF6c20HXaSOjeyiLfo2Ioc2a5laUJSTwPC/F9LauaJAXwRhSM45c+bMPJEED0h6cnY8Xj5iKjOpSYlbSDChKdOUyQOXvdQ9Vw1jXdty/TepSXyHn0lPOuUcdxapRI1UedbSQQZHvUfLGfdMtGrNCbcz6Z8IxMbjY3TYTPMfmOc4JdKTP2Py08cVG2LjxmnxTZ5G3KW5QHIrmfe783f097Obw/XN6daM8/q4iZ70BDhn6AJQiSxQJV2ghllNOzXIgK4N1tjTs5gK5gRjM/v7r/h+KgmL60UjZMOosOS5Jh4KrJQHmPHw+biy2CS6WOP1Ft5++yfyiJn0on4V85TujRvjm4d5WOLo32xfUpNjno6YS8RNjet99ZUEZw1rhBaMkZpcgbuLCavdRXU9biU6wxR6SS0GT5XlQK2SknrZQid06DoRSE3eYU44fpP1Kro8zVMo1YvI1Xc4+7PzywTDiCvGWxhnJDXZzfsCycM4Jbz8hG4pp1yHvKzH51MqMS3TMle7VPA2w8v56/vzJWdMpbqBclf9BvMd6YkF4zhYsADauYAtw8A8A806j9pr9Er4ziurvFaWOa1g4MpDy4ewRkhNfl013C/O4Tx/ZbOZZBw3DtVLn87HiKl8JyPpCeMovDRIg+CaKuOBQist1VrqgNzZTvBXmAPkWyykJ8EMw8CcG4Sy3IGwIH1g0utgheg6UEG2nRJat6wD57tOcOVlhw5kkMwIUpObPPnFlR9bLbcG3OTpH3RllYr0RLO2NQ6GQWjFDViDimtltMXOqgBMSTcAbw0MA2fQMmeNcMC1BTX4NnxR6TLdxoQXcT6O8PkbXNPoRv7iAAwPoH64/jbDA36c8v37k42b95cHUpMPS1o3QrVbLfIxJvpJrxN9tYwlHu4ygkd/NXn82Ue4jku1TWhgQYNTjkouBFVBKzqA5NRytNYa2XHRkZpcTO4ec7U6DGJap+pLsut9dZbdXSzoypJXfn9tPK731c0IJUz54btN8k1hPwrQCMnZ2hmPj6dttHHU3hv0mlOheEuVtZwa23Z06MxgB22wdati10s5LqX6gD5mdGWr+MWLG+b5Hbr7n8V4dcGLRTlbvbY/bbFN2v+1xJ5r8oAwLxkftm3dP5FdSpg35IvlNIq7dBXHMc7opuRn0pvW8EYbLV4Y/uc9LXWjhTXPz8/P/wIAAP//AwDpyqsjGwYAAA==

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUS2/bOBD+KwLPpkJRFCUK2IPhtFgjTWMk7u5ibxQ5jInIpEFRToMi/31BWa6dooe9CaN5fY/hD+TkHlCLlofDpyO4OKAFinYKUUI5JhyTcluwtqJt1eSiFpyU7F+0QPYO3lCLaqZUoQTgEjhgpkmFu9IorDWIghSa0IqlnvJ5QO0PJG2u4WgV5H74C8JgvUMt+ts67V/TbGlz1ftR58H3sHZDlE6lZb6uV3f4y3KzfdicsgYYUnFuNWqRrlmheSOwMlRgxiTBHaEGC6U1F6STpjOnMusiBCf7fNAvl/naRwdRtTSnZU4wFeh9gbSMMq3cyQG2b4e0xUTRbYovpvD02f5ARwiopYszmad2N6q3N/uhG22vb6IMzxBNkHt49eEFjrJHC3QI/gAhWpi4eXjKLisVJCc55ZQQtED3Uu2sg2x9mz30CbBUDWGgSyzAaMxEIbFgZYl1WcmaclPXNAG+g+Cgv+p6b1XwgzcxmynPPsxZeRetG/04ZGsX4TnIeKr7LPsB0AJtxq63w+4RpH7b+scx/XNjn7CsxhDAxWwj4y77Uw471CIhG1VIIYWUXCkDFQFDNJGc1Bq45qAZ1bVmgmnOBFGcya5gWlZFZ1IELdDDGA9jPPP//WqJbbD7PejLBtuJ400vo/Fhv9bgojU2SXPOmAzV9xOqbO656i24+IFk1CJSANVlA9jQgmPWaIllVQrMeckNFErUtDgRZuzz+JMnYoxmpamahneiKwhQUzeM1KYjWplSCV11UNOa1A00QhHSVaxoOqGIhqJiOqELXo8q/mqFchLocXTpOrN1gv1qHf7O2YWRTfDRK98nwWwPT29DhP3vVFv5/cEPNib8Zpb22wD33qW/8/3PnM3FSx8vMUh3kE2nR6uOUMI4po0ymOma4g4kwaKsGK8bIAAKLdCtVy8QsmQwad2kydlTD0/ZMqidjaDiGNLkfyZQJzk/n0/mwkf+9dN25QMsD4fFHP3jmEg6NTsb4MO78m1I6Ts/xCvIM7Qn6M15sSs7aTieniqbglzrBjQvMGVFhZkQBW5EVeOubjrR8QYqdbFr9gjaBlBx6rcNY5o2M/s7Xy5DtEaqOKTj+eLVZKfZn3PKr0teyfaz+jT8SrrZSnOngiTLPp0ezslB/+vdfH9/f/8PAAD//wMABJt80ygGAAA=

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUwW7cNhCGX0WYsyiTEkWJuhnrBF24qRfJIi16ozijmLBMLUjKiWHsuxfUbpD6VPRCECPO8J9vfuoNvHkmGOD2dPrwQj5FKCG5LVTzWjGuGG+OQg5tM/C+kk0tRdv/DSW4e3qFATpprbCaWEOKmETesrGZLEMkLbhAXrcy1zTfIgxvYFyF9OIsVUv8SiG6xcMAfzqPy/d8t3GVnZcVq7DMtPcxGW+zmD/2u3v2++3h+HC4nIoUc3LlEAbATgpUvWZ2qjWT0nA28npi2iIqzUczjdMlzflEwZu5ivj0635ckqdkh7qqm4qzWsO5BDTJZMmjiXR8PWUVG6K7HC+38LYd3uCFAgx1+RPmpdyNnd3NcxxXN+PNZU0mPsVtnYxNS3iFEk5hOVFIjjY+tzHS8zi/Hk18+ng5k7fxww+yayLcLatPMIBoOihhAzTPJrnFF1eNu9mRT9vHFJyPzv5XrQZKePhSHGaTpiU8v5vHlwvm/f+g/MnYR+ep2N/BAFxQjU1PbKqFYrJHw0zbaKZUoyYSVne1gBJ2i0/Or8sai71P9C1sLcEAH80c6aLvNthHl8imNeQ+/1LZV4ew4GpT8WuWgle8ajjPSWs6ran4TOgC2US5iWNYr/W+vk+pVb0l7dYQyKfiYNJj8ZuJjzCANr0VRhttjLJ2opbTxJEbxTskhYpQ1tih1BKV1NwqaUYh0bRinHIESqBsnWJz69hMulNSsNaaiUkpiBmFxPpOj42Smqwc33EsHuacZ2zPJWHDNE3IpBaGadk0DJvWdLWauq7O/H8iufpB8AwY6eXy7jYFCrEnVILl18yk1oL1uu3Y2PWjHlVPrc2a7yl4mv8F6pOzYYnLlIqrQ4p36D6vPv87is0t351nP7YZ3S32iUKRZ2ycz0/lOtbz+Xz+BwAA//8DAK3VVNOCBAAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSTQW/cNhCF/4owZ1EmKYoSdTPWKbpwEy+SRVr0RnFGMWGZWlCUk8DY/x5Qu4GT3noRhAEf571vhq8Q7DNBD7en07sXCmmBEpLfSpJLzbhmvD4K1Td1z7tK1Uoa3v4LJfh7+g49tMo54QyxmjQxhbxhQz06hkhGcIFcNirfab8s0L+C9RXSi3dUzctnioufA/Twtw84f829ra/cNK9YxXmifViSDS6b+bDf3bO/bg/Hh8Pl1EJLFlceoQdslUDdGeZGaZhSlrOBy5EZh6gNH+w4jBeZD4lisFO14NNbf5xToOR6Wcm64kwaOJeANtlsebALHb+fsosN0V2ul1t5++1f4YUi9LL8CfNy3Y2b/M3zMqx+wpvLN9nlKac8xflEMXnamGwxp8kmP4fi2mk3eQoJSnj4VBwmm8Y5Pv8G6tMl//5/xD/m7u++kVsT4W5eQ4IehOJQwnvrHn2gYn8HPXBBEuuO2CiFZqpDy2xTG6Z1rUcSzrRSQAm7OSQf1nldin1I9CVuAaCHP+y00MX6bXSPPpFLa8yp/tF5Fw5xxtWl4o2/4BWvap6dPKzptKbiI6GP5BLlfMe4Xu/7j0RquYl2a4wUUnGw6bH40y6P0IOxnRPWWGOtdm6khtPIkVvNWySNmlBJbFEZhVoZ7rSyg1BoGzGMuQIlUB53sW2YwIGjVophbTRTQ9My46xkrtV80AYFbYjfOBYPU9ZZ13FFWDNDIzJlhGVG1TXDurGt1GPbyqz7ieQ6fcEzYKSXy1vZHGjEjlALJpVomDJGsM40LRvabjCD7qhx2fM9xUDTL6DeexfnZR5TcV2e4jd0H9eQ33uxLdJXH9i3bUZ3s3uiWOQZWx/yel/Hej6fzz8AAAD//wMAtGYnyDYEAAA=

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTwY7cKBD9FYuz8QDG2Pg26tlVWpPstJJRstobpoo0igdaGE8SjfrfV7g7Sia33NCDV/XqveKFBPOEZCS3p9NfzxjyQmqS/QYJJhRlirL2kcuxa0c2NLLVnZTyP1ITf4/fyUh6aS23GmmLCqkE1tGpdZYCoOaMAxOdLDXN54WML8T4BvDZW2zi8hHT4mMgI/nkA8SvpbfxjZ3jCk2KM+7Dkk2wRcw/+909fXt7eHw4XF4tuBRy44GMBHrJQQ2aWic0ldIwOjHhqLYASrPJuMldaD5kTMHMzQJffvaHmANmO4pGtA2jQpNzTcBkUyRPZsHH76eiYrPoruD1Bm/H8YU8YyKjqH+YeSl3Y2d/Y+PTkwlw43zwy5HU5JTiCVP2uNmxTTjPJvsYqmuT3ewxZFKThw/VYTbZxfT0yqMPl9H3fzD5O2OPPmC1vyMjYRwFtANSJ7iicgBDTddqqlSrHHKre8FJTXYxZB/WuC7VPmT8nDaVZCR/m3nBi77bZI8+o81rKtL/VSVr/ObzLkIBGKnJIUVYba5+2s1Zw5qWlcuHNZ/WXL1H8AltxjLTY1qv5X+jCCU20m5NCUOuDiYfqzdmOZKRaDNYbrTRxihrHXYMHQNmFOsBFSgEKaAHqSUoqZlV0kxcgun45ApSdJd0q22hBqE4KieoGGRLpRWc6nZCOvUd4tA5KUT3ytbqYS48YwcmEVqq0QGVmhuqZdtSaDvTC+X6XpQ4flhyTZyz4jfg8+VrbAoUwICgOBWSd1Rqzemgu55O/TDpSQ3Y2aL5HlPA+Rej3nmb4hJdrq4LU72y7v0ayveutuX56gP9tkV2F+0XTFWJ3PhQtvma8vl8Pv8PAAD//wMAZYD86yUEAAA=

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUXW/sJhD9KxavXRzAGBu/pZtcdZUmWd1dtVXfBhgnNA5eYZzcqyj/vcKb3HxcVeqLZQ0w58yZM/NEAtwj6cjp4XD+gCFNZEWSX0KCCUWZoqzac9nVVcfaUlZKK9X8TVbEX+B30pFGWsutRlqhQiodq6mpekudQ80Zd0zUMueEm4l0TwR86fDBWyzH6Q+Mkx8D6cifPrjxMWODL+0wzq6M44CbMCUINpO52qwv6O+n2/319nhrwik/Lr0jHXGN5E61mtpeaColMGqY6Km2zinNDPSmPz7zIWEMMJSTu3vDd2MKmGwnSlGVjApNnlfEQYJM2cCE+++HzGKR6CzHV0t4+e2eyANG0onVq5jHdCd28Cf3k5n94E6WL1mRQxwPGJPHRY3rXfFGgrOSlUIJxsiKXIK99QGLzVlxPeQSwbZMoquoxt5RqTlQLauKuqqGRqi+aUQu8QJjwOFd1ktv4ziNfSpeRC4+4OxO1+cBzIAZ4wsME5IV2Uy7BMHBMAY8/4Z2Tsdc+zjn4/UYkg/zOE/FJiS8ifBy/vp+PceIIRVbSLfFbzDdko5oaC0HDRpAWdtjzbBnjoFijUPlFDopXOOklk5JzaySYLh0UHPT5whZkV+zhrvZWpymNzaLSYZh4VC89Gk9eAzpg4ykI4yjcFWLtBdcUdk6oFBXmipVqR651Y3grzB7iDeYSEf61hjDrDVCam5BaKhczyqnei1E04Dsq7qRQqmaNWBd0wguXdWghaqvWCvIimzj6GabPre6WhqwjeM/aFOWinREsbpuLRgjlOQt6BYlV7JVGhste2CysgZ43YIxnEHNrG6FBa40SOPq/odK5+HGBzzz02GA7+9w21KV1S8WoOU9yE/Xv0S4x8cx3l0dbVxene/JinydQ94IxSZb5NEH+k3lib6ch+T3txHBobscHf7sI8zjUiwTKpVrNUpOeyF6KoWoKZjWUGF45ZwxTOvM5my0dxiL7DDwIU/Vj2TXu+I02luf0KY5Zn5/LTyud8V2gNSP8f7DJnlX2GcBSlFxljvj8OG4jRaOyrkWneJUSF5TqTWnra4baprWaKNarG3meD2nw5yKr+h8RJuWil+8uGCub9He/SzGqwteLMpZ9truuMUWaf/XEntekXuEaY54v2zr7olsQsC4IJ/Nx1HchEs/DH5COwY3kY4zplmpdatfKP73Ra5ZqVQjn5+fn/8FAAD//wMAY5ugbB0GAAA=

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUTW/cNhD9KwKvXcr8EkXq5toOunAdG9lFW/Q2JEc2a5laSJSTwPB/L6i1EztBgV4EYciZ9/jmzTyRBA9IOnJ6OFw8Ysoz2ZAc15BgQlOmKZN7rjpmu0bXlgthW/432ZB4iV9JR1rlPfcWqUSNVAXWUCd7T0NAyxkPTDSq1ITbmXRPBGId8DF6rMf5D5zmOCbSkT9jCuPngg2x9sO4hHoaB9ymOUPyhczH7dkl/f30Zn99c7w141yS6xhIR4J0lmHwVBjtqQLmqHGM0RCM74VAaFpxTIsp45RgqOdw/x0/jDlh9p2ohawZFZY8b0iADIWygxn3Xw+FxSrReYlv1vD62z2RR5xIJzavYh7LnfghnjzMbolDOFm/ZEMO03jAKUdc1bjeVd9JcFazWmjBGNmQK/B3MWG1Pa+uh/JE8IYpDJJa7ANVlgO1SkoaZAOt0H3bip5syCVOCYc3Va+in8Z57HP1InL1Dmd3enaRwA1YMD7AMCPZkO28y5ACDGPCiy/ol3ystZ+Wcnw2phzTMi5ztU0Zbyd4OX/NP1umCVOubiDfVb/BfEc6YsF4DhYsgPa+x4ZhzwIDzdqAOmgMSoQ2KKuCVpZ5rcBxFaDhri8RsiG/Fg13i/c4z9/ZrCYZhpVD9dKnsyFiyu9kJB1hHEWQBmkvuKbKBKDQSEu1lrpH7m0r+CvMHqZbzKQjvXHOMe+dUJZ7EBZk6JkMurdCtC2oXjatElo3rAUf2lZwFWSLHmQvmSm2u5nGsPj8Y6vl2oCbafwHfS5SkY5o1jTGg3NCK27AGlRcK6Mttlb1wJT0DnhjwDnOoGHeGuGBawvKhab/ptJFuo0Jz+N8GODrG1xT61r+4gEM70H9cP3DBA/4eZzuPx5tXH+82JMN+bSkshGqbbHI55joF10m+moZctzfTQgBw9UY8GcfYRmXap3Q1lomnHFUCtNTJVpNnVGBOmwbqaSRvS1SnY/+HqeqOAxiKlP1rdj1rjqd/F3M6PMyFX5/rTyud9XNALkfp4d3m+TNw34UoBaSswIX8PG4jVaOOgSDQXMqFG+ospZTY5uWutY467TBxhfFrpd8WHL1CUOc0Of1xS9eXDHP7tDf/yzGqwteLMpZ8druuMVWaf/XEnvekAeEeZnwYd3W3RPZpoTTiny+HEdxm67iMMQZ/ZjCTDrOhapN06oXhv99z7DaWCmen5+f/wUAAP//AwAsxW2tGwYAAA==

Binary file not shown.

View File

@ -0,0 +1,89 @@
using Microsoft.AspNetCore.Mvc;
using PlotLine.Services;
using PlotLine.ViewModels;
namespace PlotLine.Controllers;
public sealed class ProjectMetricsController(ISceneMetricTypeService metrics) : Controller
{
public async Task<IActionResult> Index(int projectId)
{
var model = await metrics.GetAsync(projectId);
return model is null ? NotFound() : View(model);
}
public async Task<IActionResult> Edit(int projectId, int id)
{
var model = await metrics.GetEditAsync(projectId, id);
return model is null ? NotFound() : View(model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Save(SceneMetricTypeEditViewModel model)
{
if (model.MinValue >= model.MaxValue)
{
ModelState.AddModelError(nameof(model.MaxValue), "Maximum value must be greater than minimum value.");
}
if (model.DefaultValue < model.MinValue || model.DefaultValue > model.MaxValue)
{
ModelState.AddModelError(nameof(model.DefaultValue), "Default value must sit inside the metric range.");
}
if (!ModelState.IsValid)
{
return model.MetricTypeID == 0
? RedirectToAction(nameof(Index), new { projectId = model.ProjectID })
: View("Edit", model);
}
await metrics.SaveAsync(model);
TempData["ProjectMetricMessage"] = model.MetricTypeID == 0 ? "Scene metric added." : "Scene metric updated.";
return RedirectToAction(nameof(Index), new { projectId = model.ProjectID });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> AddMissingDefaults(int projectId)
{
await metrics.AddMissingDefaultsAsync(projectId);
TempData["ProjectMetricMessage"] = "Missing default scene metrics added.";
return RedirectToAction(nameof(Index), new { projectId });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SetActive(int projectId, int id, bool isActive)
{
var updated = await metrics.SetActiveAsync(projectId, id, isActive);
TempData["ProjectMetricMessage"] = updated
? (isActive ? "Metric reactivated." : "Metric deactivated.")
: (isActive ? "Metric could not be reactivated." : "Metric could not be deactivated.");
return RedirectToAction(nameof(Index), new { projectId });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Move(int projectId, int id, string direction)
{
direction = string.Equals(direction, "Up", StringComparison.OrdinalIgnoreCase) ? "Up" : "Down";
var moved = await metrics.MoveAsync(projectId, id, direction);
TempData["ProjectMetricMessage"] = moved
? $"Metric moved {direction.ToLowerInvariant()}."
: $"Metric could not be moved {direction.ToLowerInvariant()} because it is already at the edge of its group.";
return RedirectToAction(nameof(Index), new { projectId });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Remove(int projectId, int id)
{
var removed = await metrics.RemoveUnusedAsync(projectId, id);
TempData["ProjectMetricMessage"] = removed
? "Unused metric removed."
: "Metric cannot be removed because scene values exist or the metric is still active.";
return RedirectToAction(nameof(Index), new { projectId });
}
}

View File

@ -72,6 +72,18 @@ public interface ITimelineSettingsRepository
Task SaveAsync(ProjectTimelineSettings settings, IEnumerable<int> metricTypeIds);
}
public interface ISceneMetricTypeRepository
{
Task<IReadOnlyList<SceneMetricType>> ListForManagementAsync(int projectId);
Task<SceneMetricType?> GetAsync(int metricTypeId, int projectId);
Task<int> SaveAsync(SceneMetricType metric);
Task<bool> SetActiveAsync(int metricTypeId, int projectId, bool isActive);
Task<bool> MoveAsync(int metricTypeId, int projectId, string direction);
Task AddMissingDefaultsAsync(int projectId);
Task<bool> RemoveUnusedAsync(int metricTypeId, int projectId);
Task<bool> HasProjectMetricsAsync(int projectId);
}
public interface IPlotRepository
{
Task<PlotLookupData> GetLookupsAsync();
@ -1465,6 +1477,87 @@ public sealed class TimelineSettingsRepository(ISqlConnectionFactory connectionF
}
}
public sealed class SceneMetricTypeRepository(ISqlConnectionFactory connectionFactory) : ISceneMetricTypeRepository
{
public async Task<IReadOnlyList<SceneMetricType>> ListForManagementAsync(int projectId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<SceneMetricType>(
"dbo.SceneMetricType_ListForProjectManagement",
new { ProjectID = projectId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<SceneMetricType?> GetAsync(int metricTypeId, int projectId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<SceneMetricType>(
"dbo.SceneMetricType_GetForProject",
new { MetricTypeID = metricTypeId, ProjectID = projectId },
commandType: CommandType.StoredProcedure);
}
public async Task<int> SaveAsync(SceneMetricType metric)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.SceneMetricType_SaveForProject",
new
{
metric.MetricTypeID,
metric.ProjectID,
metric.MetricName,
metric.Description,
metric.MinValue,
metric.MaxValue,
metric.DefaultValue,
metric.SortOrder,
metric.IsActive
},
commandType: CommandType.StoredProcedure);
}
public async Task<bool> SetActiveAsync(int metricTypeId, int projectId, bool isActive)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<bool>(
"dbo.SceneMetricType_SetActiveForProject",
new { MetricTypeID = metricTypeId, ProjectID = projectId, IsActive = isActive },
commandType: CommandType.StoredProcedure);
}
public async Task<bool> MoveAsync(int metricTypeId, int projectId, string direction)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<bool>(
"dbo.SceneMetricType_MoveForProject",
new { MetricTypeID = metricTypeId, ProjectID = projectId, Direction = direction },
commandType: CommandType.StoredProcedure);
}
public async Task AddMissingDefaultsAsync(int projectId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync("dbo.SceneMetricType_AddMissingDefaultsForProject", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure);
}
public async Task<bool> RemoveUnusedAsync(int metricTypeId, int projectId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<bool>(
"dbo.SceneMetricType_RemoveUnusedForProject",
new { MetricTypeID = metricTypeId, ProjectID = projectId },
commandType: CommandType.StoredProcedure);
}
public async Task<bool> HasProjectMetricsAsync(int projectId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<bool>("dbo.SceneMetricType_ProjectHasMetrics", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure);
}
}
public sealed class TimelinePresetRepository(ISqlConnectionFactory connectionFactory) : ITimelinePresetRepository
{
public async Task<IReadOnlyList<TimelineViewPreset>> ListAsync(int projectId, int? bookId)

View File

@ -152,6 +152,8 @@ public sealed class SceneMetricType
public int DefaultValue { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; }
public bool IsEnabledForProject { get; set; }
public int ValueCount { get; set; }
}
public sealed class SceneMetricValue

View File

@ -26,6 +26,7 @@ public class Program
builder.Services.AddScoped<ITimelineRepository, TimelineRepository>();
builder.Services.AddScoped<ITimelinePresetRepository, TimelinePresetRepository>();
builder.Services.AddScoped<ITimelineSettingsRepository, TimelineSettingsRepository>();
builder.Services.AddScoped<ISceneMetricTypeRepository, SceneMetricTypeRepository>();
builder.Services.AddScoped<IPlotRepository, PlotRepository>();
builder.Services.AddScoped<IAssetRepository, AssetRepository>();
builder.Services.AddScoped<ICharacterRepository, CharacterRepository>();
@ -46,6 +47,7 @@ public class Program
builder.Services.AddScoped<IChapterService, ChapterService>();
builder.Services.AddScoped<ISceneService, SceneService>();
builder.Services.AddScoped<ITimelineService, TimelineService>();
builder.Services.AddScoped<ISceneMetricTypeService, SceneMetricTypeService>();
builder.Services.AddScoped<IPlotService, PlotService>();
builder.Services.AddScoped<IAssetService, AssetService>();
builder.Services.AddScoped<ICharacterService, CharacterService>();

View File

@ -150,6 +150,17 @@ public interface ITimelineService
Task AddLocationRelationshipByDropAsync(TimelineLocationRelationshipDropViewModel model);
}
public interface ISceneMetricTypeService
{
Task<ProjectSceneMetricsViewModel?> GetAsync(int projectId);
Task<SceneMetricTypeEditViewModel?> GetEditAsync(int projectId, int metricTypeId);
Task<int> SaveAsync(SceneMetricTypeEditViewModel model);
Task<bool> SetActiveAsync(int projectId, int metricTypeId, bool isActive);
Task<bool> MoveAsync(int projectId, int metricTypeId, string direction);
Task AddMissingDefaultsAsync(int projectId);
Task<bool> RemoveUnusedAsync(int projectId, int metricTypeId);
}
public interface IAnalyticsService
{
Task<AnalyticsViewModel?> GetStoryHealthAsync(int projectId, int? bookId, decimal? startChapterNumber, decimal? endChapterNumber, decimal? startSceneNumber, decimal? endSceneNumber);
@ -1954,6 +1965,63 @@ public sealed class ContinuityValidationService(
public Task RestoreAsync(int warningId) => warnings.RestoreAsync(warningId);
}
public sealed class SceneMetricTypeService(IProjectRepository projects, ISceneMetricTypeRepository metricTypes) : ISceneMetricTypeService
{
public async Task<ProjectSceneMetricsViewModel?> GetAsync(int projectId)
{
var project = await projects.GetAsync(projectId);
if (project is null)
{
return null;
}
return new ProjectSceneMetricsViewModel
{
Project = project,
Metrics = await metricTypes.ListForManagementAsync(projectId),
NewMetric = new SceneMetricTypeEditViewModel { ProjectID = projectId }
};
}
public async Task<SceneMetricTypeEditViewModel?> GetEditAsync(int projectId, int metricTypeId)
{
var metric = await metricTypes.GetAsync(metricTypeId, projectId);
return metric is null ? null : new SceneMetricTypeEditViewModel
{
MetricTypeID = metric.MetricTypeID,
ProjectID = projectId,
MetricName = metric.MetricName,
Description = metric.Description,
MinValue = metric.MinValue,
MaxValue = metric.MaxValue,
DefaultValue = metric.DefaultValue,
SortOrder = metric.SortOrder,
IsActive = metric.IsActive
};
}
public Task<int> SaveAsync(SceneMetricTypeEditViewModel model) => metricTypes.SaveAsync(new SceneMetricType
{
MetricTypeID = model.MetricTypeID,
ProjectID = model.ProjectID,
MetricName = model.MetricName,
Description = model.Description,
MinValue = model.MinValue,
MaxValue = model.MaxValue,
DefaultValue = model.DefaultValue,
SortOrder = model.SortOrder,
IsActive = model.IsActive
});
public Task<bool> SetActiveAsync(int projectId, int metricTypeId, bool isActive) => metricTypes.SetActiveAsync(metricTypeId, projectId, isActive);
public Task<bool> MoveAsync(int projectId, int metricTypeId, string direction) => metricTypes.MoveAsync(metricTypeId, projectId, direction);
public Task AddMissingDefaultsAsync(int projectId) => metricTypes.AddMissingDefaultsAsync(projectId);
public Task<bool> RemoveUnusedAsync(int projectId, int metricTypeId) => metricTypes.RemoveUnusedAsync(metricTypeId, projectId);
}
public sealed class AnalyticsService(IProjectRepository projects, IBookRepository books, IAnalyticsRepository analytics) : IAnalyticsService
{
public async Task<AnalyticsViewModel?> GetStoryHealthAsync(int projectId, int? bookId, decimal? startChapterNumber, decimal? endChapterNumber, decimal? startSceneNumber, decimal? endSceneNumber)

View File

@ -0,0 +1,430 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
CREATE OR ALTER PROCEDURE dbo.SceneMetricType_ProjectHasMetrics
@ProjectID int
AS
BEGIN
SET NOCOUNT ON;
SELECT CAST(CASE WHEN EXISTS
(
SELECT 1
FROM dbo.ProjectTimelineMetricSettings ptms
INNER JOIN dbo.SceneMetricTypes mt ON mt.MetricTypeID = ptms.MetricTypeID
WHERE ptms.ProjectID = @ProjectID AND mt.IsActive = 1
) THEN 1 ELSE 0 END AS bit);
END;
GO
CREATE OR ALTER PROCEDURE dbo.SceneMetricType_AddMissingDefaultsForProject
@ProjectID int
AS
BEGIN
SET NOCOUNT ON;
IF NOT EXISTS (SELECT 1 FROM dbo.ProjectTimelineSettings WHERE ProjectID = @ProjectID)
BEGIN
INSERT dbo.ProjectTimelineSettings
(ProjectID, ShowSceneCards, ShowMetricShape, ShowPlotLines, ShowStoryAssets, ShowCharacterAppearances, ShowWarnings)
VALUES
(@ProjectID, 1, 1, 1, 1, 1, 1);
END;
INSERT dbo.ProjectTimelineMetricSettings (ProjectID, MetricTypeID, SortOrder)
SELECT @ProjectID, mt.MetricTypeID, mt.SortOrder
FROM dbo.SceneMetricTypes mt
WHERE mt.ProjectID IS NULL
AND mt.IsActive = 1
AND mt.MetricName IN
(
N'Overall Intensity', N'Tension', N'Emotional Weight', N'Action', N'Darkness',
N'Romance', N'Sexual Charge', N'Violence', N'Hope / Lightness', N'Comedy', N'Mystery'
)
AND NOT EXISTS
(
SELECT 1
FROM dbo.ProjectTimelineMetricSettings ptms
INNER JOIN dbo.SceneMetricTypes existing ON existing.MetricTypeID = ptms.MetricTypeID
WHERE ptms.ProjectID = @ProjectID
AND LOWER(existing.MetricName) = LOWER(mt.MetricName)
);
END;
GO
CREATE OR ALTER PROCEDURE dbo.SceneMetricType_ListForProjectManagement
@ProjectID int
AS
BEGIN
SET NOCOUNT ON;
;WITH CandidateMetrics AS
(
SELECT mt.*
FROM dbo.SceneMetricTypes mt
WHERE mt.ProjectID = @ProjectID
UNION
SELECT mt.*
FROM dbo.SceneMetricTypes mt
WHERE mt.ProjectID IS NULL
AND mt.MetricName IN
(
N'Overall Intensity', N'Tension', N'Emotional Weight', N'Action', N'Darkness',
N'Romance', N'Sexual Charge', N'Violence', N'Hope / Lightness', N'Comedy', N'Mystery'
)
UNION
SELECT mt.*
FROM dbo.SceneMetricTypes mt
INNER JOIN dbo.ProjectTimelineMetricSettings ptms ON ptms.MetricTypeID = mt.MetricTypeID
WHERE ptms.ProjectID = @ProjectID
)
SELECT cm.MetricTypeID, cm.ProjectID, cm.MetricName, cm.Description, cm.MinValue, cm.MaxValue, cm.DefaultValue,
COALESCE(ptms.SortOrder, cm.SortOrder) AS SortOrder,
CAST(CASE WHEN cm.IsActive = 1 AND ptms.MetricTypeID IS NOT NULL THEN 1 ELSE 0 END AS bit) AS IsActive,
CAST(CASE WHEN cm.IsActive = 1 AND ptms.MetricTypeID IS NOT NULL THEN 1 ELSE 0 END AS bit) AS IsEnabledForProject,
COUNT(smv.SceneID) AS ValueCount
FROM CandidateMetrics cm
LEFT JOIN dbo.ProjectTimelineMetricSettings ptms ON ptms.ProjectID = @ProjectID AND ptms.MetricTypeID = cm.MetricTypeID
LEFT JOIN dbo.SceneMetricValues smv ON smv.MetricTypeID = cm.MetricTypeID
GROUP BY cm.MetricTypeID, cm.ProjectID, cm.MetricName, cm.Description, cm.MinValue, cm.MaxValue, cm.DefaultValue,
cm.SortOrder, cm.IsActive, ptms.MetricTypeID, ptms.SortOrder
ORDER BY
CASE WHEN cm.IsActive = 1 AND ptms.MetricTypeID IS NOT NULL THEN 0 ELSE 1 END,
COALESCE(ptms.SortOrder, cm.SortOrder),
cm.MetricName;
END;
GO
CREATE OR ALTER PROCEDURE dbo.SceneMetricType_GetForProject
@MetricTypeID int,
@ProjectID int
AS
BEGIN
SET NOCOUNT ON;
SELECT mt.MetricTypeID, mt.ProjectID, mt.MetricName, mt.Description, mt.MinValue, mt.MaxValue, mt.DefaultValue,
COALESCE(ptms.SortOrder, mt.SortOrder) AS SortOrder,
CAST(CASE WHEN mt.IsActive = 1 AND ptms.MetricTypeID IS NOT NULL THEN 1 ELSE 0 END AS bit) AS IsActive,
CAST(CASE WHEN mt.IsActive = 1 AND ptms.MetricTypeID IS NOT NULL THEN 1 ELSE 0 END AS bit) AS IsEnabledForProject,
(SELECT COUNT(*) FROM dbo.SceneMetricValues smv WHERE smv.MetricTypeID = mt.MetricTypeID) AS ValueCount
FROM dbo.SceneMetricTypes mt
LEFT JOIN dbo.ProjectTimelineMetricSettings ptms ON ptms.ProjectID = @ProjectID AND ptms.MetricTypeID = mt.MetricTypeID
WHERE mt.MetricTypeID = @MetricTypeID
AND (mt.ProjectID IS NULL OR mt.ProjectID = @ProjectID);
END;
GO
CREATE OR ALTER PROCEDURE dbo.SceneMetricType_SaveForProject
@MetricTypeID int = NULL,
@ProjectID int,
@MetricName nvarchar(100),
@Description nvarchar(500) = NULL,
@MinValue int,
@MaxValue int,
@DefaultValue int,
@SortOrder int,
@IsActive bit = 1
AS
BEGIN
SET NOCOUNT ON;
IF @MinValue >= @MaxValue
THROW 50010, 'Metric minimum must be less than maximum.', 1;
IF @DefaultValue < @MinValue OR @DefaultValue > @MaxValue
THROW 50011, 'Metric default value must be inside the metric range.', 1;
IF @MetricTypeID IS NULL OR @MetricTypeID = 0
BEGIN
SELECT @MetricTypeID = MetricTypeID
FROM dbo.SceneMetricTypes
WHERE ProjectID = @ProjectID AND LOWER(MetricName) = LOWER(@MetricName);
IF @MetricTypeID IS NULL OR @MetricTypeID = 0
BEGIN
INSERT dbo.SceneMetricTypes (ProjectID, MetricName, Description, MinValue, MaxValue, DefaultValue, SortOrder, IsActive)
VALUES (@ProjectID, @MetricName, @Description, @MinValue, @MaxValue, @DefaultValue, @SortOrder, 1);
SET @MetricTypeID = CAST(SCOPE_IDENTITY() AS int);
END
END
UPDATE dbo.SceneMetricTypes
SET MetricName = @MetricName,
Description = @Description,
MinValue = @MinValue,
MaxValue = @MaxValue,
DefaultValue = @DefaultValue,
SortOrder = @SortOrder,
IsActive = 1
WHERE MetricTypeID = @MetricTypeID
AND (ProjectID = @ProjectID OR ProjectID IS NULL);
IF @IsActive = 1
BEGIN
MERGE dbo.ProjectTimelineMetricSettings AS target
USING (SELECT @ProjectID AS ProjectID, @MetricTypeID AS MetricTypeID, @SortOrder AS SortOrder) AS source
ON target.ProjectID = source.ProjectID AND target.MetricTypeID = source.MetricTypeID
WHEN MATCHED THEN UPDATE SET SortOrder = source.SortOrder
WHEN NOT MATCHED THEN INSERT (ProjectID, MetricTypeID, SortOrder) VALUES (source.ProjectID, source.MetricTypeID, source.SortOrder);
END
ELSE
BEGIN
DELETE FROM dbo.ProjectTimelineMetricSettings WHERE ProjectID = @ProjectID AND MetricTypeID = @MetricTypeID;
END;
SELECT @MetricTypeID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.SceneMetricType_SetActiveForProject
@MetricTypeID int,
@ProjectID int,
@IsActive bit
AS
BEGIN
SET NOCOUNT ON;
IF @IsActive = 1
BEGIN
UPDATE dbo.SceneMetricTypes
SET IsActive = 1
WHERE MetricTypeID = @MetricTypeID
AND ProjectID = @ProjectID;
INSERT dbo.ProjectTimelineMetricSettings (ProjectID, MetricTypeID, SortOrder)
SELECT @ProjectID, mt.MetricTypeID, mt.SortOrder
FROM dbo.SceneMetricTypes mt
WHERE mt.MetricTypeID = @MetricTypeID
AND mt.IsActive = 1
AND (mt.ProjectID IS NULL OR mt.ProjectID = @ProjectID)
AND NOT EXISTS
(
SELECT 1 FROM dbo.ProjectTimelineMetricSettings ptms
WHERE ptms.ProjectID = @ProjectID AND ptms.MetricTypeID = @MetricTypeID
);
SELECT CAST(CASE WHEN EXISTS
(
SELECT 1
FROM dbo.ProjectTimelineMetricSettings ptms
INNER JOIN dbo.SceneMetricTypes mt ON mt.MetricTypeID = ptms.MetricTypeID
WHERE ptms.ProjectID = @ProjectID
AND ptms.MetricTypeID = @MetricTypeID
AND mt.IsActive = 1
AND (mt.ProjectID IS NULL OR mt.ProjectID = @ProjectID)
) THEN 1 ELSE 0 END AS bit);
RETURN;
END
ELSE
BEGIN
DELETE FROM dbo.ProjectTimelineMetricSettings
WHERE ProjectID = @ProjectID AND MetricTypeID = @MetricTypeID;
UPDATE dbo.SceneMetricTypes
SET IsActive = 0
WHERE MetricTypeID = @MetricTypeID AND ProjectID = @ProjectID;
SELECT CAST(CASE WHEN NOT EXISTS
(
SELECT 1
FROM dbo.ProjectTimelineMetricSettings
WHERE ProjectID = @ProjectID AND MetricTypeID = @MetricTypeID
) THEN 1 ELSE 0 END AS bit);
RETURN;
END;
END;
GO
CREATE OR ALTER PROCEDURE dbo.SceneMetricType_MoveForProject
@MetricTypeID int,
@ProjectID int,
@Direction nvarchar(10)
AS
BEGIN
SET NOCOUNT ON;
DECLARE @NormalisedDirection nvarchar(10) = CASE WHEN UPPER(@Direction) = N'UP' THEN N'Up' ELSE N'Down' END;
;WITH CandidateMetrics AS
(
SELECT mt.MetricTypeID, mt.ProjectID, mt.MetricName,
COALESCE(ptms.SortOrder, mt.SortOrder) AS SortOrder,
CAST(CASE WHEN mt.IsActive = 1 AND ptms.MetricTypeID IS NOT NULL THEN 1 ELSE 0 END AS bit) AS IsEnabledForProject,
CAST(CASE WHEN mt.IsActive = 1 AND ptms.MetricTypeID IS NOT NULL THEN 0 ELSE 1 END AS int) AS GroupRank
FROM dbo.SceneMetricTypes mt
LEFT JOIN dbo.ProjectTimelineMetricSettings ptms ON ptms.ProjectID = @ProjectID AND ptms.MetricTypeID = mt.MetricTypeID
WHERE
mt.ProjectID = @ProjectID
OR ptms.ProjectID = @ProjectID
OR
(
mt.ProjectID IS NULL
AND mt.MetricName IN
(
N'Overall Intensity', N'Tension', N'Emotional Weight', N'Action', N'Darkness',
N'Romance', N'Sexual Charge', N'Violence', N'Hope / Lightness', N'Comedy', N'Mystery'
)
)
)
SELECT *,
ROW_NUMBER() OVER (PARTITION BY GroupRank ORDER BY SortOrder, MetricName, MetricTypeID) AS RowNumber,
ROW_NUMBER() OVER (PARTITION BY GroupRank ORDER BY SortOrder, MetricName, MetricTypeID) * 10 AS NormalisedSortOrder
INTO #OrderedMetrics
FROM CandidateMetrics;
UPDATE ptms
SET SortOrder = ordered.NormalisedSortOrder
FROM dbo.ProjectTimelineMetricSettings ptms
INNER JOIN #OrderedMetrics ordered ON ordered.MetricTypeID = ptms.MetricTypeID
WHERE ptms.ProjectID = @ProjectID
AND ordered.IsEnabledForProject = 1;
UPDATE mt
SET SortOrder = ordered.NormalisedSortOrder
FROM dbo.SceneMetricTypes mt
INNER JOIN #OrderedMetrics ordered ON ordered.MetricTypeID = mt.MetricTypeID
WHERE mt.ProjectID = @ProjectID
AND ordered.IsEnabledForProject = 0;
;WITH OrderedMetrics AS
(
SELECT MetricTypeID, ProjectID, MetricName, NormalisedSortOrder AS SortOrder, IsEnabledForProject, GroupRank, RowNumber
FROM #OrderedMetrics
),
MovePair AS
(
SELECT currentMetric.MetricTypeID AS CurrentMetricTypeID,
currentMetric.ProjectID AS CurrentProjectID,
currentMetric.IsEnabledForProject AS CurrentIsEnabledForProject,
currentMetric.SortOrder AS CurrentSortOrder,
adjacentMetric.MetricTypeID AS AdjacentMetricTypeID,
adjacentMetric.ProjectID AS AdjacentProjectID,
adjacentMetric.IsEnabledForProject AS AdjacentIsEnabledForProject,
adjacentMetric.SortOrder AS AdjacentSortOrder
FROM OrderedMetrics currentMetric
INNER JOIN OrderedMetrics adjacentMetric ON adjacentMetric.GroupRank = currentMetric.GroupRank
AND adjacentMetric.RowNumber = CASE WHEN @NormalisedDirection = N'Up' THEN currentMetric.RowNumber - 1 ELSE currentMetric.RowNumber + 1 END
WHERE currentMetric.MetricTypeID = @MetricTypeID
)
SELECT *
INTO #MovePair
FROM MovePair;
IF NOT EXISTS (SELECT 1 FROM #MovePair)
BEGIN
SELECT CAST(0 AS bit);
RETURN;
END;
IF EXISTS (SELECT 1 FROM #MovePair WHERE CurrentIsEnabledForProject = 0 AND CurrentProjectID IS NULL)
BEGIN
SELECT CAST(0 AS bit);
RETURN;
END;
IF EXISTS (SELECT 1 FROM #MovePair WHERE AdjacentIsEnabledForProject = 0 AND AdjacentProjectID IS NULL)
BEGIN
SELECT CAST(0 AS bit);
RETURN;
END;
UPDATE ptms
SET SortOrder = mp.AdjacentSortOrder
FROM dbo.ProjectTimelineMetricSettings ptms
INNER JOIN #MovePair mp ON mp.CurrentMetricTypeID = ptms.MetricTypeID
WHERE ptms.ProjectID = @ProjectID
AND mp.CurrentIsEnabledForProject = 1;
UPDATE mt
SET SortOrder = mp.AdjacentSortOrder
FROM dbo.SceneMetricTypes mt
INNER JOIN #MovePair mp ON mp.CurrentMetricTypeID = mt.MetricTypeID
WHERE mt.ProjectID = @ProjectID
AND mp.CurrentIsEnabledForProject = 0;
UPDATE ptms
SET SortOrder = mp.CurrentSortOrder
FROM dbo.ProjectTimelineMetricSettings ptms
INNER JOIN #MovePair mp ON mp.AdjacentMetricTypeID = ptms.MetricTypeID
WHERE ptms.ProjectID = @ProjectID
AND mp.AdjacentIsEnabledForProject = 1;
UPDATE mt
SET SortOrder = mp.CurrentSortOrder
FROM dbo.SceneMetricTypes mt
INNER JOIN #MovePair mp ON mp.AdjacentMetricTypeID = mt.MetricTypeID
WHERE mt.ProjectID = @ProjectID
AND mp.AdjacentIsEnabledForProject = 0;
SELECT CAST(1 AS bit);
END;
GO
CREATE OR ALTER PROCEDURE dbo.SceneMetricType_RemoveUnusedForProject
@MetricTypeID int,
@ProjectID int
AS
BEGIN
SET NOCOUNT ON;
IF EXISTS
(
SELECT 1
FROM dbo.SceneMetricTypes mt
WHERE mt.MetricTypeID = @MetricTypeID
AND mt.ProjectID = @ProjectID
AND mt.IsActive = 0
AND NOT EXISTS (SELECT 1 FROM dbo.SceneMetricValues smv WHERE smv.MetricTypeID = mt.MetricTypeID)
)
BEGIN
DELETE FROM dbo.ProjectTimelineMetricSettings
WHERE ProjectID = @ProjectID AND MetricTypeID = @MetricTypeID;
DELETE FROM dbo.SceneMetricTypes
WHERE MetricTypeID = @MetricTypeID AND ProjectID = @ProjectID;
SELECT CAST(1 AS bit);
RETURN;
END;
SELECT CAST(0 AS bit);
END;
GO
CREATE OR ALTER PROCEDURE dbo.ProjectTimelineSettings_EnsureDefaults
@ProjectID int
AS
BEGIN
SET NOCOUNT ON;
IF NOT EXISTS (SELECT 1 FROM dbo.Projects WHERE ProjectID = @ProjectID AND IsArchived = 0)
RETURN;
IF NOT EXISTS (SELECT 1 FROM dbo.ProjectTimelineSettings WHERE ProjectID = @ProjectID)
BEGIN
INSERT dbo.ProjectTimelineSettings
(ProjectID, ShowSceneCards, ShowMetricShape, ShowPlotLines, ShowStoryAssets, ShowCharacterAppearances, ShowWarnings)
VALUES
(@ProjectID, 1, 1, 1, 1, 1, 1);
END;
END;
GO
CREATE OR ALTER PROCEDURE dbo.ProjectTimelineSettings_MetricTypes
@ProjectID int
AS
BEGIN
SET NOCOUNT ON;
SELECT mt.MetricTypeID, mt.ProjectID, mt.MetricName, mt.Description, mt.MinValue, mt.MaxValue, mt.DefaultValue,
ptms.SortOrder, mt.IsActive
FROM dbo.ProjectTimelineMetricSettings ptms
INNER JOIN dbo.SceneMetricTypes mt ON mt.MetricTypeID = ptms.MetricTypeID
WHERE ptms.ProjectID = @ProjectID
AND mt.IsActive = 1
AND (mt.ProjectID IS NULL OR mt.ProjectID = @ProjectID)
ORDER BY ptms.SortOrder, mt.SortOrder, mt.MetricName;
END;
GO

View File

@ -447,6 +447,41 @@ public sealed class TimelineMetricSettingOptionViewModel
public bool IsSelected { get; set; }
}
public sealed class ProjectSceneMetricsViewModel
{
public Project Project { get; set; } = new();
public IReadOnlyList<SceneMetricType> Metrics { get; set; } = [];
public SceneMetricTypeEditViewModel NewMetric { get; set; } = new();
public bool HasEnabledMetrics => Metrics.Any(x => x.IsEnabledForProject && x.IsActive);
}
public sealed class SceneMetricTypeEditViewModel
{
public int MetricTypeID { get; set; }
public int ProjectID { get; set; }
[Required, StringLength(100)]
[Display(Name = "Metric name")]
public string MetricName { get; set; } = string.Empty;
public string? Description { get; set; }
[Display(Name = "Minimum value")]
public int MinValue { get; set; } = 1;
[Display(Name = "Maximum value")]
public int MaxValue { get; set; } = 10;
[Display(Name = "Default value")]
public int DefaultValue { get; set; } = 5;
[Display(Name = "Sort order")]
public int SortOrder { get; set; } = 100;
[Display(Name = "Active")]
public bool IsActive { get; set; } = true;
}
public sealed class StoryBibleViewModel
{
public Project Project { get; set; } = new();

View File

@ -0,0 +1,61 @@
@model SceneMetricTypeEditViewModel
@{
ViewData["Title"] = "Edit Scene Metric";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Project Settings</p>
<h1>Edit scene metric</h1>
</div>
<a class="btn btn-outline-secondary" asp-action="Index" asp-route-projectId="@Model.ProjectID">Back to metrics</a>
</div>
<form asp-action="Save" method="post" class="edit-panel">
<input asp-for="MetricTypeID" type="hidden" />
<input asp-for="ProjectID" type="hidden" />
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="mb-3">
<label asp-for="MetricName" class="form-label"></label>
<input asp-for="MetricName" class="form-control" />
<span asp-validation-for="MetricName" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Description" class="form-label"></label>
<textarea asp-for="Description" class="form-control" rows="3"></textarea>
</div>
<div class="row g-3">
<div class="col-md-3">
<label asp-for="MinValue" class="form-label"></label>
<input asp-for="MinValue" class="form-control" />
<span asp-validation-for="MinValue" class="text-danger"></span>
</div>
<div class="col-md-3">
<label asp-for="MaxValue" class="form-label"></label>
<input asp-for="MaxValue" class="form-control" />
<span asp-validation-for="MaxValue" class="text-danger"></span>
</div>
<div class="col-md-3">
<label asp-for="DefaultValue" class="form-label"></label>
<input asp-for="DefaultValue" class="form-control" />
<span asp-validation-for="DefaultValue" class="text-danger"></span>
</div>
<div class="col-md-3">
<label asp-for="SortOrder" class="form-label"></label>
<input asp-for="SortOrder" class="form-control" />
</div>
</div>
<div class="form-check mt-3">
<input asp-for="IsActive" class="form-check-input" />
<label asp-for="IsActive" class="form-check-label"></label>
</div>
<div class="button-row mt-3">
<button class="btn btn-primary" type="submit">Save Metric</button>
<a class="btn btn-outline-secondary" asp-action="Index" asp-route-projectId="@Model.ProjectID">Cancel</a>
</div>
</form>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -0,0 +1,182 @@
@model ProjectSceneMetricsViewModel
@{
ViewData["Title"] = "Scene Metrics";
}
<nav class="breadcrumb-trail">
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
<span>Scene Metrics</span>
</nav>
<div class="page-heading compact">
<div>
<p class="eyebrow">Project Settings</p>
<h1>Scene Metrics</h1>
<p class="lead-text">Create and manage the metric sliders available when editing scenes.</p>
</div>
<div class="button-row">
<a class="btn btn-outline-secondary" asp-controller="Timeline" asp-action="Settings" asp-route-projectId="@Model.Project.ProjectID">Timeline Settings</a>
<a class="btn btn-outline-secondary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">Back to project</a>
</div>
</div>
@if (TempData["ProjectMetricMessage"] is string message)
{
<div class="alert alert-info">@message</div>
}
<section class="story-bible-section">
<div class="story-bible-section-heading">
<div>
<p class="eyebrow">Defaults</p>
<h2>Add missing default metrics</h2>
</div>
</div>
<p class="muted">Adds any missing core metrics for this project without duplicating existing metrics.</p>
<form asp-action="AddMissingDefaults" method="post">
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<button class="btn btn-outline-primary" type="submit">Add missing default metrics</button>
</form>
</section>
<section class="story-bible-section">
<div class="story-bible-section-heading">
<div>
<p class="eyebrow">New Metric</p>
<h2>Add scene metric</h2>
</div>
</div>
<form asp-action="Save" method="post" class="row g-3">
<input asp-for="NewMetric.ProjectID" type="hidden" name="ProjectID" />
<div class="col-md-4">
<label class="form-label" for="MetricName">Metric name</label>
<input class="form-control" id="MetricName" name="MetricName" maxlength="100" required />
</div>
<div class="col-md-8">
<label class="form-label" for="Description">Description</label>
<input class="form-control" id="Description" name="Description" maxlength="500" />
</div>
<div class="col-md-2">
<label class="form-label" for="MinValue">Min</label>
<input class="form-control" id="MinValue" name="MinValue" type="number" value="1" />
</div>
<div class="col-md-2">
<label class="form-label" for="MaxValue">Max</label>
<input class="form-control" id="MaxValue" name="MaxValue" type="number" value="10" />
</div>
<div class="col-md-2">
<label class="form-label" for="DefaultValue">Default</label>
<input class="form-control" id="DefaultValue" name="DefaultValue" type="number" value="5" />
</div>
<div class="col-md-2">
<label class="form-label" for="SortOrder">Sort order</label>
<input class="form-control" id="SortOrder" name="SortOrder" type="number" value="100" />
</div>
<div class="col-md-2 d-flex align-items-end">
<label class="form-check mb-2">
<input class="form-check-input" name="IsActive" type="checkbox" value="true" checked />
<span class="form-check-label">Active</span>
</label>
</div>
<div class="col-md-2 d-flex align-items-end">
<button class="btn btn-primary w-100" type="submit">Add Metric</button>
</div>
</form>
</section>
<section class="story-bible-section">
<div class="story-bible-section-heading">
<div>
<p class="eyebrow">Available Metrics</p>
<h2>Project scene metrics</h2>
</div>
<span class="soft-count">@Model.Metrics.Count metric@(Model.Metrics.Count == 1 ? "" : "s")</span>
</div>
@if (!Model.Metrics.Any())
{
<div class="empty-panel compact">
<p>This project has no scene metrics yet.</p>
<form asp-action="AddMissingDefaults" method="post">
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<button class="btn btn-primary" type="submit">Add default metrics</button>
</form>
</div>
}
else
{
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>Metric</th>
<th>Range</th>
<th>Default</th>
<th>Sort</th>
<th>Status</th>
<th>Values</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var metric in Model.Metrics)
{
var canMove = metric.IsEnabledForProject || metric.ProjectID == Model.Project.ProjectID;
var canRemove = !metric.IsActive && metric.ProjectID == Model.Project.ProjectID && metric.ValueCount == 0;
<tr class="@(metric.IsActive ? "" : "text-muted")">
<td>
<strong>@metric.MetricName</strong>
@if (!string.IsNullOrWhiteSpace(metric.Description))
{
<div class="muted">@metric.Description</div>
}
</td>
<td>@metric.MinValue-@metric.MaxValue</td>
<td>@metric.DefaultValue</td>
<td>@metric.SortOrder</td>
<td>
<span class="status-pill">@(metric.IsActive ? "Active" : "Inactive")</span>
</td>
<td>@metric.ValueCount</td>
<td>
<div class="button-row justify-content-end">
<form asp-action="Move" method="post">
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<input type="hidden" name="id" value="@metric.MetricTypeID" />
<input type="hidden" name="direction" value="Up" />
<button class="btn btn-outline-secondary btn-sm" type="submit" disabled="@(!canMove)">Up</button>
</form>
<form asp-action="Move" method="post">
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<input type="hidden" name="id" value="@metric.MetricTypeID" />
<input type="hidden" name="direction" value="Down" />
<button class="btn btn-outline-secondary btn-sm" type="submit" disabled="@(!canMove)">Down</button>
</form>
<a class="btn btn-outline-secondary btn-sm" asp-action="Edit" asp-route-projectId="@Model.Project.ProjectID" asp-route-id="@metric.MetricTypeID">Edit</a>
<form asp-action="SetActive" method="post">
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<input type="hidden" name="id" value="@metric.MetricTypeID" />
<input type="hidden" name="isActive" value="@(metric.IsActive ? "false" : "true")" />
<button class="btn btn-outline-secondary btn-sm" type="submit">@(metric.IsActive ? "Deactivate" : "Reactivate")</button>
</form>
@if (canRemove)
{
<form asp-action="Remove" method="post"
data-confirm-title="Remove unused metric?"
data-confirm-body="This metric has no scene values and can be removed safely."
data-confirm-button-text="Remove"
data-confirm-button-class="btn-danger">
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<input type="hidden" name="id" value="@metric.MetricTypeID" />
<button class="btn btn-outline-danger btn-sm" type="submit">Remove</button>
</form>
}
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>

View File

@ -16,6 +16,7 @@
<nav class="context-nav" aria-label="Project navigation">
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Timeline</a>
<a asp-controller="Timeline" asp-action="Settings" asp-route-projectId="@Model.Project.ProjectID">Timeline Settings</a>
<a asp-controller="ProjectMetrics" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Scene Metrics</a>
<a asp-controller="Scenarios" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Scenarios</a>
<a asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Writer Workspace</a>
<a asp-controller="StoryBible" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story Bible</a>
@ -44,6 +45,7 @@
<a class="btn btn-outline-secondary" asp-action="Edit" asp-route-id="@Model.Project.ProjectID">Edit project</a>
<a class="btn btn-outline-primary" asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Timeline</a>
<a class="btn btn-outline-secondary" asp-controller="Timeline" asp-action="Settings" asp-route-projectId="@Model.Project.ProjectID">Timeline Settings</a>
<a class="btn btn-outline-secondary" asp-controller="ProjectMetrics" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Scene Metrics</a>
<a class="btn btn-outline-primary" asp-controller="Scenarios" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Scenarios</a>
<a class="btn btn-outline-primary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Writer Workspace</a>
<a class="btn btn-outline-primary" asp-controller="StoryBible" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story Bible</a>

View File

@ -195,6 +195,18 @@
<div class="inspector-section" data-section-title="Scene Metrics" data-section-id="inspector-metrics">
<h3>Scene Metrics</h3>
@if (!Model.Metrics.Any())
{
<div class="empty-panel compact">
<p>This project has no scene metrics yet.</p>
@if (Model.ReturnProjectID.HasValue)
{
<a class="btn btn-outline-primary btn-sm" asp-controller="ProjectMetrics" asp-action="Index" asp-route-projectId="@Model.ReturnProjectID">Add default metrics</a>
}
</div>
}
else
{
<div class="metric-slider-stack">
@for (var index = 0; index < Model.Metrics.Count; index++)
{
@ -213,6 +225,7 @@
</div>
}
</div>
}
</div>
<div class="inspector-actions">

View File

@ -17,6 +17,7 @@
</div>
<nav class="context-nav" aria-label="Timeline settings navigation">
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open timeline</a>
<a asp-controller="ProjectMetrics" asp-action="Index" asp-route-projectId="@Model.ProjectID">Scene Metrics</a>
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Project</a>
</nav>
</section>
@ -102,6 +103,15 @@
</div>
<p class="muted">These metrics appear on the timeline graph and Story Health metric shape for this project.</p>
<span asp-validation-for="SelectedMetricTypeIDs" class="text-danger"></span>
@if (!Model.MetricOptions.Any())
{
<div class="empty-panel compact">
<p>This project has no scene metrics yet.</p>
<a class="btn btn-primary" asp-controller="ProjectMetrics" asp-action="Index" asp-route-projectId="@Model.ProjectID">Add default metrics</a>
</div>
}
else
{
<div class="timeline-settings-grid timeline-metric-settings-grid">
@foreach (var metric in Model.MetricOptions)
{
@ -117,6 +127,7 @@
</label>
}
</div>
}
</section>
<div class="button-row">