Timeline UI changes.

This commit is contained in:
Nick Beckley 2026-06-02 21:09:30 +01:00
parent 305446126e
commit 92aff32155
33 changed files with 429 additions and 88 deletions

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTS2/cNhD+K8KcRZkvUaJuxjpFF27iRbJIi94ozigmLFMLiXISGPvfA2o3SNxbLwQx4DfzPYavEN0zQQe3p9O7F4ppgRJS2EqSS8O4YVwcRdvptuNNVSvBTV3/CyWEe/oOHTTae+EtMUWGmEZes14NniGSFVwgl7XOPd2XBbpXcKFCegmeqmn5TPMSpggd/B0iTl/zbBcqP04rVvM00j4uyUWfyXzY7+7ZX7eH48Ph8mqhJYOrgNBBzxVy6QdmOCqmB+9Zq5qaIdaNEo3vNZoLLMREc3RjteDTr/k4pUjJd7KSquJMWjiXgC65TLl3Cx2/nzKLzaK7XC+38nbtXuGFZuhk+dPMS7sbP4ab56Vfw4g3lzO55SmrPM3TieYUaPNkkzmOLoUpFtdJuzFQTFDCw6fiMLo0TPPzG6M+XfTv/4f8Y57+7hv5NRHupjUm6EBoCSW8d/4xRCr2d9ABFyRRtcQGKQzTLTrmamWZMcoMJLxtpIASdlNMIa7TuhT7mOjLvAmADv5w40IX6rezfwyJfFrnrOofk3fhME+4+lT88l/wileK8wxa02lNxUfCMJNPlPUd5/Xa7z8QaeQG2q3zTDEVB5ceiz/d8ggdWNd64ayzzhnvB6o5DRy5M7xBMmgItcQGtdVotOXeaNcLja4W/ZArUALluIttw5TwaBvnGSlEpt3gWK/QMGGUxMYZMTTqjY/Fw5hxzrdcEypmaUCmrXDMaqUYqto10gxNI4ffLLmmL3g2GOnl8lc2BgaxJTSCSS1qpq0VrLV1w/qm7W1vWqp95nxPc6TxN6PeBz9PyzSk4ro8xRvrPq4x//diW6SvIbJvW0Z3k3+iucgZuxDzel9jPZ/P5x8AAAD//wMAk4+m1jYEAAA=

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxU227jNhD9FYGvFRXeRIl6S50saqRJjLXRFn0bkaOYjUwZFJXsIsi/F5KTzWVRoC+CMCTnnDlzZp5IgAOShpwfj5cPGNJIcpL8EhJMaMo0ZXzH60bVDauKUiqmZf03yYm/wu+kIZWylluDVKJGqhwraSs7S51Dwxl3TJRqzgl3I2meCPjC4YO3WAzjHxhHPwTSkD99cMPjjA2+sP0wuSIOPa7DmCDYmczNenVFfz/f7G43p1sjjvPjwjvSkJZJx4TtqGZOUtVZS2tZldS5spK8sq1y+vTMh4QxQF+M7v4N3w0pYLKNKIQsGBWGPOfEQYKZcgsj7r4fZxaLRBdzPF/Cy2/zRB4wkkbkr2Ke0p3Z3p8dxnbyvTtbviQnxzgcMSaPixq32+yNBGcFK4QWjJGcXIPd+4DZ+iK77ecSwdZMoZPUYOeoMhyoUVJSJ0uohO6qSnQkJ1cYA/bvsl57G4dx6FL2InL2AWd7vroM0PY4Y3yBfkSSk/W4TRAc9EPAy29op3TKtYvTfLwaQvJhGqYxW4eEdxFezl/fr6YYMaRsA2mf/QbjnjTEQG05GDAA2toOS4Ydcww0qxxqp9Ep4SqnjHJaGWa1gpYrByVvuzlCcvLrrOF2shbH8Y3NYpK+XzhkL31a9R5D+iAjaQjjKJyskXaCa6pqBxRKaajWUnfIrakEf4XZQbzDRBrS1W3bMmtboQy3IAxI1zHpdGeEqCpQnSwrJbQuWQXWVZXgyskKLchOslqQnGzi4CabPrdaLg3YxOEftGmWijREs7KsLbSt0IrXYGpUXKtaG6yM6oApaVvgZQ1tyxmUzJpaWODagGpd2f1Q6TLc+YAXfjz28P0dbl3oQv5iAWregfp0/UuEAz4O8f7mZOPi5nJHcvJ1CvNGyNazRR59oN/0PNHXU5/8bh8RHLrrweHPPsJ5XLJlQq00bctKS23lHFXMKgraIu06KcqulBwX+14M9h5jNjsMfJin6key2212Hu3eJ7RpijO/vxYet9ts00Pqhnj4sEneFfZZgEJIzubOOHw4baOFo3auRqc5FYqXVBnDaW3KirZV3ZpW11jaWbHbKR2nlH1F5yPatFT84sUFc7VHe/+zGK8ueLEoZ7PXtqcttkj7v5bYc04OCOMU8bBs6+aJrEPAuCBfTKdRXIdr3/d+RDsEN5KmVlwVRtfqheF/36tUwStdPj8/P/8LAAD//wMAb1GgshsGAAA=

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUS2/bOBD+KwLPokJKFCUK2IPhtKiRpjESd3fRG0UOYyKyaFCU0yDwf19Qlmun6GFvwmhe32P4jnq5A9SgxX7/6QB9GFCKgp1COck5JhwTuqF1U5KG0IzzvMgp+YFSZO/gDTWoYkpRJQAXwAEzTUrcFkZhrUFQQjXJSxZ7yucBNe9I2kzDwSrI3PA3+MG6HjXoH9tr9xpnS5upzo06866DVT8E2au4zLfV8g5/Xaw3D+tT1gBDLM6sRg3SRcu1AIIZlCVmbcWx4DnBFFrFqlJKWdWnMtsH8L3sskG/XOZrF3oIqsmzvMgIzgU6pkjLIOPKrRxg87aPW0wU3cZ4OoWnz+YdHcCjJk/PZJ7a3ajO3uyGdrSdvgnSP0MwXu7g1fkXOMgOpWjv3R58sDBx8/CUXFaiJCNZznNCUIrupdraHpLVbfLQRcBS1YSBLrAAozETVGLBigLropRVzk1V5Qal6A58D91V13urvBucCclMefJhztL1wfajG4dk1Qd49jKc6j7LbgCUovXYdnbYPoLUbxv3OMZ//dhFLMvRe+hDspZhm3yRwxY1SMhaUSmkkJIrZaAkYIgmkpNKA9ccNMt1pZlgmjNBFGeypUzLkrYmRlCKHsawH8OZ/59XS2y83e1AXzbYTByvOxmM87uVhj5YY6M054zJUF03oUrmnsvOQh8+kIwaRCjkuqgBm5xyzGotsSwLgTkvuAGqRJXTE2HGPo+/eCLGaFaYsq55K1pKIDdVzUhlWqKVKZTQZQtVXpGqhlooQtqS0boVimigJdMRnXd6VOF3KxSTQI9jH68zWUXYr7bHPzm7MLL2LjjluiiY7eDpbQiw+5NqS7fbu8GGiN/M0n4f4N718e98/zNnc/HChUsM4h0k0+mJmkrWmhqXVOSY6aLENSE1NoSxkiopeFGhFN069QI+iQaTtp80OXvq4SlZeLW1AVQYfZz87wTqJOfn88lc+Mi+fdosnYfFfp/O0b8OkaRTs7MBPrwr34eYvnVDuII8Q3uCzpwXu7KThsPpqbIxyLWuQXOKc0ZLzISguBZlhduqbkXLayjVxa7JI2jrQYWp38aPcdrM7J98ufDBGqnCEI/nq1OTnWZ/zim/L3kl26/q0/Ar6WYrzZ0oiZZ9Oj2ck4P+17t5PB6P/wEAAP//AwD7D0aXKAYAAA==

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUTW/jNhCG/4rAs6mQFEWJuqVOFzWy2RibdFv0NiJHDhGZNCgq2yDIf19QdhBnFz30Joz4zsczL/lCPOyRdOTycPj9CX2ayIokt4QEE4oyRRm/521Xs47xUqi6Ebz+h6yIu8Zn0pFGGsONRlqhQiotq2lfDYZai5ozbpmoZc4Ju4l0LwRcafHJGSzD9A3j5IInHfnLeRu+59rgSjOG2ZYxjLjxUwJvcjNfNutr+vlye3+7PZ6acMri0lnSEVv1ympkVGJdU9k3imolGOXYG9nUANC0R5nzCaOHsZzs43t9G5LHZDpRiqpkVGjyuiIWEuSWe5jw/vmQu1gQXeX4agkvn90LecJIOrF6g3lMd2FGd7Gf+tmN9mIMu53zu3Xwg9vNEVIuvCKHGA4Yk8MFzm/OQ3z+HHa7nI98gnFCsiL3GPfOw3j8cYUDzGO6C3NcyEz28b/OkI7AnAJZkYXkOC5li9Mw69GhT79ozwrf3hXbEdIQ4v7Dku6O7Df/A/0NmAfnsdhckY4wjsJWLdJBcEVla4FCXWmqVKUG5EY3gpMVWQefnJ/DPBUbn3B3ovahv8toHlxCk+aYZ/pbZbNtY7CzScX7gjkrWVkxlkVzOsyp+IrWRTQJ8xD3cT7l+0kilFhEn9yIv+BZzzGiT8UW0kPxB0wPpCMaWsNBgwZQxgxYMxyYZaBYY1FZhVYK21ippVVSM6Mk9FxaqHk/5AhZEcwmKxZfa2xQClFTJQWnUgPQvmUVBcaYwmFQCu0HuMXtmHVgWibRVlTjYKnUHKiWVUVtVUMj1NA0YjjjdDIEZ5n6uQn/nNCevPTlaO232S0+He/x0qeytkWrOBWS11RqzWmr64b2TdvrXrVYmzzZOvgpvIH8hrEPk0v5Eblx3u1h/PnI+2auMXocz7Zz40wMUxhScbJl8WFfX2efX7Fiseh35+m/izGugnnEWGRjgfNny3x9fX39AQAA//8DAMAt750MBQAA

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUTW/cNhCG/4rAsyjzS6Som2G3qOumXsSLtOiN5IxsIVpqQVGbBsb+94DaDRL31psw0DszfOadeSPRHZD05PZ4/OWEMS+kJnncQoIJTZmmjO9517esZ7zh1mgm5T+kJuMjfiU9MSoEHixSiRqpAtZSL4dAAdByxoGJVpWc7mUh/RtxYwN4GgM28/IJ0zLOkfTkrzHC/KXUdmMTpnmFJs0TPsQluxhKM38+3D3SP253+6fd5a8FlyJuRiA9Aek1WGRUYdtS5Y2mVgtGOfqgTOucM91FNsaMKbqpWeDzj/ow54g59KIRsmFUWHKuCbjsSsveLbj/eixdbIjuS7zewttn/0ZOmEgv6u8wL+luwjTe5Pk44Qmno0sLppswHw4uAqnJMc1HTHnEDcv20mlyeZxjdS12N40YM6nJ03O1m1we5nR4x+r5guDhfxA4YfKkJ5K1UhuDSkrprPaCIRgdtBY+OGYGL5AHZK1VxnLDuLSCWdlq0xlwzAZSk5dp9m76fdn4WcF8B8wILVChFUprGxjKDofWIAOntdddsCA7I1QHnXTWOW49mmB1KAb54MLrGLF6uCc9YRwFyA7pILimqgNHXSst1VrqAXmwRnBSk7s55jGu87pUDzHjS9r4kZ786qYFL+RuU3gdM4a8pgL1b12K7dIMa8jVDwdw1rBGMlZEaz6uufqIMCYMGQvefVqv+f4jEVpsors1JYy52rn8Wv3mltdCxXWBO1ueqkMYsGU4MGBOMwOoQSMoAQaUVaCVZUEr57kC13I/lAipCRbDVZvHmWfDgIhU29ZSZb2gvms1tQDAEGHwSr/jWD1NRedCxxSCpBYHoMpyR62SkoJsnRF6MEYMPyG5mo+zAhjwdNnWrQMN0CFoToXiLVXWctrZ1lBvOm+97rANpedHTBGnn0B9GEOal3nI1dW71Tt0H9dYLk61+fjLGOm/24zu5/AZU1Vm7MZYFuw61nNNDuiWNeFhO1j9G9mV7ar2290SupFc6Jo8Z5fyeryGpW2U7OT5fD5/AwAA//8DAD65h/X1BAAA

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUT2/cNhDFv4rAsyjzP0XdDLtFDTfNIjbaorcRZxQL0UoLito0MPzdC2o3SN1bb8JAb/j4mzd8ZTMciXXs9nT66UxzXlnN8riXlFCOC8eFfJZtZ0UnZKO0dVqrv1jNxkf6xjrmTYwyBuKaHHGDwvJeD5EjUpBColDWlJ7weWXdK4OxQTqPkZpl/Z3SOi4z69gf44zL13I2jE2clg2btEz0MK8Z5ljM/PZw98h/vT08fzxc/lppLeJmRNYx1L3DQIIbspab3jsenBJcUh+NtwDg24tsnDOlGaZmxS8/zsclz5RjpxqlG8FVYG81Q8hQLPew0vO3U3GxI7ov9Xov75/dKztTYp2qv8O8tLuJ03izbv1EZ5pOkFZKN3E5HmFGVrNTWk6U8kg7ljOlflnHXIjG6KFtpcQWvLEeW22V9qgFYSu8xYABnHGDitYO2LfBm8GgkgYdGdN7VrMd3DRBHpe5unq/m0aaM6vZx6fqMEEelnR8h/7pQvThfwAttlnHtLDaeU9Gaw3B9UoQehedU30E4YdekYwkbDA+SC+kDkoEbZ1vPYIIkdXsA8SXcabq4Z51TEhSqFvig5KOmxaBg9WBO6fdQDIGrySr2d0y53Helm2tHuZMn9N+X9axn2Fa6XLT2xRfxkwxb6lA+NOVMB7SglvM1Y8ASNGIRgtRRFs+bbn6RDgmipkKjue0Xfv9R6Kc2kV3W0o05+oA+aX6BdYX1rEAbZQQIAC4GAeyggaBApzwSA4doVHo0QSDzgQRnYFeGgQr+6FUWM2o5K3aIz5I25P1hg8oem6U7zkEDBwwDLYkJPbDO47Vx6noILbCEGoeaEBuggQejNYctQWv3OC9KrrvSK5hkaIARjpflnV34BBbQie5MtJyE4LkbbCe977tQ+9asrF4fqQ00/QvUB/GmJZ1GXJ1zVr1Dt2nbS4PTrXn7us487/3Gd0v8QulqswYxrns13WsbzU7EqxbouP+XnWv7FCWq3reny3lGi2Vq9lThpS307WsQ2N0q9/e3t7+AQAA//8DAI0J9d70BAAA

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTTW/cNhD9K8KcRZmkKErUzVin6MJNvEgWadEbxRnFhGVqQVFOAmP/e0DtBk5664UgBnwz72P4CsE+E/Rwezq9e6GQFigh+a0kudSMa8bFUXR9w3veVXXHjebiXyjB39N36KFVzglniNWkiSnkDRvq0TFEMoIL5LJRuaf9skD/CtZXSC/eUTUvnykufg7Qw98+4Pw1z7a+ctO8YhXnifZhSTa4TObDfnfP/ro9HB8Ol1cLLRlceYQesB40GuJMUdMwNbSaGS05EzQ41TbW2ra7wHxIFIOdqgWf3ubjnAIl18tK1hVn0sC5BLTJZsqDXej4/ZRZbBbd5Xq5lbdr/wovFKGX5U8zL+1u3ORvnpdh9RPeXM5kl6es8hTnE8XkafNkkzlNNvk5FNdJu8lTSFDCw6fiMNk0zvH5N6M+XfTv/4f8Y57+7hu5NRHu5jUk6EHUBkp4b92jD1Ts76AHLkhi3REbpdBMdWiZbWrDtK71SMKZVgooYTeH5MM6r0uxD4m+xE0A9PCHnRa6UL+N7tEncmmNWdU/Ou/CIc64ulS8+S94xaua8wxa02lNxUdCH8klyvqOcb32+w9EarmBdmuMFFJxsOmx+NMuj9CDsZ0T1lhjrXZupIbTyJFbzVskjZpQSWxRGYVaGe60soNQaBsxjLkCJVCOu9g2bFCmUUYaJlupmHLcMYOETKtWtUrJpu2ytDcfi4cp46zruCKsmaERmTLCMqPqmmHd2FbqsW3l+Isl1/QFzwYjvVz+ysZAI3aEWjCpRMOUMYJ1pmnZ0HaDGXRHjcuc7ykGmn4x6r13cV7mMRXX5Sl+s+7jGvJ/L7ZF+uoD+7ZldDe7J4pFztj6kNf7Guv5fD7/AAAA//8DADYi/1M2BAAA

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRUwW7cNhD9FWHOokxKFCXqZqwTdOGmXiSLtOhtRI5iwjK1ICknhrH/XlC7ReJT0QtBjPiGb9571Bt4fCYY4PZ0+vBCPkUoIbmtVPNaMa4YF0fRDy0feF81Xd8o0fwNJbh7eoUBOmmMMJpYQ4qYtLxlYzMZZi1pwYXldStzT/wWYXgDdJWlF2eoWuJXCtEtHgb403m7fM93o6vMvKy2CstMex8TepPJ/LHf3bPfbw/Hh8PlVKSYwZWzMIBtRmU1cSapbZkcO8W0qjkTNBrZtYjY9ReY84mCx7mK9unn/XZJnpIZ6qpuKs5qDecSLCbMlEeMdHw9ZRabRHe5Xm7lbTu8wQsFGOryXzEv7W7M7G6e47i62d5c1oTxKW7rhCYt4RVKOIXlRCE52vS5jZGex/n1iPHp4+VM3sYPP8isiexuWX2CAUSjoIRNoHnG5BZfXDnuZkc+bR9TcD4681+9Gijh4UtxmDFNS3h+58eXi8z7/6HyJzSPzlOxv4MBuKDaNj2xqRaKyd4iw7bRTKlGTSSM7moBJewWn5xflzUWe5/oW9hGggE+4hzpwu82mEeXyKQ15Dn/UjlXh7DY1aTip5eCV7xqOM+gNZ3WVHwm6wKZRHmIY1iv/b6+h9Sq3kC7NQTyqThgeix+w/gIA2jsjUCNGlEZM1HLaeKWo+KdJWUVWVnbzkotrZKaGyVxFNJiK8YpV6AEytEptrRKO8mubg3rOimYxNYw5NgzoaVRvBsnqd7rWDzMGYem55JswzRNlkktkGnZNMw2LXa1mrqunn6R5JoHwbPAll4u725joKztySrBailaJrUWrNdtx8auH/WoempN5nxPwdP8i1CfnAlLXKZUXBNSvJPu8+rzv6PY0vLdefZj8+huMU8UiuwxOp+fytXW8/l8/gcAAP//AwCpOTG6ggQAAA==

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTwW7bOBD9FYFnUSEpihJ1C5xd1Ei7MdqgLXobcUY1UYUyKCptEfjfF5RdpN3b3ohHvpk37w1fWIAnYj27PZ3+eqaQFlay5DdICWW4MFzIR9n1jehFV9VdrVujvrCS+Xv6yXrWaueks8RrMsQ1ioYP9eg4IlkpJArV6FwTvi6sf2HgK6Rn76ial48UFz8H1rNPPuD8PfcGX7lpXrGK80T7sCQILov5Z7+7529vD48Ph8urhZZMrjyynmE9GLQkuKam4XpoDbdGCS5pcLptAKDtLjQfEsUAU7Xgt9f+OKdAyfWqUnUluLLsXDKEBFnyAAs9/jxlFZtFdxkvN3g79i/smSLrVfnLzEu5Gzf5Gzc/PUHAm9EHvxxZyU5xPlFMnjY7tgmnCZKfQ3Ftsps8hcRK9vChOEyQxjk+/eHRh8vo+/8x+TtwRx+o2N+xnglJCuuO+Kik4bpD4NDUlhtTm5Gks62SrGS7OSQf1nldin1I9DVuKlnP/oZpoYu+2+iOPpFLa8zSP5ucNf3waTdjBgQr2SHOuLpUvNotRSWqWuTLhzWd1lS8J/SRXKI802Ncr+X/Q1FGbaTdGiOFVBwgHYs3sBxZzyx0ToIFC2CcG6kRNAoUYESLZNAQaoUtaqvRaCuc0TBIjdDIYcxI1p3TLbaFasmJsQXiaCxyTfXAO20sh1aia5oRNGSJr7YWD1PmgeuEJqy5pRG5thK41XXNsW6gVWZsWzX+Zsk1cSmy30jPl6+xKTCIHaGRXGnZcG2t5J1tWj603WAH01HjsuZ7ioGm34x6512cl3lMxXVhij+se7+G/L2LbXm++8B/bJHdze4bxSJHDj7kbb6mfD6fz/8CAAD//wMA1TddHSUEAAA=

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRUTW/bOhD8KwLPpsIvUaJugdPiGXltjNZoi94o7qoholAGSaUNAv/3grKLNKeHdyGIFWc5OzPUCwn2EUlPro/Hd08YciIbkv1aEkxoyjRl4sBN38iet7XkmstWfCcb4m/xmfSkVc5xZ5BK1EgVsIYOcnQUAA1nHJhoVOlpfyTSvxDra8An77Ce0xeMyc+B9OSrDzD/LHdbX7tpXqCO84S7kLINrpD5uNve0n+v94e7/flUwlTAtYdCFUTHJHR0YK2iCnVLDQdFXWMG7Zi0QpgzzIeMMdipTvDwej/MOWB2vaiFrBkVhpw2BGy2hfJgEx6ej4XFKtFNqW/W8rrtX8gTRtKLzR8xz+2u3OSvHtOw+Amuzmu26SGt62hdnuMz2ZBjnI8Ys8dVn+uU8HGYng82Pbw/nynb9O4XuiUjbOclZNITrjjZkFWgabLZz6G6cNxOHkNeP+boQ/Luv3oVf+4+V/vJ5nGOj2/8+HyWefc/VP5g3b0PWO1uSE8YRwGyQzoKrqnqwFLbSEO1lnpE7kwryiDbOWQflnlJ1S5k/BHXkUhP3tsp4ZnfdXT3PqPLSyxzftOF9z7OsLhcvXrJWc1qyVgBLfm45OoTgo/oMpYhDnG59PvyFiK0WEHbJUYMudrbfF/9Y9M96YmxnePWWGOtdm7EhuHIgFnNWkANGkEJaEEZBVoZ5rSyA1dgGz6MpUI2BEt0qjWtbByN0cxRK1tGVdc6OnSOU9N2nInOgjAlYK86VndTwVnXMYUgqcERqDLcUqOkpCAb2wo9tq0Y/5LkkgfOisCAT+d3tzLQAB2C5lQo3lBlDKedaVo6tN1gBt1h4wrnW4wBp7+E+uBdnNM85uqSkOqNdJ+WUP4d1ZqWnz7QX6tHN7N7wFgVj60P5alcbD2dTqffAAAA//8DAH1Y7jeCBAAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSTTW/cOAyG/4qhs+Xoy7LlWzDZRQdpN4M2aIveaJHuCHXkgS2nLYL57wt5pki7t70JlEg+fF/qhUV4Itax29Ppr2eKaWElS2ELKaEsF5YL9ShdV+tONpVW2igjv7CShXv6yTrWGO+ld8Q1WeIGRc17PXiOSE4KiULVJteErwvrXhiECuk5eKqm5SPNS5gi69inEHH6nntDqPw4rVjN00j7uCSIPsP8s9/d87e3h8eHw+XVQktOrgJmVFSt0NjyXjSGG7INdxIN97XrrRcalHKXtBATzRHGasFvr/1xSpGS71SldCW4cuxcMoQEGbmHhR5/njLFJtFdjpdbeDt2L+yZZtap8peYl3I3fgw3fnp6gog3Q4hhObKSnebpRHMKtMmxTTiOkMIUi2uT3RgoJlayhw/FYYQ0TPPTHxp9uIy+/x+TvwN/DJGK/R3rmJCkULfEByUtNy0Ch1o7bq22A0nvGiVZyXZTTCGu07oU+5jo67xRso79DeNCF77b2R9DIp/WOaN/ttlr+hHSbsIcEKxkh3nC1afiVW4pKlFpkS8f1nRaU/GeMMzkE+WZHuf1Wv4/KcqqLWm3zjPFVBwgHYs3sBxZxxy0XoIDB2C9H6gWNAgUYEWDZNESGoUNGmfQGie8NdBLg1DLfsiRzJ3dLbaFGtABoTC8h6blpjaSO6uBSwutVVobkBnkVdbiYcx54FthCDV3NCA3TgJ3RmuOuoZG2aFp1PCbJFfHpch6Iz1fvsZGYBFbQiu5MrLmxjnJW1c3vG/a3vW2pdpn5nuaI42/CfUu+HlapiEV14Up/pDu/Rrz9y625fkeIv+xWXY3+W80F9lyCDFv89Xl8/l8/hcAAP//AwCN39GdJQQAAA==

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUTW/cNhCG/4rAsyjzS6Som2G3qOGmWcRGW/Q25IxiIVppQVGbBob/e0HtFol7640YcIYvn3lnXtkMR2I9uz2dfjrTnFdWszzuISWU5cJyoZ6l71vXy7ZxznSik3+xmo2P9I31zJkYZfTENVniBkXLgx4iRyQvhUShWlNqwueV9a8MxgbpPEZqlvV3Suu4zKxnf4wzLl/L2zA2cVo2bNIy0cO8ZphjEfPbw90j//X28PzxcLm10lqSmxFZz8AHMN1gOanguAEgHlwAThGJWgKpTbykjXOmNMPUrPjl+/u45Jly7FWjdCO48uytZggZiuQAKz1/OxUVO6L7Eq/38H7sX9mZEutV/S/MS7mbOI036xYmOtN0grRSuonL8Qgzspqd0nKilEfasZwphWUdcyEao4OukxI7cKZ12OlWaYdaEHbCtejRgzV2ULFtBwydd2YwqKRBS8YEx2q2g5smyOMyV1ftd9NIc2Y1+/hUHSbIw5KO79A/XYg+/A+gRTbrmRatts6R0VqDt0EJQmejtSpEEG4IimQk0XrjvHRCaq+E1611nUMQvlT6APFlnKl6uGc9E5IU6o74oKTlpkPg0GrPrdV2IBm9U5LV7G6Z8zhvy7ZWD3Omz2n/L+vZzzCtdPnpbYovY6aYt1Qg/GmLGQ9pwS3m6rsBpGhEo4UoSVs+bbn6RDgmipkKjue0Xev9J0VZtSfdbSnRnKsD5JfqF1hfWM88dFGCBw9gYxyoFTQIFGCFQ7JoCY1Ch8YbtMaLaA0EaRBaGYYSYTWj4rdqtziBH2hAy8Fp4qZTinchOC6UNcHpIAWGdxyrj9PeydgJQ6i5pwG58RK4N1pz1C04ZQfn1PADkqtZpCiAkc6XYd0VWMSO0EqujGy58V7yzreOB9cFH2xHbSyaHynNNP0A6sMY07IuQ66uXqveofu0zWXhVLvvvo4z/3vv0f0Sv1CqSo9hnMt8Xdv6VrMjwbolOu77qn9lhzJc1fO+trRopNK6Zk8ZUt5O17DVjZGqfXt7e/sHAAD//wMAlFq6YfQEAAA=

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUTW/cNhCG/4rAsyjzS6Som2G3qOumXsSLtOhtSI5sIVpqQVGbBsb+94DaDRL31psw0DszfOadeSMRDkh6cns8/nLCmBdSkzxuIcGEpkxTJvbc9q3pedsYqXkn+T+kJuMjfiU9Mcp77i1SiRqpCqylTg6ehoCWMx6YaFXJCS8L6d8IjE3A0+ixmZdPmJZxjqQnf40xzF9KbRgbP81raNI84UNcMkRfmvnz4e6R/nG72z/tLn8tuBRxMwbSE7AOVDdoisIZqgCQOuOAog+ILQKXyl9kY8yYIkzNEj7/qB/mHDH7XjRCNowKS841CZChtOxgwf3XY+liQ3Rf4vUW3j77N3LCRHpRf4d5SXfjp/Emz8cJTzgdIS2Ybvx8OEAMpCbHNB8x5RE3LNtLpwnyOMfqWuxuGjFmUpOn52o3QR7mdHjH6vmC4OF/EDhhcqQnkrVSG4NKSglWO8EwGO21Fs4DM4MTyD2y1ipjuWFcWsGsbLXpTABmS6aXaXYw/b5s/KxgrgvMCC1QoRVKa+sZyg6H1iALoLXTnbdBdkaoLnQSLAC3Do232heDfAD/OkasHu5JTxhHEWSHdBBcU9UFoNBKS7WWekDurRGc1ORujnmM67wu1UPM+JI2fqQnv8K04IXcbfKvY0af11Sg/q1LsV2aw+pz9cMBnDWskYwV0ZqPa64+YhgT+owF7z6t13z/kQgtNtHdmhLGXO0gv1a/wfJaqEDnOdjyVO39gC3DgQUGmpmAOmgMSgQTlFVBK8u8VuC4CtByN5QIqQkWw1WbxwdoUXZgqJC+parVnlqwnHZdkIDGO8b9O47V07Q5w3dMYZDU4hCoshyoVVLSIFswQg/GiOEnJFfzcVYABzxdtnXrQIfQYdCcCsVbqmwpbltDnemcdbrD1peeHzFFnH4C9WH0aV7mIVdX71bv0H1cY7k41ebjL2Ok/24zup/9Z0xVmTGMsSzYdaznmhwQljXhYTtY/RvZle2q9tvdkqzhQsqaPGdIeT1ew1o2iov2fD6fvwEAAP//AwBUXko/9QQAAA==

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUzW7jNhSFX0Xg2lRIiqJE7VKngxqZTIxJOi26uyKvHCIyaVBUpkGQdx9QdhBnBl10J1zx/n3nkC/Ewx5JRy4Ph9+f0KeJrEhyS0gwoShTlIl7rru66XhdtqzmrFL/kBVx1/hMOtJIY7jRSCtUSKVlNe2rwVBrUXPGLRO1zDVhN5HuhYArLT45g2WYvmGcXPCkI385b8P33BtcacYw2zKGETd+SuBNHubLZn1NP19u72+3x1MTTjm5dJZ0BHQPsh0URdE3VAIg7ZseKBqLWCPwSppjmvMJo4exnOzje38bksdkOlGKqmRUaPK6IhYS5JF7mPD++ZCnWBBd5fhqCS+f3Qt5wkg6sXqDeSx3YUZ3sZ/62Y32Ygy7nfO7dfCD280RUm68IocYDhiTwwXOb85DfP4cdrtcj3yCcUKyIvcY987DePxxhQPMY7oLc1zITPbxv85kMnMKZEUWkuO4tC1Oy6xHhz79knvW+Pau2I6QhhD3H0S6O7Lf/A/0N2AenMdic0U6wjgKW7VIB8EVla0FCnWlqVKVGpAb3QhOVmQdfHJ+DvNUbHzC3Ynah/kuo3lwCU2aY97pb5XNto3BziYV7wJzVrKyYiwnzekwp+IrWhfRJMxL3Mf5VO+nFKHEkvTJjfgLnvUcI/pUbCE9FH/A9EA6oqE1HDRoAGXMgDXDgVkGijUWlVVopbCNlVpaJTUzSkLPpYWa90OOkBXBbLJi8TUfBGhAQ3mLNZVVjxS0MLSqGylkpfUwDB/gFrfjIoppmURbUY2DpVJzoFpWFbVVDY1QQ9OI4YzTyRCcZernJvxzQnvy0pejtd92t/h0vMfLnMraFq3iVEheU6k1p62uG9o3ba971WJt8mbr4KfwBvIbxj5MLuVH5MZ5t4fx5yPvylxj9DieqXPjTAxTGFJxsmXxQa+vs8+vWLFY9Lvz9N/FGFfBPGIssrHA+TMxX19fX38AAAD//wMAr++TXgwFAAA=

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUTW/bOBD9KwLPokJS1AcF7MFwWqyRpjESd3exN4ocxkRk0aAop0GR/76gJNdO0cPehNF8vHnvDX+gXh4ANWh1PH46QR8GlKJgpxAjrMSkxITtqGiKqqFlxipOKaP/ohTZO3hDDaq4UlQJwDmUgLkmBW5zo7DWICihmrCCx57yeUDNDyRtpuFkFWRu+Av8YF2PGvS37bV7jbOlzVTnRp1518GmH4LsVQTzdbO+w19W293Dds4aYIjFmdWoQVK0ktemxMDaCnMpAbdVKzEoDVCApDlXc5ntA/hedtmgXy7ztQs9BNWwjOUZwUyg9xRpGWSE3MoBdm/HiGKi6DbG0yk8fTY/0Ak8alh6JnNud6M6e3MY2tF2+iZI/wzBeHmAV+df4CQ7lKKjd0fwwcLEzcNTcoFESUYyVjJCUIrupdrbHpLNbfLQTQurmnDQORZgNOaCSix4nmOdF7JipakqZlCK7sD30F11vbfKu8GZkCyUJx/mrF0fbD+6cUg2fYBnL8Nc91l2A6AUbce2s8P+EaR+27nHMf7rxy7ush69hz4kWxn2yZ9y2KMGCVkrKoUUUpZKGSgIGKKJLEmlodQlaM50pbnguuSCqJLLlnItC9qaGEEpehjDcQxn/r9fgdh5eziAviDYTRxvOxmM84eNhj5YY6M054zJUF03bZUsPdedhT58IBk1iFBgOq8BG0ZLzGstsSxygcsyLw1QJSpGZ8KMfR5/8kSM0Tw3RV2XrWgpAWaqmpPKtEQrkyuhixYqVpGqhlooQtqC07oVimigBddxO+/0qMKvVsgngR7HPl5nsolrv9oefy/5hZGtd8Ep10XBbAdPb0OAw+9UW7vD0Q02xP3NIu23Ae5dH/8u979wthSvXLjEIN5BMp2eriSYghdYVBIwNzVgCdzgGog2NSeEqAKl6NapF/BJNJi0/aTJ2VMPT8nKq70NoMLo4+R/pqVmOT+fT+bCR/b1027tPKyOx3SJ/nGKJM3Nzgb48K58G2L63g1xi+DHKx89QWfOuK7cpOE0v1Q2Bkuta9AlxYzTAnMhKK5FUeG2qlvRljUU6uLW5BG09aDC1G83T1uI/Z0tVz5YI1UY4u18cWpy02LPJeVXkFeq/ayeh18ptzhp6URJdOzT/G5OBvpfz+b7+/v7fwAAAP//AwB0fDGlJwYAAA==

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTTW/cNhD9K8KcRZmkKErUzVin6MJNvEgWadHbiBzFhGVqQVFOAmP/e0DtBk5664UgBnwz72P4CgGfCXq4PZ3evVBIC5SQ/FaSXGrGNePyKEzftL2sq7btlNT1v1CCv6fv0EOrrBXWEKtJE1OON2yoR8ucIyO4cFw2KvfELwv0r4C+cvTiLVXz8pni4ucAPfztg5u/5tnoKzvNq6viPNE+LAmDzWQ+7Hf37K/bw/HhcHm10JLBlXfQA5oBVTdqRnJomUIkNrQDMrKOqCEUtbIXmA+JYsCpWtzT23w3p0DJ9rKSdcWZNHAuwWHCTHnAhY7fT5nFZtFdrpdbebv2r/BCEXpZ/jTz0u7GTv7meRlWP7mby5lwecoqT3E+UUyeNk82mdOEyc+huE7aTZ5CghIePhWHCdM4x+ffjPp00b//H/KPefq7b2TXRG43ryFBD0I1UMJ7tI8+ULG/gx64IOnqjtgohWaqc8iwqQ3TutYjCWtaKaCE3RySD+u8LsU+JPoSNwHQwx84LXShfhvto09k0xqzqn903oVDnN1qU/Hmv+AVr2rOM2hNpzUVH8n5SDZR1neM67XffyBSyw20W2OkkIoDpsfiT1weoQeDnRVo0CBqa0dqOI3ccdS8daSdJqeka50yymlluNUKB6EcNmIYcwVKoBx3sW2YNkRcaMtka5EprQc2KNuxQQ1167BrrMzS3nwsHqYtGttxRa5mhkbHlBHIjKpr5uoGW6nHtpXjL5Zc0xc8G+zo5fJXLgyc68hpwaQSDVPGCNaZpmVD2w1m0B01NnO+pxho+sWo997GeZnHVFyXp/jNuo9ryP+92Bbpqw/s25bR3WyfKBY5Y/Qhr/c11vP5fP4BAAD//wMA//ibKjYEAAA=

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTwW7bOBD9FYFnUSEpihJ1C5xd1Ei7MdqgLXobcUY1UYUyKCptEfjfF5RdpN3b3ohHvpk37w1fWIAnYj27PZ3+eqaQFlay5DdICWW4MFyoR2n7pu1VXbWd6FohvrCS+Xv6yXrWaueks8RrMsQ1ioYP9eg4IlkpJArV6FwTvi6sf2HgK6Rn76ial48UFz8H1rNPPuD8PfcGX7lpXrGK80T7sCQILov5Z7+7529vD48Ph8urhZZMrjyynoEdQHej4aSGlmsA4kM7ACeHRA2BrLW70HxIFANM1YLfXvvjnAIl16tK1ZXgyrJzyRASZMkDLPT485RVbBbdZbzc4O3Yv7BniqxX5S8zL+Vu3ORv3Pz0BAFvRh/8cmQlO8X5RDF52uzYJpwmSH4OxbXJbvIUEivZw4fiMEEa5/j0h0cfLqPv/8fk78AdfaBif8d6JiQprDvio5KG6w6BQ1NbbkxtRpLOtkqyku3mkHxY53Up9iHR17ipZD37G6aFLvpuozv6RC6tMUv/bHLW9MOn3YwZEKxkhzjj6lLxarcUlahqkS8f1nRaU/Ge0EdyifJMj3G9lv8PRRm1kXZrjBRScYB0LN7AcmQ9s9A5CRYsgHFupEbQKFCAES2SQUOoFbaorUajrXBGwyA1QiOHMSNZd0632BYKjdC21cglNQPXUozcggROgMKOrXCmzYxXW4uHaYvDdUIT1tzSiFxbCdzquuZYN9AqM7atGn+z5Jq4FNlvpOfL19gUGMSO0EiutGy4tlbyzjYtH9pusIPpqHFZwT3FQNNvRr3zLs7LPKbiujDFH9a9X0P+3sW2PN994D+2yO5m941ikSMHH/I2X1M+n8/nfwEAAP//AwAfRvRKJQQAAA==

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUwW7cNhCGX0XgeSmTFEWJuhl2gi7c1IvESIveRpxRTFimFiTlxDD23Qtqt0h8KnohiBFn+M/3D/XGAjwTG9j18fjhhUJObMey30JKKMOF4UI9SDu03aCauuta06j2b7Zj/o5e2cA67Zx0lnhDhrhG0fKxmRxHJCuFRKFaXWrCt8SGNwa+RnrxjuolfaWY/BLYwP70AZfv5W7wtZuXFeu4zLQPKUNwRcwf+5s7/vv14eH+cD6VKJXk2iMbGNgRdD8ZTmrsuAYgPnYjcHJI1BLIRrtzmg+ZYoC5Tvj0835ccqDsBlWrphZcWXbaMYQMRfIIiR5ej0XFhui2xHdbeNsOb+yFIhvU7l+Y53JXbvZXz2lc/YxX5zVDekrbOoHLS3xlO3aMy5Fi9rTxuU6Jnsf59QHS08fzmbJNH36QWzPhzbKGzAYmtWQ7tgGaZ8h+CdVF483sKeTtY44+JO/+q1bx5/5LdZghT0t8fufHlzPm/f+g/Ancow9U7W/ZwIQkhU1PfFLScN0jcGgby41pzETS2U6VRm6WkH1YlzVV+5DpW9xaYgP7CHOis77r6B59JpfXWPr8yxTdh7jg6nL100spalE3QpSkNR/XXH0m9JFcptLEQ1wv9b6+T1FGbUk3a4wUcnWA/Fj9BumRDcxC7yRYsADGuYlaQZNAAUZ0SAYNoVbYobYajbbCGQ2j1AitHKcSYTtGZXSqbVrbroHWTJr3I3VcS7QcJBreCbI4WWnHtn/HsbqfN/6uF5qw4ZYm5NpK4FY3DcemhU6ZqevU9AuSyzxIUQAjvZzf3abAIPaERnKlZcu1tZL3tu342PWjHU1PrSua7ygGmn8B9cm7uKRlytVlQqp36D6vofw7qm1avvvAf2we3S7uiWJVPAYfylO52Ho6nU7/AAAA//8DAIN+pxyCBAAA

View File

@ -95,10 +95,10 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
AgeReferenceSceneID = (int?)null, AgeReferenceSceneID = (int?)null,
Height = (string?)null, Height = (string?)null,
EyeColour = (string?)null, EyeColour = (string?)null,
CharacterImportance = NormalizeCharacterImportance(character.Role, character.Importance),
DefaultDescription = CombineText( DefaultDescription = CombineText(
character.Description, character.Description,
character.Notes, character.Notes,
CharacterImportMetadata(character),
ImportMarker) ImportMarker)
}, },
transaction, transaction,
@ -658,12 +658,6 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
MetadataLine("Package description", package.Description)) ?? ImportMarker; MetadataLine("Package description", package.Description)) ?? ImportMarker;
} }
private static string? CharacterImportMetadata(ImportCharacterDto character)
{
var role = NormalizeCharacterRole(character.Role, character.Importance);
return role is null ? null : $"Imported character classification: {role}";
}
private static IReadOnlyList<string> GetSkippedOptionalFields(PlotLineImportPackage package) private static IReadOnlyList<string> GetSkippedOptionalFields(PlotLineImportPackage package)
{ {
var skipped = new List<string>(); var skipped = new List<string>();
@ -1059,7 +1053,7 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
: lookupIds.ThreadEventTypeIds.Values.First(); : lookupIds.ThreadEventTypeIds.Values.First();
} }
private static string? NormalizeCharacterRole(string? role, int? importance) private static int? NormalizeCharacterImportance(string? role, int? importance)
{ {
var normalized = Clean(role).ToLowerInvariant(); var normalized = Clean(role).ToLowerInvariant();
if (normalized.Contains("protagonist", StringComparison.OrdinalIgnoreCase) if (normalized.Contains("protagonist", StringComparison.OrdinalIgnoreCase)
@ -1068,33 +1062,33 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
|| normalized.Contains("lead", StringComparison.OrdinalIgnoreCase) || normalized.Contains("lead", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("antagonist", StringComparison.OrdinalIgnoreCase)) || normalized.Contains("antagonist", StringComparison.OrdinalIgnoreCase))
{ {
return "Major"; return 10;
} }
if (normalized.Contains("minor", StringComparison.OrdinalIgnoreCase) if (normalized.Contains("minor", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("background", StringComparison.OrdinalIgnoreCase) || normalized.Contains("background", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("cameo", StringComparison.OrdinalIgnoreCase)) || normalized.Contains("cameo", StringComparison.OrdinalIgnoreCase))
{ {
return "Minor"; return 2;
} }
if (normalized.Contains("support", StringComparison.OrdinalIgnoreCase) if (normalized.Contains("support", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("secondary", StringComparison.OrdinalIgnoreCase)) || normalized.Contains("secondary", StringComparison.OrdinalIgnoreCase))
{ {
return "Supporting"; return 6;
} }
if (importance >= 8) if (importance >= 8)
{ {
return "Major"; return 10;
} }
if (importance >= 5) if (importance >= 5)
{ {
return "Supporting"; return 6;
} }
return importance.HasValue ? "Minor" : null; return importance.HasValue ? 2 : null;
} }
private static int ResolveSceneDependencyTypeId(ImportLookupIds lookupIds, string? dependencyType) private static int ResolveSceneDependencyTypeId(ImportLookupIds lookupIds, string? dependencyType)

View File

@ -1816,6 +1816,7 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory)
character.AgeReferenceSceneID, character.AgeReferenceSceneID,
character.Height, character.Height,
character.EyeColour, character.EyeColour,
character.CharacterImportance,
character.DefaultDescription character.DefaultDescription
}, },
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);

View File

@ -467,6 +467,7 @@ public sealed class Character
public int? AgeReferenceSceneID { get; set; } public int? AgeReferenceSceneID { get; set; }
public string? Height { get; set; } public string? Height { get; set; }
public string? EyeColour { get; set; } public string? EyeColour { get; set; }
public int? CharacterImportance { get; set; }
public string? DefaultDescription { get; set; } public string? DefaultDescription { get; set; }
public DateTime CreatedDate { get; set; } public DateTime CreatedDate { get; set; }
public DateTime UpdatedDate { get; set; } public DateTime UpdatedDate { get; set; }

View File

@ -1,3 +1,4 @@
using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace PlotLine.Models; namespace PlotLine.Models;
@ -52,6 +53,7 @@ public sealed class ImportCharacterDto
public string? DisplayName { get; set; } public string? DisplayName { get; set; }
public string? ShortName { get; set; } public string? ShortName { get; set; }
public string? Role { get; set; } public string? Role { get; set; }
[JsonConverter(typeof(ImportImportanceJsonConverter))]
public int? Importance { get; set; } public int? Importance { get; set; }
public string? Description { get; set; } public string? Description { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
@ -299,3 +301,65 @@ public sealed record ImportResult
public string? TechnicalDetail { get; init; } public string? TechnicalDetail { get; init; }
public string Message { get; init; } = string.Empty; public string Message { get; init; } = string.Empty;
} }
public sealed class ImportImportanceJsonConverter : JsonConverter<int?>
{
public override int? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
{
return null;
}
if (reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out var numeric))
{
return Math.Clamp(numeric, 1, 10);
}
if (reader.TokenType != JsonTokenType.String)
{
return null;
}
var text = reader.GetString()?.Trim();
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
if (int.TryParse(text, out var parsed))
{
return Math.Clamp(parsed, 1, 10);
}
var normalized = text.ToLowerInvariant();
if (normalized.Contains("major") || normalized.Contains("main") || normalized.Contains("protagonist"))
{
return 10;
}
if (normalized.Contains("support") || normalized.Contains("secondary"))
{
return 6;
}
if (normalized.Contains("minor") || normalized.Contains("background") || normalized.Contains("cameo"))
{
return 2;
}
return null;
}
public override void Write(Utf8JsonWriter writer, int? value, JsonSerializerOptions options)
{
if (value.HasValue)
{
writer.WriteNumberValue(value.Value);
}
else
{
writer.WriteNullValue();
}
}
}

View File

@ -1349,9 +1349,8 @@ public sealed class TimelineService(
characterGroup => characterGroup.GroupBy(x => x.SceneID).ToDictionary(sceneGroup => sceneGroup.Key, sceneGroup => sceneGroup.ToList())); characterGroup => characterGroup.GroupBy(x => x.SceneID).ToDictionary(sceneGroup => sceneGroup.Key, sceneGroup => sceneGroup.ToList()));
return characters return characters
// Character has no global category/importance column. Timeline grouping uses lane order: // Timeline grouping prefers CharacterImportance when present; imported classification
// the first six lanes render as "Major characters", so imported classification metadata // metadata and strong scene roles remain as fallbacks for older projects.
// and strong scene roles should sort likely main characters ahead of supporting cast.
.OrderByDescending(character => CharacterLanePriority(character, appearancesByCharacterAndScene.TryGetValue(character.CharacterID, out var sceneAppearances) ? sceneAppearances.Values.SelectMany(x => x) : [])) .OrderByDescending(character => CharacterLanePriority(character, appearancesByCharacterAndScene.TryGetValue(character.CharacterID, out var sceneAppearances) ? sceneAppearances.Values.SelectMany(x => x) : []))
.ThenBy(x => x.CharacterName) .ThenBy(x => x.CharacterName)
.Select(character => .Select(character =>
@ -1374,9 +1373,22 @@ public sealed class TimelineService(
private static int CharacterLanePriority(Character character, IEnumerable<SceneCharacter> appearances) private static int CharacterLanePriority(Character character, IEnumerable<SceneCharacter> appearances)
{ {
var description = character.DefaultDescription ?? string.Empty; var description = character.DefaultDescription ?? string.Empty;
var appearanceList = appearances as IReadOnlyCollection<SceneCharacter> ?? appearances.ToList();
var priority = 0; var priority = 0;
if (description.Contains("Imported character classification: Major", StringComparison.OrdinalIgnoreCase)) if (character.CharacterImportance >= 8)
{
priority += 1_200;
}
else if (character.CharacterImportance >= 4)
{
priority += 500;
}
else if (character.CharacterImportance.HasValue)
{
priority -= 150;
}
else if (description.Contains("Imported character classification: Major", StringComparison.OrdinalIgnoreCase))
{ {
priority += 1_000; priority += 1_000;
} }
@ -1389,7 +1401,9 @@ public sealed class TimelineService(
priority -= 100; priority -= 100;
} }
foreach (var appearance in appearances) priority += appearanceList.Count * 3;
foreach (var appearance in appearanceList)
{ {
if (string.Equals(appearance.RoleInSceneTypeName, "POV Character", StringComparison.OrdinalIgnoreCase)) if (string.Equals(appearance.RoleInSceneTypeName, "POV Character", StringComparison.OrdinalIgnoreCase))
{ {
@ -2904,6 +2918,7 @@ public sealed class CharacterService(IProjectRepository projects, ICharacterRepo
AgeAtSeriesStart = character.AgeAtSeriesStart, AgeAtSeriesStart = character.AgeAtSeriesStart,
Height = character.Height, Height = character.Height,
EyeColour = character.EyeColour, EyeColour = character.EyeColour,
CharacterImportance = character.CharacterImportance,
DefaultDescription = character.DefaultDescription, DefaultDescription = character.DefaultDescription,
Project = await projects.GetAsync(character.ProjectID) Project = await projects.GetAsync(character.ProjectID)
}; };
@ -2947,6 +2962,7 @@ public sealed class CharacterService(IProjectRepository projects, ICharacterRepo
AgeAtSeriesStart = model.AgeAtSeriesStart, AgeAtSeriesStart = model.AgeAtSeriesStart,
Height = model.Height, Height = model.Height,
EyeColour = model.EyeColour, EyeColour = model.EyeColour,
CharacterImportance = model.CharacterImportance,
DefaultDescription = model.DefaultDescription DefaultDescription = model.DefaultDescription
}); });

View File

@ -771,7 +771,7 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
{ {
if (package.Characters.Any(character => character.Importance.HasValue || !string.IsNullOrWhiteSpace(character.Role))) if (package.Characters.Any(character => character.Importance.HasValue || !string.IsNullOrWhiteSpace(character.Role)))
{ {
result.Warnings.Add("Character importance is not stored in a dedicated character field; the importer uses role/importance for timeline grouping where supported and preserves a clean classification note."); result.Warnings.Add("Character role/importance will be mapped to Story importance for timeline grouping.");
} }
foreach (var group in package.Characters foreach (var group in package.Characters

View File

@ -43,6 +43,7 @@ BEGIN
AgeReferenceSceneID int NULL, AgeReferenceSceneID int NULL,
Height nvarchar(50) NULL, Height nvarchar(50) NULL,
EyeColour nvarchar(50) NULL, EyeColour nvarchar(50) NULL,
CharacterImportance int NULL,
DefaultDescription nvarchar(max) NULL, DefaultDescription nvarchar(max) NULL,
CreatedDate datetime2 NOT NULL CONSTRAINT DF_Characters_CreatedDate DEFAULT SYSUTCDATETIME(), CreatedDate datetime2 NOT NULL CONSTRAINT DF_Characters_CreatedDate DEFAULT SYSUTCDATETIME(),
UpdatedDate datetime2 NOT NULL CONSTRAINT DF_Characters_UpdatedDate DEFAULT SYSUTCDATETIME(), UpdatedDate datetime2 NOT NULL CONSTRAINT DF_Characters_UpdatedDate DEFAULT SYSUTCDATETIME(),
@ -319,7 +320,7 @@ AS
BEGIN BEGIN
SET NOCOUNT ON; SET NOCOUNT ON;
SELECT CharacterID, ProjectID, CharacterName, ShortName, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID, SELECT CharacterID, ProjectID, CharacterName, ShortName, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID,
Height, EyeColour, DefaultDescription, CreatedDate, UpdatedDate, IsArchived Height, EyeColour, CharacterImportance, DefaultDescription, CreatedDate, UpdatedDate, IsArchived
FROM dbo.Characters FROM dbo.Characters
WHERE ProjectID = @ProjectID AND IsArchived = 0 WHERE ProjectID = @ProjectID AND IsArchived = 0
ORDER BY CharacterName; ORDER BY CharacterName;
@ -332,7 +333,7 @@ AS
BEGIN BEGIN
SET NOCOUNT ON; SET NOCOUNT ON;
SELECT CharacterID, ProjectID, CharacterName, ShortName, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID, SELECT CharacterID, ProjectID, CharacterName, ShortName, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID,
Height, EyeColour, DefaultDescription, CreatedDate, UpdatedDate, IsArchived Height, EyeColour, CharacterImportance, DefaultDescription, CreatedDate, UpdatedDate, IsArchived
FROM dbo.Characters FROM dbo.Characters
WHERE CharacterID = @CharacterID AND IsArchived = 0; WHERE CharacterID = @CharacterID AND IsArchived = 0;
END; END;
@ -349,14 +350,15 @@ CREATE OR ALTER PROCEDURE dbo.Character_Save
@AgeReferenceSceneID int = NULL, @AgeReferenceSceneID int = NULL,
@Height nvarchar(50) = NULL, @Height nvarchar(50) = NULL,
@EyeColour nvarchar(50) = NULL, @EyeColour nvarchar(50) = NULL,
@CharacterImportance int = NULL,
@DefaultDescription nvarchar(max) = NULL @DefaultDescription nvarchar(max) = NULL
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET NOCOUNT ON;
IF @CharacterID IS NULL OR @CharacterID = 0 IF @CharacterID IS NULL OR @CharacterID = 0
BEGIN BEGIN
INSERT dbo.Characters (ProjectID, CharacterName, ShortName, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID, Height, EyeColour, DefaultDescription) INSERT dbo.Characters (ProjectID, CharacterName, ShortName, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID, Height, EyeColour, CharacterImportance, DefaultDescription)
VALUES (@ProjectID, @CharacterName, @ShortName, @Sex, @BirthDate, @AgeAtSeriesStart, @AgeReferenceSceneID, @Height, @EyeColour, @DefaultDescription); VALUES (@ProjectID, @CharacterName, @ShortName, @Sex, @BirthDate, @AgeAtSeriesStart, @AgeReferenceSceneID, @Height, @EyeColour, @CharacterImportance, @DefaultDescription);
SET @CharacterID = CAST(SCOPE_IDENTITY() AS int); SET @CharacterID = CAST(SCOPE_IDENTITY() AS int);
END END
ELSE ELSE
@ -364,7 +366,7 @@ BEGIN
UPDATE dbo.Characters UPDATE dbo.Characters
SET CharacterName = @CharacterName, ShortName = @ShortName, Sex = @Sex, BirthDate = @BirthDate, SET CharacterName = @CharacterName, ShortName = @ShortName, Sex = @Sex, BirthDate = @BirthDate,
AgeAtSeriesStart = @AgeAtSeriesStart, AgeReferenceSceneID = @AgeReferenceSceneID, AgeAtSeriesStart = @AgeAtSeriesStart, AgeReferenceSceneID = @AgeReferenceSceneID,
Height = @Height, EyeColour = @EyeColour, DefaultDescription = @DefaultDescription, Height = @Height, EyeColour = @EyeColour, CharacterImportance = @CharacterImportance, DefaultDescription = @DefaultDescription,
UpdatedDate = SYSUTCDATETIME() UpdatedDate = SYSUTCDATETIME()
WHERE CharacterID = @CharacterID; WHERE CharacterID = @CharacterID;
END END
@ -710,11 +712,11 @@ CREATE OR ALTER PROCEDURE dbo.CharacterTimeline_GetByProject
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET NOCOUNT ON;
SELECT TOP (12) CharacterID, ProjectID, CharacterName, ShortName, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID, SELECT CharacterID, ProjectID, CharacterName, ShortName, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID,
Height, EyeColour, DefaultDescription, CreatedDate, UpdatedDate, IsArchived Height, EyeColour, CharacterImportance, DefaultDescription, CreatedDate, UpdatedDate, IsArchived
FROM dbo.Characters FROM dbo.Characters
WHERE ProjectID = @ProjectID AND IsArchived = 0 WHERE ProjectID = @ProjectID AND IsArchived = 0
ORDER BY CharacterName; ORDER BY ISNULL(CharacterImportance, 0) DESC, CharacterName;
SELECT sc.SceneCharacterID, sc.SceneID, sc.CharacterID, c.ProjectID, c.CharacterName, c.ShortName, c.BirthDate, c.AgeAtSeriesStart, SELECT sc.SceneCharacterID, sc.SceneID, sc.CharacterID, c.ProjectID, c.CharacterName, c.ShortName, c.BirthDate, c.AgeAtSeriesStart,
sc.RoleInSceneTypeID, role.TypeName AS RoleInSceneTypeName, sc.PresenceTypeID, presence.TypeName AS PresenceTypeName, sc.RoleInSceneTypeID, role.TypeName AS RoleInSceneTypeName, sc.PresenceTypeID, presence.TypeName AS PresenceTypeName,

View File

@ -10,11 +10,11 @@ AS
BEGIN BEGIN
SET NOCOUNT ON; SET NOCOUNT ON;
SELECT TOP (12) CharacterID, ProjectID, CharacterName, ShortName, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID, SELECT CharacterID, ProjectID, CharacterName, ShortName, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID,
Height, EyeColour, DefaultDescription, CreatedDate, UpdatedDate, IsArchived Height, EyeColour, CharacterImportance, DefaultDescription, CreatedDate, UpdatedDate, IsArchived
FROM dbo.Characters FROM dbo.Characters
WHERE ProjectID = @ProjectID AND IsArchived = 0 WHERE ProjectID = @ProjectID AND IsArchived = 0
ORDER BY CharacterName; ORDER BY ISNULL(CharacterImportance, 0) DESC, CharacterName;
SELECT sc.SceneCharacterID, sc.SceneID, sc.CharacterID, c.ProjectID, c.CharacterName, c.ShortName, c.BirthDate, c.AgeAtSeriesStart, SELECT sc.SceneCharacterID, sc.SceneID, sc.CharacterID, c.ProjectID, c.CharacterName, c.ShortName, c.BirthDate, c.AgeAtSeriesStart,
sc.RoleInSceneTypeID, role.TypeName AS RoleInSceneTypeName, sc.PresenceTypeID, presence.TypeName AS PresenceTypeName, sc.RoleInSceneTypeID, role.TypeName AS RoleInSceneTypeName, sc.PresenceTypeID, presence.TypeName AS PresenceTypeName,

View File

@ -0,0 +1,117 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF COL_LENGTH(N'dbo.Characters', N'CharacterImportance') IS NULL
BEGIN
ALTER TABLE dbo.Characters ADD CharacterImportance int NULL;
END;
GO
UPDATE dbo.Characters
SET CharacterImportance =
CASE
WHEN DefaultDescription LIKE N'%Imported character classification: Major%' THEN 10
WHEN DefaultDescription LIKE N'%Imported character classification: Supporting%' THEN 6
WHEN DefaultDescription LIKE N'%Imported character classification: Minor%' THEN 2
ELSE CharacterImportance
END
WHERE CharacterImportance IS NULL
AND DefaultDescription LIKE N'%Imported character classification:%';
GO
CREATE OR ALTER PROCEDURE dbo.Character_ListByProject
@ProjectID int
AS
BEGIN
SET NOCOUNT ON;
SELECT CharacterID, ProjectID, CharacterName, ShortName, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID,
Height, EyeColour, CharacterImportance, DefaultDescription, CreatedDate, UpdatedDate, IsArchived
FROM dbo.Characters
WHERE ProjectID = @ProjectID AND IsArchived = 0
ORDER BY CharacterName;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Character_Get
@CharacterID int
AS
BEGIN
SET NOCOUNT ON;
SELECT CharacterID, ProjectID, CharacterName, ShortName, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID,
Height, EyeColour, CharacterImportance, DefaultDescription, CreatedDate, UpdatedDate, IsArchived
FROM dbo.Characters
WHERE CharacterID = @CharacterID AND IsArchived = 0;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Character_Save
@CharacterID int = NULL,
@ProjectID int,
@CharacterName nvarchar(200),
@ShortName nvarchar(100) = NULL,
@Sex nvarchar(50) = NULL,
@BirthDate date = NULL,
@AgeAtSeriesStart int = NULL,
@AgeReferenceSceneID int = NULL,
@Height nvarchar(50) = NULL,
@EyeColour nvarchar(50) = NULL,
@CharacterImportance int = NULL,
@DefaultDescription nvarchar(max) = NULL
AS
BEGIN
SET NOCOUNT ON;
IF @CharacterID IS NULL OR @CharacterID = 0
BEGIN
INSERT dbo.Characters (ProjectID, CharacterName, ShortName, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID, Height, EyeColour, CharacterImportance, DefaultDescription)
VALUES (@ProjectID, @CharacterName, @ShortName, @Sex, @BirthDate, @AgeAtSeriesStart, @AgeReferenceSceneID, @Height, @EyeColour, @CharacterImportance, @DefaultDescription);
SET @CharacterID = CAST(SCOPE_IDENTITY() AS int);
END
ELSE
BEGIN
UPDATE dbo.Characters
SET CharacterName = @CharacterName, ShortName = @ShortName, Sex = @Sex, BirthDate = @BirthDate,
AgeAtSeriesStart = @AgeAtSeriesStart, AgeReferenceSceneID = @AgeReferenceSceneID,
Height = @Height, EyeColour = @EyeColour, CharacterImportance = @CharacterImportance, DefaultDescription = @DefaultDescription,
UpdatedDate = SYSUTCDATETIME()
WHERE CharacterID = @CharacterID;
END
SELECT @CharacterID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterTimeline_GetByProject
@ProjectID int,
@BookID int = NULL
AS
BEGIN
SET NOCOUNT ON;
SELECT CharacterID, ProjectID, CharacterName, ShortName, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID,
Height, EyeColour, CharacterImportance, DefaultDescription, CreatedDate, UpdatedDate, IsArchived
FROM dbo.Characters
WHERE ProjectID = @ProjectID AND IsArchived = 0
ORDER BY ISNULL(CharacterImportance, 0) DESC, CharacterName;
SELECT sc.SceneCharacterID, sc.SceneID, sc.CharacterID, c.ProjectID, c.CharacterName, c.ShortName, c.BirthDate, c.AgeAtSeriesStart,
sc.RoleInSceneTypeID, role.TypeName AS RoleInSceneTypeName, sc.PresenceTypeID, presence.TypeName AS PresenceTypeName,
sc.LocationID, location.LocationName, location.LocationPath, sc.EntryLocationID, entry.LocationName AS EntryLocationName,
sc.ExitLocationID, exitLocation.LocationName AS ExitLocationName, sc.AppearanceNotes, sc.OutfitDescription,
sc.PhysicalCondition, sc.EmotionalState, sc.KnowledgeNotes, s.SceneNumber, s.SceneTitle, ch.ChapterNumber, b.BookTitle,
sc.CreatedDate, sc.UpdatedDate
FROM dbo.SceneCharacters sc
INNER JOIN dbo.Characters c ON c.CharacterID = sc.CharacterID
INNER JOIN dbo.Scenes s ON s.SceneID = sc.SceneID
INNER JOIN dbo.Chapters ch ON ch.ChapterID = s.ChapterID
INNER JOIN dbo.Books b ON b.BookID = ch.BookID
LEFT JOIN dbo.CharacterRoleInSceneTypes role ON role.CharacterRoleInSceneTypeID = sc.RoleInSceneTypeID
LEFT JOIN dbo.PresenceTypes presence ON presence.PresenceTypeID = sc.PresenceTypeID
LEFT JOIN dbo.LocationPaths location ON location.LocationID = sc.LocationID
LEFT JOIN dbo.LocationPaths entry ON entry.LocationID = sc.EntryLocationID
LEFT JOIN dbo.LocationPaths exitLocation ON exitLocation.LocationID = sc.ExitLocationID
WHERE c.ProjectID = @ProjectID AND c.IsArchived = 0 AND s.IsArchived = 0 AND ch.IsArchived = 0 AND b.IsArchived = 0
AND (@BookID IS NULL OR b.BookID = @BookID)
ORDER BY b.SortOrder, ch.SortOrder, s.SortOrder, c.CharacterName;
END;
GO

View File

@ -838,9 +838,20 @@ public sealed class CharacterEditViewModel
[Display(Name = "Eye colour")] [Display(Name = "Eye colour")]
public string? EyeColour { get; set; } public string? EyeColour { get; set; }
[Display(Name = "Story importance")]
public int? CharacterImportance { get; set; }
[Display(Name = "Default description")] [Display(Name = "Default description")]
public string? DefaultDescription { get; set; } public string? DefaultDescription { get; set; }
public IReadOnlyList<SelectListItem> ImportanceOptions { get; set; } =
[
new("Use timeline fallback", string.Empty),
new("Major", "10"),
new("Secondary / Supporting", "6"),
new("Minor", "2")
];
public Project? Project { get; set; } public Project? Project { get; set; }
} }

View File

@ -2,6 +2,13 @@
@{ @{
ViewData["Title"] = Model.Character.CharacterName; ViewData["Title"] = Model.Character.CharacterName;
var firstAppearance = Model.Appearances.FirstOrDefault(); var firstAppearance = Model.Appearances.FirstOrDefault();
var storyImportance = Model.Character.CharacterImportance switch
{
>= 8 => "Major",
>= 4 => "Secondary / Supporting",
>= 1 => "Minor",
_ => "Timeline fallback"
};
} }
<nav class="breadcrumb-trail" aria-label="Breadcrumb"> <nav class="breadcrumb-trail" aria-label="Breadcrumb">
@ -54,6 +61,7 @@
<p><strong>Age:</strong> @Model.DisplayAge</p> <p><strong>Age:</strong> @Model.DisplayAge</p>
<p><strong>Height:</strong> @Model.Character.Height</p> <p><strong>Height:</strong> @Model.Character.Height</p>
<p><strong>Eye colour:</strong> @Model.Character.EyeColour</p> <p><strong>Eye colour:</strong> @Model.Character.EyeColour</p>
<p><strong>Story importance:</strong> @storyImportance</p>
</article> </article>
<article class="plain-card"> <article class="plain-card">
<h2>Relationships</h2> <h2>Relationships</h2>

View File

@ -44,6 +44,11 @@
<label asp-for="EyeColour" class="form-label"></label> <label asp-for="EyeColour" class="form-label"></label>
<input asp-for="EyeColour" class="form-control" /> <input asp-for="EyeColour" class="form-control" />
</div> </div>
<div class="col-md-6">
<label asp-for="CharacterImportance" class="form-label"></label>
<select asp-for="CharacterImportance" asp-items="Model.ImportanceOptions" class="form-select"></select>
<div class="form-text">Controls timeline grouping for major, secondary/supporting, and minor characters.</div>
</div>
<div class="col-12"> <div class="col-12">
<label asp-for="DefaultDescription" class="form-label"></label> <label asp-for="DefaultDescription" class="form-label"></label>
<textarea asp-for="DefaultDescription" class="form-control" rows="5"></textarea> <textarea asp-for="DefaultDescription" class="form-control" rows="5"></textarea>

View File

@ -51,6 +51,61 @@
} }
int ChapterSceneColumns(ChapterTimelineViewModel chapter) => Math.Max(chapter.Scenes.Count, 1); int ChapterSceneColumns(ChapterTimelineViewModel chapter) => Math.Max(chapter.Scenes.Count, 1);
int BookSceneColumns(BookTimelineViewModel book) => Math.Max(book.Chapters.Any() ? book.Chapters.Sum(ChapterSceneColumns) : 1, 1); int BookSceneColumns(BookTimelineViewModel book) => Math.Max(book.Chapters.Any() ? book.Chapters.Sum(ChapterSceneColumns) : 1, 1);
int CharacterAppearanceCount(CharacterLaneViewModel lane) => lane.SceneSlots.Sum(slot => slot.Appearances.Count);
bool HasLeadSceneRole(CharacterLaneViewModel lane) => lane.SceneSlots
.SelectMany(slot => slot.Appearances)
.Any(appearance =>
string.Equals(appearance.RoleInSceneTypeName, "POV Character", StringComparison.OrdinalIgnoreCase) ||
string.Equals(appearance.RoleInSceneTypeName, "Main Participant", StringComparison.OrdinalIgnoreCase));
string CharacterGroupName(CharacterLaneViewModel lane)
{
var description = lane.Character.DefaultDescription ?? string.Empty;
// Timeline grouping prefers the editable CharacterImportance field; imported
// classification metadata remains a fallback for older imported projects.
if (lane.Character.CharacterImportance >= 8)
{
return "Major characters";
}
if (lane.Character.CharacterImportance >= 4)
{
return "Secondary / supporting characters";
}
if (lane.Character.CharacterImportance.HasValue)
{
return "Minor / mentioned characters";
}
if (description.Contains("Imported character classification: Major", StringComparison.OrdinalIgnoreCase))
{
return "Major characters";
}
if (description.Contains("Imported character classification: Supporting", StringComparison.OrdinalIgnoreCase))
{
return "Secondary / supporting characters";
}
if (description.Contains("Imported character classification: Minor", StringComparison.OrdinalIgnoreCase))
{
return "Minor / mentioned characters";
}
if (HasLeadSceneRole(lane) || CharacterAppearanceCount(lane) >= 6)
{
return "Major characters";
}
return CharacterAppearanceCount(lane) <= 1 ? "Minor / mentioned characters" : "Secondary / supporting characters";
}
int CharacterGroupOrder(string groupName) => groupName switch
{
"Major characters" => 0,
"Secondary / supporting characters" => 1,
_ => 2
};
} }
<section class="context-header"> <section class="context-header">
@ -106,7 +161,7 @@
</div> </div>
<section class="timeline-control-panel" data-timeline-controls> <section class="timeline-control-panel" data-timeline-controls>
<details class="timeline-filter-drawer" @(hasActiveFilter ? "open" : "")> <details class="timeline-filter-drawer">
<summary>Filters and focus</summary> <summary>Filters and focus</summary>
<form method="get" class="timeline-filter-grid"> <form method="get" class="timeline-filter-grid">
<input type="hidden" name="ProjectID" value="@Model.Project.ProjectID" /> <input type="hidden" name="ProjectID" value="@Model.Project.ProjectID" />
@ -390,7 +445,10 @@
@foreach (var chapter in book.Chapters) @foreach (var chapter in book.Chapters)
{ {
var chapterWarnings = chapter.Scenes.Sum(x => x.WarningCount); var chapterWarnings = chapter.Scenes.Sum(x => x.WarningCount);
var isActiveChapter = chapter.Scenes.Any(scene => scene.SceneID == Model.SelectedSceneID);
<button type="button" <button type="button"
class="@(isActiveChapter ? "is-active" : "")"
data-active-chapter="@(isActiveChapter ? "true" : "false")"
data-jump-scene="@chapter.Scenes.FirstOrDefault()?.SceneID" data-jump-scene="@chapter.Scenes.FirstOrDefault()?.SceneID"
style="--chapter-scenes:@Math.Max(chapter.Scenes.Count, 1); --chapter-warnings:@Math.Min(chapterWarnings, 8)" style="--chapter-scenes:@Math.Max(chapter.Scenes.Count, 1); --chapter-warnings:@Math.Min(chapterWarnings, 8)"
title="Chapter @chapter.Chapter.ChapterNumber: @chapter.Chapter.ChapterTitle (@chapter.Scenes.Count scenes, @chapterWarnings warnings)"> title="Chapter @chapter.Chapter.ChapterNumber: @chapter.Chapter.ChapterTitle (@chapter.Scenes.Count scenes, @chapterWarnings warnings)">
@ -784,14 +842,19 @@ else
<div class="character-lane-board" data-timeline-section="characters" style="--scene-count:@timelineColumns.Count"> <div class="character-lane-board" data-timeline-section="characters" style="--scene-count:@timelineColumns.Count">
<div class="lane-board-heading character-lane-board-heading"> <div class="lane-board-heading character-lane-board-heading">
<button type="button" data-collapse-toggle="characters">Character appearances</button> <button type="button" data-collapse-toggle="characters">Character appearances</button>
<small>First twelve project characters</small> <small>All project characters, grouped by role and activity</small>
</div> </div>
<div data-collapse-body="characters"> <div data-collapse-body="characters">
@foreach (var group in Model.CharacterLanes.Select((lane, index) => new { lane, group = index < 6 ? "Major characters" : "Secondary characters" }).GroupBy(x => x.group)) @foreach (var group in Model.CharacterLanes
.Select((lane, index) => new { lane, index, group = CharacterGroupName(lane) })
.GroupBy(x => x.group)
.OrderBy(group => CharacterGroupOrder(group.Key)))
{ {
var groupId = $"character-group-{CharacterGroupOrder(group.Key)}";
var defaultCollapsed = CharacterGroupOrder(group.Key) > 0 ? "collapsed" : null;
<div class="lane-group"> <div class="lane-group">
<button type="button" class="lane-group-heading" data-collapse-toggle="character-group-@group.Key.GetHashCode()">@group.Key</button> <button type="button" class="lane-group-heading" data-collapse-toggle="@groupId" data-collapse-default="@defaultCollapsed">@group.Key <span>@group.Count()</span></button>
<div data-collapse-body="character-group-@group.Key.GetHashCode()"> <div class="character-lane-group-body" data-collapse-body="@groupId">
@foreach (var laneItem in group) @foreach (var laneItem in group)
{ {
var lane = laneItem.lane; var lane = laneItem.lane;
@ -874,7 +937,7 @@ else
<div class="metric-graph-stack mt-2"> <div class="metric-graph-stack mt-2">
@foreach (var graph in Model.MetricGraphs) @foreach (var graph in Model.MetricGraphs)
{ {
<div class="metric-graph-row"> <div class="metric-graph-row" style="--scene-count:@Math.Max(timelineColumns.Count, 1)">
<div class="metric-graph-label">@graph.MetricName</div> <div class="metric-graph-label">@graph.MetricName</div>
<div class="metric-graph-points" style="--scene-count:@Math.Max(timelineColumns.Count, 1)"> <div class="metric-graph-points" style="--scene-count:@Math.Max(timelineColumns.Count, 1)">
@foreach (var column in timelineColumns) @foreach (var column in timelineColumns)

View File

@ -393,6 +393,7 @@ td.text-end {
.timeline-workspace { .timeline-workspace {
min-width: 0; min-width: 0;
max-width: 100%;
} }
.pacing-panel { .pacing-panel {
@ -421,6 +422,11 @@ td.text-end {
.metric-graph-stack { .metric-graph-stack {
display: grid; display: grid;
gap: 10px; gap: 10px;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
padding-bottom: 4px;
} }
.metric-graph-row { .metric-graph-row {
@ -428,6 +434,7 @@ td.text-end {
grid-template-columns: 150px minmax(260px, 1fr); grid-template-columns: 150px minmax(260px, 1fr);
gap: 10px; gap: 10px;
align-items: center; align-items: center;
min-width: max(100%, calc(150px + (var(--scene-count, 1) * 34px)));
} }
.metric-graph-label { .metric-graph-label {
@ -445,6 +452,7 @@ td.text-end {
border-radius: 8px; border-radius: 8px;
padding: 6px; padding: 6px;
background: linear-gradient(180deg, #f6faf8 0%, #edf4f1 100%); background: linear-gradient(180deg, #f6faf8 0%, #edf4f1 100%);
min-width: calc(var(--scene-count) * 34px);
} }
.metric-point { .metric-point {
@ -1953,6 +1961,12 @@ td.text-end {
gap: 4px; gap: 4px;
margin: 0 0 12px; margin: 0 0 12px;
padding: 8px; padding: 8px;
max-width: 100%;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
scrollbar-gutter: stable;
border: 1px solid rgba(47, 111, 99, 0.18); border: 1px solid rgba(47, 111, 99, 0.18);
border-radius: 8px; border-radius: 8px;
background: rgba(255, 255, 255, 0.72); background: rgba(255, 255, 255, 0.72);
@ -1960,7 +1974,7 @@ td.text-end {
.timeline-minimap button { .timeline-minimap button {
position: relative; position: relative;
flex: var(--chapter-scenes) 1 42px; flex: 0 0 max(42px, calc(var(--chapter-scenes) * 18px));
min-width: 42px; min-width: 42px;
min-height: 34px; min-height: 34px;
border: 1px solid rgba(47, 111, 99, 0.18); border: 1px solid rgba(47, 111, 99, 0.18);
@ -1971,6 +1985,13 @@ td.text-end {
font-weight: 800; font-weight: 800;
} }
.timeline-minimap button.is-active {
border-color: var(--plotline-accent);
color: #fff;
background: linear-gradient(180deg, var(--plotline-accent) 0%, var(--plotline-accent-dark) 100%);
box-shadow: 0 0 0 2px rgba(47, 111, 99, 0.14);
}
.timeline-minimap button::after { .timeline-minimap button::after {
content: ""; content: "";
position: absolute; position: absolute;
@ -2157,6 +2178,11 @@ td.text-end {
gap: 7px; gap: 7px;
} }
.character-lane-group-body {
display: grid;
gap: 7px;
}
.lane-group-heading { .lane-group-heading {
position: sticky; position: sticky;
left: 0; left: 0;

View File

@ -255,19 +255,30 @@
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.font = "700 10px system-ui, -apple-system, Segoe UI, sans-serif"; ctx.font = "700 10px system-ui, -apple-system, Segoe UI, sans-serif";
let previousChapter = null; const chapterStarts = [];
scenes.forEach((scene, index) => { scenes.forEach((scene, index) => {
if (scene.chapterLabel === previousChapter) { if (!index || scene.chapterLabel !== scenes[index - 1].chapterLabel) {
return; chapterStarts.push({ scene, index });
} }
});
const labelInterval = chapterStarts.length > 40 ? 5 : chapterStarts.length > 24 ? 4 : chapterStarts.length > 14 ? 2 : 1;
chapterStarts.forEach((entry, chapterIndex) => {
const { scene, index } = entry;
const x = scales.x.getPixelForValue(index); const x = scales.x.getPixelForValue(index);
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x, chartArea.top); ctx.moveTo(x, chartArea.top);
ctx.lineTo(x, chartArea.bottom); ctx.lineTo(x, chartArea.bottom);
ctx.stroke(); ctx.stroke();
const chapterNumber = Number.parseInt(String(scene.chapterLabel).replace(/[^0-9]/g, ""), 10);
const shouldLabel = chapterIndex === 0 ||
chapterIndex === chapterStarts.length - 1 ||
labelInterval === 1 ||
(Number.isFinite(chapterNumber) && chapterNumber % labelInterval === 0);
if (shouldLabel) {
ctx.fillText(scene.chapterLabel, Math.min(x + 4, chartArea.right - 70), chartArea.bottom + 22); ctx.fillText(scene.chapterLabel, Math.min(x + 4, chartArea.right - 70), chartArea.bottom + 22);
previousChapter = scene.chapterLabel; }
}); });
ctx.restore(); ctx.restore();
@ -478,6 +489,7 @@
const densitySelect = document.querySelector("[data-timeline-density]"); const densitySelect = document.querySelector("[data-timeline-density]");
const zoomSelect = document.querySelector("[data-timeline-zoom]"); const zoomSelect = document.querySelector("[data-timeline-zoom]");
const searchInput = document.querySelector("[data-timeline-search]"); const searchInput = document.querySelector("[data-timeline-search]");
const minimap = root.querySelector("[data-timeline-minimap]");
const jumpSelects = document.querySelectorAll("[data-timeline-jump], [data-timeline-warning-jump], [data-timeline-entity-jump]"); const jumpSelects = document.querySelectorAll("[data-timeline-jump], [data-timeline-warning-jump], [data-timeline-entity-jump]");
const sceneCards = [...root.querySelectorAll(".timeline-scene-card[data-scene-id]")]; const sceneCards = [...root.querySelectorAll(".timeline-scene-card[data-scene-id]")];
const collapsedKey = "plotline.timeline.collapsed"; const collapsedKey = "plotline.timeline.collapsed";
@ -494,6 +506,7 @@
} }
}; };
const hasCollapsedPreference = localStorage.getItem(collapsedKey) !== null;
const collapsed = readSet(collapsedKey); const collapsed = readSet(collapsedKey);
const applyMode = (kind, value, values) => { const applyMode = (kind, value, values) => {
@ -535,6 +548,21 @@
window.setTimeout(() => target.classList.remove("keyboard-focus"), 1800); window.setTimeout(() => target.classList.remove("keyboard-focus"), 1800);
}; };
const scrollActiveChapterIntoView = (behavior = "auto") => {
const activeChapter = minimap?.querySelector("[data-active-chapter='true']");
if (!minimap || !activeChapter) {
return;
}
const hostRect = minimap.getBoundingClientRect();
const activeRect = activeChapter.getBoundingClientRect();
const activeCenter = activeRect.left + activeRect.width / 2;
const hostCenter = hostRect.left + hostRect.width / 2;
if (activeRect.left < hostRect.left || activeRect.right > hostRect.right) {
minimap.scrollBy({ left: activeCenter - hostCenter, behavior });
}
};
const saveCollapsed = () => localStorage.setItem(collapsedKey, JSON.stringify([...collapsed])); const saveCollapsed = () => localStorage.setItem(collapsedKey, JSON.stringify([...collapsed]));
const setCollapsed = (id, shouldCollapse) => { const setCollapsed = (id, shouldCollapse) => {
@ -549,6 +577,10 @@
document.querySelectorAll("[data-collapse-toggle]").forEach((button) => { document.querySelectorAll("[data-collapse-toggle]").forEach((button) => {
const id = button.dataset.collapseToggle; const id = button.dataset.collapseToggle;
if (!hasCollapsedPreference && button.dataset.collapseDefault === "collapsed") {
collapsed.add(id);
}
const isCollapsed = collapsed.has(id); const isCollapsed = collapsed.has(id);
setCollapsed(id, isCollapsed); setCollapsed(id, isCollapsed);
button.addEventListener("click", () => { button.addEventListener("click", () => {
@ -567,6 +599,7 @@
zoomSelect?.addEventListener("change", () => applyZoom(zoomSelect.value)); zoomSelect?.addEventListener("change", () => applyZoom(zoomSelect.value));
applyDensity(localStorage.getItem(densityKey) || "comfortable"); applyDensity(localStorage.getItem(densityKey) || "comfortable");
applyZoom(localStorage.getItem(zoomKey) || "chapter"); applyZoom(localStorage.getItem(zoomKey) || "chapter");
window.requestAnimationFrame(() => scrollActiveChapterIntoView("auto"));
const runSearch = () => { const runSearch = () => {
const query = searchInput?.value.trim().toLowerCase() || ""; const query = searchInput?.value.trim().toLowerCase() || "";