diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..af8ec52c7f --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Optional values for development through the legacy root docker-compose.yml. +# Copy this file to .env. Database authentication remains the safe default. +# Never commit an institution credential or a populated .env file. +DF_AUTH_METHOD=database + +# To exercise AAF locally, obtain a dedicated non-production registration from +# the identity owner, change DF_AUTH_METHOD to aaf, and fill every field below. +DF_AAF_ISSUER_URL= +DF_AAF_AUDIENCE_URL=http://localhost:4200 +DF_AAF_CALLBACK_URL=http://localhost:4200/api/auth/jwt +DF_AAF_IDENTITY_PROVIDER_URL= +DF_AAF_UNIQUE_URL= +DF_AAF_AUTH_SIGNOUT_URL= +DF_SECRET_KEY_AAF= diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 06b5afd845..0000000000 --- a/.eslintignore +++ /dev/null @@ -1,4 +0,0 @@ -build/ -coverage/ -dist/ -docs/ diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 9bf5e49a86..0000000000 --- a/.eslintrc.json +++ /dev/null @@ -1,91 +0,0 @@ -{ - "root": true, - "overrides": [ - { - "files": ["*.ts"], - "parserOptions": { - "project": ["tsconfig.*?.json"], - "createDefaultProgram": true - }, - "plugins": ["@typescript-eslint"], - "extends": [ - "plugin:@angular-eslint/recommended", - "plugin:@typescript-eslint/recommended", - "plugin:prettier/recommended" - ], - "env": { - "browser": true, - "jasmine": true - }, - "rules": { - "@typescript-eslint/no-unused-vars": [ - "warn", - { - "args": "all", - "argsIgnorePattern": "^_", - "caughtErrors": "all", - "caughtErrorsIgnorePattern": "^_", - "destructuredArrayIgnorePattern": "^_", - "varsIgnorePattern": "^_", - "ignoreRestSiblings": true - } - ], - "@angular-eslint/no-empty-lifecycle-method": "warn", - "@angular-eslint/component-class-suffix": "warn", - "@angular-eslint/no-output-on-prefix": "warn", - "@typescript-eslint/no-inferrable-types": "off", - "@angular-eslint/directive-selector": [ - "warn", - { - "type": "attribute", - "prefix": "f", - "style": "camelCase" - } - ], - "@angular-eslint/component-selector": [ - "warn", - { - "type": "element", - "prefix": "f", - "style": "kebab-case" - } - ], - "@typescript-eslint/ban-types": "warn", - "@typescript-eslint/no-empty-function": "warn", - "@typescript-eslint/no-inferrable-types": "off", - "@typescript-eslint/no-this-alias": "warn", - "no-dupe-class-members": "warn", - "no-prototype-builtins": "warn", - "no-unused-vars": "off", - "no-useless-escape": "warn", - "no-var": "warn", - "quotes": [ - "warn", - "single", - { - "allowTemplateLiterals": true - } - ], - "prefer-const": "warn", - "prettier/prettier": "warn" - } - }, - { - "files": ["*.component.html"], - "extends": ["plugin:@angular-eslint/template/recommended", "plugin:prettier/recommended"], - "rules": { - "max-len": [ - "warn", - { - "code": 140 - } - ], - "prettier/prettier": "warn" - } - }, - { - "files": ["*.component.ts"], - "extends": ["plugin:@angular-eslint/template/process-inline-templates"] - } - ] -} diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000000..260dc5e2fd --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,5 @@ +# 8 June 2026: Repository-wide formatting and lint configuration +26b4962794d16e90587fb179aab966b39459050c + +# 17 June 2026: Repository-wide sorting of HTML attributes +1ffc7ad7b9dc18f22be32bf6313bfde97957dd2f diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..d56abbf304 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Set the default behavior, in case people don't have core.autocrlf set. +* text=auto eol=lf diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..1cc4d7d71b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,5 @@ +# The App key is available only to the default-branch policy job. Keep every +# workflow and the evaluator itself under lead review. +/.github/workflows/ @ontrack-features-t2-2026/ontrack-leads +/.github/review-policy/ @ontrack-features-t2-2026/ontrack-leads +/.github/CODEOWNERS @ontrack-features-t2-2026/ontrack-leads diff --git a/.github/review-policy/README.md b/.github/review-policy/README.md new file mode 100644 index 0000000000..bcf9e76f76 --- /dev/null +++ b/.github/review-policy/README.md @@ -0,0 +1,51 @@ +# OnTrack pull-request review policy + +The required status context `ontrack/review-policy` passes when the current pull +request head has either: + +- one approval from a current `ontrack-leads` member; or +- two approvals from distinct current `ontrack-contributors` members. + +Approvals from the pull-request author, bots, stale commits, dismissed reviews, +or reviewers whose latest actionable review requests changes do not count. + +## Security model + +`ontrack-review-policy-signal.yml` is unprivileged and never checks out pull-request +code. A completed signal wakes `ontrack-review-policy.yml` through `workflow_run`. +The evaluator workflow checks out only this directory from the protected default +branch, then mints a short-lived token for the organization-owned GitHub App. + +The App is installed only on the three OnTrack Doubtfire repositories and has: + +- organization Members: read; +- repository Metadata: read (mandatory); +- repository Pull requests: read; and +- repository Commit statuses: write. + +The App has no contents, workflow, administration, merge, or webhook permission. +Its private key is held in `ONTRACK_REVIEW_APP_PRIVATE_KEY` in the +`ontrack-review-policy` environment, which only permits the protected `11.0.x` +branch. Its numeric App ID is held in `ONTRACK_REVIEW_APP_ID`. + +The evaluator reports on the pull-request head commit. GitHub gates on the test merge +commit whenever that commit carries a status and only falls back to the head when it +carries none, so reporting on the test merge commit would move the merge gate onto a +commit that carries none of this repository's other checks. The head is also stable, +where the test merge commit is recomputed every time the base branch moves. +A five-minute reconciliation covers team membership and base-branch changes that +do not emit a pull-request review event. Unchanged results are not republished, +which avoids GitHub's per-commit status limit. + +## Ruleset integration + +Keep the native one-overall-approval rule, stale-review dismissal, and conversation +resolution. Require `ontrack/review-policy` with the OnTrack Review Policy App as +its expected source. Remove the native `ontrack-leads` required-reviewer entry only +after the App status has been observed and made required; otherwise GitHub combines +the native team rules with AND semantics. + +Changes to any workflow, the evaluator, or CODEOWNERS should continue to require +one `ontrack-leads` approval through a path-specific native reviewer rule. This is +necessary because any default-branch workflow could otherwise reference the App's +environment secret. diff --git a/.github/review-policy/evaluate.mjs b/.github/review-policy/evaluate.mjs new file mode 100644 index 0000000000..f9dfa6fc45 --- /dev/null +++ b/.github/review-policy/evaluate.mjs @@ -0,0 +1,529 @@ +import { createSign } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; + +const API_VERSION = '2022-11-28'; +const POLICY_CONTEXT = 'ontrack/review-policy'; +const APP_BOT_LOGIN = 'ontrack-review-policy-t2-2026[bot]'; +const PAGE_SIZE = 100; +const MAX_PAGES = 50; +const ALLOWED_REPOSITORIES = new Set([ + 'doubtfire-deploy', + 'doubtfire-api', + 'doubtfire-web', +]); + +function normalizeLogin(login) { + return String(login || '').toLowerCase(); +} + +function encodeJson(value) { + return Buffer.from(JSON.stringify(value)).toString('base64url'); +} + +export function createAppJwt(appId, privateKey, nowSeconds = Math.floor(Date.now() / 1000)) { + if (!/^\d+$/.test(String(appId))) { + throw new Error('ONTRACK_REVIEW_APP_ID must be a numeric GitHub App ID.'); + } + if (!String(privateKey).includes('PRIVATE KEY')) { + throw new Error('ONTRACK_REVIEW_APP_PRIVATE_KEY is missing or invalid.'); + } + + const header = encodeJson({ alg: 'RS256', typ: 'JWT' }); + const payload = encodeJson({ + iat: nowSeconds - 60, + exp: nowSeconds + 540, + iss: String(appId), + }); + const unsigned = `${header}.${payload}`; + const signer = createSign('RSA-SHA256'); + signer.update(unsigned); + signer.end(); + const signature = signer.sign(privateKey).toString('base64url'); + return `${unsigned}.${signature}`; +} + +export function approvedReviewers(reviews, headSha, authorLogin) { + const author = normalizeLogin(authorLogin); + const latestActionableReview = new Map(); + const actionableStates = new Set(['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED']); + + const ordered = [...reviews].sort((left, right) => { + const leftTime = Date.parse(left.submitted_at || 0) || 0; + const rightTime = Date.parse(right.submitted_at || 0) || 0; + return leftTime - rightTime || Number(left.id || 0) - Number(right.id || 0); + }); + + for (const review of ordered) { + const login = normalizeLogin(review.user?.login); + const state = String(review.state || '').toUpperCase(); + if (!login || login === author || review.user?.type === 'Bot') { + continue; + } + // A comment after an approval does not revoke the approval. + if (!actionableStates.has(state)) { + continue; + } + latestActionableReview.set(login, review); + } + + return new Set( + [...latestActionableReview.entries()] + .filter(([, review]) => ( + String(review.state || '').toUpperCase() === 'APPROVED' + && review.commit_id === headSha + )) + .map(([login]) => login), + ); +} + +export function evaluatePolicy(approved, leadMembers, contributorMembers) { + const leads = new Set([...leadMembers].map(normalizeLogin)); + const contributors = new Set([...contributorMembers].map(normalizeLogin)); + let leadApprovals = 0; + let contributorApprovals = 0; + + for (const login of approved) { + const normalized = normalizeLogin(login); + if (leads.has(normalized)) { + leadApprovals += 1; + } + if (contributors.has(normalized)) { + contributorApprovals += 1; + } + } + + return { + leadApprovals, + contributorApprovals, + passes: leadApprovals >= 1 || contributorApprovals >= 2, + }; +} + +export function pullRequestNumbersFromWorkflowRun(workflowRun) { + const numbers = new Set(); + for (const pullRequest of workflowRun?.pull_requests || []) { + const number = Number(pullRequest?.number); + if (Number.isSafeInteger(number) && number > 0) { + numbers.add(number); + } + } + + const title = String(workflowRun?.display_title || ''); + const titleMatch = title.match(/\bPR #([1-9]\d{0,9})\b/); + if (titleMatch) { + const number = Number(titleMatch[1]); + if (Number.isSafeInteger(number)) { + numbers.add(number); + } + } + return [...numbers]; +} + +function safeError(error) { + return String(error?.message || error || 'Unknown error') + .replace(/gh[opsu]_[A-Za-z0-9_]+/g, '[redacted token]') + .replace( + /-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/g, + '[redacted private key]', + ) + .slice(0, 500); +} + +function repositoryParts(repository) { + const match = String(repository || '').match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/); + if (!match) { + throw new Error('GITHUB_REPOSITORY is invalid.'); + } + return { owner: match[1], repo: match[2] }; +} + +class GitHubApi { + constructor(apiUrl, token) { + this.apiUrl = String(apiUrl || 'https://api.github.com').replace(/\/$/, ''); + this.token = token; + } + + async request(path, { method = 'GET', body, expected = [200] } = {}) { + const response = await fetch(`${this.apiUrl}${path}`, { + method, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${this.token}`, + 'User-Agent': 'ontrack-review-policy', + 'X-GitHub-Api-Version': API_VERSION, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + if (!expected.includes(response.status)) { + const requestId = response.headers.get('x-github-request-id'); + throw new Error( + `GitHub API ${method} ${path} returned ${response.status}` + + (requestId ? ` (request ${requestId})` : ''), + ); + } + + if (response.status === 204) { + return null; + } + const text = await response.text(); + return text ? JSON.parse(text) : null; + } + + async paginate(path) { + const items = []; + const separator = path.includes('?') ? '&' : '?'; + for (let page = 1; page <= MAX_PAGES; page += 1) { + const batch = await this.request( + `${path}${separator}per_page=${PAGE_SIZE}&page=${page}`, + ); + if (!Array.isArray(batch)) { + throw new Error(`Expected a list from GitHub API path ${path}.`); + } + items.push(...batch); + if (batch.length < PAGE_SIZE) { + return items; + } + } + throw new Error(`GitHub API pagination exceeded ${MAX_PAGES} pages for ${path}.`); + } +} + +async function mintInstallationToken({ apiUrl, owner, repo, appId, privateKey }) { + const appJwt = createAppJwt(appId, privateKey); + const appApi = new GitHubApi(apiUrl, appJwt); + const installation = await appApi.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/installation`, + ); + const tokenResponse = await appApi.request( + `/app/installations/${installation.id}/access_tokens`, + { + method: 'POST', + expected: [201], + body: { + repositories: [repo], + permissions: { + members: 'read', + pull_requests: 'read', + statuses: 'write', + }, + }, + }, + ); + + if (!tokenResponse?.token) { + throw new Error('GitHub did not return an installation access token.'); + } + // Generated tokens are not repository secrets, so mask them explicitly. + console.log(`::add-mask::${tokenResponse.token}`); + return tokenResponse.token; +} + +async function teamMembers(api, owner, teamSlug) { + const members = await api.paginate( + `/orgs/${encodeURIComponent(owner)}/teams/${encodeURIComponent(teamSlug)}/members`, + ); + return new Set(members.map((member) => normalizeLogin(member.login))); +} + +async function openPullRequests(api, owner, repo) { + return api.paginate( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?state=open`, + ); +} + +async function pullRequest(api, owner, repo, number) { + return api.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}`, + ); +} + +async function reviewsForPullRequest(api, owner, repo, number) { + return api.paginate( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}/reviews`, + ); +} + +function runUrl(repository, runId) { + return `https://github.com/${repository}/actions/runs/${runId}`; +} + +async function setPolicyStatus(api, owner, repo, sha, state, description, targetUrl) { + if (!/^[0-9a-f]{40}$/i.test(String(sha || ''))) { + throw new Error('Cannot publish the review policy without a valid commit SHA.'); + } + const clippedDescription = description.slice(0, 140); + try { + const statuses = await api.paginate( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}` + + `/commits/${encodeURIComponent(sha)}/statuses`, + ); + const latest = statuses.find((status) => ( + status.context === POLICY_CONTEXT + && normalizeLogin(status.creator?.login) === APP_BOT_LOGIN + )); + if (latest?.state === state && latest?.description === clippedDescription) { + return false; + } + } catch (error) { + // Deduplication is only an optimization. Always attempt the fail-closed write + // when status history cannot be read but the status endpoint may still work. + console.warn(`::warning::Status deduplication failed: ${safeError(error)}`); + } + + await api.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/statuses/${sha}`, + { + method: 'POST', + expected: [201], + body: { + state, + context: POLICY_CONTEXT, + description: clippedDescription, + target_url: targetUrl, + }, + }, + ); + return true; +} + +export { setPolicyStatus }; + +// Report on the pull-request head. GitHub gates on the test merge commit whenever that +// commit carries a status and only falls back to the head when it carries none, and the +// test merge commit carries none of this repository's checks. Reporting there would move +// the whole merge gate onto a commit that CI never sees. The head is also stable while +// the test merge commit is recomputed every time the base branch moves. +export function statusShaForPullRequest(pullRequestToReport) { + return pullRequestToReport.head?.sha; +} + +function reviewDigest(reviews) { + return reviews + .map((review) => ( + `${review.id}:${review.state}:${review.commit_id}:` + + `${review.submitted_at}:${review.user?.id}:${review.user?.login}` + )) + .sort() + .join('|'); +} + +function samePullRequestVersion(left, right) { + return ( + left.state === right.state + && left.draft === right.draft + && left.head?.sha === right.head?.sha + && left.base?.ref === right.base?.ref + && left.base?.sha === right.base?.sha + && left.mergeable === right.mergeable + ); +} + +async function evaluatePullRequest({ + api, + owner, + repo, + pullRequest: current, + leads, + contributors, + targetUrl, + attempt = 0, +}) { + // Re-fetch before evaluating so a delayed workflow_run never trusts its event's + // old head or merge SHA. + current = await pullRequest(api, owner, repo, current.number); + if (current.state !== 'open') { + return; + } + + const statusSha = statusShaForPullRequest(current); + if (current.draft) { + await setPolicyStatus( + api, + owner, + repo, + statusSha, + 'pending', + 'Waiting for the pull request to be marked ready for review', + targetUrl, + ); + console.log(`PR #${current.number}: draft`); + return; + } + + const firstReviews = await reviewsForPullRequest(api, owner, repo, current.number); + const checked = await pullRequest(api, owner, repo, current.number); + const secondReviews = await reviewsForPullRequest(api, owner, repo, current.number); + const live = await pullRequest(api, owner, repo, current.number); + if (checked.state !== 'open' || live.state !== 'open') { + return; + } + const liveStatusSha = statusShaForPullRequest(live); + if ( + !samePullRequestVersion(current, checked) + || !samePullRequestVersion(checked, live) + || reviewDigest(firstReviews) !== reviewDigest(secondReviews) + || liveStatusSha !== statusSha + ) { + if (attempt >= 1) { + throw new Error('Pull request changed repeatedly during evaluation.'); + } + console.log(`PR #${current.number}: changed during evaluation; retrying once`); + return evaluatePullRequest({ + api, + owner, + repo, + pullRequest: live, + leads, + contributors, + targetUrl, + attempt: attempt + 1, + }); + } + + const approved = approvedReviewers( + secondReviews, + live.head.sha, + live.user?.login, + ); + const result = evaluatePolicy(approved, leads, contributors); + const state = result.passes ? 'success' : 'pending'; + const description = result.passes + ? `Passed: ${result.leadApprovals}/1 lead or ${result.contributorApprovals}/2 contributors` + : `Waiting: ${result.leadApprovals}/1 lead or ${result.contributorApprovals}/2 contributors`; + + await setPolicyStatus(api, owner, repo, liveStatusSha, state, description, targetUrl); + console.log( + `PR #${current.number}: lead=${result.leadApprovals}, ` + + `contributors=${result.contributorApprovals}, status=${state}`, + ); +} + +async function eventPayload() { + const payloadPath = process.env.GITHUB_EVENT_PATH; + if (!payloadPath) { + return {}; + } + return JSON.parse(await readFile(payloadPath, 'utf8')); +} + +async function pullRequestsToEvaluate(api, owner, repo, eventName, payload) { + const open = await openPullRequests(api, owner, repo); + if (eventName === 'workflow_run') { + // Treat workflow_run fields only as untrusted locators. Match them against + // live open PRs fetched with the App token before evaluating anything. + const numbers = new Set(pullRequestNumbersFromWorkflowRun(payload.workflow_run)); + const headSha = payload.workflow_run?.head_sha; + const linked = open.filter((candidate) => ( + numbers.has(candidate.number) + || candidate.head?.sha === headSha + || candidate.merge_commit_sha === headSha + )); + return linked.length > 0 ? linked : open; + } + return open; +} + +export async function main() { + const repository = process.env.GITHUB_REPOSITORY; + const { owner, repo } = repositoryParts(repository); + if (owner !== 'ontrack-features-t2-2026' || !ALLOWED_REPOSITORIES.has(repo)) { + throw new Error('This evaluator only runs for the three approved OnTrack repositories.'); + } + + const appId = process.env.ONTRACK_REVIEW_APP_ID; + const privateKey = process.env.ONTRACK_REVIEW_APP_PRIVATE_KEY; + const apiUrl = process.env.GITHUB_API_URL || 'https://api.github.com'; + const token = await mintInstallationToken({ + apiUrl, + owner, + repo, + appId, + privateKey, + }); + const api = new GitHubApi(apiUrl, token); + const payload = await eventPayload(); + const eventName = process.env.GITHUB_EVENT_NAME || ''; + const pullRequests = await pullRequestsToEvaluate(api, owner, repo, eventName, payload); + + if (pullRequests.length === 0) { + console.log('No open pull requests require review-policy evaluation.'); + return; + } + + const leadTeam = process.env.ONTRACK_LEAD_TEAM || 'ontrack-leads'; + const contributorTeam = process.env.ONTRACK_CONTRIBUTOR_TEAM || 'ontrack-contributors'; + let leads; + let contributors; + try { + [leads, contributors] = await Promise.all([ + teamMembers(api, owner, leadTeam), + teamMembers(api, owner, contributorTeam), + ]); + } catch (error) { + const targetUrl = runUrl(repository, process.env.GITHUB_RUN_ID); + for (const current of pullRequests) { + try { + const sha = statusShaForPullRequest(current); + await setPolicyStatus( + api, + owner, + repo, + sha, + 'error', + 'OnTrack team membership could not be verified', + targetUrl, + ); + } catch (statusError) { + console.error(`::error::${safeError(statusError)}`); + } + } + throw error; + } + + const targetUrl = runUrl(repository, process.env.GITHUB_RUN_ID); + const failures = []; + for (const current of pullRequests) { + try { + await evaluatePullRequest({ + api, + owner, + repo, + pullRequest: current, + leads, + contributors, + targetUrl, + }); + } catch (error) { + failures.push(error); + try { + const sha = statusShaForPullRequest(current); + await setPolicyStatus( + api, + owner, + repo, + sha, + 'error', + 'OnTrack review policy evaluation failed', + targetUrl, + ); + } catch (statusError) { + console.error(`::error::${safeError(statusError)}`); + } + console.error(`::error::PR #${current.number}: ${safeError(error)}`); + } + } + + if (failures.length > 0) { + throw new Error(`${failures.length} pull-request evaluation(s) failed.`); + } +} + +const invokedPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : ''; +if (import.meta.url === invokedPath) { + main().catch((error) => { + console.error(`::error::${safeError(error)}`); + process.exitCode = 1; + }); +} diff --git a/.github/review-policy/evaluate.test.mjs b/.github/review-policy/evaluate.test.mjs new file mode 100644 index 0000000000..0c093524a5 --- /dev/null +++ b/.github/review-policy/evaluate.test.mjs @@ -0,0 +1,236 @@ +import assert from 'node:assert/strict'; +import { generateKeyPairSync, verify } from 'node:crypto'; +import { test } from 'node:test'; + +import { + approvedReviewers, + createAppJwt, + evaluatePolicy, + pullRequestNumbersFromWorkflowRun, + setPolicyStatus, + statusShaForPullRequest, +} from './evaluate.mjs'; + +function review({ + id, + login, + state = 'APPROVED', + commit = 'head', + submitted = `2026-08-24T00:00:${String(id).padStart(2, '0')}Z`, + type = 'User', +}) { + return { + id, + state, + commit_id: commit, + submitted_at: submitted, + user: { login, type }, + }; +} + +test('one lead approval passes', () => { + const result = evaluatePolicy( + new Set(['lead']), + new Set(['lead']), + new Set(['lead', 'contributor']), + ); + assert.equal(result.passes, true); + assert.equal(result.leadApprovals, 1); +}); + +test('two distinct contributor approvals pass', () => { + const result = evaluatePolicy( + new Set(['contributor-a', 'contributor-b']), + new Set(['lead']), + new Set(['lead', 'contributor-a', 'contributor-b']), + ); + assert.equal(result.passes, true); + assert.equal(result.contributorApprovals, 2); +}); + +test('one contributor approval does not pass', () => { + const result = evaluatePolicy( + new Set(['contributor-a']), + new Set(['lead']), + new Set(['lead', 'contributor-a']), + ); + assert.equal(result.passes, false); +}); + +test('duplicate approvals from one reviewer count once', () => { + const approved = approvedReviewers([ + review({ id: 1, login: 'Contributor-A' }), + review({ id: 2, login: 'contributor-a' }), + ], 'head', 'author'); + assert.deepEqual([...approved], ['contributor-a']); +}); + +test('a later comment does not revoke an approval', () => { + const approved = approvedReviewers([ + review({ id: 1, login: 'contributor-a' }), + review({ id: 2, login: 'contributor-a', state: 'COMMENTED' }), + ], 'head', 'author'); + assert.deepEqual([...approved], ['contributor-a']); +}); + +test('a later changes-requested review revokes an approval', () => { + const approved = approvedReviewers([ + review({ id: 1, login: 'contributor-a' }), + review({ id: 2, login: 'contributor-a', state: 'CHANGES_REQUESTED' }), + ], 'head', 'author'); + assert.deepEqual([...approved], []); +}); + +test('stale, author, and bot approvals are ignored', () => { + const approved = approvedReviewers([ + review({ id: 1, login: 'stale', commit: 'old-head' }), + review({ id: 2, login: 'author' }), + review({ id: 3, login: 'review-bot[bot]', type: 'Bot' }), + ], 'head', 'author'); + assert.deepEqual([...approved], []); +}); + +test('workflow run PR numbers are deduplicated and validated', () => { + assert.deepEqual( + pullRequestNumbersFromWorkflowRun({ + display_title: 'OnTrack review policy signal for PR #42', + pull_requests: [{ number: 42 }, { number: 17 }, { number: 0 }], + }), + [42, 17], + ); +}); + +test('workflow run ignores unsafe or implausibly large PR numbers', () => { + assert.deepEqual( + pullRequestNumbersFromWorkflowRun({ + display_title: 'OnTrack review policy signal for PR #12345678901', + pull_requests: [{ number: Number.MAX_SAFE_INTEGER + 1 }, { number: -4 }], + }), + [], + ); +}); + +test('GitHub App JWT has a valid RSA signature and bounded lifetime', () => { + const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }); + const now = 1_800_000_000; + const jwt = createAppJwt('4699573', privateKeyPem, now); + const [header, payload, signature] = jwt.split('.'); + assert.equal( + verify( + 'RSA-SHA256', + Buffer.from(`${header}.${payload}`), + publicKey, + Buffer.from(signature, 'base64url'), + ), + true, + ); + const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); + assert.equal(claims.iss, '4699573'); + assert.equal(claims.iat, now - 60); + assert.equal(claims.exp, now + 540); +}); + +test('unchanged App status is not republished and spoofed sources are ignored', async () => { + const posts = []; + const api = { + async paginate() { + return [ + { + context: 'ontrack/review-policy', + state: 'success', + description: 'Passed', + creator: { login: 'not-the-policy-app[bot]' }, + }, + { + context: 'ontrack/review-policy', + state: 'pending', + description: 'Waiting', + creator: { login: 'ontrack-review-policy-t2-2026[bot]' }, + }, + ]; + }, + async request(path, options) { + posts.push({ path, options }); + return {}; + }, + }; + + assert.equal( + await setPolicyStatus( + api, + 'owner', + 'repo', + 'a'.repeat(40), + 'pending', + 'Waiting', + 'https://example.test/run', + ), + false, + ); + assert.equal(posts.length, 0); + + assert.equal( + await setPolicyStatus( + api, + 'owner', + 'repo', + 'a'.repeat(40), + 'success', + 'Passed', + 'https://example.test/run', + ), + true, + ); + assert.equal(posts.length, 1); +}); + +test('status-history failure does not suppress a fail-closed write', async () => { + const posts = []; + const warnings = []; + const originalWarn = console.warn; + console.warn = (message) => warnings.push(message); + const api = { + async paginate() { + throw new Error('history unavailable'); + }, + async request(path, options) { + posts.push({ path, options }); + return {}; + }, + }; + + try { + assert.equal( + await setPolicyStatus( + api, + 'owner', + 'repo', + 'b'.repeat(40), + 'error', + 'Evaluation failed', + 'https://example.test/run', + ), + true, + ); + assert.equal(posts.length, 1); + assert.equal(posts[0].options.body.state, 'error'); + assert.equal(warnings.length, 1); + } finally { + console.warn = originalWarn; + } +}); + +test('the status is reported on the head, never on the test merge commit', () => { + assert.equal( + statusShaForPullRequest({ head: { sha: 'head' }, merge_commit_sha: 'test-merge' }), + 'head', + ); +}); + +test('a conflicting pull request with no test merge commit still reports on its head', () => { + assert.equal( + statusShaForPullRequest({ head: { sha: 'head' }, merge_commit_sha: null }), + 'head', + ); +}); diff --git a/.github/workflows/deployment-institution.yml b/.github/workflows/deployment-institution.yml new file mode 100644 index 0000000000..d326026b90 --- /dev/null +++ b/.github/workflows/deployment-institution.yml @@ -0,0 +1,76 @@ +name: create-institution-deployment +on: + push: + tags: + - 'v*' + workflow_dispatch: +permissions: + contents: read + +jobs: + docker-web-server: + if: github.repository_owner == 'doubtfire-lms' + environment: deployment-secrets + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + submodules: recursive + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 + - name: Check custom institution deployment secrets + id: custom_config + env: + INSTITUTION_DOCKERHUB_USERNAME: ${{ secrets.INSTITUTION_DOCKERHUB_USERNAME }} + INSTITUTION_DOCKERHUB_TOKEN: ${{ secrets.INSTITUTION_DOCKERHUB_TOKEN }} + INSTITUTION: ${{ secrets.INSTITUTION }} + run: | + if [ -n "$INSTITUTION_DOCKERHUB_USERNAME" ] && [ -n "$INSTITUTION_DOCKERHUB_TOKEN" ] && [ -n "$INSTITUTION" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "Skipping custom institution Docker publish because one or more institution secrets are not set." + fi + - name: Login to DockerHub + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 + if: github.event_name != 'pull_request' && steps.custom_config.outputs.enabled == 'true' + with: + username: ${{ secrets.INSTITUTION_DOCKERHUB_USERNAME }} + password: ${{ secrets.INSTITUTION_DOCKERHUB_TOKEN }} + - name: Setup meta for custom institution web server + id: docker_meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 + if: steps.custom_config.outputs.enabled == 'true' + with: + images: ${{ secrets.INSTITUTION_DOCKERHUB_USERNAME }}/${{ secrets.INSTITUTION }} + tags: | + type=ref,event=tag + type=ref,event=branch + type=semver,pattern=prod-{{version}} + type=semver,pattern=prod-{{major}}.{{minor}} + type=semver,pattern=prod-{{major}} + - name: Build and push custom web server + id: docker_build + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + if: steps.custom_config.outputs.enabled == 'true' + with: + file: deploy.Dockerfile + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.docker_meta.outputs.tags }} + labels: ${{ steps.docker_meta.outputs.labels }} + sbom: true + provenance: mode=max + build-args: | + SENTRY_DSN=${{ secrets.SENTRY_DSN }} + SENTRY_ORG=${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT=${{ secrets.SENTRY_PROJECT }} + SENTRY_RELEASE=${{ github.ref_name }} + SENTRY_DIST=${{ github.run_number }} + UPLOAD_SENTRY_SOURCEMAPS=true + secrets: | + sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }} + - name: Image digest + if: steps.custom_config.outputs.enabled == 'true' + run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 124fe4c2ae..507dfb6c2a 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -10,56 +10,60 @@ on: # deployment: # workflow_dispatch: jobs: - docker-deploy-development-image: - if: github.repository_owner == 'doubtfire-lms' - environment: deployment-secrets - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Login to DockerHub - uses: docker/login-action@v3 - if: github.event_name != 'pull_request' - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Setup meta for development image - id: docker_meta - uses: docker/metadata-action@v5 - with: - images: lmsdoubtfire/doubtfire-web - tags: | - type=semver,pattern={{major}}.{{minor}}.x-dev - - name: Build and push web server - id: docker_build - uses: docker/build-push-action@v5 - with: - context: . - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.docker_meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - - name: Image digest - run: echo ${{ steps.docker_build.outputs.digest }} + # docker-deploy-development-image: + # if: github.repository_owner == 'doubtfire-lms' + # environment: deployment-secrets + # runs-on: ubuntu-latest + # steps: + # - name: Checkout code + # uses: actions/checkout@v4 + # with: + # submodules: recursive + # - name: Set up Docker Buildx + # uses: docker/setup-buildx-action@v3 + # - name: Login to DockerHub + # uses: docker/login-action@v3 + # if: github.event_name != 'pull_request' + # with: + # username: ${{ secrets.DOCKERHUB_USERNAME }} + # password: ${{ secrets.DOCKERHUB_TOKEN }} + # - name: Setup meta for development image + # id: docker_meta + # uses: docker/metadata-action@v5 + # with: + # images: lmsdoubtfire/doubtfire-web + # tags: | + # type=semver,pattern={{major}}.{{minor}}.x-dev + # - name: Build and push web server + # id: docker_build + # uses: docker/build-push-action@v5 + # with: + # context: . + # push: ${{ github.event_name != 'pull_request' }} + # tags: ${{ steps.docker_meta.outputs.tags }} + # labels: ${{ steps.docker_meta.outputs.labels }} + # - name: Image digest + # run: echo ${{ steps.docker_build.outputs.digest }} docker-web-server: if: github.repository_owner == 'doubtfire-lms' environment: deployment-secrets runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + submodules: recursive - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 - name: Login to DockerHub - uses: docker/login-action@v3 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 if: github.event_name != 'pull_request' with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Setup meta for web server id: docker_meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 with: images: lmsdoubtfire/doubtfire-web tags: | @@ -70,12 +74,14 @@ jobs: type=semver,pattern=prod-{{major}} - name: Build and push web server id: docker_build - uses: docker/build-push-action@v5 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: file: deploy.Dockerfile context: . push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.docker_meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + labels: ${{ steps.docker_meta.outputs.labels }} + sbom: true + provenance: mode=max - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000000..053acc04a6 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,23 @@ +name: Lint CI + +on: + push: + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [22] + + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: ${{ matrix.node-version }} + - run: npm ci + - run: npm run lint + - run: npm run typecheck diff --git a/.github/workflows/nodejs-ci.yml b/.github/workflows/nodejs-ci.yml index 3ef6877d51..dd2f6e9990 100644 --- a/.github/workflows/nodejs-ci.yml +++ b/.github/workflows/nodejs-ci.yml @@ -13,16 +13,16 @@ jobs: strategy: matrix: - node-version: [20] + node-version: [22] steps: - - uses: actions/checkout@v4 - - uses: browser-actions/setup-chrome@latest + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + submodules: recursive + - uses: browser-actions/setup-chrome@48ad923757ca74d66703209fe939badbdf80f2f4 # v2 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: ${{ matrix.node-version }} - - run: npm install -g @angular/cli - run: npm ci - # - run: npm run lint - # - run: npm run test:ci + - run: npm run verify:deployment-config - run: npm run build --if-present diff --git a/.github/workflows/notify-teams-pr.yml b/.github/workflows/notify-teams-pr.yml new file mode 100644 index 0000000000..164a85b240 --- /dev/null +++ b/.github/workflows/notify-teams-pr.yml @@ -0,0 +1,260 @@ +# NOTE ON THE BASE BRANCH. GitHub loads a `pull_request_target` workflow from the +# repository DEFAULT branch (11.0.x), not from the pull request's base branch. That +# changed on 2025-12-08. So this file is merged into 11.0.x deliberately, against the +# usual CONTRIBUTING rule, and a copy on feature/notifications would be dead code. +# It fires for pull requests into every base branch, which is what we want. +name: Notify Teams when a pull request opens + +on: + pull_request_target: + types: + - opened + - reopened + - ready_for_review + # - review_requested # matches "reviewer alerts" most directly, but adds one + # # message per requested reviewer. Decide, do not default. + +# Removes all GITHUB_TOKEN scopes, which this workflow does not need. It does NOT +# restrict secrets.* - the webhook is protected by the guards below, not by this. +permissions: {} + +jobs: + notify-teams: + name: Post pull request notification to Teams + # The job guard makes the file inert if it ever travels to thoth-tech or + # doubtfire-lms. The step below decides who gets a notification: a pull request + # from a branch in this repository always does, and a fork pull request only if + # its author is in one of the two OnTrack teams. + if: github.repository_owner == 'ontrack-features-t2-2026' + runs-on: ubuntu-latest + timeout-minutes: 2 + + steps: + - name: Check pull request author team membership + id: team-membership + shell: bash + env: + TEAM_MEMBERSHIP_TOKEN: ${{ secrets.TEAM_MEMBERSHIP_ACCESS }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + + # A branch in this repository can only be pushed by someone who already + # has write access, so those pull requests need no lookup. That is also + # the only reason Dependabot notifies: dependabot[bot] is in neither + # team and 404s on both. The team check below is for fork pull requests, + # which is where the webhook actually needed protecting. + if [[ "${PR_HEAD_REPO}" == "${REPOSITORY}" ]]; then + echo "eligible=true" >> "${GITHUB_OUTPUT}" + exit 0 + fi + + if [[ -z "${TEAM_MEMBERSHIP_TOKEN:-}" ]]; then + echo "::error::The TEAM_MEMBERSHIP_ACCESS secret is not configured." + exit 1 + fi + + # The login goes into a URL path segment, so percent-encode it. curl also + # reads [ ] { } * in a URL as a glob and exits before sending anything, so + # --globoff below. Between them a login like dependabot[bot] is safe. + author_encoded="$(python3 -c 'import os, urllib.parse; print(urllib.parse.quote(os.environ["PR_AUTHOR"], safe=""))')" + + for team in ontrack-contributors ontrack-leads; do + http_status="$( + curl \ + --proto '=https' \ + --tlsv1.2 \ + --globoff \ + --silent \ + --show-error \ + --connect-timeout 10 \ + --max-time 30 \ + --header 'Accept: application/vnd.github+json' \ + --header "Authorization: Bearer ${TEAM_MEMBERSHIP_TOKEN}" \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + --output "${RUNNER_TEMP}/team-membership.json" \ + --write-out '%{http_code}' \ + "https://api.github.com/orgs/ontrack-features-t2-2026/teams/${team}/memberships/${author_encoded}" \ + )" || http_status="000" + + case "${http_status}" in + 200) + membership_state="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["state"])' \ + < "${RUNNER_TEMP}/team-membership.json")" + # "pending" is a teammate who has not accepted the org invitation + # yet. They are on the team and they review, so notify them too. + if [[ "${membership_state}" == "active" || "${membership_state}" == "pending" ]]; then + echo "eligible=true" >> "${GITHUB_OUTPUT}" + exit 0 + fi + ;; + 404) ;; + *) + echo "::error::GitHub returned HTTP ${http_status} while checking team membership." + exit 1 + ;; + esac + done + + # A skip is a decision, so say so on the run summary. Without this the job + # goes green and looks the same as one that posted a message. + echo "::warning::No Teams notification sent. ${PR_AUTHOR} opened this pull request from a fork and is in neither ontrack-contributors nor ontrack-leads." + echo "eligible=false" >> "${GITHUB_OUTPUT}" + + - name: Build and send Teams notification + if: steps.team-membership.outputs.eligible == 'true' + shell: bash + env: + TEAMS_WEBHOOK_URL: ${{ secrets.TEAMS_PR_WEBHOOK_URL }} + PAYLOAD_PATH: ${{ runner.temp }}/teams-pr-notification.json + + REPOSITORY: ${{ github.repository }} + PR_ACTION: ${{ github.event.action }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_URL: ${{ github.event.pull_request.html_url }} + PR_DRAFT: ${{ github.event.pull_request.draft }} + PR_HEAD: ${{ github.event.pull_request.head.label }} + PR_BASE: ${{ github.event.pull_request.base.ref }} + + run: | + set -euo pipefail + + if [[ -z "${TEAMS_WEBHOOK_URL:-}" ]]; then + echo "::error::The TEAMS_PR_WEBHOOK_URL Actions secret is not configured." + exit 1 + fi + + python3 - <<'PY' + import json + import os + import re + + + def plain(value): + """Teams renders these fields as Markdown, and titles and branch names + come from anyone who can open a pull request. Removing [ ] < > and + backticks stops a title forging a Markdown link or code span. + Parentheses are left alone so `feat(scope): ...` still reads + properly. Note this does NOT stop Teams auto-linking a bare URL in + a title, it only stops the link TEXT being controlled.""" + value = " ".join(value.split()) + return re.sub(r"[\[\]<>`]", " ", value)[:200] + + + headline = { + "opened": "New pull request opened", + "reopened": "Pull request reopened", + "ready_for_review": "Pull request is ready for review", + }.get(os.environ["PR_ACTION"], "Pull request updated") + + status = ( + "Draft" + if os.environ.get("PR_DRAFT", "").lower() == "true" + else "Ready for review" + ) + + card = { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.2", + "body": [ + { + "type": "TextBlock", + "size": "Medium", + "weight": "Bolder", + "wrap": True, + "text": headline, + }, + { + "type": "TextBlock", + "wrap": True, + "text": f"PR #{os.environ['PR_NUMBER']}: {plain(os.environ['PR_TITLE'])}", + }, + { + "type": "FactSet", + "facts": [ + { + "title": "Repository", + "value": plain(os.environ["REPOSITORY"]), + }, + { + "title": "Author", + "value": plain(os.environ["PR_AUTHOR"]), + }, + { + "title": "Status", + "value": status, + }, + { + "title": "Branches", + "value": ( + f"{plain(os.environ['PR_HEAD'])} -> " + f"{plain(os.environ['PR_BASE'])}" + ), + }, + ], + }, + ], + "actions": [ + { + "type": "Action.OpenUrl", + "title": "Open pull request", + "url": os.environ["PR_URL"], + } + ], + } + + payload = { + "type": "message", + "attachments": [ + { + "contentType": "application/vnd.microsoft.card.adaptive", + "contentUrl": None, + "content": card, + } + ], + } + + with open( + os.environ["PAYLOAD_PATH"], + "w", + encoding="utf-8", + ) as payload_file: + json.dump( + payload, + payload_file, + ensure_ascii=False, + ) + PY + + http_status="$( + curl \ + --proto '=https' \ + --tlsv1.2 \ + --silent \ + --show-error \ + --connect-timeout 10 \ + --max-time 30 \ + --header 'Content-Type: application/json' \ + --data-binary "@${PAYLOAD_PATH}" \ + --output "${RUNNER_TEMP}/teams-response.txt" \ + --write-out '%{http_code}' \ + --url "${TEAMS_WEBHOOK_URL}" + )" || http_status="000" + + # Uncomment to debug a failing webhook. The body comes from a third party + # and lands in a PUBLIC Actions log, and GitHub only masks an exact + # full-value match of a secret. Read it once, then comment it out again. + # cat "${RUNNER_TEMP}/teams-response.txt" + + if [[ "${http_status}" != 2* ]]; then + echo "::error::Teams webhook returned HTTP ${http_status}." + exit 1 + fi + + echo "Teams webhook returned HTTP ${http_status} for PR #${PR_NUMBER}." + echo "That means the request was accepted. It does not prove the message rendered in the channel." diff --git a/.github/workflows/ontrack-review-policy-signal.yml b/.github/workflows/ontrack-review-policy-signal.yml new file mode 100644 index 0000000000..d87949cbbc --- /dev/null +++ b/.github/workflows/ontrack-review-policy-signal.yml @@ -0,0 +1,32 @@ +name: OnTrack review policy signal +run-name: "OnTrack review policy signal for PR #${{ github.event.pull_request.number || 'all' }}" + +on: + pull_request_target: + types: + - opened + - reopened + - synchronize + - edited + - ready_for_review + - converted_to_draft + pull_request_review: + types: + - submitted + - edited + - dismissed + workflow_dispatch: + +# This workflow deliberately has no permissions and never checks out pull-request +# code or secrets. Its completion wakes the trusted evaluator on the default +# branch; manual runs safely reconcile every open pull request. +permissions: {} + +jobs: + signal: + if: github.repository_owner == 'ontrack-features-t2-2026' + runs-on: ubuntu-24.04 + timeout-minutes: 1 + steps: + - name: Signal policy evaluation + run: ':' diff --git a/.github/workflows/ontrack-review-policy.yml b/.github/workflows/ontrack-review-policy.yml new file mode 100644 index 0000000000..61c194b103 --- /dev/null +++ b/.github/workflows/ontrack-review-policy.yml @@ -0,0 +1,56 @@ +name: OnTrack review policy evaluator + +on: + workflow_run: + workflows: + - OnTrack review policy signal + types: + - completed + push: + branches: + - 11.0.x + schedule: + # Reconcile team membership and base-branch changes even when no PR event fires. + - cron: '3-58/5 * * * *' + +# The repository token is used only to check out the trusted evaluator from the +# default branch. The GitHub App token is separately scoped to member/PR reads and +# commit-status writes. +permissions: + contents: read + +concurrency: + # Serialize signals for the same PR without allowing unrelated PR activity to + # replace a queued revocation. Scheduled and base-push reconciliations share a + # separate group. Every run re-reads authoritative GitHub state. + group: ontrack-review-policy-${{ github.repository }}-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.display_title || 'reconcile' }} + cancel-in-progress: false + +jobs: + evaluate: + if: github.repository_owner == 'ontrack-features-t2-2026' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + environment: + name: ontrack-review-policy + deployment: false + + steps: + - name: Check out the trusted policy evaluator + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: refs/heads/11.0.x + fetch-depth: 1 + persist-credentials: false + sparse-checkout: .github/review-policy + + - name: Verify policy logic + run: node --test .github/review-policy/evaluate.test.mjs + + - name: Evaluate current pull-request approvals + env: + ONTRACK_REVIEW_APP_ID: ${{ vars.ONTRACK_REVIEW_APP_ID }} + ONTRACK_REVIEW_APP_PRIVATE_KEY: ${{ secrets.ONTRACK_REVIEW_APP_PRIVATE_KEY }} + ONTRACK_CONTRIBUTOR_TEAM: ontrack-contributors + ONTRACK_LEAD_TEAM: ontrack-leads + run: node .github/review-policy/evaluate.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000000..61cee19002 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,19 @@ +name: Test CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Use Node.js 22 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run test:ci diff --git a/.github/workflows/weekly-integration-prs.yml b/.github/workflows/weekly-integration-prs.yml new file mode 100644 index 0000000000..59eefb3cf4 --- /dev/null +++ b/.github/workflows/weekly-integration-prs.yml @@ -0,0 +1,100 @@ +name: Weekly integration PRs + +on: + schedule: + - cron: "17 9 * * 1" + timezone: Australia/Melbourne + workflow_dispatch: + +permissions: + contents: read + +jobs: + ensure-pr: + if: github.repository_owner == 'ontrack-features-t2-2026' + name: ${{ matrix.source }} -> ${{ matrix.target }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + + strategy: + fail-fast: false + max-parallel: 4 + matrix: + include: + - source: feature/cross-unit + target: 11.0.x + - source: feature/notifications + target: 11.0.x + - source: feature/peer-progress-indicator + target: 11.0.x + - source: feature/calendar-integration + target: 11.0.x + + concurrency: + group: ${{ github.workflow }}-${{ matrix.target }}-${{ matrix.source }} + cancel-in-progress: false + + steps: + - name: Ensure pull request exists + shell: bash + env: + GH_TOKEN: ${{ secrets.INTEGRATION_BOT_TOKEN }} + SOURCE_BRANCH: ${{ matrix.source }} + TARGET_BRANCH: ${{ matrix.target }} + run: | + set -euo pipefail + + if [[ -z "${GH_TOKEN:-}" ]]; then + echo "::error::INTEGRATION_BOT_TOKEN is not configured." + exit 1 + fi + + existing="$( + gh api --method GET "repos/$GITHUB_REPOSITORY/pulls" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + -f state=open \ + -f head="${GITHUB_REPOSITORY_OWNER}:${SOURCE_BRANCH}" \ + -f base="$TARGET_BRANCH" \ + -F per_page=1 \ + --jq '.[0].html_url // empty' + )" + + if [[ -n "$existing" ]]; then + echo "Pull request already open: $existing" + exit 0 + fi + + target_ref="$(jq -rn --arg ref "$TARGET_BRANCH" '$ref | @uri')" + source_ref="$(jq -rn --arg ref "$SOURCE_BRANCH" '$ref | @uri')" + + for ref in "$target_ref" "$source_ref"; do + if ! gh api "repos/$GITHUB_REPOSITORY/branches/${ref}" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + --silent >/dev/null 2>&1; then + echo "Branch $ref no longer exists in $GITHUB_REPOSITORY; skipping." + exit 0 + fi + done + comparison="$( + gh api "repos/$GITHUB_REPOSITORY/compare/${target_ref}...${source_ref}" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + --jq '[.ahead_by, (.files | length)] | @tsv' + )" + read -r ahead changed_files <<< "$comparison" + + if [[ "$ahead" == "0" || "$changed_files" == "0" ]]; then + echo "Nothing to merge from $SOURCE_BRANCH into $TARGET_BRANCH." + exit 0 + fi + + gh api --method POST "repos/$GITHUB_REPOSITORY/pulls" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + -f title="Integration: $SOURCE_BRANCH into $TARGET_BRANCH" \ + -f head="$SOURCE_BRANCH" \ + -f base="$TARGET_BRANCH" \ + -f body="Created by the scheduled integration workflow. The source branch is intentionally retained after merge." \ + --jq '.html_url' diff --git a/.gitignore b/.gitignore index 9abc43ca67..65909d007d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,10 +11,13 @@ vendor/ .sass-cache* .bundle* tmp.scss -.vscode .tscache a.env +.env dist/ .angulardoc.json .nx .idea + +# Sentry Config File +.sentryclirc diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..f259c41cca --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "JPlag-Report-Viewer"] + path = JPlag-Report-Viewer + url = https://github.com/doubtfire-lms/jplag-report-viewer-static.git diff --git a/.husky/commit-msg b/.husky/commit-msg index fe4c17a22d..70bd3dd23d 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,4 +1 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -npx --no-install commitlint --edit "" +npx --no-install commitlint --edit "$1" diff --git a/.husky/pre-commit b/.husky/pre-commit index 6700f51282..e69de29bb2 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +0,0 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" diff --git a/.nvmrc b/.nvmrc index 6276cf12fb..6bb900519d 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v16.14.2 +v22.22.3 diff --git a/.postcssrc.json b/.postcssrc.json new file mode 100644 index 0000000000..865e00b364 --- /dev/null +++ b/.postcssrc.json @@ -0,0 +1,6 @@ +{ + "syntax": "postcss-scss", + "plugins": { + "@tailwindcss/postcss": {} + } +} diff --git a/.prettierignore b/.prettierignore index 25257e586f..d541041bd2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,12 @@ yarn.lock dist build e2e +CHANGELOG.md +JPlag-Report-Viewer/ +.github/ +karma/ +Gruntfile.js +karma.conf.js +polyfills.ts +test.ts +main.ts diff --git a/.prettierrc b/.prettierrc index d73829a932..021153e85a 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,9 +1,15 @@ { "printWidth": 100, "tabWidth": 2, - "tabs": false, "singleQuote": true, - "semicolon": true, + "semi": true, "quoteProps": "preserve", - "bracketSpacing": false + "endOfLine": "lf", + "trailingComma": "all", + "bracketSpacing": false, + "plugins": ["@trivago/prettier-plugin-sort-imports"], + "importOrder": ["^@angular/(.*)$", "^rxjs$", "^rxjs/(.*)$", "^src/app/(.*)$", "^[./]"], + "importOrderSeparation": false, + "importOrderSortSpecifiers": true, + "importOrderParserPlugins": ["typescript", "decorators-legacy"] } diff --git a/.tool-versions b/.tool-versions index c2ca3d3d25..42bb250e67 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -nodejs 20.9.0 +nodejs 22.22.3 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..7e6882bfa3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "files.eol": "\n" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ca7d64128..52ce354d6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,1840 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [11.0.0-45](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-44...v11.0.0-45) (2026-07-13) + + +### Features + +* add label for starting tasks ([46100f5](https://github.com/b0ink/doubtfire-deploy/commit/46100f50ca8ddac11668e97deb482994f47f87f9)) +* add task list filters and sorting ([c65279c](https://github.com/b0ink/doubtfire-deploy/commit/c65279cb1900b5198e764d53b671719512486a83)) +* show all tasks in task planner ([eb87251](https://github.com/b0ink/doubtfire-deploy/commit/eb87251dbef37d5cca8d11c29816c42722605764)) + + +### Bug Fixes + +* dont reset task list sidebar when switching between project and task dashboard ([acc3ac2](https://github.com/b0ink/doubtfire-deploy/commit/acc3ac2625137075262c44239772d121e52c3a34)) +* lint ([dab681d](https://github.com/b0ink/doubtfire-deploy/commit/dab681d0e3d0e025804d3477eb68727ab42330ed)) +* use task weighting system as default sorting ([aa3c261](https://github.com/b0ink/doubtfire-deploy/commit/aa3c2613f9c1027eea761f51297380a4443a42b2)) + +## [11.0.0-44](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-43...v11.0.0-44) (2026-07-08) + + +### Features + +* display submitted grade in portfolio view ([a76cecf](https://github.com/b0ink/doubtfire-deploy/commit/a76cecfe1e4f3a78becf9507900e621e87447d69)) + + +### Bug Fixes + +* ensure accurate tutor views ([3b5e693](https://github.com/b0ink/doubtfire-deploy/commit/3b5e69397abcc53136942428fb97ac81a5cddb3d)) +* only call d2l mapping as convenor ([d6ecd44](https://github.com/b0ink/doubtfire-deploy/commit/d6ecd44658f6a668dde409d399944f53793c1c35)) +* persist portfolio filters in url ([322eef2](https://github.com/b0ink/doubtfire-deploy/commit/322eef29e57d13267a2466da4bcb4a55a7b49db6)) +* reduce threshold for mobile view in inbox dashboard ([9479351](https://github.com/b0ink/doubtfire-deploy/commit/9479351619479fed2ac26199f77946ac64903e7c)) + +## [11.0.0-43](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-42...v11.0.0-43) (2026-07-03) + + +### Bug Fixes + +* regenerate ngsw cache after sourcemaps removed ([be85d84](https://github.com/b0ink/doubtfire-deploy/commit/be85d8443b56f20a6866c0fc8d65915d5fe63a2a)) + +## [11.0.0-42](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-41...v11.0.0-42) (2026-07-03) + + +### Features + +* collapse task comments in narrow screen sizes ([380f277](https://github.com/b0ink/doubtfire-deploy/commit/380f2773b44d6582b62b13785b90a146a7002123)) +* render ansi output to html ([732aedd](https://github.com/b0ink/doubtfire-deploy/commit/732aedd2414dcfe3d74a78b4b9ca0bc17d4f5521)) + + +### Bug Fixes + +* add tooltip to collapsed task list item ([c81e61e](https://github.com/b0ink/doubtfire-deploy/commit/c81e61ef48b83f076ba7dac2d039b4bade5c9717)) +* ensure project is valid ([095315b](https://github.com/b0ink/doubtfire-deploy/commit/095315b212fff98cbe661df7a33a7b091cb66b1e)) + +## [11.0.0-41](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-40...v11.0.0-41) (2026-07-02) + + +### Bug Fixes + +* add padding when user cant view submission ([f16d9dc](https://github.com/b0ink/doubtfire-deploy/commit/f16d9dc6ee815e93022f9b8446dd32d1852ce088)) +* avoid repeated unit fetching ([cfe04dc](https://github.com/b0ink/doubtfire-deploy/commit/cfe04dc29f78ab815a17312f929e6deb052a88ed)) +* remove hash prefix ([51794b8](https://github.com/b0ink/doubtfire-deploy/commit/51794b800d2d731d6445cd21b5b592302e43defd)) +* tutorials route transition error ([db1294a](https://github.com/b0ink/doubtfire-deploy/commit/db1294a37e7333612202a8413256288ae97c05aa)) + +## [11.0.0-40](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-39...v11.0.0-40) (2026-07-01) + + +### Bug Fixes + +* reveal overflow task claims to convenors only ([c17a6d2](https://github.com/b0ink/doubtfire-deploy/commit/c17a6d239b71f446300b305de5877372c206028d)) + +## [11.0.0-39](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-38...v11.0.0-39) (2026-07-01) + + +### Bug Fixes + +* dynamic hint sizing to prevent overlap ([518b58b](https://github.com/b0ink/doubtfire-deploy/commit/518b58ba30c23dc4456a98ebfbe41cb48446e715)) + +## [11.0.0-38](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-37...v11.0.0-38) (2026-06-29) + +## [11.0.0-37](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-36...v11.0.0-37) (2026-06-29) + + +### Bug Fixes + +* fetch images on init ([b28bc69](https://github.com/b0ink/doubtfire-deploy/commit/b28bc6905b43d5843eee010cefff4938568edb10)) + +## [11.0.0-36](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-35...v11.0.0-36) (2026-06-29) + + +### Bug Fixes + +* modify grey border ([59c102d](https://github.com/b0ink/doubtfire-deploy/commit/59c102d7708e4c28a5584cc3614046e8e8f5fd52)) + +## [11.0.0-35](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-34...v11.0.0-35) (2026-06-29) + + +### Features + +* add skeleton loading ui to overseer image list ([b833a1c](https://github.com/b0ink/doubtfire-deploy/commit/b833a1c58ce07e6bf9b6a12c77a1bbea1635da10)) +* add skeleton ui for activities ([47ffe2b](https://github.com/b0ink/doubtfire-deploy/commit/47ffe2b261d26be39e93e7b89204c65b5d5b1f57)) + + +### Bug Fixes + +* reveal tii threshold if tii enabled ([ab1a401](https://github.com/b0ink/doubtfire-deploy/commit/ab1a401a3f67db83f7a55aa25060f2a29116eae1)) +* use correct api url ([ed4dba4](https://github.com/b0ink/doubtfire-deploy/commit/ed4dba4552ebf088893dfa58fffac2546690cedd)) + +## [11.0.0-34](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-33...v11.0.0-34) (2026-06-25) + + +### Bug Fixes + +* align icons ([8af5f5a](https://github.com/b0ink/doubtfire-deploy/commit/8af5f5ab177a8057e6b894ae7b039b8b8203c19f)) +* set correct button size ([ec8eef4](https://github.com/b0ink/doubtfire-deploy/commit/ec8eef49ca4275f00502872dab5b0cf23251c337)) + +## [11.0.0-33](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-32...v11.0.0-33) (2026-06-25) + + +### Bug Fixes + +* ensure session replays are captured ([bc35164](https://github.com/b0ink/doubtfire-deploy/commit/bc351643d65104ff85d716cff0e0d405cdb10170)) + +## [11.0.0-32](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-31...v11.0.0-32) (2026-06-25) + + +### Bug Fixes + +* ensure sentry replays work ([b2a8cdd](https://github.com/b0ink/doubtfire-deploy/commit/b2a8cdd2bcb15d409047c346b93136fd2f605b5f)) + +## [11.0.0-31](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-30...v11.0.0-31) (2026-06-25) + +## [11.0.0-30](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-29...v11.0.0-30) (2026-06-25) + + +### Bug Fixes + +* tunnel sentry requests ([2d24ebc](https://github.com/b0ink/doubtfire-deploy/commit/2d24ebc9e611dbf7ca0cbdcd8507b238538d9ea3)) + +## [11.0.0-29](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-28...v11.0.0-29) (2026-06-25) + + +### Features + +* sentry ([#1305](https://github.com/b0ink/doubtfire-deploy/issues/1305)) ([865faa2](https://github.com/b0ink/doubtfire-deploy/commit/865faa286016bf61fdb0ae0e300df48e92045bbb)) + +## [11.0.0-28](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-27...v11.0.0-28) (2026-06-24) + +## [11.0.0-27](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-26...v11.0.0-27) (2026-06-24) + + +### Features + +* customisable grades ([#1271](https://github.com/b0ink/doubtfire-deploy/issues/1271)) ([c2b68ac](https://github.com/b0ink/doubtfire-deploy/commit/c2b68aca0b5b0873443823795fc50bbcdc81fa4d)) + + +### Bug Fixes + +* allow empty strings to fix create user dialog ([efb4e5d](https://github.com/b0ink/doubtfire-deploy/commit/efb4e5d97f9641843f3e3e50f1fd38bb560fe853)) +* center div ([1c20dd7](https://github.com/b0ink/doubtfire-deploy/commit/1c20dd73befb5c0e204874140ca494c968487bd3)) +* close dialog after creating teachiing period ([af9ab4d](https://github.com/b0ink/doubtfire-deploy/commit/af9ab4d692f3e4c5e84a10e54a7f63d289a8092b)) +* ensure overseer assessment is valid before expanding ([6cad43c](https://github.com/b0ink/doubtfire-deploy/commit/6cad43c1c6aac6cf7552ed9db937d6a73cb17369)) +* ensure user is authenticated before loading scorm ([1b066c0](https://github.com/b0ink/doubtfire-deploy/commit/1b066c0e08cffc7a1176c7ec7f1b23da4ce6cf3f)) +* extend reply height to avoid text cutoff ([b4af5d1](https://github.com/b0ink/doubtfire-deploy/commit/b4af5d1b64dc32bea17b65c817aeaf56139be0fb)) +* hide quality pts if negative ([e43b96d](https://github.com/b0ink/doubtfire-deploy/commit/e43b96d77f21ebc654b4ca9e0b5a1cfeb5b8058c)) +* only show status icon if set ([46f494c](https://github.com/b0ink/doubtfire-deploy/commit/46f494c926b604f0c1fd396b59678f5ee74a7492)) +* truncate portfolio task list item correctly ([a02fe9e](https://github.com/b0ink/doubtfire-deploy/commit/a02fe9ebe57ae4157b9124d8a8a1c7aea364ba60)) +* update husky hooks for v9 ([5961860](https://github.com/b0ink/doubtfire-deploy/commit/596186040329ee198e1ef703d76676d15ad3ce89)) +* use correct scorm url ([d95c4ca](https://github.com/b0ink/doubtfire-deploy/commit/d95c4ca400a7aee7dd50f816e1b342e3754db87a)) + +## [11.0.0-26](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-25...v11.0.0-26) (2026-06-19) + + +### Bug Fixes + +* avoid converting colons in urls to emojis [#706](https://github.com/b0ink/doubtfire-deploy/issues/706) ([550b867](https://github.com/b0ink/doubtfire-deploy/commit/550b867df0e52026825e594a72d8544c9505a22b)) +* improve global loading logic for staff ([d81ac52](https://github.com/b0ink/doubtfire-deploy/commit/d81ac52cf1b90e139a34bf13ed08142919b6cc53)) +* prevent multiple global fetches ([f1528d1](https://github.com/b0ink/doubtfire-deploy/commit/f1528d1aedc7d6485b094ed3c4923b44c786d45b)) + +## [11.0.0-25](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-24...v11.0.0-25) (2026-06-18) + + +### Features + +* add day of week to gantt chart ([1fb4637](https://github.com/b0ink/doubtfire-deploy/commit/1fb4637cd2386f1aeff227043e0002bf9b77837b)) +* engagement passport ([#1257](https://github.com/b0ink/doubtfire-deploy/issues/1257)) ([7a754dc](https://github.com/b0ink/doubtfire-deploy/commit/7a754dcc82ca228f5688abab4480774df75a007e)) +* show students name if viewing other project ([38c0e85](https://github.com/b0ink/doubtfire-deploy/commit/38c0e85ebe698c71ea087f3aeecc6d7ca24c86d7)) + + +### Bug Fixes + +* ensure dashboard dropdown switches from task details view ([1ba5d1e](https://github.com/b0ink/doubtfire-deploy/commit/1ba5d1e42d302218520f8be4c0875673ad5d7a3f)) +* web calendar ([#1282](https://github.com/b0ink/doubtfire-deploy/issues/1282)) ([83b6d5e](https://github.com/b0ink/doubtfire-deploy/commit/83b6d5ea381604ae88585195314eafd239880632)) + +## [11.0.0-24](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-23...v11.0.0-24) (2026-06-17) + + +### Features + +* check access token expiry locally before attempting request ([#1270](https://github.com/b0ink/doubtfire-deploy/issues/1270)) ([4ff5562](https://github.com/b0ink/doubtfire-deploy/commit/4ff55627566f9004872c915658bd91bc348dd8e8)) +* submission history ([#1269](https://github.com/b0ink/doubtfire-deploy/issues/1269)) ([4cdae07](https://github.com/b0ink/doubtfire-deploy/commit/4cdae07eb5bef0f9866927892e92b6b80dde0c74)) +* upgrade gantt chart and add screenshotting ability ([#1263](https://github.com/b0ink/doubtfire-deploy/issues/1263)) ([08a8eee](https://github.com/b0ink/doubtfire-deploy/commit/08a8eee9337331aa328960e206722f8905c2a0f9)) + + +### Bug Fixes + +* use either portfolio available field ([f8d5142](https://github.com/b0ink/doubtfire-deploy/commit/f8d514261c7eca5465ac3af95da33b95a39d3f23)) + +## [11.0.0-23](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-22...v11.0.0-23) (2026-06-11) + + +### Features + +* display list of tasks still being processed ([#1264](https://github.com/b0ink/doubtfire-deploy/issues/1264)) ([55ef4c0](https://github.com/b0ink/doubtfire-deploy/commit/55ef4c0409cf5a066e91763a00f2bb428670e520)) + + +### Bug Fixes + +* apply status color ([df6a6ec](https://github.com/b0ink/doubtfire-deploy/commit/df6a6ecc73d01dd2ef449270bc7c6de446d93884)) +* avoid loading rendering all students at the same time ([ba7910a](https://github.com/b0ink/doubtfire-deploy/commit/ba7910a59460a4d4139273b272e99625e5efc518)) +* ensure entire task list can be scrolled through ([8ce79a8](https://github.com/b0ink/doubtfire-deploy/commit/8ce79a8f33c82231f7f3c4033a4d37039514c015)) + +## [11.0.0-22](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-21...v11.0.0-22) (2026-06-09) + + +### Features + +* communications system ([#1239](https://github.com/b0ink/doubtfire-deploy/issues/1239)) ([b55bcfc](https://github.com/b0ink/doubtfire-deploy/commit/b55bcfc8f84f381beb7d62f41031db3ba6a38193)) + +## [11.0.0-21](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-20...v11.0.0-21) (2026-06-08) + + +### Bug Fixes + +* ensure unit dates map correctly ([532f185](https://github.com/b0ink/doubtfire-deploy/commit/532f185d2bc6839549a06a2f9e625c676cc280a6)) + +## [11.0.0-20](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-19...v11.0.0-20) (2026-06-04) + +## [11.0.0-19](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-18...v11.0.0-19) (2026-06-03) + + +### Bug Fixes + +* ensure unit is fetched in unit task editor ([54b1cf0](https://github.com/b0ink/doubtfire-deploy/commit/54b1cf03ed662447e7027a61d084f62f2548c996)) + +## [11.0.0-18](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-17...v11.0.0-18) (2026-06-03) + +## [11.0.0-17](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-16...v11.0.0-17) (2026-06-03) + + +### Features + +* support zip file submissions ([#1240](https://github.com/b0ink/doubtfire-deploy/issues/1240)) ([99d546f](https://github.com/b0ink/doubtfire-deploy/commit/99d546f1544feeab31fc6b2434e330b22544b56f)) + +## [11.0.0-16](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-15...v11.0.0-16) (2026-06-03) + +## [11.0.0-15](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-14...v11.0.0-15) (2026-06-03) + + +### Bug Fixes + +* display past due date on same day ([cd47f5c](https://github.com/b0ink/doubtfire-deploy/commit/cd47f5c0e4c351e2421afd94647a1297b3b5ec75)) +* prevent reload of task list component when switching back to dashboard ([be780b1](https://github.com/b0ink/doubtfire-deploy/commit/be780b14bc875c05e1083353c6d447315bc179e7)) + +## [11.0.0-14](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-13...v11.0.0-14) (2026-06-03) + +## [11.0.0-13](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-12...v11.0.0-13) (2026-06-03) + +## [11.0.0-12](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-11...v11.0.0-12) (2026-06-02) + + +### Bug Fixes + +* correctly apply task tutorial filters ([924c267](https://github.com/b0ink/doubtfire-deploy/commit/924c2676c6739820cc066d046b7ace71ee2d2f9e)) + +## [11.0.0-11](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-10...v11.0.0-11) (2026-05-19) + +## [11.0.0-10](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-9...v11.0.0-10) (2026-05-18) + +## [11.0.0-9](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-8...v11.0.0-9) (2026-05-18) + + +### Bug Fixes + +* ensure unit is loaded first before querying inbox ([6d7c9fb](https://github.com/b0ink/doubtfire-deploy/commit/6d7c9fb8ff7fe714844ebc8f0e04f2ef9a40d4d0)) + +## [11.0.0-8](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-7...v11.0.0-8) (2026-05-18) + +## [11.0.0-7](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-6...v11.0.0-7) (2026-05-17) + +## [11.0.0-6](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-5...v11.0.0-6) (2026-05-14) + +## [11.0.0-5](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-4...v11.0.0-5) (2026-05-14) + + +### Bug Fixes + +* ensure access to tutor notes ([5e0ec7c](https://github.com/b0ink/doubtfire-deploy/commit/5e0ec7cf6ccf04286aa6865e5d84d14487f68f7f)) +* ensure valid selected task ([deaae7f](https://github.com/b0ink/doubtfire-deploy/commit/deaae7fb819ea8dfc01d6ad67415f50c2e4258a2)) + +## [11.0.0-4](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-3...v11.0.0-4) (2026-05-14) + + +### Features + +* add route auth guards ([fb2e781](https://github.com/b0ink/doubtfire-deploy/commit/fb2e78113b13782dbbafe38c21f27c5d63be69c6)) + + +### Bug Fixes + +* enable inbox access for tutors ([05d3eda](https://github.com/b0ink/doubtfire-deploy/commit/05d3eda76bdcfb491649f5d83e69213db3de0885)) + +## [11.0.0-3](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-2...v11.0.0-3) (2026-05-14) + + +### Bug Fixes + +* add padding ([94f4216](https://github.com/b0ink/doubtfire-deploy/commit/94f4216e588d6dfb6abc4d10804971cb0e8f8678)) +* unlock task status selection for staff ([#1222](https://github.com/b0ink/doubtfire-deploy/issues/1222)) ([e4080d0](https://github.com/b0ink/doubtfire-deploy/commit/e4080d03a629654518bc362f48e8e0fbb790dd96)) + +## [11.0.0-2](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-1...v11.0.0-2) (2026-05-13) + +## [11.0.0-1](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-21...v11.0.0-1) (2026-05-13) + + +### Features + +* (wip) add name to new student experience skeleton ([7b9ace4](https://github.com/b0ink/doubtfire-deploy/commit/7b9ace47132f0237bc621d3d0e45c3031e7fac05)) +* add burndown replacement ([837658a](https://github.com/b0ink/doubtfire-deploy/commit/837658afff090e52d53cefa7c2eed20a46c1ac01)) +* add new progress component ([8beef70](https://github.com/b0ink/doubtfire-deploy/commit/8beef701e6b7fc0dbea42f8849dc02e79a665fb7)) +* add new student experience ([9bb77ab](https://github.com/b0ink/doubtfire-deploy/commit/9bb77ab108e50b637329bd739925d1d9566d9203)) +* allow paste attachment comment ([#1165](https://github.com/b0ink/doubtfire-deploy/issues/1165)) ([0f24980](https://github.com/b0ink/doubtfire-deploy/commit/0f24980e6edcc5d0f81e015990adaf14afea400e)) +* batch upload feedback csv ([#1175](https://github.com/b0ink/doubtfire-deploy/issues/1175)) ([9f95987](https://github.com/b0ink/doubtfire-deploy/commit/9f959877b6f47d878d87b468f4aab73f536d598b)) +* bulk import staff via emails ([#1195](https://github.com/b0ink/doubtfire-deploy/issues/1195)) ([c91ab47](https://github.com/b0ink/doubtfire-deploy/commit/c91ab478ae1d1cd459040290f6f7a44c7dce3efe)) +* confirm recursive fix in mobile tutor view ([b64601a](https://github.com/b0ink/doubtfire-deploy/commit/b64601a425c7a64bccfaf47c1e4fccb0343b1589)) +* confirmation modal to reassign tutorials when removing staff ([5f90e90](https://github.com/b0ink/doubtfire-deploy/commit/5f90e9085d69913eced55ad54ce9ac09431d3822)) +* discussed in class refactor ([#1145](https://github.com/b0ink/doubtfire-deploy/issues/1145)) ([4d8bb5b](https://github.com/b0ink/doubtfire-deploy/commit/4d8bb5b82d89caa02ed4936819be601b3f6977fc)) +* display icon for tasks escalated by student ([dfcfd47](https://github.com/b0ink/doubtfire-deploy/commit/dfcfd472305ef2971fa72734c18e77253b98fa11)) +* display portfolio submission time ([d717b27](https://github.com/b0ink/doubtfire-deploy/commit/d717b270eb80c0b0245e6b39f0b3f0567c379512)) +* display sso redirecting state ([248c992](https://github.com/b0ink/doubtfire-deploy/commit/248c992f46488f61916e00a87ca32ed987721f31)) +* edit comments ([#1194](https://github.com/b0ink/doubtfire-deploy/issues/1194)) ([976b6ac](https://github.com/b0ink/doubtfire-deploy/commit/976b6ac4fa3bd2d5e954eebcae3fcb40dd8d1f0e)) +* enable task pinning in explorer ([1647e2b](https://github.com/b0ink/doubtfire-deploy/commit/1647e2bcc86ac6fb08c82be9795a06939c57bb6e)) +* improve look of task status count ([475c316](https://github.com/b0ink/doubtfire-deploy/commit/475c3165db099e1412e30fb6cf18bbaddd516e75)) +* pause feedback threshold during teaching period breaks ([#1138](https://github.com/b0ink/doubtfire-deploy/issues/1138)) ([12fbf81](https://github.com/b0ink/doubtfire-deploy/commit/12fbf8147107dc185a9a9cbea940efac93a0f316)) +* require discussion before marking complete ([#1103](https://github.com/b0ink/doubtfire-deploy/issues/1103)) ([86ae886](https://github.com/b0ink/doubtfire-deploy/commit/86ae8865268ee19e4b3430ff679358cc075e1346)) +* staff note and similarity indicators ([4a65a9b](https://github.com/b0ink/doubtfire-deploy/commit/4a65a9b21d615ecf78bf09743c9cf8c3026ff621)) +* visualisations ([f1cc39e](https://github.com/b0ink/doubtfire-deploy/commit/f1cc39e59dd224c55ee0e8b5f1d50855263d147e)) + + +### Bug Fixes + +* avoid rendering the staff list twice ([9e5be59](https://github.com/b0ink/doubtfire-deploy/commit/9e5be591b9a0cf72d01efa5e28e5b2b9e7eba043)) +* burndown chart visualisation ([#942](https://github.com/b0ink/doubtfire-deploy/issues/942)) ([8487b8d](https://github.com/b0ink/doubtfire-deploy/commit/8487b8d20e838fa1b2a2238bb856f73edc86442c)) +* check for valid unit ([d9560c3](https://github.com/b0ink/doubtfire-deploy/commit/d9560c3b68df0a09f577cdab990d252b5cd24c58)) +* complete student enrolment modal ([efa89c3](https://github.com/b0ink/doubtfire-deploy/commit/efa89c344069d386f2411a417e570d4158af91b9)) +* debounce duplicate task submission requests ([80f92e2](https://github.com/b0ink/doubtfire-deploy/commit/80f92e2277c48978e1738340063a7223c04fddb8)) +* display groups only when a group set is selected ([dde1e76](https://github.com/b0ink/doubtfire-deploy/commit/dde1e7697f8a9a93577bb0e18e3e81a3ebd1ace2)) +* duplicate files ([da51e8e](https://github.com/b0ink/doubtfire-deploy/commit/da51e8e0cc9518a2a76447a48079b42f144064fc)) +* ensure authorisation active in angular ([deaa1e9](https://github.com/b0ink/doubtfire-deploy/commit/deaa1e940f8295ff962c111f1dbac75ed5ff51fa)) +* ensure loading screen removed in sign in component ([6954ac6](https://github.com/b0ink/doubtfire-deploy/commit/6954ac62627e058b6a73c0e83dc8dc8a1740698d)) +* ensure pdf viewer is visible [#1186](https://github.com/b0ink/doubtfire-deploy/issues/1186) ([6bc0752](https://github.com/b0ink/doubtfire-deploy/commit/6bc075228fe2a48dcf91aa5350b5f15f602e2dc8)) +* ensure selected group is valid ([95cb9ac](https://github.com/b0ink/doubtfire-deploy/commit/95cb9ace6615b45dd75161fd02d6dd130420817b)) +* fix pdf viewer for portfolios ([456cf46](https://github.com/b0ink/doubtfire-deploy/commit/456cf466bf49b86467b269ec3d6fb4e63f6a059f)) +* get new visualisations to build ([f828fd4](https://github.com/b0ink/doubtfire-deploy/commit/f828fd42fafaf09d7f807cea8f6a1e14ec56ecc5)) +* link task list to definitions for project dashboard ([90853e9](https://github.com/b0ink/doubtfire-deploy/commit/90853e93c109430669e3f80e4324649cd2d0dba6)) +* new burndown and task status count ([bfecd08](https://github.com/b0ink/doubtfire-deploy/commit/bfecd086eeeae33e9bbba430c79bcc4f21506a07)) +* only render if submission date is valid ([04986a0](https://github.com/b0ink/doubtfire-deploy/commit/04986a0e501f0185dbd199b8d2554133217f7e75)) +* open report in turnitin ([091aaf8](https://github.com/b0ink/doubtfire-deploy/commit/091aaf8ba744bbf677f9b8f51a58bc16b7d631ab)) +* remove hardcoded chart view size ([1b31930](https://github.com/b0ink/doubtfire-deploy/commit/1b319303ac969ef63617b2362297233bc79bb916)) +* remove markdown filter from learning outcomes ([1de217c](https://github.com/b0ink/doubtfire-deploy/commit/1de217c0bc19b8dbedb21cf2e89e195772ae2a02)) +* set task data for project dashboard state ([ef75340](https://github.com/b0ink/doubtfire-deploy/commit/ef7534064ef92c06e459953076bc30b6eff4647d)) +* support building on windows ([b2aa7ae](https://github.com/b0ink/doubtfire-deploy/commit/b2aa7ae6ea7a456953ed4f2a8c63f784ac3870b5)) +* switch staff to unit roles in unit service ([0e5d9de](https://github.com/b0ink/doubtfire-deploy/commit/0e5d9de9df2eaf16e473f4f8fde920ffb59ce765)) +* task route transition race when switching from inbox ([63c52dc](https://github.com/b0ink/doubtfire-deploy/commit/63c52dc4cdb2f2fbc701f69c64b7273f5778c868)) + +### [10.0.1-35](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-34...v10.0.1-35) (2026-04-28) + + +### Features + +* bulk import staff via emails ([#1195](https://github.com/b0ink/doubtfire-deploy/issues/1195)) ([c91ab47](https://github.com/b0ink/doubtfire-deploy/commit/c91ab478ae1d1cd459040290f6f7a44c7dce3efe)) +* edit comments ([#1194](https://github.com/b0ink/doubtfire-deploy/issues/1194)) ([976b6ac](https://github.com/b0ink/doubtfire-deploy/commit/976b6ac4fa3bd2d5e954eebcae3fcb40dd8d1f0e)) + + +### Bug Fixes + +* task route transition race when switching from inbox ([63c52dc](https://github.com/b0ink/doubtfire-deploy/commit/63c52dc4cdb2f2fbc701f69c64b7273f5778c868)) + +### [10.0.1-34](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-33...v10.0.1-34) (2026-04-27) + +### [10.0.1-33](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-32...v10.0.1-33) (2026-04-23) + + +### Features + +* allow paste attachment comment ([#1165](https://github.com/b0ink/doubtfire-deploy/issues/1165)) ([0f24980](https://github.com/b0ink/doubtfire-deploy/commit/0f24980e6edcc5d0f81e015990adaf14afea400e)) + + +### Bug Fixes + +* open report in turnitin ([091aaf8](https://github.com/b0ink/doubtfire-deploy/commit/091aaf8ba744bbf677f9b8f51a58bc16b7d631ab)) + +### [10.0.1-32](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-31...v10.0.1-32) (2026-04-18) + +### [10.0.1-31](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-30...v10.0.1-31) (2026-04-18) + +### [10.0.1-30](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-29...v10.0.1-30) (2026-04-18) + + +### Features + +* batch upload feedback csv ([#1175](https://github.com/b0ink/doubtfire-deploy/issues/1175)) ([9f95987](https://github.com/b0ink/doubtfire-deploy/commit/9f959877b6f47d878d87b468f4aab73f536d598b)) +* display icon for tasks escalated by student ([dfcfd47](https://github.com/b0ink/doubtfire-deploy/commit/dfcfd472305ef2971fa72734c18e77253b98fa11)) +* enable task pinning in explorer ([1647e2b](https://github.com/b0ink/doubtfire-deploy/commit/1647e2bcc86ac6fb08c82be9795a06939c57bb6e)) + + +### Bug Fixes + +* debounce duplicate task submission requests ([80f92e2](https://github.com/b0ink/doubtfire-deploy/commit/80f92e2277c48978e1738340063a7223c04fddb8)) + +### [10.0.1-29](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-28...v10.0.1-29) (2026-04-15) + + +### Features + +* discussed in class refactor ([#1145](https://github.com/b0ink/doubtfire-deploy/issues/1145)) ([4d8bb5b](https://github.com/b0ink/doubtfire-deploy/commit/4d8bb5b82d89caa02ed4936819be601b3f6977fc)) + +### [10.0.1-28](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-27...v10.0.1-28) (2026-04-13) + +### [10.0.1-27](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.27...v10.0.1-27) (2026-04-13) + +### [10.0.27](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-26...v10.0.27) (2026-04-13) + + +### Features + +* confirmation modal to reassign tutorials when removing staff ([5f90e90](https://github.com/b0ink/doubtfire-deploy/commit/5f90e9085d69913eced55ad54ce9ac09431d3822)) + +### [10.0.1-26](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-25...v10.0.1-26) (2026-03-26) + +### [10.0.1-25](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-24...v10.0.1-25) (2026-03-26) + + +### Features + +* display sso redirecting state ([248c992](https://github.com/b0ink/doubtfire-deploy/commit/248c992f46488f61916e00a87ca32ed987721f31)) +* pause feedback threshold during teaching period breaks ([#1138](https://github.com/b0ink/doubtfire-deploy/issues/1138)) ([12fbf81](https://github.com/b0ink/doubtfire-deploy/commit/12fbf8147107dc185a9a9cbea940efac93a0f316)) + +### [10.0.1-24](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-23...v10.0.1-24) (2026-03-24) + + +### Features + +* confirm recursive fix in mobile tutor view ([b64601a](https://github.com/b0ink/doubtfire-deploy/commit/b64601a425c7a64bccfaf47c1e4fccb0343b1589)) + +### [10.0.1-23](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-22...v10.0.1-23) (2026-03-24) + +### [10.0.1-22](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-21...v10.0.1-22) (2026-03-23) + + +### Features + +* display portfolio submission time ([d717b27](https://github.com/b0ink/doubtfire-deploy/commit/d717b270eb80c0b0245e6b39f0b3f0567c379512)) +* require discussion before marking complete ([#1103](https://github.com/b0ink/doubtfire-deploy/issues/1103)) ([86ae886](https://github.com/b0ink/doubtfire-deploy/commit/86ae8865268ee19e4b3430ff679358cc075e1346)) + + +### Bug Fixes + +* avoid rendering the staff list twice ([9e5be59](https://github.com/b0ink/doubtfire-deploy/commit/9e5be591b9a0cf72d01efa5e28e5b2b9e7eba043)) +* only render if submission date is valid ([04986a0](https://github.com/b0ink/doubtfire-deploy/commit/04986a0e501f0185dbd199b8d2554133217f7e75)) + +### [10.0.1-21](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-20...v10.0.1-21) (2026-03-13) + +### [10.0.1-20](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-19...v10.0.1-20) (2026-03-13) + +### [10.0.1-19](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-18...v10.0.1-19) (2026-03-13) + + +### Features + +* view submission history + zip file editor ([#1104](https://github.com/b0ink/doubtfire-deploy/issues/1104)) ([d706f09](https://github.com/b0ink/doubtfire-deploy/commit/d706f0918285a2c6d69c6240a93b41e88506f771)) + +### [10.0.1-18](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-17...v10.0.1-18) (2026-03-11) + + +### Bug Fixes + +* convert emojis in submission comment to string with colons ([#1105](https://github.com/b0ink/doubtfire-deploy/issues/1105)) ([7c508cb](https://github.com/b0ink/doubtfire-deploy/commit/7c508cba8695fcd7b6df4aaa53a70066ecb137f3)) +* dont subtract tutor note count for reading your own notes ([8fe6171](https://github.com/b0ink/doubtfire-deploy/commit/8fe61718c0e263fa965b861763337af27c175b36)) + +### [10.0.1-17](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-16...v10.0.1-17) (2026-03-06) + + +### Bug Fixes + +* empty prompts layout ([c77325a](https://github.com/b0ink/doubtfire-deploy/commit/c77325a2992d0b326bdeb27cd00a955aaa19811c)) + +### [10.0.1-16](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-15...v10.0.1-16) (2026-03-04) + +### [10.0.1-15](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-14...v10.0.1-15) (2026-03-04) + +### [10.0.1-14](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-13...v10.0.1-14) (2026-03-03) + + +### Features + +* view tutor notes from unit staff editor ([011980d](https://github.com/b0ink/doubtfire-deploy/commit/011980ddebb4caddaebb380aae4825930c077122)) + +### [10.0.1-13](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-12...v10.0.1-13) (2026-03-02) + + +### Features + +* add snooze moderation action ([#1096](https://github.com/b0ink/doubtfire-deploy/issues/1096)) ([bdcefda](https://github.com/b0ink/doubtfire-deploy/commit/bdcefda81e4acdef56ebe9f228632f26a904287c)) + +### [10.0.1-12](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-11...v10.0.1-12) (2026-03-02) + +### [10.0.1-11](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-10...v10.0.1-11) (2026-02-28) + + +### Bug Fixes + +* ensure deadline is returned ([6629a5f](https://github.com/b0ink/doubtfire-deploy/commit/6629a5f4b9834009dabf19a60a36c1289126034d)) + +### [10.0.1-10](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-9...v10.0.1-10) (2026-02-26) + + +### Features + +* display available storage for docker images ([#1092](https://github.com/b0ink/doubtfire-deploy/issues/1092)) ([81ebd34](https://github.com/b0ink/doubtfire-deploy/commit/81ebd3488d20a1e057c24b37190f4eea99bad9ec)) + +### [10.0.1-9](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-8...v10.0.1-9) (2026-02-25) + + +### Bug Fixes + +* typo ([4ecfe27](https://github.com/b0ink/doubtfire-deploy/commit/4ecfe27c91104c746ac8a4f9059030d5475776bb)) + +### [10.0.1-8](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-7...v10.0.1-8) (2026-02-25) + + +### Features + +* overflow marking ([#1086](https://github.com/b0ink/doubtfire-deploy/issues/1086)) ([e047988](https://github.com/b0ink/doubtfire-deploy/commit/e047988c4fff787852d1cc5a041cb48e11ee57f8)) + +### [10.0.1-7](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-6...v10.0.1-7) (2026-02-25) + + +### Bug Fixes + +* ensure timestamp is normalised to start of day ([671c391](https://github.com/b0ink/doubtfire-deploy/commit/671c3916797dc5ab680b9dac52ae68626e49d351)) + +### [10.0.1-6](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-5...v10.0.1-6) (2026-02-24) + +### [10.0.1-5](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-4...v10.0.1-5) (2026-02-24) + + +### Features + +* per grade start dates ([#1090](https://github.com/b0ink/doubtfire-deploy/issues/1090)) ([a35019a](https://github.com/b0ink/doubtfire-deploy/commit/a35019aa4011fce7808da8cc1481fae7635aac69)) + +### [10.0.1-4](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-3...v10.0.1-4) (2026-02-23) + + +### Features + +* allow custom target dates per target grade ([#1089](https://github.com/b0ink/doubtfire-deploy/issues/1089)) ([79b5af6](https://github.com/b0ink/doubtfire-deploy/commit/79b5af6bf05419e05dbef69e6ef1f72bfc85097c)) + +### [10.0.1-3](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-2...v10.0.1-3) (2026-02-21) + +### [10.0.1-2](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-1...v10.0.1-2) (2026-02-19) + + +### Bug Fixes + +* set min width for mentor dropdown ([75cc1b3](https://github.com/b0ink/doubtfire-deploy/commit/75cc1b3569d17cd54e71f86874addb4bc34d566f)) + +### [10.0.1-1](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.1-0...v10.0.1-1) (2026-02-18) + + +### Features + +* tutor notes + mentorship + moderation + escalation ([#1068](https://github.com/b0ink/doubtfire-deploy/issues/1068)) ([48f4db7](https://github.com/b0ink/doubtfire-deploy/commit/48f4db730f06e349f5e3c791f1972d23b0f8573f)) + + +### Bug Fixes + +* ensure tutor notes view extends full height ([c872ed7](https://github.com/b0ink/doubtfire-deploy/commit/c872ed766bf493456da56e33dcda48a90ed3f6ce)) + +### [10.0.1-0](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0...v10.0.1-0) (2026-02-17) + +## [10.0.0](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-73...v10.0.0) (2026-02-12) + +## [10.0.0-73](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-72...v10.0.0-73) (2026-02-12) + +## [10.0.0-72](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-71...v10.0.0-72) (2026-02-12) + + +### Bug Fixes + +* null safe user access ([f6aeecc](https://github.com/b0ink/doubtfire-deploy/commit/f6aeecc6c2c79909ca859422e7d9b3bc65ef6456)) + +## [10.0.0-71](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-70...v10.0.0-71) (2026-02-11) + + +### Features + +* download staff notes csv ([#1062](https://github.com/b0ink/doubtfire-deploy/issues/1062)) ([6dcfe95](https://github.com/b0ink/doubtfire-deploy/commit/6dcfe952f0415fb1327b738137f817a0fcfa0488)) +* migrate to jplag report viewer v6.3.0 ([#1082](https://github.com/b0ink/doubtfire-deploy/issues/1082)) ([697588b](https://github.com/b0ink/doubtfire-deploy/commit/697588b98e28ec663f4982c49f18fe8109ba7c34)) + +## [10.0.0-70](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-69...v10.0.0-70) (2026-01-05) + + +### Bug Fixes + +* skip query for unsaved task definition ([a6bdd22](https://github.com/b0ink/doubtfire-deploy/commit/a6bdd22ff864e72c679fae1844345963447fdf08)) + +## [10.0.0-69](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-68...v10.0.0-69) (2026-01-05) + + +### Features + +* attention required task status ([#1061](https://github.com/b0ink/doubtfire-deploy/issues/1061)) ([15365ed](https://github.com/b0ink/doubtfire-deploy/commit/15365ed4f42af790af9a9ae24dae42f041dff1df)) +* display number of stuff notes in tutor discussion ([2674d4e](https://github.com/b0ink/doubtfire-deploy/commit/2674d4e5ade26e5420b87518f4cd9da909ec7246)) +* overseer pipeline ([#1064](https://github.com/b0ink/doubtfire-deploy/issues/1064)) ([7cb8fc7](https://github.com/b0ink/doubtfire-deploy/commit/7cb8fc7471f0402c562fb8a0b87df672ceb22ea3)) +* project task planner gantt chart ([#1051](https://github.com/b0ink/doubtfire-deploy/issues/1051)) ([4f60792](https://github.com/b0ink/doubtfire-deploy/commit/4f607926e3ac305316a05607fda7f7bff7faec00)) + +## [10.0.0-68](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-67...v10.0.0-68) (2025-12-08) + + +### Bug Fixes + +* only trim if valid ([fbc63b0](https://github.com/b0ink/doubtfire-deploy/commit/fbc63b0308d87d921e1d74788e8da6031443e57e)) + +## [10.0.0-67](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-66...v10.0.0-67) (2025-12-03) + + +### Features + +* allow sidekiq web access ([#1053](https://github.com/b0ink/doubtfire-deploy/issues/1053)) ([6651279](https://github.com/b0ink/doubtfire-deploy/commit/6651279c49f431dbddf6fd5d810440ae7b3b9898)) + +## [10.0.0-66](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-65...v10.0.0-66) (2025-12-03) + + +### Features + +* discussion prompts ([#1042](https://github.com/b0ink/doubtfire-deploy/issues/1042)) ([730ffd0](https://github.com/b0ink/doubtfire-deploy/commit/730ffd0c3f79a5ad42c0c59c2f2c2a901ab0d702)) + + +### Bug Fixes + +* correctly set rollover end date ([0611be0](https://github.com/b0ink/doubtfire-deploy/commit/0611be04e8fe7e75ae07504945a2984839475e3a)) + +## [10.0.0-65](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-64...v10.0.0-65) (2025-11-25) + + +### Bug Fixes + +* typo ([bdcd75d](https://github.com/b0ink/doubtfire-deploy/commit/bdcd75d91d5ddc7d9a60317bd91289a7242251e1)) + +## [10.0.0-64](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-63...v10.0.0-64) (2025-11-25) + + +### Bug Fixes + +* avoid calling window to open project in new tab ([9ab015d](https://github.com/b0ink/doubtfire-deploy/commit/9ab015d168cf7e421500910766b9e6fb0bf907f5)) +* use new google fonts api for proper weight loading ([#1039](https://github.com/b0ink/doubtfire-deploy/issues/1039)) ([40f2640](https://github.com/b0ink/doubtfire-deploy/commit/40f2640e6579536cef8e82e9476733083b82bac1)) + +## [10.0.0-63](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-62...v10.0.0-63) (2025-11-10) + + +### Features + +* open project dashboard from portfolios view ([5b4b097](https://github.com/b0ink/doubtfire-deploy/commit/5b4b097a9b189e5a816695cce7438104f1144283)), closes [#1040](https://github.com/b0ink/doubtfire-deploy/issues/1040) + +## [10.0.0-62](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-61...v10.0.0-62) (2025-11-06) + + +### Features + +* restrict assessments to tutors in the same tutorial stream ([#1033](https://github.com/b0ink/doubtfire-deploy/issues/1033)) ([29fbc14](https://github.com/b0ink/doubtfire-deploy/commit/29fbc1498b0d48811cb59c5f2a66871fbac06849)) + +## [10.0.0-61](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-60...v10.0.0-61) (2025-11-06) + +## [10.0.0-60](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-59...v10.0.0-60) (2025-11-05) + + +### Features + +* upload grades csv ([#1038](https://github.com/b0ink/doubtfire-deploy/issues/1038)) ([c67ceaa](https://github.com/b0ink/doubtfire-deploy/commit/c67ceaa84d02bb71626890fc28bb4aaea164b952)) + +## [10.0.0-59](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-58...v10.0.0-59) (2025-11-04) + + +### Bug Fixes + +* require comment for new evidence ([58ec499](https://github.com/b0ink/doubtfire-deploy/commit/58ec499a8d59c7202f591de661551d8a32c37527)) +* require comment for new evidence ([c5397fc](https://github.com/b0ink/doubtfire-deploy/commit/c5397fc5c444e76b431aa5d6487553c60e711c2e)) + +## [10.0.0-58](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-57...v10.0.0-58) (2025-11-01) + +## [10.0.0-57](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-56...v10.0.0-57) (2025-10-30) + + +### Features + +* open similarities from project dashboard ([#1035](https://github.com/b0ink/doubtfire-deploy/issues/1035)) ([ec0af0c](https://github.com/b0ink/doubtfire-deploy/commit/ec0af0cbe47392d6542d9741e1131954195d407f)) + + +### Bug Fixes + +* enable submission button for 'need help' trigger ([#1034](https://github.com/b0ink/doubtfire-deploy/issues/1034)) ([07de5b1](https://github.com/b0ink/doubtfire-deploy/commit/07de5b1e34f78f83c1418399f45e8c754aea530a)) +* typo ([2712a68](https://github.com/b0ink/doubtfire-deploy/commit/2712a68f6031794c571a7ee5866c653bf67de1dd)) + +## [10.0.0-56](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-55...v10.0.0-56) (2025-10-23) + + +### Bug Fixes + +* ensure grade has been selected ([32e03d1](https://github.com/b0ink/doubtfire-deploy/commit/32e03d111e61df42a9c67cb9e68d271f9300d5bc)) + +## [10.0.0-55](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-54...v10.0.0-55) (2025-10-20) + + +### Features + +* display task count in inbox ([#1025](https://github.com/b0ink/doubtfire-deploy/issues/1025)) ([3858e37](https://github.com/b0ink/doubtfire-deploy/commit/3858e3792fef3f0542d954e213987484ac97a990)) + + +### Bug Fixes + +* display correct marking session details ([c49ffed](https://github.com/b0ink/doubtfire-deploy/commit/c49ffeddedb2b79c39252770692e7df9dcd97bfc)) +* typo ([8a1dd1a](https://github.com/b0ink/doubtfire-deploy/commit/8a1dd1ac806c5acbbf8d1ccf7bdcb6af1c68dd14)) + +## [10.0.0-54](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-53...v10.0.0-54) (2025-10-17) + +## [10.0.0-53](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-52...v10.0.0-53) (2025-10-17) + +## [10.0.0-52](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-51...v10.0.0-52) (2025-10-17) + + +### Features + +* campus timezone ([#1022](https://github.com/b0ink/doubtfire-deploy/issues/1022)) ([4384b37](https://github.com/b0ink/doubtfire-deploy/commit/4384b376eaedf8ab66febec4c0ada53111311ff0)) +* download marking sessions for tutor ([e3fbe44](https://github.com/b0ink/doubtfire-deploy/commit/e3fbe4404661eb16b9c0e5ba19a1369dac1fb5b0)) + + +### Bug Fixes + +* display tutor name ([8956020](https://github.com/b0ink/doubtfire-deploy/commit/89560205d7316c53d77f376b5f7cfff5bf99e9c6)) +* filter out students from unit staff editor ([#1018](https://github.com/b0ink/doubtfire-deploy/issues/1018)) ([5c0c997](https://github.com/b0ink/doubtfire-deploy/commit/5c0c9974569e720a6c95aa8333c2a38d0ab4a1b0)) +* re-enable user filtering on click ([697daf2](https://github.com/b0ink/doubtfire-deploy/commit/697daf26693f20f61dcfbbeb0597a02715c9512d)) + +## [10.0.0-51](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-50...v10.0.0-51) (2025-10-14) + + +### Bug Fixes + +* unlock tasks when prerequisite requires rff and task is in aip state ([173d8c2](https://github.com/b0ink/doubtfire-deploy/commit/173d8c25375e231b25e64a04c1e3d2aedf741300)) + +## [10.0.0-50](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-49...v10.0.0-50) (2025-10-13) + + +### Features + +* jplag base code ([#1010](https://github.com/b0ink/doubtfire-deploy/issues/1010)) ([9a7a860](https://github.com/b0ink/doubtfire-deploy/commit/9a7a860fe799e351831e2a3f9701792792b76b38)) + +## [10.0.0-49](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-48...v10.0.0-49) (2025-10-12) + + +### Features + +* display session start and end time ([bb548b9](https://github.com/b0ink/doubtfire-deploy/commit/bb548b92d04bb7b8665444e7935317bc4b6a0981)) + +## [10.0.0-48](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-47...v10.0.0-48) (2025-10-12) + + +### Features + +* add tutor times summary download ([#1015](https://github.com/b0ink/doubtfire-deploy/issues/1015)) ([2b4afe2](https://github.com/b0ink/doubtfire-deploy/commit/2b4afe24e3f8139fec23337facc7656eec9a9d3a)) + + +### Bug Fixes + +* add assess in portfolio tasks as completed tasks in burndown chart ([8d36c94](https://github.com/b0ink/doubtfire-deploy/commit/8d36c94f03563a0924cfeb7d2d9e8dd911b65960)) + +## [10.0.0-47](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-46...v10.0.0-47) (2025-09-25) + + +### Features + +* add observer only ui ([bfbcee9](https://github.com/b0ink/doubtfire-deploy/commit/bfbcee98cfa4888241c5956372d004ec1cd813cb)) + +## [10.0.0-46](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-45...v10.0.0-46) (2025-09-24) + +## [10.0.0-45](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-44...v10.0.0-45) (2025-09-22) + + +### Bug Fixes + +* check for valid unit ([bfae1ad](https://github.com/b0ink/doubtfire-deploy/commit/bfae1ad69bb767cdad47dd8fac35cc84e5134534)) + +## [10.0.0-44](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-43...v10.0.0-44) (2025-09-18) + +## [10.0.0-43](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-42...v10.0.0-43) (2025-09-17) + + +### Bug Fixes + +* remove /api from host url ([5889edc](https://github.com/b0ink/doubtfire-deploy/commit/5889edc35bb427dfd4defefa7b77e8c81cd6dd7c)) + +## [10.0.0-42](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-41...v10.0.0-42) (2025-09-17) + + +### Features + +* display result of student imports ([e0503f4](https://github.com/b0ink/doubtfire-deploy/commit/e0503f424dfbef49e68b4e2f697e8d135120634e)) +* enable unit linking to lms context using deeplink ([73de1f1](https://github.com/b0ink/doubtfire-deploy/commit/73de1f1c68223c863c35198b773f0bdeaa1f549e)) +* init lti integration ([248be93](https://github.com/b0ink/doubtfire-deploy/commit/248be93e7c8b5ff060957e500b6a4943882f456c)) +* sync enrolments for all lti members ([5ec499f](https://github.com/b0ink/doubtfire-deploy/commit/5ec499f37b93f2b4bd56d145501fd55ab6af45e6)) +* sync grades for all members ([0f5daa8](https://github.com/b0ink/doubtfire-deploy/commit/0f5daa852f4c3750367f7d2c3f9b0c07ffbfed3f)) +* unauthorised lti banner ([12e65d8](https://github.com/b0ink/doubtfire-deploy/commit/12e65d8f1609f2030b25347beb1c0e06f513f65c)) + + +### Bug Fixes + +* forward correct lti token ([e95544e](https://github.com/b0ink/doubtfire-deploy/commit/e95544e2e158affefafd2fc0fe9657bf47c200db)) + +## [10.0.0-41](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-40...v10.0.0-41) (2025-09-15) + +## [10.0.0-40](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-39...v10.0.0-40) (2025-09-15) + + +### Features + +* assess in portfolio status ([ada4a21](https://github.com/b0ink/doubtfire-deploy/commit/ada4a21c945018abeb9ca1169e48fdd822a27322)) +* ensure complete button updates aip only tasks to working on it ([c8dd020](https://github.com/b0ink/doubtfire-deploy/commit/c8dd020a5e572765f7710c266ae2b20563ab05c2)) +* send automated message when set back to working on it ([a858d3a](https://github.com/b0ink/doubtfire-deploy/commit/a858d3a7b2f41ac7478d4917d2d2f6c68a167e6e)) +* submission type modal ([52f7d40](https://github.com/b0ink/doubtfire-deploy/commit/52f7d404ef154ff27c0ba887c8321360df95ddb6)) + + +### Bug Fixes + +* typo ([2a2e4a4](https://github.com/b0ink/doubtfire-deploy/commit/2a2e4a41e6ea20152632a9f82ca1723d9a6b56cc)) +* wording ([6291bd6](https://github.com/b0ink/doubtfire-deploy/commit/6291bd61ccc4ff45c708300d0a5014a59a565414)) + +## [10.0.0-39](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-38...v10.0.0-39) (2025-09-10) + + +### Features + +* display warning above task sheet for incomplete prerequisites ([86433bb](https://github.com/b0ink/doubtfire-deploy/commit/86433bb18e6ff9b024d7c397b8744618721b602f)) + +## [10.0.0-38](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-37...v10.0.0-38) (2025-09-09) + + +### Bug Fixes + +* ensure task prerequisites are correctly cached ([3945f4e](https://github.com/b0ink/doubtfire-deploy/commit/3945f4e2ecff1a1d79662c099baf745bfd6f4b7d)) + +## [10.0.0-37](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-36...v10.0.0-37) (2025-09-09) + +## [10.0.0-36](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-35...v10.0.0-36) (2025-09-09) + +## [10.0.0-35](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-34...v10.0.0-35) (2025-09-09) + + +### Features + +* add task prerequisites ([d5c1a51](https://github.com/b0ink/doubtfire-deploy/commit/d5c1a51dc049b025ae85b2ef6513630a197eabee)) +* cross out prerequisite tasks that have been submitted ([7e1a7db](https://github.com/b0ink/doubtfire-deploy/commit/7e1a7dbd3351dbcc8230f115554d6afdfaca0f70)) +* show list of prerequisites in task dashboard ([111f60a](https://github.com/b0ink/doubtfire-deploy/commit/111f60a4272abcf683c50b711bd43a1f772153ec)) + + +### Bug Fixes + +* typo ([e0c867f](https://github.com/b0ink/doubtfire-deploy/commit/e0c867fc7f24301d414ed584b6859e09d95c652c)) + +## [10.0.0-34](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-33...v10.0.0-34) (2025-09-08) + + +### Features + +* task assessment counts csv ([c2c6002](https://github.com/b0ink/doubtfire-deploy/commit/c2c6002cef6e5a75f722c964bf2843cf662380f4)) + + +### Bug Fixes + +* allow scrolling in edit profile form dialog ([ee2b908](https://github.com/b0ink/doubtfire-deploy/commit/ee2b9085a931df551408cd12dbc8187c04d088fc)) + +## [10.0.0-33](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-32...v10.0.0-33) (2025-09-04) + +## [10.0.0-32](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-31...v10.0.0-32) (2025-09-02) + +## [10.0.0-31](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-30...v10.0.0-31) (2025-08-27) + + +### Bug Fixes + +* use new checked_in comment type ([3033c6c](https://github.com/b0ink/doubtfire-deploy/commit/3033c6ca3c27ad4fc0a7ce4bda3ac44df09516e3)) + +## [10.0.0-30](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-29...v10.0.0-30) (2025-08-27) + +## [10.0.0-29](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-28...v10.0.0-29) (2025-08-27) + +## [10.0.0-28](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-27...v10.0.0-28) (2025-08-27) + +## [10.0.0-27](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-26...v10.0.0-27) (2025-08-27) + + +### Features + +* add option for qr footer text ([9e9b9db](https://github.com/b0ink/doubtfire-deploy/commit/9e9b9dbfb13d7531cb5387060407e23c25212cca)) +* support viewing qr for selected student in tutor view ([1f7fa51](https://github.com/b0ink/doubtfire-deploy/commit/1f7fa51d65767b3186e6eda8970a408cf0e6c847)) + + +### Bug Fixes + +* add padding when edit profile form displayed as modal ([a3f3b97](https://github.com/b0ink/doubtfire-deploy/commit/a3f3b977251a206b268c369c370934ff69b574d3)) +* show student id ([d1eeefb](https://github.com/b0ink/doubtfire-deploy/commit/d1eeefbea53cb13385bef2b3ba6b04d2f606c391)) + +## [10.0.0-26](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-25...v10.0.0-26) (2025-08-27) + + +### Features + +* add attendance marked task comment ([1559e81](https://github.com/b0ink/doubtfire-deploy/commit/1559e8104c8c4bc137df4f46a0a5ed0e345260af)) +* add tutor discussion to unit dropdown ([90f4149](https://github.com/b0ink/doubtfire-deploy/commit/90f41493313d6a3ad9bdafdd4cd4865c80e94ca2)) +* redirect back to inbox if unit context exists ([b7f4ab9](https://github.com/b0ink/doubtfire-deploy/commit/b7f4ab9e7801fbb33ffc6b4e02bf6613e6d7a524)) + + +### Bug Fixes + +* enforce minimum height on task comments viewer ([a7588e6](https://github.com/b0ink/doubtfire-deploy/commit/a7588e6f007381c04a570650257cd6f2ff93e5ae)) +* set max width on main container ([d1e5ab1](https://github.com/b0ink/doubtfire-deploy/commit/d1e5ab1427352fcf1b0fdd920cb9838a4f8799a0)) + +## [10.0.0-25](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-24...v10.0.0-25) (2025-08-26) + + +### Bug Fixes + +* only clear draft comment after successfully sending message ([2556355](https://github.com/b0ink/doubtfire-deploy/commit/2556355b0c3e1770fab021ed7d46e462882025b3)) + +## [10.0.0-24](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-23...v10.0.0-24) (2025-08-21) + +## [10.0.0-23](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-22...v10.0.0-23) (2025-08-14) + +## [10.0.0-22](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-21...v10.0.0-22) (2025-08-14) + + +### Features + +* download tasks awaiting feedback csv ([4a94373](https://github.com/b0ink/doubtfire-deploy/commit/4a943736689e41c405a831ca88a1303fb7c00385)) + + +### Bug Fixes + +* add return statement ([b8779e7](https://github.com/b0ink/doubtfire-deploy/commit/b8779e70d4decdc837caa8c84e8e8e2dfd3faf7f)) +* show full progress when sidekiq job is complete ([6a60d94](https://github.com/b0ink/doubtfire-deploy/commit/6a60d94b896d173a5008badd1b45bc7f45eee808)) + +## [10.0.0-21](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-20...v10.0.0-21) (2025-08-12) + + +### Bug Fixes + +* clear notification after fetching task comments ([c2d1536](https://github.com/b0ink/doubtfire-deploy/commit/c2d15367d9c53cb426dd12e73dc8d0b779a7f563)) + +## [10.0.0-20](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-19...v10.0.0-20) (2025-08-11) + +## [10.0.0-19](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-18...v10.0.0-19) (2025-08-07) + + +### Features + +* mark as discussed button in mobile tutor discussion ([9e62214](https://github.com/b0ink/doubtfire-deploy/commit/9e62214df772355048f4c7c7ffc13f3181f746e2)) + +## [10.0.0-18](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-17...v10.0.0-18) (2025-07-31) + + +### Bug Fixes + +* ensure no progress returns 0 ([9cb1f1a](https://github.com/b0ink/doubtfire-deploy/commit/9cb1f1ab770ac798ec8ecc555ceb73024bc98649)) + +## [10.0.0-17](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-16...v10.0.0-17) (2025-07-31) + + +### Features + +* return task to not started status ([bce1483](https://github.com/b0ink/doubtfire-deploy/commit/bce1483296064bafd44bc538f83731809d816a05)) + + +### Bug Fixes + +* check if project task stats exist ([e59af80](https://github.com/b0ink/doubtfire-deploy/commit/e59af803f8440338b0210b6a437537372f5dac5b)) +* ensure new comments are fetched for selected project only ([3b0e387](https://github.com/b0ink/doubtfire-deploy/commit/3b0e3877e05f162143fc78065cee9a191d0755f4)) +* ensure whitespace is trimmed ([fe687df](https://github.com/b0ink/doubtfire-deploy/commit/fe687df8b035ef851b9d147e01f575187f428a8c)) +* prevent additional inbox queries ([0d35f7f](https://github.com/b0ink/doubtfire-deploy/commit/0d35f7f4ef2e87dd078edf20e509c8f6efc5c216)) + +## [10.0.0-16](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-15...v10.0.0-16) (2025-07-29) + +## [10.0.0-15](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-14...v10.0.0-15) (2025-07-29) + + +### Bug Fixes + +* correctly load start page ([e297f26](https://github.com/b0ink/doubtfire-deploy/commit/e297f261e3df19faf11be9fde6515ce75434c320)) +* map correct variables ([b180799](https://github.com/b0ink/doubtfire-deploy/commit/b180799efd716d802cf83ce08b6df0639d9055b6)) +* prevent report button from expanding accordion ([e715415](https://github.com/b0ink/doubtfire-deploy/commit/e7154157ff1b88817141bae5b1b3de9fd37171f0)) + +## [10.0.0-14](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-13...v10.0.0-14) (2025-07-28) + + +### Features + +* portfolio zip downloading via sidekiq ([9000830](https://github.com/b0ink/doubtfire-deploy/commit/9000830e5e98bc0b921d618c034471eeddf996b4)) +* show list of active sidekiq jobs ([aa85665](https://github.com/b0ink/doubtfire-deploy/commit/aa85665670a23fea556c6420f139e7e7853d16ba)) +* show maintenance ui when api fails ([29bb95f](https://github.com/b0ink/doubtfire-deploy/commit/29bb95f5974dc8f258b204957a212a8399ad2d6e)) +* sidekiq progress modal component ([b9691e7](https://github.com/b0ink/doubtfire-deploy/commit/b9691e793204e84fae323610a7b555b15e065514)) + + +### Bug Fixes + +* fallback to task definition due date ([bef0a9c](https://github.com/b0ink/doubtfire-deploy/commit/bef0a9cbfefc58407f851d3ca73b90af6b81151f)) + +## [10.0.0-13](https://github.com/macite/doubtfire-deploy/compare/v10.0.0-12...v10.0.0-13) (2025-07-16) + + +### Bug Fixes + +* display task-date-slider only when task exists ([c886034](https://github.com/macite/doubtfire-deploy/commit/c886034d4bf69851f4b06599a45bd1b135c3fff2)) + +## [10.0.0-12](https://github.com/macite/doubtfire-deploy/compare/v10.0.0-11...v10.0.0-12) (2025-07-15) + + +### Bug Fixes + +* exclude jplag route from ngsw cache ([69b55bb](https://github.com/macite/doubtfire-deploy/commit/69b55bb85d8583b6935e08c91aad63e98e6ce887)) + +## [10.0.0-11](https://github.com/macite/doubtfire-deploy/compare/v10.0.0-10...v10.0.0-11) (2025-07-15) + +## [10.0.0-10](https://github.com/macite/doubtfire-deploy/compare/v10.0.0-9...v10.0.0-10) (2025-07-15) + +## [10.0.0-9](https://github.com/macite/doubtfire-deploy/compare/v10.0.0-8...v10.0.0-9) (2025-07-15) + +## [10.0.0-8](https://github.com/macite/doubtfire-deploy/compare/v10.0.0-7...v10.0.0-8) (2025-07-15) + +## [10.0.0-7](https://github.com/macite/doubtfire-deploy/compare/v10.0.0-6...v10.0.0-7) (2025-07-14) + + +### Features + +* add icon to discussed in class update ([c11cec0](https://github.com/macite/doubtfire-deploy/commit/c11cec02ece3b6bf7c2e36998ee2fa2a3572bc58)) +* add jplag report download button to student submission tutor teacher footer view ([2ec1b06](https://github.com/macite/doubtfire-deploy/commit/2ec1b06b5330c5e142cc3fc01c49c2aded5411cb)) +* add tutor discussion qr scanning ([e549c4e](https://github.com/macite/doubtfire-deploy/commit/e549c4e0ac9377bd864c81122cffce004681c845)) +* auto send dicussed in class comment ([fbf90c7](https://github.com/macite/doubtfire-deploy/commit/fbf90c7fdfa440f565e2c8abae4ae62bc1fe70eb)) +* **comments:** enhanced drafting for messages using localStorage ([f331734](https://github.com/macite/doubtfire-deploy/commit/f33173409f0804da4de24e1e6ec53971896b28e9)) +* **comments:** enhanced drafting for messages using localStorage ([b6f6831](https://github.com/macite/doubtfire-deploy/commit/b6f683175db6ab9549b8b4481ab54bb89700016a)) +* edit staff note ([b2d8ba4](https://github.com/macite/doubtfire-deploy/commit/b2d8ba41b1dc467a03728d5df4ede6ac6fb3c9a1)) +* fetch new comments on task switching and status updates ([1b7da69](https://github.com/macite/doubtfire-deploy/commit/1b7da692423acaf53b96229b665b971360bacc89)) +* force scanner to use back camera, enhance ux ([e5799b1](https://github.com/macite/doubtfire-deploy/commit/e5799b18ec0603123b6a99b3733919d2611b4fbd)) +* highlight selected task ([25b361f](https://github.com/macite/doubtfire-deploy/commit/25b361f699ad54e45c32167ccae230635a6b2758)) +* indicate reply has been deleted ([c3e288a](https://github.com/macite/doubtfire-deploy/commit/c3e288ae95e2fa5fd43bdf3922e81501f26e1baf)) +* init qr tutor marking state ([41d1d61](https://github.com/macite/doubtfire-deploy/commit/41d1d61a417573e33c4344898ae6d7562c459004)) +* init reply to button ([100a533](https://github.com/macite/doubtfire-deploy/commit/100a5335aa7f37f4477b9d9b8f43806ca32c640b)) +* init staff notes ([c7eed99](https://github.com/macite/doubtfire-deploy/commit/c7eed9903bcc4463f4d0b52889e0d3d9045b03ba)) +* jplag report download button in task explorer view ([16670a2](https://github.com/macite/doubtfire-deploy/commit/16670a27d5dc544ae828da85cda48358cab4d75e)) +* link note that is being replied to ([27726b3](https://github.com/macite/doubtfire-deploy/commit/27726b3f1368031c24bf069bde4f930dd02c1e80)) +* load jplag report viewer into iframe ([1360438](https://github.com/macite/doubtfire-deploy/commit/13604383b403dbf82b013786127a72fab08ac895)) +* localized date time pipe ([5cf752b](https://github.com/macite/doubtfire-deploy/commit/5cf752baf1b9de6105ea2a951ec75818ce1e0cfc)) +* mark task as discussed ([a54f94f](https://github.com/macite/doubtfire-deploy/commit/a54f94f045b4280d0f13304a6b5728b62fc4107e)) +* message tooltip timestamps ([2d79007](https://github.com/macite/doubtfire-deploy/commit/2d79007c033d56218326f55dd8d6821e17fec74f)) +* native jplag report viewer state ([8f6bc6a](https://github.com/macite/doubtfire-deploy/commit/8f6bc6ae5b80502b5d81b65bab2be3aaa56ce42f)) +* qr code generation for student projects ([6f8b94f](https://github.com/macite/doubtfire-deploy/commit/6f8b94f7c0b48d292dfe28871ff7ae7fda86b2b9)) +* redirect back to home if exiting the scanner ([71f09ae](https://github.com/macite/doubtfire-deploy/commit/71f09ae77da6f0a2f5ed135071c92c110940c2f5)) +* reply to ui ([40078df](https://github.com/macite/doubtfire-deploy/commit/40078df477bd300c9b8897976d10658fb91a6d04)) +* show number of notes project has ([f3d4c4c](https://github.com/macite/doubtfire-deploy/commit/f3d4c4c00128ca00dec18ea9950c01df25b75b61)) +* staff note deletion ([0ab1ebf](https://github.com/macite/doubtfire-deploy/commit/0ab1ebff3964d8665d9560bf3007a2d30109ee37)) +* switch to staff note tab view ([65f8c71](https://github.com/macite/doubtfire-deploy/commit/65f8c71bd37ef71481bd14f591af263470987a6e)) +* task and unit ilos ui ([1cef438](https://github.com/macite/doubtfire-deploy/commit/1cef438b458efcee4adce01cc81648d32bb97f35)) +* view filtered tasks only button ([a4e422d](https://github.com/macite/doubtfire-deploy/commit/a4e422dcf3dfa329b83cd7e3d521fc3665c9c45f)) +* view staff notes in portfolio assessment ([020ab5b](https://github.com/macite/doubtfire-deploy/commit/020ab5b9a98a401835e41bee7abc3dee70978c07)) + + +### Bug Fixes + +* allow oversized images to be scrollable ([8ff67d2](https://github.com/macite/doubtfire-deploy/commit/8ff67d2f8e483813bc3af0dc2429b58fe16f8f2b)) +* avoid fetching twice if no comments are cached yet ([d219d49](https://github.com/macite/doubtfire-deploy/commit/d219d49ca6457119084d4c3d2cb39343dece5aa0)) +* decode url from qr code ([31822ab](https://github.com/macite/doubtfire-deploy/commit/31822abaa4c66e2dcbc3ab74c71fe0f6e9b5861d)) +* discussed context type ([d87e620](https://github.com/macite/doubtfire-deploy/commit/d87e620ab54aa75f03d8295f0319798214ad39f0)) +* display load more button only if there are more tasks ([84d5bca](https://github.com/macite/doubtfire-deploy/commit/84d5bcaa4f6329ed34f30a58d88e18fce9c3a76d)) +* enable unit name text wrapping ([5f779b8](https://github.com/macite/doubtfire-deploy/commit/5f779b8cb626ada2e8b6488812424813cc5e9913)) +* ensure jplag iframe extends to full height ([7ae6466](https://github.com/macite/doubtfire-deploy/commit/7ae6466e6586ceaf58edec8a741c9e2f8a737c0c)) +* ensure scanner doesn't break on ios ([a0b010e](https://github.com/macite/doubtfire-deploy/commit/a0b010ef31161b6cf2ab1038deb82c199ffb9ecb)) +* improve task similarity notice ui ([ba26841](https://github.com/macite/doubtfire-deploy/commit/ba2684192c2a8a139237affc4c0702af901f1808)) +* match comments-modals's original max width ([7edb445](https://github.com/macite/doubtfire-deploy/commit/7edb445d7bc5f61daba1b47a3a36b633b551267e)) +* merge conflict ([e599fe3](https://github.com/macite/doubtfire-deploy/commit/e599fe35ec338cfd7f5ffc419a3379d3f3160312)) +* show edit icon for authors only ([a83a875](https://github.com/macite/doubtfire-deploy/commit/a83a875f25efbe9297fe76d773ef4ff55456bcf8)) +* support markdown ([3ac10f6](https://github.com/macite/doubtfire-deploy/commit/3ac10f6cab39dab42ce19f61029488d220786703)) +* **task-comment-composer:** deleting the key when text is empty ([6ef91e0](https://github.com/macite/doubtfire-deploy/commit/6ef91e0ddb119e03dbaf2b6e53e450e5bcc58b8b)) +* update discussion route ([b5c35d9](https://github.com/macite/doubtfire-deploy/commit/b5c35d9cb0be11a6c6cbd49d9e6aee37bb5b83cd)) +* welcome page redirect issues ([14ad26e](https://github.com/macite/doubtfire-deploy/commit/14ad26e8ed4acf6f77043447a2691360fd62ce51)) + +## [10.0.0-6](https://github.com/macite/doubtfire-deploy/compare/v10.0.0-5...v10.0.0-6) (2025-06-13) + + +### Features + +* add spec con and flexible dates ([a874cff](https://github.com/macite/doubtfire-deploy/commit/a874cff846e788a1820da7e19899ecb20a113888)) + + +### Bug Fixes + +* only get task feedback templates if staff ([8341b93](https://github.com/macite/doubtfire-deploy/commit/8341b933487ccd451a15cb8f7ec6afc1695cb121)) +* remove unnecessary safe pipe ([c92242a](https://github.com/macite/doubtfire-deploy/commit/c92242ad9afb0a934d4e8fb00afee0c69782c625)) + +## [10.0.0-5](https://github.com/macite/doubtfire-deploy/compare/v10.0.0-4...v10.0.0-5) (2025-05-30) + + +### Bug Fixes + +* correct broken transition hooks ([d237eda](https://github.com/macite/doubtfire-deploy/commit/d237eda82dd989fc1b69d6e4236932050bc5b1a4)) +* delay authorisation code until after authentication ([34410d4](https://github.com/macite/doubtfire-deploy/commit/34410d4afdf5bd73d9c244a6f40c7fb78b714e63)) +* ensure transition in student pages update pdf view ([68a2f38](https://github.com/macite/doubtfire-deploy/commit/68a2f38167c792974aafd907f04fcf9e8b5bf091)) +* simplify setting of auto redirect on login ([60dbceb](https://github.com/macite/doubtfire-deploy/commit/60dbceb2a104894b4eff157c038446796bf01708)) + +## [10.0.0-4](https://github.com/macite/doubtfire-deploy/compare/v10.0.0-3...v10.0.0-4) (2025-04-15) + + +### Bug Fixes + +* ensure sign in waits for global load ([fb6e61f](https://github.com/macite/doubtfire-deploy/commit/fb6e61f874797cd48e77f2a8d0a32f345f5b785b)) + +## [10.0.0-3](https://github.com/macite/doubtfire-deploy/compare/v10.0.0-2...v10.0.0-3) (2025-04-10) + + +### Bug Fixes + +* correct auth callback ([2e087d2](https://github.com/macite/doubtfire-deploy/commit/2e087d267c8bfde633550823aed460379a643ad9)) + +## [10.0.0-2](https://github.com/macite/doubtfire-deploy/compare/v10.0.0-1...v10.0.0-2) (2025-04-10) + + +### Bug Fixes + +* correct handling of errors on auth ([622768a](https://github.com/macite/doubtfire-deploy/commit/622768a06112910d5ba79061689fc0ba486982a5)) + +## [10.0.0-1](https://github.com/macite/doubtfire-deploy/compare/v8.0.37...v10.0.0-1) (2025-04-10) + + +### Features + +* add comment text to comment area ([5c45415](https://github.com/macite/doubtfire-deploy/commit/5c45415eafb38e221963781e4575480294d74e09)) +* add connected learning outcomes ([9532d9f](https://github.com/macite/doubtfire-deploy/commit/9532d9ffe620d67d7bc83137d59e0a91ecf8a73a)) +* add editor for task feedback templates ([3da7a4b](https://github.com/macite/doubtfire-deploy/commit/3da7a4b54a5892ea99cd365ae439677b0f52ed6f)) +* add feedback template picker to comment composer ([9acc365](https://github.com/macite/doubtfire-deploy/commit/9acc36502dc3fd2365817f73ccc742dd3947e293)) +* add field to connect outcomes ([db2d2bc](https://github.com/macite/doubtfire-deploy/commit/db2d2bc32b19165bac8ff6ba0282a16adffbb815)) +* add glo editor ([4efe876](https://github.com/macite/doubtfire-deploy/commit/4efe87694a426c4b6a55ef943576fe47498166e5)) +* add greeting and summary chip ([ed68317](https://github.com/macite/doubtfire-deploy/commit/ed683174ab7b330a48b2aeacdd65012396357479)) +* add option for institution logo ([5141529](https://github.com/macite/doubtfire-deploy/commit/5141529b1ed37f8ca254e89e1b91edac3139f166)) +* add startpage property to auto scroll to on pdf render ([90830cd](https://github.com/macite/doubtfire-deploy/commit/90830cdba73bcfe564362015b5abaf2c40810f5f)) +* combine outcome and template editor ([c12536b](https://github.com/macite/doubtfire-deploy/commit/c12536bc4375b1ae5f19d4e54d0783b6948921eb)) +* edit feedback chips ([04f8dce](https://github.com/macite/doubtfire-deploy/commit/04f8dce30f79d86637abd6db2aa9fa3aa14d1de2)) +* **feedback-template:** add hierarchical sorting for feedback templates ([8db1844](https://github.com/macite/doubtfire-deploy/commit/8db1844402d080902a9d3938ecc04dd7a2f0340f)) +* **feedback-template:** improve sorting logic for feedback templates ([d3f156a](https://github.com/macite/doubtfire-deploy/commit/d3f156a640e6a3da99c147c4c2303a1af3050f8f)) +* **feedback-template:** merge upstream changes ([0fb1d8a](https://github.com/macite/doubtfire-deploy/commit/0fb1d8abfa3264befddd97e1ce0832359866919d)) +* fetch feedback chips when selecting task in unit editor ([9caeda4](https://github.com/macite/doubtfire-deploy/commit/9caeda4e71946ddb7907027ebf7a54397f11606f)) +* init pdf viewer on page 2 for student submissions ([45cc89e](https://github.com/macite/doubtfire-deploy/commit/45cc89e18cff8fcde66480448eea178940d316a2)) +* make template editor reusable across contexts ([a42f177](https://github.com/macite/doubtfire-deploy/commit/a42f177f61b3f7166edf3b9c93dfd2dcd3949df7)) +* modify fields and operate on learning outcomes ([65ffc4f](https://github.com/macite/doubtfire-deploy/commit/65ffc4f8d61a9d0b2cc7c5f8d13db932436fc4f5)) +* persistent pdf zoom level ([1348da2](https://github.com/macite/doubtfire-deploy/commit/1348da2d345b080002a62a24d717fac15c4b0a92)) +* rebuild auth with refresh token ([5d3a36e](https://github.com/macite/doubtfire-deploy/commit/5d3a36e8f16e8993c5607241112c490361a31c97)) +* show chips on picker ([e54216f](https://github.com/macite/doubtfire-deploy/commit/e54216f8472b67cf9f2ff69c9bcb3355efa90081)) +* suggest task status according to feedback ([95796da](https://github.com/macite/doubtfire-deploy/commit/95796da4e7855bcd76023754d95cec8d033668fc)) + + +### Bug Fixes + +* adjust ui for nested csv modal ([d7fac6f](https://github.com/macite/doubtfire-deploy/commit/d7fac6f0fb23f060373fd4c40f5e82b960098c77)) +* check difference before updating linked outcomes ([aa0bc76](https://github.com/macite/doubtfire-deploy/commit/aa0bc76d32656dc14d28ebca520e4f90d6d9434e)) +* check for unit context for template csv name ([6ecec84](https://github.com/macite/doubtfire-deploy/commit/6ecec8483222304161f9cda87a8bdf3ecd774bcb)) +* comment console logs ([e86162d](https://github.com/macite/doubtfire-deploy/commit/e86162d1592d8418bab3c889b1d0bbfe3662b86d)) +* correct a typo in /edit_profile ([4b7901f](https://github.com/macite/doubtfire-deploy/commit/4b7901fd03d9652230aecb52791313f052d7b53d)) +* correct string manipulation for task period formatting ([aa06cbf](https://github.com/macite/doubtfire-deploy/commit/aa06cbf3a08efa02f508062d47c127289fc8c6fa)) +* display feedback picker chips in color ([6ae0771](https://github.com/macite/doubtfire-deploy/commit/6ae077149d16e5af538d432a9ce2db974298b549)) +* display nested feedback groups in picker ([db93b18](https://github.com/macite/doubtfire-deploy/commit/db93b187cfe6e197a3b6243445809ec6d767d4bd)) +* display task status in coloured chip in feedback editor ([73986e8](https://github.com/macite/doubtfire-deploy/commit/73986e89839603b19f643404e5a48964ac9a3c1b)) +* exclude greeting and summary from selected ([1978e54](https://github.com/macite/doubtfire-deploy/commit/1978e5410f6aa9314252ae3cf922917033f551f0)) +* feedback picker search ([3a5e02e](https://github.com/macite/doubtfire-deploy/commit/3a5e02e2f7864b9e1ea4f8d49ce69d23addbcc5a)) +* **feedback-template-editor:** add missing operator ([a1f220c](https://github.com/macite/doubtfire-deploy/commit/a1f220c9f5462a2b91035eb936f3ff25b7d4cd67)) +* **feedback-template-editor:** improve feedback template sorting logic ([bd48c40](https://github.com/macite/doubtfire-deploy/commit/bd48c40c4c3b0a3d67d41373b624ed8b3b754f4a)) +* **feedback-template:** ensure sorting defaults are applied ([96f7555](https://github.com/macite/doubtfire-deploy/commit/96f7555f9dfcf4c62ee3dd54b9e9e74087bec47a)) +* focus on composer input after selecting chip ([95afdff](https://github.com/macite/doubtfire-deploy/commit/95afdff12aac914bb8af8727f8b8d960bc3b072c)) +* hide feedback group comment text ([55a6625](https://github.com/macite/doubtfire-deploy/commit/55a662584c4b8b6123335bcbc5149a9f0f0701d8)) +* hide picker on task change ([198b7f9](https://github.com/macite/doubtfire-deploy/commit/198b7f9d0ccad631059ed5e1ad5487604582074b)) +* hide summary instead of comment text for groups ([7644cba](https://github.com/macite/doubtfire-deploy/commit/7644cba92f92f5c261501c1907f7f5418538e3a7)) +* hide template editor on outcome switch ([a3706e9](https://github.com/macite/doubtfire-deploy/commit/a3706e9394b78edf9ec15030246c98acca9d3852)) +* lock ng2-pdf-viewer to 10.2.2 ([2cf6215](https://github.com/macite/doubtfire-deploy/commit/2cf62152524ec728c73aaa23f678bbce9ae6b4d1)) +* only get feedback chips if staff ([7f4381e](https://github.com/macite/doubtfire-deploy/commit/7f4381e1179400642821068d1acdba346a2969a2)) +* organise outcome and feedback csv functionality ([641f1d9](https://github.com/macite/doubtfire-deploy/commit/641f1d9f75177d48b3911c300e23c901e0a603e1)) +* prevent saving empty items ([0eed547](https://github.com/macite/doubtfire-deploy/commit/0eed5477940db511cee09bbdb7ccb0adde57cbbf)) +* refine feedback editor ([f410cfe](https://github.com/macite/doubtfire-deploy/commit/f410cfe47b0c242f1a164c6345bc4d29f5659f6a)) +* refresh context when changing task defs ([c078bcf](https://github.com/macite/doubtfire-deploy/commit/c078bcf40198c5495d4c94354fab411d3e0ee934)) +* remove duplicate alert ([955d0a1](https://github.com/macite/doubtfire-deploy/commit/955d0a17ae23be77954145ba189ac8bb0aa90da0)) +* remove glo outcome specific csv buttons ([579c491](https://github.com/macite/doubtfire-deploy/commit/579c49170808a6fcca1bfd67f0923f0339255463)) +* remove selected outcomes from dropdown ([5494384](https://github.com/macite/doubtfire-deploy/commit/5494384f1a4becf462dd031e89fae2734ed46e55)) +* reply to position and wrapping ([e065c46](https://github.com/macite/doubtfire-deploy/commit/e065c46c3fe6689a7b295a3f91a576eabc11f7bd)) +* reset template table for new outcome ([7585c1c](https://github.com/macite/doubtfire-deploy/commit/7585c1c60ca8fe168084ec3d857aec4856bfb0db)) +* save connected outcomes ([eb3cf6d](https://github.com/macite/doubtfire-deploy/commit/eb3cf6daf99b893642336efb48ed58bc9c8b8207)) +* save linked outcomes upon selection ([07f103d](https://github.com/macite/doubtfire-deploy/commit/07f103de46defa2a78f5d7c43030e67255c0d5fe)) +* save to proper cache and clean up ([80edff2](https://github.com/macite/doubtfire-deploy/commit/80edff2ef04137b3d38ccdd252788cfb40f458f0)) +* show description for connected outcome selection ([0f2c164](https://github.com/macite/doubtfire-deploy/commit/0f2c164579491298b81849cc110301229f2ca5af)) +* show picker above composer and below comments ([ef524f1](https://github.com/macite/doubtfire-deploy/commit/ef524f1b048b3bfe1781307a34e1a5fe364943a4)) +* show proper selected outcome or template ([0ac3881](https://github.com/macite/doubtfire-deploy/commit/0ac3881e7f6d0f5b7ddeb85e5e8f9e6835c5dba6)) +* support hot reloading on windows ([247330e](https://github.com/macite/doubtfire-deploy/commit/247330e46d74186519dbcbbef8378b508eaca83f)) +* support windows build ([3594528](https://github.com/macite/doubtfire-deploy/commit/35945288c73052afd28209a7381335af2b14ba10)) +* template editor not showing if new ([6cfe379](https://github.com/macite/doubtfire-deploy/commit/6cfe37999fa7f484e1b89f4dbe657b99907adae1)) +* tlo csv url ([73588d8](https://github.com/macite/doubtfire-deploy/commit/73588d886118861bbd3e5ae380cddc7baa532dbb)) +* update packages ([e5a57f8](https://github.com/macite/doubtfire-deploy/commit/e5a57f8ba3669345a272b308b0c892fdab4843ef)) +* upon save update entity in frontend ([a128744](https://github.com/macite/doubtfire-deploy/commit/a1287449b945295ced60e86a21bcaab67ed0f9bd)) +* use icon instead of color for template task status ([11a3993](https://github.com/macite/doubtfire-deploy/commit/11a399324ee64af901f44f28de3526cbd7650bac)) +* use task status color for recommended button ([174cde0](https://github.com/macite/doubtfire-deploy/commit/174cde0022d0c728d2c6408d40129d6c343aa91d)) + +### [8.0.37](https://github.com/macite/doubtfire-deploy/compare/v8.0.36...v8.0.37) (2025-03-10) + + +### Bug Fixes + +* ensure overseer download get resources zip ([a1e4d71](https://github.com/macite/doubtfire-deploy/commit/a1e4d71b34733bc53cd2cfa63ee1e70f8f43386f)) + +### [8.0.36](https://github.com/macite/doubtfire-deploy/compare/v8.0.35...v8.0.36) (2025-03-07) + + +### Bug Fixes + +* overseer image admin in unit and task ([72c89b3](https://github.com/macite/doubtfire-deploy/commit/72c89b30cca426d56c7efbbac34e00fd734fe714)) + +### [8.0.35](https://github.com/macite/doubtfire-deploy/compare/v8.0.34...v8.0.35) (2025-02-28) + + +### Bug Fixes + +* correct ability to edit teaching periods ([ce37bf2](https://github.com/macite/doubtfire-deploy/commit/ce37bf27689744a6f43be46912be0d14e2cdbc6b)) +* ensure an old grade is present in portfolio assessment ([7daacd1](https://github.com/macite/doubtfire-deploy/commit/7daacd1bf0af050aeb6db503d963e574f68b9a94)) +* ensure overseer images can be edited ([3479b7a](https://github.com/macite/doubtfire-deploy/commit/3479b7af589b9ba986ddbb7dc8a84fd0be3e66e6)) + +### [8.0.34](https://github.com/macite/doubtfire-deploy/compare/v8.0.33...v8.0.34) (2025-02-12) + + +### Bug Fixes + +* enhance rollover messages ([48348c8](https://github.com/macite/doubtfire-deploy/commit/48348c8e6a7ffdb4186fe3e6d27fe860076922e1)) + +### [8.0.33](https://github.com/macite/doubtfire-deploy/compare/v8.0.32...v8.0.33) (2025-01-29) + + +### Bug Fixes + +* nginx conf redirect for spa ([95c0ca9](https://github.com/macite/doubtfire-deploy/commit/95c0ca9384e5a0fe7e522f61146b711b706712c1)) + +### [8.0.32](https://github.com/macite/doubtfire-deploy/compare/v8.0.31...v8.0.32) (2025-01-29) + + +### Bug Fixes + +* correct issue fetching user from cookie ([0d63775](https://github.com/macite/doubtfire-deploy/commit/0d63775339dcbb648d7c8702b1c3428b8622a704)) + +### [8.0.31](https://github.com/macite/doubtfire-deploy/compare/v8.0.30...v8.0.31) (2025-01-28) + + +### Bug Fixes + +* ensure test attempt work for staff students ([06bfa71](https://github.com/macite/doubtfire-deploy/commit/06bfa714d0b54938231004a29ae11e68fe515d80)) +* serve index.html for unknown paths ([d07ad26](https://github.com/macite/doubtfire-deploy/commit/d07ad26bd7e7bd8ba7c7877a1208a8f82958c72b)) + +### [8.0.30](https://github.com/doubtfire-lms/doubtfire-deploy/compare/v8.0.29...v8.0.30) (2025-01-22) + + +### Features + +* add ability to collect d2l org and grade object ids ([93fd614](https://github.com/doubtfire-lms/doubtfire-deploy/commit/93fd6144c798fd302204728a6c2dd43f74d7bf91)) +* add ability to trigger d2l grade transfer ([fd0cc07](https://github.com/doubtfire-lms/doubtfire-deploy/commit/fd0cc0737682e9aa02af14f858c8a5f5f27f22e7)) +* add success page for oauth callback ([921406e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/921406ed595c7a74cb750bc87d49fac0337bdb8a)) +* check if d2l results available ([2460c88](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2460c88af08740f8626ed71d8851a75af095a03d)) +* d2l integration checks settings ([0a61df5](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0a61df53c9fc187b7462e8564a19641ac1693cf0)) +* switch to html5 mode ([9d017fb](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9d017fbfaa1d5bae7f43da37ec7ae43e1dfafa8f)) + + +### Bug Fixes + +* ensure standard error propagate to the ui ([fd056db](https://github.com/doubtfire-lms/doubtfire-deploy/commit/fd056db47631087d5ef29223bd3dc9037c4b10dd)) +* report better errors during file downloads ([cf87d68](https://github.com/doubtfire-lms/doubtfire-deploy/commit/cf87d68ea3a5433a073ae6db413f8c1f825ada80)) + +### [8.0.29](https://github.com/macite/doubtfire-deploy/compare/v8.0.28...v8.0.29) (2024-10-25) + + +### Features + +* add support for the view language ([151ca04](https://github.com/macite/doubtfire-deploy/commit/151ca04ca8da9059890d7846293bf34ae67f05c5)) + + +### Bug Fixes + +* correct task scroll into view ([7984b39](https://github.com/macite/doubtfire-deploy/commit/7984b393752381e0a219dbbdafc0e7b44c3fcdb3)) + +### [8.0.27](https://github.com/macite/doubtfire-deploy/compare/v8.0.26...v8.0.27) (2024-08-09) + + +### Bug Fixes + +* encode . in the username in scorm player ([1ec1754](https://github.com/macite/doubtfire-deploy/commit/1ec175419ab3ef80f22ac8a6384f6ed40a17c217)) + +### [8.0.26](https://github.com/macite/doubtfire-deploy/compare/v8.0.25...v8.0.26) (2024-08-09) + + +### Bug Fixes + +* register service names in state resolve functions ([64e41f4](https://github.com/macite/doubtfire-deploy/commit/64e41f4aedcf519a2786d7fe4929900f86731896)) + +### [8.0.25](https://github.com/macite/doubtfire-deploy/compare/v8.0.24...v8.0.25) (2024-08-09) + + +### Bug Fixes + +* adjust shortcuts and unregister on inbox destroy ([8a2ce3b](https://github.com/macite/doubtfire-deploy/commit/8a2ce3bfe32d99be1bc0667ca1aba55d36f987d2)) +* switch to control shift ([a0c1185](https://github.com/macite/doubtfire-deploy/commit/a0c1185bf4af3acb8f58c97cc27021c952861603)) + +### [8.0.24](https://github.com/macite/doubtfire-deploy/compare/v8.0.23...v8.0.24) (2024-08-09) + + +### Features + +* add ability to preview scorm test ([7ffbea9](https://github.com/macite/doubtfire-deploy/commit/7ffbea9f56c393d44571c18f30735e090701b554)) +* add new Numbas Feature ([6f1ac4e](https://github.com/macite/doubtfire-deploy/commit/6f1ac4e4c1a78115c426838bce350bce286e44f0)) +* add new Numbas Feature ([8eba913](https://github.com/macite/doubtfire-deploy/commit/8eba913ec4462e1c252a8a698b4b6de67c4ec25f)) +* add numbas component ([7c1734b](https://github.com/macite/doubtfire-deploy/commit/7c1734b137ac369b3b605b38749c845df38b9a78)) +* add Numbas config options to task def service keys ([ff28e48](https://github.com/macite/doubtfire-deploy/commit/ff28e4802b86dc22be339cc196ef82ade67934f7)) +* add Numbas test attempt model ([097df79](https://github.com/macite/doubtfire-deploy/commit/097df7960d34a8c905ee495b8c6c8b8843e43b36)) +* add Numbas test section on ready for feedback ([e61295c](https://github.com/macite/doubtfire-deploy/commit/e61295c50006dbc89cfc0639bb3c68a09a3cda9d)) +* add Numbas test upload section and reorder editor sections ([edbd536](https://github.com/macite/doubtfire-deploy/commit/edbd536e73ace8b6b4cced1481c809fd36524dce)) +* add Numbas upload component and related functions to task-definition model ([4ecaee8](https://github.com/macite/doubtfire-deploy/commit/4ecaee8ad1c0caed9a5d848066a173000989f79b)) +* add test attempt service ([a576f48](https://github.com/macite/doubtfire-deploy/commit/a576f484bc434728eab9632c022bccf1ed26cb01)) +* add test attempt service and minor numbas related changes ([0652b56](https://github.com/macite/doubtfire-deploy/commit/0652b56f69eb850c2dfb43be54d07beb4c4eb469)) +* added numbas-lms service code ([471d344](https://github.com/macite/doubtfire-deploy/commit/471d34486f2582e95aac84469187f087277cc079)) +* allow changing scorm review config and add minor UI changes ([fc023af](https://github.com/macite/doubtfire-deploy/commit/fc023af462656e3557e2970f211fa4d59ee1e3d5)) +* change Numbas time delay config to enable incremental delays ([0afa719](https://github.com/macite/doubtfire-deploy/commit/0afa7197293c90a154c1716db91ecadae3677d53)) +* disable attempt button if passed and add button to review latest attempt in card ([703563c](https://github.com/macite/doubtfire-deploy/commit/703563c86253c60ad30d451ee2d8e0fa7ebbfabb)) +* display numbas task comments ([48a31da](https://github.com/macite/doubtfire-deploy/commit/48a31da1442f1e14f497c614053d9002c9f2631b)) +* enable reviewing, passing, deleting test attempts and add test attempt model and service ([561b924](https://github.com/macite/doubtfire-deploy/commit/561b9241c2f44fd69d3f09c656a025f514bbaf3a)) +* enable students to request extra scorm attempt ([d904ffd](https://github.com/macite/doubtfire-deploy/commit/d904ffd6fd674f6e61894d517f5aa147d1db6d29)) +* get unique token for scorm asset retrieval ([8fe2f9e](https://github.com/macite/doubtfire-deploy/commit/8fe2f9e9ca60c02e55c24d0f2bb3eb6dbea8217c)) +* implement numbas test data upload in task definition service ([2c7dab5](https://github.com/macite/doubtfire-deploy/commit/2c7dab555fc9a226b980a8dae96f5738bf517b1b)) +* insert Numbas test rules options in the task editor ([7e52ad5](https://github.com/macite/doubtfire-deploy/commit/7e52ad5e5759291d7d61805070ec482eb49c90be)) +* numbas-test-numbas-service ([cee13b7](https://github.com/macite/doubtfire-deploy/commit/cee13b727b35f804fc16070885f0e57c422c9982)) +* prevent uploading files until scorm passed ([ec86e4e](https://github.com/macite/doubtfire-deploy/commit/ec86e4eca8e9c50ebdaf54c34c302da88dd44b31)) +* show banner based on scorm success status ([db16172](https://github.com/macite/doubtfire-deploy/commit/db161721fab1359694c44d8df7237cd01892938b)) +* show launch button on ready for feedback if Numbas test is enabled for the task ([17af5b7](https://github.com/macite/doubtfire-deploy/commit/17af5b7fe9e1fbc8e5f18e2fece6e029b017408c)) +* use confirmation modal when passing or deleting test attempts ([3fb25bb](https://github.com/macite/doubtfire-deploy/commit/3fb25bb373acab837a3ef787620dd654bfd6803f)) + + +### Bug Fixes + +* add accepted Numbas file types ([bcaa8af](https://github.com/macite/doubtfire-deploy/commit/bcaa8af150aaf68b5cf5fb07ad9cb72037212d5d)) +* add auth headers to scorm adapter xhr requests ([97e1ea1](https://github.com/macite/doubtfire-deploy/commit/97e1ea187b769a51ebb66b82d76f21193bb29386)) +* adjusted edit profile accidental change ([316abc7](https://github.com/macite/doubtfire-deploy/commit/316abc775eeba6ca846c7c742bc88bec61d05a5e)) +* change success status descriptions ([20da042](https://github.com/macite/doubtfire-deploy/commit/20da0423450fe3b9b8006660bf6f5e06670f520c)) +* delete comment as well as test attempt ([b6887e8](https://github.com/macite/doubtfire-deploy/commit/b6887e85b7a06b166519924dd682ef57650a4e32)) +* disable launch scorm test button if user is staff ([9bc48b0](https://github.com/macite/doubtfire-deploy/commit/9bc48b03905c0a839d78ea13be9817ca73ba54cf)) +* ensure counters are incremented after object creation ([2b1dcfc](https://github.com/macite/doubtfire-deploy/commit/2b1dcfc717eb770dd623c62ac99d8d961d1c3124)) +* ensure datamodel is updated on termination ([2ac487f](https://github.com/macite/doubtfire-deploy/commit/2ac487f13c3743f2b5b805521964bdcbca7cdc15)) +* ensure scorm frame loads when src is available ([6ae4295](https://github.com/macite/doubtfire-deploy/commit/6ae42954162996acf145cef211981153381ff49d)) +* hide config options if numbas test is disabled ([0b15b1c](https://github.com/macite/doubtfire-deploy/commit/0b15b1c38b8f09a30f53bb42dccd1971109bce01)) +* indicate task def has scorm data when zip is uploaded ([4b983ba](https://github.com/macite/doubtfire-deploy/commit/4b983bae1f522013f750b8c36d5e1a52624abbbc)) +* initialise SCORM API wrapper before iframe loads ([5d0606c](https://github.com/macite/doubtfire-deploy/commit/5d0606c56eaeb929d5873cae7038ce729581512c)) +* integrate Numbas services well with the existing system ([d47b8ed](https://github.com/macite/doubtfire-deploy/commit/d47b8edc745660801984346c4fa67d6bb371f4cb)) +* marking shortcuts no longer conflict with common browser shortcuts ([ec7524a](https://github.com/macite/doubtfire-deploy/commit/ec7524aaae040794de76521363c2d885a0425db0)) +* remove attempt number field ([f0ff40b](https://github.com/macite/doubtfire-deploy/commit/f0ff40bfd7e86b904adae94bf8cbe5d56371e011)) +* remove saved scorm token and always get ([c8fc702](https://github.com/macite/doubtfire-deploy/commit/c8fc702bde6f1c448fc585042e5265625552bae8)) +* retrieve test attempt data correctly ([2810ce6](https://github.com/macite/doubtfire-deploy/commit/2810ce65d423a137600d621a98bf27dbc221921c)) +* send task id with numbas completed attempt data ([e46214d](https://github.com/macite/doubtfire-deploy/commit/e46214dd59d86014bb04d1c31f3a3bf21240e32b)) +* show correct attempts left and allow tutor to review attempt always ([58c24c3](https://github.com/macite/doubtfire-deploy/commit/58c24c3a6760af168a918bc099e755f475eac5f0)) +* show correct Numbas test from the task def with all assets loaded ([bee0a0b](https://github.com/macite/doubtfire-deploy/commit/bee0a0bb1eb905c964caf7888419effe70553520)) +* show delete and download buttons in editor when Numbas test exists ([821feb9](https://github.com/macite/doubtfire-deploy/commit/821feb93a8a40d095ef4f50adedbfd9216278fa9)) +* show Numbas button component and modify iframe request ([c9c2fbe](https://github.com/macite/doubtfire-deploy/commit/c9c2fbe10db156512946355f3cabfe54461f0eba)) +* show Numbas iframe on top of other elements ([f53befd](https://github.com/macite/doubtfire-deploy/commit/f53befd82b52bba24bc8c593a9266c11f9155d32)) +* show previously configured Numbas attempt limit ([56e1a5d](https://github.com/macite/doubtfire-deploy/commit/56e1a5de44ee275f9544b77909b7472f1020c2c3)) +* update numbas api path ([3df59dc](https://github.com/macite/doubtfire-deploy/commit/3df59dcc58f021ef16d81938d1d05473818bc75e)) +* use modal for Numbas and enable authentication ([64b1bfb](https://github.com/macite/doubtfire-deploy/commit/64b1bfb2918993e58a6659948d9621fc7d0b8ba4)) +* use nullish coalescing when retrieving data from the datamodel ([226d919](https://github.com/macite/doubtfire-deploy/commit/226d9193251fb675c92bec8265508477857a4ec9)) + +### [8.0.28](https://github.com/macite/doubtfire-deploy/compare/v8.0.23...v8.0.28) (2024-09-05) + + +### Features + +* add support for the view language ([b4b3654](https://github.com/macite/doubtfire-deploy/commit/b4b36541943232f7df3ee4888bf4ca46c4c04018)) + +### [8.0.23](https://github.com/macite/doubtfire-deploy/compare/v8.0.22...v8.0.23) (2024-08-02) + + +### Features + +* allow pdf download and switch to native viewer ([16ac9c8](https://github.com/macite/doubtfire-deploy/commit/16ac9c8bc488607025ee95dbb4b41134a5346b00)) + + +### Bug Fixes + +* ensure overseer menu only shows when active for task ([c04e9fe](https://github.com/macite/doubtfire-deploy/commit/c04e9fe3a824e316a632b3f7b53b08887e57c831)) + +### [8.0.22](https://github.com/macite/doubtfire-deploy/compare/v8.0.21...v8.0.22) (2024-07-26) + + +### Bug Fixes + +* correct duplicate shortcut registration ([dca03a4](https://github.com/macite/doubtfire-deploy/commit/dca03a41f502947136e301e51242570169826dd9)) + +### [8.0.21](https://github.com/macite/doubtfire-deploy/compare/v8.0.20...v8.0.21) (2024-07-24) + + +### Bug Fixes + +* ensure groups can be locked ([b671065](https://github.com/macite/doubtfire-deploy/commit/b67106589b71660bdfe01151deb68d3092b9091f)) + +### [8.0.20](https://github.com/macite/doubtfire-deploy/compare/v8.0.19...v8.0.20) (2024-07-15) + + +### Bug Fixes + +* ensure turn it in eula is accessible by all ([3d44c11](https://github.com/macite/doubtfire-deploy/commit/3d44c115b498366b80e5b8b99ccd3eaa660ff613)) + +### [8.0.19](https://github.com/macite/doubtfire-deploy/compare/v8.0.18...v8.0.19) (2024-07-10) + + +### Features + +* add ability to minimise task details in task viewer ([e904640](https://github.com/macite/doubtfire-deploy/commit/e9046400e827481e09492bcb458b00b4e3aca347)) + + +### Bug Fixes + +* ensure pdf is visible in task viewer mobile/narrow ([f5388e4](https://github.com/macite/doubtfire-deploy/commit/f5388e45715597f0c92f252f721cf274bb8de0d5)) +* task def editor so save is not over fields and header visible for task being edited ([22a2616](https://github.com/macite/doubtfire-deploy/commit/22a26165c9f2ef634b3e79d1e5d5d6b74e6f2731)) + +### [8.0.18](https://github.com/macite/doubtfire-deploy/compare/v8.0.17...v8.0.18) (2024-07-03) + + +### Features + +* add ability to set unit code on import from rollover ([d47d105](https://github.com/macite/doubtfire-deploy/commit/d47d10561bd7bfad495f878ebb0bab399ce144b7)) + +### [8.0.17](https://github.com/macite/doubtfire-deploy/compare/v8.0.16...v8.0.17) (2024-07-01) + + +### Bug Fixes + +* correct broken template in activities list ([4d81fa1](https://github.com/macite/doubtfire-deploy/commit/4d81fa1fabb07e40d0d42068f23b8e756f4cdf4b)) +* correct campus list templates ([1795895](https://github.com/macite/doubtfire-deploy/commit/17958955f65bab2b268826f2a7e19f311071615b)) +* switch date formats to date-fns ([2394efb](https://github.com/macite/doubtfire-deploy/commit/2394efb07c03da18fb8114d87b0c89e68bf7fd2d)) + +### [8.0.16](https://github.com/macite/doubtfire-deploy/compare/v8.0.15...v8.0.16) (2024-06-28) + + +### Bug Fixes + +* ensure drop works on task resource upload in windows ([f0505d7](https://github.com/macite/doubtfire-deploy/commit/f0505d7b62b97f1b821b19b1c4178973503789da)) + +### [8.0.15](https://github.com/macite/doubtfire-deploy/compare/v8.0.14...v8.0.15) (2024-06-28) + + +### Bug Fixes + +* ensure zip uploads work on windows ([1857bdc](https://github.com/macite/doubtfire-deploy/commit/1857bdc708c81dc39d467f42c9e316a8664dc080)) + +### [8.0.14](https://github.com/macite/doubtfire-deploy/compare/v8.0.13...v8.0.14) (2024-06-27) + + +### Bug Fixes + +* ensure unit import allows unit code change ([01013d3](https://github.com/macite/doubtfire-deploy/commit/01013d3d16ab5b704acc589d34975693030def0b)) +* ensure unit load sets main convenor user in all cases ([4c96624](https://github.com/macite/doubtfire-deploy/commit/4c966245d270af46107aae94bdd38e84b2188b65)) + +### [8.0.13](https://github.com/macite/doubtfire-deploy/compare/v8.0.12...v8.0.13) (2024-06-27) + + +### Bug Fixes + +* update discuss text to promote use ([7b5d35c](https://github.com/macite/doubtfire-deploy/commit/7b5d35c89a577d0408a51d8390400f44615591eb)) + +### [8.0.12](https://github.com/macite/doubtfire-deploy/compare/v8.0.11...v8.0.12) (2024-06-25) + + +### Bug Fixes + +* ensure null fields will not break in entity mapping ([32046e8](https://github.com/macite/doubtfire-deploy/commit/32046e89eaaf2a9b36e88f078a7b695b1506886d)) +* reinstate unit import for teaching period ([991230f](https://github.com/macite/doubtfire-deploy/commit/991230f7b527957116263b6abaa588ed43d3a787)) + +### [8.0.11](https://github.com/macite/doubtfire-deploy/compare/v8.0.10...v8.0.11) (2024-06-24) + + +### Bug Fixes + +* ensure turn it in only appears to task editor when enabled ([3a81688](https://github.com/macite/doubtfire-deploy/commit/3a81688904159c8c9e3efdde8f3605aa5677630f)) +* task date picker allows direct entry ([2f91e8f](https://github.com/macite/doubtfire-deploy/commit/2f91e8fbba7902e1e22871cc82c87f4ff797b058)) + +### [7.0.24](https://github.com/macite/doubtfire-deploy/compare/v7.0.23...v7.0.24) (2024-06-05) + + +### Bug Fixes + +* ensure download blob supports 206 responses ([6445f9f](https://github.com/macite/doubtfire-deploy/commit/6445f9f998db70fbe9b9abf723dc17fd298b5d2f)) + +### [8.0.10](https://github.com/macite/doubtfire-deploy/compare/v7.0.23...v8.0.10) (2024-06-21) + + +### Bug Fixes + +* ensure loading screen removed in sign in component ([729c438](https://github.com/macite/doubtfire-deploy/commit/729c438f7f6a988f5ff2fa4035493b5cae1c98f7)) +* ensure portfolio can get grades from strings ([fd916b9](https://github.com/macite/doubtfire-deploy/commit/fd916b94e9012c0909c5880f377f93d326c18e4d)) +* ensure portfolio only shown when it exists ([ce970f5](https://github.com/macite/doubtfire-deploy/commit/ce970f5fdc4f42708edc71c98dc9d869056e9e2c)) +* ensure tii open report alerts errors ([c5dd45c](https://github.com/macite/doubtfire-deploy/commit/c5dd45c57fd34e8373f11d065a0cd08edef0a794)) +* fix pdf viewer for portfolios ([da376b9](https://github.com/macite/doubtfire-deploy/commit/da376b9bc7905aadeb4d234cbb4f595e4c4424a1)) +* grade icon for portfolio page ([aab1b9c](https://github.com/macite/doubtfire-deploy/commit/aab1b9c3e7365cdf7dd64d767fdb6e5a9a2436a3)) +* show burndown and pie chart on portfolio page ([e70f4c7](https://github.com/macite/doubtfire-deploy/commit/e70f4c7cd1395eaab942ee389788f75f92e985c9)) + +### [8.0.9](https://github.com/macite/doubtfire-deploy/compare/v7.0.22...v8.0.9) (2024-05-31) + + +### Bug Fixes + +* comment out fix and complete hotkeys temporarily ([c1f623d](https://github.com/macite/doubtfire-deploy/commit/c1f623d17432f3563366ef32fc3da3b54dbef0b7)) + +### [8.0.8](https://github.com/macite/doubtfire-deploy/compare/v8.0.7...v8.0.8) (2024-05-30) + + +### Features + +* added percentage to tooltip when hovering student's task progress ([69ed613](https://github.com/macite/doubtfire-deploy/commit/69ed61380bd7681d4496cb109e628111873b6e82)) + + +### Bug Fixes + +* change from hiding element to not loading ([414da65](https://github.com/macite/doubtfire-deploy/commit/414da65fcef9b10367f53112239a60e1b271d475)) +* change grade values, change grade display ([081c35b](https://github.com/macite/doubtfire-deploy/commit/081c35ba0fbc7ab4ddb2fba3c58892d0e66f325c)) +* fix task inbox shortcuts ([9bc949f](https://github.com/macite/doubtfire-deploy/commit/9bc949fd6dcb71ea165e89de9a4ec9d9347e6562)) +* inbox search box width ([02a99a8](https://github.com/macite/doubtfire-deploy/commit/02a99a8f71033014b26260d3b0dbfca075ca7273)) +* remove pdf viewer on task view when no sheet ([f3e973d](https://github.com/macite/doubtfire-deploy/commit/f3e973dacc5b079ab58da239194d802c5586ca07)) +* revert index changes, html changes for grade ([8f41cd1](https://github.com/macite/doubtfire-deploy/commit/8f41cd1656971963c2035a9249acdde0a257766e)) +* task list scrolling ([0088e9e](https://github.com/macite/doubtfire-deploy/commit/0088e9e245dbd326dea4032239b53eee88754179)) +* use tailwind classes instead of css styling ([42434a5](https://github.com/macite/doubtfire-deploy/commit/42434a5fd4b866781da5d23ecdb0b9b4369aace1)) + +### [8.0.7](https://github.com/macite/doubtfire-deploy/compare/v8.0.6...v8.0.7) (2024-05-26) + + +### Bug Fixes + +* add task description editing ([ee0cfbf](https://github.com/macite/doubtfire-deploy/commit/ee0cfbf7ce804a1806ca5810bad138b97703ebfe)) + +### [8.0.6](https://github.com/macite/doubtfire-deploy/compare/v8.0.5...v8.0.6) (2024-05-25) + + +### Bug Fixes + +* ensure all admin units are fully loaded ([917325e](https://github.com/macite/doubtfire-deploy/commit/917325ed08b89690a7eea0716438673732ba1ec9)) + +### [8.0.5](https://github.com/macite/doubtfire-deploy/compare/v8.0.4...v8.0.5) (2024-05-25) + + +### Bug Fixes + +* ensure (click) is on button, not mat-icon ([99c717d](https://github.com/macite/doubtfire-deploy/commit/99c717d2333de0975da1b3f282195a0b3577a3f6)) + +### [8.0.4](https://github.com/macite/doubtfire-deploy/compare/v8.0.3...v8.0.4) (2024-05-25) + + +### Bug Fixes + +* fix various issues with admin unit list ([3720666](https://github.com/macite/doubtfire-deploy/commit/3720666fb4105bac274449eb468ce34e0f68bae7)) + +### [8.0.3](https://github.com/macite/doubtfire-deploy/compare/v8.0.2...v8.0.3) (2024-05-25) + + +### Features + +* add 4-character abbreviations ([eb62dfc](https://github.com/macite/doubtfire-deploy/commit/eb62dfc90db836870d6c396727fc43c149808e9d)) + +### [8.0.2](https://github.com/macite/doubtfire-deploy/compare/v8.0.1...v8.0.2) (2024-05-25) + + +### Bug Fixes + +* gradeservice bugs ([fd6eb1c](https://github.com/macite/doubtfire-deploy/commit/fd6eb1cd152b72cf297cbd6bd529579858afc543)) +* gradeservice bugs ([7545eb9](https://github.com/macite/doubtfire-deploy/commit/7545eb92b9353d99ac8d8d7d89982cdadf9688b1)) + +### [8.0.1](https://github.com/macite/doubtfire-deploy/compare/v8.0.0...v8.0.1) (2024-05-24) + + +### Features + +* implement live PDF search that updates dynamically as you type ([b9b9945](https://github.com/macite/doubtfire-deploy/commit/b9b9945346d55392e5ee9a6431d85ae92de1bde5)) + + +### Bug Fixes + +* fix critical bugs ([356846e](https://github.com/macite/doubtfire-deploy/commit/356846e5c385c484a6034553366965cde95ec78b)) +* fix splashscreen ([2692b7f](https://github.com/macite/doubtfire-deploy/commit/2692b7f4a3e1ca2a5bc4df3b82a9dfd4aa2ec68a)) + +## [8.0.0](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-6...v8.0.0) (2024-05-23) + + +### Features + +* add multi-badge support ([44c1473](https://github.com/macite/doubtfire-deploy/commit/44c1473700a3d7a4a025d68acac5aa61189d8b52)) +* add synchronised multi-badge ([f70ebdc](https://github.com/macite/doubtfire-deploy/commit/f70ebdca92a05e5ca350d4062a8dc8efba9fd681)) +* add working unit-badge ([dd30247](https://github.com/macite/doubtfire-deploy/commit/dd302478e97228e6bfbfdbee0134abb3eabf2587)) +* add working unit-badge ([c7338ff](https://github.com/macite/doubtfire-deploy/commit/c7338ffd88edb457a3443b43ea21706c43231973)) +* qol improvements for unit codes ([f7f928e](https://github.com/macite/doubtfire-deploy/commit/f7f928e485448a69b054dca94f07d623f84e328f)) +* small fixes to home and unit code ([918e804](https://github.com/macite/doubtfire-deploy/commit/918e8047745b489aae1fafdc67720568dd8d829e)) + + +### Bug Fixes + +* add missing imports ([659b52c](https://github.com/macite/doubtfire-deploy/commit/659b52c82a26b9496a3dba81401b5147d05248ae)) +* change time exceeded to feedback exceeded ([673619d](https://github.com/macite/doubtfire-deploy/commit/673619d99f177d834fba3643171426092e0d05e0)) +* correct task resource upload actions ([5e9c9b1](https://github.com/macite/doubtfire-deploy/commit/5e9c9b1d05d6d6975fd4ba3a3176314ddd7d8514)) +* ensure loading state changed after projects and unit roles load ([9171d0c](https://github.com/macite/doubtfire-deploy/commit/9171d0c2c37c17d26f464f6aa0ba7c479851e6eb)) +* ensure staff task list checks unit exists ([dbb312d](https://github.com/macite/doubtfire-deploy/commit/dbb312dc3c5c9e2be24161914e8d95e1a5c1bc53)) +* ensure unit load updates unit object if it already exists ([e48ffad](https://github.com/macite/doubtfire-deploy/commit/e48ffadf63891212c3e40ab696d54a3935485b08)) +* hide overseer settings if automation disabled ([0c08fe3](https://github.com/macite/doubtfire-deploy/commit/0c08fe3e82a78dd1e91fb44ce6008fb5298c46ee)) +* slide unit code off left side ([8cd57b5](https://github.com/macite/doubtfire-deploy/commit/8cd57b589812fd59c6da4de02246791fb4410959)) +* sort units desc by start date ([9b78856](https://github.com/macite/doubtfire-deploy/commit/9b788561372a3ddf065d3b28e547a2194ef29655)) +* zip upload for overseer ([5ad2085](https://github.com/macite/doubtfire-deploy/commit/5ad2085b2d730235f61e7b7c603727d8b6736cf2)) + +## [8.0.0-6](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-5...v8.0.0-6) (2024-05-13) + +## [8.0.0-5](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-4...v8.0.0-5) (2024-05-13) + +## [8.0.0-4](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-3...v8.0.0-4) (2024-05-11) + + +### Features + +* add task badge ([b48cd4c](https://github.com/macite/doubtfire-deploy/commit/b48cd4c99333e41c0c1d32531e571d32bb482a81)) + + +### Bug Fixes + +* (WIP) fix alert service migration ([3d9f4dc](https://github.com/macite/doubtfire-deploy/commit/3d9f4dc05e7a45884dc383e0f5ed4bdb09b0558d)) +* add max length validator and change reason field to text-area ([6227200](https://github.com/macite/doubtfire-deploy/commit/6227200566c3f608c279e92a3f255a7e94148c1d)) +* address linter issues in task.ts ([14e9699](https://github.com/macite/doubtfire-deploy/commit/14e9699bdfe336f1e3f3cce3d722c835d58f3cdb)) +* copy dist browser to nginx html ([08fa919](https://github.com/macite/doubtfire-deploy/commit/08fa91937e028cb3ee093709e5a31f94da44fc33)) +* correct filenames for grotesk fonts ([9bedd81](https://github.com/macite/doubtfire-deploy/commit/9bedd812b12628df9cb32e48cb91a88b5cdf69b1)) +* ensure eula state remains visible ([131540c](https://github.com/macite/doubtfire-deploy/commit/131540ca4b934773afd9f72aa2578579048ed6a2)) +* ensure unit admin shows all units ([02b7a15](https://github.com/macite/doubtfire-deploy/commit/02b7a15bbbd2306a61e344f995dffb72a3915498)) +* fix course progress bar on firefox ([ae5c1e0](https://github.com/macite/doubtfire-deploy/commit/ae5c1e0b5901bae0411cf7bbaa32ee8f340b908a)) +* fix incorrect projcets listed in dropdown ([6a7e478](https://github.com/macite/doubtfire-deploy/commit/6a7e4788b522e5af6331a82ed33e9c9137d2ca86)) +* fix margin issue on dropdown ([c8145f0](https://github.com/macite/doubtfire-deploy/commit/c8145f0972a03e52377e0203cabb2cf6353db8b3)) +* grade and quality point display on task-assessment-card ([7cba502](https://github.com/macite/doubtfire-deploy/commit/7cba502e002143e8255bf0d786551bd270f73f0e)) +* indicate mandatory fields in upload-submission-modal ([e8801e2](https://github.com/macite/doubtfire-deploy/commit/e8801e2a64fe15ad423d9017d10e5e23f6e00eda)) +* only check grade if it exists ([1c213c5](https://github.com/macite/doubtfire-deploy/commit/1c213c53571f79563e9a071e2dd9645598382522)) +* remove unused imports in extension-modal.service ([8081f51](https://github.com/macite/doubtfire-deploy/commit/8081f51b5f28ea511b9f76d026ae63f16153ae2c)) +* use tailwindcss classes ([8d7e160](https://github.com/macite/doubtfire-deploy/commit/8d7e160e2d40c42284ee0c1fc85d6d9e01166cda)) + +## [8.0.0-3](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-2...v8.0.0-3) (2024-05-02) + + +### Bug Fixes + +* reenable eula html fetch ([cda9e52](https://github.com/macite/doubtfire-deploy/commit/cda9e52a259bfaa27e8252f40e579d11d1af280c)) + +## [8.0.0-2](https://github.com/macite/doubtfire-deploy/compare/v7.0.21...v8.0.0-2) (2024-05-01) + + +### Features + +* accept promela files ([df93a69](https://github.com/macite/doubtfire-deploy/commit/df93a69e45d2d9e41b9cf0802d521a3663d7a36c)) +* migrate unit list in all locations ([6651d00](https://github.com/macite/doubtfire-deploy/commit/6651d0011446fdde7179c5173f902ce2e4ea7545)) +* remove misleading progress statement ([63e276c](https://github.com/macite/doubtfire-deploy/commit/63e276c041f77d8b401d24a4f49896cfa2095442)) + + +### Bug Fixes + +* correct change detection on task def ([55daff2](https://github.com/macite/doubtfire-deploy/commit/55daff234441a242d96de097c2b2912df0c6c9a8)) +* correct npm install for dev container image ([5126c3b](https://github.com/macite/doubtfire-deploy/commit/5126c3ba7fed55217e88c54c25dc70595ec4c666)) +* ensure npm will force install ([f91751e](https://github.com/macite/doubtfire-deploy/commit/f91751eae788bc3f65d33b610ed80c96d3978974)) +* fix firefox-specific bug ([6b6054b](https://github.com/macite/doubtfire-deploy/commit/6b6054bd5c1104a96ccac413a7115009eeba8d2f)) +* remove ngClass ([d1d8c72](https://github.com/macite/doubtfire-deploy/commit/d1d8c72ace21551a68bd4d0a8ba88eabb2ebdb9d)) +* remove plagiarism checks ([21b02fb](https://github.com/macite/doubtfire-deploy/commit/21b02fb7cc616090f1fb2d26f65b336072f9105b)) +* remove the incorrect option from for scrollIntoView() ([9e752fe](https://github.com/macite/doubtfire-deploy/commit/9e752fe9d49df786ceb3e7007cbf0aad57809604)) +* remove unused loading from f-units ([c4c38fa](https://github.com/macite/doubtfire-deploy/commit/c4c38fad4fbdfe248e383cf247f949197ad0a047)) +* typo ([820c56f](https://github.com/macite/doubtfire-deploy/commit/820c56f3a1bc30b7637b98e6f80b406ca350b5b8)) + +## [8.0.0-1](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-0...v8.0.0-1) (2024-03-21) + + +### Bug Fixes + +* update deploy dockerfile ([41a5161](https://github.com/macite/doubtfire-deploy/commit/41a5161b356931eafd8e4caf256f311f10bc8bb4)) + +## [8.0.0-0](https://github.com/macite/doubtfire-deploy/compare/v7.0.18...v8.0.0-0) (2024-03-21) + + +### Features + +* (wip) add similarities notification ([8e4a198](https://github.com/macite/doubtfire-deploy/commit/8e4a198a037de832bbebe96e309c4c8485d076cc)) +* add ability to accept tii eula to model ([0106b01](https://github.com/macite/doubtfire-deploy/commit/0106b01ed249139cbe06096f95a36b5d11bcd0ac)) +* add ability to accept turn it in eula ([380ad1e](https://github.com/macite/doubtfire-deploy/commit/380ad1e93252f8f928893626b65f4dab01813524)) +* add ability to open turnitin viewer ([5248e80](https://github.com/macite/doubtfire-deploy/commit/5248e80622e55b79ce4d7b35f8202ee8099c2dce)) +* add ability to update similarity flag ([de005ee](https://github.com/macite/doubtfire-deploy/commit/de005ee98a270998e2175109783cab72b92e36f3)) +* add dynamic footer ([3c62fe0](https://github.com/macite/doubtfire-deploy/commit/3c62fe06d009c3b752259f50887c625c98a371cd)) +* add empty pdf view to new task dashboard ([9c3cdfb](https://github.com/macite/doubtfire-deploy/commit/9c3cdfbb93711a6ae911ed461651200d8c75db48)) +* add file drop component ([c211003](https://github.com/macite/doubtfire-deploy/commit/c211003004ae6c7505158a65ce2006b219920827)) +* add footer content ([735c43b](https://github.com/macite/doubtfire-deploy/commit/735c43b5c2df7e8f367310ad92ef716fd5140375)) +* add link to student task from inbox ([d4301e0](https://github.com/macite/doubtfire-deploy/commit/d4301e0d1f2f6ec4c563801a7f0d1132884f9989)) +* add mobile inbox view ([56ded70](https://github.com/macite/doubtfire-deploy/commit/56ded707e638ae769665a863ce65acffea5a588a)) +* add multi-file support to f-upload ([d181969](https://github.com/macite/doubtfire-deploy/commit/d181969198e2b7611e3f0e330577b825e99a248f)) +* add new file upload component ([891ab20](https://github.com/macite/doubtfire-deploy/commit/891ab20eb6f1d545ff58d0157fdd9912b9546c9f)) +* add new inbox component ([ec0f62c](https://github.com/macite/doubtfire-deploy/commit/ec0f62c488ae3e99e9487ea1ab87f5bb2f1fbed7)) +* add new pdf-viewer ([617fbd2](https://github.com/macite/doubtfire-deploy/commit/617fbd24f8424541aeb87b16651f06737b570633)) +* add new similarity component ([48ba01c](https://github.com/macite/doubtfire-deploy/commit/48ba01c8d780a04a1f526f89fcd9bc63ef5d4aec)) +* add new task assessment card ([ac3b50f](https://github.com/macite/doubtfire-deploy/commit/ac3b50f29b8a2722d09ad94a2f82bb54c1c32da7)) +* add new task dashboard component ([dc841f9](https://github.com/macite/doubtfire-deploy/commit/dc841f9e7af811a3a56a0c21fd3b3968ec7aead9)) +* add new task due card ([3ade5ea](https://github.com/macite/doubtfire-deploy/commit/3ade5ea34eb3c2c756d7df0f7a6c250fa50290bb)) +* add new task due card content ([c43ea3e](https://github.com/macite/doubtfire-deploy/commit/c43ea3e27abdf253596bfedd5112f5ef7a7b929d)) +* add new task submission card ([6d27690](https://github.com/macite/doubtfire-deploy/commit/6d2769029413f84b73c7c825d16e9a3366142177)) +* add pdf search and zoom ([f83ce57](https://github.com/macite/doubtfire-deploy/commit/f83ce570e80dc20d118288c6d32c2d2a9ca263b5)) +* add resizable inbox panels ([4920e31](https://github.com/macite/doubtfire-deploy/commit/4920e31ce366c3dfb4340ab4da159b176381cf49)) +* add resizable panels to inbox ([5abda03](https://github.com/macite/doubtfire-deploy/commit/5abda039af5b2d3b5e2589c4864b2e1715c5e8c0)) +* add rest of layout to footer ([3b9de7c](https://github.com/macite/doubtfire-deploy/commit/3b9de7cc6d923a0e6431524f2967b5e3bc360a6f)) +* add selected task sheet url ([00a7fb5](https://github.com/macite/doubtfire-deploy/commit/00a7fb516d362d3725b915d012878a9ccf8ef009)) +* add selectedTask service ([02aa41a](https://github.com/macite/doubtfire-deploy/commit/02aa41a34232359d1366d841d1236a068a837ae5)) +* add similarities staff dashboard switch ([1ec0b72](https://github.com/macite/doubtfire-deploy/commit/1ec0b72c26cc27bd4d17376af5b47650d28bbc10)) +* add similarity flags to view ([8c7975c](https://github.com/macite/doubtfire-deploy/commit/8c7975cadd14e6d3390cfa9917983cd01498a69c)) +* add simple project progress bar ([73fda77](https://github.com/macite/doubtfire-deploy/commit/73fda776ee36c6574c1ddc0dc09b958d61de3000)) +* add start of new similarity view ([a1e51b6](https://github.com/macite/doubtfire-deploy/commit/a1e51b6bc7030086a1d7a4d715f23b3e76f7ddb3)) +* add task plagairism warning on inbox ([6bd8620](https://github.com/macite/doubtfire-deploy/commit/6bd8620f5473db4517e38fd0487ab5876f921a36)) +* add teaching period remove break function ([90c6a37](https://github.com/macite/doubtfire-deploy/commit/90c6a3730f84da9e64abec676bcdf66c10b7fc60)) +* add tii action log ([96d982d](https://github.com/macite/doubtfire-deploy/commit/96d982d5fb945bdbf9ea8b6d534a5dd18ab64ad7)) +* add unselected user icon badge in inbox ([794697b](https://github.com/macite/doubtfire-deploy/commit/794697b53d6ee81f57b37b173d03a0688c6993b5)) +* complete mobile task inbox view ([00a4b3f](https://github.com/macite/doubtfire-deploy/commit/00a4b3f1e8f52364a83b6affae34295da4a7af34)) +* disable hover on task inbox for mobile ([a59fd01](https://github.com/macite/doubtfire-deploy/commit/a59fd0153de8b1f9e9b9022d0f6df1032c92f8d1)) +* downgrade task due card ([c342ac2](https://github.com/macite/doubtfire-deploy/commit/c342ac2e3cc4213c2b92499bc4a191485184af37)) +* enhance task search with similarity flags ([65ad918](https://github.com/macite/doubtfire-deploy/commit/65ad91842882e76379033350f90b966d874c6ba4)) +* enhance ui/ux of task inbox screen ([6b275c3](https://github.com/macite/doubtfire-deploy/commit/6b275c3cd5d575a399e901d52a62808238c6d0e4)) +* expand new file uplaoder ([11d798d](https://github.com/macite/doubtfire-deploy/commit/11d798d6a6e5eca4555a7d141e1d11d2a862fcfe)) +* finish new alerts service ([3beb059](https://github.com/macite/doubtfire-deploy/commit/3beb05987e605c9f4270fc0d7bdd95f82bf64007)) +* make all datetime in aus format ([7a0f749](https://github.com/macite/doubtfire-deploy/commit/7a0f749a4328a44c1ffb5692017dfa39a061e1d5)) +* migrate new unit dialog ([0a2a1e9](https://github.com/macite/doubtfire-deploy/commit/0a2a1e9baca6b0553730c1a939b246bf1be178a3)) +* minor improvements to file-drop ([73139be](https://github.com/macite/doubtfire-deploy/commit/73139be83449f50036eedb3520ed2d8daa5da09c)) +* new footer component ([c3b4c18](https://github.com/macite/doubtfire-deploy/commit/c3b4c182921079d6ebc0851ec7e6515e65f1c15b)) +* new grade service ([31073e1](https://github.com/macite/doubtfire-deploy/commit/31073e1dbe963c1e0f2c84139a2c3f746cc31bbb)) +* new student user badge ([97c84d3](https://github.com/macite/doubtfire-deploy/commit/97c84d3016b0e322d54062fae0d46da112146d5d)) +* new task status card ([78218cf](https://github.com/macite/doubtfire-deploy/commit/78218cf6d45f5cac50203f96109d27400fa37686)) +* only download similarity resource on view ([563d102](https://github.com/macite/doubtfire-deploy/commit/563d1024ee7023e4228a9ceef9f556e48e7127d1)) +* progress redesigned inbox ([f1b672b](https://github.com/macite/doubtfire-deploy/commit/f1b672b7495a9086abb88f5e1991cb4c4f7b60c1)) +* register new inbox component ([3d79baa](https://github.com/macite/doubtfire-deploy/commit/3d79baae505e44c401179ae34de4804e8d90b7ba)) +* show TII accepted status in profile ([7b5c3e2](https://github.com/macite/doubtfire-deploy/commit/7b5c3e2c0fe9fcc30f9fffc73d9a942714db7cb2)) +* small change to task view ([f640920](https://github.com/macite/doubtfire-deploy/commit/f6409201e9d4af4f075b4db8559fc1e26f4a374f)) +* small changes to user table ([18957ac](https://github.com/macite/doubtfire-deploy/commit/18957acfc700e941ebb161d9ddb8f5e1ebe1beff)) +* trigger window resize when pdf loads ([044c170](https://github.com/macite/doubtfire-deploy/commit/044c170ebe05e7e3a368df2e8041884d2e23c752)) +* update file viewer to use file download and blobs ([8751123](https://github.com/macite/doubtfire-deploy/commit/87511232ca384a355ddfc5eb396186babd676f92)) +* wip add file uploader component ([48c73f5](https://github.com/macite/doubtfire-deploy/commit/48c73f547892ae3ce12fdac416bf93c3a2c72006)) + + +### Bug Fixes + +* add activeUntil date mapping to tps ([2cae20a](https://github.com/macite/doubtfire-deploy/commit/2cae20a6b3fa3e5ea8a001ffa40a87263c438b68)) +* add arbitrary task argument to `togglePin` in task inbox ([6e1784f](https://github.com/macite/doubtfire-deploy/commit/6e1784ff52a745330e57f2ccc2a55b3206d535d0)) +* add seperator to naviation on mobile ([fc4f052](https://github.com/macite/doubtfire-deploy/commit/fc4f0527b144fd8f3b6e98b335e629f85fe1bb3b)) +* add the correct enterkeyhint to the comment composer ([569db05](https://github.com/macite/doubtfire-deploy/commit/569db059fc9786363b1b62d7be72e44f711faba3)) +* add unloaded state to progressbar ([2c3a28e](https://github.com/macite/doubtfire-deploy/commit/2c3a28e9718879f55596221517db35bda8c09450)) +* adjust due card layout ([0360a6e](https://github.com/macite/doubtfire-deploy/commit/0360a6e0868e995d7ba5c9091be556dad4bfd474)) +* always display shortened task name in header ([a05c8ff](https://github.com/macite/doubtfire-deploy/commit/a05c8ffd9aef9c865f72458157b465f9de1e07f3)) +* center pins along items in new task inbox ([6b3c0d2](https://github.com/macite/doubtfire-deploy/commit/6b3c0d2b1d5541fe36c6fbd03dfc752f43d03e18)) +* center placeholder text in task inbox search bar & reduce padding on far-right ([ffec9b6](https://github.com/macite/doubtfire-deploy/commit/ffec9b6f4bfc9d2aee6aa762c41bcce1ed1823ba)) +* center student and task name on bottom left screen ([e986199](https://github.com/macite/doubtfire-deploy/commit/e9861992e9e5a40076973a2215bcc919c1777b5d)) +* center view all units button on home page ([9807dfd](https://github.com/macite/doubtfire-deploy/commit/9807dfd1f18e1efc9f882a67ef99f1d198a03ff7)) +* centering of Administration icons on home page ([1043f74](https://github.com/macite/doubtfire-deploy/commit/1043f74e2ca5d23cad8c4d890a202b20ddb4bc37)) +* change display name of student on bottom left screen from being nickname to full name ([5fe7790](https://github.com/macite/doubtfire-deploy/commit/5fe7790e23aec709f8be5a444bf84ebdf77c9ce5)) +* change shown name on messages in chat to be student's full name ([93636c4](https://github.com/macite/doubtfire-deploy/commit/93636c400c1850e20320a57be010347541c5137c)) +* chatbox spacing, placement of messages <> photos, distance between message & name ([dd9cb2a](https://github.com/macite/doubtfire-deploy/commit/dd9cb2ab3bdde886c2f297d8044986427febd366)) +* ensure accepted TII eula details are retained ([e60ae54](https://github.com/macite/doubtfire-deploy/commit/e60ae547121f33edd7cc8d8c5d4afd27a1f90e8c)) +* ensure blobs are freed on destroy ([ee017eb](https://github.com/macite/doubtfire-deploy/commit/ee017eb00a506c81a108ce6dec52f7f91064d350)) +* ensure font is set to roboto ([a6ff4ce](https://github.com/macite/doubtfire-deploy/commit/a6ff4cee9794e37a208af28d7d085182441fa555)) +* ensure loadedUnitRoles is public in global ([b92159f](https://github.com/macite/doubtfire-deploy/commit/b92159ff33cf9537c0ccec50d57a54636315bec4)) +* ensure logout in timeout always signs out ([3380075](https://github.com/macite/doubtfire-deploy/commit/33800759083156682014f0f63bc83a0283ae96fa)) +* ensure mat menu icons have correct tags ([f971ad1](https://github.com/macite/doubtfire-deploy/commit/f971ad1cf7fa56de0f4e97e9533a0567db897da4)) +* ensure only staff need to accept turn it in EULA ([029c40b](https://github.com/macite/doubtfire-deploy/commit/029c40b556bb1709e75547615b2d1a1109997a74)) +* ensure pdf actions loads after pdf loaded ([0425c54](https://github.com/macite/doubtfire-deploy/commit/0425c5465a678509e1a8d719fef39975c4d8e9e5)) +* ensure similarities without files still view ([5794e2f](https://github.com/macite/doubtfire-deploy/commit/5794e2fb345328396517a7d2f945cfabb1c37173)) +* ensure task pdf viewer works ([d3d1bf8](https://github.com/macite/doubtfire-deploy/commit/d3d1bf8564c6e6134130b44df90bef42430f4654)) +* ensure task submission status updated on init ([ca77c92](https://github.com/macite/doubtfire-deploy/commit/ca77c92d251bf4087964886579de642f6f300b9a)) +* ensure that timeout state not re-routed ([c16ee90](https://github.com/macite/doubtfire-deploy/commit/c16ee90c2c372ea1889d3470e2be510aefd2245f)) +* ensure welcome page still can go to timeout ([5253f2e](https://github.com/macite/doubtfire-deploy/commit/5253f2eb6fce1de1b6df585589f1cc2594ac87a8)) +* excessive spacing below unit tags on home page ([1bfaa21](https://github.com/macite/doubtfire-deploy/commit/1bfaa216440d880d82875f519e9b3f0f59ddcc23)) +* fix few type errors ([8a96e5b](https://github.com/macite/doubtfire-deploy/commit/8a96e5b33a095cd70222719b33d7dc853d67f347)) +* fix file drop to support click to upload ([599cd9e](https://github.com/macite/doubtfire-deploy/commit/599cd9e82c1518113374f0675fc54dd1483fcc85)) +* fix footer height not resetting on similarity ([19c43d7](https://github.com/macite/doubtfire-deploy/commit/19c43d7945c5848d1ca511e146fefdfa9735c49b)) +* fix incorrect closing tag ([ad28818](https://github.com/macite/doubtfire-deploy/commit/ad28818fba9dac552a2adf14785c5bbf98b4e206)) +* fix page height recalculation trigger ([8f219c3](https://github.com/macite/doubtfire-deploy/commit/8f219c31e9a3461626a9344352c4d7aa68c57c43)) +* fix various build issues ([8b947ae](https://github.com/macite/doubtfire-deploy/commit/8b947aeea1cb0dfa4bac8e22ff5bdb2a9a82ec49)) +* fix various task comment composer/display issues ([5670bf1](https://github.com/macite/doubtfire-deploy/commit/5670bf1dcd77e8010fe88bf24662ec96e4e29847)) +* hide extension for staff if can't apply ([1699e50](https://github.com/macite/doubtfire-deploy/commit/1699e50f2d7424361e0f7d5a173bdd9fffd87c24)) +* initialise inbox on return ([1249e96](https://github.com/macite/doubtfire-deploy/commit/1249e96f53ef9544cd81e3f2c3f1d5acf4a628f3)) +* initialise inbox view ([1ae4ac9](https://github.com/macite/doubtfire-deploy/commit/1ae4ac9b304348f5528368d87ded3c26e8e5cf40)) +* issues with UI in photo spacing and text alignment ([716ead3](https://github.com/macite/doubtfire-deploy/commit/716ead3497a8a2ecd1050484bd289c003a47dffa)) +* make photo in chatbox fall to bottom ([aa4e814](https://github.com/macite/doubtfire-deploy/commit/aa4e81446546f1e62b08fd74ba8ac1e563e5efc8)) +* make read receipt smaller to fix message alignment ([c869458](https://github.com/macite/doubtfire-deploy/commit/c869458cd45334e009ae83ab3c911033bc1987b9)) +* null input checks on migrated cards ([7a192af](https://github.com/macite/doubtfire-deploy/commit/7a192af22bf1eef026d05a16edcc3a97c6abbebf)) +* pass selected task to footer's user icon ([28782b1](https://github.com/macite/doubtfire-deploy/commit/28782b134cb03f5c334df48e8887f81393661bda)) +* photo from below message to being inline ([0e97a03](https://github.com/macite/doubtfire-deploy/commit/0e97a03a491f4137ec301dd18a42130a845ebe37)) +* reduce user badge clickable region ([7305f86](https://github.com/macite/doubtfire-deploy/commit/7305f866a9582e10dd870fad1861de4ce209a4e0)) +* regenerate user icons on input changes ([257ab4e](https://github.com/macite/doubtfire-deploy/commit/257ab4eb63ec15de545193f8b60176ac33e17b01)) +* remove call to teaching period name ([1e2e019](https://github.com/macite/doubtfire-deploy/commit/1e2e0198a32acd59c11932fde2a2f14a5bacf48f)) +* remove dupe case in transition hook ([9808798](https://github.com/macite/doubtfire-deploy/commit/98087981e06296a1eb9a17f527c49ab9b27caef3)) +* remove incorrect aria hidden tags ([46bfe21](https://github.com/macite/doubtfire-deploy/commit/46bfe2109e4b912a146201572671ff759fc02226)) +* remove no PDF text from overlapping with icon ([c6543cf](https://github.com/macite/doubtfire-deploy/commit/c6543cf2f9be9644b7382eeb64699e15e461447a)) +* remove old teaching periods from home ([4f4a617](https://github.com/macite/doubtfire-deploy/commit/4f4a6175f39ac9df75c7efe920ffcc15576cc98e)) +* remove pct copy data ([6bb96b4](https://github.com/macite/doubtfire-deploy/commit/6bb96b49f436a7d4bd6f5e587b30a8528e6d75b7)) +* remove user badge from old task panel ([9129956](https://github.com/macite/doubtfire-deploy/commit/9129956f41b78a8e3e84ffe9a124a7941d8905c1)) +* require eula for any user ([daf7ba9](https://github.com/macite/doubtfire-deploy/commit/daf7ba9d9056c262cafc871b623c59a0ef837274)) +* resolved issue with intelligent discussion ([18cd1fc](https://github.com/macite/doubtfire-deploy/commit/18cd1fc6f0443c353fc5f49c81dafd2428901cbc)) +* resolved issue with intelligent-discussion-dialog component rendering ([1efa758](https://github.com/macite/doubtfire-deploy/commit/1efa758fd813751788cc79fe30e01ca8a09013d8)) +* shrink header menu on small screens ([580445d](https://github.com/macite/doubtfire-deploy/commit/580445d25573bf00671246269c61685917c34867)) +* spacing (of own/person messaging) is now right/left aligned respectively ([7d10aa0](https://github.com/macite/doubtfire-deploy/commit/7d10aa0f58e2d7c8e8fd45439d8c464fabe3508c)) +* split header onto seperate lines on small screens ([d8a9016](https://github.com/macite/doubtfire-deploy/commit/d8a90168e58ef8d71f31f76d999d4f6a59232987)) +* switch max pct to similarity flag in project ([094b418](https://github.com/macite/doubtfire-deploy/commit/094b418c1bff709e755bf7c61207ebf22bfc6106)) +* task explorer 405 error ([7ea516c](https://github.com/macite/doubtfire-deploy/commit/7ea516ce8e346d46e8acf68c8b41fa81485515ba)) +* task submission pdf downloading ([b0becb4](https://github.com/macite/doubtfire-deploy/commit/b0becb4c76e4e45f10782561a6b53a322eb43665)) +* tweak theme.scss file ([393770b](https://github.com/macite/doubtfire-deploy/commit/393770b35d1688d27bce47e1d6b94735a1822494)) +* update package lock ([674f3c7](https://github.com/macite/doubtfire-deploy/commit/674f3c70bd0a4c8c33e25d34beeab76c29f56325)) +* use angular 15 ([d0104cc](https://github.com/macite/doubtfire-deploy/commit/d0104cc57edda68a1a165bdf1fd7cf75bd74ec68)) +* use correct task object in task pinning ([a5fceb0](https://github.com/macite/doubtfire-deploy/commit/a5fceb048b931dbd5ccc3ad5d4fd898b146284fb)) +* use correct theming import ([b0d3232](https://github.com/macite/doubtfire-deploy/commit/b0d3232eaed2096296cf025437dda26ddcd595e0)) +* use disabled blinding ([628ca55](https://github.com/macite/doubtfire-deploy/commit/628ca55b4e8f569f751b78ead7a50fa9e086ccec)) +* use new state name in task dropdown ([3c2fe44](https://github.com/macite/doubtfire-deploy/commit/3c2fe444d398bcedb5c998a47683d04279996680)) +* use onChanges rather than input setter ([8069c21](https://github.com/macite/doubtfire-deploy/commit/8069c21d9ecfc412dea37cade8e8a37e79730f01)) + ### [8.0.9](https://github.com/doubtfire-lms/doubtfire-deploy/compare/v8.0.8...v8.0.9) (2024-05-31) @@ -328,8 +2162,15 @@ All notable changes to this project will be documented in this file. See [standa * use tailwindcss classes ([8d7e160](https://github.com/macite/doubtfire-deploy/commit/8d7e160e2d40c42284ee0c1fc85d6d9e01166cda)) -### [7.0.23](https://github.com/macite/doubtfire-deploy/compare/v7.0.22...v7.0.23) (2024-06-04) +### [7.0.24](https://github.com/macite/doubtfire-deploy/compare/v7.0.23...v7.0.24) (2024-06-05) + +### Bug Fixes +* ensure download blob supports 206 responses ([6445f9f](https://github.com/macite/doubtfire-deploy/commit/6445f9f998db70fbe9b9abf723dc17fd298b5d2f)) + + + +### [7.0.23](https://github.com/macite/doubtfire-deploy/compare/v7.0.22...v7.0.23) (2024-06-04) ### Bug Fixes diff --git a/CROSS_UNIT_STATUS.md b/CROSS_UNIT_STATUS.md new file mode 100644 index 0000000000..b83cfc162e --- /dev/null +++ b/CROSS_UNIT_STATUS.md @@ -0,0 +1,41 @@ +# Cross-Unit (Cross-Project) Dashboard — starting-point status + +**Branch:** `feature/cross-unit` (off `11.0.x`) · **Repos:** `doubtfire-web` + `doubtfire-api` +**State:** skeleton ported onto v11.0 and wired — *not yet built/tested* (see Verification). + +## What this feature is +A single dashboard that shows a student's tasks across **all** their units in one view +(`/dashboard`, Student-only). Currently a bare unit→task list — the visual/UX build sits on top. + +## What landed in this port +Ported from the old `Feature/Cross-Unit` branch (forked ~`10.0.0`, ~561 web / 169 api commits +behind) and **re-homed onto v11.0** — not a git rebase. + +### doubtfire-web +- **Clean adds (7 files):** `src/app/dashboard/**` — `f-cross-dashboard`, `list-item/dashboard-list-item`, + `list-item/expanded-list-item/*`. Verified to compile against v11's `GlobalStateService` + (`onLoad`, `currentUserProjects.values`). +- **Wiring (4 edits), translated UI-Router → Angular Router:** + - `app.routes.ts` — new top-level `{path: 'dashboard', component: CrossDashboardComponent, + canActivate: [roleWhitelistGuard], data: {roleWhitelist: ['Student']}}`. + - `doubtfire-angular.module.ts` — registered the 3 components in `declarations`. + ⚠️ **Shared file — coordinate with `feature/notifications` and `peer-progress`.** + - `home/states/home/home.component.html` — added a "View all" (`/dashboard`) button beside + "View previous" (`/view-all-projects`); converted `uiSref` → `routerLink`. + - `projects/states/index/global-state.service.ts` — projects query now sends + `include_task_definitions: true` (also fixed the latent `include_in_active` → `include_inactive` + param typo so web matches the API). + +### doubtfire-api (`feature/cross-unit` branch there) +- `app/api/projects_api.rb` — new `include_task_definitions` param on `GET /projects`. +- `app/api/entities/project_entity.rb` — expose `tasks` when `include_task_definitions`. +- `app/api/entities/minimal/minimal_unit_entity.rb` — expose `task_definitions` when requested. + +## Verification (NOT yet run — do before relying on this) +- web: `npm ci && npm run build && npm run lint` +- api: `bundle exec rubocop` + relevant tests +- manual: log in as a Student → home shows the new button → `/dashboard` renders the unit list. + +## What the team builds on top +Task status/colours, due dates, sorting/filtering, Material styling, empty/loading states. +The port only gets the compiling skeleton onto v11 as a shared starting point. diff --git a/DASHBOARD-SAMPLE-DATA.md b/DASHBOARD-SAMPLE-DATA.md new file mode 100644 index 0000000000..2befa99d66 --- /dev/null +++ b/DASHBOARD-SAMPLE-DATA.md @@ -0,0 +1,10 @@ +# Cross-unit dashboard sample data + +These values are entirely synthetic and are intended only as a visual reference for dashboard +mockups. They are not an API schema or an automated-test fixture. Dates use `DD/MM/YYYY`. + +| Unit code | Example task due date | Unit completion | Example task status | +| --------- | --------------------- | --------------- | ------------------- | +| COS10001 | 17/10/2026 | 10% | `not_started` | +| COS20007 | 19/08/2026 | 100% | `complete` | +| COS30046 | 25/08/2026 | 90% | `working_on_it` | diff --git a/Dockerfile b/Dockerfile index 18590c09e9..8818bb1674 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,14 @@ -FROM node:20 +FROM node:22.23.2-bookworm-slim@sha256:83f487e0a63425e5b4d146fb5e5be574bcbe1b7b843d3ebafdd95eaf7767a7e5 -ENV DEBIAN_FRONTEND noninteractive +# The upstream image ships npm 10.9.8. Upgrade to the latest Node 22-compatible +# npm 11 release to pick up patched bundled dependencies before dropping +# privileges. +RUN npm install --global npm@11.19.1 \ + && npm cache clean --force + +ENV DEBIAN_FRONTEND=noninteractive ENV USER=node -ENV NODE_ENV docker +ENV NODE_ENV=docker # You can not use `${USER}` here, but reference `/home/node`. ENV PATH="/home/node/.npm-global/bin:${PATH}" @@ -26,5 +32,5 @@ RUN npm ci --force --include=optional EXPOSE 9876 -# Launch - install on launch so that node_modules are updated in volume -CMD /bin/bash -c 'npm install; npm start' +# Install on launch so that bind-mounted source and node_modules stay current. +CMD ["/bin/bash", "-c", "npm install && exec npm start"] diff --git a/JPlag-Report-Viewer b/JPlag-Report-Viewer new file mode 160000 index 0000000000..4132c30d9e --- /dev/null +++ b/JPlag-Report-Viewer @@ -0,0 +1 @@ +Subproject commit 4132c30d9ed5606df4648a7f1494ca751d3dca49 diff --git a/MIGRATION-GUIDE.md b/MIGRATION-GUIDE.md index 049c5d2e52..3c3800c24f 100644 --- a/MIGRATION-GUIDE.md +++ b/MIGRATION-GUIDE.md @@ -57,7 +57,7 @@ Notice the naming convention. When migrating a component we use the format _name Add the start of the typescript using something based on the following: ```typescript -import { Component, Input, Inject } from '@angular/core'; +import {Component, Inject, Input} from '@angular/core'; @Component({ selector: 'task-description-card', @@ -96,7 +96,6 @@ We want to make sure we can see our progress as quickly as possible. So lets sta There are a few files we need to update to achieve this. - Remove link to component from the angular module. - - Open the component's CoffeeScript file and make a note of the name of the module. ```coffeescript @@ -111,7 +110,7 @@ There are a few files we need to update to achieve this. - Setup the new component in **doubtfire-angular.module.ts** - Import like this: ```ts - import { TaskDescriptionCardComponent } from './projects/states/dashboard/directives/task-dashboard/directives/task-description-card/task-description-card.component'; + import {TaskDescriptionCardComponent} from './projects/states/dashboard/directives/task-dashboard/directives/task-description-card/task-description-card.component'; ``` - Then add the component name to the list of **declarations**. Now the component will be available in Angular. - Remove the old and downgrade the new in **doubtfire-angularjs.module.ts** @@ -124,24 +123,31 @@ There are a few files we need to update to achieve this. ```typescript DoubtfireAngularJSModule.directive( 'taskDescriptionCard', - downgradeComponent({ component: TaskDescriptionCardComponent }) + downgradeComponent({component: TaskDescriptionCardComponent}), ); ``` - Update attributes on the new component usage. - - Search for all of the places where the component was already used (i.e. search for the component HTML tag). - Update the property binding style to use the Angular form which is `[property]="value"`. For example: ```html - + ``` Needs to change to: ```html - + ``` - Add matching inputs into your components typescript declaration. These use the syntax `@Input() name: type;`. For the task description card we use: diff --git a/README.md b/README.md index b213dcce33..206d0377e7 100644 --- a/README.md +++ b/README.md @@ -1,335 +1,154 @@ -![Doubtfire Logo](src/assets/icons/android-chrome-192x192.png) +

+ OnTrack logo +

-# Doubtfire Web [![CI](https://img.shields.io/github/workflow/status/doubtfire-lms/doubtfire-web/Node.js%20CI?label=CI&logo=GitHub)](https://github.com/doubtfire-lms/doubtfire-web/actions/workflows/nodejs-ci.yml) +# OnTrack Web [![CI](https://github.com/doubtfire-lms/doubtfire-web/actions/workflows/nodejs-ci.yml/badge.svg)](https://github.com/doubtfire-lms/doubtfire-web/actions/workflows/nodejs-ci.yml) A modern, lightweight learning management system. -> ## 🛠 Migration Status: In Development -> -> Doubtfire web migration from AngularJS/Coffeescript to Angular/Typescript, including refactoring all components, is currently in development. -> -> See the progress of component migration below. - -## Migration Progress - -SUMMARY: - -73 / 132 components migrated - -MIGRATED: - -- [x] ./src/app/home/splash-screen/splash-screen.component.ts -- [x] ./src/app/home/states/home/home.component.ts -- [x] ./src/app/tasks/task-submission-history/task-submission-history.component.ts -- [x] ./src/app/tasks/task-comments-viewer/task-assessment-comment/task-assessment-comment.component.ts -- [x] ./src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.ts -- [x] ./src/app/tasks/task-comments-viewer/intelligent-discussion-player/intelligent-discussion-player.component.ts -- [x] ./src/app/tasks/task-comments-viewer/intelligent-discussion-player/intelligent-discussion-recorder/intelligent-discussion-recorder.component.ts -- [x] ./src/app/tasks/task-comments-viewer/pdf-image-comment/pdf-image-comment.component.ts -- [x] ./src/app/tasks/task-comments-viewer/comment-bubble-action/comment-bubble-action.component.ts -- [x] ./src/app/tasks/task-comments-viewer/task-comments-viewer.component.ts -- [x] ./src/app/tasks/task-comment-composer/task-comment-composer.component.ts -- [x] ./src/app/tasks/task-comment-composer/discussion-prompt-composer/discussion-prompt-composer.component.ts -- [x] ./src/app/projects/states/dashboard/directives/student-task-list/create-portfolio-task-list-item/create-portfolio-task-list-item.component.ts -- [x] ./src/app/projects/states/dashboard/directives/student-task-list/task-list-item/task-list-item.component.ts -- [x] ./src/app/projects/states/dashboard/directives/task-dashboard/directives/task-assessment-card/task-assessment-card.component.ts -- [x] ./src/app/projects/states/dashboard/directives/task-dashboard/directives/task-similarity-view/task-similarity-view.component.ts -- [x] ./src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component.ts -- [x] ./src/app/projects/states/dashboard/directives/task-dashboard/directives/task-due-card/task-due-card.component.ts -- [x] ./src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/task-description-card.component.ts -- [x] ./src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.ts -- [x] ./src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.component.ts -- [x] ./src/app/admin/institution-settings/overseer-images/overseer-image-list.component.ts -- [x] ./src/app/admin/institution-settings/institution-settings.component.ts -- [x] ./src/app/admin/institution-settings/campuses/campus-list/campus-list.component.ts -- [x] ./src/app/admin/institution-settings/activity-type-list/activity-type-list.component.ts -- [x] ./src/app/admin/tii-action-log/tii-action-log.component.ts -- [x] ./src/app/admin/states/teaching-periods/teaching-period-list/teaching-period-list.component.ts -- [x] ./src/app/admin/states/teaching-periods/teaching-period-unit-import/teaching-period-unit-import.dialog.ts -- [x] ./src/app/eula/accept-eula/accept-eula.component.ts -- [x] ./src/app/welcome/welcome.component.ts -- [x] ./src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts -- [x] ./src/app/units/states/tasks/inbox/inbox.component.ts -- [x] ./src/app/units/states/edit/directives/unit-students-editor/student-tutorial-select/student-tutorial-select.component.ts -- [x] ./src/app/units/states/edit/directives/unit-students-editor/unit-students-editor.component.ts -- [x] ./src/app/units/states/edit/directives/unit-students-editor/student-campus-select/student-campus-select.component.ts -- [x] ./src/app/units/states/edit/directives/unit-tutorials-manager/unit-tutorials-manager.component.ts -- [x] ./src/app/units/states/edit/directives/unit-tutorials-list/unit-tutorials-list.component.ts -- [x] ./src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.ts -- [x] ./src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-who/task-definition-who.component.ts -- [x] ./src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-dates/task-definition-dates.component.ts -- [x] ./src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-options/task-definition-options.component.ts -- [x] ./src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-resources/task-definition-resources.component.ts -- [x] ./src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/task-definition-overseer.component.ts -- [x] ./src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.ts -- [x] ./src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-general/task-definition-general.component.ts -- [x] ./src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-upload/task-definition-upload.component.ts -- [x] ./src/app/units/states/analytics/unit-analytics-route.component.ts -- [x] ./src/app/common/footer/footer.component.ts -- [x] ./src/app/common/audio-recorder/audio/audio-comment-recorder/audio-comment-recorder.ts -- [x] ./src/app/common/audio-recorder/audio/microphone-tester/microphone-tester.component.ts -- [x] ./src/app/common/audio-player/audio-player.component.ts -- [x] ./src/app/common/edit-profile-form/edit-profile-form.component.ts -- [x] ./src/app/common/file-drop/file-drop.component.ts -- [x] ./src/app/common/modals/extension-modal/extension-modal.component.ts -- [x] ./src/app/common/modals/about-doubtfire-modal/about-doubtfire-modal.component.ts -- [x] ./src/app/common/modals/calendar-modal/calendar-modal.component.ts -- [x] ./src/app/common/modals/task-assessment-modal/task-assessment-modal.component.ts -- [x] ./src/app/common/pdf-viewer/pdf-viewer.component.ts -- [x] ./src/app/common/obect-select/object-select.component.ts -- [x] ./src/app/common/hero-sidebar/hero-sidebar.component.ts -- [x] ./src/app/common/project-progress-bar/project-progress-bar.component.ts -- [x] ./src/app/common/f-chip/f-chip.component.ts -- [x] ./src/app/common/status-icon/status-icon.component.ts -- [x] ./src/app/common/user-badge/user-badge.component.ts -- [x] ./src/app/common/file-viewer/file-viewer.component.ts -- [x] ./src/app/common/user-icon/user-icon.component.ts -- [x] ./src/app/common/pdf-viewer-panel/pdf-viewer-panel.component.ts -- [x] ./src/app/common/header/header.component.ts -- [x] ./src/app/common/header/task-dropdown/task-dropdown.component.ts -- [x] ./src/app/common/header/unit-dropdown/unit-dropdown.component.ts -- [x] ./src/app/common/services/alert.service.ts -- [x] ./src/app/sessions/states/sign-in/sign-in.component.ts -- [x] ./src/app/account/edit-profile/edit-profile.component.ts - -TODO: - -- [ ] ./src/app/visualisations/alignment-bar-chart.coffee -- [ ] ./src/app/visualisations/summary-task-status-scatter.coffee -- [ ] ./src/app/visualisations/target-grade-pie-chart.coffee -- [ ] ./src/app/visualisations/achievement-custom-bar-chart.coffee -- [ ] ./src/app/visualisations/student-task-status-pie-chart.coffee -- [ ] ./src/app/visualisations/alignment-bullet-chart.coffee -- [ ] ./src/app/visualisations/progress-burndown-chart.coffee -- [ ] ./src/app/visualisations/task-status-pie-chart.coffee -- [ ] ./src/app/visualisations/achievement-box-plot.coffee -- [ ] ./src/app/visualisations/task-completion-box-plot.coffee -- [ ] ./src/app/visualisations/visualisations.coffee -- [ ] ./src/app/tasks/task-status-selector/task-status-selector.coffee -- [ ] ./src/app/tasks/tasks.coffee -- [ ] ./src/app/tasks/modals/modals.coffee -- [ ] ./src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee -- [ ] ./src/app/tasks/modals/grade-task-modal/grade-task-modal.coffee -- [ ] ./src/app/tasks/task-definition-selector/task-definition-selector.coffee -- [ ] ./src/app/tasks/project-tasks-list/project-tasks-list.coffee -- [ ] ./src/app/tasks/task-ilo-alignment/task-ilo-alignment-rater/task-ilo-alignment-rater.coffee -- [ ] ./src/app/tasks/task-ilo-alignment/modals/task-ilo-alignment-modal/task-ilo-alignment-modal.coffee -- [ ] ./src/app/tasks/task-ilo-alignment/modals/task-ilo-alignment.coffee -- [ ] ./src/app/tasks/task-ilo-alignment/task-ilo-alignment-editor/task-ilo-alignment-editor.coffee -- [ ] ./src/app/tasks/task-ilo-alignment/task-ilo-alignment.coffee -- [ ] ./src/app/tasks/task-ilo-alignment/task-ilo-alignment-viewer/task-ilo-alignment-viewer.coffee -- [ ] ./src/app/config/privacy-policy/privacy-policy.coffee -- [ ] ./src/app/config/config.coffee -- [ ] ./src/app/config/runtime/runtime.coffee -- [ ] ./src/app/config/root-controller/root-controller.coffee -- [ ] ./src/app/config/local-storage/local-storage.coffee -- [ ] ./src/app/config/routing/routing.coffee -- [ ] ./src/app/config/vendor-dependencies/vendor-dependencies.coffee -- [ ] ./src/app/config/analytics/analytics.coffee -- [ ] ./src/app/config/debug/debug.coffee -- [ ] ./src/app/projects/projects.coffee -- [ ] ./src/app/projects/project-progress-dashboard/project-progress-dashboard.coffee -- [ ] ./src/app/projects/states/states.coffee -- [ ] ./src/app/projects/states/all/directives/directives.coffee -- [ ] ./src/app/projects/states/all/directives/all-projects-list/all-projects-list.coffee -- [ ] ./src/app/projects/states/all/all.coffee -- [ ] ./src/app/projects/states/groups/groups.coffee -- [ ] ./src/app/projects/states/feedback/feedback.coffee -- [ ] ./src/app/projects/states/dashboard/directives/directives.coffee -- [ ] ./src/app/projects/states/dashboard/directives/progress-dashboard/progress-dashboard.coffee -- [ ] ./src/app/projects/states/dashboard/directives/student-task-list/student-task-list.coffee -- [ ] ./src/app/projects/states/dashboard/directives/task-dashboard/directives/directives.coffee -- [ ] ./src/app/projects/states/dashboard/directives/task-dashboard/directives/task-outcomes-card/task-outcomes-card.coffee -- [ ] ./src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.coffee -- [ ] ./src/app/projects/states/dashboard/dashboard.coffee -- [ ] ./src/app/projects/states/outcomes/outcomes.coffee -- [ ] ./src/app/projects/states/portfolio/directives/portfolio-review-step/portfolio-review-step.coffee -- [ ] ./src/app/projects/states/portfolio/directives/directives.coffee -- [ ] ./src/app/projects/states/portfolio/directives/portfolio-learning-summary-report-step/portfolio-learning-summary-report-step.coffee -- [ ] ./src/app/projects/states/portfolio/directives/portfolio-add-extra-files-step/portfolio-add-extra-files-step.coffee -- [ ] ./src/app/projects/states/portfolio/directives/portfolio-grade-select-step/portfolio-grade-select-step.coffee -- [ ] ./src/app/projects/states/portfolio/directives/portfolio-welcome-step/portfolio-welcome-step.coffee -- [ ] ./src/app/projects/states/portfolio/directives/portfolio-tasks-step/portfolio-tasks-step.coffee -- [ ] ./src/app/projects/states/portfolio/portfolio.coffee -- [ ] ./src/app/projects/states/index/index.coffee -- [ ] ./src/app/projects/states/tutorials/tutorials.coffee -- [ ] ./src/app/projects/project-outcome-alignment/project-outcome-alignment.coffee -- [ ] ./src/app/admin/modals/modals.coffee -- [ ] ./src/app/admin/modals/create-unit-modal/create-unit-modal.coffee -- [ ] ./src/app/admin/states/states.coffee -- [ ] ./src/app/admin/states/units/units.coffee -- [ ] ./src/app/admin/states/users/users.coffee -- [ ] ./src/app/admin/admin.coffee -- [ ] ./src/app/groups/group-selector/group-selector.coffee -- [ ] ./src/app/groups/group-set-manager/group-set-manager.coffee -- [ ] ./src/app/groups/group-member-contribution-assigner/group-member-contribution-assigner.coffee -- [ ] ./src/app/groups/group-member-list/group-member-list.coffee -- [ ] ./src/app/groups/group-set-selector/group-set-selector.coffee -- [ ] ./src/app/groups/tutor-group-manager/tutor-group-manager.coffee -- [ ] ./src/app/groups/groups.coffee -- [ ] ./src/app/units/modals/unit-student-enrolment-modal/unit-student-enrolment-modal.coffee -- [ ] ./src/app/units/modals/modals.coffee -- [ ] ./src/app/units/modals/unit-ilo-edit-modal/unit-ilo-edit-modal.coffee -- [ ] ./src/app/units/units.coffee -- [ ] ./src/app/units/states/states.coffee -- [ ] ./src/app/units/states/tasks/inbox/inbox.coffee -- [ ] ./src/app/units/states/tasks/tasks.coffee -- [ ] ./src/app/units/states/tasks/viewer/directives/directives.coffee -- [ ] ./src/app/units/states/tasks/viewer/directives/task-sheet-view/task-sheet-view.coffee -- [ ] ./src/app/units/states/tasks/viewer/directives/task-details-view/task-details-view.coffee -- [ ] ./src/app/units/states/tasks/viewer/directives/unit-task-list/unit-task-list.coffee -- [ ] ./src/app/units/states/tasks/viewer/viewer.coffee -- [ ] ./src/app/units/states/tasks/definition/definition.coffee -- [ ] ./src/app/units/states/portfolios/portfolios.coffee -- [ ] ./src/app/units/states/all/directives/all-units-list/all-units-list.coffee -- [ ] ./src/app/units/states/all/directives/directives.coffee -- [ ] ./src/app/units/states/all/all.coffee -- [ ] ./src/app/units/states/groups/groups.coffee -- [ ] ./src/app/units/states/edit/directives/directives.coffee -- [ ] ./src/app/units/states/edit/directives/unit-group-set-editor/unit-group-set-editor.coffee -- [ ] ./src/app/units/states/edit/directives/unit-details-editor/unit-details-editor.coffee -- [ ] ./src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.coffee -- [ ] ./src/app/units/states/edit/directives/unit-ilo-editor/unit-ilo-editor.coffee -- [ ] ./src/app/units/states/edit/edit.coffee -- [ ] ./src/app/units/states/rollover/directives/directives.coffee -- [ ] ./src/app/units/states/rollover/directives/unit-dates-selector/unit-dates-selector.coffee -- [ ] ./src/app/units/states/rollover/rollover.coffee -- [ ] ./src/app/units/states/index/index.coffee -- [ ] ./src/app/units/states/students-list/students-list.coffee -- [ ] ./src/app/units/states/analytics/analytics.coffee -- [ ] ./src/app/common/filters/filters.coffee -- [ ] ./src/app/common/content-editable/content-editable.coffee -- [ ] ./src/app/common/alert-list/alert-list.coffee -- [ ] ./src/app/common/modals/confirmation-modal/confirmation-modal.coffee -- [ ] ./src/app/common/modals/comments-modal/comments-modal.coffee -- [ ] ./src/app/common/modals/modals.coffee -- [ ] ./src/app/common/modals/csv-result-modal/csv-result-modal.coffee -- [ ] ./src/app/common/modals/progress-modal/progress-modal.coffee -- [ ] ./src/app/common/grade-icon/grade-icon.coffee -- [ ] ./src/app/common/file-uploader/file-uploader.coffee -- [ ] ./src/app/common/common.coffee -- [ ] ./src/app/common/services/grade-service.coffee -- [ ] ./src/app/common/services/date-service.coffee -- [ ] ./src/app/common/services/alert-service.coffee -- [ ] ./src/app/common/services/media-service.coffee -- [ ] ./src/app/common/services/recorder-service.coffee -- [ ] ./src/app/common/services/outcome-service.coffee -- [ ] ./src/app/common/services/listener-service.coffee -- [ ] ./src/app/common/services/analytics-service.coffee -- [ ] ./src/app/common/services/services.coffee -- [ ] ./src/app/sessions/auth/http-auth-injector.coffee -- [ ] ./src/app/sessions/sessions.coffee -- [ ] ./src/app/errors/errors.coffee -- [ ] ./src/app/errors/states/states.coffee -- [ ] ./src/app/errors/states/unauthorised/unauthorised.coffee -- [ ] ./src/app/errors/states/not-found/not-found.coffee -- [ ] ./src/app/errors/states/timeout/timeout.coffee - ## Table of Contents -1. [Getting Started](#getting-started) -2. [Resources](#resources) -3. [Contributing](#contributing) -4. [Deployment](#deployment) -5. [License](#license) +- [Getting Started](#getting-started) +- [Checks](#checks) +- [Production build and handover](#production-build-and-handover) +- [Demo and live feature boundaries](#demo-and-live-feature-boundaries) +- [Resources](#resources) +- [Contributing](#contributing) +- [License](#license) ## Getting Started -If you will be using [Docker](https://github.com/doubtfire-lms/doubtfire-api/#getting-started-via-docker), follow the instructions there. - -Before you get started, make sure you have the [Doubtfire API](https://github.com/doubtfire-lms/doubtfire-api) up and running. You will need to do this before continuing. - -First, clone the web repository, and change to the root directory: +OnTrack Web requires Node.js 22.22.3 or newer. The exact handover version is in +`.nvmrc`. Clone recursively because the JPlag report viewer is a production +asset supplied by a git submodule: ```sh -git clone https://github.com/doubtfire-lms/doubtfire-web.git -cd ./doubtfire-web +git clone --recurse-submodules https://github.com/doubtfire-lms/doubtfire-web.git +cd doubtfire-web +git submodule update --init --recursive +nvm install +nvm use +npm ci ``` -You can automate the installation process by running the automated setup script: +Run the [Doubtfire API](https://github.com/doubtfire-lms/doubtfire-api) on +`http://localhost:3000`, then start the Angular development server: ```sh -./setup.sh +npm start ``` -Or, you can continue following the below steps to manually install `doubtfire-web`. +Open . `proxy.conf.json` forwards `/api` to the local API. +It also forwards `/lti/api` to the optional local LTI service on port 3001; LTI +requests will fail if that service is not running. -Install [Node.js](http://nodejs.org/) either by [downloading it](http://nodejs.org/download/) and installing it manually, or via [Homebrew](http://brew.sh) on OS X: +The legacy root `docker-compose.yml` also defaults to local database +authentication. Optional AAF development must use a dedicated non-production +registration supplied through an ignored `.env` file copied from +`.env.example`. Any AAF secret ever committed to Git must be treated as +compromised and rotated by its identity owner. -```sh -brew install node -``` +## Checks -_or_ by using `apt-get` on Linux: +Run the same checks required for a release before handing the branch over: ```sh -curl -sL https://deb.nodesource.com/setup_10.x | sudo -E bash - -sudo apt-get install nodejs +npm run verify:deployment-config +npm run lint +npm run typecheck +npm run test:ci +npm run deploy ``` -Install Ruby [SASS](http://sass-lang.com): +## Production build and handover -```sh -gem install sass -``` +The production application deliberately uses the browser origin for both API +contracts: -If `gem` fails, you should read the Doubtfire API README to install ruby. If you are _not_ using `rbenv`, e.g., using Docker instead, you may need to prepend `sudo` to the above commands to have root write access. +- `/api` must be reverse-proxied to Doubtfire API. +- `/lti/api` must be reverse-proxied to the LTI service when LTI is enabled. -If using `rbenv`, rehash to ensure each of the gems are on your `PATH`: +`DF_API_URL` is not a runtime switch for the Angular production build. Hosting +the API on another origin requires an explicit web configuration and matching +CORS policy. Production must use HTTPS for service workers, installation, and +Web Push. + +Build the static production bundle directly with: ```sh -rbenv rehash +npm ci +git submodule update --init --recursive +npm run deploy ``` -Install all node dependencies using `npm`, as well as [grunt-cli](http://gruntjs.com/using-the-cli) globally: +Or build the production Nginx image, which is the release artifact used by the +deployment repository: ```sh -npm install +docker build -f deploy.Dockerfile -t doubtfire-web:handover . +docker run --rm -p 8080:80 doubtfire-web:handover ``` -**Note:** You may need to install `grunt-cli` globally in Linux using `sudo`. +The root `Dockerfile` is the bind-mounted development image: it contains the +source tree, npm, and development dependencies so that local Compose workflows +can rebuild on startup. Do not publish or assess it as the production runtime. +Release vulnerability scans must build `deploy.Dockerfile` and scan its final +Nginx image digest; build-stage and development-image findings should be tracked +separately. + +The Dockerfile pins its Node and Nginx base images by multi-architecture digest +and performs the build as the unprivileged `node` user. Refresh those digests +deliberately as part of a reviewed dependency update; do not replace them with +floating tags for a release. -Lastly, to compile and run a watch server and web server, use `npm start`: +Sentry is optional. Direct production builds leave it disabled. The image build +can substitute `SENTRY_DSN`, `SENTRY_RELEASE`, and `SENTRY_DIST`; source-map +upload additionally requires the documented Sentry build arguments and a +BuildKit `sentry_auth_token` secret. + +Before publishing, verify the generated service-worker files and JPlag assets: ```sh -npm start +test -f dist/browser/index.html +test -f dist/browser/ngsw.json +test -f dist/browser/ngsw-worker.js +test -f dist/browser/manifest.webmanifest +test -n "$(find dist/browser/JPlag -mindepth 1 -maxdepth 1 -print -quit)" ``` -This will automatically run the angular 1 `grunt watch`, and the angular 7 `ng serve`. +Against the running image, confirm application routes fall back to `index.html`, +each named control-file URL returns 404 rather than HTML if its build output is +missing, and these files return `Cache-Control: no-store, no-cache` when present: -You can then navigate to the Doubtfire web interface at **http://localhost:8000**. +- `/index.html` +- `/ngsw.json` +- `/ngsw-worker.js` +- `/safety-worker.js` +- `/worker-basic.min.js` +- `/manifest.webmanifest` -## Deployment +The external proxy or CDN must preserve that cache policy. Pin the tested image +digest in `doubtfire-deploy`, then smoke-test sign-in, API routing, service-worker +registration/update, and any enabled LTI or Web Push integration. -To compile the front-end, ensure `doubtfire-api` is placed as a sibling directory to `doubtfire-web`, then run: - -```sh -cd /path/to/repos -ls -doubtfire-api doubtfire-web -cd ./doubtfire-api -grunt deploy -``` +## Demo and live feature boundaries -You may prefix this command with the following environment variables: +Demo source, fixtures, controls, and evidence remain in the repository for local +demonstrations. Production builds set `production: true` and +`enableDemoTools: false`; `/demo-controls` is unavailable and genuine API data +passes through unchanged. Do not change that flag from deployment tooling. -- `DF_API_URL` - the URL of the API (e.g., `https://doubtfire.com/api`). This will default to `window.location.host` if not set and dynamically generate a URL. -- `DF_EXTERNAL_NAME` - a new name that removes references to the _Doubtfire_ name should you so want to not use such its original name (😢). +Task-level peer progress is live when the matching API feature and privacy +configuration are enabled. The unit summary and burndown peer-median displays +are explicitly demo-only fixtures. The push-notification demo preview is visual +only; real Web Push requires API VAPID configuration, HTTPS, a running worker, +and browser permission. ## Resources -Doubtfire Web is an [Angular](http://angularjs.org) application built using [Bootstrap](http://getbootstrap.com). It uses many Open Source libraries, which you can read up on: - -- [Lodash](http://lodash.com/docs) -- [Moment.js](http://momentjs.com) -- [Font Awesome](http://fontawesome.io) -- [UI Router](https://github.com/angular-ui/ui-router) -- [UI Bootstrap](http://angular-ui.github.io/bootstrap/versioned-docs/0.13.4/) -- [UI Select](https://github.com/angular-ui/ui-select) -- [NVD3 Charts](http://krispo.github.io/angular-nvd3/#/) -- [Angular X-Editable](http://vitalets.github.io/angular-xeditable/) -- [Angular Filters](https://github.com/a8m/angular-filter) -- [Angular Markdown Filter](https://github.com/vpegado/angular-markdown-filter) +Doubtfire Web is an [Angular](https://angular.dev) application using +[Angular Material](https://material.angular.dev/). Production is served as a +static progressive web application by Nginx. ## Contributing diff --git a/angular.json b/angular.json index 2c0b1f0b92..3ba756018f 100644 --- a/angular.json +++ b/angular.json @@ -10,44 +10,50 @@ "prefix": "f", "schematics": { "@schematics/angular:application": { - "strict": false + "strict": false, + "standalone": false, + "style": "scss", + "skipTests": true } }, "architect": { "build": { - "builder": "@angular-devkit/build-angular:application", + "builder": "@angular/build:application", "options": { "outputPath": "dist", - "index": "build/index.html", + "index": "src/index.html", "browser": "src/main.ts", "polyfills": ["src/polyfills.ts"], "tsConfig": "src/tsconfig.app.json", "assets": [ "src/assets", - "src/manifest.webmanifest" + "src/manifest.webmanifest", + { + "input": "./JPlag-Report-Viewer", + "output": "/JPlag", + "glob": "**/!(*.gitignore|README.md|.git)" + }, + { + "glob": "**/*", + "input": "./node_modules/monaco-editor/min", + "output": "/assets/monaco/min/" + } ], + "loader": { + ".ttf": "binary" + }, + "stylePreprocessorOptions": { + "includePaths": ["src"] + }, "styles": [ "src/theme.scss", "src/styles.scss", - "./node_modules/bootstrap/dist/css/bootstrap.css", - "./node_modules/ngx-bootstrap/datepicker/bs-datepicker.css", - "./build/assets/doubtfire.css", - "./build/assets/node_modules/angular-xeditable/dist/css/xeditable.css", - "./build/assets/node_modules/codemirror/lib/codemirror.css", - "./build/assets/node_modules/codemirror/theme/xq-light.css", - "./build/assets/node_modules/nvd3/build/nv.d3.css", - "node_modules/@ctrl/ngx-emoji-mart/picker.css" + "node_modules/@ctrl/ngx-emoji-mart/picker.css", + "node_modules/@worktile/gantt/styles/index.scss" ], "scripts": [ "node_modules/moment/moment.js", - "node_modules/d3/d3.js", - "node_modules/lodash/lodash.js", - "node_modules/underscore.string/dist/underscore.string.min.js", - "node_modules/nvd3/build/nv.d3.js", - "node_modules/es5-shim/es5-shim.js", - "node_modules/codemirror/lib/codemirror.js", - "node_modules/codemirror/addon/display/placeholder.js", - "node_modules/codemirror/mode/markdown/markdown.js", + "node_modules/d3/dist/d3.min.js", "node_modules/canvas-confetti/dist/confetti.browser.js" ], "extractLicenses": false, @@ -68,8 +74,8 @@ "with": "src/environments/environment.prod.ts" }, { - "replace": "src/app/config/constants/apiURL.ts", - "with": "src/app/config/constants/apiURL.prod.ts" + "replace": "src/app/config/constants/hostUrl.ts", + "with": "src/app/config/constants/hostUrl.prod.ts" } ], "optimization": true, @@ -81,7 +87,15 @@ "development": { "optimization": false, "extractLicenses": false, - "sourceMap": true + "sourceMap": true, + "serviceWorker": "ngsw-config.json" + }, + "testing": { + "aot": false, + "optimization": false, + "extractLicenses": false, + "sourceMap": true, + "polyfills": ["zone.js"] }, "devcontainer": { "optimization": false, @@ -89,15 +103,15 @@ "sourceMap": true, "fileReplacements": [ { - "replace": "src/app/config/constants/apiURL.ts", - "with": "src/app/config/constants/apiURL.devcontainer.ts" + "replace": "src/app/config/constants/hostUrl.ts", + "with": "src/app/config/constants/hostUrl.devcontainer.ts" } ] } } }, "serve": { - "builder": "@angular-devkit/build-angular:dev-server", + "builder": "@angular/build:dev-server", "options": { "buildTarget": "doubtfire:build", "port": 4200, @@ -121,60 +135,65 @@ } }, "extract-i18n": { - "builder": "@angular-devkit/build-angular:extract-i18n", + "builder": "@angular/build:extract-i18n", "options": { "buildTarget": "doubtfire:build" } }, "test": { - "builder": "@angular-devkit/build-angular:karma", + "builder": "@angular/build:unit-test", "options": { - "main": "src/test.ts", - "polyfills": "src/polyfills.ts", + "buildTarget": "doubtfire:build:testing", "tsConfig": "src/tsconfig.spec.json", - "karmaConfig": "src/karma.conf.js", - "styles": [ - "./node_modules/bootstrap/dist/css/bootstrap.css", - "./node_modules/ngx-bootstrap/datepicker/bs-datepicker.css" - ], - "scripts": [], - "assets": [ - "src/favicon.ico", - "src/assets", - "src/manifest.webmanifest" - ] + "runner": "vitest", + "setupFiles": ["src/vitest-setup.ts"] } }, "lint": { "builder": "@angular-eslint/builder:lint", "options": { - "lintFilePatterns": [ - "src/**/*.ts", - "src/**/*.component.html" - ] + "lintFilePatterns": ["src/**/*.ts", "src/**/*.component.html", "src/**/*.html"] } }, "eslint": { "builder": "@angular-eslint/builder:lint", "options": { - "lintFilePatterns": [ - "src/**/*.ts", - "src/**/*.component.html" - ] + "lintFilePatterns": ["src/**/*.ts", "src/**/*.component.html"] } } } } }, "schematics": { - "@schematics/angular:component": { - "style": "scss" - }, "@angular-eslint/schematics:application": { "setParserOptionsProject": true }, "@angular-eslint/schematics:library": { "setParserOptionsProject": true + }, + "@schematics/angular:component": { + "type": "component" + }, + "@schematics/angular:directive": { + "type": "directive" + }, + "@schematics/angular:service": { + "type": "service" + }, + "@schematics/angular:guard": { + "typeSeparator": "." + }, + "@schematics/angular:interceptor": { + "typeSeparator": "." + }, + "@schematics/angular:module": { + "typeSeparator": "." + }, + "@schematics/angular:pipe": { + "typeSeparator": "." + }, + "@schematics/angular:resolver": { + "typeSeparator": "." } }, "cli": { @@ -182,8 +201,6 @@ "cache": { "enabled": true }, - "schematicCollections": [ - "@angular-eslint/schematics" - ] + "schematicCollections": ["@angular-eslint/schematics"] } } diff --git a/build.config.js b/build.config.js index 2f9b32b333..ffb10f91a4 100644 --- a/build.config.js +++ b/build.config.js @@ -24,9 +24,9 @@ module.exports = { api: { src: ['build/src/app/api/api-url.js', 'build/src/app/config/external-name/external-name.js'], options: { - inline: true - } - } + inline: true, + }, + }, }, /** @@ -38,31 +38,15 @@ module.exports = { * app's unit tests. */ app_files: { - js: [ - 'src/**/*.js', - '!src/**/*.spec.js', - '!src/assets/**/*.js' - ], - jsunit: [ - 'src/**/*.spec.js' - ], + js: ['src/**/*.js', '!src/**/*.spec.js', '!src/assets/**/*.js'], + jsunit: ['src/**/*.spec.js'], - coffee: [ - 'src/**/*.coffee', - '!src/**/*.spec.coffee', - '!src/**/*.old.coffee' - ], - coffeeunit: [ - 'src/**/*.spec.coffee' - ], + coffee: ['src/**/*.coffee', '!src/**/*.spec.coffee', '!src/**/*.old.coffee'], + coffeeunit: ['src/**/*.spec.coffee'], - atpl: [ - 'src/app/**/*.tpl.html' - ], + atpl: ['src/app/**/*.tpl.html'], - html: [ - 'src/index.html' - ], + html: ['src/index.html'], scss: [ // Do not modify the order @@ -70,8 +54,8 @@ module.exports = { 'src/styles/common/**/*.scss', 'src/styles/modules/**/*.scss', 'src/app/**/*.scss', - '!src/app/**/*.component.scss' - ] + '!src/app/**/*.component.scss', + ], }, /** @@ -92,26 +76,22 @@ module.exports = { */ vendor_files: { compile: { - js: [ - ], - jsmap: [ - ], + js: [], + jsmap: [], scss: [ 'node_modules/bootstrap-sass/**/_bootstrap.scss', - 'node_modules/font-awesome/**/font-awesome.scss' + 'node_modules/font-awesome/**/font-awesome.scss', ], }, copy: { - js: [ - ], - jsmap: [ - ], + js: [], + jsmap: [], css: [ - "node_modules/nvd3/build/nv.d3.css", - "node_modules/angular-xeditable/dist/css/xeditable.css", - "node_modules/codemirror/lib/codemirror.css", - "node_modules/codemirror/theme/xq-light.css" - ] - } - } + 'node_modules/nvd3/build/nv.d3.css', + 'node_modules/angular-xeditable/dist/css/xeditable.css', + 'node_modules/codemirror/lib/codemirror.css', + 'node_modules/codemirror/theme/xq-light.css', + ], + }, + }, }; diff --git a/commitlint.config.js b/commitlint.config.js index 28fe5c5bf9..3347cb961c 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -1 +1 @@ -module.exports = {extends: ['@commitlint/config-conventional']} +module.exports = {extends: ['@commitlint/config-conventional']}; diff --git a/deploy.Dockerfile b/deploy.Dockerfile index 2326fca4ec..d0e431c68d 100644 --- a/deploy.Dockerfile +++ b/deploy.Dockerfile @@ -1,5 +1,14 @@ ### STAGE 1: Build ### -FROM node:20 AS build +FROM node:22.23.2-bookworm-slim@sha256:83f487e0a63425e5b4d146fb5e5be574bcbe1b7b843d3ebafdd95eaf7767a7e5 AS build + +# Keep the build package manager aligned with the development image. npm 11 +# removes the vulnerable dependencies bundled with this Node 22 base. +RUN npm install --global npm@11.19.1 \ + && npm cache clean --force + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gettext-base \ + && rm -rf /var/lib/apt/lists/* USER node @@ -7,17 +16,41 @@ USER node # Copy in doubtfire-web code WORKDIR /doubtfire-web COPY package.json package-lock.json ./ -RUN npm ci --force --include=optional +RUN npm ci --include=optional COPY --chown=node:node . . -RUN chmod 777 src + +ARG SENTRY_DSN +ARG SENTRY_ORG +ARG SENTRY_PROJECT +ARG SENTRY_RELEASE +ARG SENTRY_DIST +ARG UPLOAD_SENTRY_SOURCEMAPS=false +ENV SENTRY_DSN=$SENTRY_DSN +ENV SENTRY_ORG=$SENTRY_ORG +ENV SENTRY_PROJECT=$SENTRY_PROJECT +ENV SENTRY_RELEASE=$SENTRY_RELEASE +ENV SENTRY_DIST=$SENTRY_DIST +RUN envsubst '${SENTRY_DSN} ${SENTRY_RELEASE} ${SENTRY_DIST}' < src/environments/environment.prod.ts > src/environments/environment.prod.ts.tmp && mv src/environments/environment.prod.ts.tmp src/environments/environment.prod.ts # Launch - build to dist folder -RUN npm run-script deploy +RUN --mount=type=secret,id=sentry_auth_token,uid=1000 \ + if [ "$UPLOAD_SENTRY_SOURCEMAPS" = "true" ]; then \ + npm run deploy:build2api:sourcemaps; \ + SENTRY_AUTH_TOKEN="$(cat /run/secrets/sentry_auth_token)" npm run sentry:sourcemaps; \ + find dist/browser -name '*.map' -delete; \ + npx ngsw-config dist/browser ngsw-config.json /; \ + else \ + npm run-script deploy; \ + fi ## STAGE 2: Host ### -FROM nginx:1.21.5-alpine +FROM nginx:1.30.4-alpine@sha256:97d490c12ba55b4946b01546d1c3ed324e8d41ab1c9fcb2a616aa470620e5b46 + +# The pinned Nginx image predates Alpine's OpenSSL 3.5.8 security update. +# Upgrade only the affected runtime libraries rather than the whole base. +RUN apk upgrade --no-cache libcrypto3 libssl3 # Remove the default Nginx configuration file RUN rm -v /etc/nginx/nginx.conf diff --git a/docker-compose.yml b/docker-compose.yml index b97ff4e52c..e478833212 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,9 @@ -version: '3' services: doubtfire-api: container_name: web-doubtfire-api image: lmsdoubtfire/doubtfire-api:7.0-dev ports: - - "3000:3000" + - '3000:3000' volumes: - ../data/tmp:/doubtfire/tmp - ../data/student-work:/student-work @@ -14,22 +13,24 @@ services: RAILS_ENV: 'development' DF_STUDENT_WORK_DIR: /student-work - DF_INSTITUTION_HOST: http://localhost:3000 + DF_INSTITUTION_HOST: http://localhost:4200 DF_INSTITUTION_PRODUCT_NAME: OnTrack DF_SECRET_KEY_BASE: test-secret-key-test-secret-key! DF_SECRET_KEY_ATTR: test-secret-key-test-secret-key! DF_SECRET_KEY_DEVISE: test-secret-key-test-secret-key! - # Authentication method - can set to AAF or ldap - DF_AUTH_METHOD: database - DF_AAF_ISSUER_URL: https://rapid.test.aaf.edu.au - DF_AAF_AUDIENCE_URL: http://localhost:3000 - DF_AAF_CALLBACK_URL: http://localhost:3000/api/auth/jwt - DF_AAF_IDENTITY_PROVIDER_URL: https://signon-uat.deakin.edu.au/idp/shibboleth - DF_AAF_UNIQUE_URL: https://rapid.test.aaf.edu.au/jwt/authnrequest/research/Ag4EJJhjf0zXHqlKvKZEbg - DF_AAF_AUTH_SIGNOUT_URL: https://sync-uat.deakin.edu.au/auth/logout - DF_SECRET_KEY_AAF: v4~LMFLzzwRGZdju\5QBa@FiHIN9 + # Database authentication is the safe local default. Optional AAF + # development values must come from an ignored .env file; never commit an + # institution credential or registration URL here. + DF_AUTH_METHOD: ${DF_AUTH_METHOD:-database} + DF_AAF_ISSUER_URL: ${DF_AAF_ISSUER_URL:-} + DF_AAF_AUDIENCE_URL: ${DF_AAF_AUDIENCE_URL:-http://localhost:4200} + DF_AAF_CALLBACK_URL: ${DF_AAF_CALLBACK_URL:-http://localhost:4200/api/auth/jwt} + DF_AAF_IDENTITY_PROVIDER_URL: ${DF_AAF_IDENTITY_PROVIDER_URL:-} + DF_AAF_UNIQUE_URL: ${DF_AAF_UNIQUE_URL:-} + DF_AAF_AUTH_SIGNOUT_URL: ${DF_AAF_AUTH_SIGNOUT_URL:-} + DF_SECRET_KEY_AAF: ${DF_SECRET_KEY_AAF:-} # Database settings - for development env DF_DEV_DB_ADAPTER: mysql2 @@ -74,8 +75,8 @@ services: build: . command: /bin/bash -c 'npm install; npm start' ports: - - "4200:4200" - - "9876:9876" + - '4200:4200' + - '9876:9876' depends_on: - doubtfire-api volumes: diff --git a/docs/CAL-D01-webcal-entry-point-audit.md b/docs/CAL-D01-webcal-entry-point-audit.md new file mode 100644 index 0000000000..9ed2185b4a --- /dev/null +++ b/docs/CAL-D01-webcal-entry-point-audit.md @@ -0,0 +1,257 @@ +# CAL-D01: WebCal entry point audit + +Recon and documentation only. No feature code or component changes were made for this +ticket. All findings below are from reading the actual frontend source on +`feature/CAL-D01-webcal-entry-point-audit` (branched off `feature/calendar-integration`), +quoting real file paths and real code. + +## What WebCal is + +WebCal is an existing (since 2020, per project history) feed-subscription feature. A +student enables it, gets a personal `.ics` feed URL, and can point any external calendar +app (Google Calendar, Apple Calendar, Outlook) at that URL to subscribe. The subscribed +calendar then pulls task due dates on a refresh interval. This is different from a one-off +"add this single event" action (which is what CAL-F01 builds): WebCal is a standing +subscription to a feed, not a per-task button. + +The entire frontend surface for WebCal is two things: + +- `src/app/api/services/webcal.service.ts`, a thin CRUD service for the user's webcal + settings (enabled, guid, includeStartDates, reminder, unitExclusions). It does not + render anything itself. +- `src/app/common/modals/calendar-modal/calendar-modal.component.ts` (and its + `.component.html`), a Material dialog that is the actual settings UI: an enable toggle, + the `.ics` URL with a copy-to-clipboard button, reminder configuration, and per-unit + exclusion controls. Confirmed by reading the component directly, every mutation in it + (`this.webcalService.update(...)`, `this.webcalService.get({})`) goes through + `WebcalService`, and the dialog's own title, read from + `calendar-modal.component.html:4`, is: + + ```html +

Web calendar

+ ``` + + Note this title says "Web calendar", not "Calendar", a small labeling inconsistency + from the entry points that lead to it, covered below. + +The modal is opened exclusively through `CalendarModalService` +(`src/app/common/modals/calendar-modal/calendar-modal.service.ts`): + +```ts +public show(_task?: Task) { + this.dialog.open(CalendarModalComponent, { + height: 'h-min', + maxHeight: '90vh', + width: '800px', + maxWidth: '95vw', + }); +} +``` + +The `_task` parameter is unused (underscore-prefixed, never read inside `show`), and both +real call sites pass `null`. This confirms the modal always shows the same +account-level webcal settings, it does not open scoped to a specific task in any way, +regardless of where it was triggered from. + +## Entry point 1: header avatar menu, "Calendar" + +**File:** `src/app/common/header/header.component.html`, lines 91-102, wired to +`src/app/common/header/header.component.ts`, `openCalendar()` at lines 243-245. + +```html + + + + + ... + +``` + +- **Visible label:** "Calendar", with a `calendar_month` Material icon. +- **UI hierarchy:** top-right user avatar icon in the global header (always present, + ` +

+``` + +- **Visible label:** "Open calendar", plain text on a `mat-stroked-button`, with an + explanatory sentence directly above it ("Subscribe to your unit calendar to be reminded + about your due dates."). No icon on this specific button. +- **UI hierarchy:** this button sits directly on the Task Planner page + (`/projects/:projectId/plan`), visible immediately on page load, not behind any further + menu on that page. Reaching the page itself requires going through the breadcrumb + dropdown in the header, see the click path below. +- **The page itself is reached via a "Task Planner" link that does not say "calendar" in + its own label**, `src/app/common/header/task-dropdown/task-dropdown.component.html`, + lines 59-62: + + ```html + + ``` + + This reuses the same `calendar_month` icon glyph as entry point 1's "Calendar" menu + item, but the visible text label here is "Task Planner", not "Calendar". Two different + strings, one icon, worth flagging as a real discoverability inconsistency: the icon that + correctly signals "calendar" on one menu item is reused on a different item whose label + gives no calendar hint at all. + +## Click paths from `/projects/2/dashboard/2.3P` + +**Entry point 1 (header avatar, "Calendar"), 2 clicks:** + +1. Click the user avatar icon in the top-right of the header (always visible, the + `` inside `[matMenuTriggerFor]="menu2"`). +2. In the dropdown that opens, click "Calendar" (second item, `calendar_month` icon, + between "My Profile" and "About"). + +This opens the "Web calendar" dialog directly. 2 clicks, 0 page navigations. + +**Entry point 2 (Task Planner, "Open calendar"), 3 clicks:** + +1. Click the breadcrumb dropdown trigger in the header. At `/projects/2/dashboard/2.3P` + this button's visible text is derived from the matched route's `data.task` value. The + `dashboard/:taskAbbreviation` route + (`src/app/app.routes.ts:244-247`) has `data: {task: 'Dashboard'}`, and + `'Dashboard'` has no override in `task-dropdown.component.ts`'s `taskToShortName` map, + so the button reads literally **"Dashboard"** with a dropdown arrow icon next to it. + This is a guess about visual rendering specifically, confirmed from the data binding + and route data, not from an actual screenshot yet, screenshots are the placeholder + section below. +2. In the dropdown, click "Task Planner" (third item, after Dashboard and a divider, + `calendar_month` icon, see entry point 2 above). +3. On the Task Planner page that loads, click "Open calendar" (visible immediately, no + further menu). + +3 clicks, 1 page navigation, to reach the same dialog as entry point 1. + +## Discoverability assessment + +Answering the "nobody can find it" claim directly, with evidence rather than assertion: + +- **Neither entry point is visible without opening a menu first.** Entry point 1 requires + opening the avatar dropdown. Entry point 2 requires opening the breadcrumb dropdown, + then navigating to a whole separate page, where the button is visible without a further + menu, but getting there in the first place is still menu-gated. There is no persistent, + always-on-screen calendar affordance anywhere in the task dashboard itself, confirmed by + the earlier CAL-F01 recon of `task-description-card.component.html`'s + `mat-card-actions` block, which had no calendar link before CAL-F01 added one for a + different purpose (a single add-to-Google-Calendar action, not WebCal). +- **Only one of the two visible labels says "calendar."** Entry point 1's menu item is + literally labeled "Calendar". Entry point 2's *menu item* is labeled "Task Planner", the + word "calendar" only appears once you have already navigated to that page and are + reading the "Open calendar" button and the sentence above it. A student scanning menu + labels for the word "calendar" would find entry point 1 but not entry point 2's link. +- **The icon is reused inconsistently.** `calendar_month` appears on both entry point 1's + "Calendar" item and entry point 2's "Task Planner" item. Same glyph, one matches its + label, one does not. This undercuts the icon as a reliable visual cue across the app. +- **The modal's own title ("Web calendar") differs from both entry points' labels** + ("Calendar", "Open calendar"). Three different strings for the same feature across the + full path, which is a small but real naming-consistency problem on top of the + discoverability one. + +Net: the "nobody can find it" claim holds up. The clearest path (entry point 1) is still +2 clicks deep with no persistent visual presence on the page a student spends the most +time on (the task dashboard), and the second path is both deeper and more ambiguously +labeled. + +## Role differences + +Checked both entry points and their surrounding menus directly for role gating: + +- **Entry point 1 (header avatar menu) has no role gating.** The trigger button + (`header.component.html:91-93`) and every item inside `#menu2` (My Profile, Calendar, + About, Sign Out) are unconditional, no `@if` around any of them. This is different from + the separate admin-only menu in the same header (`#menu`, gated to + `currentUser.role === 'Admin' || currentUser.role === 'Convenor'` at line 60), which + confirms role gating exists elsewhere in this file and was deliberately not applied to + the Calendar item, this looks like an intentional "everyone gets this" choice, not an + oversight, though that is inferred from the surrounding code pattern, not from a comment + stating it explicitly. +- **Entry point 2 depends on view context, not role directly.** The "Task Planner" item + in `task-dropdown.component.html` sits inside + `@if (currentProject !== null && currentView === 'PROJECT')` (line 54), the branch used + whenever anyone, student or staff, is viewing a specific project. Staff viewing a + student's project (`viewingOtherStudentProject`, defined in + `project-plan.component.ts:84-89`) still see the "Open calendar" button, it is not + wrapped in any conditional in the template. Worth flagging as a real behavioural quirk: + if a tutor opens a student's Task Planner page and clicks "Open calendar", the dialog + shows the **tutor's own** webcal settings (`webcalService.get({})` always fetches the + current authenticated user's webcal), not the student's. Nothing in the UI clarifies + this, a tutor could plausibly believe they are looking at or subscribing to the + student's calendar. This is a real, evidenced UX gap, not a guess, from directly + reading `webcal.service.ts`'s `get` call and the absence of any `projectId`/`userId` + parameter being passed. +- **Staff viewing at UNIT level (not inside a specific project) see no calendar-related + entry point via entry point 2 at all.** The `@if (unitRole && currentView === 'UNIT')` + branch in the same file (line 77 onward, the Inbox/Explorer/Moderation/Students/etc. + menu for staff) has no calendar-related item anywhere in it. They still retain entry + point 1 (header avatar menu), which is present regardless of view context or role. + +Net: no role sees an *additional* WebCal entry point the others lack. If anything, staff +in unit-level views temporarily lose access to entry point 2 until they navigate into a +specific project, while retaining entry point 1 throughout. + +## Dead or unreachable entry points + +None found. Searched for every reference to `CalendarModalService`/`calendarModal.show` +across the frontend (`grep` across `src/app`) and found exactly four files: the service +definition, the two real call sites documented above +(`header.component.ts`, `project-plan.component.ts`), and `header.component.spec.ts` (a +test, not a UI entry point). No commented-out calendar or webcal markup was found anywhere +in the templates searched. No feature flag or `isFeatureEnabled`-style condition gates +either button. Both entry points are live and reachable exactly as described above. + +One adjacent false positive worth ruling out explicitly, since it shares a name: the +`angular-calendar` npm package (imported as `CalendarEvent` from `'angular-calendar'`) is +also used by `src/app/units/states/analytics/directives/analytics-tutor-times.component.ts`, +for a tutor session-times week-view widget. This is a completely unrelated feature that +happens to use the same third-party calendar-rendering library. It is not a WebCal entry +point and should not be confused with one in any follow-up work. + +## Recommendation for CAL-F04 + +CAL-F04 should not rely on either existing entry point as "sufficient" prior art, both are +menu-gated and inconsistently labeled, which is the root cause this audit was asked to +evidence. A visible entry point on the task dashboard itself, the page a student actually +spends time on, is the right target. The `task-description-card.component.html` +`mat-card-actions` row (already used by CAL-F01 for a related but distinct +add-single-event action) is a reasonable candidate location to consider, since a student +is already looking at that card when deciding whether they want ongoing reminders, not +just a one-off addition. This is a recommendation, not a decision, the actual placement +call belongs to whoever scopes CAL-F04, and should confirm with the team whether a WebCal +entry point and CAL-F01's per-task button are meant to coexist in the same card or be +kept visually distinct, since they are different mechanisms (standing subscription vs. +one-off event) that could otherwise read as duplicates to a student. + +## Screenshots + +These are on the PR diff --git a/docs/CAL-D03-calendar-download-design.md b/docs/CAL-D03-calendar-download-design.md new file mode 100644 index 0000000000..b1d872e14d --- /dev/null +++ b/docs/CAL-D03-calendar-download-design.md @@ -0,0 +1,122 @@ +# Calendar download: design notes and codebase findings + +## Summary + +The calendar work adds the ability to download task due dates as an `.ics` file alongside the +existing WebCal subscription (a live feed). It has two intended entry points: + +1. The per-unit **Progress Dashboard**, to download the current unit's tasks. +2. The global **calendar modal**, to download across the units a student is enrolled in, using the + modal's existing unit selection to choose which units/subjects are included. + +The download is a one-time file. The WebCal subscription remains the option for a live-updating +feed. The per-unit download and its grade selector are implemented; the cross-unit calendar-modal +download remains follow-on work. + +## Placement + +### Progress Dashboard (per unit) + +- Component: `progress-dashboard.component` + (`src/app/projects/states/dashboard/directives/progress-dashboard/`), rendered by + `project-dashboard` at route `projects/:projectId/dashboard`. +- The button fits in the "Plan Your Tasks" card (`task-planner-card.component`), beside "View Task + Planner". That card already receives the project (`[project]` input) and is about task due dates. + The implementation lives in that card and injects `FileDownloaderService` and `GradeService`. +- Data readiness: the dashboard route resolves **progressively**. `project.resolver.ts:11` detects + `/dashboard` after stripping the query string; the progressive branch + (`project.resolver.ts:23-27`) emits a project stub immediately and loads tasks asynchronously. The + implementation therefore gates the button with `hasDownloadableTasks` until at least one task for + the selected grade is available. This differs from the Task Planner route + `projects/:projectId/plan`, which blocks until mapping completes. + +### Calendar modal (across units) + +- Component: `calendar-modal.component` (`src/app/common/modals/calendar-modal/`), opened from the + header calendar entry point and the Task Planner. +- The modal already loads the user's projects: + `projectService.query(undefined, {params: {include_in_active: false}})`, filtering out explicitly + inactive teaching periods while retaining projects without teaching-period metadata + (`calendar-modal.component.ts:56-60`), held as `projects: Project[]` (`ts:30`). +- The modal already has a unit include/exclude mechanism backed by `webcal.unitExclusions`: + `includedProjects` / `excludedProjects` getters (`ts:205-222`) and `includeExclusion` / + `removeExclusion` (`ts:227-246`), rendered as removable unit chips + (`calendar-modal.component.html:52-77`). +- The modal already exposes the live feed URL (`webcalUrl` + `.ics`, + `calendar-modal.component.html:190`). +- Projects returned by `query()` are list summaries and do not carry loaded task caches. A + client-side multi-unit build would need to load each unit's tasks first. + +## Date resolution + +- Resolve a task's date by calling `Task.localDueDate()`. Do not read `Task.dueDate`, + `TaskDefinition.dueDate`, or `TaskDefinition.targetDate` directly as a shortcut around it. +- On `TaskDefinition` the field names are inverted relative to their meaning: `localDueDate()` + returns `targetDate` (`task-definition.ts:180`), while `localDeadlineDate()` and + `finalDeadlineDate()` return `dueDate` (`task-definition.ts:221, 245`). `dueDate` is the deadline; + `targetDate` is the due/target date. +- `Task.localDueDate()` (`task.ts:294-312`) is the correct resolver. It handles flexible dates, the + student's custom target date, and grade target dates, and falls back through `dueDate` to + `definition.localDueDate()`. The shared event builder (`calendar-event-builder.ts`) already calls + it and returns a `YYYY-MM-DD` civil-date string, which avoids a daylight-saving off-by-one that a + `Date`-instant path would introduce. + +## ICS generation + +- No third-party ICS/iCalendar generation library is used. `ics-calendar-builder.ts` builds the + `VCALENDAR`/`VEVENT` text from the shared `calendar-event-builder.ts` result. +- Text escaping follows RFC 5545 section 3.3.11, with backslash first: `\`->`\\`, `;`->`\;`, + `,`->`\,`, and every CR/LF form ->`\n`. Content lines use CRLF and are folded at 75 UTF-8 octets + without splitting a Unicode code point. Each `VEVENT` carries a UTC `DTSTAMP`. +- All-day dates use the `YYYYMMDD` form. `DTSTART` is the task's civil due date and `DTEND` is the + following civil date because RFC 5545 defines `DTEND` as exclusive and later than `DTSTART`. This + deliberately corrects the legacy WebCal backend's equal-start/end convention; the same + exclusive-end rule is also required by the Google Calendar template URL. +- Events also carry `STATUS:CONFIRMED`, `X-DOUBTFIRE-UNIT` (unit id), `X-DOUBTFIRE-TASK` (task + definition id), and a `UID` of the form `E-`. + +## Download mechanisms + +Two approaches exist in the codebase: + +- URL-based: `FileDownloaderService.downloadFile(url, filename)` (`file-downloader.service.ts:152`) + fetches a URL and saves the response as a file. This suits saving the existing WebCal feed URL + directly, in which case the server produces the `.ics` and the unit selection is already applied + by `unitExclusions`. +- In-memory: build the string, `new Blob([ics], {type: 'text/calendar;charset=utf-8'})`, + `URL.createObjectURL`, then `FileDownloaderService.downloadBlobToFile(url, filename)` + (`file-downloader.service.ts:141`) and `releaseBlob(url)` (`file-downloader.service.ts:130`). The + per-unit download uses this approach. An equivalent self-contained pattern is used for CSV export + at `students-list.component.ts:140-156`. + +## Grade and task-status fields + +- Grade values: Fail -1, Pass 0, Credit 1, Distinction 2, High Distinction 3 (`unit.ts:91-95`, + `grade.service.ts`). +- Per-task grade: `task.definition.targetGrade`. Limit to a target grade with + `task.definition.targetGrade <= grade`. `Project.activeTasks()` (`project.ts:182`) applies this + against the saved `project.targetGrade`; `unit.taskDefinitionsForGrade(grade)` (`unit.ts:239`) is + the definition-level equivalent. +- `project.tasks` (`project.ts:125`) is unfiltered; a grade-limited download must apply the filter + explicitly. +- Task completion: `task.inFinalState()` (`task.ts:670`) returns + `TaskStatus.FINAL_STATUSES.includes(status)`. `task.submissionDate` is available where a + "submitted" predicate is preferred. + +## Feature status and scope + +- Implemented: download a unit's calendar from the Progress Dashboard as an `.ics` file. +- Implemented: select a target grade for that unit download without changing the student's saved + target grade. +- Follow-on: download from the calendar modal across the user's included units/subjects. +- Follow-on: optionally exclude completed tasks from either download. + +## Related existing behaviour + +The WebCal subscription generates events server-side (`doubtfire-api` `webcal.rb`) and already +filters by target grade and by unit exclusions. The download reuses the shared event builder's bare +title, civil date, and UID. That title/UID pairing matches the server feed only when +`include_start_dates` is off; when it is on, the feed also emits `Start:` events and prefixes end +events with `End:`. The one-time download deliberately does not recreate that reminder-style +variant. Its exclusive next-day `DTEND` is intentionally standards-compliant even though the legacy +server feed currently emits equal start and end dates. diff --git a/docs/CPD-Q06-privacy-safe-screenshot-checklist.md b/docs/CPD-Q06-privacy-safe-screenshot-checklist.md new file mode 100644 index 0000000000..cfa715104c --- /dev/null +++ b/docs/CPD-Q06-privacy-safe-screenshot-checklist.md @@ -0,0 +1,45 @@ +# Cross-Project Dashboard Privacy-Safe Screenshot Checklist + +Use this checklist before sharing a dashboard screenshot in a pull request, Teams message, +report, portfolio, presentation, or handover document. + +## Before sharing + +- [ ] Real student names have been removed or replaced with mock names. +- [ ] Student IDs and email addresses are not visible. +- [ ] Personal account details and profile images are not visible. +- [ ] Unit and project information does not reveal a student's private enrolment details. +- [ ] Task titles do not contain sensitive or personally identifying information. +- [ ] Marks, feedback text, and individual assessment results are removed, permanently + redacted in the final exported image, or replaced with mock data. +- [ ] Extension details and changed due dates do not reveal private student circumstances. +- [ ] Browser tabs, URLs, tokens, local file paths, and account information are cropped out or + permanently redacted in the final exported image. +- [ ] Mock or seeded data has been used wherever possible. +- [ ] The screenshot contains only the area needed to prove the result. +- [ ] The final exported screenshot has been reopened and checked at full size to confirm that + no sensitive data remains. +- [ ] The original unredacted screenshot is not committed to Git, attached to the pull request, + or posted in a shared project channel. + +## Safe example + +A screenshot of the dashboard populated entirely with seeded demo data. For example, students +named "Test Student 1" and "Test Student 2" with placeholder emails, generic task titles, and +sample marks that were never tied to a real person. The screenshot is cropped tightly to the +feature being demonstrated, with no browser chrome, URL bar, or login or account menu visible. + +## Information that must be removed + +A screenshot taken from a real unit that shows an actual student's name, ID, or email next to +their real mark, feedback comment, or an approved extension with a reason attached. This kind +of detail must be cropped out, permanently redacted in the final exported image, or replaced +with mock data before sharing. Do not rely on light blur or pixelation to protect sensitive +information. + +## Recommendation + +The safest default for future contributors is to demonstrate features using mock data rather +than real student or account data, even when it takes a little longer to set up. When redaction +is unavoidable, export a flattened copy, reopen that final file, and check it again before +sharing. diff --git a/docs/PULL_REQUEST_TEMPLATE.md b/docs/PULL_REQUEST_TEMPLATE.md index ba3fe15bdc..92d5dc9709 100644 --- a/docs/PULL_REQUEST_TEMPLATE.md +++ b/docs/PULL_REQUEST_TEMPLATE.md @@ -1,35 +1,41 @@ -_Any italic text should be deleted from the final Pull Request text, including this line_ +## Jira ticket -# Description +Ticket number or link: -_Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change._ +## Summary -Fixes # (issue) +Briefly explain what you changed and why. -## Type of change +## Target branch -_Please delete options that are not relevant._ +Which shared branch should this be merged into? -- [ ] Bug fix (non-breaking change which fixes an issue) -- [ ] New feature (non-breaking change which adds functionality) -- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) -- [ ] This change requires a documentation update +Example: `feature/email-notifications` -# How Has This Been Tested? +## Testing -_Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration_ +Explain how you tested the change. -## Testing Checklist: +Include any useful commands, screenshots, logs, or test results. -- [ ] Tested in latest Chrome -- [ ] Tested in latest Safari -- [ ] Tested in latest Firefox +## Security and privacy -# Checklist: +Does this change affect authentication, permissions, notifications, student data, +secrets, personal information, or privacy? -- [ ] My code follows the style guidelines of this project -- [ ] I have performed a self-review of my own code -- [ ] I have commented my code in hard-to-understand areas -- [ ] I have made corresponding changes to the documentation -- [ ] My changes generate no new warnings -- [ ] I have requested a review from @macite and @jakerenzella on the Pull Request +If there is no known impact, write: `No known security or privacy impact.` + +## Evidence + +Add any screenshots, test output, diagrams, or other evidence that will help the reviewer. + +## Checklist + +- [ ] I selected the correct base branch. +- [ ] My changes match the assigned Jira ticket. +- [ ] I kept the change within the agreed scope. +- [ ] I tested my changes. +- [ ] I did not include passwords, tokens, API keys, secrets, or real student data. +- [ ] I updated relevant documentation, or no documentation change was needed. +- [ ] I reviewed my own changes before requesting review. +- [ ] This pull request is ready for review. diff --git a/docs/assets/global-filters.png b/docs/assets/global-filters.png new file mode 100644 index 0000000000..282e52cf4b Binary files /dev/null and b/docs/assets/global-filters.png differ diff --git a/docs/assets/ppi/ppi-d01-copy-location-wireframe.svg b/docs/assets/ppi/ppi-d01-copy-location-wireframe.svg new file mode 100644 index 0000000000..c5593b8ee8 --- /dev/null +++ b/docs/assets/ppi/ppi-d01-copy-location-wireframe.svg @@ -0,0 +1,350 @@ + + + Annotated copy-location wireframe for task-level and unit-level peer progress + + + Two privacy-safe mock Peer Progress Indicator cards. Number one marks the visible label, number + two marks an accessible help control, and number three marks the expanded explanation. The + task-level card is current and the unit-level card is a planned proof of concept. + + + + + + PPI-D01 copy-location wireframe + + + + Sanitised mock values only. Task-level is current; unit-level is planned. + + + + + A. Current task-level widget + + + + + + Peer task progress + + + + + 1 + + + + + ? + + + + + 2 + + + + 42% of peers at your target grade have submitted + + + + + + 42% + + + + + + This is one combined group percentage. It does not show names, individual results, + + + raw group size or rankings, and it does not assess your own work. + + + + + 3 + + + + + B. Planned PPI-F02 unit-level proof of concept + + + + + + Unit progress overview + + + + + 1 + + + + + ? + + + + + 2 + + + + My progress + + + + + 63% + + + + Anonymous group progress + + + + + 42% + + + + + + My progress and Anonymous group progress are separate values. The group value does + + + not show names, individual results, raw group size, marks, feedback or rankings. + + + + + 3 + + + + + Callouts + + + + + 1 + + + Visible label + + + + + 2 + + + Keyboard, touch and screen-reader accessible help control + + + + + 3 + + + Expanded explanation + + + + Based on the earlier unit-level PPI visual baseline by Umeda Ranuluge. Sanitised and updated for + PPI-D01 copy placement. No real student data is shown. + + diff --git a/docs/assets/unit-scope-dropdown.png b/docs/assets/unit-scope-dropdown.png new file mode 100644 index 0000000000..7c4aadddff Binary files /dev/null and b/docs/assets/unit-scope-dropdown.png differ diff --git a/docs/cpd-data-source-map.md b/docs/cpd-data-source-map.md new file mode 100644 index 0000000000..83731cfe6a --- /dev/null +++ b/docs/cpd-data-source-map.md @@ -0,0 +1,132 @@ +**Cross-unit Dashboard (CPD): Data Sources & Ownership Rules** + +**Branch:** docs/cpd-data-source-map + +**Purpose:** Provide frontend, backend, and security contributors with a +single source of truth regarding dashboard data sources, role +permissions, cross-unit visibility rules. + +**Field-to-Source Mapping Table** + +This table maps user-facing fields on the Cross-unit Dashboard to their +underlying backend services and models: +| Dashboard Field / Element | Angular Component / Data Binding | API Endpoint & Method | Backend Model / Entity | Access Scope & Permissions | +| :--- | :--- | :--- | :--- | :--- | +| **Unit Code & Name** | `DashboardUnit.code`, `DashboardUnit.name` | `GET /projects` | `Unit#code`, `Unit#name` via `Project.eager_load(:unit)` | Authenticated Student (`for_user current_user`) | +| **Task Title & Subtitle** | `DashboardTask.title`, `DashboardTask.subtitle` | `GET /projects` (`include_task_definitions=true`) | `TaskDefinition#name`, `TaskDefinition#abbreviation`, `TaskDefinition#targetGradeText` | Authenticated Student (`current_user`) | +| **Task Description** | `DashboardTask.description` | `GET /projects` (`include_task_definitions=true`) | `TaskDefinition#description` | Authenticated Student (`current_user`) | +| **Task Status & Color** | `DashboardTask.status`, `statusLabel`, `color` | `GET /projects` | `Task#status`, mapped via `TaskStatus.STATUS_LABELS/COLORS` | Authenticated Student (`current_user`) | +| **New Comments Count** | `DashboardTask.comments` | `GET /projects` | `Task#numNewComments` | Authenticated Student (`current_user`) | +| **Due Date** | `DashboardTask.dueDate` | `GET /projects` | `TaskDefinition#targetDate` | Authenticated Student (`current_user`) | +| **Task Weight / Priority** | `DashboardTask.weight` | `GET /projects` | `Task#topWeight` (calculated via `project.calcTopTasks()`) | Authenticated Student (`current_user`) | +| **Project ID / Unit Key** | `DashboardUnit.projectId` | `GET /projects` | `Project#id` | Authenticated Student (`current_user`) | + +**Data Ownership, Enrolment & Visibility Rules** + +**Active vs. Inactive Enrolment Filtering** + +- **Backend Inactive Toggle:** GET /projects accepts an optional query + parameter. By default (false), only active unit projects for + current_user are fetched. + +- **Frontend Active Task Scope:** CrossDashboardComponent populates + unit tasks via project.activeTasks(), ensuring archived or inactive + tasks are excluded from the default view. + +**Client-Side State, Filtering & Sorting Rules** + +- **Hide Completed Filter (Filter.HideCompleted):** Managed via + private filters: Map\. When active, tasks with + task.status == \'complete\' (completedTypes) are removed from + unitsProcessed. + +- **Default Task Ordering:** In all sort modes, completed tasks are + pushed to the bottom of the list (completedTypes.includes(a.status) + evaluation). + +- **Due Date Sort (SortMode.SubmissionDate):** Orders active tasks + chronologically using a.dueDate.getTime() - b.dueDate.getTime() + (TaskDefinition#targetDate). + +- **Default Weight Sort (SortMode.Default):** Orders tasks by + calculated priority weight using a.weight - b.weight + (Task#topWeight). + +**Role-Based Access Control & Data Isolation** + +- **Student Access:** All dashboard data subscriptions execute through + GlobalStateService.currentUserProjects, which calls ProjectsApi + endpoints protected by authenticated?. Queries are strictly scoped + to the authenticated session (for_user current_user). + +- **Write Safeguards:** Modifying target grades or submitted grades + via PUT /projects/:id is restricted once portfolio_exists? evaluates + to true (returns HTTP 403 Forbidden). + +**Technical Gaps** + +During this documentation audit, the following technical gaps were +identified directly in the source code: + +1. **SortMode.Recommended Stub:** In + CrossDashboardComponent.processTasks(), the recommended sort mode is + stubbed out with // TODO: Connect to recommender\'s points and + returns 0. Recommended sorting currently falls back to default + ordering and is not connected to the backend Task Prioritisation + Recommender Service. + +2. **In-Memory Filtering & Performance:** Task filtering (Hide + Completed) and sorting are processed entirely client-side on the + Angular main thread (this.unitsProcessed = this.units.map(\...)). + Heavy task volumes across multiple units may impact frontend + rendering performance. + +3. **Missing Inactive Unit Filter Toggle in UI:** While ProjectsApi + supports include_inactive: Boolean, CrossDashboardComponent does not + currently expose an interface control to pass this query parameter + to GlobalStateService. + +**Data-Flow Diagram** + +```text + [ CrossDashboardComponent ] + | + | (1) Subscribes to currentUserProjects + v + [ Frontend: GlobalStateService ] + | + | (2) GET /projects?include_task_definitions=true + v + [ Backend API: ProjectsApi ] + | + | (3) Project.eager_load(:unit, :user).for_user(current_user) + v + [ PostgreSQL Database ] + | + | (4) Represents array via Entities::ProjectEntity + v + [ CrossDashboardComponent: mapProjects() & mapTasks() ] + | + | (5) In-Memory Filter/Sort via processTasks() + v + [ Rendered Dashboard Unit Cards ] +``` + +**Recommended Follow-Up Tickets** + +- **Integrate Task Prioritisation Recommender with + SortMode.Recommended:** Connect SortMode.Recommended in + CrossDashboardComponent to the backend recommender service priority + scores, replacing the current return 0 stub. + +- **Add UI Toggle for Inactive/Archived Units:** Add a filter option + to pass include_inactive=true to GlobalStateService, allowing + students to view historical unit projects. + +- **Batch Priority Points in ProjectEntity:** Extend + Entities::ProjectEntity to include pre-calculated recommender + weights per task definition to streamline frontend sorting. + +- **Unit Test Coverage for CrossDashboardComponent:** Write Angular + unit tests for toggleFilter(), setSort(), and processTasks() to + prevent sorting regressions. diff --git a/docs/cpd-sizing-investigation.md b/docs/cpd-sizing-investigation.md new file mode 100644 index 0000000000..00155232e0 --- /dev/null +++ b/docs/cpd-sizing-investigation.md @@ -0,0 +1,189 @@ +# CPD sizing investigation + +Ticket: [CPD - Investigate sizing issue](https://teams.microsoft.com/l/entity/com.microsoft.teamspace.tab.planner/planner.v1.df787fb0-1a0b-4bb5-9bc9-9b92a428eb13_p_KuipOoIL0E-7orl64gLd0cgAGv3z?tenantId=d02378ec-1688-46d5-8540-1c28b5f470f6&webUrl=https%3A%2F%2Ftasks.teams.microsoft.com%2Fteamsui%2FpersonalApp%2Falltasklists&context=%7B%22subEntityId%22%3A%22%2Fv1%2Fplan%2FKuipOoIL0E-7orl64gLd0cgAGv3z%2Fview%2Fboard%2Ftask%2FhHsrVI-btk62f51L1DisTMgAOioK%22%2C%22channelId%22%3A%2219%3Abd20175d09414f079490a2403f7fca74%40thread.tacv2%22%7D) + +Status: Investigation complete. An initial responsive sizing adjustment was implemented by CPD-Q04 in [PR #34](https://github.com/ontrack-features-t2-2026/doubtfire-web/pull/34). The phone-layout follow-up recorded below supersedes the original recommendation to retain horizontal scrolling below 640 px. + +## Superseding phone layout (2026-08-28) + +The Cross-Project Dashboard now changes navigation model below the established 640 px one-card containment threshold: + +- Unit scope and global task search stay first, followed immediately by project summaries. +- Status, grade and date controls move into a labelled `More filters` disclosure on phones. An active-filter count keeps hidden criteria discoverable. +- Projects become a full-width vertical accordion. Each collapsed summary exposes the unit name and state, matching task count, and nearest actionable deadline or warning. +- Expanding a project reveals its existing task search, sort, filter and task rows. Only one project is expanded at a time. +- The accordion and filter disclosures use native buttons, 48 px minimum targets, keyboard focus indicators, `aria-expanded`, and `aria-controls` relationships. +- Widths are fluid down to 320 px; phone content is contained without horizontal panning. The existing 512 px cards, horizontal project strip and independently scrolling task lists remain unchanged at 640 px and wider. +- Task titles are keyboard-focusable links, and expanded task actions wrap instead of overflowing a narrow row. + +Focused component evidence covers the disclosure state, active-filter count, single-open accordion behavior, summary contents, unique controlled regions, non-duplicated task rows, 320 px-safe utility classes, and retained desktop utility classes. The historical 390 px images below remain useful before-state evidence; they no longer describe the implemented phone behavior. + +## Scope + +This report explains why the Cross-Project Dashboard (CPD) became noticeably larger when its 10.x feature implementation was moved to the 11.x codebase. It records the reproduction, affected dimensions, source and dependency history, existing remediation, remaining limitations, and implementation guidance. + +The original investigation did not change CPD production code or CPD-Q04 behavior. The 2026-08-28 follow-up does change the sub-640 px presentation as documented above; the historical analysis below is retained to explain the sizing boundary and earlier evidence. + +## Finding + +The primary cause was a missed rem-unit migration: + +1. The original CPD feature ran on an OnTrack 10.x baseline that loaded Bootstrap 3 after the application styles. Bootstrap 3 set the root font size to 10 px. +2. Commit [`a8ba06c01`](https://github.com/ontrack-features-t2-2026/doubtfire-web/commit/a8ba06c01cd47937249d5780face427143b7dac4) removed Bootstrap. Its commit notes explicitly state that Tailwind spacing, sizing, and font utilities would otherwise render 60 percent larger, so 138 files were rescaled. +3. CPD was still on the separate `Feature/Cross-Unit` history and was not present in that migration. +4. Commit [`2c6d2786e`](https://github.com/ontrack-features-t2-2026/doubtfire-web/commit/2c6d2786e) later reintroduced the legacy CPD template onto the 11.x base with the same rem-based width and spacing classes. With the browser default 16 px root, those values rendered 60 percent larger. + +For example, the legacy `w-[34rem]` card was 340 px under the 10 px root. The same 34 rem value became 544 px under the 16 px root. This is a 204 px, or 60 percent, increase before CPD-Q04. + +The Angular Material upgrade and later toolbar controls increased the amount of space needed inside the card header, but they did not cause the uniform 60 percent increase. Tailwind also did not redefine 34 rem: the root font-size context changed. + +A secondary height issue has the same migration boundary. Commit [`edab0928c`](https://github.com/ontrack-features-t2-2026/doubtfire-web/commit/edab0928c3419cb7babb3363cc1c2046decb3c0b) deleted the `.panel-full-screen` rule, including its viewport-height constraint. CPD still applies that class, so it is now a no-op. The remaining `h-full` has no explicitly sized route parent and does not reliably constrain the task list to the viewport. This can make the dashboard feel vertically oversized, although it is separate from the uniform width and spacing increase. + +## Baselines and ownership + +| Baseline | Commit | Package context | Purpose | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | +| Plain 10.0.x | [`c57c1c320`](https://github.com/ontrack-features-t2-2026/doubtfire-web/commit/c57c1c3206073f7b45fba69eefe9875985a4bee9) | 10.x | Does not contain CPD and cannot be used as a visual CPD baseline. | +| 10.x CPD feature | [`187902747`](https://github.com/ontrack-features-t2-2026/doubtfire-web/commit/187902747adb3a851bd3294b97b2a9d4a6e8ca95) | `10.0.1-29`, Bootstrap 3.4, Angular Material 17.3.10, Tailwind 3.3 | Valid legacy CPD baseline. Its merge base with 10.0.x is `c57c1c320`. | +| 11.x CPD reintroduction | [`2c6d2786e`](https://github.com/ontrack-features-t2-2026/doubtfire-web/commit/2c6d2786e) | `11.0.0-45`, no Bootstrap, Angular Material 22.0.2, Tailwind 4.3.1 | Reintroduced the legacy class values after the repository-wide rescaling. | +| 11.x before CPD-Q04 | [`b9386055`](https://github.com/ontrack-features-t2-2026/doubtfire-web/commit/b9386055) | 11.x | Reproduction baseline immediately before the responsive sizing adjustment. | +| CPD-Q04 implementation | [`854f9ce9b`](https://github.com/ontrack-features-t2-2026/doubtfire-web/commit/854f9ce9ba5f100b0425996c3f5d7e248faa6b32) | 11.x | Existing production remediation by SandilBandara in PR #34. | +| Investigation snapshot | [`53f1c5532`](https://github.com/ontrack-features-t2-2026/doubtfire-web/commit/53f1c5532eefc75cadf7834748a5a85c2bc6b3e8) | 11.x | Source snapshot used for the measurements and original investigation. | +| Docs-only replacement base | [`9962e7ea1`](https://github.com/ontrack-features-t2-2026/doubtfire-web/commit/9962e7ea171a2bf6d7a12be50874fa5c7ee77e21) | 11.x | Current `feature/cross-unit` base used to re-home the report. The relevant sizing classes are unchanged from the investigation snapshot. | + +## Reproduction + +1. Check out `187902747` in an isolated worktree and inspect `angular.json`, `package.json`, and `src/app/dashboard/f-cross-dashboard.component.html`. +2. Confirm that Bootstrap 3.4 is loaded after `src/styles.scss`, the CPD card uses `w-[34rem]`, the search field uses `w-64`, and the dashboard strip uses `gap-8 px-16`. +3. Check out `b9386055` in a second isolated worktree. Confirm that Bootstrap is absent and that the corresponding pre-fix values are `w-136`, `w-64`, `gap-8`, and `px-16`. +4. Render the dashboard with the same synthetic units and tasks at 100 percent browser zoom. Record computed styles at 1440 x 900, 1280 x 900, 1024 x 900, 768 x 900, 640 x 800, and 390 x 844. +5. Repeat against `854f9ce9b` or the current `feature/cross-unit` branch to verify the CPD-Q04 adjustment. + +The included standalone evidence harness reconstructs only the relevant CPD dimensions and uses synthetic data. It makes no network or API requests. The application-level images from PR #34 use the same privacy-safe principle. A live application render additionally requires a compatible local API and an authenticated synthetic student session. + +## Measurements + +The values below assume the original 10 px root and the current browser-default 16 px root documented above. + +| Element or utility | 10.x CPD feature | 11.x before CPD-Q04 | Difference | Current after CPD-Q04 | +| ------------------------ | ------------------: | ------------------: | ------------: | --------------------: | +| Unit card | `34rem` = 340 px | `34rem` = 544 px | +204 px, +60% | `32rem` = 512 px | +| Search field | `16rem` = 160 px | `16rem` = 256 px | +96 px, +60% | `14rem` = 224 px | +| Side padding, each side | `4rem` = 40 px | `4rem` = 64 px | +24 px, +60% | 64 px | +| Gap between cards | `2rem` = 20 px | `2rem` = 32 px | +12 px, +60% | 32 px | +| Card-header padding | `1rem` = 10 px | `1rem` = 16 px | +6 px, +60% | 16 px | +| `text-xl` reference size | `1.25rem` = 12.5 px | `1.25rem` = 20 px | +7.5 px, +60% | 20 px | + +The same unconverted scale remains in the task rows and expanded details: + +| Legacy-derived utility | 10.x rendering | Current rendering | Increase | +| ----------------------------------- | -------------: | ----------------: | -------: | +| `w-4` task rail | 10 px | 16 px | +60% | +| `mr-6` task margin | 15 px | 24 px | +60% | +| `py-4` row padding, each side | 10 px | 16 px | +60% | +| `size-8` comment badge | 20 px | 32 px | +60% | +| `text-xl` badge text | 12.5 px | 20 px | +60% | +| `gap-2` and `pt-2` expanded spacing | 5 px | 8 px | +60% | + +Containment thresholds for one card are: + +- 10.x CPD feature: 40 + 340 + 40 = 420 px. +- 11.x before CPD-Q04: 64 + 544 + 64 = 672 px. +- Current after CPD-Q04: 64 + 512 + 64 = 640 px. + +At a 640 px viewport, the legacy layout had 220 px of spare horizontal space, the 11.x pre-fix layout overflowed by 32 px, and the CPD-Q04 layout fits exactly. At the historical investigation snapshot, a 390 px fixed-width card overflowed by 250 px and used horizontal scrolling. The 2026-08-28 follow-up supersedes that narrow behavior with full-width stacked summaries below 640 px. + +CPD-Q04 reduced the pre-fix card by 32 px (5.9 percent) and the search field by 32 px (12.5 percent). It also changed the unit-scope selector from fixed `w-64` to `w-full max-w-64`, allowing the selector to shrink while retaining its 256 px maximum. + +## Visual evidence + +### True 10.x and 11.x scale comparison + +The following privacy-safe reconstructions use the exact root-size context and component utility values from each source baseline. + +![10.x CPD feature at 1440 x 900](evidence/cpd-sizing-investigation/02-legacy-v10-feature-1440x900.jpg) + +![Current 11.x CPD after CPD-Q04 at 1440 x 900](evidence/cpd-sizing-investigation/01-current-v11-1440x900.jpg) + +### Narrow viewport threshold + +![10.x CPD feature at 640 x 800](evidence/cpd-sizing-investigation/04-legacy-v10-feature-640x800.jpg) + +![11.x CPD before CPD-Q04 at 640 x 800](evidence/cpd-sizing-investigation/06-v11-before-sizing-fix-640x800.jpg) + +![Current 11.x CPD after CPD-Q04 at 640 x 800](evidence/cpd-sizing-investigation/03-current-v11-640x800.jpg) + +![Historical 11.x CPD at 390 x 844 before the stacked phone follow-up](evidence/cpd-sizing-investigation/05-current-v11-390x844-mobile-limitation.jpg) + +At 480 x 800, the legacy one-card footprint fits while the current one-card footprint overflows by 160 px: + +![10.x CPD feature at 480 x 800](evidence/cpd-sizing-investigation/08-legacy-v10-feature-480x800.jpg) + +![Current 11.x CPD at 480 x 800](evidence/cpd-sizing-investigation/07-current-v11-480x800.jpg) + +The complete application-level CPD-Q04 before and after capture set is also stored in [`docs/evidence/cpd-sizing-investigation`](evidence/cpd-sizing-investigation/README.md) for the 1440, 1280, 1024, 768, and 390 px viewport widths. + +## Affected source and dependencies + +The affected legacy-derived sizing values span three CPD templates. + +[`f-cross-dashboard.component.html`](../src/app/dashboard/f-cross-dashboard.component.html): + +- The unit-scope selector is fluid with a 256 px maximum. +- At 640 px and wider, the fixed horizontal card strip retains 32 px gaps, 64 px side padding, and horizontal scrolling between 512 px cards. +- Below 640 px, the strip becomes a vertically scrolling, full-width accordion with 16 px side padding and no horizontal panning. +- The desktop task-search field remains 224 px; the expanded phone control uses the available card width. +- The sort and filter controls use Angular Material icon buttons. Their larger accessible control footprints are a secondary header-space constraint and should not be globally reduced to solve this local issue. + +[`dashboard-list-item.component.html`](../src/app/dashboard/list-item/dashboard-list-item.component.html): + +- `mr-6`, `ml-2`, and `gap-4` control row spacing. +- `w-4` controls the task rail and `py-4` controls row padding. +- `size-8` and `text-xl` control the comment badge and its text. + +[`expanded-list-item.component.html`](../src/app/dashboard/list-item/expanded-list-item/expanded-list-item.component.html): + +- `mr-6`, `w-4`, `pb-4`, `pt-2`, and `gap-2` control expanded-row spacing. + +Related history and context: + +- `angular.json` at the 10.x feature baseline loaded Bootstrap CSS after the application styles. +- `package.json` moved from Bootstrap 3.4, Angular Material 17.3.10, and Tailwind 3.3 to no Bootstrap, Angular Material 22.0.2, and Tailwind 4.3.1. +- Commit [`edab0928c`](https://github.com/ontrack-features-t2-2026/doubtfire-web/commit/edab0928c3419cb7babb3363cc1c2046decb3c0b) removed the old `.panel-full-screen` height rule. The `$main-view-max-height` token still exists in `styles.scss`, and the current inbox applies the equivalent calculated height directly. + +## Original recommendation and handover + +SandilBandara's CPD-Q04 change in PR #34 safely addressed the immediate 11.x overflow threshold without changing behavior: + +- Keep the unit-scope selector fluid up to its existing maximum. +- Keep the local card and search reductions from `w-136` to `w-128` and `w-64` to `w-56`. +- Preserve the horizontal card strip at and above 640 px, search behavior, grade and date filtering, warning states, and Material touch targets. +- Do not apply a global root-font, Tailwind scale, or Angular Material density override for this local dashboard issue. + +The investigation shows that PR #34 was a partial remediation rather than a restoration of the 10.x visual scale. If parity with 10.x is still required, hand CPD-Q04 or a follow-up ticket these scoped implementation options: + +1. Apply the repository's 0.625 conversion to legacy-derived CPD spacing in all three templates. Examples include `gap-8` to `gap-5`, `px-16` to `px-10`, `gap-4` and `p-4` to `gap-2.5` and `p-2.5`, `mr-6` to `mr-3.5`, `w-4` to `w-2.5`, and `size-8` to `size-5`. +2. Decide the card target explicitly. Exact historic parity is 340 px for the card and 160 px for search. Preserving the relative CPD-Q04 reductions under the corrected scale produces 320 px and 140 px. Either compact target requires the header to reflow because the new Material icon buttons must retain their accessible touch targets. +3. Replace the dead `panel-full-screen h-full` assumption with an explicit viewport-bounded height such as the calculated height already used by the inbox, together with `min-h-0 overflow-hidden`. +4. Preserve the horizontal card strip at and above 640 px, search behavior, grade and date filtering, warning states, and Material touch targets. +5. Add focused privacy-safe regression coverage when making phone-width full-card containment a requirement. The 2026-08-28 follow-up implements that scoped change and records its component evidence above. + +The 2026-08-28 production follow-up preserves the authorship and implementation history in PR #34 while superseding only its sub-640 px navigation behavior. + +## Risks + +- Reducing the card further can crowd the unit title, search field, and two Material icon buttons. +- Making cards fluid on phones changes the horizontal multi-unit navigation model; this is contained below 640 px and covered by accessible native disclosure controls. +- Changing the global rem baseline or Material density would affect unrelated screens and touch targets. +- Removing horizontal overflow without a replacement layout could make additional units inaccessible. +- Removing `.panel-full-screen` without checking the older project dashboard could introduce a vertical layout regression. + +## Verification completed + +- Compared the 10.x feature, 11.x reintroduction, pre-fix, production fix, and current shared branch histories. +- Verified the Bootstrap removal commit and its explicit 60 percent rescaling rationale. +- Calculated the relevant rem-to-pixel values and one-card containment thresholds. +- Reviewed privacy-safe evidence at the specified desktop, tablet, and phone widths. +- Confirmed that the docs-only replacement changes no source, configuration, or CPD behavior. +- Ran the repository lint, typecheck, full test, and production build checks recorded with the evidence. +- Added focused 2026-08-28 tests for the phone filter disclosure, active-filter indicator, unit summaries, accordion state, accessible control relationships, single task-row rendering, and responsive width contract. diff --git a/docs/cpd-user-guide-handover.md b/docs/cpd-user-guide-handover.md new file mode 100644 index 0000000000..f4fa3a7f4f --- /dev/null +++ b/docs/cpd-user-guide-handover.md @@ -0,0 +1,115 @@ +# Cross-Project Dashboard: User Guide & Contributor Handover + +This document is the user guide for students using the Cross-Project Dashboard and a handover reference for contributors maintaining the feature on `11.0.x`. The historical `feature/cross-unit` work is already integrated into that release line. + +--- + +## Part 1: Student User Guide + +### 1. Unit Scopes + +The dashboard allows students to filter visible projects using the **Unit scope** dropdown. + +- **Active units:** Displays currently enrolled and active projects. +- **Previous units:** Displays authorised projects from past teaching periods. If no historical data exists, a "No previous units are available" message is displayed. +- **All units:** Combines active and previous authorised projects in one view. + +![Unit Scope Dropdown](assets/unit-scope-dropdown.png) + +### 2. Searching and Filtering + +The dashboard features a global toolbar to filter tasks across all visible units. + +- **Global search:** Matches unit code/name and task title, abbreviation, status, target grade or due date across visible units. +- **Per-unit search:** Matches a task's title, subtitle, description, abbreviation or status inside one unit card. +- **Task statuses:** Filters by any task state offered in the dropdown. +- **Target grades:** Filters by Pass, Credit, Distinction or High Distinction tiers. +- **Date filters:** Start and end date fields filter tasks by their local due date. An invalid range is announced and is not applied. +- **Clear controls:** "Clear all" restores the Active-units scope and resets all global toolbar filters, including dates. "Clear dates" resets only the date range. Per-unit search and card filters remain independent. +- **Empty states:** The dashboard distinguishes between no available tasks and no tasks matching the current filters. + +![Global Search and Filters](assets/global-filters.png) + +### 3. Phone Layout + +On screens narrower than 640 px, the dashboard uses a stacked layout designed +for touch and keyboard use rather than a horizontally scrolling row of unit +cards. + +- The unit scope and global search stay at the top of the page. +- Secondary filters move into a **More filters** disclosure. Its label includes + the number of active secondary filters so hidden filter state remains visible. +- Projects become full-width disclosures, and opening one project closes the + previously open project. Each closed header still shows the unit, project + state, matching-task count and nearest actionable deadline. +- Task actions wrap within the viewport. The page must not require horizontal + panning at supported phone widths down to 320 px. + +At 640 px and wider, the desktop/tablet layout remains a horizontally arranged +set of independently scrolling unit cards, with the full filter toolbar visible. + +### 4. Grade Completion Summaries + +When one or more target-grade filters are selected, each visible unit card displays a **Grade task completion** summary for those tiers. + +- It displays counts and percentages, such as "Pass: 1 of 3 complete (33%)". +- These percentages represent _task completion counts_ for the selected tier. They are **not** predicted final grades or official academic marks. + +### 5. Due Date Warnings & Limitations + +The dashboard provides visual urgency indicators for approaching task deadlines. + +- **Overdue:** Displayed in red. +- **Within 24 hours:** Displayed in orange. +- **Within 3 days:** Displayed in yellow. +- **Within 7 days:** Displayed in blue. + +Warnings are shown only for tasks that have not been submitted. The dashboard uses `Task.localDueDate()`, which prefers the task-specific due date supplied by the API before falling back to the task definition. The dashboard does not independently grant or calculate extensions; the project record remains the authoritative deadline source. + +### 6. Accessibility + +- **Keyboard navigation:** Filter controls and task-detail toggles are keyboard operable, and per-unit search controls have visible focus styling. +- **Project disclosures:** Phone project headers expose their expanded state, + keep a visible focus indicator and preserve focus when filters are cleared. +- **Screen readers:** Task-detail buttons expose their expanded state and use task-specific Expand/Collapse labels. Decorative warning icons are hidden from assistive technology. +- **Non-colour meaning:** Due warnings include visible text such as "Overdue" or "Due within 3 days", so colour is not the only signal. + +### 7. Authorized Data Boundary (Privacy) + +The Cross-Project Dashboard uses the authenticated student's authorised project and task responses. + +- It displays task statuses, local due dates and definitions only for projects returned to that student by the API. +- It does not introduce official numerical marks, staff feedback comments or peer-assessment information. + +--- + +## Part 2: Contributor Handover + +### 1. Architecture & Key Files + +- **Target branch:** `11.0.x` +- **Main component:** `src/app/dashboard/f-cross-dashboard.component.ts` handles unit scopes, global and per-unit filtering, grade completion summaries, previous-unit loading and the single-open phone disclosure state. +- **Main template:** `src/app/dashboard/f-cross-dashboard.component.html` defines the filter toolbar, responsive project disclosures/cards and empty/error states. +- **Responsive styling:** `src/app/dashboard/f-cross-dashboard.component.scss` switches the stacked phone experience at 640 px while retaining the wider horizontal card layout. +- **Due-warning component:** `src/app/dashboard/list-item/dashboard-list-item.component.ts` owns warning thresholds and labels. +- **Test suites:** The adjacent component specs cover unit scopes, global/per-unit search, status/grade/date filters, summaries, warnings and accessibility state. + +### 2. Linked Tickets & Context + +- **CPD-F04:** Local frontend date-range filtering alongside unit scopes and existing task data. +- **CPD-F05:** Accessible global-toolbar date controls and targeted tests. +- **CPD-Q03:** Accessible due-date warning badges in the dashboard task presentation. +- **CPD-Q04:** Responsive-sizing regression coverage and visual evidence. +- **CPD-Q05:** UI comparison and acceptance evidence; keep the evidence link with the ticket rather than treating this guide as the comparison matrix itself. + +### 3. Known Gaps & Post-MVP Work + +The following limitations remain post-MVP work: + +1. **Deadline source:** Web displays the local due date supplied through the task model. Any new extension representation must be added to the API/model contract before the dashboard can display it. +2. **Visualisation scale:** High task volumes can create substantial scrolling. Adaptive scale controls or compact toggles remain a possible follow-up. +3. **Filter persistence:** Global toolbar filters reset after a page refresh; no account-level filter preference is stored. + +### 4. Verification Basis + +This guide was reconciled with the merged `11.0.x` implementation after the Cross-Project Dashboard closure and phone-layout merges. The two screenshots show the wider toolbar using demonstration units and contain no real student records. Before changing the guide, rerun the dashboard-focused component specs, verify the screenshots still match the current wider toolbar and unit-scope labels, and check computed layout at 320 px, 390 px, 640 px and a desktop width. diff --git a/docs/english-only-submission-notice.md b/docs/english-only-submission-notice.md new file mode 100644 index 0000000000..68169dd27a --- /dev/null +++ b/docs/english-only-submission-notice.md @@ -0,0 +1,21 @@ +# English-Only Assessment Submission Notice + +## Proposed Notice + +All assessment submissions must be written and submitted in English. Translation tools may be used to help understand the assessment instructions, but the final submitted work must remain in English. + +## Recommended Placement + +The notice should appear in the following locations: + +1. **Assessment task details page** — near the assessment requirements. +2. **Submission upload page** — above the upload or submit button. +3. **Final submission confirmation window** — before the student confirms the submission. + +## Behaviour When Translation Is Enabled + +The notice should remain visible in English when the language or translation option is active. Translation must not hide or change the requirement that the final assessment submission must be in English. + +## Scope + +This document provides the proposed wording and recommended placement only. No application or interface changes have been implemented. diff --git a/docs/evidence/cpd-sizing-investigation/01-current-v11-1440x900.jpg b/docs/evidence/cpd-sizing-investigation/01-current-v11-1440x900.jpg new file mode 100644 index 0000000000..37d22cc965 Binary files /dev/null and b/docs/evidence/cpd-sizing-investigation/01-current-v11-1440x900.jpg differ diff --git a/docs/evidence/cpd-sizing-investigation/02-legacy-v10-feature-1440x900.jpg b/docs/evidence/cpd-sizing-investigation/02-legacy-v10-feature-1440x900.jpg new file mode 100644 index 0000000000..b91ae46fae Binary files /dev/null and b/docs/evidence/cpd-sizing-investigation/02-legacy-v10-feature-1440x900.jpg differ diff --git a/docs/evidence/cpd-sizing-investigation/03-current-v11-640x800.jpg b/docs/evidence/cpd-sizing-investigation/03-current-v11-640x800.jpg new file mode 100644 index 0000000000..46ad713822 Binary files /dev/null and b/docs/evidence/cpd-sizing-investigation/03-current-v11-640x800.jpg differ diff --git a/docs/evidence/cpd-sizing-investigation/04-legacy-v10-feature-640x800.jpg b/docs/evidence/cpd-sizing-investigation/04-legacy-v10-feature-640x800.jpg new file mode 100644 index 0000000000..3127af3fe9 Binary files /dev/null and b/docs/evidence/cpd-sizing-investigation/04-legacy-v10-feature-640x800.jpg differ diff --git a/docs/evidence/cpd-sizing-investigation/05-current-v11-390x844-mobile-limitation.jpg b/docs/evidence/cpd-sizing-investigation/05-current-v11-390x844-mobile-limitation.jpg new file mode 100644 index 0000000000..cf85864ea2 Binary files /dev/null and b/docs/evidence/cpd-sizing-investigation/05-current-v11-390x844-mobile-limitation.jpg differ diff --git a/docs/evidence/cpd-sizing-investigation/06-v11-before-sizing-fix-640x800.jpg b/docs/evidence/cpd-sizing-investigation/06-v11-before-sizing-fix-640x800.jpg new file mode 100644 index 0000000000..21b9d961fa Binary files /dev/null and b/docs/evidence/cpd-sizing-investigation/06-v11-before-sizing-fix-640x800.jpg differ diff --git a/docs/evidence/cpd-sizing-investigation/07-current-v11-480x800.jpg b/docs/evidence/cpd-sizing-investigation/07-current-v11-480x800.jpg new file mode 100644 index 0000000000..0dd58231b3 Binary files /dev/null and b/docs/evidence/cpd-sizing-investigation/07-current-v11-480x800.jpg differ diff --git a/docs/evidence/cpd-sizing-investigation/08-legacy-v10-feature-480x800.jpg b/docs/evidence/cpd-sizing-investigation/08-legacy-v10-feature-480x800.jpg new file mode 100644 index 0000000000..7363d11972 Binary files /dev/null and b/docs/evidence/cpd-sizing-investigation/08-legacy-v10-feature-480x800.jpg differ diff --git a/docs/evidence/cpd-sizing-investigation/README.md b/docs/evidence/cpd-sizing-investigation/README.md new file mode 100644 index 0000000000..472bf8a621 --- /dev/null +++ b/docs/evidence/cpd-sizing-investigation/README.md @@ -0,0 +1,107 @@ +# CPD sizing investigation evidence + +Evidence captured: 2026-08-23 + +Docs-only replacement prepared: 2026-08-27 + +Phone-layout follow-up prepared: 2026-08-28 + +Report: [`docs/cpd-sizing-investigation.md`](../../cpd-sizing-investigation.md) + +This folder contains the evidence for `CPD - Investigate sizing issue`. All visible unit codes, task names, dates, and statuses are synthetic. No production system, live account, student record, token, or secret was used. + +## 2026-08-28 phone-layout follow-up + +The 390 px images in this folder are retained as historical before-state evidence. The follow-up implementation supersedes their horizontal-panning behavior below 640 px with a full-width vertical project accordion. Its focused automated component evidence verifies: + +- scope and global search remain ahead of a collapsed `More filters` control; +- hidden secondary criteria have a visible active-filter count; +- project summaries expose task count and nearest deadline/warning; +- accordion controls have 48 px targets and explicit expanded/controlled state; +- opening one project closes the prior project; +- task rows are reused rather than duplicated for responsive markup; +- base widths use full-width/min-width-zero containment and desktop keeps the fixed 512 px card strip; +- 320 px fallback states and expanded task actions do not retain fixed-width overflow. + +These assertions run in `f-cross-dashboard.component.spec.ts` and `dashboard-list-item.component.spec.ts`. The original deterministic harness and hashes below remain unchanged because they reproduce the historical sizing investigation rather than the new Angular interaction model. + +A headless Chrome computed-layout check against the production build confirmed the compiled Tailwind and component media rules: + +| Viewport/state | Project card | Layout and containment | Responsive visibility | +| ------------------------------- | -----------: | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| 320 px, collapsed | 288 px | Column, 16 px side padding, body `scrollWidth` 320 px | Mobile summary visible; secondary filters and project content hidden. | +| 320 px, expanded | 288 px | Column, no page-width overflow | Secondary filters and project content visible. | +| 390 px, expanded | 358 px | Column, no page-width overflow | Secondary filters and project content visible. | +| 640 px, expanded before resize | 512 px | Row, 64 px side padding, desktop horizontal overflow contract restored | Mobile heading hidden; desktop title/content visible; secondary wrapper resolves to `display: contents`. | +| 1024 px, collapsed mobile state | 512 px | Row, desktop horizontal overflow contract retained | Desktop title/content and all global filters visible regardless of mobile disclosure state. | + +This check used the built `styles.css` plus the compiled component rules and device-metric overrides at each listed width. In every scenario, document `scrollWidth` was no greater than the emulated viewport width. + +## Source baselines + +- 10.x CPD feature: `187902747adb3a851bd3294b97b2a9d4a6e8ca95`. +- Bootstrap removal and repository utility rescaling: `a8ba06c01cd47937249d5780face427143b7dac4`. +- CPD reintroduction on 11.x: `2c6d2786e9484c57436099d25281aa45fa89453c`. +- 11.x before the responsive adjustment: `b9386055`. +- CPD-Q04 responsive adjustment: `854f9ce9ba5f100b0425996c3f5d7e248faa6b32`. +- Investigation snapshot: `53f1c5532eefc75cadf7834748a5a85c2bc6b3e8`. +- Docs-only replacement base: `9962e7ea171a2bf6d7a12be50874fa5c7ee77e21`. + +## Deterministic comparison set + +The numbered JPG files were captured from `synthetic-cpd-comparison.html`. The local-only harness reconstructs the source-derived card, search, padding, gap, and root-font dimensions. It does not load the Angular application or make network requests. + +| File | Evidence | +| ---------------------------------------------- | ----------------------------------------------------------- | +| `01-current-v11-1440x900.jpg` | Current 11.x, three cards, 1440 x 900. | +| `02-legacy-v10-feature-1440x900.jpg` | Legacy 10.x-root rendering, three cards, 1440 x 900. | +| `03-current-v11-640x800.jpg` | Current one-card containment threshold at 640 x 800. | +| `04-legacy-v10-feature-640x800.jpg` | Legacy one-card rendering at 640 x 800. | +| `05-current-v11-390x844-mobile-limitation.jpg` | Historical pre-follow-up phone-width overflow at 390 x 844. | +| `06-v11-before-sizing-fix-640x800.jpg` | 11.x immediately before CPD-Q04 at 640 x 800. | +| `07-current-v11-480x800.jpg` | Current one-card overflow at 480 x 800. | +| `08-legacy-v10-feature-480x800.jpg` | Legacy one-card fit at 480 x 800. | + +Supporting files: + +- `measurements.txt` records measured widths and overflow results. +- `source-comparison.txt` records the exact commits, utility classes, and rendered values. +- `synthetic-cpd-comparison.html` is the reproducible standalone harness. +- `verification.txt` records the passing repository checks. +- `SHA256SUMS` provides integrity checks for every evidence artifact. + +Verify the evidence integrity from the repository root: + +```sh +cd docs/evidence/cpd-sizing-investigation && shasum -a 256 -c SHA256SUMS +``` + +The harness images are deterministic layout reconstructions, not signed-in application screenshots. This limitation is intentional so the root-scale comparison is repeatable and privacy-safe. The exported raster dimensions may exclude browser chrome or scrollbar pixels, so `measurements.txt` is the authoritative record of the captured DOM client and scroll widths. + +## CPD-Q04 application capture set + +The `before-*.png` and `after-*.png` files preserve the application-level evidence supplied with [PR #34](https://github.com/ontrack-features-t2-2026/doubtfire-web/pull/34). They compare the 11.x state immediately before and after its local responsive adjustment at these viewport widths: + +- 1440 x 900 +- 1280 x 900 +- 1024 x 900 +- 768 x 900 +- 390 x 900 + +These images show that CPD-Q04 changed the card from 544 px to 512 px, the search field from 256 px to 224 px, and the unit-scope selector from fixed width to fluid with a 256 px maximum. They do not represent the original 10.x 10 px root scale; use the numbered comparison set for that question. + +## Reproduction + +Serve this folder locally: + +```sh +python3 -m http.server 8765 --bind 127.0.0.1 +``` + +Then open one of these URLs and set the viewport to the size in the filename: + +```text +http://127.0.0.1:8765/synthetic-cpd-comparison.html?mode=legacy +http://127.0.0.1:8765/synthetic-cpd-comparison.html?mode=pre-fix +http://127.0.0.1:8765/synthetic-cpd-comparison.html?mode=current +``` diff --git a/docs/evidence/cpd-sizing-investigation/SHA256SUMS b/docs/evidence/cpd-sizing-investigation/SHA256SUMS new file mode 100644 index 0000000000..2c7c055543 --- /dev/null +++ b/docs/evidence/cpd-sizing-investigation/SHA256SUMS @@ -0,0 +1,22 @@ +3541c299daf8de7adb99565754859d8bc2cd9ec256c054c17bfc89636333bfde 01-current-v11-1440x900.jpg +ee0a1143926d4b9a3579de9d476da072d128cc2293f9dce97b2dae91151ae0c9 02-legacy-v10-feature-1440x900.jpg +60cbac1d7fe742399002be8d6ab877b1e1230d53ce862e0a9a2c6c16cb4039cd 03-current-v11-640x800.jpg +8fe839a0a89510f446754632f86a87e60b4beff899ac2d043e817c15784ba43c 04-legacy-v10-feature-640x800.jpg +db8327cec1b67dc6d2c16d6524177a2595594562b8e24af1d64a784353e3d49d 05-current-v11-390x844-mobile-limitation.jpg +35ae6b3d9d02346e79c7dcaed04cc76c97a2e6d6c36672f9290e8b2d00e8786a 06-v11-before-sizing-fix-640x800.jpg +e9971bcfb40e114a1f5b6668baa12dcf76ec90d7228586b88a445170dd1edd94 07-current-v11-480x800.jpg +21273f22f715109381eb79da8987e2bea31481050be72c36f0f3364bec90056f 08-legacy-v10-feature-480x800.jpg +55f5c273610ea0352af980b2c38ee356cba0c0ff201899243c7b5f86b58b71ff after-1440x900.png +a7c5db0c304b0266e40c2f3212b706d96f326700ad4007d131e0e90b318698ac after-1280x900.png +49b5f9c62579e1f139977ea01c9a4ba37d51cc228c80abf9d66c9e04005ce8f8 after-1024x900.png +228ed36601f465011c4d4939edca751fcb1612ecc47856a3a335f01f556f031d after-768x900.png +da166e582fc08c6dec2c9c8fdadf8fed457e1821b2aed39f6236d0229ab5ba28 after-390x900.png +0ce6e03138edcba5f8bf2eb5f9b1f556a867ef094bcd9f85aa8946d7d34c41ea before-1440x900.png +4a8c27f19f5cf2aaba354001860ea103b4d2f26be000dd87cdf82eae094cab00 before-1280x900.png +16965adb873827e5d9b30b46340237ef146e74b5efd973add35cd93a93a4ebc2 before-1024x900.png +96e810d2ff565123384a962e61be7f462204bedb107951c97a29a64ffd9fe920 before-768x900.png +4b3fdcd12b68fc6a44798f4f58a17881d6c4d30a6c060faec2a46c7bab93486b before-390x900.png +14f2b26c5c0d5dfc9a1a528fdbdaa5c404c68fe5b8b18f76c70c5d734136120e measurements.txt +e6b3318cadba254378c1ca8d61f167eb22e000712c3882697d9f86282861446f source-comparison.txt +ae8ccfaf88912e57259eac2fbe9236382b559493625ad0a63e654917d7e04744 synthetic-cpd-comparison.html +a1e395fc9c9a76e89815d6064f73ebed35a1152440ab8f414b2c29b4abed0302 verification.txt diff --git a/docs/evidence/cpd-sizing-investigation/after-1024x900.png b/docs/evidence/cpd-sizing-investigation/after-1024x900.png new file mode 100644 index 0000000000..8fa3057acc Binary files /dev/null and b/docs/evidence/cpd-sizing-investigation/after-1024x900.png differ diff --git a/docs/evidence/cpd-sizing-investigation/after-1280x900.png b/docs/evidence/cpd-sizing-investigation/after-1280x900.png new file mode 100644 index 0000000000..44bbba2e45 Binary files /dev/null and b/docs/evidence/cpd-sizing-investigation/after-1280x900.png differ diff --git a/docs/evidence/cpd-sizing-investigation/after-1440x900.png b/docs/evidence/cpd-sizing-investigation/after-1440x900.png new file mode 100644 index 0000000000..39b14e7d6f Binary files /dev/null and b/docs/evidence/cpd-sizing-investigation/after-1440x900.png differ diff --git a/docs/evidence/cpd-sizing-investigation/after-390x900.png b/docs/evidence/cpd-sizing-investigation/after-390x900.png new file mode 100644 index 0000000000..83b8569b5a Binary files /dev/null and b/docs/evidence/cpd-sizing-investigation/after-390x900.png differ diff --git a/docs/evidence/cpd-sizing-investigation/after-768x900.png b/docs/evidence/cpd-sizing-investigation/after-768x900.png new file mode 100644 index 0000000000..62a0638f43 Binary files /dev/null and b/docs/evidence/cpd-sizing-investigation/after-768x900.png differ diff --git a/docs/evidence/cpd-sizing-investigation/before-1024x900.png b/docs/evidence/cpd-sizing-investigation/before-1024x900.png new file mode 100644 index 0000000000..b9538ee4c7 Binary files /dev/null and b/docs/evidence/cpd-sizing-investigation/before-1024x900.png differ diff --git a/docs/evidence/cpd-sizing-investigation/before-1280x900.png b/docs/evidence/cpd-sizing-investigation/before-1280x900.png new file mode 100644 index 0000000000..9a94b5e3fb Binary files /dev/null and b/docs/evidence/cpd-sizing-investigation/before-1280x900.png differ diff --git a/docs/evidence/cpd-sizing-investigation/before-1440x900.png b/docs/evidence/cpd-sizing-investigation/before-1440x900.png new file mode 100644 index 0000000000..9049d7c61e Binary files /dev/null and b/docs/evidence/cpd-sizing-investigation/before-1440x900.png differ diff --git a/docs/evidence/cpd-sizing-investigation/before-390x900.png b/docs/evidence/cpd-sizing-investigation/before-390x900.png new file mode 100644 index 0000000000..dfceffdef7 Binary files /dev/null and b/docs/evidence/cpd-sizing-investigation/before-390x900.png differ diff --git a/docs/evidence/cpd-sizing-investigation/before-768x900.png b/docs/evidence/cpd-sizing-investigation/before-768x900.png new file mode 100644 index 0000000000..661fd7bd2b Binary files /dev/null and b/docs/evidence/cpd-sizing-investigation/before-768x900.png differ diff --git a/docs/evidence/cpd-sizing-investigation/measurements.txt b/docs/evidence/cpd-sizing-investigation/measurements.txt new file mode 100644 index 0000000000..e78613a02f --- /dev/null +++ b/docs/evidence/cpd-sizing-investigation/measurements.txt @@ -0,0 +1,61 @@ +CPD sizing evidence measurements +Captured 2026-08-23 + +Current v11, 1440 x 900, three units +card: 512 px +strip client: 1440 px +strip scroll: 1728 px +horizontal overflow: yes (expected because three fixed cards require horizontal scrolling) + +Legacy v10 feature, 1440 x 900, three units +card: 340 px +strip client: 1440 px +strip scroll: 1440 px +horizontal overflow: no + +Current v11, 640 x 800, one unit +card: 512 px +strip client: 640 px +strip scroll: 640 px +horizontal overflow: no + +Legacy v10 feature, 640 x 800, one unit +card: 340 px +strip client: 640 px +strip scroll: 640 px +horizontal overflow: no + +V11 before sizing fix, 640 x 800, one unit +card: 544 px +strip client: 640 px +strip scroll: 672 px +horizontal overflow: yes + +Current v11, 390 x 844, one unit +card: 512 px +strip client: 390 px +strip scroll: 640 px +horizontal overflow: yes + +Current v11, 480 x 800, one unit +card: 512 px +strip client: 480 px +strip scroll: 640 px +horizontal overflow: yes + +Legacy v10 feature, 480 x 800, one unit +card: 340 px +strip client: 480 px +strip scroll: 480 px +horizontal overflow: no + +Derived one-card containment thresholds +legacy: 40 + 340 + 40 = 420 px +v11 pre-fix: 64 + 544 + 64 = 672 px +current: 64 + 512 + 64 = 640 px +current versus v11 pre-fix improvement: 32 px +current versus legacy increase: 220 px (52.4 percent larger footprint) + +Derived three-card footprint +legacy: 40 + (3 x 340) + (2 x 20) + 40 = 1140 px +current: 64 + (3 x 512) + (2 x 32) + 64 = 1728 px diff --git a/docs/evidence/cpd-sizing-investigation/source-comparison.txt b/docs/evidence/cpd-sizing-investigation/source-comparison.txt new file mode 100644 index 0000000000..cacb26e741 --- /dev/null +++ b/docs/evidence/cpd-sizing-investigation/source-comparison.txt @@ -0,0 +1,67 @@ +CPD sizing source comparison + +Plain 10.0.x +commit: c57c1c3206073f7b45fba69eefe9875985a4bee9 +result: src/app/dashboard/f-cross-dashboard.component.html does not exist + +Legacy Feature/Cross-Unit based on 10.0.x +commit: 187902747adb3a851bd3294b97b2a9d4a6e8ca95 +package version: 10.0.1-29 +component classes: + dashboard strip: panel-full-screen flex flex-row gap-8 px-16 overflow-x-auto + unit card: flex flex-col h-full w-[34rem] shadow + search: h-full w-64 p-2 px-4 text-md rounded-md +rendering context: + Bootstrap 3.4 sets html font-size to 10px +rendered sizes: + px-16 = 40 px each side + gap-8 = 20 px + w-[34rem] = 340 px + w-64 = 160 px + +Bootstrap removal +commit: a8ba06c01cd47937249d5780face427143b7dac4 +message: refactor: remove bootstrap and grunt packages +recorded migration rule: Tailwind spacing, sizing, and font classes render 60 percent larger after + Bootstrap removal, so existing utilities were reduced to 0.625 of their old scale + +CPD skeleton reintroduction +commit: 2c6d2786e9484c57436099d25281aa45fa89453c +finding: copied the legacy CPD utility classes after Bootstrap had been removed, reintroducing the + unscaled w-[34rem], w-64, px-16, gap-8, gap-4, and p-4 values + +Current feature/cross-unit docs-only replacement base +commit: 9962e7ea171a2bf6d7a12be50874fa5c7ee77e21 +package version: 11.0.0-45 +component classes: + dashboard strip: flex min-h-0 flex-1 flex-row gap-8 overflow-x-auto px-16 pb-4 + unit card: flex h-full w-128 shrink-0 flex-col shadow + search: text-md h-full w-56 rounded-md p-2 px-4 + unit scope: w-full max-w-64 +rendering context: + browser default html font-size is 16 px +rendered sizes: + px-16 = 64 px each side + gap-8 = 32 px + w-128 = 512 px + w-56 = 224 px + +Responsive sizing fix +commit: 854f9ce9ba5f100b0425996c3f5d7e248faa6b32 +changes: + unit scope: w-64 -> w-full max-w-64 + unit card: w-136 -> w-128 + search: w-64 -> w-56 +rendered effect at 16 px root: + card: 544 px -> 512 px + search: 256 px -> 224 px + +Comparison to legacy rendered values + card: 340 px -> 512 px (+172 px, +50.6 percent) + search: 160 px -> 224 px (+64 px, +40 percent) + side padding: 40 px -> 64 px (+60 percent) + inter-card gap: 20 px -> 32 px (+60 percent) + +Sizing fix conflict resolution +commit: 09b3d248f48b92b7b4a5cb58434eb9c3d0944017 +result: retained w-full max-w-64, w-128, and w-56 while integrating date controls diff --git a/docs/evidence/cpd-sizing-investigation/synthetic-cpd-comparison.html b/docs/evidence/cpd-sizing-investigation/synthetic-cpd-comparison.html new file mode 100644 index 0000000000..482a360bd1 --- /dev/null +++ b/docs/evidence/cpd-sizing-investigation/synthetic-cpd-comparison.html @@ -0,0 +1,500 @@ + + + + + + CPD sizing investigation - synthetic comparison + + + +
+

Cross-project dashboard sizing evidence

+

Repository sample data only

+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+
+
+

COS10001

+

Active

+
+ + + +
+
+
+
+
+

Getting started reflection

+

1.1P - Pass Task

+
+ +
+
+
+
+

Professional practice plan

+

2.1P - Pass Task

+
+ +
+
+
+
+

Evidence portfolio

+

3.1C - Credit Task

+
+ +
+
+
+ +
+
+
+

COS20007

+

Active

+
+ + + +
+
+
+
+
+

Communication report

+

4.1P - Pass Task

+
+ +
+
+
+
+

Team contribution review

+

5.1P - Pass Task

+
+ +
+
+
+ +
+
+
+

COS30046

+

Active

+
+ + + +
+
+
+
+
+

Capstone project proposal

+

6.1D - Distinction Task

+
+ +
+
+
+
+
+ + + + + + diff --git a/docs/evidence/cpd-sizing-investigation/verification.txt b/docs/evidence/cpd-sizing-investigation/verification.txt new file mode 100644 index 0000000000..e21fdc7bac --- /dev/null +++ b/docs/evidence/cpd-sizing-investigation/verification.txt @@ -0,0 +1,31 @@ +CPD sizing investigation docs-only replacement verification +Date: 2026-08-27 +Branch: docs/cpd-sizing-investigation +Target: feature/cross-unit at 9962e7ea171a2bf6d7a12be50874fa5c7ee77e21 + +1. npm run lint + Result: PASS + Detail: All files pass linting. + +2. npm run typecheck + Result: PASS + Detail: Angular compiler typecheck exited with code 0. + +3. npm run test:ci + Result: PASS + Detail: 68 test files passed. 168 tests passed and 1 existing test is marked todo. + +4. CI=true npm run build -- --no-progress + Result: PASS + Detail: Application bundle generation completed successfully. + +5. git diff --check + Result: PASS + Detail: No whitespace errors. + +6. Evidence integrity + Result: PASS + Detail: Every file listed in SHA256SUMS matches its recorded digest. + +The replacement diff contains only 25 files under docs/. It changes no application source, +configuration, dependency, or workflow file. diff --git a/docs/images/PPI-task-sheet-widget-v1.png b/docs/images/PPI-task-sheet-widget-v1.png new file mode 100644 index 0000000000..010199c6c6 Binary files /dev/null and b/docs/images/PPI-task-sheet-widget-v1.png differ diff --git a/docs/images/survey-distribution-chart.png b/docs/images/survey-distribution-chart.png new file mode 100644 index 0000000000..1704943d51 Binary files /dev/null and b/docs/images/survey-distribution-chart.png differ diff --git a/docs/images/teams-review-doc.png b/docs/images/teams-review-doc.png new file mode 100644 index 0000000000..d7a12d6dd3 Binary files /dev/null and b/docs/images/teams-review-doc.png differ diff --git a/docs/ppi-f02-unit-summary-handover.md b/docs/ppi-f02-unit-summary-handover.md new file mode 100644 index 0000000000..d459b84df0 --- /dev/null +++ b/docs/ppi-f02-unit-summary-handover.md @@ -0,0 +1,127 @@ +# PPI-F02 Unit Peer Progress Summary Handover + +## Delivered scope + +PPI-F02 adds a reusable mock-backed unit-level Peer Progress Indicator summary +to the student Progress Dashboard. + +The component displays: + +- the student's own unit progress; +- a separately labelled anonymous cohort aggregate; +- genuine zero progress; +- small-cohort suppression; +- unavailable data; +- stale data; +- disabled, loading and error states. + +The presentational component receives the existing typed +`PeerProgressUnitSummaryViewModel` through an input. + +It does not make an HTTP request, create another PPI service, or calculate its +own percentage formula. + +## Existing PPI work reused + +This implementation reuses: + +- `PeerProgressUnitSummary`; +- `PeerProgressUnitSummaryViewModel`; +- `resolvePeerProgressUnitSummaryState`; +- `PeerProgressIndicatorService.getDemoUnitSummary` (only while the local demo-mode switch is on); +- `calculateCompletionPercentage`. + +The existing task-sheet PPI widget and Progress Burndown implementation were +not modified. + +## Privacy boundary + +The component does not display peer names, student IDs, project IDs, individual +task statuses, marks, feedback, rankings, or raw cohort counts. + +Suppressed, unavailable and stale states remove the anonymous cohort percentage +before it reaches the component display. + +Disabled, loading and error states do not retain percentages that could be +mistaken for current information. + +A real 0% remains visibly different from unavailable or suppressed data. + +The unit summary is not displayed when staff are viewing another student's +project. + +## Remaining live unit-level API requirement + +PPI-F02 does not deliver a live backend unit-summary endpoint. + +A future backend endpoint and frontend adapter should: + +- authenticate the caller; +- derive student identity from the authenticated session; +- **derive the target grade server-side from the student's own project, and + never accept one from the browser.** `PeerProgressIndicatorService`'s mock + takes a caller-supplied `targetGrade` and its own comment warns that this + must not become the design of a live endpoint. A unit endpoint that trusts a + browser-supplied grade lets a student read another cohort's aggregate; +- authorise access to the requested unit/project; +- enforce minimum-cohort suppression server-side; +- **add a `student_percentage` field.** The task-level contract shipped in + doubtfire-api#16 does not carry one, so the card computes it in the browser + today. That is a stopgap, not the target design; +- **quantise the student percentage into the same buckets as the cohort + percentage.** doubtfire-api#16 quantises the cohort figure to 10-point + buckets. An exact student number beside a bucketed cohort number invites a + comparison neither supports; +- avoid peer identities and raw cohort counts; +- avoid marks, feedback and individual task-state information; +- expose feature-enabled, suppression, stale/unavailable and timestamp state; +- use appropriate audit logging without recording sensitive student content. + +The future frontend adapter can replace the mock call inside +`ProgressDashboardComponent.loadPeerProgressUnitSummary()` without redesigning +the presentational component. + +### Task-level adapter nullability + +doubtfire-api#16 returns `null` for `last_updated_at` and `target_grade` in +several documented states. The task-level live adapter now maps those values to +`PeerProgressIndicator.lastUpdatedAt: string | null` and +`PeerProgressIndicator.targetGrade: number | null`. + +### The mock must not reach a production build + +The unit-level summary in this document remains mock-backed and is gated on +`environment.production`, so a built application shows the unavailable state +rather than a fabricated percentage. The task-sheet `f-ppi-widget` now uses the +authorised project/task API route and does not render a fixture in production. + +## Validation + +Targeted component tests cover: + +- normal progress; +- genuine zero; +- small-cohort suppression; +- unavailable data; +- disabled data; +- stale data; +- changed component inputs. + +Final validation should include: + +- TypeScript typecheck; +- targeted PPI-F02 tests; +- lint; +- full frontend tests; +- build; +- `git diff --check`; +- normal desktop screenshot; +- normal narrow-screen screenshot; +- suppressed or unavailable screenshot. + +## Attribution and takeover + +PPI-F02 builds on the shared unit-summary foundation merged through PR #31. + +Any earlier work from the original assignee should remain credited and preserved. +The takeover message and branch/PR search should be retained as project evidence. diff --git a/docs/ppi-help-text-pack.md b/docs/ppi-help-text-pack.md new file mode 100644 index 0000000000..4d388814e0 --- /dev/null +++ b/docs/ppi-help-text-pack.md @@ -0,0 +1,299 @@ +# PPI-D01: Peer Progress Help Text and Privacy Copy + +**Ticket:** PPI-D01 - Write the peer-comparison explanation, privacy limits and help text +**Branch:** `docs/ppi-help-privacy` +**Scope:** Documentation and copy specification only. No production widget logic, API +behaviour or data flow is changed by this document. + +**Implementation status:** + +- The task-level location map is based on the current PPI widget. +- The unit-level copy is planned for the mock-backed PPI-F02 proof of concept. +- The unit-level section does not claim that a live unit-level component or API already + exists. +- Labels, help controls and expanded explanations still require a separate frontend + implementation. + +## Acceptance criteria coverage + +| Requirement | Result | Evidence | +| ----------------------------------------------------------------------- | ------ | -------------------------------------------------------------------- | +| Label, tooltip and expanded explanation for the task-level view | Met | Section 3 | +| Label, tooltip and expanded explanation for the planned unit-level view | Met | Section 4 | +| Explain that the result is an anonymous aggregate and not a ranking | Met | Section 1 and all visible-value states | +| Cover suppression, unavailable and stale data | Met | Sections 3 and 4 | +| Use plain language without shame or pressure | Met | All copy avoids ahead, behind, winning, losing and judgement wording | +| Map copy to exact current or planned UI locations | Met | Section 2 | +| Include one annotated screenshot or wireframe | Met | `assets/ppi/ppi-d01-copy-location-wireframe.svg` | + +## 1. Shared privacy and wellbeing rules + +These rules apply to both views: + +1. A group result is shown as one combined percentage. +2. It must not display names, student IDs, project IDs, individual results, individual + task statuses, marks, feedback, raw group size or rankings. +3. A displayed `0%` is a real privacy-bucket value. It must not be presented as missing, + suppressed or unavailable data, but it may represent a small percentage that rounds to zero. +4. A suppressed result explains that the group value is hidden for privacy without + revealing the exact group size. +5. Unavailable and stale states describe a data or timing condition. They must not sound + like feedback about the student's own work. +6. The wording must not describe the student as ahead, behind, better or worse than + anyone else. +7. The percentage is not a predicted grade or a judgement about the student's chance of + receiving a grade. +8. Student-facing text must not expose technical errors, internal identifiers or backend + details. + +This wording describes the intended display boundary. Backend authorisation, aggregation +and small-group suppression must still enforce that boundary. + +## 2. Copy placement and accessibility + +### 2.1 Current task-level location + +- **Widget template:** `src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component.html` +- **Parent mount location:** `src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/task-description-card.component.html` +- **State resolver:** `src/app/api/models/peer-progress-indicator-state.ts` +- **Current screen location:** inside the task description card, after the task date + controls. +- **State locations:** use the durable template cases rather than relying only on line + numbers: + - `@case ('success')` + - `@case ('no-data')` + - `@case ('hidden')` + - `@case ('unavailable')` + - `@case ('stale')` + - `@case ('disabled')` + +The task-level copy should use the three numbered locations in the wireframe: + +1. A visible label naming the information. +2. A short tooltip or help summary opened from an accessible help control. +3. An expanded explanation in a popover, panel or other accessible disclosure. + +### 2.2 Planned unit-level location + +The unit-level copy is planned for the PPI-F02 mock-backed proof of concept. It should use +the same three locations: + +1. **Label:** at the top of the unit progress summary card. +2. **Tooltip or short help text:** opened from the card's help control. +3. **Expanded explanation:** below the two progress values or inside an accessible + disclosure panel. + +The planned card should keep these values separate: + +- **My progress** +- **Anonymous group progress** + +The earlier unit-level wireframe used one anonymous Ready-for-Feedback value. The current +PPI-F02 scope requires the student's own progress and the anonymous group value to be +separately labelled. The annotated wireframe below keeps the earlier simple card style +while showing the current two-value boundary. + +![Annotated PPI copy-location wireframe](assets/ppi/ppi-d01-copy-location-wireframe.svg) + +### 2.3 Accessibility requirement for implementation + +The future help control must: + +- be a real button with a useful accessible name; +- work with keyboard, touch, and screen-reader use; +- not depend on mouse hover alone; +- expose expanded text in a predictable reading and focus order; +- close without trapping keyboard focus; +- remain understandable without colour; +- avoid using an icon as the only explanation. + +Suggested accessible name: + +> About peer progress + +## 3. Task-level copy + +### 3.1 Normal result + +**Template state:** `@case ('success')` + +- **Label:** Peer task progress +- **Tooltip or short help text:** Shows the percentage of students in your + privacy-protected target-grade group who have submitted this task. +- **Expanded explanation:** This is one combined percentage for students aiming for the + same target grade. It does not show names, individual results, raw group size or + rankings. It does not assess your own work or predict your grade. + +### 3.2 Rounded zero result + +**Template state:** `@case ('no-data')` + +- **Label:** Peer task progress +- **Tooltip or short help text:** The privacy-protected group percentage currently rounds + to 0%. This is a displayed value, not missing data. +- **Expanded explanation:** The API rounds percentages into privacy-safe bands, so 0% may + include a small number of submissions. It is not feedback about your own progress. The + displayed percentage may change as submissions are made. + +### 3.3 Small-group suppression + +**Template state:** `@case ('hidden')` + +- **Label:** Peer task progress hidden +- **Tooltip or short help text:** The group percentage is hidden because the group is too + small to show safely. +- **Expanded explanation:** OnTrack hides the group percentage to protect student + privacy. It does not show the hidden value, exact group size, names or individual + results. This is a privacy safeguard, not an error or a judgement about your work. + +### 3.4 Unavailable data + +**Template state:** `@case ('unavailable')` + +- **Label:** Peer task progress unavailable +- **Tooltip or short help text:** Current group progress is not available. This does not + describe your own progress. +- **Expanded explanation:** OnTrack cannot show a current group percentage for this task + right now. This is a data-availability state, not feedback about your work. Check again + later. + +### 3.5 Stale data + +**Template state:** `@case ('stale')` + +- **Label:** Peer task progress not current +- **Tooltip or short help text:** The group value has not refreshed recently, so it is not + shown as current. +- **Expanded explanation:** The latest group percentage may be out of date. OnTrack does + not present it as current because that could be misleading. This is a timing issue, not + feedback about your own progress. + +### 3.6 Feature disabled + +**Template state:** `@case ('disabled')` + +- **Label:** Peer task progress off +- **Tooltip or short help text:** Peer progress is turned off for this unit. +- **Expanded explanation:** Peer comparison is not shown for this unit. This message only + describes whether the feature is available. It is not a judgement about your work, + submission or progress. + +### 3.7 Supporting loading and error copy + +These states already exist in the task-level widget and should use the same accessible +help pattern when the copy is implemented. + +- **Loading:** Loading peer progress. +- **Error:** Could not load peer progress. Please try again. + +The error message must remain generic and must not reveal peer details, internal IDs, +authorisation rules or backend errors. + +## 4. Planned unit-level copy + +This section is design-ready copy for the mock-backed PPI-F02 proof of concept. It is not a +claim that live unit-level data has been delivered. + +### 4.1 Normal result + +- **Label:** Unit progress overview +- **Tooltip or short help text:** Shows your own unit progress beside one + privacy-protected group percentage. +- **Expanded explanation:** My progress and Anonymous group progress are separate values. + They use the agreed unit-progress measure. The group value is one combined percentage + and does not show names, individual results, raw group size, marks, feedback or + rankings. + +### 4.2 Rounded zero group result + +- **Label:** Unit progress overview +- **Tooltip or short help text:** Anonymous group progress currently rounds to 0%. This is + a displayed value, not missing data. +- **Expanded explanation:** Your own progress remains separate from the group result. A + displayed group value of 0% may include a small value that rounds to zero. It is not + feedback about your work and does not predict your grade. + +### 4.3 Small-group suppression + +- **Label:** Anonymous group progress hidden +- **Tooltip or short help text:** The group percentage is hidden because the group is too + small to show safely. +- **Expanded explanation:** Your own progress may still be shown when it is available, but + the anonymous group percentage is hidden for privacy. OnTrack does not show the hidden + percentage, exact group size, names or individual results. + +### 4.4 Unavailable group data + +- **Label:** Anonymous group progress unavailable +- **Tooltip or short help text:** Your own progress may remain visible, but current group + progress is unavailable. +- **Expanded explanation:** OnTrack cannot show a current anonymous group percentage + right now. This does not change or assess your own progress. Check again later. + +### 4.5 Stale group data + +- **Label:** Anonymous group progress not current +- **Tooltip or short help text:** The group value has not refreshed recently and is not + shown as current. +- **Expanded explanation:** The latest anonymous group percentage may be out of date. + OnTrack does not present it as current because that could be misleading. Your own + progress remains a separate value. + +### 4.6 Feature disabled + +- **Label:** Unit peer progress off +- **Tooltip or short help text:** Peer progress is turned off for this unit. +- **Expanded explanation:** The unit-level peer comparison is not shown. This message only + describes feature availability. It is not feedback about your work, submission or + progress. + +### 4.7 Supporting loading and error copy + +- **Loading:** Loading unit progress. +- **Error:** Could not load unit progress. Please try again. + +A failed request or unit change must not leave the previous unit's anonymous group value +visible as though it were current. + +## 5. Implementation status and handover + +| Item | Status after PPI-D01 | +| ------------------------------------------------- | ---------------------------------------------------- | +| Task-level state copy | Complete in this document | +| Task-level label, help control and expanded panel | Planned frontend implementation | +| Unit-level state copy | Complete as planned PPI-F02 copy | +| Unit-level component | Separate PPI-F02 work | +| Live unit-level API | Future work and not claimed here | +| Annotated copy-location evidence | Complete in the sanitised SVG wireframe | +| Privacy and authorisation enforcement | Separate backend and security testing responsibility | + +### Known implementation follow-ups + +1. The current task-level template contains a visible message but no dedicated accessible + help control or expanded explanation location. +2. The genuine zero message is currently handled differently from several other states. + A frontend follow-up should decide whether all state messages use one consistent + source. +3. Stale and unavailable states need clearly different visible messages when this copy is + implemented. +4. The disabled state must remain neutral. The frontend must not claim that a particular + role or person disabled the feature unless the trusted response explicitly supplies + that information. +5. The exact unit-progress metric must be confirmed by the implementation and calculation + tickets before the planned unit-level wording is wired into production. + +## 6. Evidence and attribution + +- Original PPI-D01 task-level research and copy: + - David Tenni + - commits `c98e044` and `c5b379d` +- Earlier unit-level visual baseline and usability work: + - Umeda Ranuluge + - used as a design reference without copying personal identifying details +- Acceptance-criteria completion, privacy wording correction, sanitised annotated + wireframe and handover: + - Maple Fox +- Documentation: + - `docs/ppi-help-text-pack.md` +- Annotated wireframe: + - `docs/assets/ppi/ppi-d01-copy-location-wireframe.svg` diff --git a/docs/service-worker.md b/docs/service-worker.md new file mode 100644 index 0000000000..818a6c5579 --- /dev/null +++ b/docs/service-worker.md @@ -0,0 +1,147 @@ +# The service worker in development + +Push notifications are received by a service worker. Until MN-F03, the service +worker only ran in production builds, so **no push could ever arrive while +developing** and nobody could test any push work. + +This describes what changed, what it costs, and how to get out of trouble. + +## What changed + +Two halves. Doing only one of them leaves the app worse than before. + +1. **`angular.json`** — the `development` build configuration now sets + `"serviceWorker": "ngsw-config.json"`. Before this it was set only on + `production`, so a development build never generated `ngsw-worker.js` at all. +2. **`doubtfire-angular.module.ts`** — `ServiceWorkerModule.register` now reads + `environment.production || environment.enableServiceWorker` instead of + `environment.production`. + +Order matters. Flipping the flag without the first half leaves the app asking the +server for a file that does not exist. That fails quietly and looks exactly like +push being broken. + +## Turning it off + +`src/environments/environment.ts`: + +```ts +enableServiceWorker: false, +``` + +Then reload with the worker cleared (below). Production is unaffected either way, +because `production: true` already enables it. + +## Which build configuration is actually in use + +`package.json` runs `ng serve --configuration $NODE_ENV`, so **the configuration +depends on an environment variable and is not necessarily `development`.** +"I added it to `development`" does not mean `development` is the one running. + +Check what `$NODE_ENV` is: + + docker exec doubtfire-web printenv NODE_ENV + +In the Docker stack it is `docker`. That is a **serve** configuration in +`angular.json`, and it maps to the `development` **build** configuration: + +```json +"docker": { "buildTarget": "doubtfire:build:development" } +``` + +So the change does apply in Docker. If you add another serve configuration, point +it at a build configuration that has `serviceWorker` set, or push silently stops +working for anyone using it. + +## Registration is delayed six seconds + +`doubtfire-angular.module.ts` sets: + +```ts +registrationStrategy: () => interval(6000).pipe(take(1)), +``` + +The worker registers **six seconds after bootstrap**, not at bootstrap. Anything +that asks for the service worker during app init finds nothing there. Wait on +`navigator.serviceWorker.ready` rather than assuming it exists — MN-C01 depends +on this. + +## What it costs + +**Measured** on the Docker stack, Angular 22, `ng serve`: + +- `ngsw-worker.js` and `ngsw.json` are served (they were 404 before). The dev + server generates them, so no separate `ng build` step is needed. +- `ngsw.json` lists 158 hashed files, and the `app` asset group prefetches + `/index.html`, `/main.js`, `/styles.css`, `/polyfills.js` and `/scripts.js`. + **The whole app bundle is cached.** +- A source change does regenerate the manifest: after editing a file under `src/` + the `/main.js` hash in `ngsw.json` changed, so the worker can see there is an + update. +- API calls are not cached. The `api` data group in `ngsw-config.json` uses + `"strategy": "freshness"` with `maxAge: 0u` and `maxSize: 0`. + +**Confirmed in a browser** on 2026-08-02: the worker registers, and a push sent +from the api arrives as a desktop notification. So the two halves above are +enough to make push work in development. + +What still has not been measured is how live reload behaves once the worker is +serving from its cache over a long session. Angular's worker normally picks up a +new version on a later page load rather than the current one, which would mean +**after saving a file you reload and still see the old code**. Treat that as +expected until somebody hits it. + +The cost to watch for is stale app code, not stale data — API responses are not +cached. If something you just changed is not showing up, clear the worker before +assuming the change is wrong. + +## Push subscription rotation + +Angular 22's worker handles the browser's `pushsubscriptionchange` event and +forwards it through `SwPush.pushSubscriptionChanges`. The app starts that +listener with its other push lifecycle services. When the browser supplies a +replacement, the app POSTs it to `/api/push_subscriptions` first and removes the +old endpoint only after the replacement is stored. A keys-only rotation keeps +the same endpoint and updates the existing api row in place. + +The focused service tests simulate this event because browsers do not provide a +reliable way to force a real rotation. They verify the POST-before-DELETE order, +keys-only updates, teardown, and that one failed api request does not stop later +rotations. A change event with no replacement cannot be repaired automatically: +creating a fresh subscription may require a user gesture, so the user must use +the existing opt-in control again; the api removes the dead row after a failed +delivery. + +## Clearing a stuck service worker + +Fastest, in the browser console: + +```js +(await navigator.serviceWorker.getRegistrations()).forEach((r) => r.unregister()); +const keys = await caches.keys(); +await Promise.all(keys.map((k) => caches.delete(k))); +location.reload(); +``` + +Through dev tools instead: + +1. Application → Service Workers → **Unregister**. +2. Application → Storage → **Clear site data**. +3. Reload. + +While actively working on the app, Application → Service Workers → **Bypass for +network** stops the worker serving cached responses without unregistering it. A +normal hard reload is not enough on its own, because the worker still intercepts. + +## Checking it is working + + curl -s -o /dev/null -w "%{http_code}\n" http://localhost:4200/ngsw-worker.js + +200 is what you want. 404 means the build configuration in use has no +`serviceWorker` entry — see "Which build configuration is actually in use". + +In the browser: dev tools → Application → Service Workers. It should be +registered and activated roughly six seconds after the page loads. + +Then follow `doubtfire-api/docs/notifications/push-setup.md` to register the +browser and send a push. diff --git a/docs/task-sheet-widget-feedback.md b/docs/task-sheet-widget-feedback.md new file mode 100644 index 0000000000..a427d5791a --- /dev/null +++ b/docs/task-sheet-widget-feedback.md @@ -0,0 +1,190 @@ +# PPI-D03: Proof of Concept, Feedback and Design Evaluation + +**Ticket:** PPI-D03 - Gather feedback and document the task-sheet PPI design decisions +**Branch:** `docs/ppi-task-sheet-widget-feedback` +**Group:** Feature/Peer Progress Indicator +**Resource:** `PPI-task-sheet-widget-v1.png` +**Resource Attribution:** Eloise Ridder-Strickland + +![Original task-sheet Peer Progress Indicator design showing the proposed indicator options and placement concepts](images/PPI-task-sheet-widget-v1.png) + +**Purpose** +> To identify design recommendations for the development of the Peer Progress Indicator widget, in line with user, stakeholder and project scope/expectations and accessibility, usability and style guidelines. + +### Implementation status + +*Proof of concept.* This document does not claim that a live unit-level component or API is implemented or already exists. The proposed widget could show an authorised, anonymous cohort aggregate. Live accuracy, refresh timing and the required unit-level data source remain future implementation and validation work. + +### Privacy and evidence handling + +Reviewer evidence is de-identified. Reviewer names are omitted unless consent to publish them is recorded. The evidence does not include student IDs, marks, student assessment feedback, progress records, credentials, secrets or real student data. + +### Acceptance Criteria Coverage + +| | Requirement | Result | Evidence | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------ | ------ | -------------------------------------------------------------------------- | +| `1.` | The original ticket, mock-up and author are clearly identified. | *Met* | Header and Section 7. | +| `2.` | At least two people review the design. A third reviewer is encouraged but not required. | *Met* | Two formal reviewers represented by general roles in Section 2. | +| `3.` | One reviewer represents implementation or technical knowledge and one represents likely users, tutors, UX or documentation. | *Met* | Section 2. | +| `4.` | The same core questions are used for each review. | *Met* | Sections 1.2 and 2.1; both formal reviewers answered the same follow-up question. | +| `5.` | The Markdown file clearly separates reviewer feedback from Eloise's conclusions. | *Met* | Sections 2 and 3. | +| `6.` | The evidence does not expose student IDs, marks, feedback, progress records or other unnecessary personal information. | *Met* | Privacy statement and de-identified roles in Section 2. | +| `7.` | The review covers clarity, placement, usefulness, anonymity, accessibility, possible stress or competition, and the user preference. | *Met* | Sections 3 to 5. | +| `8.` | Each suggested change is marked as accepted, deferred or not adopted, with a short reason. | *Met* | Section 3. | +| `9.` | Any revised design is optional and does not block completion. | *Met* | Section 6. | +| `10.` | If a revision is made, the original image remains in the repository so the design history is preserved. | *Met* | `docs/images/PPI-task-sheet-widget-v1.png`. | +| `11.` | The documentation does not claim that the design has been implemented or connected to live data. | *Met* | Implementation status and Sections 4 to 6. | +| `12.` | The pull request links the original Planner ticket, this follow-up ticket and the supporting evidence. | *Met* | PR description and Section 7. | + +## 1. Methodology + +Two formal reviewers evaluated the same mock-up using the same survey question set, including the same follow-up improvement question. Earlier project discussions and general Teams comments were retained as supplementary discovery evidence and were not counted as formal reviews. + +### 1.1 Evidence sources + +1. **Supplementary discovery discussion** before the mock-up was developed, involving project leadership and mentors: [Ontrack - Sprint Planning (Fortnightly)](https://deakin365.sharepoint.com/:v:/r/sites/ThothTech2/Shared%20Documents/Product%20-%20OnTrack/Recordings/Ontrack%20-%20Sprint%20Planning%20\(Fortnightly\)-20260720_180013-Meeting%20Recording.mp4?d=w0b96f239856c4927b309d448a06b7e8c&csf=1&web=1&e=e0uYqS&nav=eyJyZWZlcnJhbEluZm8iOnsicmVmZXJyYWxBcHAiOiJTdHJlYW1XZWJBcHAiLCJyZWZlcnJhbFZpZXciOiJTaGFyZURpYWxvZy1MaW5rIiwicmVmZXJyYWxBcHBQbGF0Zm9ybSI6IldlYiIsInJlZmVycmFsTW9kZSI6InZpZXcifX0%3D) +2. **Supplementary open group discussion** after the initial mock-up was shared in Teams. + +![Redacted Teams feedback discussing the PPI options, placement and local visibility control](images/teams-review-doc.png) + +3. **Formal survey review** completed by two reviewers using the same evaluation questions: [Peer Progress Indicator Mock-up Evaluation](https://docs.google.com/forms/d/e/1FAIpQLSeXGCQzNpgdOlEv686l91L1ko58_SnHXD6obsdpx---WXpTzA/viewform?usp=dialog) + +### 1.2 Evaluation questions + +#### Feedback profile + +1. Which feature group are you a part of? +2. Do you have previous UI or general design experience? (Yes/No) + +#### Context and design + +3. Without context, what do you think the Peer Progress Indicator primarily shows? +4. Which indicator design option is more useful or comfortable to view, and why? +5. ~~Which placement option is more useful or comfortable to view, and why?~~ - *Removed as redundant with Question 6.* +6. Which placement is easiest to notice and understand without cluttering the task sheet? + +#### Accessibility and privacy + +7. Is it clear that this is an anonymous cohort value rather than a ranking? +8. Is the local show/hide option clear for users who do not want to view peer progress? +9. If this feature were available, would you choose to show or hide it by default? +10. What is the main reason for that preference? + +#### General considerations + +11. Are there accessibility concerns that are not addressed in the mock-up? +12. Is a local On/Off control appropriate, and could you suggest a better location or label? +13. What is the single most useful improvement you would make? - *Completed as the same follow-up question for both formal reviewers.* + +## 2. Aggregated Feedback Summary + +The following are de-identified extracts from the two formal survey reviews and supplementary team communications about *PPI-task-sheet-widget-v1.png*. + +The formal reviewer roles were: + +- A **technical/design reviewer** with relevant implementation or design knowledge. +- A **likely end-user reviewer** representing student usability needs. + +Earlier PPI contributor and team discussions were retained as supplementary discovery evidence and were not counted as additional formal reviews. + +![Survey respondent distribution showing the two formal reviewers across the recorded design-experience categories](images/survey-distribution-chart.png) + +### 2.1 Common improvement question + +Both formal reviewers were asked the same missed question as a follow-up. + +- **Technical/design reviewer:** The single most useful improvement I would make is to keep the richer Option B concept, but introduce it gradually rather than showing every task status immediately. I would start with a simple anonymous comparison, keep the Mockup 02 placement, and include a clear show/hide preference. Once the data and behaviour are proven to be accurate and useful, the additional status breakdowns could be added later. +- **Likely end-user reviewer:** The single most useful improvement for me would be giving students clear control over whether the indicator is shown. I like the Mockup 02 placement because it is visible without getting in the way, but I would keep the wording neutral and make it easy to hide so students who find peer comparison distracting or stressful do not have to see it. + +| Strength | Concerns | Suggestion | +| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | +| Option B - it gives a clearer outline. | Option B - gives a lot more information but could scare people who aren't ready to see a lot of information. | Option A - with a toggle for Option B called 'Advanced'. for people who would like to see an advanced view (Option B). | +| Option B - the data is helpful for me. | Option B - could cause issues for subjects that have tutors that mark stuff really quickly and others that don't so it might cause undue stress on students. | An overall dark mode would be very interesting / helpful | +| Placement 1 - the information is where you want it when scanning over a page. | 'InProgress' aren't used by many students so it's not useful data. | Having the Opt-In/Opt-out in the same area as the 'placement' would likely be easier for most/all students as it's simple to find. | +| Placement 1 - does not over cutter the reading space. | Option B - I don't think it would be practical to share that much information with students. | As far as placement goes I think 1 or 3. | +| Opt-in, because it is useful and clear. | Example Toggle - The wording is wrong, we won't have an opt-out for statistics. | Option B - looks good but I think that's more of a stretch goal. | +| Opt-in, I'd would like see the data & would want others to benefit off it as well. | The indicator itself might give some students anxiety because they feel behind. | | +| It is a good feature and is appropriate. | | | +| I'm a big fan of Option B, Mock-up 2 placement and the toggle. | | | +| If people don't like it they can just turn it off. | | | + +## 3. Decision Table + +Each item is marked as **Accepted**, **Deferred**, or **Not adopted**, with a short reason. Wellbeing concerns are treated as real design risks rather than dismissed because a visibility control exists. + +| Status | Reviewer feedback | Design conclusion and reason | +| --- | --- | --- | +| **Accepted** | *Placement 1 - the information is where you want it when scanning over a page.* | Use Placement 1 as the baseline because it is central, easy to notice and contextually relevant. | +| **Accepted** | *Placement 1 - does not overly clutter the reading space.* | Keep the first view compact so the submission workflow remains the main focus. | +| **Accepted** | *If people do not like it they can turn it off.* | Provide a clear local show/hide control beside the widget. This reduces exposure but does not remove the underlying wellbeing risk. | +| **Accepted** | *Option B gives a clearer outline.* | Use this to inform a possible future advanced view while keeping Option A as the simpler baseline. | +| **Accepted** | *'InProgress' is not used by many students, so it is not useful data.* | Exclude this metric from the first proof of concept because reviewers did not identify enough value to justify the extra detail. | +| **Accepted** | *Place the visibility control in the same area as the widget.* | A local control is easier to find and clearly communicates that it hides only the visual widget. | +| **Accepted** | *The example toggle wording suggests an opt-out from statistics.* | Correct the interface wording. Users may show or hide the widget locally; this does not imply opting out of authorised anonymous aggregation. | +| **Accepted as risk** | *Option B could scare people who are not ready to see that much information.* | Valid wellbeing and cognitive-load risk. Start with Option A, avoid ranking language and validate the default visibility before implementation. | +| **Accepted as risk** | *Different tutor marking speeds could create undue stress.* | Valid interpretation risk. Any future implementation needs neutral wording, explanation of data limits and testing across different unit workflows. | +| **Accepted as risk** | *The indicator might make some students anxious because they feel behind.* | Valid wellbeing risk. A show/hide control reduces but does not remove it; the feature needs safe defaults and user testing. | +| **Deferred** | *Add an Advanced option that expands Option A into Option B.* | Useful future extension, but outside the first proof-of-concept scope. | +| **Deferred** | *Add an overall dark mode.* | Potentially useful but outside this feature's scope. Existing OnTrack accessibility and theme work should be considered separately. | +| **Deferred** | *Option B looks good but is a stretch goal.* | Retain as a future option after the simple baseline and data contract are validated. | +| **Not adopted** | *It is a good feature and is appropriate.* | Positive but too general to create a separate design action. | +| **Not adopted** | *Option B data is helpful to me.* | Recorded as useful feedback, but one personal preference does not justify making the detailed view the default. | +| **Not adopted** | *I would show it because it is useful and clear.* | Supports perceived usefulness but does not create a separate design change. | +| **Not adopted** | *Option B is not practical to share with students.* | Addressed through the simpler Option A baseline and the deferred advanced view rather than rejecting the whole concept. | +| **Not adopted** | *Placement 1 or 3.* | Too broad to act on directly; the more specific placement feedback supports Placement 1. | +| **Not adopted** | *I would show it and want others to benefit from it.* | Recorded as a preference, but it does not determine the default for all students. | + +## 4. Impact Consideration Summary + +| Area | Functional considerations | Non-functional considerations | +| --- | --- | --- | +| **Clarity** | Option A provides a simple baseline. Option B adds a detailed stage breakdown and may be considered later. | Reduce visual clutter, use neutral wording and clearly explain that the value is an anonymous cohort aggregate rather than a ranking. | +| **Placement** | Consider Placement 1 as the initial design, with the local show/hide control beside the widget. | Keep the primary task submission workflow central and place the indicator near related information. | +| **Usefulness** | The proposed widget could show an authorised, anonymous aggregate comparison. Live accuracy, refresh timing and the required data source still need implementation and validation. | The feature may support pacing and reassurance, but it must not imply that cohort progress determines an individual student's success. | +| **Anonymity** | Use aggregated percentage metrics and prevent access to individual student records. | Protect psychological safety and test authorisation so users cannot infer or access another student's progress. | +| **Accessibility** | Provide accessible text equivalents, screen-reader labels and keyboard operation for controls and tooltips. | Do not rely on colour alone. Use sufficient contrast, patterns or labels, and test the design with accessibility tooling and users where possible. | +| **Wellbeing** | Give users a clear local show/hide control and avoid ranking or competitive language. | Seeing peer completion rates may create stress, competition or a false sense of security. The control reduces but does not remove these risks. | +| **User preference** | Allow students to show or hide the widget locally without implying that they can opt out of authorised anonymous aggregation. | Use a considered default, preserve user agency and minimise sensitive information shown outside the main OnTrack context. | + +## 5. Summary of Findings + +Based on the available feedback, the first proof-of-concept direction should include: + +- **Placement 1:** It places the information where students naturally scan while keeping it near the relevant task context. +- **Option A as the baseline:** Option B received positive feedback, but Option A is a more realistic first step. A detailed advanced view can remain future work. +- **Local show/hide control:** Place the control on or beside the widget. Make it clear that this controls local visibility only. +- **Neutral and accessible wording:** Explain that the value is anonymous and is not a ranking. Avoid competitive language and do not expose individual student information. +- **Focused metrics:** Omit `InProgress` from the first proof of concept because the reviewers did not identify enough value to include it. +- **Wellbeing safeguards:** Treat stress, anxiety, tutor marking-speed differences and false reassurance as design risks that require safe defaults and validation. + +Avoid: + +- **Placement 2 as the initial choice:** The specific feedback more strongly supports Placement 1. +- **Global statistical opt-out wording:** The local control hides the widget; it does not imply an opt-out from authorised anonymous aggregation. +- **Mandatory persistent display:** A forced view could negatively affect some students and removes user agency. +- **Claims of live or real-time accuracy:** No live unit-level data source or implemented widget is claimed in this document. + +### Implementation notes + +> **User agency and wellbeing:** Put a binary show/hide control directly on or beside the widget and validate the default before implementation. + +> **Data and security:** Use authorised aggregate metrics, filter out irrelevant statuses, minimise exposed information and test access controls. + +> **Accessibility:** Provide high-contrast visual cues, text equivalents, screen-reader labels, keyboard operation and clear tooltips where applicable. + +> **Validation:** Test comprehension, accessibility, privacy, different tutor marking patterns and possible stress effects before treating the design as production-ready. + +## 6. Handover and Recommendations + +1. **Implement Option A only as a future baseline**, keeping Option B or an Advanced View as a later stretch goal after the data source and user need are validated. +2. **Use correct interface wording:** state that the control shows or hides the visual widget locally rather than suggesting an opt-out from authorised anonymous aggregation. +3. **Prototype Placement 1:** place the compact widget and local control near the task context, then test that it does not interfere with submission work. +4. **Define the data contract and security boundary:** document the authorised aggregate fields, refresh behaviour, cohort rules, suppression thresholds and access-control tests before connecting live data. +5. **Validate accessibility and wellbeing:** test neutral wording, keyboard and screen-reader support, colour independence, default visibility and responses across different marking speeds. +6. **Preserve design history and evidence:** keep the original image, de-identified feedback, decisions, risks and follow-up reviewer answers so future contributors can understand why the design changed. + +## 7. Reference Links +--- +*Links* +- [Planner - PPI-D03 - Gather feedback and document the task-sheet PPI design decisions](https://teams.microsoft.com/l/entity/com.microsoft.teamspace.tab.planner/planner.v1.df787fb0-1a0b-4bb5-9bc9-9b92a428eb13_p_KuipOoIL0E-7orl64gLd0cgAGv3z?tenantId=d02378ec-1688-46d5-8540-1c28b5f470f6&webUrl=https%3A%2F%2Ftasks.teams.microsoft.com%2Fteamsui%2FpersonalApp%2Falltasklists&context=%7B%22subEntityId%22%3A%22%2Fv1%2Fplan%2FKuipOoIL0E-7orl64gLd0cgAGv3z%2Fview%2Fboard%2Ftask%2FU0BJo6N2Mkq_kw3IYFFWuMgAAM_I%22%2C%22channelId%22%3A%2219%3Abd20175d09414f079490a2403f7fca74%40thread.tacv2%22%7D) +- [Planner - PPI - Draft a small task-sheet indicator/widget interactions](https://teams.microsoft.com/l/entity/com.microsoft.teamspace.tab.planner/planner.v1.df787fb0-1a0b-4bb5-9bc9-9b92a428eb13_p_KuipOoIL0E-7orl64gLd0cgAGv3z?tenantId=d02378ec-1688-46d5-8540-1c28b5f470f6&webUrl=https%3A%2F%2Ftasks.teams.microsoft.com%2Fteamsui%2FpersonalApp%2Falltasklists&context=%7B%22subEntityId%22%3A%22%2Fv1%2Fplan%2FKuipOoIL0E-7orl64gLd0cgAGv3z%2Fview%2Fboard%2Ftask%2FYQjP6F8GfkaDmq5IgUryQcgAL9Ay%22%2C%22channelId%22%3A%2219%3Abd20175d09414f079490a2403f7fca74%40thread.tacv2%22%7D) diff --git a/docs/theme/THEME-CONTRACT.md b/docs/theme/THEME-CONTRACT.md new file mode 100644 index 0000000000..85bb2b64ed --- /dev/null +++ b/docs/theme/THEME-CONTRACT.md @@ -0,0 +1,1522 @@ +# OnTrack theme contract + +**Ticket:** THM-D02 · **Status:** Draft, awaiting approval · **Applies to:** `doubtfire-web` + +This is the contract every contributor works to before anyone edits styles for Light/Dark/System. +It fixes the theme states, the semantic token names, how the preference is stored, the +accessibility floor, and what the implementation tickets must prove. It does not implement +anything. + +Read sections 4, 6, 7 and 10 before writing any style code. The rest is why. + +--- + +## 1. Missing inputs, and what is provisional because of them + +THM-D02 was scheduled after two tickets that have not started. + +| Ticket | Owner | What it was meant to hand over | State | +| ------- | ----------------- | -------------------------------------------------- | ----------- | +| THM-D01 | Owen Brian Costin | Theme audit and dark-mode blueprint | Not started | +| MG-05 | Unassigned | CSS style guide (naming, layering, file ownership) | Not started | + +So the audit in section 2 was done from scratch against the working tree at +`origin/11.0.x`. Every number in it comes from a command that is printed next to it, and every +statement about how OnTrack styles itself today comes from a file that was opened. + +Three things stay provisional until those two tickets land. + +| Provisional | Why | Who settles it | +| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| The token **naming prefix** `--ot-*` and the `--ot-color-* / --ot-status-* / --ot-chart-*` grouping | MG-05 owns naming. `--ot-` was chosen because it collides with nothing in the tree today (section 2.6). | MG-05 | +| The **file layout** in section 10 (`src/styles/tokens/_light.scss`, `_dark.scss`) | MG-05 owns which layer may declare a colour. | MG-05 | +| The **per-page migration order** for THM-M01 to THM-M04 | THM-D01's audit was meant to rank pages by risk. Section 2.5 lists the hard surfaces but not an order. | THM-D01, and THM-M05 for the FlexLayout sequencing | + +Everything else here — states, storage, tokens, values, accessibility floor, test list — is +proposed as final and is not waiting on either ticket. + +--- + +## 2. Audit of the current stylesheet, done for this ticket + +Commands were run from the repo root on branch `docs/theme-contract`, rebased onto `origin/11.0.x` +at **`4034e7d1a`** (27 Aug 2026, the closure merge of PR #105). Every count below was re-taken on +that commit. Appendix B has the full list with each command beside its result, and the note there +on which nine numbers moved when the closure branch landed. + +### 2.1 Versions + +`package.json`: Angular `^22.0.3`, Angular Material `^22.0.2`, Tailwind via +`@tailwindcss/postcss ^4.3.2`, tests on `vitest ^4.1.9` through the +`@angular/build:unit-test` builder. Node `>=22.22.3`. + +The Thoth Tech handover says Angular 17 and Karma. Both are stale. Do not plan against them. + +### 2.2 How Material is themed today + +`angular.json` loads two global stylesheets, in this order: `src/theme.scss`, then +`src/styles.scss`. + +`src/theme.scss` builds a **Material 2** theme. It defines three palettes by hand +(`$md-formatif` from `#3939ff`, an accent, a warn), calls `mat.m2-define-light-theme(...)`, then +`@include mat.all-component-themes($theme)`. There is one theme and it is light. The file defines +no dark theme at all — line 162 only reads `is-dark` back off the light config — and there is no +`.dark-theme` class anywhere in the tree. The repo's only `$dark-theme` is at +`src/styles/m3-theme.scss:158`, in the file nothing imports. + +`src/styles/m3-theme.scss` **already exists**, is 177 lines, and is generated +(`// This file was generated by running 'ng generate @angular/material:m3-theme'`). It defines +**both** `$light-theme` and `$dark-theme` with `theme-type: light` / `theme-type: dark`, +`use-system-variables: true` and `system-variables-prefix: sys`. + +It is not used. `src/styles.scss` lines 24-25 have it commented out: + +```scss +// This is the new Angular Material 3 theme file which we will migrate to +// @use './styles/m3-theme.scss'; +``` + +``` +$ grep -rIn -- "--sys-" src/ | wc -l +0 +``` + +Nothing consumes it. Turning that comment into code is **not** the plan — see section 3. + +`mat.app-background()` and `mat.elevation-classes()` are each included twice, once in +`theme.scss` and again in `styles.scss`. Harmless duplication today, worth a cleanup ticket, not +this one. + +### 2.3 The colours the app actually paints + +Because the M2 light theme is what runs, the base surfaces come from Material's own light +palettes at `node_modules/@angular/material/core/m2/_palette.scss`: + +| Role | M2 source | Value | +| -------------- | ------------------------------------------------------ | --------------------- | +| page | `$light-theme-background-palette.background` = grey 50 | `#fafafa` | +| card, dialog | `.card` / `.dialog` | `#ffffff` | +| app bar | grey 100 | `#f5f5f5` | +| body text | `rgba(black, 0.87)` | flattens to `#212121` | +| secondary text | `rgba(black, 0.54)` | flattens to `#757575` | +| disabled text | `rgba(black, 0.38)` | flattens to `#9e9e9e` | +| dividers | `rgba(black, 0.12)` | flattens to `#e0e0e0` | + +Those four flattened values land exactly on M2 grey 900 / 600 / 500 / 300. That is not a +coincidence, it is how the palette was built, and it is why the light column in section 7 is a +derivation and not an invention. + +The four **text and divider** rows are reproducible without Material installed: they are the +alpha values above composited over white, and `flatten()` in Appendix A returns `#212121`, +`#757575`, `#9e9e9e` and `#e0e0e0` exactly. The three **surface** rows (`#fafafa`, `#ffffff`, +`#f5f5f5`) are read from the M2 palette, and `node_modules` is not installed in this worktree, so +they are quoted from the package rather than re-checked here. They are the stock M2 grey 50 / +white / grey 100 and nothing in the repo overrides them, but treat the file path as a pointer +rather than a citation until someone re-runs it with dependencies installed. + +### 2.4 Hard-coded colour, measured + +``` +$ grep -rIoE '#[0-9a-fA-F]{3,8}\b' src --include='*.scss' | wc -l +471 +$ grep -rIlE 'rgba?\(|hsla?\(' src --include='*.scss' | wc -l +25 +$ find src -name '*.scss' | wc -l +189 +$ grep -rIoh '!important' src --include='*.scss' | wc -l +63 +``` + +238 of the 471 hex literals are in three files that are palettes by design +(`m3-theme.scss` 107, `theme.scss` 84, `task-status-colors.scss` 47). Of the rest, **226 are +scattered across component SCSS under `src/app`**, 6 sit in other shared partials and 1 is in +`styles.scss`. + +``` +$ grep -rIoE '#[0-9a-fA-F]{3,8}\b' src/app --include='*.scss' | wc -l +226 +$ grep -rIlE '#[0-9a-fA-F]{3,8}|rgba?\(|hsla?\(' src/app --include='*.scss' | wc -l +44 +$ grep -rIlE 'color|background|border|fill|stroke' src/app --include='*.scss' | wc -l +57 +$ find src/app -name '*.scss' | wc -l +178 +``` + +So **57 of the 178 component stylesheets under `src/app` declare a colour, a border or a fill**, +against 44 that hold a hex or an `rgba()`. The wider grep is the honest one for sizing a +migration: it also catches `stroke: white`, `border-radius: $border-radius-base` and a +`task-status-color($status)` call, none of which contain a literal but all of which a theme has +to account for. 57 is the number to burn down, 226 is the number of literals inside it. + +The three palette files have not moved (107 / 84 / 47, unchanged), so the whole of the growth is +in component stylesheets: the loose literal count more than doubled from 105 to 226 in a single +closure merge. Two files new to this base carry 94 of the 226 between them, +`ppi-widget.component.scss` with 50 and `demo-controls.component.scss` with 44. That is the rate +this contract exists to stop, and it is the strongest argument for landing the section 10 lint +gate early rather than after the migration — every week it is not in place, the burn-down grows +faster than a migration ticket can shrink it. + +Templates carry colour too, as Tailwind arbitrary values: + +``` +$ grep -rIhoE '\b(text|bg|border)-\[#[0-9a-fA-F]{3,8}\]' src --include='*.html' --include='*.ts' \ + | sort | uniq -c | sort -rn + 5 text-[#c5c5c5] 3 bg-[#da532c] 2 text-[#969696] 2 bg-[#e7e7ff] + 1 text-[#fab143] 1 text-[#da532c] 1 text-[#9696969d] 1 text-[#2c2c2c] + 1 bg-[#fab143] 1 bg-[#43a047] 1 bg-[#333] 1 bg-[#126352] +``` + +Nine files. That is the whole of the "someone will invent a new grey" risk, made concrete. + +### 2.5 Status, urgency and the hard surfaces + +**Task status colour has two sources of truth.** +`src/styles/common/task-status-colors.scss` holds a Sass map of 15 statuses with `base`, `dark`, +`light` and `fore`. `src/app/api/models/task-status.ts` holds `STATUS_COLORS`, a second map of +the same 15 hex values in TypeScript. Each file carries a comment telling you to keep it in step +with the other, and the TS one still points at `task-status-colors.less`, a file that no longer +exists. The two maps **do agree today** — all 15 pairs match, checked value by value. They agree +by hand, not by construction. + +**Due-date urgency is inline in one template.** +`src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.html:264-267`: + +```html +[ngClass]="{ 'text-[#da532c]': task.daysUntilDueDate() <= 0, 'font-normal text-[#fab143]': +task.daysUntilDueDate() > 0 && task.daysUntilDueDate() < 11, 'font-normal text-gray-500': +task.daysUntilDueDate() > 11, }" +``` + +Three urgency bands, no named token, one of them a Tailwind default grey. + +**Surfaces THM-M04 will fight**, all confirmed present in `package.json` and in use: + +| Surface | Library | Why it is hard | +| --------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| Code editors | `monaco-editor`, `ngx-monaco-editor-v2-alternative`, `@ngstack/code-editor` | Monaco takes a theme **name**, not CSS. Seven call sites pin `theme: 'vs'` or `'vs-dark'` literally. | +| Charts | `@swimlane/ngx-charts`, `d3` | Colour arrives as a JS `domain` array, not CSS. Two hard-coded domains found. | +| PDF / portfolio | `ng2-pdf-viewer` | Renders into a canvas. CSS cannot reach it; a filter inversion is the only lever. | +| Calendar | `angular-calendar` (CSS imported raw in `styles.scss`) | Third-party CSS with its own light palette. | +| Gantt | `@worktile/gantt` (SCSS loaded in `angular.json`) | Same. | +| Terminal output | `ansi-to-html` | Emits inline `style="color:#..."` from ANSI codes. | +| Emoji picker | `@ctrl/ngx-emoji-mart` (`picker.css`) | Ships a `darkMode` input; `task-comment-composer.component.html:5` currently hard-codes `[darkMode]="false"`. | + +The two chart palettes, verbatim: + +```ts +// visualisations/progress-burndown-chart/progress-burndown-chart.component.ts:70-76 +private readonly seriesPalette: string[] = [ + '#AAAAAA', '#777777', '#0079d8', '#E01B5D', '#7C3AED', +]; +// ...fed to the scheme at :81 as domain: [...this.seriesPalette] +// and per-series at :360 via seriesColor(index) + +// common/project-progress/project-progress-gauge.component.ts:41 +domain: ['#5AA454', '#E44D25', '#CFC0BB', '#7aa3e5', '#a8385d', '#aae3f5'], +``` + +The burndown palette moved during the closure merge: it was an inline `domain` array and is now a +named constant, and its fifth slot changed from `'transparent'` to a real fifth colour `#7C3AED`. +That is one more series colour for THM-M04 to map onto `--ot-chart-*`, and section 8.2 allocates +six slots, so it fits. The refactor is also the better shape to migrate — one constant to +re-point instead of two call sites. + +### 2.6 What does not exist yet + +``` +$ grep -rIn "prefers-color-scheme" src/ | wc -l +0 +$ grep -rIn "color-scheme" src/ | wc -l +0 +$ grep -rIn "@media print" src/ | wc -l +0 +$ grep -rIn "prefers-reduced-motion" src/ | wc -l +2 +``` + +The two `prefers-reduced-motion` blocks are new and both are component-local +(`demo-controls.component.scss:497`, `ppi-widget.component.scss:503`). There is still no +app-wide reduced-motion rule, so section 12 sets one rather than assuming it is covered. + +No dark preference is stored anywhere. `src/index.html` sets a fixed +`` and `src/manifest.webmanifest` sets +`"theme_color": "#3939ff"`, `"background_color": "#ffffff"`. `` is +the only body class. + +CSS custom properties are barely used, which is why the token namespace is free: + +``` +$ grep -rIhoE '^\s*--[a-z0-9-]+\s*:' src --include='*.scss' | tr -d ' ' | sort | uniq -c + 3 --background-gray: 2 --mat-chip-disabled-label-text-color: + 1 --status-chip-bg: 1 --mat-tab-container-height: + 1 --mat-progress-bar-track-height: 1 --mat-progress-bar-active-indicator-height: + 1 --mat-badge-text-color: 1 --mat-badge-container-overlap-offset: + 1 --mat-badge-container-offset: +``` + +Nine names, twelve declarations, and six of the nine are Material's own `--mat-*` overrides +rather than anything of ours. The `--ot-` prefix still collides with nothing. + +Focus handling is a live risk, and the headline counts understate it. There are 11 +`outline: none` / `outline: 0` declarations and 11 `:focus-visible` rules, which reads as level +where it used to read 11-against-7. It is not level. The two sets barely overlap: + +``` +$ comm -12 <(grep -rIlE "outline:\s*(none|0)" src --include='*.scss' | sort) \ + <(grep -rIl "focus-visible" src --include='*.scss' | sort) +src/app/common/file-uploader/file-uploader.component.scss +``` + +Ten files suppress an outline and seven declare a `:focus-visible` rule, but only **one file does +both** — `file-uploader.component.scss`. The four `:focus-visible` rules added by the closure +merge landed in four files that suppress no outline at all, three of which +(`notifications-page`, `demo-controls`, `ppi-widget`) did not exist on the previous base. So the +gap did not close: **nine of the ten outline-suppressing files still have no replacement ring**, +exactly as before. This is why section 12 makes auditing all 11 suppressions a named item in +THM-F01 rather than treating the ratio as evidence of anything. + +### 2.7 The accessibility problem that already ships + +Status chips pair a `base` fill with a `fore` foreground of `#fff` or `#444`. Measured (method +and script in Appendix A): + +| Status | Fill | Foreground | Ratio | AA 4.5:1 | +| ------------------------------------- | --------- | ---------- | ----- | -------- | +| working-on-it | `#eb8f06` | `#ffffff` | 2.48 | **fail** | +| complete | `#5bb75b` | `#ffffff` | 2.51 | **fail** | +| discuss | `#31b0d5` | `#ffffff` | 2.53 | **fail** | +| attention-required | `#f1814d` | `#ffffff` | 2.63 | **fail** | +| need-help | `#a48fce` | `#ffffff` | 2.84 | **fail** | +| feedback-exceeded | `#d46b54` | `#ffffff` | 3.48 | **fail** | +| demonstrate | `#428bca` | `#ffffff` | 3.63 | **fail** | +| ready-for-feedback | `#0079d8` | `#ffffff` | 4.44 | **fail** | +| fail, time-exceeded | `#d93713` | `#ffffff` | 4.66 | pass | +| not-started | `#cccccc` | `#444444` | 6.06 | pass | +| fix-and-resubmit, assess-in-portfolio | `#f2d85c` | `#444444` | 6.84 | pass | +| rediscuss | `#126352` | `#ffffff` | 7.16 | pass | +| redo | `#804000` | `#ffffff` | 7.92 | pass | + +Eight of thirteen distinct pairs fail AA today, in light mode, before dark mode exists. The +secondary text colour has the same problem: `#757575` on a `#ffffff` card is 4.61 and passes, but +on the `#fafafa` page it is **4.41** and fails. + +This is why section 7 does not simply copy the current values forward. + +--- + +## 3. Material theming approach + +**Decision: a staged bridge. Keep the M2 theme rendering the components, add an independent +semantic token layer on top now, and move Material onto tokens per component group later.** + +### The three options + +| Option | What it means | Verdict | +| ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A. Stay on M2, add a second `m2-define-dark-theme` under a class | One extra `@include mat.all-component-colors($dark)` in a `.dark` block | Rejected. Roughly doubles emitted CSS, gives no token vocabulary for the 233 loose hexes outside the three palette files, and locks us further into an API Angular is winding down. | +| B. Uncomment `m3-theme.scss` and switch wholesale | Swap M2 for the generated M3 light and dark themes | Rejected. See below. | +| **C. Staged bridge** | Ship `--ot-*` tokens and the state machine first; migrate Material component groups after | **Chosen.** | + +### Why B is rejected + +`m3-theme.scss` was generated from `#3939FF` and never wired up. Switching to it in one commit +would, in a single PR: change every Material component's density, shape and typography scale at +once; re-map the `warn` palette (M2 `#ef4444`) onto the M3 `error` ramp (`#ba1a1a`); and leave +the 226 component-level hex literals and the 15 status colours untouched and now clashing with a +new set of greys. The failure mode is exactly the one this ticket exists to prevent: several +people fixing different dark greys in parallel. + +It is also not a small change. `m3-theme.scss:138` and `:158` both call `mat.define-theme`, which +is the older M3 entry point, and the file was generated against it (`m3-theme.scss:1`). Angular +Material has since added `mat.theme()`, which takes a `theme-type` of `color-scheme` and emits +both themes from one include. If we are going to move Material, that is the target, not the +generated file as written — and either way it is a separately sized ticket, not a comment +uncomment. + +> **Not verified here.** `node_modules` is not installed in this worktree, so the `mat.theme()` +> behaviour above is stated from the Material 22 public API and **has not been checked against +> the installed package**. An earlier draft pinned it to +> `node_modules/@angular/material/core/tokens/_system.scss:57-82` and `:253`; those line numbers +> are removed because nothing in this repo can confirm them and a citation nobody can check is +> worth less than none. The rejection of option B does not rest on this paragraph — it rests on +> the blast radius in the paragraph above it, which is verifiable in-tree. Whoever picks up the +> Material migration ticket should confirm the API against the installed version before quoting +> it. + +### What C means concretely + +1. **Now (THM-F01).** `src/theme.scss` is untouched. A new token layer declares `--ot-*` on the + root element for both themes. Nothing Material renders changes. +2. **Now.** New and migrated code reads `--ot-*` only. The 226 loose literals get replaced page + by page (THM-M01 to M04) with no visual change in light mode. +3. **Later, per component group.** Material components are pulled onto the tokens with + `mat.theme-overrides()` or `--mat-*` system variables, one group per PR (buttons, then form + fields, then tables, and so on). Each PR is independently reviewable and revertible. +4. **Later still.** When every group is covered, `theme.scss` shrinks to typography and the M2 + colour include is deleted. + +### Compatibility + +- `--ot-*` custom properties inherit and cascade. They cannot break an M2 rule that does not + reference them, so step 1 is inert by construction. +- `light-dark()` is **not** used in the token layer. Values are written literally under the DOM + marker so the resolved theme is legible in DevTools and does not depend on the browser's + `color-scheme` computation. The `color-scheme` CSS property is still set (section 11) so + scrollbars and form controls follow. +- Tailwind's `important: true` in `tailwind.config.js` means every utility already outranks + Material component CSS. Reading a token through a utility keeps that behaviour, it does not + change it. + +### Rollback + +| Stage failing | Rollback | Blast radius | +| -------------------- | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | +| Token layer | Remove the `@use` of the token file. Every `var(--ot-x, )` falls back to its literal. | None, if the fallback rule in section 10 is obeyed. | +| Toggle / persistence | Feature-flag the toggle off. The DOM marker is then never set, and the `:root` block is the light theme. | None. Light mode is the unmarked default by design. | +| One component group | Revert that one PR. | That group only. | +| A migrated page | Revert that one PR. | That page only. | + +**Every `var()` in the token layer's consumers must carry a literal fallback matching today's +light value.** That single rule is what makes rollback free, and it is a review gate. + +--- + +## 4. Theme states + +Two vocabularies. They are not the same set and they are never stored in the same place. + +| | Values | Where it lives | Who writes it | +| --------------------- | ------------------------- | -------------------------------- | ------------------------ | +| **Stored preference** | `light`, `dark`, `system` | `localStorage`, key in section 6 | The user, via the toggle | +| **Resolved theme** | `light`, `dark` | In memory, and on the DOM marker | `ThemeService` only | + +The stored preference is the same three values wherever it is kept. THM-B01 adds a second store +for it on the user account (section 6.2); it does not add a fourth value, a fourth state or a +new resolution rule, so everything below survives phase two unchanged. + +`system` is never a resolved value. It is an instruction meaning _follow +`prefers-color-scheme`_. Anything that needs to know what is on screen reads the resolved +theme. Anything that renders the toggle reads the stored preference, so the toggle can show +three options and highlight `system`. + +Resolution is one line: + +``` +resolved = stored === 'system' ? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') + : stored +``` + +### State diagram + +```mermaid +stateDiagram-v2 + [*] --> Read: app boot (pre-hydration script) + + Read --> System: key missing + Read --> System: value not in the allowlist + Read --> Light: value = "light" + Read --> Dark: value = "dark" + Read --> System: value = "system" + + state "stored = system" as System { + [*] --> ResolveOS + ResolveOS --> SysLight: prefers-color-scheme is not dark + ResolveOS --> SysDark: prefers-color-scheme is dark + SysLight --> SysDark: OS switches to dark (live) + SysDark --> SysLight: OS switches to light (live) + } + + state "stored = light" as Light + state "stored = dark" as Dark + + Light --> Dark: user picks Dark + Light --> System: user picks System + Dark --> Light: user picks Light + Dark --> System: user picks System + System --> Light: user picks Light + System --> Dark: user picks Dark + + note right of System + resolved = light or dark + OS changes apply live, no reload + end note + note right of Dark + resolved = dark, pinned + OS changes are ignored + end note +``` + +Three rules fall out of the diagram and are testable: + +1. An unreadable, missing or invalid stored value resolves to `system`. Never to a hard `dark`. +2. While stored is `light` or `dark`, OS changes are ignored. The user's explicit choice wins. +3. Changing the OS preference while stored is `system` repaints without a reload. + +--- + +## 5. DOM marker + +**One attribute, on ``, carrying the resolved theme only:** + +```html + +``` + +- Name: `data-ot-theme`. Values: exactly `light` or `dark`. Nothing else is ever written. +- It goes on `document.documentElement`, not ``. `` is + already load-bearing and the pre-hydration script (section 11) runs in ``, before + `` exists. +- **In light mode the attribute is present and set to `light`.** It is not omitted. A selector + should never have to mean "absence of dark". +- A data attribute, not a class, because `` classes are a shared namespace that Angular, + Material and Tailwind all write into, and a stray `.dark` is easy to add by accident. An + attribute with a fixed two-value domain is not. +- Selector form for the token layer, and the only two blocks that may declare a colour value: + +```scss +:root, +:root[data-ot-theme='light'] { + /* light values */ +} + +:root[data-ot-theme='dark'] { + /* dark values */ +} +``` + +The bare `:root` first means the app is correct in light mode even if the script never runs. + +--- + +## 6. Storage + +| Field | Value | +| ------------------- | ---------------------------------------------------------------------------- | +| Key | `ontrack.theme.preference` | +| Allowed values | `light`, `dark`, `system` — exactly these three lowercase ASCII strings | +| Default when absent | `system` | +| Format | A bare string. **Not** JSON, not an object, not a serialised class | +| Scope | `localStorage`, per browser profile and origin. Section 6.2 adds the account | + +The key follows the convention already in the tree, which now has two independent uses of it: + +``` +src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.ts:586 + `ontrack.unitTaskList.${unitId}.viewPreferences` +src/app/projects/states/plan/task-planner/task-planner.component.ts:82 + `ontrack.taskPlanner.${projectId}.showTasksAboveTargetGrade` +``` + +Namespace, dot separators, camelCase leaf. `ontrack.theme.preference` is the same shape with no +id segment, because the preference is per browser profile rather than per unit or per project. + +### 6.1 Validation, and the rule that matters + +```ts +export type ThemePreference = 'light' | 'dark' | 'system'; +const THEME_PREFERENCES: readonly ThemePreference[] = ['light', 'dark', 'system'] as const; +const THEME_STORAGE_KEY = 'ontrack.theme.preference'; + +function isThemePreference(v: unknown): v is ThemePreference { + return typeof v === 'string' && (THEME_PREFERENCES as readonly string[]).includes(v); +} + +function readThemePreference(): ThemePreference { + try { + const raw = localStorage.getItem(THEME_STORAGE_KEY); + return isThemePreference(raw) ? raw : 'system'; + } catch { + return 'system'; // private mode, blocked storage, quota + } +} +``` + +**The stored string is never evaluated as CSS or as code.** It is compared against a fixed +allowlist and then used only to pick one of two literal attribute values. It is never +interpolated into a stylesheet, a `style` attribute, a class name, a selector, an +`innerHTML`, a `Function`, or an `eval`. There is no path from `localStorage` to the CSSOM. + +Any value not in the allowlist — a typo, a stale value from an older build, a string another +tab wrote, a value a user typed into DevTools — falls back to `system` **and is not rewritten**. +Silently overwriting it would hide the fact that something is writing junk to that key. + +The same allowlist governs the write side. `setPreference` takes `ThemePreference`, so an +invalid value cannot be stored through the service at all. + +### 6.2 Cross-device persistence — THM-B01, phase two + +`localStorage` is per browser profile. Sign in on a laptop and on a lab machine and you set the +preference twice. **THM-B01 closes that**, and this section is the contract it builds to, so the +card can be specified now and landed after THM-F01 without reopening anything here. + +The client-only store is not "local forever". It is phase one of two. + +| Phase | Store | Ticket | +| ---------------- | ---------------------------------------- | ------- | +| One, ships first | `localStorage` only | THM-F01 | +| Two, adds to it | `localStorage` **plus** the user account | THM-B01 | + +Phase two **adds** a store. It does not replace one, and it changes nothing in section 4, +section 5 or the startup script. Five rules hold the two stores together. + +**1. Local is always the boot store.** The script in section 11 runs before the app has a +session, so `localStorage` is the only thing it can read. The account value is not reachable at +first paint and must never be waited on, or the no-flash guarantee is gone. Phase two therefore +does not touch startup at all. + +**2. The account stores the preference, never the resolved theme.** A user who picked `system` +has `system` on their account. It never becomes `dark` because their operating system was dark +when they last signed in. Same three values, same allowlist as 6.1, validated again server-side. +This is the rule that keeps section 15's objection answered: the account never learns the user's +OS appearance. + +**3. Conflict is settled by last write, not by store.** Neither side outranks the other. + +- The device writes `ontrack.theme.preference.updatedAt`, an ISO 8601 string, beside the + preference. A **second** key on purpose, so the key section 11 reads stays a bare allowlisted + enum and the boot script never parses a date. +- The API carries the equivalent beside its field. +- Newer wins. On a tie the account value wins, so two devices cannot ping-pong. +- The winner is written to **both** sides in the same pass, so one round trip converges them. + +**Presence beats recency, and timestamps are only consulted when both sides hold a value.** This +is the rule that decides the migration, so it is written as a table rather than as prose. Read +"has a preference" as "holds one of the three allowlisted strings"; anything else is treated as +absent, per 6.1. + +| Local preference | Account preference | Outcome | +| ---------------- | ------------------ | ------------------------------------------------------------------------------ | +| absent | absent | Nothing is stored on either side. Follow `system`. Write nothing anywhere. | +| absent | present | Adopt the account value and write it locally. Rule 4. | +| **present** | **absent** | **Keep the local value, upload it, and stamp `updatedAt` at the moment of the upload.** | +| present | present | Compare timestamps. Newer wins, tie goes to the account. | + +Only the fourth row consults a timestamp. So "a local timestamp that is missing, unparseable or +in the future counts as older, and the account value is taken" applies **inside that row only**, +where the account demonstrably holds a real choice. It must never be read as licence to discard a +local preference against an empty account field. + +**Why the third row matters more than it looks.** It is not an edge case, it is day one of +THM-B01 for the entire existing user base. Phase one writes only +`ontrack.theme.preference`; the `updatedAt` key does not exist until phase two introduces it. So +on the morning THM-B01 ships, every user who ever touched the toggle has a valid local +preference, **no** local timestamp, and an empty account field. Under a naive reading of the +timestamp clause — missing timestamp counts as older, so take the account — every one of them +would silently have their theme reset to `system` by a backend migration they did not ask for. +Presence beating recency is what prevents that, and it is why the ordering is fixed here rather +than left to THM-B01. + +Two consequences that follow from the third row and are intended: + +- **The upload stamps `now`, and does not backdate.** The device cannot know when the preference + was originally chosen, and inventing a plausible earlier timestamp would be fabricating the + input to a conflict resolution. `now` is the honest value. +- **If the same user migrates on two devices, the later sign-in wins.** Device A signs in, finds + an empty account, and uploads `dark` stamped `T1`. Device B signs in at `T2` holding `light` + with no timestamp; the account now has a value, so row four applies, B's missing timestamp + counts as older, and B adopts `dark`. Deterministic, converges in one pass, no ping-pong. It + does mean one device's pre-migration choice is dropped, which is the price of having no + timestamp to compare, and THM-Q01 should confirm it looks like a single repaint rather than a + fight. + +**4. A device that has never set a preference adopts the account value.** Nothing valid in +`localStorage` means nothing to compare, so the account value is adopted, written locally, and +the next boot on that device is flash-free. Until the response lands, that first session follows +`system`. One repaint, on the first session on a new device, is the accepted cost of rule 1. It +is stated behaviour, not a defect for THM-Q01 to raise. Where the account is *also* empty, row +one of the table applies and nothing is written at all — a brand-new user is not given a stored +preference they never chose. + +**5. The sync never blocks and never fails loudly.** A 4xx, a 5xx, an offline device, or a +request still in flight, all leave the local value applied and in charge. The retry is the next +boot. A theme preference is not worth a spinner, a toast or an error state. + +Two further constraints, both review gates. + +- **Sign-out does not clear the key.** It is presentation state, not session state. The next + person on a shared machine sees the previous theme until their own preference arrives, then it + repaints. Clearing it would reintroduce a flash for the common single-user case to fix the rare + shared-machine one. +- **Writes are debounced and only fire on a real change.** Cycling the toggle three times sends + one request. + +What THM-B01 owns and this document does not decide: the field name, whether it hangs off the +user record or a separate preferences table, and the endpoint shape. Those are `doubtfire-api` +decisions made with that repo's reviewers. The three values, the allowlist, last-write-wins, and +"preference not resolved theme" are fixed here and are not THM-B01's to change. + +--- + +## 7. Semantic tokens + +Every one of these is a **role**, not a place. There is no `--ot-dashboard-bg`. If a page needs +a colour that is not here, that is a conversation in the theme thread, not a new hex in a +component stylesheet. + +**Derivation.** The light column is the M2 light theme the app renders today (section 2.3), +flattened where Material used alpha, and darkened in 0.5% HSL lightness steps only where the +current value misses 4.5:1 against the **page** background `#fafafa`, which is the worst case. +The dark column is taken from the neutral and primary ramps in the already-checked-in +`src/styles/m3-theme.scss`, so the dark greys are not invented either, then lifted the same way +where needed. Ratios were computed, not estimated. Appendix A has the script. + +| Token | Role | Light | Dark | Worst-case ratio | +| ---------------------------- | --------------------------------------------------------- | --------- | --------- | --------------------- | +| `--ot-color-page` | Page background behind everything | `#fafafa` | `#131316` | — | +| `--ot-color-surface` | Cards, dialogs, table bodies, panels | `#ffffff` | `#201f23` | — | +| `--ot-color-surface-raised` | Menus, popovers, sticky headers, anything above a surface | `#ffffff` | `#2a292d` | — | +| `--ot-color-text` | Body and heading text | `#212121` | `#e5e1e6` | 11.18 on dark raised | +| `--ot-color-text-muted` | Secondary text, captions, hints | `#616161` | `#adaaaf` | 5.93 on light page | +| `--ot-color-border` | Control boundaries: inputs, outlined buttons, chips | `#8a8a8a` | `#78767a` | 3.21 on dark raised | +| `--ot-color-divider` | Decorative rules between rows and sections | `#e0e0e0` | `#353438` | n/a, decorative | +| `--ot-color-focus` | Focus ring | `#3939ff` | `#c0c1ff` | 6.28 light, 8.47 dark | +| `--ot-color-link` | Inline links | `#3939ff` | `#c0c1ff` | 6.28 on light page | +| `--ot-color-primary` | Primary actions, active nav, brand accents | `#3939ff` | `#c0c1ff` | 6.28 on light page | +| `--ot-color-on-primary` | Text and icons on a primary fill | `#ffffff` | `#131316` | 6.56 / 10.87 | +| `--ot-color-success` | Success text and icons | `#398239` | `#5bb75b` | 4.55 on light page | +| `--ot-color-warning` | Warning text and icons | `#a56504` | `#eb8f06` | 4.51 on light page | +| `--ot-color-error` | Error text, invalid fields, destructive actions | `#d73613` | `#ef6445` | 4.53 on dark raised | +| `--ot-color-info` | Informational text and icons | `#0075d0` | `#0792ff` | 4.52 on light page | +| `--ot-color-inverse-surface` | Snackbars, tooltips, anything inverted | `#212121` | `#e5e1e6` | — | +| `--ot-color-inverse-text` | Text on an inverse surface | `#fafafa` | `#201f23` | 15.43 / 12.67 | + +Three notes that are part of the contract, not commentary. + +- **`--ot-color-surface` and `--ot-color-surface-raised` are the same value in light** (`#ffffff`). + In light mode a raised surface is separated by `--ot-elevation-1`, not by hue. In dark mode it + is separated by hue, because shadows do not read on a dark ground. **Never rely on the + raised-versus-surface difference alone to communicate anything.** +- **`--ot-color-border` and `--ot-color-divider` are different tokens on purpose.** WCAG 1.4.11 + requires 3:1 for boundaries that carry meaning, such as the edge of a text input. It does not + apply to a decorative rule between two table rows. `--ot-color-border` clears 3:1 in both + themes. `--ot-color-divider` does not, and must never be used as a control boundary. +- **`--ot-color-text-muted` in light is `#616161`, not the `#757575` the app uses today**, because + `#757575` measures 4.41 against `--ot-color-page`. This is a real fix, not a restyle. + +--- + +## 8. Domain tokens + +### 8.1 Task status + +Fifteen statuses. The method, applied in order: keep the shipped fill if white text on it already +clears 4.5:1; otherwise keep the shipped fill and use `--ot-color-text` (`#212121`) if that +clears 4.5:1; only if neither works, darken the fill in 0.5% HSL lightness steps, hue and +saturation held, until white text reaches 4.5:1. **Thirteen of fifteen fills come through +unchanged.** The dark fill is a container: same hue, same saturation, lightness pinned to 22%. +The dark on-fill is the same hue lifted until it clears 4.5:1 on that container. + +| Status | Shipped fill | `--ot-status-*` light | `-on` light | Ratio | `--ot-status-*` dark | `-on` dark | Ratio | +| --------------------- | ------------ | --------------------- | ----------- | ----- | -------------------- | ---------- | ----- | +| `ready-for-feedback` | `#0079d8` | `#0078d5` | `#ffffff` | 4.52 | `#003f70` | `#47aeff` | 4.51 | +| `not-started` | `#cccccc` | `#cccccc` | `#212121` | 10.03 | `#383838` | `#a1a1a1` | 4.54 | +| `working-on-it` | `#eb8f06` | `#eb8f06` | `#212121` | 6.49 | `#6d4303` | `#fab042` | 4.63 | +| `need-help` | `#a48fce` | `#a48fce` | `#212121` | 5.67 | `#31224e` | `#9d86ca` | 4.56 | +| `fix-and-resubmit` | `#f2d85c` | `#f2d85c` | `#212121` | 11.31 | `#685708` | `#f1d44c` | 4.83 | +| `feedback-exceeded` | `#d46b54` | `#d46b54` | `#212121` | 4.62 | `#5a2317` | `#dc8572` | 4.53 | +| `redo` | `#804000` | `#804000` | `#ffffff` | 7.92 | `#703800` | `#ff9e3d` | 4.53 | +| `discuss` | `#31b0d5` | `#31b0d5` | `#212121` | 6.37 | `#134c5d` | `#5ec1de` | 4.58 | +| `rediscuss` | `#126352` | `#126352` | `#ffffff` | 7.16 | `#115f4f` | `#5be1c5` | 4.69 | +| `demonstrate` | `#428bca` | `#337ab7` | `#ffffff` | 4.56 | `#193a58` | `#6ea6d6` | 4.53 | +| `complete` | `#5bb75b` | `#5bb75b` | `#212121` | 6.42 | `#224e22` | `#78c478` | 4.57 | +| `fail` | `#d93713` | `#d93713` | `#ffffff` | 4.66 | `#671a09` | `#f17c62` | 4.51 | +| `time-exceeded` | `#d93713` | `#d93713` | `#ffffff` | 4.66 | `#671a09` | `#f17c62` | 4.51 | +| `assess-in-portfolio` | `#f2d85c` | `#f2d85c` | `#212121` | 11.31 | `#685708` | `#f1d44c` | 4.83 | +| `attention-required` | `#f1814d` | `#f1814d` | `#212121` | 6.12 | `#682708` | `#f28a5a` | 4.55 | + +Token names are `--ot-status-` and `--ot-status--on`, where `` is the existing +kebab-case class from `TaskStatus.statusClass()`. + +Only two light fills move: `ready-for-feedback` (`#0079d8` → `#0078d5`) and `demonstrate` +(`#428bca` → `#337ab7`). Six flip their foreground from `#ffffff` to `--ot-color-text` with the +fill untouched — `working-on-it`, `need-help`, `feedback-exceeded`, `discuss`, `complete`, +`attention-required`. Three keep their fill and move their foreground off the shipped `#444444` +onto the same token — `not-started`, `fix-and-resubmit`, `assess-in-portfolio`. That leaves four +untouched in both fill and foreground: `redo`, `rediscuss`, `fail`, `time-exceeded`. + +Thirteen of fifteen dark fills are distinct. The two collisions are `fail`/`time-exceeded` and +`fix-and-resubmit`/`assess-in-portfolio`, which already share a base colour today. No **new** +collision is introduced. + +**The two-maps problem must be closed during the shared-component migration, not carried +forward.** No card on the board names it in its own words, and both files are shared rather than +per-page — `src/styles/common/task-status-colors.scss` and `src/app/api/models/task-status.ts` — +so it belongs to **THM-M01, shell and shared components**, and THM-JL01 should confirm that when +the card is written up. After migration `STATUS_COLORS` in `task-status.ts` is deleted and any +TypeScript that needs a status colour reads `var(--ot-status-)`. Where a JS value is +unavoidable (charts), it is read once via +`getComputedStyle(document.documentElement).getPropertyValue(...)` and re-read on theme change. +One source of truth, in CSS. + +### 8.2 Everything else + +| Group | Token | Light | Dark | Note | +| --------- | ----------------------------- | ---------------------------- | ---------------------------- | ---------------------------------------------------------------- | +| Urgency | `--ot-urgency-overdue` | `#c94823` | `#e06f4f` | from `#da532c`; 4.54 light page, 4.51 dark raised | +| | `--ot-urgency-soon` | `#a56504` | `#fab143` | from `#fab143`; 4.51 light page, 7.87 dark raised | +| | `--ot-urgency-later` | `#616161` | `#adaaaf` | alias of `--ot-color-text-muted`; replaces `text-gray-500` | +| Charts | `--ot-chart-1` | `#3939ff` | `#6c6cff` | series slots, ≥3:1 vs their own page (1.4.11); worst 3.02 / 4.52 | +| | `--ot-chart-2` | `#0079d8` | `#007fe2` | | +| | `--ot-chart-3` | `#58a152` | `#5aa454` | | +| | `--ot-chart-4` | `#e44d25` | `#e44d25` | | +| | `--ot-chart-5` | `#a8385d` | `#c7587d` | | +| | `--ot-chart-6` | `#d07e05` | `#eb8f06` | | +| | `--ot-chart-grid` | `#e0e0e0` | `#353438` | gridlines, decorative | +| | `--ot-chart-axis` | `#616161` | `#adaaaf` | axis labels are text, 4.5:1 | +| Code | `--ot-code-surface` | `#f5f5f5` | `#0e0e11` | `
`, ANSI output, diff panes                                 |
+|           | `--ot-code-text`              | `#212121`                    | `#e5e1e6`                    | 14.77 / 14.91 on their own surface                               |
+|           | Monaco theme name             | `vs`                         | `vs-dark`                    | not a CSS var; set in TS from the resolved theme                 |
+| Overlays  | `--ot-scrim`                  | `rgba(0, 0, 0, 0.32)`        | `rgba(0, 0, 0, 0.60)`        | dialog and drawer backdrop                                       |
+|           | `--ot-elevation-1`            | `0 1px 3px rgba(0,0,0,0.12)` | `0 1px 3px rgba(0,0,0,0.50)` | shadow, not colour                                               |
+| Disabled  | `--ot-color-disabled-text`    | `#9e9e9e`                    | `#6f6d72`                    | 1.4.3 exempts inactive controls; still needs a non-colour cue    |
+|           | `--ot-color-disabled-surface` | `#eeeeee`                    | `#2a292d`                    |                                                                  |
+| Selection | `--ot-color-selected`         | `#e7e7ff`                    | `#2e2e5c`                    | selected row or chip fill                                        |
+|           | `--ot-color-selected-text`    | `#212121`                    | `#e5e1e6`                    | 13.24 / 9.78                                                     |
+|           | `--ot-color-hover`            | `rgba(0, 0, 0, 0.04)`        | `rgba(255, 255, 255, 0.06)`  | matches the M2 `hover` slot already in use                       |
+
+`--ot-color-selected` light is `#e7e7ff`, which is `formatif-blue-lighter` from
+`tailwind.config.js` and `$md-formatif.50` from `theme.scss`. It is already the app's selection
+tint, it just had no name.
+
+---
+
+## 9. Fixed brand vs theme-aware
+
+| Fixed in both themes                                                                       | Why                                                                                                                          |
+| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
+| `#3939ff` as the **brand mark** — the logo, the favicon, `icon.svg`, the PWA `theme_color` | Brand identity. A logo that changes colour is a different logo.                                                              |
+| The status **identity**, meaning which hue means `complete`                                | Green means complete in both themes. Teaching staff read these chips daily and re-learning them in dark mode is a real cost. |
+| `#da532c`, `$doubtfire-color` in `variables.scss`                                          | Legacy brand colour. Keep or retire it, do not theme it.                                                                     |
+
+| Theme-aware                                                 | Why                                                                                                                                |
+| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
+| `--ot-color-primary`, `--ot-color-link`, `--ot-color-focus` | `#3939ff` is 6.28:1 on the light page, 2.83:1 on the dark page and 2.20:1 on the worst-case dark raised surface. It misses 4.5:1 for text and 3:1 for a focus ring, so the brand blue cannot be the dark-mode interactive colour. |
+| Every status **fill and foreground**                        | The hue stays recognisable; the lightness has to move or the chip is unreadable. Section 8.1.                                      |
+| All surfaces, text, borders, dividers, shadows, scrims      | Definitionally.                                                                                                                    |
+| Chart series                                                | A series colour is a graphical object under WCAG 1.4.11 and needs 3:1 against its own ground.                                      |
+| The `theme-color` meta tag                                  | Section 12.                                                                                                                        |
+
+The distinction in one sentence: **the brand mark is fixed, the brand as an interface colour is
+theme-aware.**
+
+---
+
+## 10. Who may declare a colour, and how each layer reads the tokens
+
+Four layers. Exactly one of them is allowed to contain a colour value.
+
+| Layer                                                           | May declare a colour?  | Job                                                                           |
+| --------------------------------------------------------------- | ---------------------- | ----------------------------------------------------------------------------- |
+| **Token layer** — `src/styles/tokens/_light.scss`, `_dark.scss` | **Yes, and only here** | Declare every `--ot-*` for one theme. Two files, two blocks, no logic.        |
+| **Material theme** — `src/theme.scss`                           | Transitional           | M2 palettes stay until section 3 step 4 retires them. No new colour goes in.  |
+| **Shared SCSS** — `src/styles/common`, `src/styles/mixins`      | No                     | Mixins and layout. Colour only through `var(--ot-*)`.                         |
+| **Component SCSS and templates**                                | No                     | Consume `var(--ot-*)` or a Tailwind utility. A new hex is a review rejection. |
+
+### Tailwind
+
+Tailwind must **map** to the tokens, never restate them. Tailwind 4 is in use with a v3-style
+JS config loaded through `@config '../tailwind.config.js'` from `src/styles.scss` and
+`src/tailwind-intellisense.css`. Extend the theme with `var()` references:
+
+```js
+// tailwind.config.js
+theme: {
+  extend: {
+    colors: {
+      'formatif-blue': '#3939ff',                    // brand mark, stays literal
+      'formatif-blue-lighter': '#e7e7ff',
+      page: 'var(--ot-color-page)',
+      surface: 'var(--ot-color-surface)',
+      'surface-raised': 'var(--ot-color-surface-raised)',
+      content: 'var(--ot-color-text)',
+      muted: 'var(--ot-color-text-muted)',
+      border: 'var(--ot-color-border)',
+      divider: 'var(--ot-color-divider)',
+      primary: 'var(--ot-color-primary)',
+      link: 'var(--ot-color-link)',
+      success: 'var(--ot-color-success)',
+      warning: 'var(--ot-color-warning)',
+      error: 'var(--ot-color-error)',
+      info: 'var(--ot-color-info)',
+    },
+  },
+},
+```
+
+`bg-surface text-content border-border` then resolves per theme with **no dark variant on a
+colour utility**. Tokens are the default and they stay the default. A `dark:bg-x` on a colour
+doubles every class list and moves the palette back into templates, which is the thing this
+contract exists to stop.
+
+#### The dark variant is configured and pointed at the marker — THM-F03
+
+`darkMode` is not set today, and leaving it unset is **not** the same as having no dark variant.
+Tailwind's default `dark:` is `@media (prefers-color-scheme: dark)`. So the prefix already works
+in this build, and it already **follows the operating system and ignores the stored
+preference**. A `dark:bg-black` written today stays dark for a user whose preference is `light`,
+which breaks rule 2 of section 4 without anyone editing a line of this contract.
+
+Configuring it is a correctness fix before it is a feature. **THM-F03 binds the variant to
+`data-ot-theme`**, the marker section 5 fixes, so the app has one dark authority and it is the
+resolved theme:
+
+```css
+/* src/styles.scss, beside the existing @config line */
+@custom-variant dark (&:where([data-ot-theme='dark'], [data-ot-theme='dark'] *));
+```
+
+Tailwind here is `4.3.1` driven by a v3-style JS config through `@config` (`src/styles.scss:7`
+and `src/tailwind-intellisense.css:2`), so the legacy
+`darkMode: ['selector', '[data-ot-theme="dark"]']` may be honoured as well. **THM-F03 picks one
+form, proves it with a build, and records which.** Two mechanisms for one variant is how they
+drift apart.
+
+Three rules bound what the variant is then for.
+
+1. **Never for a colour that already has a token.** `bg-surface` flips on its own.
+   `dark:bg-[#201f23]` is a rejection and so is `dark:bg-surface-raised`.
+2. **Yes for what a custom property cannot carry.** A different asset, `dark:invert` on the PDF
+   viewer canvas, a border width, an opacity, a `mix-blend-mode`, a shadow that has to change
+   shape rather than colour. Section 7 has no token for these and never will.
+3. **Yes for third-party CSS the token layer cannot reach**, where a `dark:` utility carried by
+   `important: true` is a smaller intervention than forking a vendor stylesheet.
+
+`dark:` on a colour utility is a lint violation (rule 2 below). `dark:` on anything else is
+allowed and needs no exception.
+
+`important: true` stays as it is. It is what lets a utility override Material today, and
+changing it is out of scope.
+
+### Shared SCSS
+
+```scss
+// src/styles/mixins/_surface.scss
+@mixin card-surface {
+  background-color: var(--ot-color-surface, #ffffff);
+  color: var(--ot-color-text, #212121);
+  border: 1px solid var(--ot-color-border, #8a8a8a);
+}
+```
+
+The fallback is the current light value. That is the rollback guarantee from section 3, applied
+at every call site.
+
+### Material
+
+Material components are pulled onto the tokens with `mat.theme-overrides()` inside the same two
+theme blocks, one component group per PR:
+
+```scss
+:root[data-ot-theme='dark'] {
+  @include mat.theme-overrides(
+    (
+      surface: var(--ot-color-surface),
+      on-surface: var(--ot-color-text),
+      outline: var(--ot-color-border),
+    )
+  );
+}
+```
+
+### The three lint rules that hold the line
+
+These belong in MG-05 and are proposed here so the reviewer knows what to enforce until then.
+
+1. No hex, `rgb()` or `hsl()` literal in any file under `src/app`. Grep gate in CI, allowlist the
+   token files and `theme.scss`.
+2. No Tailwind arbitrary colour value, and no `dark:` prefix on a colour utility.
+   `eslint-plugin-tailwindcss` is already wired (`eslint.config.js:89-101`); add
+   `tailwindcss/no-arbitrary-value` for colour utilities, and a grep gate for
+   `dark:(bg|text|border|fill|stroke|ring|outline|from|via|to)-`.
+3. No new `--ot-*` name without an entry in section 7 or 8 of this document.
+
+Today's baseline for rule 1 is **226** literals in component SCSS under `src/app` plus 20 Tailwind
+arbitrary colour values across 9 template files, measured on `11.0.x` at `4034e7d1a`. That number
+is the migration burn-down and should only go down.
+
+**Re-measure it before quoting it.** This baseline read 105 when it was first taken, against
+`efda57967` at 09:24 the same morning. One closure merge landed at 22:00 and it became 226. The
+figure more than doubled inside thirteen hours, so a burn-down target copied out of this document
+a week from now will be wrong. THM-M01 should re-run the command on the commit it actually
+branches from and record that figure on its own card rather than inheriting this one.
+
+---
+
+## 11. Startup and live system changes
+
+### Live OS changes
+
+`ThemeService` holds one listener for the whole app lifetime. It fires only when the stored
+preference is `system`.
+
+```ts
+private readonly query = window.matchMedia('(prefers-color-scheme: dark)');
+
+constructor() {
+  this.query.addEventListener('change', () => {
+    if (this.preference() === 'system') {
+      this.applyResolved();
+    }
+  });
+}
+```
+
+- `addEventListener('change', ...)`, not the deprecated `addListener`.
+- Registered once, in a root-provided service, and removed on destroy. One listener, not one per
+  component.
+- No reload, no re-render of the router outlet. Changing `data-ot-theme` repaints via CSS.
+- Charts and Monaco cannot repaint from CSS, so the service also emits a signal that those
+  components subscribe to in order to re-read their colours. That subscription is part of
+  THM-M04, and this is the hook it uses.
+
+### No-flash startup — THM-F04
+
+Two cards touch startup. THM-F01 builds the foundation, which includes System detection and
+persistence, and THM-F04 specifies and implements the no-flash mechanism. They overlap, so the
+split is fixed here: **THM-F04 writes the `` script below and the drift test in section 14
+item 15. THM-F01 consumes whatever that script already set, at bootstrap, and owns the theme
+from that point on.** Neither card writes the other's half.
+
+The wrong theme flashing for one frame on every page load is the single most visible defect a
+theme feature can ship. Angular bootstraps after `` paints, so the marker has to be set
+before that. One small blocking inline script in `` of `src/index.html`, before any
+stylesheet:
+
+```html
+
+```
+
+Constraints on that script, all reviewable:
+
+- **Inline and synchronous.** A deferred or external script paints late and the flash returns.
+- **Under 400 bytes, no dependencies**, so it cannot meaningfully delay first paint.
+- **The whole body is inside `try`.** Safari private mode throws on `localStorage.getItem`.
+  Anything thrown here would block the app from booting at all.
+- **It writes only `'light'` or `'dark'`.** The stored value never reaches `setAttribute`
+  directly. This is the same allowlist as section 6, restated in the one place that runs before
+  the service exists.
+- **It duplicates the storage key as a literal.** That is deliberate: it runs before any module
+  loads, so it cannot import the constant. THM-T01 must assert the literal in `index.html`
+  matches `THEME_STORAGE_KEY` in the service, or the two will drift.
+- `d.style.colorScheme = t` makes native scrollbars, `
-
-        
-          
-        
-      
-    
-  
-
diff --git a/src/app/units/states/edit/directives/unit-students-editor/student-campus-select/student-campus-select.component.html b/src/app/units/states/edit/directives/unit-students-editor/student-campus-select/student-campus-select.component.html
index 79237858bf..0be9ed0567 100644
--- a/src/app/units/states/edit/directives/unit-students-editor/student-campus-select/student-campus-select.component.html
+++ b/src/app/units/states/edit/directives/unit-students-editor/student-campus-select/student-campus-select.component.html
@@ -1,4 +1,4 @@
-
+
   
     None
     @for (campus of campuses; track campus) {
diff --git a/src/app/units/states/edit/directives/unit-students-editor/student-campus-select/student-campus-select.component.ts b/src/app/units/states/edit/directives/unit-students-editor/student-campus-select/student-campus-select.component.ts
index 92a80265a2..bb80001e70 100644
--- a/src/app/units/states/edit/directives/unit-students-editor/student-campus-select/student-campus-select.component.ts
+++ b/src/app/units/states/edit/directives/unit-students-editor/student-campus-select/student-campus-select.component.ts
@@ -1,14 +1,16 @@
-import { Component, Inject, Input, OnInit } from '@angular/core';
-import { Campus, CampusService, Project, Unit } from 'src/app/api/models/doubtfire-model';
-import { MatSelectChange } from '@angular/material/select';
-import { AlertService } from 'src/app/common/services/alert.service';
+import {ChangeDetectionStrategy, Component, Input, OnChanges, OnInit} from '@angular/core';
+import {MatSelectChange} from '@angular/material/select';
+import {Campus, CampusService, Project, Unit} from 'src/app/api/models/doubtfire-model';
+import {AlertService} from 'src/app/common/services/alert.service';
 
 @Component({
   selector: 'student-campus-select',
   templateUrl: 'student-campus-select.component.html',
   styleUrls: ['student-campus-select.component.scss'],
+  changeDetection: ChangeDetectionStrategy.Eager,
+  standalone: false,
 })
-export class StudentCampusSelectComponent implements OnInit {
+export class StudentCampusSelectComponent implements OnChanges, OnInit {
   @Input() unit: Unit;
   @Input() student: Project;
   @Input() update: boolean;
@@ -19,7 +21,7 @@ export class StudentCampusSelectComponent implements OnInit {
   constructor(
     private campusService: CampusService,
     private alerts: AlertService,
-  ) { }
+  ) {}
 
   ngOnChanges() {
     this.originalCampus = this.student.campus;
@@ -41,8 +43,8 @@ export class StudentCampusSelectComponent implements OnInit {
         error: (message) => {
           this.student.campus = this.originalCampus;
           this.alerts.error(message, 6000);
-        }
-      })
+        },
+      });
     }
   }
 }
diff --git a/src/app/units/states/edit/directives/unit-students-editor/student-tutorial-select/student-tutorial-select.component.html b/src/app/units/states/edit/directives/unit-students-editor/student-tutorial-select/student-tutorial-select.component.html
index d135f777e9..ba2377910a 100644
--- a/src/app/units/states/edit/directives/unit-students-editor/student-tutorial-select/student-tutorial-select.component.html
+++ b/src/app/units/states/edit/directives/unit-students-editor/student-tutorial-select/student-tutorial-select.component.html
@@ -1,4 +1,4 @@
-
+
   
     @if (tutorialsForStreamAndStudent(student).length > 0) {
       
diff --git a/src/app/units/states/edit/directives/unit-students-editor/student-tutorial-select/student-tutorial-select.component.ts b/src/app/units/states/edit/directives/unit-students-editor/student-tutorial-select/student-tutorial-select.component.ts
index f3c47ba355..452ecd3fa6 100644
--- a/src/app/units/states/edit/directives/unit-students-editor/student-tutorial-select/student-tutorial-select.component.ts
+++ b/src/app/units/states/edit/directives/unit-students-editor/student-tutorial-select/student-tutorial-select.component.ts
@@ -1,10 +1,12 @@
-import { Component, Input } from '@angular/core';
-import { Project, Tutorial, TutorialStream, Unit } from 'src/app/api/models/doubtfire-model';
+import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
+import {Project, Tutorial, TutorialStream, Unit} from 'src/app/api/models/doubtfire-model';
 
 @Component({
   selector: 'student-tutorial-select',
   templateUrl: 'student-tutorial-select.component.html',
   styleUrls: ['student-tutorial-select.component.scss'],
+  changeDetection: ChangeDetectionStrategy.Eager,
+  standalone: false,
 })
 export class StudentTutorialSelectComponent {
   @Input() unit: Unit;
@@ -26,8 +28,12 @@ export class StudentTutorialSelectComponent {
   public tutorialsForStreamAndStudent(student: Project, stream?: TutorialStream) {
     return this.unit.tutorials.filter((tutorial) => {
       const result: boolean =
-        student.campus == null || tutorial.campus == null || student.campus.id === tutorial.campus.id;
-      if (!result) return result;
+        student.campus == null ||
+        tutorial.campus == null ||
+        student.campus.id === tutorial.campus.id;
+      if (!result) {
+        return result;
+      }
       if (tutorial.tutorialStream && stream) {
         return tutorial.tutorialStream.abbreviation === stream.abbreviation;
       } else if (!tutorial.tutorialStream && !stream) {
diff --git a/src/app/units/states/edit/directives/unit-students-editor/unit-students-editor.component.html b/src/app/units/states/edit/directives/unit-students-editor/unit-students-editor.component.html
index 4797fff33b..9110f1dac4 100644
--- a/src/app/units/states/edit/directives/unit-students-editor/unit-students-editor.component.html
+++ b/src/app/units/states/edit/directives/unit-students-editor/unit-students-editor.component.html
@@ -8,63 +8,112 @@ 

Enrolled Students

Search - +
- + @if (loadingStudents) { +
+
+ @for (width of ['10%', '12%', '12%', '20%', '10%', '20%', '8%', '8%']; track $index) { + + } +
+ + @for (row of [0, 1, 2, 3, 4, 5, 6, 7]; track row) { +
+ + + + + @for (column of [0, 1, 2, 3]; track column) { + + } +
+ } +
+ } +
- - + - - - + - - - + - - - + - - - + - - - + - - - + - + - - - - + + +
Username + Username {{ project.student.username }} First Name + + First Name {{ project.student.firstName }} Last Name + + Last Name {{ project.student.lastName }} Email + + Email {{ project.student.email }} Campus - + + Campus + Tutorial - + + Tutorial + Enrolled + + Enrolled Enrolled Students - - - + + +
+ + + +
+ - + - +
diff --git a/src/app/units/states/edit/directives/unit-students-editor/unit-students-editor.component.ts b/src/app/units/states/edit/directives/unit-students-editor/unit-students-editor.component.ts index f29d36c605..d9af32d5f6 100644 --- a/src/app/units/states/edit/directives/unit-students-editor/unit-students-editor.component.ts +++ b/src/app/units/states/edit/directives/unit-students-editor/unit-students-editor.component.ts @@ -1,67 +1,93 @@ +import {HttpClient} from '@angular/common/http'; import { - csvUploadModalService, - csvResultModalService, - unitStudentEnrolmentModal, -} from './../../../../../ajs-upgraded-providers'; -import { ViewChild, Component, Input, Inject, AfterViewInit, OnDestroy } from '@angular/core'; -import { MatTable, MatTableDataSource } from '@angular/material/table'; -import { MatSort, Sort } from '@angular/material/sort'; -import { MatPaginator } from '@angular/material/paginator'; -import { HttpClient } from '@angular/common/http'; -import { FileDownloaderService } from 'src/app/common/file-downloader/file-downloader.service'; -import { Project, ProjectService, Unit } from 'src/app/api/models/doubtfire-model'; -import { UIRouter } from '@uirouter/angular'; -import { Subscription } from 'rxjs'; -import { AlertService } from 'src/app/common/services/alert.service'; + AfterViewInit, + ChangeDetectionStrategy, + Component, + Input, + OnDestroy, + OnInit, + ViewChild, +} from '@angular/core'; +import {MatPaginator} from '@angular/material/paginator'; +import {MatSort, Sort} from '@angular/material/sort'; +import {MatTable, MatTableDataSource} from '@angular/material/table'; +import {Router} from '@angular/router'; +import {Subscription, finalize, timer} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; +import {Project, ProjectService, Unit} from 'src/app/api/models/doubtfire-model'; +import {SidekiqJob} from 'src/app/api/models/sidekiq-job'; +import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; +import { + CsvResult, + CsvResultModalService, +} from 'src/app/common/modals/csv-result-modal/csv-result-modal.service'; +import {CsvUploadModalService} from 'src/app/common/modals/csv-upload-modal/csv-upload-modal.service'; +import {SidekiqProgressModalService} from 'src/app/common/modals/sidekiq-progress-modal/sidekiq-progress-modal.service'; +import {SpecConModalService} from 'src/app/common/modals/spec-con-modal/spec-con-modal.service'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {UnitStudentEnrolmentModalService} from 'src/app/units/modals/unit-student-enrolment-modal/unit-student-enrolment-modal.service'; @Component({ selector: 'unit-students-editor', templateUrl: 'unit-students-editor.component.html', styleUrls: ['unit-students-editor.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, }) -export class UnitStudentsEditorComponent implements AfterViewInit, OnDestroy { - @ViewChild(MatTable, { static: false }) table: MatTable; - @ViewChild(MatSort, { static: false }) sort: MatSort; - @ViewChild(MatPaginator, { static: false }) paginator: MatPaginator; +export class UnitStudentsEditorComponent implements OnInit, AfterViewInit, OnDestroy { + @ViewChild(MatTable, {static: false}) table: MatTable; + @ViewChild(MatSort, {static: false}) sort: MatSort; + @ViewChild(MatPaginator, {static: false}) paginator: MatPaginator; @Input() unit: Unit; private subscriptions: Subscription[] = []; - columns: string[] = ['username', 'firstName', 'lastName', 'email', 'campus', 'tutorial', 'enrolled', 'goto']; - dataSource: MatTableDataSource; + columns: string[] = [ + 'username', + 'firstName', + 'lastName', + 'email', + 'campus', + 'tutorial', + 'enrolled', + 'goto', + ]; + dataSource: MatTableDataSource = new MatTableDataSource([]); + loadingStudents = true; // Calls the parent's constructor, passing in an object // that maps all of the form controls that this form consists of. constructor( private httpClient: HttpClient, - @Inject(unitStudentEnrolmentModal) private enrolModal: any, + private enrolModal: UnitStudentEnrolmentModalService, private alerts: AlertService, - @Inject(csvUploadModalService) private csvUploadModal: any, - @Inject(csvResultModalService) private csvResultModal: any, + private csvUploadModal: CsvUploadModalService, + private csvResultModal: CsvResultModalService, private fileDownloader: FileDownloaderService, - private router: UIRouter, - private projectService: ProjectService + private router: Router, + private projectService: ProjectService, + private specConModalService: SpecConModalService, + private sidekiqProgressModalService: SidekiqProgressModalService, ) {} - // The paginator is inside the table - ngAfterViewInit() { - this.dataSource = new MatTableDataSource(this.unit.studentCache.currentValuesClone()); - this.dataSource.paginator = this.paginator; - this.dataSource.sort = this.sort; - this.dataSource.filterPredicate = (data: any, filter: string) => data.matches(filter); + ngOnInit(): void { + this.dataSource.data = this.unit.studentCache.currentValuesClone(); + this.dataSource.filterPredicate = (data: Project, filter: string) => data.matches(filter); this.subscriptions.push( this.unit.studentCache.values.subscribe((students) => { this.dataSource.data = students; - }) + }), ); - this.subscriptions.push( - this.projectService.loadStudents(this.unit, true).subscribe(() => { - // projects included in unit... - }) - ); + this.refreshStudentsAfterRender(); + } + + // The paginator is inside the table + ngAfterViewInit() { + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; } ngOnDestroy(): void { @@ -76,6 +102,21 @@ export class UnitStudentsEditorComponent implements AfterViewInit, OnDestroy { } } + private refreshStudentsAfterRender(): void { + this.subscriptions.push( + timer(0) + .pipe( + switchMap(() => this.projectService.loadStudents(this.unit, false, true)), + finalize(() => { + this.loadingStudents = false; + }), + ) + .subscribe(() => { + // projects included in unit... + }), + ); + } + private sortCompare(aValue: number | string, bValue: number | string, isAsc: boolean) { return (aValue < bValue ? -1 : 1) * (isAsc ? 1 : -1); } @@ -104,7 +145,7 @@ export class UnitStudentsEditorComponent implements AfterViewInit, OnDestroy { } public gotoStudent(student: Project) { - this.router.stateService.go('projects/dashboard', { projectId: student.id, tutor: true, taskAbbr: '' }); + this.router.navigate(['/projects', student.id, 'dashboard'], {queryParams: {tutor: true}}); } enrolStudent() { @@ -114,32 +155,45 @@ export class UnitStudentsEditorComponent implements AfterViewInit, OnDestroy { uploadEnrolments() { this.csvUploadModal.show( 'Upload Students to Enrol', - 'Test message', - { file: { name: 'Enrol CSV Data', type: 'csv' } }, + 'Upload a CSV to enrol students.', + {file: {name: 'Enrol CSV Data', type: 'csv'}}, this.unit.enrolStudentsCSVUrl, - (response: any) => { - // at least one student? - this.csvResultModal.show('Enrol Student CSV Results', response); - if (response.success.length > 0) { - this.unit.refreshStudents(true); + (response: SidekiqJob) => { + if (!response || !response.id) { + return this.alerts.error('Failed to start student import job', 6000); } - } + this.sidekiqProgressModalService + .show(`Importing Students: ${this.unit.code}`, response.id) + .subscribe({ + next: (job) => { + const result = JSON.parse(job.result); + this.csvResultModal.show('Enrol Student CSV Results', result); + // at least one student? + if (result.success.length > 0) { + this.unit.refreshStudents(true); + } + }, + error: (error) => { + console.error(error); + }, + }); + }, ); } uploadWithdrawals() { this.csvUploadModal.show( 'Upload Students to Withdraw', - 'Test message', - { file: { name: 'Withdraw CSV Data', type: 'csv' } }, + 'Upload a CSV to withdraw students.', + {file: {name: 'Withdraw CSV Data', type: 'csv'}}, this.unit.withdrawStudentsCSVUrl, - (response: any) => { + (response: CsvResult) => { // at least one student? this.csvResultModal.show('Withdraw Student CSV Results', response); if (response.success.length > 0) { this.unit.refreshStudents(true); } - } + }, ); } @@ -148,4 +202,8 @@ export class UnitStudentsEditorComponent implements AfterViewInit, OnDestroy { this.fileDownloader.downloadFile(url, `${this.unit.code}-students.csv`); } + + public updateSpecCon(student: Project) { + this.specConModalService.show(student); + } } diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-dates/task-definition-dates.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-dates/task-definition-dates.component.html index a2285a0969..f94c7e6037 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-dates/task-definition-dates.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-dates/task-definition-dates.component.html @@ -1,43 +1,43 @@ -
- - Start date to suggested completion date - - - - - - - +
+ + Start Date + + + + Suggested date for students to begin the task. - - Suggestion completion date to final feedback - - - - + + Target Date + + + + Recommended target date for students to complete the task. + - - + + Final Feedback Date + + + + Final deadline for receiving feedback.
diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-dates/task-definition-dates.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-dates/task-definition-dates.component.ts index f0d5d6dc23..40d0f325c5 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-dates/task-definition-dates.component.ts +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-dates/task-definition-dates.component.ts @@ -1,11 +1,13 @@ -import { Component, Input } from '@angular/core'; -import { TaskDefinition } from 'src/app/api/models/task-definition'; -import { Unit } from 'src/app/api/models/unit'; +import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; +import {TaskDefinition} from 'src/app/api/models/task-definition'; +import {Unit} from 'src/app/api/models/unit'; @Component({ selector: 'f-task-definition-dates', templateUrl: 'task-definition-dates.component.html', styleUrls: ['task-definition-dates.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, }) export class TaskDefinitionDatesComponent { @Input() taskDefinition: TaskDefinition; diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-discussion-prompts/task-definition-discussion-prompts.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-discussion-prompts/task-definition-discussion-prompts.component.html new file mode 100644 index 0000000000..b3d0eb258b --- /dev/null +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-discussion-prompts/task-definition-discussion-prompts.component.html @@ -0,0 +1,107 @@ +
+
+ + + + + + + + + + + + + + + + + + +
Discussion Prompt + @if (!editing(prompt)) { + {{ prompt.content }} + } @else { + + Discussion Prompt + + + } + Priority + @if (!editing(prompt)) { + {{ prompt.priorityLabel }} + } @else { +
+ + Priority + + + High + Medium + Low + + +
+ } +
Actions +
+ @if (editing(prompt)) { + + + } @else { + + + } +
+
+
+ @if (!dataSource.data.length) { +
No discussion prompts
+ } + @if (!creatingNewDiscussionPrompt) { +
+ +
+ } + + @if (creatingNewDiscussionPrompt) { + + +
+ + Discussion Prompt + + + + Priority + + + High + Medium + Low + + +
+
+ + +
+
+
+ } +
diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-discussion-prompts/task-definition-discussion-prompts.component.scss b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-discussion-prompts/task-definition-discussion-prompts.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-discussion-prompts/task-definition-discussion-prompts.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-discussion-prompts/task-definition-discussion-prompts.component.ts new file mode 100644 index 0000000000..7f9de46ef7 --- /dev/null +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-discussion-prompts/task-definition-discussion-prompts.component.ts @@ -0,0 +1,167 @@ +import { + ChangeDetectionStrategy, + Component, + Input, + OnChanges, + OnInit, + SimpleChanges, +} from '@angular/core'; +import {UntypedFormControl, Validators} from '@angular/forms'; +import {MatTableDataSource} from '@angular/material/table'; +import {Observable, Subscription} from 'rxjs'; +import {DiscussionPrompt} from 'src/app/api/models/discussion-prompt'; +import {Task} from 'src/app/api/models/task'; +import {TaskDefinition} from 'src/app/api/models/task-definition'; +import {TaskPrerequisite} from 'src/app/api/models/task-prerequisite'; +import {Unit} from 'src/app/api/models/unit'; +import {DiscussionPromptService} from 'src/app/api/services/discussion-prompt.service'; +import {TaskDefinitionService} from 'src/app/api/services/task-definition.service'; +import {TaskPrerequisiteService} from 'src/app/api/services/task-prerequisite.service'; +import {EntityFormComponent} from 'src/app/common/entity-form/entity-form.component'; +import {AlertService} from 'src/app/common/services/alert.service'; + +@Component({ + selector: 'f-task-definition-discussion-prompts', + templateUrl: 'task-definition-discussion-prompts.component.html', + styleUrls: ['task-definition-discussion-prompts.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class TaskDefinitionDiscussionPromptsComponent + extends EntityFormComponent + implements OnInit, OnChanges +{ + @Input() taskDefinition: TaskDefinition; + @Input() staffView: boolean; + @Input() task: Task; + + displayedColumns: string[] = ['content', 'priority', 'actions']; + + private prereqSub?: Subscription; + + public dataSource: MatTableDataSource = new MatTableDataSource(); + + creatingNewDiscussionPrompt: boolean = false; + + newDiscussionPromptContent: string; + newDiscussionPromptWeight: number = 2; + + constructor( + private taskDefinitionService: TaskDefinitionService, + private alertService: AlertService, + private taskPrerequisiteService: TaskPrerequisiteService, + private discussionPromptService: DiscussionPromptService, + ) { + super( + { + content: new UntypedFormControl('', [Validators.required]), + priority: new UntypedFormControl('', [Validators.required]), + }, + 'Discussion Prompt', + ); + } + public get unit(): Unit { + return this.taskDefinition?.unit; + } + + public get prerequisites(): Observable { + return this.taskDefinition.taskPrerequisitesCache.values; + } + + ngOnInit(): void { + this.prereqSub = this.taskDefinition.discussionPromptsCache.values.subscribe((values) => { + this.dataSource.data = values; + }); + } + + ngOnChanges(changes: SimpleChanges): void { + if ( + changes.taskDefinition && + changes.taskDefinition.previousValue?.id !== changes.taskDefinition.currentValue?.id + ) { + this.prereqSub?.unsubscribe(); + this.prereqSub = this.taskDefinition.discussionPromptsCache.values.subscribe((values) => { + this.dataSource.data = values; + }); + this.fetchDiscussionPrompts(); + } + } + + private fetchDiscussionPrompts() { + const taskDefinition = this.taskDefinition; + if (!taskDefinition.id) { + return; + } + this.discussionPromptService.loadDiscussionPrompts(null, taskDefinition).subscribe({ + next: (data) => { + this.dataSource.data = data; + }, + error: (error) => { + this.alertService.error(`Failed to load discussion prompts: ${error}`, 6000); + }, + }); + } + + public addNewPrompt() { + const content = this.newDiscussionPromptContent; + const priority = this.newDiscussionPromptWeight; + this.discussionPromptService + .create( + { + task_definition_id: this.taskDefinition.id, + content: content, + priority: priority, + }, + { + cache: this.taskDefinition.discussionPromptsCache, + constructorParams: this.taskDefinition, + }, + ) + .subscribe({ + next: (_result) => { + this.cancelNewDiscussionPrompt(); + this.prereqSub?.unsubscribe(); + this.prereqSub = this.taskDefinition.discussionPromptsCache.values.subscribe((values) => { + this.dataSource.data = values; + }); + this.alertService.success(`Succesfully created prompt`, 3000); + }, + error: (error) => { + this.alertService.error(`Failed to create prompt: ${error}`, 6000); + }, + }); + } + + public deletePrompt(prompt: DiscussionPrompt) { + prompt.delete(); + } + + createNewDiscussionPrompt() { + this.creatingNewDiscussionPrompt = true; + } + + cancelNewDiscussionPrompt() { + this.creatingNewDiscussionPrompt = false; + this.newDiscussionPromptContent = ''; + this.newDiscussionPromptWeight = 2; + } + + submit() { + this.discussionPromptService + .put({ + id: this.selected.id, + task_definition_id: this.taskDefinition.id, + content: this.selected.content, + priority: this.selected.priority, + }) + .subscribe({ + next: (_response) => { + this.cancelEdit(); + this.alertService.success('Successfully saved prompt', 3000); + }, + error: (error) => { + this.alertService.error(`Failed to update prompt: ${error}`, 6000); + }, + }); + } +} diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html index 5bbf1e64e5..5958295741 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html @@ -1,146 +1,175 @@ -
-
-
-
- 1 -
-
+
+

Details for {{ taskDefinition.abbreviation }} - {{ taskDefinition.name }}

+ +
-
-

Task details

-

Name the task and set target grade

-
- -
+
+
+ -
-
-
- 2 -
-
+
+
+

Task Details

+

Name the task and set target grade

+ +
+ + +
+

Task Learning Outcomes

+

Add learning outcomes for this task

+ +
+ -
-

Inbox

-

+

+

Inbox

+

Who assesses {{ unit.hasGroupwork() ? 'and submits ' : '' }}this task?

-
- -
-
-
+ + + -
-
-
- 3 -
-
+
+

Due Dates

+

When is the task due?

+ +
+ -
-

Due dates

-

When is this task due?

-
- -
-
-
+
+

Upload Requirements

+

What do students need to upload?

+ +
+ -
-
-
- 4 -
-
+
+

Task Resources

+

Upload task descriptions and resources

+ +
+ -
-

Upload requirements

-

What do students need to upload?

-
- -
-
-
- -
-
-
- 5 -
-
+
+

Prerequisite Tasks

+

+ Select which tasks need to be submitted before + {{ taskDefinition.abbreviation }} {{ taskDefinition.name }} + can be submitted +

+ + +
+ -
-

- Task description and resources -

-

Upload task descriptions and resources

-
- -
-
-
+
+

Discussion Prompts

+

+ Discussion prompts for tutors to use when discussing student tasks in class +

+ + +
+ -
-
-
- 6 -
-
+

Task Assessment Automation

+

Configure automated assessment

+ + + + } -
-

- Task assessment automation -

-

Automation is not enabled

-

- Configure automated assessment +

+

SCORM Test

+

+ Upload the corresponding SCORM 2004 test (e.g. Numbas)

-
- -
-
-
+ + + -
-
-
- 7 -
-
+
+

Optional Settings

+

Apply other options

+ +
-
-

Optional settings

-

Configure automated assessment

- - - - Options - - - - -
+ + +
-
- +
+
+ +
diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.scss b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.scss index 05e8bffddc..e69de29bb2 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.scss +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.scss @@ -1,10 +0,0 @@ -.mat-toolbar { - background-color: white; - // position: fixed; - // bottom: 0; - // width: 100%; -} - -.form-group { - -} diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.ts index 88f7ccfe2d..f6f6d51382 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.ts +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.ts @@ -1,24 +1,177 @@ -import { Component, Input } from '@angular/core'; -import { TaskDefinition } from 'src/app/api/models/task-definition'; -import { Unit } from 'src/app/api/models/unit'; -import { TaskDefinitionService } from 'src/app/api/services/task-definition.service'; -import { AlertService } from 'src/app/common/services/alert.service'; -import { DoubtfireConstants } from 'src/app/config/constants/doubtfire-constants'; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + HostListener, + Input, + OnChanges, + OnDestroy, + OnInit, + QueryList, + SimpleChanges, + ViewChild, + ViewChildren, +} from '@angular/core'; +import {Subscription} from 'rxjs'; +import {TaskDefinition} from 'src/app/api/models/task-definition'; +import {Unit} from 'src/app/api/models/unit'; +import {TaskDefinitionService} from 'src/app/api/services/task-definition.service'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; + +type TaskDefinitionSectionId = + | 'task-details' + | 'task-learning-outcomes' + | 'inbox' + | 'due-dates' + | 'upload-requirements' + | 'task-resources' + | 'prerequisite-tasks' + | 'discussion-prompts' + | 'task-assessment-automation' + | 'scorm-test' + | 'optional-settings'; + +interface TaskDefinitionSection { + id: TaskDefinitionSectionId; + label: string; +} @Component({ selector: 'f-task-definition-editor', templateUrl: 'task-definition-editor.component.html', styleUrls: ['task-definition-editor.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, }) -export class TaskDefinitionEditorComponent { +export class TaskDefinitionEditorComponent implements OnInit, AfterViewInit, OnChanges, OnDestroy { @Input() taskDefinition: TaskDefinition; @Input() unit: Unit; + @ViewChild('sectionScrollContainer') sectionScrollContainer: ElementRef; + @ViewChildren('sectionElement') sectionElements: QueryList>; + + public overseerEnabled: boolean = false; + public activeSectionId: TaskDefinitionSectionId = 'task-details'; + public readonly sectionList: TaskDefinitionSection[] = [ + {id: 'task-details', label: 'Task Details'}, + {id: 'task-learning-outcomes', label: 'Task Learning Outcomes'}, + {id: 'inbox', label: 'Inbox'}, + {id: 'due-dates', label: 'Due Dates'}, + {id: 'upload-requirements', label: 'Upload Requirements'}, + {id: 'task-resources', label: 'Task Resources'}, + {id: 'prerequisite-tasks', label: 'Prerequisite Tasks'}, + {id: 'discussion-prompts', label: 'Discussion Prompts'}, + {id: 'task-assessment-automation', label: 'Task Assessment Automation'}, + {id: 'scorm-test', label: 'SCORM Test'}, + {id: 'optional-settings', label: 'Optional Settings'}, + ]; + + private sectionElementMap: Map = new Map(); + private sectionChangesSubscription?: Subscription; + private overseerEnabledSubscription?: Subscription; + private readonly scrollTopOffsetPx: number = 112; + + constructor( + private taskDefinitionService: TaskDefinitionService, + private alerts: AlertService, + private constants: DoubtfireConstants, + ) {} - constructor(private taskDefinitionService: TaskDefinitionService, private alerts: AlertService, private constants: DoubtfireConstants) { - constants.IsOverseerEnabled.subscribe((enabled) => (this.overseerEnabled = enabled && this.unit.overseerEnabled)); + public ngOnInit() { + this.overseerEnabledSubscription = this.constants.IsOverseerEnabled.subscribe((enabled) => { + this.overseerEnabled = enabled && this.unit.overseerEnabled; + this.ensureActiveSectionIsVisible(); + this.rebuildSectionElementMap(); + }); } - public overseerEnabled: boolean = false; + public ngAfterViewInit() { + this.rebuildSectionElementMap(); + this.sectionChangesSubscription = this.sectionElements.changes.subscribe(() => { + this.rebuildSectionElementMap(); + this.syncActiveSectionOnScroll(); + }); + queueMicrotask(() => this.syncActiveSectionOnScroll()); + } + + public ngOnChanges(changes: SimpleChanges) { + if (changes.taskDefinition && !changes.taskDefinition.firstChange) { + this.ensureActiveSectionIsVisible(); + queueMicrotask(() => this.syncActiveSectionOnScroll()); + } + } + + public ngOnDestroy() { + this.sectionChangesSubscription?.unsubscribe(); + this.overseerEnabledSubscription?.unsubscribe(); + } + + public get visibleSections(): TaskDefinitionSection[] { + return this.overseerEnabled + ? this.sectionList + : this.sectionList.filter((section) => section.id !== 'task-assessment-automation'); + } + + public scrollToSection(sectionId: TaskDefinitionSectionId) { + this.activeSectionId = sectionId; + + const container = this.sectionScrollContainer?.nativeElement; + const target = this.sectionElementMap.get(sectionId); + + if (!container || !target) { + return; + } + + if (this.isContainerScrollable(container)) { + const targetTop = this.getSectionTopInContainer(container, target); + container.scrollTo({ + top: Math.max(targetTop - 8, 0), + behavior: 'smooth', + }); + return; + } + + target.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + } + + public syncActiveSectionOnScroll() { + const container = this.sectionScrollContainer?.nativeElement; + if (!container) { + return; + } + + if (!this.isContainerScrollable(container)) { + this.syncActiveSectionOnWindowScroll(); + return; + } + + const scrollPosition = container.scrollTop + 48; + let nextActiveSection = this.visibleSections[0]?.id; + + this.visibleSections.forEach((section) => { + const sectionElement = this.sectionElementMap.get(section.id); + if ( + sectionElement && + this.getSectionTopInContainer(container, sectionElement) <= scrollPosition + ) { + nextActiveSection = section.id; + } + }); + + if (nextActiveSection) { + this.activeSectionId = nextActiveSection; + } + } + + @HostListener('window:scroll') + @HostListener('window:resize') + public onWindowScroll() { + this.syncActiveSectionOnScroll(); + } public save() { this.taskDefinition.save().subscribe({ @@ -29,4 +182,49 @@ export class TaskDefinitionEditorComponent { error: (message) => this.alerts.error(message), }); } + + private ensureActiveSectionIsVisible() { + if (!this.visibleSections.some((section) => section.id === this.activeSectionId)) { + this.activeSectionId = this.visibleSections[0]?.id ?? 'task-details'; + } + } + + private rebuildSectionElementMap() { + this.sectionElementMap.clear(); + + this.sectionElements?.forEach((sectionElementRef) => { + const nativeElement = sectionElementRef.nativeElement; + const sectionId = nativeElement.getAttribute('data-section-id') as TaskDefinitionSectionId; + + if (sectionId) { + this.sectionElementMap.set(sectionId, nativeElement); + } + }); + } + + private getSectionTopInContainer(container: HTMLElement, sectionElement: HTMLElement): number { + const containerRect = container.getBoundingClientRect(); + const sectionRect = sectionElement.getBoundingClientRect(); + return container.scrollTop + (sectionRect.top - containerRect.top); + } + + private syncActiveSectionOnWindowScroll() { + const threshold = this.scrollTopOffsetPx + 12; + let nextActiveSection = this.visibleSections[0]?.id; + + this.visibleSections.forEach((section) => { + const sectionElement = this.sectionElementMap.get(section.id); + if (sectionElement && sectionElement.getBoundingClientRect().top <= threshold) { + nextActiveSection = section.id; + } + }); + + if (nextActiveSection) { + this.activeSectionId = nextActiveSection; + } + } + + private isContainerScrollable(container: HTMLElement): boolean { + return container.scrollHeight > container.clientHeight + 1; + } } diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-general/task-definition-general.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-general/task-definition-general.component.html index 5ec2551e03..bd4e07d144 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-general/task-definition-general.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-general/task-definition-general.component.html @@ -1,8 +1,8 @@ -
+
Grade where task appears - - @for (grade of grades; track grade) { + + @for (grade of grades; track grade.value) { {{ grade.viewValue }} } @@ -10,21 +10,21 @@ Abbreviation - Week, sequence, and grade. eg 1.1P - + Weight - Effort relative to other tasks. - +
Descriptive task name - + Task description - + diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-general/task-definition-general.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-general/task-definition-general.component.ts index 3bfe2ecd0c..ab781061d2 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-general/task-definition-general.component.ts +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-general/task-definition-general.component.ts @@ -1,20 +1,22 @@ -import { Component, Input } from '@angular/core'; -import { TaskDefinition } from 'src/app/api/models/task-definition'; -import { Unit } from 'src/app/api/models/unit'; -import { GradeService } from 'src/app/common/services/grade.service'; +import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; +import {TaskDefinition} from 'src/app/api/models/task-definition'; +import {Unit} from 'src/app/api/models/unit'; +import {GradeService} from 'src/app/common/services/grade.service'; @Component({ selector: 'f-task-definition-general', templateUrl: 'task-definition-general.component.html', styleUrls: ['task-definition-general.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, }) export class TaskDefinitionGeneralComponent { @Input() taskDefinition: TaskDefinition; - public grades: { value: number; viewValue: string }[]; + constructor(private gradeService: GradeService) {} - constructor(private gradeService: GradeService) { - this.grades = this.gradeService.gradeViewData.filter((grade) => grade.value !== -1); + public get grades(): {value: number; viewValue: string}[] { + return this.gradeService.gradeViewDataFor(this.unit); } public get unit(): Unit { diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-options/task-definition-options.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-options/task-definition-options.component.html index a479f20ce3..92a1002ce2 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-options/task-definition-options.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-options/task-definition-options.component.html @@ -1,32 +1,57 @@ -
-
+
+
Restrict updates - This task will be locked once it goes into a staff-allocated status. Students will not be able to resubmit, even - if placed on the fix status. + This task will be locked once it goes into a staff-allocated status. Students will not be able + to resubmit, even if placed on the fix status.
-
- Graded +
+ Assess in Portfolio Only - Provide a grade along with the complete or discuss status. We recommend avoiding this practice. + This task cannot be signed off as complete and will only be assessed in the final portfolio. + Tutors can leave feedback and return it to the "Working on it" state. Students must then + update the status to "Assess in Portfolio" to ensure it's included in their portfolio.
-
+
+ + Requires Discussion + + + Tutors cannot mark this task as complete unless it has first been marked as discussed in + class. + +
+ +
+ Graded + + Provide a grade along with the complete or discuss status. We recommend avoiding this + practice. + +
+ +
- Maximum Score - + Quality Stars + - Provide a score alongside the task status. We recommend avoiding this practice. + Provide a number of stars alongside the task status. Make sure you have a clear reason for + each star within your task description.
diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-options/task-definition-options.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-options/task-definition-options.component.ts index 7fc62fa813..29037633b7 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-options/task-definition-options.component.ts +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-options/task-definition-options.component.ts @@ -1,16 +1,39 @@ -import { Component, Input } from '@angular/core'; -import { TaskDefinition } from 'src/app/api/models/task-definition'; -import { Unit } from 'src/app/api/models/unit'; +import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; +import {TaskDefinition} from 'src/app/api/models/task-definition'; +import {Unit} from 'src/app/api/models/unit'; +import {ConfirmationModalService} from 'src/app/common/modals/confirmation-modal/confirmation-modal.service'; @Component({ selector: 'f-task-definition-options', templateUrl: 'task-definition-options.component.html', styleUrls: ['task-definition-options.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, }) export class TaskDefinitionOptionsComponent { @Input() taskDefinition: TaskDefinition; + constructor(private confirmationModal: ConfirmationModalService) {} public get unit(): Unit { return this.taskDefinition?.unit; } + + public onToggleAssessInPortfolioOnly() { + if (!this.taskDefinition.assessInPortfolioOnly) { + return; + } + + setTimeout(() => { + this.taskDefinition.assessInPortfolioOnly = false; + console.log(this.taskDefinition.assessInPortfolioOnly); + + this.confirmationModal.show( + `Enable Assess in Portfolio Only?`, + `Enabling Assess in Portfolio Only will update all overdue tasks for ${this.taskDefinition.name} to the Assess in Portfolio state`, + () => { + this.taskDefinition.assessInPortfolioOnly = true; + }, + ); + }); + } } diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/overseer-script-editor-modal/overseer-script-editor-modal.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/overseer-script-editor-modal/overseer-script-editor-modal.component.html new file mode 100644 index 0000000000..3e53b5fd7d --- /dev/null +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/overseer-script-editor-modal/overseer-script-editor-modal.component.html @@ -0,0 +1,18 @@ +
+
+

{{ data.taskDefinition.abbreviation }} {{ data.taskDefinition.name }}

+

Overseer script

+
+
+ +
+ @if (!loading) { + + } +
diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/overseer-script-editor-modal/overseer-script-editor-modal.component.scss b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/overseer-script-editor-modal/overseer-script-editor-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/overseer-script-editor-modal/overseer-script-editor-modal.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/overseer-script-editor-modal/overseer-script-editor-modal.component.ts new file mode 100644 index 0000000000..c2bc724c2e --- /dev/null +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/overseer-script-editor-modal/overseer-script-editor-modal.component.ts @@ -0,0 +1,63 @@ +import {CodeModel} from '@ngstack/code-editor'; +import {HttpClient} from '@angular/common/http'; +import {ChangeDetectionStrategy, Component, Inject, OnInit} from '@angular/core'; +import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {OverseerScriptEditorModalData} from './overseer-script-editor-modal.service'; + +@Component({ + selector: 'f-overseer-script-editor-modal', + templateUrl: './overseer-script-editor-modal.component.html', + styleUrls: ['./overseer-script-editor-modal.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class OverseerScriptEditorModalComponent implements OnInit { + constructor( + @Inject(MAT_DIALOG_DATA) public data: OverseerScriptEditorModalData, + public dialogRef: MatDialogRef, + private httpClient: HttpClient, + private alertService: AlertService, + ) {} + + public model: CodeModel = { + language: 'shell', + uri: 'run.sh', + value: '', + }; + + scriptContent: string; + + loading: boolean = false; + ngOnInit() { + this.loading = true; + this.httpClient + .get(this.data.taskDefinition.taskOverseerExecutionScriptUrl) + .subscribe((data: string) => { + this.model.value = data; + this.loading = false; + }); + } + + save() { + const scriptOriginal = this.model.value; + const scriptEncoded = this.base64UrlEncode(scriptOriginal); + + this.httpClient + .put(this.data.taskDefinition.taskOverseerExecutionScriptUrl, { + script_content: scriptEncoded, + }) + .subscribe({ + next: (_result) => { + this.dialogRef.close(); + }, + error: (error) => { + this.alertService.error(`Failed to save script: ${error}`, 6000); + }, + }); + } + + private base64UrlEncode(str) { + return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + } +} diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/overseer-script-editor-modal/overseer-script-editor-modal.service.ts b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/overseer-script-editor-modal/overseer-script-editor-modal.service.ts new file mode 100644 index 0000000000..a7e360800c --- /dev/null +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/overseer-script-editor-modal/overseer-script-editor-modal.service.ts @@ -0,0 +1,28 @@ +import {Injectable} from '@angular/core'; +import {MatDialog} from '@angular/material/dialog'; +import {TaskDefinition} from 'src/app/api/models/task-definition'; +import {OverseerScriptEditorModalComponent} from './overseer-script-editor-modal.component'; + +export interface OverseerScriptEditorModalData { + taskDefinition: TaskDefinition; +} + +@Injectable({ + providedIn: 'root', +}) +export class OverseerScriptEditorModalService { + constructor(public dialog: MatDialog) {} + + public show(taskDefinition: TaskDefinition) { + const _dialogRef = this.dialog.open< + OverseerScriptEditorModalComponent, + OverseerScriptEditorModalData + >(OverseerScriptEditorModalComponent, { + data: { + taskDefinition: taskDefinition, + }, + width: '100%', + maxWidth: '1200px', + }); + } +} diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/task-definition-overseer.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/task-definition-overseer.component.html index 92ef121ff3..249f683e26 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/task-definition-overseer.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/task-definition-overseer.component.html @@ -1,40 +1,415 @@ -
- - Automation Enabled - +
+
+ + Automation Enabled + +
+ + You can add or modify Overseer steps while keeping this unchecked and use the test submission + feature. Once everything is ready, enable automation to apply it to all student submissions. + +
- + Docker Image - - @for (image of images | async; track image) { - {{ image.description }} -} + + @for (image of images | async; track image.id) { + {{ image.description }} + } Docker image for Overseer - + + @if (taskDefinition.hasTaskAssessmentResources) { +
+ + + +
+ + @if (showOverseerResourcesEditor) { +
+ @if (isLoadingOverseerResourcesArchive) { +
+ Loading Overseer Resources archive... +
+ } @else if (overseerResourcesArchive) { + + } @else { +
+ Unable to load Overseer Resources archive. +
+ } +
+ } + } -
+
+ +
+ +@if (taskDefinition.overseerImageId) { + @if (!taskDefinitionHasChanges()) { +
+
+ @if (this.selectedOverseerStep) { + + + } +
+
+ + + +
+
Overseer Steps
+ @if (!newOverseerStep) { + + } +
+
+ @for (step of overseerSteps; track step.id) { +
+ drag_indicator + {{ step.sortOrder }}. {{ step.name ?? 'Untitled Step' }} +
+ } + @if (newOverseerStep) { +
+ {{ newOverseerStep.sortOrder }}. {{ newOverseerStep.name || 'Untitled Step' }} +
+ } +
+
+ + @if (selectedOverseerStep) { + + +
+ + Step Name + + Visible to staff only + + + Description + + Visible to staff only + +
+ + + Step Type + + Custom Script + Input/Output + + + + @if (selectedOverseerStep.stepType === 'output_diff') { +
+
+ + Script Input File + + + (none) + @for (file of taskDefinition.overseerResourceFiles; track file) { + {{ file }} + } + + The selected file will be passed as standard input to the program when it + runs. + + + + Show input file to student + +
+
+ + Expected Output File + + (none) + @for (file of taskDefinition.overseerResourceFiles; track file) { + {{ file }} + } + + Program output must exactly match this file to pass. + + + Show expected output file to student + +
+ Partial Output Comparison +
+ + If enabled, test passes if the expected output appears anywhere in the + student's output. Otherwise, only exact matches pass. + +
+
+ } + +
+
Execution Script
+ + Language + + @for (language of getLanguages; track language) { + {{ + language.id ?? language.aliases?.[0] ?? language.id + }} + } + + Visual use only + +
+ +
+ + + @if (selectedOverseerStep.stepType === 'status_check') { + This step passes only if the script exits with status 0. Any non-zero exit code + marks the step as failed. + } @else if (selectedOverseerStep.stepType === 'output_diff') { + The script output is compared against the expected output. If noisy warnings + cause failures, move that logic into a separate step. + } + +
+ + Time Limit (s) + + How long the code can execute for before overseer automatically kills the + process. A timeout will result in a failed test (Exit status 124) + +
+
+ +
+ Halt on success + + @if (selectedOverseerStep.haltOnSuccess) { + + Status on Success + + No Change + @for (status of statusKeys; track status) { + {{ statusName(status) }} + } + + + } +
+
+ +
+ +
+ + Halt on failure + + @if (selectedOverseerStep.haltOnFailure) { + + + Status on Fail + + No Change + @for (status of statusKeys; track status) { + {{ statusName(status) }} + } + + + } +
+
+
+ + +
+ + + Test Name + + Shown to student in overseer report + + + Description + + Shown to student in overseer report + +
+ + Feedback message + + Feedback to provide to the student if the task failed. You can leave this blank + and a default will message will be used instead. + + +
+ Enabled +
+ + If disabled, this step will not run and will not appear in the Overseer report for + students. + +
+ +
+ Show output +
+ + When enabled, students can see this step’s standard output, including script + output, program output, and compile errors. + +
+
+
+ } @else { +
+ No steps selected + tab_unselected +
+ } +
+
+ } @else { +
+ Please save the task definition before configuring the overseer steps. +
+ } +} diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/task-definition-overseer.component.scss b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/task-definition-overseer.component.scss index e69de29bb2..ff10c528b0 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/task-definition-overseer.component.scss +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/task-definition-overseer.component.scss @@ -0,0 +1,50 @@ +.example-list { + width: 300px; + max-width: 100%; + border: solid 1px #ccc; + min-height: 50px; + height: 100%; + display: block; + background: white; + // border-radius: 4px; + overflow: hidden; +} + +.example-box { + padding: 20px 10px; + border-bottom: solid 1px #ccc; + height: 50px; + color: rgba(0, 0, 0, 0.87); + display: flex; + flex-direction: row; + align-items: center; + // justify-content: space-between; + box-sizing: border-box; + cursor: pointer; + background: white; + font-size: 14px; + font-family: sans-serif; + user-select: none; +} + +.cdk-drag-preview { + border: none; + box-sizing: border-box; + border-radius: 4px; + box-shadow: + 0 5px 5px -3px rgba(0, 0, 0, 0.2), + 0 8px 10px 1px rgba(0, 0, 0, 0.14), + 0 3px 14px 2px rgba(0, 0, 0, 0.12); +} +.cdk-drag-placeholder { + opacity: 0; +} +.cdk-drag-animating { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} +.example-box:last-child { + border: none; +} +.example-list.cdk-drop-list-dragging .example-box:not(.cdk-drag-placeholder) { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/task-definition-overseer.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/task-definition-overseer.component.ts index f9b30dde0d..f9bcd613bc 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/task-definition-overseer.component.ts +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/task-definition-overseer.component.ts @@ -1,39 +1,258 @@ -import { Component, Input, OnChanges } from '@angular/core'; -import { Observable } from 'rxjs'; +import * as monaco from 'monaco-editor'; +import {CdkDragDrop, moveItemInArray} from '@angular/cdk/drag-drop'; +import {HttpClient, HttpResponse} from '@angular/common/http'; +import { + ChangeDetectionStrategy, + Component, + Input, + OnChanges, + OnInit, + SimpleChanges, + ViewChild, +} from '@angular/core'; +import {MatSelectChange} from '@angular/material/select'; +import {Observable} from 'rxjs'; import { - OverseerAssessment, OverseerImage, OverseerImageService, Task, + TaskService, + TaskStatusEnum, User, UserService, } from 'src/app/api/models/doubtfire-model'; -import { TaskDefinition } from 'src/app/api/models/task-definition'; -import { Unit } from 'src/app/api/models/unit'; -import { TaskDefinitionService } from 'src/app/api/services/task-definition.service'; -import { TaskAssessmentModalService } from 'src/app/common/modals/task-assessment-modal/task-assessment-modal.service'; -import { AlertService } from 'src/app/common/services/alert.service'; -import { TaskSubmissionService } from 'src/app/common/services/task-submission.service'; +import {OverseerStep} from 'src/app/api/models/overseer/overseer-step'; +import {TaskDefinition} from 'src/app/api/models/task-definition'; +import {Unit} from 'src/app/api/models/unit'; +import {OverseerStepService} from 'src/app/api/services/overseer-step.service'; +import {TaskDefinitionService} from 'src/app/api/services/task-definition.service'; +import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; +import {TaskAssessmentModalService} from 'src/app/common/modals/task-assessment-modal/task-assessment-modal.service'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {TaskSubmissionService} from 'src/app/common/services/task-submission.service'; +import {OverseerScriptEditorModalService} from './overseer-script-editor-modal/overseer-script-editor-modal.service'; @Component({ selector: 'f-task-definition-overseer', templateUrl: 'task-definition-overseer.component.html', styleUrls: ['task-definition-overseer.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, }) -export class TaskDefinitionOverseerComponent implements OnChanges { +export class TaskDefinitionOverseerComponent implements OnChanges, OnInit { @Input() taskDefinition: TaskDefinition; + @ViewChild('editor') editorComponent; + public currentUserTask: Task; + editorOptions = { + theme: 'vs', + language: 'shell', + renderMinimap: false, + + minimap: { + enabled: false, + }, + }; + + public stepType: 'status_check' | 'output_diff' = 'status_check'; + public visibility = 'public'; + public showOverseerResourcesEditor = false; + public isLoadingOverseerResourcesArchive = false; + public overseerResourcesArchive: Blob | File | null = null; + public images: Observable; + constructor( + private http: HttpClient, private alerts: AlertService, private overseerImageService: OverseerImageService, private modalService: TaskAssessmentModalService, private submissions: TaskSubmissionService, private userService: UserService, - private taskDefinitionService: TaskDefinitionService + private taskDefinitionService: TaskDefinitionService, + private fileDownloaderService: FileDownloaderService, + private overseerScriptEditorModal: OverseerScriptEditorModalService, + private overseerStepService: OverseerStepService, + private taskService: TaskService, ) {} + public get statusKeys() { + return this.taskService.statusKeys; + } + + public statusName(status: TaskStatusEnum) { + return this.taskService.statusLabels.get(status); + } + + public selectedOverseerStep: OverseerStep = null; + public newOverseerStep: OverseerStep = null; + + public overseerSteps: OverseerStep[] = []; + + onRunCommandChange(step: OverseerStep, value: string) { + step.runCommand = `b64:${this.base64UrlEncode(value)}`; + } + + private base64UrlEncode(str) { + return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + } + + selectStep(step: OverseerStep) { + this.selectedOverseerStep = step; + setTimeout(() => { + const editor = this.editorComponent?._editor; + if (editor) { + editor.revealLine(1); + editor.setScrollPosition({scrollTop: 0}); + } + }); + } + + addStep() { + this.newOverseerStep = new OverseerStep(this.taskDefinition); + this.newOverseerStep.stepType = 'status_check'; + this.newOverseerStep.timeout = 30; + this.newOverseerStep.enabled = true; + this.newOverseerStep.showStdout = true; + this.newOverseerStep.statusOnFailure = 'no_change'; + this.newOverseerStep.statusOnSuccess = 'no_change'; + this.newOverseerStep.commandLanguage = 'shell'; + this.newOverseerStep.decodedRunCommand = '#!/bin/bash\n\n'; + this.newOverseerStep.runCommand = '#!/bin/bash\n\n'; + this.newOverseerStep.showExpectedOutput = true; + + this.newOverseerStep.sortOrder = this.taskDefinition.overseerStepsCache.currentValues.length; + this.selectedOverseerStep = this.newOverseerStep; + } + + getFeedbackMessagePlaceholder() { + // If the feedback message is blank, this is what will automatically be used + // (If this changes, ensure to update it in AcceptOverseerJob) + switch (this.selectedOverseerStep.stepType) { + case 'status_check': + return 'This test did not complete successfully. Check the output for any errors.'; + case 'output_diff': + return 'Your output did not match the expected result.'; + } + } + + ngOnInit(): void { + this.images = this.overseerImageService.query(); + + this.taskDefinition.overseerStepsCache.values.subscribe((steps) => { + this.overseerSteps = [...steps]; + }); + } + + public get getLanguages() { + return monaco?.languages.getLanguages() ?? []; + } + + onLanguageChange(event: MatSelectChange) { + const value = event.value; + this.editorOptions.language = value; + this.editorOptions = {...this.editorOptions}; + } + + drop(event: CdkDragDrop) { + if (this.newOverseerStep) { + this.alerts.error('Please save changes before re-ordering steps', 3000); + return; + } + moveItemInArray(this.overseerSteps, event.previousIndex, event.currentIndex); + // TODO: open endpoint to update sort orders in a single request + for (let i = 0; i < this.overseerSteps.length; i++) { + const step = this.taskDefinition.overseerStepsCache.get(this.overseerSteps[i].id); + if (step.sortOrder === i) { + // Ignore if no change + continue; + } + step.sortOrder = i; + this.overseerStepService + .update( + { + id: step.id, + unitId: this.unit.id, + taskDefId: this.taskDefinition.id, + }, + { + entity: step, + constructorParams: this.taskDefinition, + }, + ) + .subscribe({ + next: () => { + // console.log('updated!'); + }, + error: (error) => { + this.alerts.error(`Failed to update order of steps: ${error}`, 6000); + }, + }); + } + } + + deleteStep() { + if (this.selectedOverseerStep && this.selectedOverseerStep === this.newOverseerStep) { + this.newOverseerStep = null; + this.selectedOverseerStep = null; + return; + } + this.selectedOverseerStep?.delete(); + this.selectedOverseerStep = null; + } + + saveStep() { + if (!this.selectedOverseerStep.id) { + // this.newOverseerStep.runCommand = this.model.value; + this.overseerStepService + .create( + { + unitId: this.unit.id, + taskDefId: this.taskDefinition.id, + }, + { + entity: this.newOverseerStep, + }, + ) + .subscribe({ + next: (result) => { + this.alerts.success('Added overseer step', 3000); + result.taskDefinition = this.taskDefinition; + this.taskDefinition.overseerStepsCache.add(result); + this.selectStep(result); + this.newOverseerStep = null; + }, + error: (error) => { + console.error(error); + this.alerts.error(error, 3000); + }, + }); + } else { + this.overseerStepService + .update( + { + id: this.selectedOverseerStep.id, + unitId: this.unit.id, + taskDefId: this.taskDefinition.id, + }, + { + entity: this.selectedOverseerStep, + cache: this.taskDefinition.overseerStepsCache, + }, + ) + .subscribe({ + next: (_result) => { + this.alerts.success('Saved overseer step', 3000); + }, + error: (error) => { + console.error(error); + this.alerts.error(error, 3000); + }, + }); + } + } + public get overseerEnabled(): boolean { return this.unit.overseerEnabled; } @@ -42,20 +261,28 @@ export class TaskDefinitionOverseerComponent implements OnChanges { return this.taskDefinition?.unit; } - public get images(): Observable { - return this.overseerImageService.query(); - } - get currentUser(): User { return this.userService.currentUser; } - public ngOnChanges() { + public taskDefinitionHasChanges(): boolean { + return this.taskDefinition.hasChanges(this.taskDefinitionService.mapping); + } + + public ngOnChanges(changes: SimpleChanges) { const proj = this.unit.findProjectForUsername(this.currentUser.username); if (proj) { this.currentUserTask = proj.findTaskForDefinition(this.taskDefinition.id); this.hasAnySubmissions(); } + if (changes['taskDefinition']) { + this.taskDefinition.overseerStepsCache.values.subscribe((steps) => { + this.overseerSteps = [...steps]; + }); + this.showOverseerResourcesEditor = false; + this.isLoadingOverseerResourcesArchive = false; + this.overseerResourcesArchive = null; + } } testSubmission() { @@ -65,37 +292,105 @@ export class TaskDefinitionOverseerComponent implements OnChanges { this.currentUserTask.definition = this.taskDefinition; this.currentUserTask.status = 'ready_for_feedback'; this.currentUserTask.id = this.taskDefinition.id; // set a default id... - this.hasAnySubmissions(); + // this.hasAnySubmissions(); } this.currentUserTask.presentTaskSubmissionModal(this.currentUserTask.status, false, true); } + editScript() { + this.overseerScriptEditorModal.show(this.taskDefinition); + } + testSubmissionHistory() { this.modalService.show(this.currentUserTask); } private hasAnySubmissions() { - if (!this.currentUserTask) return; + if (!this.currentUserTask) { + return; + } this.submissions.getLatestSubmissionsTimestamps(this.currentUserTask).subscribe({ - next: (result: OverseerAssessment[]) => {}, error: (error) => { this.alerts.error('Error: ' + error, 6000); }, }); } - public uploadOverseerResources(files: FileList) { - const validFiles = Array.from(files as ArrayLike).filter((f) => f.type === 'application/zip'); + public removeOverseerResources() { + this.taskDefinition.deleteOverseerResources().subscribe({ + next: () => { + this.alerts.success('Deleted Overseer Resources', 2000); + this.taskDefinition.hasTaskAssessmentResources = false; + this.showOverseerResourcesEditor = false; + this.overseerResourcesArchive = null; + }, + }); + } + + public downloadOverseerResources() { + this.fileDownloaderService.downloadFile( + this.taskDefinition.getOverseerResourcesUrl(), + this.taskDefinition.name + '.zip', + ); + } + + public uploadOverseerResources(files: ArrayLike) { + const validFiles = Array.from(files as ArrayLike).filter( + (f) => f.type === 'application/zip' || f.type === 'application/x-zip-compressed', + ); if (validFiles.length > 0) { const file = validFiles[0]; - this.taskDefinitionService.uploadTaskResources(this.taskDefinition, file).subscribe({ - next: () => this.alerts.success('Uploaded task sheet', 2000), + this.taskDefinitionService.uploadOverseerResources(this.taskDefinition, file).subscribe({ + next: (resourceFiles) => { + this.alerts.success('Uploaded Overseer Resources', 2000); + this.taskDefinition.hasTaskAssessmentResources = true; + this.taskDefinition.overseerResourceFiles = [...resourceFiles]; + this.overseerResourcesArchive = file; + }, error: (message) => this.alerts.error(message, 6000), }); } else { - this.alerts.error('Please drop a PDF to upload for this task', 6000); + this.alerts.error('Please drop a zip with scripts for this task to upload', 6000); + } + } + + public toggleOverseerResourcesEditor() { + this.showOverseerResourcesEditor = !this.showOverseerResourcesEditor; + if ( + this.showOverseerResourcesEditor && + !this.overseerResourcesArchive && + this.taskDefinition?.hasTaskAssessmentResources + ) { + this.loadOverseerResourcesArchive(); + } + } + + public onOverseerResourcesArchiveSaved(response: HttpResponse) { + this.taskDefinition.hasTaskAssessmentResources = true; + + if (Array.isArray(response.body)) { + this.taskDefinition.overseerResourceFiles = response.body.filter( + (file): file is string => typeof file === 'string', + ); } } + + private loadOverseerResourcesArchive() { + this.isLoadingOverseerResourcesArchive = true; + this.http.get(this.taskDefinition.getOverseerResourcesUrl(), {responseType: 'blob'}).subscribe({ + next: (archiveBlob) => { + this.overseerResourcesArchive = archiveBlob; + }, + error: (error) => { + this.isLoadingOverseerResourcesArchive = false; + this.showOverseerResourcesEditor = false; + this.alerts.error(`Failed to load Overseer Resources Zip: ${error?.error?.error ?? error}`); + }, + complete: () => { + this.isLoadingOverseerResourcesArchive = false; + }, + }); + } } diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-prerequisites/task-definition-prerequisites.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-prerequisites/task-definition-prerequisites.component.html new file mode 100644 index 0000000000..62decec78a --- /dev/null +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-prerequisites/task-definition-prerequisites.component.html @@ -0,0 +1,119 @@ +
+
+ + + + + + + + + + + + + + + } @else { + + } + + + + +
Task + {{ link.prerequisite?.abbreviation }} {{ link.prerequisite?.name }} + + @if (staffView) { + Minimum Required Status + } @else { + Current Status + } + + @if (!staffView) { + + } @else { + @if (link.taskStatus) { + + @for (state of stateOptions; track state) { + + {{ state.label }} + + } + + } + } + + @if (staffView) { + Actions + } @else { + Required Status + } + + @if (staffView) { + + +
+
+ + @if (staffView) { +
+ + + + + @for (td of filteredTaskDefs; track td) { + {{ td.abbreviation }} - {{ td.name }} + } + + +
+ } +
diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-prerequisites/task-definition-prerequisites.component.scss b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-prerequisites/task-definition-prerequisites.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-prerequisites/task-definition-prerequisites.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-prerequisites/task-definition-prerequisites.component.ts new file mode 100644 index 0000000000..7870470c2c --- /dev/null +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-prerequisites/task-definition-prerequisites.component.ts @@ -0,0 +1,250 @@ +import { + ChangeDetectionStrategy, + Component, + Input, + OnChanges, + OnInit, + SimpleChanges, +} from '@angular/core'; +import {FormControl} from '@angular/forms'; +import {MatTableDataSource} from '@angular/material/table'; +import {Observable, Subscription} from 'rxjs'; +import {Task} from 'src/app/api/models/task'; +import {TaskDefinition} from 'src/app/api/models/task-definition'; +import {TaskPrerequisite} from 'src/app/api/models/task-prerequisite'; +import {TaskStatusEnum} from 'src/app/api/models/task-status'; +import {Unit} from 'src/app/api/models/unit'; +import {TaskDefinitionService} from 'src/app/api/services/task-definition.service'; +import {TaskPrerequisiteService} from 'src/app/api/services/task-prerequisite.service'; +import {AlertService} from 'src/app/common/services/alert.service'; + +@Component({ + selector: 'f-task-definition-prerequisites', + templateUrl: 'task-definition-prerequisites.component.html', + styleUrls: ['task-definition-prerequisites.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class TaskDefinitionPrerequisitesComponent implements OnInit, OnChanges { + @Input() taskDefinition: TaskDefinition; + @Input() staffView: boolean; + @Input() task: Task; + + displayedColumns: string[] = ['task-definition', 'minimum-required-state', 'actions']; + + private prereqSub?: Subscription; + + public dataSource: MatTableDataSource = new MatTableDataSource(); + + selectedTaskPrerequisite: TaskDefinition | null = null; + searchCtrl = new FormControl(''); + + // All other task definitions in the unit (exclude the current one) + filteredTaskDefs: TaskDefinition[] = []; + + public readonly STATES: Partial> = { + ready_for_feedback: 1, + assess_in_portfolio: 1, + discuss: 2, + attention_required: 0, + demonstrate: 2, + complete: 3, + }; + + public readonly stateOptions = [ + {value: 'ready_for_feedback', label: 'Ready for Feedback'}, + {value: 'discuss', label: 'Discuss'}, + {value: 'complete', label: 'Complete'}, + ]; + + constructor( + private taskDefinitionService: TaskDefinitionService, + private alertService: AlertService, + private taskPrerequisiteService: TaskPrerequisiteService, + ) {} + public get unit(): Unit { + return this.taskDefinition?.unit; + } + + public get prerequisites(): Observable { + return this.taskDefinition.taskPrerequisitesCache.values; + } + + ngOnInit(): void { + this.searchCtrl.valueChanges.subscribe((value: string | TaskDefinition) => { + const search = (typeof value === 'string' ? value : value?.name || '').toLowerCase(); + this.filterTaskDefs(search); + }); + + this.prereqSub = this.taskDefinition.taskPrerequisitesCache.values.subscribe((values) => { + this.dataSource.data = values; + }); + + // this.fetchTaskPrerequisites(); + } + + private mapPrerequisites(taskDefinition: TaskDefinition) { + const prerequisites = taskDefinition.taskPrerequisitesCache.currentValues; + const definitions = taskDefinition.unit.taskDefinitions; + for (const prerequisite of prerequisites) { + prerequisite.taskDefinition = definitions.find( + (td) => td.id === prerequisite.taskDefinitionId, + ); + prerequisite.prerequisite = definitions.find((td) => td.id === prerequisite.prerequisiteId); + } + } + + ngOnChanges(changes: SimpleChanges): void { + if ( + changes.taskDefinition && + changes.taskDefinition.previousValue?.id !== changes.taskDefinition.currentValue?.id + ) { + this.filterTaskDefs(this.searchCtrl.value ?? ''); + this.prereqSub?.unsubscribe(); + this.prereqSub = this.taskDefinition.taskPrerequisitesCache.values.subscribe((values) => { + this.dataSource.data = values; + }); + this.fetchTaskPrerequisites(); + } + } + + private fetchTaskPrerequisites() { + const taskDefinition = this.taskDefinition; + if (!taskDefinition.id) { + return; + } + this.taskPrerequisiteService + .query( + { + unitId: this.unit.id, + taskDefId: taskDefinition.id, + }, + { + cache: taskDefinition.taskPrerequisitesCache, + }, + ) + .subscribe({ + next: (data) => { + for (const prereq of data) { + if (prereq.taskDefinitionId !== taskDefinition.id) { + continue; + } + taskDefinition.taskPrerequisitesCache.getOrCreate( + prereq.id, + this.taskPrerequisiteService, + prereq, + ); + } + this.mapPrerequisites(taskDefinition); + this.filterTaskDefs(this.searchCtrl.value ?? ''); + }, + error: (error) => { + this.alertService.error( + `Failed to fetch prerequisites for task definition: ${error}`, + 6000, + ); + }, + }); + } + + private filterTaskDefs(search: string) { + this.filteredTaskDefs = this.taskDefinition.unit.taskDefinitionCache.currentValues + // Hide self from the list + .filter((td) => td.id !== this.taskDefinition.id) + // Hide tasks already added as a prerequisite + .filter( + (td) => + !this.taskDefinition.taskPrerequisitesCache.currentValues.some( + (p: TaskPrerequisite) => p.prerequisite.id === td.id, + ), + ) + // Higher target grades can not be a prerequisite + .filter((td) => td.targetGrade <= this.taskDefinition.targetGrade) + // Tasks with a later due date can not be a prerequisite + // .filter((td) => td.targetDate <= this.taskDefinition.targetDate) + // Search filter + .filter( + (td) => + td.name.toLowerCase().includes(search) || td.abbreviation.toLowerCase().includes(search), + ); + } + + displayFn(td: TaskDefinition): string { + return td && td.abbreviation ? `${td.abbreviation} - ${td.name}` : ''; + } + + public addTaskPrerequisite(event: Event): void { + event.stopPropagation(); + const taskDefinition = this.taskDefinition; + const selectedTaskPrerequisite = this.selectedTaskPrerequisite; + + this.selectedTaskPrerequisite = null; + + if (!taskDefinition) { + return this.alertService.error('Invalid task definition', 6000); + } + + if (!selectedTaskPrerequisite) { + return this.alertService.error( + 'Please select a task definition to add as a prerequisite', + 6000, + ); + } + + this.taskDefinitionService + .addTaskPrerequisite(taskDefinition, selectedTaskPrerequisite) + .subscribe({ + next: (response) => { + if (!response) { + this.alertService.error('Failed to add task prerequisite', 6000); + return; + } + taskDefinition.taskPrerequisitesCache.getOrCreate( + response.id, + this.taskPrerequisiteService, + response, + ); + this.mapPrerequisites(taskDefinition); + + this.alertService.success( + `Successfully added task ${selectedTaskPrerequisite.abbreviation} as a prerequisite`, + 5000, + ); + this.unit.refresh(); + this.filterTaskDefs(this.searchCtrl.value ?? ''); + }, + error: (error) => { + this.alertService.error(`Failed to add task prerequisite: ${error}`, 6000); + }, + }); + } + + public updateTaskPrerequisite(prerequisiteLink: TaskPrerequisite) { + this.taskDefinitionService + .updateTaskPrerequisite(prerequisiteLink, prerequisiteLink.taskStatus) + .subscribe({ + next: (response) => { + if (!response) { + this.alertService.error('Failed to update task prerequisite', 6000); + return; + } + this.alertService.success( + `Successfully updated prerequisite ${prerequisiteLink.prerequisite.abbreviation} to ${prerequisiteLink.taskStatus} `, + 5000, + ); + }, + error: (error) => { + this.alertService.error(`Failed to update task prerequisite: ${error}`, 6000); + }, + }); + } + + public removePrerequisite(prerequisiteToRemove: TaskPrerequisite) { + if (!prerequisiteToRemove) { + return; + } + prerequisiteToRemove.delete().subscribe(() => { + this.filterTaskDefs(this.searchCtrl.value ?? ''); + }); + } +} diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-resources/task-definition-resources.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-resources/task-definition-resources.component.html index 82ae176482..b007b7cdeb 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-resources/task-definition-resources.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-resources/task-definition-resources.component.html @@ -1,36 +1,43 @@ -
-
+
+
@if (taskDefinition.hasTaskSheet) { -
- - -
-} +
+ + +
+ }
-
+
@if (taskDefinition.hasTaskResources) { -
- - -
-} +
+ + +
+ }
diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-resources/task-definition-resources.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-resources/task-definition-resources.component.ts index aa62abe87f..a3894a0ef1 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-resources/task-definition-resources.component.ts +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-resources/task-definition-resources.component.ts @@ -1,4 +1,4 @@ -import {Component, Inject, Input} from '@angular/core'; +import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; import {TaskDefinition} from 'src/app/api/models/task-definition'; import {Unit} from 'src/app/api/models/unit'; import {TaskDefinitionService} from 'src/app/api/services/task-definition.service'; @@ -9,6 +9,8 @@ import {AlertService} from 'src/app/common/services/alert.service'; selector: 'f-task-definition-resources', templateUrl: 'task-definition-resources.component.html', styleUrls: ['task-definition-resources.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, }) export class TaskDefinitionResourcesComponent { @Input() taskDefinition: TaskDefinition; @@ -51,7 +53,7 @@ export class TaskDefinitionResourcesComponent { }); } - public uploadTaskSheet(files: FileList) { + public uploadTaskSheet(files: ArrayLike) { const validFiles = Array.from(files as ArrayLike).filter( (f) => f.type === 'application/pdf', ); @@ -69,9 +71,9 @@ export class TaskDefinitionResourcesComponent { } } - public uploadTaskResources(files: FileList) { + public uploadTaskResources(files: ArrayLike) { const validFiles = Array.from(files as ArrayLike).filter( - (f) => f.type === 'application/zip', + (f) => f.type === 'application/zip' || f.type === 'application/x-zip-compressed', ); if (validFiles.length > 0) { const file = validFiles[0]; @@ -83,7 +85,7 @@ export class TaskDefinitionResourcesComponent { error: (message) => this.alerts.error(message, 6000), }); } else { - this.alerts.error('Please drop a PDF to upload for this task', 6000); + this.alerts.error('Please drop a Zip to upload for this task', 6000); } } } diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.html new file mode 100644 index 0000000000..fed230e694 --- /dev/null +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.html @@ -0,0 +1,64 @@ +
+ + Enable test for task + + + @if (taskDefinition.scormEnabled) { + + @if (taskDefinition.hasScormData) { +
+ + + +
+ } + +
+
+ + Allow students to review completed test attempt + + + Allow file upload regardless of test pass status + +
+ + Limit to this number of attempts - 0 is unlimited + + +
+ +
+ + Enable incremental time delays between test attempts + + If enabled, first 2 attempts can be completed immediately. Subsequently, a time delay will + be added, increasing by 2 hours every attempt made. +
+ } +
diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.scss b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.ts new file mode 100644 index 0000000000..d0eae3dfdd --- /dev/null +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.ts @@ -0,0 +1,68 @@ +import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; +import {TaskDefinition} from 'src/app/api/models/task-definition'; +import {Unit} from 'src/app/api/models/unit'; +import {TaskDefinitionService} from 'src/app/api/services/task-definition.service'; +import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; +import {AlertService} from 'src/app/common/services/alert.service'; + +@Component({ + selector: 'f-task-definition-scorm', + templateUrl: 'task-definition-scorm.component.html', + styleUrls: ['task-definition-scorm.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class TaskDefinitionScormComponent { + @Input() taskDefinition: TaskDefinition; + + constructor( + private fileDownloaderService: FileDownloaderService, + private alerts: AlertService, + private taskDefinitionService: TaskDefinitionService, + ) {} + + public get unit(): Unit { + return this.taskDefinition?.unit; + } + + /** + * Open the SCORM test in a new tab - using preview mode. + */ + public previewScormTest() { + this.taskDefinition.previewScormTest(); + } + + public downloadScormData() { + this.fileDownloaderService.downloadFile( + this.taskDefinition.getScormDataUrl(true), + this.taskDefinition.name + '-SCORM.zip', + ); + } + + public removeScormData() { + this.taskDefinition.deleteScormData().subscribe({ + next: () => this.alerts.success('Deleted SCORM test data', 2000), + error: (message) => this.alerts.error(message, 6000), + }); + } + + public uploadScormData(files: ArrayLike) { + // console.log(Array.from(files).map((f) => f.type)); + const validMimeTypes = ['application/zip', 'application/x-zip-compressed', 'multipart/x-zip']; + const validFiles = Array.from(files as ArrayLike).filter((f) => + validMimeTypes.includes(f.type), + ); + if (validFiles.length > 0) { + const file = validFiles[0]; + this.taskDefinitionService.uploadScormData(this.taskDefinition, file).subscribe({ + next: () => { + this.alerts.success('Uploaded SCORM test data', 2000); + this.taskDefinition.hasScormData = true; + }, + error: (message) => this.alerts.error(message, 6000), + }); + } else { + this.alerts.error('Please drop a zip file to upload SCORM test data for this task', 6000); + } + } +} diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-upload/task-definition-upload.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-upload/task-definition-upload.component.html index fe96cfb2df..c0c8ff6d21 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-upload/task-definition-upload.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-upload/task-definition-upload.component.html @@ -1,57 +1,73 @@ - +
- - + - - + + + + + + - - + - - + - - + @@ -59,35 +75,72 @@ - - - - + + +
Filename + Filename - + Type + Type Code Document Image + ZIP Submission History + + Check Similarity - @if (upreq.type === 'document') { -TurnItIn -} + Check Similarity + @if (upreq.type === 'document' && tiiEnabled()) { + TurnItIn + } @if (upreq.type === 'code') { -Moss -} + Jplag + } Flag At - @if (upreq.type === 'document' && upreq.tiiCheck) { - - -  % - -} + Flag At + @if (upreq.type === 'document' && upreq.tiiCheck && tiiEnabled()) { + + +  % + + } - + + - +
-@if (taskDefinition.needsMoss) { -
- - Language used for Moss checks - - C - C# - C++ - Python - - - - Similarity percent to flag for Moss checks - - -
+@if (missingOverseerSubmissionHistory) { +
+ Overseer requires at least one upload requirement to be retained in submission history. +
+} + +@if (taskDefinition.needsJplag) { +
+ + Language used for JPLAG checks + + Java + C + C++ + C# + Python + JavaScript + TypeScript + Go + Kotlin + R + Rust + Swift + Scala + LLVM IR + Scheme + EMF Metamodel + EMF Model + SCXML + Text + Multi-language + + + + Similarity percent to flag for JPLAG checks + + +
+
+
+ + Use Task Resources for JPlag Base Code +

+ Base code is a common framework included in all submissions. When enabled, student code + matching this base code will be ignored during JPlag similarity checks. The base code will + be automatically extracted from the task resources. +

+
+
+
} diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-upload/task-definition-upload.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-upload/task-definition-upload.component.ts index a8915d843b..0cb7e0b0d7 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-upload/task-definition-upload.component.ts +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-upload/task-definition-upload.component.ts @@ -1,18 +1,30 @@ -import { Component, Input, OnChanges, SimpleChanges, ViewChild } from '@angular/core'; -import { MatTable, MatTableDataSource } from '@angular/material/table'; -import { TaskDefinition, UploadRequirement } from 'src/app/api/models/task-definition'; -import { Unit } from 'src/app/api/models/unit'; +import {ChangeDetectionStrategy, Component, Input, ViewChild} from '@angular/core'; +import {MatTable} from '@angular/material/table'; +import {TaskDefinition, UploadRequirement} from 'src/app/api/models/task-definition'; +import {Unit} from 'src/app/api/models/unit'; +import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; @Component({ selector: 'f-task-definition-upload', templateUrl: 'task-definition-upload.component.html', styleUrls: ['task-definition-upload.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, }) export class TaskDefinitionUploadComponent { @Input() public taskDefinition: TaskDefinition; - @ViewChild('upreqTable', { static: true }) table: MatTable; + @ViewChild('upreqTable', {static: true}) table: MatTable; - public columns: string[] = ['file-name', 'file-type', 'tii-check', 'flag-pct', 'row-actions']; + public columns: string[] = [ + 'file-name', + 'file-type', + 'submission-history', + 'tii-check', + 'flag-pct', + 'row-actions', + ]; + + constructor(private constants: DoubtfireConstants) {} public get unit(): Unit { return this.taskDefinition?.unit; @@ -26,13 +38,25 @@ export class TaskDefinitionUploadComponent { name: '', tiiCheck: false, tiiPct: 30, + submissionHistory: false, }); this.table.renderRows(); } + public tiiEnabled(): boolean { + return this.constants.IsTiiEnabled.value; + } + + public get missingOverseerSubmissionHistory(): boolean { + return ( + this.taskDefinition.assessmentEnabled && + !this.taskDefinition.uploadRequirements.some((requirement) => requirement.submissionHistory) + ); + } + public removeUpReq(upreq: UploadRequirement) { this.taskDefinition.uploadRequirements = this.taskDefinition.uploadRequirements.filter( - (anUpReq) => anUpReq.key != upreq.key + (anUpReq) => anUpReq.key != upreq.key, ); } } diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-who/task-definition-who.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-who/task-definition-who.component.html index 814e814197..e857fed00c 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-who/task-definition-who.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-who/task-definition-who.component.html @@ -1,33 +1,64 @@ @if (unit.hasGroupwork()) { - - Is this an individual or group task? Select group that submits - - @for (gs of unit.groupSets; track gs) { - {{ gs.name }} -} - None - Individual Submission - - + + Is this an individual or group task? Select group that submits + + @for (gs of unit.groupSets; track gs) { + {{ gs.name }} + } + None - Individual Submission + + } -
+
Tutorial Stream - Which tutor provides feedback? - + @for (stream of unit.tutorialStreams; track stream) { - {{ stream.name }} -} + {{ stream.name }} + } Related tutorials
    - @for (tutorial of taskDefinition.tutorialStream?.tutorialsIn(unit); track tutorial) { -
  • - {{ tutorial?.abbreviation }} {{ tutorial.tutor?.name }} -
  • -} + @for ( + tutorial of $safeNavigationMigration(taskDefinition.tutorialStream?.tutorialsIn(unit)) + | slice: 0 : (showAllTutorials ? undefined : 3); + track tutorial + ) { +
  • {{ tutorial?.abbreviation }} {{ tutorial.tutor?.name }}
  • + } + + @if (!showAllTutorials && taskDefinition.tutorialStream?.tutorialsIn(unit)?.length > 3) { +
  • + +{{ taskDefinition.tutorialStream.tutorialsIn(unit).length - 3 }} more tutorials +
  • + } + @if (taskDefinition.tutorialStream?.tutorialsIn(unit).length > 3) { +
    + @if (!showAllTutorials) { + + } @else { + + } +
    + }
+ +
+ Only allow tutors in this tutorial stream to assess this task +
diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-who/task-definition-who.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-who/task-definition-who.component.ts index 9732998592..7ef4f65c5a 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-who/task-definition-who.component.ts +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-who/task-definition-who.component.ts @@ -1,16 +1,23 @@ -import { Component, Input } from '@angular/core'; -import { TaskDefinition } from 'src/app/api/models/task-definition'; -import { Unit } from 'src/app/api/models/unit'; +import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; +import {TaskDefinition} from 'src/app/api/models/task-definition'; +import {Unit} from 'src/app/api/models/unit'; @Component({ selector: 'f-task-definition-who', templateUrl: 'task-definition-who.component.html', styleUrls: ['task-definition-who.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, }) export class TaskDefinitionWhoComponent { @Input() taskDefinition: TaskDefinition; + showAllTutorials: boolean = false; public get unit(): Unit { return this.taskDefinition?.unit; } + + onTutorialStreamChange() { + this.showAllTutorials = false; + } } diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.html index d346b1ce84..a239a50f97 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.html @@ -1,163 +1,265 @@ -
-
-
-
-

Task List

-

Plan the list of tasks for students to complete.

+
+
+
+

Task List

+

Plan the list of tasks for students to complete.

+
+
+
+ +
+ + +
- -
- - - +
+ +
+ + +
+
+
+ @if (unit.allowFlexibleDates) { + + } + +
+ + @if (unit.allowFlexibleDates) { + @if (manageDueDates) { +
+ +
+
+

Manage Target Dates

+

Modify or add target dates for each target grade

+
+
+ + + + + + + @for (grade of gradeColumns; track grade.value) { + + + + + } + + + +
Task + {{ td.abbreviation }} {{ td.name }} + +
+ +
+
+
+ + Start Date + + + + + + Target Date + + @if (isStartAfterTarget(td, grade)) { + + Target date must be after start date + + } + + + +
+
+
+ } + } - + - - - - - - - - - - - - - - - - - - - - - - + @if (!isTaskListCollapsed) { + +
+ +
+ } + + - - - - - - - -
Task Name - {{ taskDefinition.abbreviation }} {{ taskDefinition.name }} - Grade - {{ taskDefinition.targetGradeText }} - Start Date +
- {{ taskDefinition.startDate | date: 'd LLL y' }} -
Due Date - {{ taskDefinition.targetDate | date: 'd LLL y' }} - Deadline - {{ taskDefinition.dueDate | date: 'd LLL y' }} - - @if (taskDefinitionHasChanges(taskDefinition)) { +
- } - -
- - - - - - - - - - - - - - @if (selectedTaskDefinition) { - - } -
- +
+ @if (selectedTaskDefinition) { + + } @else { +
+

Select a task definition

+

Pick a task from the left list to edit its details.

+
+ } +
+
diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.scss b/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.scss index 5996af349a..da9f77f5b0 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.scss +++ b/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.scss @@ -1,6 +1,16 @@ -.mat-toolbar { - background-color: white; - // position: fixed; - // bottom: 0; - // width: 100%; +::ng-deep input.mat-mdc-input-element.fallback-date { + font-style: italic; + color: #bfbfbf !important; + -webkit-text-fill-color: #bfbfbf !important; /* fixes some WebKit cases */ +} + +::ng-deep .date-error .mdc-notched-outline__leading, +::ng-deep .date-error .mdc-notched-outline__notch, +::ng-deep .date-error .mdc-notched-outline__trailing { + border-color: #ef4444 !important; // tailwind red-500 +} + +::ng-deep .date-error .mat-mdc-input-element { + color: #ef4444 !important; + -webkit-text-fill-color: #ef4444 !important; } diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.spec.ts b/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.spec.ts new file mode 100644 index 0000000000..c3f7f27e02 --- /dev/null +++ b/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.spec.ts @@ -0,0 +1,111 @@ +import {describe, expect, it, vi} from 'vitest'; +import {UnitTaskEditorComponent} from './unit-task-editor.component'; + +// The component is constructed directly rather than through TestBed. The three +// handlers under test touch only the selected task definition, the filter and +// the confirmation modal, so a real component tree buys nothing here. +function editorWith(selected: unknown) { + const confirmationModal = {show: vi.fn()}; + const taskDefinitionService = {mapping: {}}; + + const component = new UnitTaskEditorComponent( + taskDefinitionService as never, + {query: () => ({subscribe: () => {}})} as never, // feedbackTemplateService + {error: () => {}} as never, // alerts + {} as never, // csvResultModalService + {} as never, // csvUploadModal + confirmationModal as never, + ); + + component.selectedTaskDefinition = selected as never; + return {component, confirmationModal}; +} + +// A saved task that has not been edited. hasChanges is what the component asks. +function savedTask(abbreviation: string) { + return { + id: 1, + abbreviation, + isNew: false, + hasOriginalSaveData: true, + hasChanges: () => false, + setOriginalSaveData: () => {}, + }; +} + +// A task built by Add Task and never saved. isNew is true and hasChanges is +// false, because nothing ever recorded original save data for it. +function unsavedTask() { + return { + id: undefined as number | undefined, + abbreviation: '1.1P', + isNew: true, + hasOriginalSaveData: false, + hasChanges: () => false, + setOriginalSaveData: () => {}, + }; +} + +describe('UnitTaskEditorComponent unsaved task guard', () => { + it('keeps an unsaved task on screen while the list is filtered', () => { + const {component} = editorWith(unsavedTask()); + const task = component.selectedTaskDefinition; + + component.taskDefinitionSource.filter = ''; + component.applyFilter('a'); + + expect(component.taskDefinitionSource.filter).toBe('a'); + expect(component.selectedTaskDefinition).toBe(task); + }); + + it('asks before another task replaces an unsaved one', () => { + const {component, confirmationModal} = editorWith(unsavedTask()); + const task = component.selectedTaskDefinition; + const other = savedTask('2.1C'); + + component.selectTaskDefinition(other as never); + + expect(confirmationModal.show).toHaveBeenCalledTimes(1); + expect(confirmationModal.show).toHaveBeenCalledWith( + 'Discard unsaved changes', + 'This task has unsaved changes. If you continue, they will be lost.', + expect.any(Function), + ); + expect(component.selectedTaskDefinition).toBe(task); + + // Running the callback is what the convenor choosing to discard looks like. + const proceed = confirmationModal.show.mock.calls[0][2] as () => void; + proceed(); + + expect(component.selectedTaskDefinition).toBe(other); + }); + + it('asks before a second Add Task discards the first one', () => { + const {component, confirmationModal} = editorWith(unsavedTask()); + + component.createTaskDefinition(); + + expect(confirmationModal.show).toHaveBeenCalledTimes(1); + }); + + // The guard must not get in the way of ordinary use. + it('does not ask when nothing is selected', () => { + const {component, confirmationModal} = editorWith(null); + const other = savedTask('2.1C'); + + component.selectTaskDefinition(other as never); + + expect(confirmationModal.show).not.toHaveBeenCalled(); + expect(component.selectedTaskDefinition).toBe(other); + }); + + it('does not ask when the selected task is saved and untouched', () => { + const {component, confirmationModal} = editorWith(savedTask('1.1P')); + const other = savedTask('2.1C'); + + component.selectTaskDefinition(other as never); + + expect(confirmationModal.show).not.toHaveBeenCalled(); + expect(component.selectedTaskDefinition).toBe(other); + }); +}); diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.ts index ee0f135c26..fad9c8e198 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.ts +++ b/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.ts @@ -1,54 +1,123 @@ -import { AfterViewInit, Component, Inject, Input, ViewChild } from '@angular/core'; -import { MatPaginator } from '@angular/material/paginator'; -import { MatSort, Sort } from '@angular/material/sort'; -import { MatTable, MatTableDataSource } from '@angular/material/table'; -import { Subscription } from 'rxjs'; -import { confirmationModal, csvResultModalService, csvUploadModalService } from 'src/app/ajs-upgraded-providers'; -import { TaskDefinition } from 'src/app/api/models/task-definition'; -import { Unit } from 'src/app/api/models/unit'; -import { TaskDefinitionService } from 'src/app/api/services/task-definition.service'; -import { AlertService } from 'src/app/common/services/alert.service'; +import {addWeeks} from 'date-fns'; +import {ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit} from '@angular/core'; +import {MatTableDataSource} from '@angular/material/table'; +import {Subscription} from 'rxjs'; +import {TaskDefinition} from 'src/app/api/models/task-definition'; +import {GradeDefinition, Unit} from 'src/app/api/models/unit'; +import {FeedbackTemplateService} from 'src/app/api/services/feedback-template.service'; +import {TaskDefinitionService} from 'src/app/api/services/task-definition.service'; +import {ConfirmationModalService} from 'src/app/common/modals/confirmation-modal/confirmation-modal.service'; +import { + CsvResult, + CsvResultModalService, +} from 'src/app/common/modals/csv-result-modal/csv-result-modal.service'; +import {CsvUploadModalService} from 'src/app/common/modals/csv-upload-modal/csv-upload-modal.service'; +import {AlertService} from 'src/app/common/services/alert.service'; @Component({ selector: 'f-unit-task-editor', templateUrl: 'unit-task-editor.component.html', styleUrls: ['unit-task-editor.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, }) -export class UnitTaskEditorComponent implements AfterViewInit { - @ViewChild(MatTable, { static: false }) table: MatTable; - @ViewChild(MatSort, { static: false }) sort: MatSort; - @ViewChild(MatPaginator, { static: false }) paginator: MatPaginator; - +export class UnitTaskEditorComponent implements OnInit, OnDestroy { @Input() unit: Unit; - public taskDefinitionSource: MatTableDataSource; - public columns: string[] = ['name', 'grade', 'startDate', 'targetDate', 'deadlineDate', 'taskDefAction']; - public filter: string; + public taskDefinitionSource: MatTableDataSource = new MatTableDataSource([]); + public filter: string = ''; public selectedTaskDefinition: TaskDefinition; + public isTaskListCollapsed: boolean = false; + + public dueDateSource: MatTableDataSource = new MatTableDataSource([]); + + public manageDueDates: boolean = false; + + protected get gradeNames(): Record { + return Object.fromEntries( + this.unit.gradeDefinitions.map((definition) => [definition.value, definition.label]), + ); + } + + public get gradeColumns(): GradeDefinition[] { + return this.unit.gradeDefinitions.filter((definition) => definition.value >= 0); + } + + public get dueDateColumns(): string[] { + return [ + 'taskDefinition', + ...this.gradeColumns.map((definition) => this.gradeColumnId(definition)), + ]; + } + + public gradeColumnId(grade: GradeDefinition): string { + return `grade-${grade.value}`; + } + + isStartAfterTarget(td: TaskDefinition, grade: GradeDefinition): boolean { + const start = this.getGradeStartDate(td, grade); + const target = this.getGradeDueDate(td, grade); + if (!start || !target) { + return false; + } + return new Date(start).getTime() > new Date(target).getTime(); + } + + getGradeStartDate(td: TaskDefinition, grade: GradeDefinition): Date | null { + return grade.value === 0 ? td.startDate : (td.gradeStartDate(grade.value) ?? td.startDate); + } + + isFallbackStartDate(td: TaskDefinition, grade: GradeDefinition): boolean { + return grade.value !== 0 && !td.gradeStartDate(grade.value); + } + + setGradeStartDate(td: TaskDefinition, grade: GradeDefinition, value: Date | null): void { + td.setGradeStartDate(grade.value, value); + this.saveTaskDefinition(td); + } + + getGradeDueDate(td: TaskDefinition, grade: GradeDefinition): Date | null { + return grade.value === 0 ? td.targetDate : (td.gradeTargetDate(grade.value) ?? td.targetDate); + } + + isFallbackTargetDate(td: TaskDefinition, grade: GradeDefinition): boolean { + return grade.value !== 0 && !td.gradeTargetDate(grade.value); + } + + setGradeDueDate(td: TaskDefinition, grade: GradeDefinition, value: Date | null): void { + td.setGradeTargetDate(grade.value, value); + this.saveTaskDefinition(td); + } constructor( private taskDefinitionService: TaskDefinitionService, + private feedbackTemplateService: FeedbackTemplateService, private alerts: AlertService, - @Inject(csvResultModalService) private csvResultModalService: any, - @Inject(csvUploadModalService) private csvUploadModal: any, - @Inject(confirmationModal) private confirmationModal: any - ) {} + private csvResultModalService: CsvResultModalService, + private csvUploadModal: CsvUploadModalService, + private confirmationModal: ConfirmationModalService, + ) { + this.taskDefinitionSource.filterPredicate = (data: TaskDefinition, filter: string) => + data.matches(filter); + } - ngAfterViewInit(): void { + ngOnInit(): void { this.subscriptions.push( this.unit.taskDefinitionCache.values.subscribe((taskDefinitions) => { - this.taskDefinitionSource = new MatTableDataSource(taskDefinitions); - this.taskDefinitionSource.paginator = this.paginator; - this.taskDefinitionSource.sort = this.sort; - this.taskDefinitionSource.filterPredicate = (data: any, filter: string) => data.matches(filter); - }) + this.taskDefinitionSource.data = taskDefinitions; + }), ); } public saveTaskDefinition(taskDefinition: TaskDefinition) { - taskDefinition.save().subscribe(() => { - this.alerts.success('Task Saved'); - taskDefinition.setOriginalSaveData(this.taskDefinitionService.mapping); + taskDefinition.save().subscribe({ + next: () => { + this.alerts.success('Task Saved'); + taskDefinition.setOriginalSaveData(this.taskDefinitionService.mapping); + }, + error: (error) => { + this.alerts.error(`Failed to update task: ${error}`, 6000); + }, }); } @@ -58,57 +127,81 @@ export class UnitTaskEditorComponent implements AfterViewInit { } public selectTaskDefinition(taskDefinition: TaskDefinition) { + // Clicking the task that is already open is not a discard, so it must not + // ask. Keep this ahead of the guard. if (this.selectedTaskDefinition === taskDefinition) { - this.selectedTaskDefinition = null; - } else { - this.selectedTaskDefinition = taskDefinition; + return; + } - // Record original save data if none present - if (!this.selectedTaskDefinition.hasOriginalSaveData) { - this.selectedTaskDefinition.setOriginalSaveData(this.taskDefinitionService.mapping); - } + this.confirmDiscardingUnsavedTask(() => this.applySelectedTaskDefinition(taskDefinition)); + } + + private applySelectedTaskDefinition(taskDefinition: TaskDefinition) { + this.selectedTaskDefinition = taskDefinition; + + // Record original save data if none present + if (!this.selectedTaskDefinition.hasOriginalSaveData) { + this.selectedTaskDefinition.setOriginalSaveData(this.taskDefinitionService.mapping); } + + this.feedbackTemplateService + .query({contextType: 'task_definitions', contextId: this.selectedTaskDefinition.id}, {}) + .subscribe({ + error: () => this.alerts.error('Error loading task feedback templates.'), + }); } - public sortData(sort: Sort) { - const data = this.taskDefinitionSource.data; + // A task being edited is unsaved if it has never been saved at all, or if it + // has been changed since it was loaded. + // + // isNew has to be tested and it has to come first. TaskDefinition.hasChanges + // returns false when there is no originalSaveData, and only selectTaskDefinition + // and a successful save ever set that, so a brand new task with every field + // filled in reports no changes whatsoever. A guard built on hasChanges alone + // protects the case that was already safe and leaves this one exactly as it was. + private hasUnsavedTaskDefinition(): boolean { + const selected = this.selectedTaskDefinition; + return !!selected && (selected.isNew || this.taskDefinitionHasChanges(selected)); + } - if (!sort.active || sort.direction === '') { - this.taskDefinitionSource.data = data; + private confirmDiscardingUnsavedTask(proceed: () => void) { + if (!this.hasUnsavedTaskDefinition()) { + proceed(); return; } - this.taskDefinitionSource.data = data.sort((a, b) => { - const isAsc = sort.direction === 'asc'; - switch (sort.active) { - case 'name': - return this.compare(a.abbreviation, b.abbreviation, isAsc); - case 'grade': - return this.compare(a.targetGrade, b.targetGrade, isAsc); - case 'startDate': - return this.compare(a.startDate.getTime(), b.startDate.getTime(), isAsc); - case 'targetDate': - return this.compare(a.targetDate.getTime(), b.targetDate.getTime(), isAsc); - case 'deadlineDate': - return this.compare(a.dueDate.getTime(), b.dueDate.getTime(), isAsc); - default: - return 0; - } - }); + this.confirmationModal.show( + 'Discard unsaved changes', + 'This task has unsaved changes. If you continue, they will be lost.', + proceed, + ); + } + + public isSelectedTaskDefinition(taskDefinition: TaskDefinition): boolean { + return this.selectedTaskDefinition === taskDefinition; } - public compare(a: number | string, b: number | string, isAsc: boolean): number { - return (a < b ? -1 : 1) * (isAsc ? 1 : -1); + public toggleTaskListCollapsed(): void { + this.isTaskListCollapsed = !this.isTaskListCollapsed; } applyFilter(filterValue: string) { - this.taskDefinitionSource.filter = filterValue.trim().toLowerCase(); + if (!this.taskDefinitionSource) { + return; + } - this.selectedTaskDefinition = null; + this.taskDefinitionSource.filter = filterValue.trim().toLowerCase(); - if (this.taskDefinitionSource.paginator) { - this.taskDefinitionSource.paginator.firstPage(); + // No dialog here on purpose. The box is [(ngModel)] bound and this runs from + // ngModelChange, so the character is already typed by the time we see it, and + // cancelling would mean writing the text back one keystroke at a time while + // the convenor answers a modal per letter. Filtering the list underneath an + // open editor is the behaviour that was wanted anyway. + if (this.hasUnsavedTaskDefinition()) { + return; } + + this.selectedTaskDefinition = null; } private guessTaskAbbreviation() { @@ -137,45 +230,47 @@ export class UnitTaskEditorComponent implements AfterViewInit { () => { this.unit.deleteTaskDefinition(taskDefinition); //TODO: reinstate ProgressModal.show "Deleting Task #{task.abbreviation}", 'Please wait while student projects are updated.', promise - - this.alerts.success('Task deleted'); - } + }, ); } public uploadTaskDefinitionsCsv() { this.csvUploadModal.show( 'Upload Task Definitions as CSV', - 'Test message', - { file: { name: 'Task Definition CSV Data', type: 'csv' } }, + 'Upload a CSV of task definitions.', + {file: {name: 'Task Definition CSV Data', type: 'csv'}}, this.unit.getTaskDefinitionBatchUploadUrl(), - (response: any) => { + (response: CsvResult) => { // at least one student? this.csvResultModalService.show('Task Definition Import Results', response); if (response.success.length > 0) { this.unit.refresh(); } - } + }, ); } public uploadTaskResourcesZip() { this.csvUploadModal.show( 'Upload Task Sheets and Resources as Zip', - 'Test message', - { file: { name: 'Task Sheets and Resources', type: 'zip' } }, + 'Upload a ZIP of task sheets and resources.', + {file: {name: 'Task Sheets and Resources', type: 'zip'}}, this.unit.taskUploadUrl, - (response: any) => { + (response: CsvResult) => { // at least one student? this.csvResultModalService.show('Task Sheet and Resources Import Results', response); if (response.success.length > 0) { this.unit.refresh(); } - } + }, ); } public createTaskDefinition() { + this.confirmDiscardingUnsavedTask(() => this.buildNewTaskDefinition()); + } + + private buildNewTaskDefinition() { const abbr = this.guessTaskAbbreviation(); const task = new TaskDefinition(this.unit); @@ -183,7 +278,7 @@ export class UnitTaskEditorComponent implements AfterViewInit { task.abbreviation = abbr; task.description = 'New Description'; task.startDate = new Date(); - task.targetDate = new Date(); + task.targetDate = addWeeks(new Date(), 2); task.uploadRequirements = []; task.weighting = 4; task.targetGrade = 0; diff --git a/src/app/units/states/edit/directives/unit-tutorials-list/unit-tutorials-list.component.html b/src/app/units/states/edit/directives/unit-tutorials-list/unit-tutorials-list.component.html index 7f24e23587..965230a659 100644 --- a/src/app/units/states/edit/directives/unit-tutorials-list/unit-tutorials-list.component.html +++ b/src/app/units/states/edit/directives/unit-tutorials-list/unit-tutorials-list.component.html @@ -1,260 +1,278 @@
-
-
-

{{ stream.name }} - {{ stream.abbreviation }}

- -
- - - - Name - - - - - - Abbreviation - - - - - - - -
+ @if (stream) { +
+ @if (!editingStream) { +
+

{{ stream.name }} - {{ stream.abbreviation }}

+ +
+ } @else { + + Name + + + - + + Abbreviation + + - + + + + } +
+ } @else {

Tutorials without a stream

- + }
- - - + - - - - + - - - - + - - - - + - - - - + - - - - + - - - - + - - - + - - - - + + +
Abbreviation -
- {{ tutorial.abbreviation }} -
- + +
Abbreviation + @if (!editing(tutorial)) { +
+ {{ tutorial.abbreviation }} +
+ } @else { - + - + }
+ - + Campus -
- {{ tutorial.campus ? tutorial.campus.name : '' }} -
- + +
Campus + @if (!editing(tutorial)) { +
+ {{ tutorial.campus ? tutorial.campus.name : '' }} +
+ } @else { Campus - + Not Specified - - {{ campus.name }} - + @for (campus of campuses; track campus) { + + {{ campus.name }} + + } - + }
+ Campus Not Specified - - {{ campus.name }} - + @for (campus of campuses; track campus) { + + {{ campus.name }} + + } Location -
- {{ tutorial.meetingLocation }} -
- + +
Location + @if (!editing(tutorial)) { +
+ {{ tutorial.meetingLocation }} +
+ } @else { - + - + }
+ - + Day -
- {{ tutorial.meetingDay }} -
- + +
Day + @if (!editing(tutorial)) { +
+ {{ tutorial.meetingDay }} +
+ } @else { Day - - - {{ day }} - + + @for (day of days; track day) { + + {{ day }} + + } - + }
+ Day - - - {{ day }} - + + @for (day of days; track day) { + + {{ day }} + + } Time -
- {{ tutorial.meetingTime }} -
- + +
Time + @if (!editing(tutorial)) { +
+ {{ tutorial.meetingTime }} +
+ } @else { - + }
+ - + Tutor -
- {{ tutorial.tutor?.name }} -
- + +
Tutor + @if (!editing(tutorial)) { +
+ {{ tutorial.tutor?.name }} +
+ } @else { Tutor - - - {{ tutor.name }} - + + @for (tutor of unit.staffUsers; track tutor) { + + {{ tutor.name }} + + } - + }
+ Tutor - - - {{ tutor.name }} - + + @for (tutor of unit.staffUsers; track tutor) { + + {{ tutor.name }} + + } Capacity -
- {{ tutorial.capacity }} -
- + +
Capacity + @if (!editing(tutorial)) { +
{{ tutorial.numStudents }} / {{ tutorial.capacity }}
+ } @else { - + - + }
+ - + -
- -
- -
-
+ @if (!editing(tutorial)) { +
+ +
+ } @else { +
+ - -
- + }
+
-
diff --git a/src/app/units/states/edit/directives/unit-tutorials-list/unit-tutorials-list.component.ts b/src/app/units/states/edit/directives/unit-tutorials-list/unit-tutorials-list.component.ts index a130e080e9..9619531dfe 100644 --- a/src/app/units/states/edit/directives/unit-tutorials-list/unit-tutorials-list.component.ts +++ b/src/app/units/states/edit/directives/unit-tutorials-list/unit-tutorials-list.component.ts @@ -1,40 +1,74 @@ -import { Component, Input, Inject, ViewChild, AfterViewInit } from '@angular/core'; -import { confirmationModal } from 'src/app/ajs-upgraded-providers'; -import { MatSort, Sort } from '@angular/material/sort'; -import { MatTableDataSource, MatTable } from '@angular/material/table'; +import {RequestOptions} from 'ngx-entity-service'; +import {HttpErrorResponse} from '@angular/common/http'; +import { + ChangeDetectionStrategy, + Component, + Input, + OnDestroy, + OnInit, + ViewChild, +} from '@angular/core'; +import {UntypedFormControl, Validators} from '@angular/forms'; +import {MatSort, Sort} from '@angular/material/sort'; +import {MatTable, MatTableDataSource} from '@angular/material/table'; +import {Subscription} from 'rxjs'; import { - Tutorial, - TutorialService, Campus, CampusService, - User, + Tutorial, + TutorialService, TutorialStream, TutorialStreamService, Unit, + User, } from 'src/app/api/models/doubtfire-model'; -import { EntityFormComponent } from 'src/app/common/entity-form/entity-form.component'; -import { UntypedFormControl, Validators } from '@angular/forms'; -import { RequestOptions } from 'ngx-entity-service'; -import { AlertService } from 'src/app/common/services/alert.service'; +import {EntityFormComponent} from 'src/app/common/entity-form/entity-form.component'; +import {ConfirmationModalService} from 'src/app/common/modals/confirmation-modal/confirmation-modal.service'; +import {AlertService} from 'src/app/common/services/alert.service'; @Component({ selector: 'df-unit-tutorials-list', templateUrl: 'unit-tutorials-list.component.html', styleUrls: ['unit-tutorials-list.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, }) -export class UnitTutorialsListComponent extends EntityFormComponent implements AfterViewInit { - @ViewChild(MatTable, { static: true }) table: MatTable; - @ViewChild(MatSort, { static: true }) sort: MatSort; +export class UnitTutorialsListComponent + extends EntityFormComponent + implements OnInit, OnDestroy +{ + @ViewChild(MatTable, {static: true}) table: MatTable; + @ViewChild(MatSort, {static: true}) sort: MatSort; @Input() stream: TutorialStream; @Input() unit: Unit; - days: string[] = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday', 'Asynchronous']; + days: string[] = [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + 'Asynchronous', + ]; campuses: Campus[] = new Array(); - columns: string[] = ['abbreviation', 'campus', 'location', 'day', 'time', 'tutor', 'capacity', 'options']; - tutorials: Tutorial[]; + columns: string[] = [ + 'abbreviation', + 'campus', + 'location', + 'day', + 'time', + 'tutor', + 'capacity', + 'options', + ]; + tutorials: Tutorial[] = []; + dataSource: MatTableDataSource = new MatTableDataSource(); - private editingStream: boolean = false; + public editingStream: boolean = false; + private subscriptions: Subscription[] = []; /** * The original stream abbreviation is required to update the stream - as it may change but is used in the url. @@ -46,7 +80,7 @@ export class UnitTutorialsListComponent extends EntityFormComponent im private tutorialService: TutorialService, private tutorialStreamService: TutorialStreamService, private campusService: CampusService, - @Inject(confirmationModal) private confirmationModal: any, + private confirmationModal: ConfirmationModalService, private alerts: AlertService, ) { super( @@ -63,7 +97,7 @@ export class UnitTutorialsListComponent extends EntityFormComponent im ); } - ngAfterViewInit(): void { + ngOnInit(): void { if (this.stream) { this.origStreamAbbr = this.stream.abbreviation; this.origName = this.stream.name; @@ -73,22 +107,28 @@ export class UnitTutorialsListComponent extends EntityFormComponent im this.campuses.push(...campuses); }); - this.dataSource = new MatTableDataSource(); this.filterTutorials(); - this.unit.tutorialsCache.values.subscribe((_t) => this.filterTutorials()); + this.subscriptions.push( + this.unit.tutorialsCache.values.subscribe((_t) => this.filterTutorials()), + ); + } + + ngOnDestroy(): void { + this.subscriptions.forEach((subscription) => subscription.unsubscribe()); } private filterTutorials(): void { this.tutorials = this.unit.tutorials.filter( - (tutorial) => tutorial.tutorialStream === this.stream || (!tutorial.tutorialStream && !this.stream), + (tutorial) => + tutorial.tutorialStream === this.stream || (!tutorial.tutorialStream && !this.stream), ); this.dataSource.data = this.tutorials; } public saveStream(): void { this.tutorialStreamService - .update({ abbreviation: this.origStreamAbbr, unit_id: this.unit.id }, { entity: this.stream }) + .update({abbreviation: this.origStreamAbbr, unit_id: this.unit.id}, {entity: this.stream}) .subscribe({ next: (stream: TutorialStream) => { this.stream = stream; @@ -97,14 +137,13 @@ export class UnitTutorialsListComponent extends EntityFormComponent im this.editingStream = false; this.alerts.success('Stream updated successfully', 2000); }, - error: (error: any) => { + error: (error: HttpErrorResponse) => { this.alerts.error('Something went wrong - ' + JSON.stringify(error.error), 6000); }, }); } public setEditStream(value: boolean): void { - console.log('set edit stream', value) if (!value) { this.stream.abbreviation = this.origStreamAbbr; this.stream.name = this.origName; @@ -123,8 +162,14 @@ export class UnitTutorialsListComponent extends EntityFormComponent im // Push the values that will be displayed in the table // to the datasource private pushToTable(value: Tutorial | Tutorial[]) { - if (!value) return; - value instanceof Array ? this.tutorials.push(...value) : this.tutorials.push(value); + if (!value) { + return; + } + if (value instanceof Array) { + this.tutorials.push(...value); + } else { + this.tutorials.push(value); + } this.renderTable(); } @@ -161,14 +206,14 @@ export class UnitTutorialsListComponent extends EntityFormComponent im // tutorial. The function is bound to the compareFn attribute on the related // mat-selects. // See: https://angular.io/api/forms/SelectControlValueAccessor - compareSelection(aEntity: User | Campus | any, bEntity: User | Campus) { + compareSelection(aEntity: User | Campus | {user_id: number}, bEntity: User | Campus) { if (!aEntity || !bEntity) { return; } if (bEntity instanceof User) { - return aEntity.user_id === bEntity.id; + return 'user_id' in aEntity && aEntity.user_id === bEntity.id; } else { - return aEntity.id === bEntity.id; + return 'id' in aEntity && aEntity.id === bEntity.id; } } @@ -198,7 +243,9 @@ export class UnitTutorialsListComponent extends EntityFormComponent im /** * Ensure that the unit is passed to the Tutorial entity when create it called. */ - protected override optionsOnRequest(kind: 'create' | 'update' | 'delete'): RequestOptions { + protected override optionsOnRequest( + _kind: 'create' | 'update' | 'delete', + ): RequestOptions { return { constructorParams: this.unit, cache: this.unit.tutorialsCache, @@ -223,9 +270,13 @@ export class UnitTutorialsListComponent extends EntityFormComponent im const isAsc = sort.direction === 'asc'; switch (sort.active) { case 'campus': - return this.sortCompare(a.campus ? a.campus.abbreviation : '', b.campus ? b.campus.abbreviation : '', isAsc); + return this.sortCompare( + a.campus ? a.campus.abbreviation : '', + b.campus ? b.campus.abbreviation : '', + isAsc, + ); case 'tutor': - return this.sortCompare(a.tutor.name, b.tutor.name, isAsc); + return this.sortCompare(a.tutor?.name ?? '', b.tutor?.name ?? '', isAsc); default: return 0; } diff --git a/src/app/units/states/edit/directives/unit-tutorials-manager/unit-tutorials-manager.component.html b/src/app/units/states/edit/directives/unit-tutorials-manager/unit-tutorials-manager.component.html index 6159d62f94..b0ccf6bd49 100644 --- a/src/app/units/states/edit/directives/unit-tutorials-manager/unit-tutorials-manager.component.html +++ b/src/app/units/states/edit/directives/unit-tutorials-manager/unit-tutorials-manager.component.html @@ -1,21 +1,30 @@
- + @for (stream of unit.tutorialStreams; track stream) { - -} - + + } @empty { +
+
+ groups +

No Tutorials

+

+ There are no tutorials yet. Create a tutorial stream to begin adding tutorials. +

+
+
+ } +
+ +
+ @for (activityType of activityTypes; track activityType) { - -} + + }
diff --git a/src/app/units/states/edit/directives/unit-tutorials-manager/unit-tutorials-manager.component.scss b/src/app/units/states/edit/directives/unit-tutorials-manager/unit-tutorials-manager.component.scss index 95fde129f0..7f40e10c71 100644 --- a/src/app/units/states/edit/directives/unit-tutorials-manager/unit-tutorials-manager.component.scss +++ b/src/app/units/states/edit/directives/unit-tutorials-manager/unit-tutorials-manager.component.scss @@ -1,5 +1,5 @@ -.new-activity-stream-button { - display: flex; - margin: 0 auto; - padding-left: 4px; +.no-tutorials .mat-icon { + font-size: 75px; + width: 75px; + height: 75px; } diff --git a/src/app/units/states/edit/directives/unit-tutorials-manager/unit-tutorials-manager.component.ts b/src/app/units/states/edit/directives/unit-tutorials-manager/unit-tutorials-manager.component.ts index 9c558e2e7c..2a71c1c6c7 100644 --- a/src/app/units/states/edit/directives/unit-tutorials-manager/unit-tutorials-manager.component.ts +++ b/src/app/units/states/edit/directives/unit-tutorials-manager/unit-tutorials-manager.component.ts @@ -1,4 +1,4 @@ -import {Component, Inject, Input} from '@angular/core'; +import {ChangeDetectionStrategy, Component, Input, OnInit} from '@angular/core'; import { ActivityType, ActivityTypeService, @@ -11,13 +11,13 @@ import {AlertService} from 'src/app/common/services/alert.service'; selector: 'unit-tutorials-manager', templateUrl: 'unit-tutorials-manager.component.html', styleUrls: ['unit-tutorials-manager.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, }) -export class UnitTutorialsManagerComponent { +export class UnitTutorialsManagerComponent implements OnInit { @Input() unit: Unit; activityTypes: ActivityType[] = new Array(); - tutorialsByStream: any[] = new Array(); - constructor( private activityTypeService: ActivityTypeService, private alertService: AlertService, diff --git a/src/app/units/states/edit/edit.coffee b/src/app/units/states/edit/edit.coffee deleted file mode 100644 index 7951553c54..0000000000 --- a/src/app/units/states/edit/edit.coffee +++ /dev/null @@ -1,72 +0,0 @@ -angular.module('doubtfire.units.states.edit', [ - 'doubtfire.units.states.edit.directives' -]) - -# -# State for convenors to edit a particular unit -# -.config(($stateProvider) -> - $stateProvider.state 'units/admin', { - parent: 'units/index' - url: '/admin' - controller: 'EditUnitStateCtrl' - templateUrl: 'units/states/edit/edit.tpl.html' - data: - task: 'Unit Administration' - pageTitle: "_Unit Administration_" - roleWhitelist: ['Convenor', 'Admin', 'Auditor'] - } -) -.controller('EditUnitStateCtrl', ($scope, $state, $stateParams, alertService, analyticsService, newUnitService, newUserService, GlobalStateService) -> - GlobalStateService.onLoad () -> - $scope.currentStaff = $scope.unit.staff - - $scope.assessingUnitRole = GlobalStateService.loadedUnitRoles.currentValues.find((role) -> role.unit == $scope.unit) - - newUserService.getTutors().subscribe( (tutors) -> $scope.staff = tutors ) - - # - # Active tab group - # - $scope.tabs = - unitTab: - title: "Unit Details" - seq: 0 - learningOutcomesTab: - title: "Learning Outcomes" - seq: 1 - staffTab: - title: "Staff" - seq: 2 - tutorialsTab: - title: "Tutorials" - seq: 3 - studentsTab: - title: "Students" - seq: 4 - tasksTab: - title: "Tasks" - seq: 5 - taskAlignmentTab: - title: "Task Alignment" - seq: 5 - groupsTab: - title: "Groups" - seq: 6 - - # Set the active tab - $scope.setActiveTab = (tab) -> - # Do nothing if we're switching to the same tab - return if tab is $scope.activeTab - - # Actions to perform when changing tab - $scope.activeTab?.active = false # Deactivate original tab - - # run de-select actions... - $scope.activeTab.deselect?() - - $scope.activeTab = tab # Switch tabs - $scope.activeTab.active = true # Make it active - - $scope.activeTab = $scope.tabs.unitTab -) diff --git a/src/app/units/states/edit/edit.tpl.html b/src/app/units/states/edit/edit.tpl.html deleted file mode 100644 index 3ba73129a5..0000000000 --- a/src/app/units/states/edit/edit.tpl.html +++ /dev/null @@ -1,15 +0,0 @@ -
- - - {{tab.title}} - - - - - - - - - - -
diff --git a/src/app/units/states/edit/unit-admin-state.component.html b/src/app/units/states/edit/unit-admin-state.component.html new file mode 100644 index 0000000000..eaedf22db4 --- /dev/null +++ b/src/app/units/states/edit/unit-admin-state.component.html @@ -0,0 +1,132 @@ +
+ + @for (tab of tabs; track tab.routeSegment) { + + } + + +
+ @if (loadingUnit) { + +
+
+ + +
+ +
+ + + + +
+
+
+ } @else { + @switch (currentTab.routeSegment) { + @case ('details') { + @if (unit) { + + + + } + } + @case ('learning-outcomes') { + @if (unit) { + +
+

Unit Learning Outcomes

+

Manage unit outcomes and related feedback templates.

+
+ +
+ } + } + @case ('staff') { + @if (unit) { + + + + } + } + @case ('tutorials') { + @if (unit) { + +
+

Tutorials

+

Manage tutorial streams and classes for this unit.

+
+ +
+ } + } + @case ('students') { + @if (unit) { + + + + } + } + @case ('tasks') { + @if (unit) { + + + + } + } + @case ('groups') { + @if (unit) { + +
+

Groups

+

Configure group sets and manage team-based activities.

+
+ +
+ } + } + @case ('communication') { + @if (unit) { + +
+

Communication System

+

+ Create communication sets and rules to target students and automate follow-up + actions. +

+
+ +
+ } + } + } + } +
+
diff --git a/src/app/units/states/edit/unit-admin-state.component.ts b/src/app/units/states/edit/unit-admin-state.component.ts new file mode 100644 index 0000000000..d36745df00 --- /dev/null +++ b/src/app/units/states/edit/unit-admin-state.component.ts @@ -0,0 +1,164 @@ +import {ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit} from '@angular/core'; +import {MatTabChangeEvent} from '@angular/material/tabs'; +import {ActivatedRoute, Router} from '@angular/router'; +import {Observable, Subscription, first, of} from 'rxjs'; +import {Unit, UnitRole, User, UserService} from 'src/app/api/models/doubtfire-model'; +import {GlobalStateService, ViewType} from 'src/app/projects/states/index/global-state.service'; + +type UnitAdminTabKey = + | 'details' + | 'learning-outcomes' + | 'staff' + | 'tutorials' + | 'students' + | 'tasks' + | 'groups' + | 'communication'; + +interface UnitAdminTab { + label: string; + routeSegment: UnitAdminTabKey; +} + +@Component({ + selector: 'f-unit-admin-state', + templateUrl: './unit-admin-state.component.html', + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class UnitAdminStateComponent implements OnInit, OnDestroy { + @Input() public unit$: Observable; + + public readonly tabs: UnitAdminTab[] = [ + {label: 'Unit Details', routeSegment: 'details'}, + {label: 'Learning Outcomes', routeSegment: 'learning-outcomes'}, + {label: 'Staff', routeSegment: 'staff'}, + {label: 'Tutorials', routeSegment: 'tutorials'}, + {label: 'Students', routeSegment: 'students'}, + {label: 'Tasks', routeSegment: 'tasks'}, + {label: 'Groups', routeSegment: 'groups'}, + {label: 'Communications', routeSegment: 'communication'}, + ]; + + public unit: Unit | null = null; + public staff: User[] = []; + public assessingUnitRole: UnitRole | null = null; + public currentTab: UnitAdminTab = this.tabs[0]; + public loadingUnit = true; + + private subscriptions: Subscription[] = []; + + constructor( + private route: ActivatedRoute, + private router: Router, + private userService: UserService, + private globalStateService: GlobalStateService, + ) {} + + public ngOnInit(): void { + this.updateCurrentTabFromState(this.route.snapshot.paramMap.get('tab')); + + this.unit$ = this.unit$ ?? of(this.route.parent.snapshot.data.unit); + if (this.unit$) { + this.subscriptions.push( + this.unit$.pipe(first()).subscribe((unit) => { + if (!unit) { + return; + } + + this.assessingUnitRole = this.findUnitRole(unit.id); + this.loadTutors(); + this.loadUnit(unit); + }), + ); + } + + this.subscriptions.push( + this.route.paramMap.subscribe((params) => this.updateCurrentTabFromState(params.get('tab'))), + ); + } + + public ngOnDestroy(): void { + this.subscriptions.forEach((subscription) => subscription.unsubscribe()); + } + + public get currentIndex(): number { + const index = this.tabs.findIndex((tab) => tab.routeSegment === this.currentTab.routeSegment); + return index >= 0 ? index : 0; + } + + public onTabChange(event: MatTabChangeEvent): void { + const nextTab = this.tabs[event.index] ?? this.tabs[0]; + this.currentTab = nextTab; + if (this.route.parent?.snapshot.data.unit) { + this.router.navigate( + [ + '/units', + this.route.parent.snapshot.paramMap.get('unitId'), + 'admin', + nextTab.routeSegment, + ], + {replaceUrl: true}, + ); + return; + } + } + + private updateCurrentTabFromState(tabParam?: string | null): void { + this.currentTab = + this.tabs.find((tab) => tab.routeSegment === tabParam) ?? + this.tabs.find((tab) => tab.routeSegment === 'details') ?? + this.tabs[0]; + } + + private findUnitRole(unitId: number): UnitRole | null { + const currentView = this.globalStateService.currentViewAndEntitySubject$.value; + + if (currentView?.viewType === ViewType.UNIT) { + const currentUnitRole = currentView.entity as UnitRole; + if (currentUnitRole?.unit?.id === unitId) { + return currentUnitRole; + } + } + + let unitRole = this.globalStateService.loadedUnitRoles.currentValues.find( + (role) => role.unit.id === unitId, + ); + + if ( + !unitRole && + (this.userService.currentUser.role === 'Admin' || + this.userService.currentUser.role === 'Auditor') + ) { + unitRole = this.userService.adminOrAuditorRoleFor( + this.userService.currentUser.role, + unitId, + this.userService.currentUser, + ); + } + + return unitRole ?? null; + } + + private loadTutors(): void { + this.subscriptions.push( + this.userService.getTutors().subscribe((tutors) => { + this.staff = tutors; + }), + ); + } + + private loadUnit(unit: Unit): void { + this.loadingUnit = false; + this.unit = unit; + + if (this.assessingUnitRole) { + this.assessingUnitRole.unit = unit; + } + + this.globalStateService.setView( + ViewType.UNIT, + this.assessingUnitRole ? this.assessingUnitRole : unit, + ); + } +} diff --git a/src/app/units/states/groups/groups.coffee b/src/app/units/states/groups/groups.coffee deleted file mode 100644 index 90e10afbad..0000000000 --- a/src/app/units/states/groups/groups.coffee +++ /dev/null @@ -1,20 +0,0 @@ -angular.module('doubtfire.units.states.groups', []) -# -# State for convenors/students assessing ]student groups -# -.config(($stateProvider) -> - $stateProvider.state 'units/students/groups', { - parent: 'units/index' - url: '/students/groups' - templateUrl: "units/states/groups/groups.tpl.html" - controller: "UnitGroupsStateCtrl" - data: - task: "Student Groups" - pageTitle: "_Home_" - roleWhitelist: ['Tutor', 'Convenor', 'Admin', 'Auditor'] - } -) -.controller("UnitGroupsStateCtrl", ($scope) -> - # No controller needed if there is no groupwork - return unless $scope.unit.hasGroupwork() -) diff --git a/src/app/units/states/groups/groups.tpl.html b/src/app/units/states/groups/groups.tpl.html deleted file mode 100644 index 319f46e08f..0000000000 --- a/src/app/units/states/groups/groups.tpl.html +++ /dev/null @@ -1,22 +0,0 @@ -
- - -
-
-
- -

No Groupwork Enabled

-
-
-

- There is no group work enabled for this unit. Convenors should create at - least one group set first for groups to be created. This can be found under - the groups tab in the unit administration settings. -

- Unit Administration -
-
diff --git a/src/app/units/states/groups/unit-groups/unit-groups.component.html b/src/app/units/states/groups/unit-groups/unit-groups.component.html new file mode 100644 index 0000000000..a3d3f64058 --- /dev/null +++ b/src/app/units/states/groups/unit-groups/unit-groups.component.html @@ -0,0 +1,21 @@ + + @if (unit.hasGroupwork()) { + + + } @else { +
+ groups + +

No Groupwork Enabled

+ +

+ There is no group work enabled for this unit. Convenors should create at least one group set + first for groups to be created. This can be found under the groups tab in the unit + administration settings. +

+ +
+ } +
diff --git a/src/app/units/states/groups/unit-groups/unit-groups.component.scss b/src/app/units/states/groups/unit-groups/unit-groups.component.scss new file mode 100644 index 0000000000..542e68a366 --- /dev/null +++ b/src/app/units/states/groups/unit-groups/unit-groups.component.scss @@ -0,0 +1,5 @@ +.mat-icon { + font-size: 75px; + width: 75px; + height: 75px; +} diff --git a/src/app/units/states/groups/unit-groups/unit-groups.component.ts b/src/app/units/states/groups/unit-groups/unit-groups.component.ts new file mode 100644 index 0000000000..08edc57065 --- /dev/null +++ b/src/app/units/states/groups/unit-groups/unit-groups.component.ts @@ -0,0 +1,79 @@ +import {ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit} from '@angular/core'; +import {ActivatedRoute} from '@angular/router'; +import {Observable, Subscription, distinctUntilChanged, of} from 'rxjs'; +import {GroupSet, Unit, UnitRole, UserService} from 'src/app/api/models/doubtfire-model'; +import {GlobalStateService, ViewType} from 'src/app/projects/states/index/global-state.service'; + +// This component is only displayed to staff +// Students will be shown the projects/states/groups (project-groups) component +@Component({ + selector: 'f-unit-groups', + templateUrl: './unit-groups.component.html', + styleUrl: './unit-groups.component.scss', + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class UnitGroupsComponent implements OnInit, OnDestroy { + @Input() unit$: Observable; + + @Input() unit: Unit; + @Input() unitRole: UnitRole; + @Input() selectedGroupSet: GroupSet; + + private unitSub?: Subscription; + + constructor( + private globalStateService: GlobalStateService, + private userService: UserService, + private route: ActivatedRoute, + ) {} + + ngOnInit(): void { + this.unit$ = this.unit$ ?? of(this.route.parent.snapshot.data.unit); + this.unitSub = this.unit$ + ?.pipe(distinctUntilChanged((a, b) => a?.id === b?.id)) + .subscribe((unit) => { + if (!unit) { + return; + } + + this.unit = unit; + this.unitRole = this.findUnitRole(unit.id); + this.selectedGroupSet = + this.selectedGroupSet?.unit?.id === unit.id ? this.selectedGroupSet : unit.groupSets?.[0]; + }); + } + + ngOnDestroy(): void { + this.unitSub?.unsubscribe(); + } + + private findUnitRole(unitId: number): UnitRole { + const currentView = this.globalStateService.currentViewAndEntitySubject$.value; + + if (currentView?.viewType === ViewType.UNIT) { + const currentUnitRole = currentView.entity as UnitRole; + if (currentUnitRole?.unit?.id === unitId) { + return currentUnitRole; + } + } + + let unitRole = this.globalStateService.loadedUnitRoles.currentValues.find( + (role) => role.unit.id === unitId, + ); + + if ( + !unitRole && + (this.userService.currentUser.role === 'Admin' || + this.userService.currentUser.role === 'Auditor') + ) { + unitRole = this.userService.adminOrAuditorRoleFor( + this.userService.currentUser.role, + unitId, + this.userService.currentUser, + ); + } + + return unitRole; + } +} diff --git a/src/app/units/states/index/index.coffee b/src/app/units/states/index/index.coffee deleted file mode 100644 index 7a7346a319..0000000000 --- a/src/app/units/states/index/index.coffee +++ /dev/null @@ -1,50 +0,0 @@ -angular.module('doubtfire.units.states.index', []) - -# -# Root state for units -# -.config(($stateProvider) -> - $stateProvider.state 'units/index', { - url: "/units/:unitId" - abstract: true - views: - main: - controller: "UnitsIndexStateCtrl" - templateUrl: "units/states/index/index.tpl.html" - data: - pageTitle: "_Home_" - roleWhitelist: ['Student', 'Tutor', 'Convenor', 'Admin', 'Auditor'] - } -) - -.controller("UnitsIndexStateCtrl", ($scope, $rootScope, $state, $stateParams, newUnitService, newProjectService, listenerService, GlobalStateService, newUserService, alertService) -> - # Error - required unitId is missing! - unitId = +$stateParams.unitId - return $state.go('home') unless unitId - - GlobalStateService.onLoad () -> - # Load assessing unit role - $scope.unitRole = GlobalStateService.loadedUnitRoles.currentValues.find((unitRole) -> unitRole.unit.id == unitId) - - if (! $scope.unitRole?) && ( newUserService.currentUser.role == "Admin" || newUserService.currentUser.role == "Auditor" ) - $scope.unitRole = newUserService.adminOrAuditorRoleFor(newUserService.currentUser.role, unitId, newUserService.currentUser) - - # Go home if no unit role was found - return $state.go('home') unless $scope.unitRole? - - GlobalStateService.setView("UNIT", $scope.unitRole) - - newUnitService.get(unitId).subscribe({ - next: (unit)-> - newProjectService.loadStudents(unit).subscribe({ - next: (students)-> - $scope.unit = unit - error: (err)-> - alertService.error( "Error loading students: " + err, 8000) - setTimeout((()-> $state.go('home')), 5000) - }) - error: (err)-> - alertService.error( "Error loading unit: " + err, 8000) - setTimeout((()-> $state.go('home')), 5000) - }) -) diff --git a/src/app/units/states/index/index.tpl.html b/src/app/units/states/index/index.tpl.html deleted file mode 100644 index 795574f613..0000000000 --- a/src/app/units/states/index/index.tpl.html +++ /dev/null @@ -1,7 +0,0 @@ -
- -

Loading unit details...

-
-
- -
diff --git a/src/app/units/states/portfolios/d2l-transfer-modal/d2l-transfer.component.html b/src/app/units/states/portfolios/d2l-transfer-modal/d2l-transfer.component.html new file mode 100644 index 0000000000..d5f194b3c2 --- /dev/null +++ b/src/app/units/states/portfolios/d2l-transfer-modal/d2l-transfer.component.html @@ -0,0 +1,83 @@ + + + + + + + + diff --git a/src/app/units/states/portfolios/d2l-transfer-modal/d2l-transfer.component.scss b/src/app/units/states/portfolios/d2l-transfer-modal/d2l-transfer.component.scss new file mode 100644 index 0000000000..0e78119808 --- /dev/null +++ b/src/app/units/states/portfolios/d2l-transfer-modal/d2l-transfer.component.scss @@ -0,0 +1,13 @@ +mat-form-field { + padding: 1rem; +} + +li { + width: 100%; + margin-top: 1rem; + margin-bottom: 1rem; +} + +li button { + margin-right: 1rem; +} diff --git a/src/app/units/states/portfolios/d2l-transfer-modal/d2l-transfer.component.ts b/src/app/units/states/portfolios/d2l-transfer-modal/d2l-transfer.component.ts new file mode 100644 index 0000000000..c965168641 --- /dev/null +++ b/src/app/units/states/portfolios/d2l-transfer-modal/d2l-transfer.component.ts @@ -0,0 +1,159 @@ +// +// Modal to show Doubtfire version info +// +import {HttpClient} from '@angular/common/http'; +import {ChangeDetectionStrategy, Component, Inject, Injectable, OnInit} from '@angular/core'; +import {MAT_DIALOG_DATA, MatDialog, MatDialogRef} from '@angular/material/dialog'; +import {D2lAssessmentMapping} from 'src/app/api/models/d2l/d2l_assessment_mapping'; +import {D2lAssessmentMappingService} from 'src/app/api/models/doubtfire-model'; +import {Unit} from 'src/app/api/models/unit'; +import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; + +@Component({ + selector: 'f-d2l-transfer', + templateUrl: 'd2l-transfer.component.html', + styleUrl: 'd2l-transfer.component.scss', + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class D2lTransferComponent implements OnInit { + public d2lDataMapping: D2lAssessmentMapping = new D2lAssessmentMapping(this.data); + private apiEndpoint: string; + private weightedUnit: boolean = false; + + constructor( + @Inject(MAT_DIALOG_DATA) public data: Unit, + @Inject(MatDialogRef) + public dialogRef: MatDialogRef, + private alertService: AlertService, + public d2lAssessmentMappingService: D2lAssessmentMappingService, + public httpClient: HttpClient, + public doubtfireConstants: DoubtfireConstants, + public fileDownloader: FileDownloaderService, + ) {} + + public ngOnInit(): void { + this.data.loadD2lMapping().subscribe({ + next: (d2lDataMapping) => { + this.d2lDataMapping = d2lDataMapping; + + // If we have the org unit it, then we can check the grading standard + if (this.d2lDataMapping.orgUnitId) { + this.checkUnitGradesWeighted(); + } + }, + error: (_err) => { + // No mapping found, create a new one + this.d2lDataMapping = new D2lAssessmentMapping(this.data); + }, + }); + + this.httpClient.get(`${this.doubtfireConstants.API_URL}/d2l/endpoint`).subscribe({ + next: (response) => { + this.apiEndpoint = response; + }, + error: (err) => { + this.alertService.error(`Failed to get locaiton of D2L instance: ${err}`); + }, + }); + } + + public checkUnitGradesWeighted(): void { + this.httpClient + .get(`${this.doubtfireConstants.API_URL}/units/${this.data.id}/d2l/grades/weighted`) + .subscribe({ + next: (response) => { + this.weightedUnit = response; + }, + error: (err) => { + this.alertService.error(`Failed to get unit weighted status: ${err}`); + }, + }); + } + + public openD2l(): void { + const url = `${this.doubtfireConstants.API_URL}/d2l/login_url`; + this.httpClient.post(url, {}).subscribe({ + next: (response) => { + window.open(response, '_blank'); + }, + error: (err) => { + this.alertService.error(`Failed to get D2L login URL: ${err}`); + }, + }); + } + + public openWeightPage(): void { + const url = `${this.apiEndpoint}/d2l/lms/grades/admin/manage/item_props_newedit.d2l?objectId=${this.d2lDataMapping.gradeObjectId}&ou=${this.d2lDataMapping.orgUnitId}&scroll=weight`; + console.log(url); + window.open(url, '_blank'); + } + + public hasD2lMapping(): boolean { + return this.d2lDataMapping.id !== undefined; + } + + public hasWeight(): boolean { + return this.weightedUnit; + } + + public startTransfer(): void { + const url = `${this.doubtfireConstants.API_URL}/units/${this.data.id}/d2l/grades`; + this.httpClient.post(url, {}).subscribe({ + next: () => { + this.alertService.success('Transfer started'); + }, + error: (err) => { + this.alertService.error(`Failed to start transfer: ${err}`); + }, + }); + } + + public downloadRecord(): void { + this.httpClient + .get( + `${this.doubtfireConstants.API_URL}/units/${this.data.id}/d2l/grades/available`, + ) + .subscribe({ + next: (response) => { + if (response.running) { + this.alertService.error('Transfer in progress, please wait'); + } else if (response.available) { + const url = `${this.doubtfireConstants.API_URL}/units/${this.data.id}/d2l/grades`; + this.fileDownloader.downloadFile(url, `${this.data.code}-d2l-grades.csv`); + } else { + this.alertService.error( + 'No grade transfer results are available, and grade transfer does not appear to be in progress', + ); + } + }, + error: (err) => { + this.alertService.error(`Failed to download record: ${err}`); + }, + }); + } +} + +/** + * The about doubtfire modal service - used to create and show the modal + */ +// eslint-disable-next-line max-classes-per-file +@Injectable() +export class D2lTransferModal { + constructor(public dialog: MatDialog) {} + + public open(unit: Unit): void { + // Show dialog while the data above is being fetched + this.dialog.open(D2lTransferComponent, { + width: '600px', + data: unit, + }); + } +} + +interface GradesAvailableResponse { + available: boolean; + running: boolean; +} diff --git a/src/app/units/states/portfolios/directives/portfolios-assessment/portfolios-assessment.component.html b/src/app/units/states/portfolios/directives/portfolios-assessment/portfolios-assessment.component.html new file mode 100644 index 0000000000..73fe7e03fb --- /dev/null +++ b/src/app/units/states/portfolios/directives/portfolios-assessment/portfolios-assessment.component.html @@ -0,0 +1,40 @@ +
+

Grade for {{ project.student.name }}

+

Assign grade for this project.

+ + Rationale + + + @for (group of gradeResults; track group) { +
+
+ {{ group.name }} +
+ +
+ @for (score of group.scores; track score) { + + } +
+
+ } +
+ +
+
diff --git a/src/app/units/states/portfolios/directives/portfolios-assessment/portfolios-assessment.component.scss b/src/app/units/states/portfolios/directives/portfolios-assessment/portfolios-assessment.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/units/states/portfolios/directives/portfolios-assessment/portfolios-assessment.component.ts b/src/app/units/states/portfolios/directives/portfolios-assessment/portfolios-assessment.component.ts new file mode 100644 index 0000000000..54f33d479e --- /dev/null +++ b/src/app/units/states/portfolios/directives/portfolios-assessment/portfolios-assessment.component.ts @@ -0,0 +1,44 @@ +import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; +import {Project} from 'src/app/api/models/project'; +import {Unit} from 'src/app/api/models/unit'; + +@Component({ + selector: 'f-portfolios-assessment', + templateUrl: './portfolios-assessment.component.html', + styleUrl: './portfolios-assessment.component.scss', + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class PortfoliosAssessmentComponent { + @Input() project: Project; + @Input() unit: Unit; + + public gradeResults = [ + { + name: 'Fail', + scores: [0, 10, 20, 30, 40, 44], + }, + { + name: 'Pass', + scores: [50, 53, 55, 57], + }, + { + name: 'Credit', + scores: [60, 63, 65, 67], + }, + { + name: 'Distinction', + scores: [70, 73, 75, 77], + }, + { + name: 'High Distinction', + scores: [80, 83, 85, 87], + }, + { + name: 'High Distinction', + scores: [90, 93, 95, 97, 100], + }, + ]; + + public maxScoresPerRow = Math.max(...this.gradeResults.map((g) => g.scores.length)); +} diff --git a/src/app/units/states/portfolios/directives/portfolios-list/portfolios-list.component.html b/src/app/units/states/portfolios/directives/portfolios-list/portfolios-list.component.html new file mode 100644 index 0000000000..9355f7f9a8 --- /dev/null +++ b/src/app/units/states/portfolios/directives/portfolios-list/portfolios-list.component.html @@ -0,0 +1,315 @@ +
+

Mark portfolios

+

Assess student portfolios

+
+
+ + Filter + + + + public + menu_book + + + + account_balance + edit + + + + + All + + @for (grade of gradeValues; track grade) { + + + + } + +
+
+
+ + + + + + + + @if (hasD2lMapping()) { + + } +
+ @if (loading) { +
+
+
+ @for ( + column of [ + {columnWidth: '110px', rowWidth: '100%'}, + {columnWidth: '170px', rowWidth: '50%'}, + {columnWidth: '180px', rowWidth: '50%'}, + {columnWidth: '130px', rowWidth: '75%'}, + {columnWidth: '80px', rowWidth: '28px'}, + {columnWidth: '80px', rowWidth: '28px'}, + {columnWidth: '180px', rowWidth: '100%'}, + {columnWidth: '400px', rowWidth: '100%'}, + {columnWidth: '80px', rowWidth: '100%'}, + ]; + track $index + ) { + + } +
+ + @for (row of [0, 1, 2, 3, 4, 5, 6, 7]; track row) { +
+ @for ( + column of [ + {columnWidth: '110px', rowWidth: '100%'}, + {columnWidth: '170px', rowWidth: '100%'}, + {columnWidth: '180px', rowWidth: '75%'}, + {columnWidth: '130px', rowWidth: '70%'}, + {columnWidth: '80px', rowWidth: '28px'}, + {columnWidth: '80px', rowWidth: '28px'}, + {columnWidth: '180px', rowWidth: '100%'}, + {columnWidth: '400px', rowWidth: '100%'}, + {columnWidth: '80px', rowWidth: '100%'}, + ]; + track $index + ) { + @if ($index === 4 || $index === 5) { + + } @else { + + } + } +
+ } +
+
+ } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Student + {{ project.student.studentId || project.student.username }} + Name + {{ project.student.name }} + Tutor + {{ project.tutorNames() }} + Tutorial + {{ project.shortTutorialDescription() }} + Target + + Submitted as + + Submission Date + {{ project.portfolioSubmissionDate | date: 'EEE d MMM y, h:mm a' }} + Has Portfolio + {{ project.hasPortfolio ? 'Yes' : 'No' }} + Stats +
+ @for (bar of project.taskStats; track bar) { +
+ @if (bar.key === 'not_started') { + {{ bar.value }}% + } + @if (bar.key === 'complete') { + {{ bar.value }}% + } +
+ } +
+
Grade + {{ project.grade }} + View + +
+ +
+ + +
diff --git a/src/app/units/states/portfolios/directives/portfolios-list/portfolios-list.component.scss b/src/app/units/states/portfolios/directives/portfolios-list/portfolios-list.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/units/states/portfolios/directives/portfolios-list/portfolios-list.component.spec.ts b/src/app/units/states/portfolios/directives/portfolios-list/portfolios-list.component.spec.ts new file mode 100644 index 0000000000..823f6a3cb2 --- /dev/null +++ b/src/app/units/states/portfolios/directives/portfolios-list/portfolios-list.component.spec.ts @@ -0,0 +1,61 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {MatTableModule} from '@angular/material/table'; +import {Project} from 'src/app/api/models/project'; +import {TaskService} from 'src/app/api/services/task.service'; +import {UnitService} from 'src/app/api/services/unit.service'; +import {UserService} from 'src/app/api/services/user.service'; +import {EmptyStateComponent} from 'src/app/common/empty-state/empty-state.component'; +import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; +import {SidekiqProgressModalService} from 'src/app/common/modals/sidekiq-progress-modal/sidekiq-progress-modal.service'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {GradeService} from 'src/app/common/services/grade.service'; +import {D2lTransferModal} from '../../d2l-transfer-modal/d2l-transfer.component'; +import {PortfoliosListComponent} from './portfolios-list.component'; + +function projectStub(name: string): Project { + return { + student: {name, username: name.toLowerCase()}, + tutorNames: () => '', + shortTutorialDescription: () => '', + taskStats: [], + } as unknown as Project; +} + +describe('PortfoliosListComponent empty state', () => { + afterEach(() => vi.restoreAllMocks()); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [PortfoliosListComponent], + imports: [MatTableModule, EmptyStateComponent], + providers: [ + {provide: TaskService, useValue: {}}, + {provide: UserService, useValue: {}}, + {provide: GradeService, useValue: {}}, + {provide: FileDownloaderService, useValue: {}}, + {provide: UnitService, useValue: {}}, + {provide: AlertService, useValue: {}}, + {provide: SidekiqProgressModalService, useValue: {}}, + {provide: D2lTransferModal, useValue: {}}, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + it('renders the empty state only while the filtered list has no rows', () => { + const fixture = TestBed.createComponent(PortfoliosListComponent); + const component = fixture.componentInstance; + component.displayedColumns = ['student', 'name', 'tutor', 'tutorial']; + + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('f-empty-state')).toBeTruthy(); + + component.dataSource.data = [projectStub('Cy Cole')]; + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('f-empty-state')).toBeFalsy(); + }); +}); diff --git a/src/app/units/states/portfolios/directives/portfolios-list/portfolios-list.component.ts b/src/app/units/states/portfolios/directives/portfolios-list/portfolios-list.component.ts new file mode 100644 index 0000000000..e479389a55 --- /dev/null +++ b/src/app/units/states/portfolios/directives/portfolios-list/portfolios-list.component.ts @@ -0,0 +1,311 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnChanges, + Output, + SimpleChanges, + ViewChild, +} from '@angular/core'; +import {MatButtonToggleChange} from '@angular/material/button-toggle'; +import {MatPaginator} from '@angular/material/paginator'; +import {MatSort, Sort} from '@angular/material/sort'; +import {MatTableDataSource} from '@angular/material/table'; +import {Project} from 'src/app/api/models/project'; +import {TaskStatusEnum} from 'src/app/api/models/task-status'; +import {Unit} from 'src/app/api/models/unit'; +import {TaskService} from 'src/app/api/services/task.service'; +import {UnitService} from 'src/app/api/services/unit.service'; +import {UserService} from 'src/app/api/services/user.service'; +import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; +import {SidekiqProgressModalService} from 'src/app/common/modals/sidekiq-progress-modal/sidekiq-progress-modal.service'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {GradeService} from 'src/app/common/services/grade.service'; +import {D2lTransferModal} from '../../d2l-transfer-modal/d2l-transfer.component'; + +export interface PortfolioListFilters { + portfolioFilter: 'all' | 'submitted_only'; + tutorialFilter: 'all' | 'mine'; + gradeFilter: number | null; + filterText: string; +} + +export const DEFAULT_PORTFOLIO_LIST_FILTERS: PortfolioListFilters = { + portfolioFilter: 'submitted_only', + tutorialFilter: 'all', + gradeFilter: null, + filterText: '', +}; + +@Component({ + selector: 'f-portfolios-list', + templateUrl: './portfolios-list.component.html', + styleUrl: './portfolios-list.component.scss', + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class PortfoliosListComponent implements OnChanges, AfterViewInit { + @Input() unit: Unit; + @Input() loading = true; + @Input() filters: PortfolioListFilters = DEFAULT_PORTFOLIO_LIST_FILTERS; + + @Output() + public studentSelected: EventEmitter = new EventEmitter(); + @Output() + public filtersChange: EventEmitter = new EventEmitter(); + + displayedColumns: string[] = []; + + @ViewChild(MatPaginator) paginator!: MatPaginator; + @ViewChild(MatSort) sort: MatSort; + + dataSource: MatTableDataSource = new MatTableDataSource([]); + + public portfolioFilter: 'all' | 'submitted_only' = 'submitted_only'; + public tutorialFilter: 'all' | 'mine' = 'all'; + public gradeFilter: number | null = null; + public filterText = ''; + + constructor( + private taskService: TaskService, + private userService: UserService, + private gradeService: GradeService, + private fileDownloaderService: FileDownloaderService, + private unitService: UnitService, + private alertService: AlertService, + private sidekiq: SidekiqProgressModalService, + private d2lTransferModal: D2lTransferModal, + ) {} + + ngAfterViewInit() { + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + this.applyTextFilter(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.filters) { + this.portfolioFilter = this.filters.portfolioFilter; + this.tutorialFilter = this.filters.tutorialFilter; + this.gradeFilter = this.filters.gradeFilter; + this.filterText = this.filters.filterText; + } + + if (!this.loading && this.unit && (changes.loading || changes.unit)) { + this.updateDataSource(); + } + + if (changes.filters) { + this.applyTextFilter(); + if (!this.loading && this.unit) { + this.updateDataSource(); + } + } + } + + openProject(event: Event, project: Project) { + event.stopPropagation(); + window.open(`/projects/${project.id}/dashboard/?tutor=true`, '_blank'); + } + + downloadGrades() { + this.fileDownloaderService.downloadFile(this.unit.gradesUrl, `${this.unit.code}-grades.csv`); + } + + downloadPortfolios() { + this.unitService.zipPortfolios(this.unit).subscribe({ + next: (newJob) => { + this.sidekiq.show(`Downloading Portfolios: ${this.unit.code}`, newJob.id).subscribe({ + next: () => { + this.fileDownloaderService.downloadFile( + this.unit.portfoliosUrl, + `${this.unit.code}-portfolios.zip`, + ); + }, + error: (error) => { + this.alertService.error(error, 6000); + }, + }); + }, + error: (error) => { + this.alertService.error(`Could not download portfolios: ${error}`, 6000); + }, + }); + } + + public hasD2lMapping() { + return this.unit.hasD2lMapping(); + } + + transferToD2l() { + this.d2lTransferModal.open(this.unit); + } + + public get gradeValues() { + return this.gradeService.gradeValuesFor(this.unit); + } + + public gradeLabel(grade) { + return this.gradeService.gradeLabel(grade, this.unit); + } + + updateDataSource() { + const currentUser = this.userService.currentUser; + + const students = this.unit.students + .filter((p) => + this.portfolioFilter === 'submitted_only' ? p.hasPortfolio || p.portfolioAvailable : true, + ) + .filter((p) => (this.tutorialFilter === 'mine' ? p.hasTutor(currentUser) : true)) + .filter((p) => (this.gradeFilter !== null ? p.submittedGrade === this.gradeFilter : true)); + + this.displayedColumns = [ + 'student', + 'name', + 'tutor', + 'tutorial', + 'target', + 'submitted-as', + 'submission-date', + ...(this.portfolioFilter === 'all' ? ['has-portfolio'] : []), + 'stats', + 'grade', + 'actions', + ]; + + this.dataSource.data = students; + this.dataSource.paginator?.firstPage(); + this.applyTextFilter(); + } + + onPortfolioFilterChange(event: MatButtonToggleChange) { + this.portfolioFilter = event.value; + this.updateDataSource(); + this.emitFilters(); + } + + onTutorialFilterChange(event: MatButtonToggleChange) { + this.tutorialFilter = event.value; + this.updateDataSource(); + this.emitFilters(); + } + + onGradeFilterChange(event: MatButtonToggleChange) { + this.gradeFilter = event.value; + this.updateDataSource(); + this.emitFilters(); + } + + applyFilter(event: Event) { + this.filterText = (event.target as HTMLInputElement).value; + this.applyTextFilter(); + this.emitFilters(); + } + + private applyTextFilter() { + this.dataSource.filterPredicate = (project, filter) => { + const text = [ + project.student.studentId, + project.student.username, + project.student.name, + project.tutorNames(), + project.shortTutorialDescription(), + String(project.grade), + ] + .join(' ') + .toLowerCase(); + + return text.includes(filter); + }; + + this.dataSource.filter = this.filterText.trim().toLowerCase(); + + if (this.dataSource.paginator) { + this.dataSource.paginator.firstPage(); + } + } + + private emitFilters() { + this.filtersChange.emit({ + portfolioFilter: this.portfolioFilter, + tutorialFilter: this.tutorialFilter, + gradeFilter: this.gradeFilter, + filterText: this.filterText, + }); + } + + selectStudent(project: Project) { + this.studentSelected.emit(project); + } + + public statusColor(status: TaskStatusEnum): string { + return this.taskService.statusColors.get(status); + } + + public statusLabel(status: TaskStatusEnum): string { + return this.taskService.statusLabels.get(status); + } + + private sortCompare(aValue: number | string, bValue: number | string, isAsc: boolean) { + return (aValue < bValue ? -1 : 1) * (isAsc ? 1 : -1); + } + + private sortDateValue(value: Date | string | number | null | undefined): number { + if (value === null || value === undefined || value === '') { + return 0; + } + + const time = new Date(value).getTime(); + return Number.isFinite(time) ? time : 0; + } + + sortTableData(sort: Sort) { + if (!sort.active || sort.direction === '') { + return; + } + this.dataSource.data = this.dataSource.data.sort((a, b) => { + switch (sort.active) { + case 'student': + return this.sortCompare( + a.student.studentId || a.student.username, + b.student.studentId || b.student.username, + sort.direction === 'asc', + ); + case 'name': + return this.sortCompare(a.student?.name, b.student?.name, sort.direction === 'asc'); + case 'tutor': { + return this.sortCompare(a.tutorNames(), b.tutorNames(), sort.direction === 'asc'); + } + case 'tutorial': + return this.sortCompare( + a.shortTutorialDescription(), + b.shortTutorialDescription(), + sort.direction === 'asc', + ); + + case 'target': + return this.sortCompare(a.targetGrade, b.targetGrade, sort.direction === 'asc'); + case 'submitted-as': + return this.sortCompare(a.submittedGrade, b.submittedGrade, sort.direction === 'asc'); + case 'submission-date': + return this.sortCompare( + this.sortDateValue(a.portfolioSubmissionDate), + this.sortDateValue(b.portfolioSubmissionDate), + sort.direction === 'asc', + ); + case 'has-portfolio': + return this.sortCompare( + a.hasPortfolio.toString(), + b.hasPortfolio.toString(), + sort.direction === 'asc', + ); + case 'grade': + return this.sortCompare(a.grade, b.grade, sort.direction === 'asc'); + default: + return 0; + } + }); + } +} diff --git a/src/app/units/states/portfolios/directives/portfolios-portfolio-view/portfolios-portfolio-view.component.html b/src/app/units/states/portfolios/directives/portfolios-portfolio-view/portfolios-portfolio-view.component.html new file mode 100644 index 0000000000..abe9bf9a51 --- /dev/null +++ b/src/app/units/states/portfolios/directives/portfolios-portfolio-view/portfolios-portfolio-view.component.html @@ -0,0 +1,14 @@ +
+

Review portfolio of {{ project.student.name }}

+

View or download portfolio for assessment.

+ + @if (project.portfolioAvailable) { + + + } @else { +
+ menu_book +

No Portfolio Submitted

+
+ } +
diff --git a/src/app/units/states/portfolios/directives/portfolios-portfolio-view/portfolios-portfolio-view.component.scss b/src/app/units/states/portfolios/directives/portfolios-portfolio-view/portfolios-portfolio-view.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/units/states/portfolios/directives/portfolios-portfolio-view/portfolios-portfolio-view.component.ts b/src/app/units/states/portfolios/directives/portfolios-portfolio-view/portfolios-portfolio-view.component.ts new file mode 100644 index 0000000000..53efce9faa --- /dev/null +++ b/src/app/units/states/portfolios/directives/portfolios-portfolio-view/portfolios-portfolio-view.component.ts @@ -0,0 +1,13 @@ +import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; +import {Project} from 'src/app/api/models/project'; + +@Component({ + selector: 'f-portfolios-portfolio-view', + templateUrl: './portfolios-portfolio-view.component.html', + styleUrl: './portfolios-portfolio-view.component.scss', + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class PortfoliosPortfolioViewComponent { + @Input() project: Project; +} diff --git a/src/app/units/states/portfolios/directives/portfolios-project-progress/portfolios-project-progress.component.html b/src/app/units/states/portfolios/directives/portfolios-project-progress/portfolios-project-progress.component.html new file mode 100644 index 0000000000..ec5c89dfd0 --- /dev/null +++ b/src/app/units/states/portfolios/directives/portfolios-project-progress/portfolios-project-progress.component.html @@ -0,0 +1,109 @@ +
+

Review progress of {{ project.student.name }}

+

Review the students progress through the unit's tasks.

+ + + + +
diff --git a/src/app/units/states/portfolios/directives/portfolios-project-progress/portfolios-project-progress.component.scss b/src/app/units/states/portfolios/directives/portfolios-project-progress/portfolios-project-progress.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/units/states/portfolios/directives/portfolios-project-progress/portfolios-project-progress.component.ts b/src/app/units/states/portfolios/directives/portfolios-project-progress/portfolios-project-progress.component.ts new file mode 100644 index 0000000000..02e826996e --- /dev/null +++ b/src/app/units/states/portfolios/directives/portfolios-project-progress/portfolios-project-progress.component.ts @@ -0,0 +1,185 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + HostListener, + Input, + OnChanges, + SimpleChanges, +} from '@angular/core'; +import {BehaviorSubject} from 'rxjs'; +import {Project} from 'src/app/api/models/project'; +import {Unit} from 'src/app/api/models/unit'; +import {ProjectService} from 'src/app/api/services/project.service'; +import {TaskService} from 'src/app/api/services/task.service'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {GradeService} from 'src/app/common/services/grade.service'; + +@Component({ + selector: 'f-portfolios-project-progress', + templateUrl: './portfolios-project-progress.component.html', + styleUrl: './portfolios-project-progress.component.scss', + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class PortfoliosProjectProgressComponent implements OnChanges { + @Input() project: Project; + @Input() unit: Unit; + @Input() taskSelectionUrlBase: unknown[] | null = null; + @Input() showSubmittedGrade?: boolean = false; + + @Input() + public project$: BehaviorSubject = new BehaviorSubject(null); + + public taskStats: {numberOfTasksCompleted: number; numberOfTasksRemaining: number} = { + numberOfTasksCompleted: 0, + numberOfTasksRemaining: 0, + }; + + constructor( + private elementRef: ElementRef, + private gradeService: GradeService, + private projectService: ProjectService, + private alertService: AlertService, + private taskService: TaskService, + ) {} + + @HostListener('wheel', ['$event']) + public prioritisePageScroll(event: WheelEvent): void { + if (event.deltaY === 0 || !(event.target instanceof HTMLElement)) { + return; + } + + const projectDashboard = event.target.closest('f-project-dashboard'); + if (!projectDashboard || !this.elementRef.nativeElement.contains(projectDashboard)) { + return; + } + + if (event.target.closest('f-unit-task-list')) { + return; + } + + const innerScrollContainer = this.findInnerScrollContainer(event.target, projectDashboard); + if ( + event.deltaY < 0 && + innerScrollContainer && + this.canScroll(innerScrollContainer, event.deltaY) + ) { + return; + } + + const scrollContainer = this.findOuterScrollContainer(projectDashboard); + if (!scrollContainer || !this.canScroll(scrollContainer, event.deltaY)) { + return; + } + + event.preventDefault(); + scrollContainer.scrollBy({top: event.deltaY}); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.project && this.project) { + this.project$.next(this.project); + } + } + + public get gradeValues() { + return this.gradeService.gradeValuesFor(this.unit); + } + public get grades() { + return Object.fromEntries( + this.unit.gradeDefinitions.map((definition) => [definition.value, definition.label]), + ); + } + + public gradeWord(grade) { + return this.gradeService.gradeLabel(grade, this.unit); + } + + updateTaskCompletionStats() { + this.taskStats.numberOfTasksCompleted = this.project.tasksByStatus( + this.taskService.completeStatus, + ).length; + this.taskStats.numberOfTasksRemaining = + this.project.activeTasks().length - this.taskStats.numberOfTasksCompleted; + } + + updateSubmittedGrade(newGrade: number): void { + const previousSubmittedGrade = this.project.submittedGrade; + this.project.submittedGrade = newGrade; + + this.projectService.update(this.project).subscribe({ + next: (project) => { + project.refreshBurndownChartData?.(); + this.alertService.success(`Updated project's submitted grade`); + this.updateTaskCompletionStats(); + }, + error: (error) => { + this.project.submittedGrade = previousSubmittedGrade; + this.alertService.error(`Failed to update submitted grade: ${error}`); + }, + }); + } + + updatedTargetGrade(newGrade: number): void { + const previousTargetGrade = this.project.targetGrade; + this.project.targetGrade = newGrade; + + this.projectService.update(this.project).subscribe({ + next: (project) => { + project.refreshBurndownChartData?.(); + this.alertService.success(`Updated project's target grade`); + this.updateTaskCompletionStats(); + }, + error: (error) => { + this.project.targetGrade = previousTargetGrade; + this.alertService.error(`Failed to update target grade: ${error}`); + }, + }); + } + + private findInnerScrollContainer(target: HTMLElement, root: Element): HTMLElement | null { + let element: HTMLElement | null = target; + + while (element && element !== root) { + if (this.isScrollable(element)) { + return element; + } + + element = element.parentElement; + } + + return null; + } + + private findOuterScrollContainer(projectDashboard: Element): HTMLElement { + let parent = projectDashboard.parentElement; + + while (parent) { + if (this.isScrollable(parent)) { + return parent; + } + + parent = parent.parentElement; + } + + return document.scrollingElement as HTMLElement; + } + + private isScrollable(element: HTMLElement): boolean { + if (element.scrollHeight <= element.clientHeight) { + return false; + } + + const overflowY = getComputedStyle(element).overflowY; + return overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay'; + } + + private canScroll(element: HTMLElement, deltaY: number): boolean { + if (deltaY > 0) { + return element.scrollTop + element.clientHeight < element.scrollHeight; + } + + return element.scrollTop > 0; + } +} diff --git a/src/app/units/states/portfolios/download-staff-notes/download-staff-notes.component.html b/src/app/units/states/portfolios/download-staff-notes/download-staff-notes.component.html new file mode 100644 index 0000000000..89963fcfef --- /dev/null +++ b/src/app/units/states/portfolios/download-staff-notes/download-staff-notes.component.html @@ -0,0 +1,3 @@ + diff --git a/src/app/units/states/portfolios/download-staff-notes/download-staff-notes.component.scss b/src/app/units/states/portfolios/download-staff-notes/download-staff-notes.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/units/states/portfolios/download-staff-notes/download-staff-notes.component.ts b/src/app/units/states/portfolios/download-staff-notes/download-staff-notes.component.ts new file mode 100644 index 0000000000..6c102ef91c --- /dev/null +++ b/src/app/units/states/portfolios/download-staff-notes/download-staff-notes.component.ts @@ -0,0 +1,26 @@ +import {ChangeDetectionStrategy, Component, Input, OnInit} from '@angular/core'; +import {Unit} from 'src/app/api/models/unit'; +import {AlertService} from 'src/app/common/services/alert.service'; + +@Component({ + selector: 'f-download-staff-notes', + templateUrl: 'download-staff-notes.component.html', + styleUrl: 'download-staff-notes.component.scss', + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class DownloadStaffNotesComponent implements OnInit { + @Input() unit: Unit; + + constructor(private alertService: AlertService) {} + + public ngOnInit(): void { + if (!this.unit) { + return console.error(`Invalid unit`); + } + } + + public downloadStaffNotesCsv() { + this.unit.downloadStaffNotesCsv(); + } +} diff --git a/src/app/units/states/portfolios/portfolios.coffee b/src/app/units/states/portfolios/portfolios.coffee deleted file mode 100644 index ebbc09ec9b..0000000000 --- a/src/app/units/states/portfolios/portfolios.coffee +++ /dev/null @@ -1,127 +0,0 @@ -angular.module('doubtfire.units.states.portfolios', []) -# -# State for staff viewing portfolios -# -.config(($stateProvider) -> - $stateProvider.state 'units/students/portfolios', { - parent: 'units/index' - url: '/students/portfolios' - templateUrl: "units/states/portfolios/portfolios.tpl.html" - controller: "UnitPortfoliosStateCtrl" - data: - task: "Student Portfolios" - pageTitle: "_Home_" - roleWhitelist: ['Tutor', 'Convenor', 'Admin', 'Auditor'] - } -) -.controller("UnitPortfoliosStateCtrl", ($scope, alertService, analyticsService, gradeService, newProjectService, Visualisation, newTaskService, fileDownloaderService, newUserService) -> - # TODO: (@alexcu) Break this down into smaller directives/substates - - $scope.downloadGrades = -> fileDownloaderService.downloadFile($scope.unit.gradesUrl, "#{$scope.unit.code}-grades.csv") - $scope.downloadPortfolios = -> fileDownloaderService.downloadFile($scope.unit.portfoliosUrl, "#{$scope.unit.code}-portfolios.zip") - - $scope.studentFilter = 'allStudents' - $scope.portfolioFilter = 'withPortfolio' - - $scope.statusClass = newTaskService.statusClass - $scope.statusText = newTaskService.statusText - - refreshCharts = Visualisation.refreshAll - - # - # Sets the active tab - # - $scope.setActiveTab = (tab) -> - # Do nothing if we're switching to the same tab - return if tab is $scope.activeTab - $scope.activeTab?.active = false - $scope.activeTab = tab - $scope.activeTab.active = true - - if $scope.activeTab == $scope.tabs.viewProgress - refreshCharts() - - # - # Active task tab group - # - $scope.tabs = - selectStudent: - title: "Select Student" - subtitle: "Select the student to assess" - seq: 0 - viewProgress: - title: "View Progress" - subtitle: "See the progress of the student" - seq: 1 - viewPortfolio: - title: "View Portfolio" - subtitle: "See the portfolio of the student" - seq: 2 - assessPortfolio: - title: "Assess Portfolio" - subtitle: "Enter a grade for the student" - seq: 3 - - $scope.setActiveTab($scope.tabs.selectStudent) - - $scope.tutor = newUserService.currentUser - - $scope.search = "" - - # Pagination details - $scope.currentPage = 1 - $scope.maxSize = 5 - $scope.pageSize = 10 - - $scope.filterOptions = {selectedGrade: -1} - $scope.gradeValues = gradeService.gradeValues - $scope.grades = gradeService.grades - $scope.gradeAcronyms = gradeService.gradeAcronyms - - $scope.selectedStudent = null - - $scope.gradeResults = [ - { - name: 'Fail', - scores: [ 0, 10, 20, 30, 40, 44 ] - } - { - name: 'Pass', - scores: [ 50, 53, 55, 57 ] - } - { - name: 'Credit', - scores: [ 60, 63, 65, 67 ] - } - { - name: 'Distinction', - scores: [ 70, 73, 75, 77 ] - } - { - name: 'High Distinction', - scores: [ 80, 83, 85, 87 ] - } - { - name: 'High Distinction', - scores: [ 90, 93, 95, 97, 100 ] - } - ] - - $scope.editingRationale = false - - $scope.toggleEditRationale = -> - $scope.editingRationale = !$scope.editingRationale - - - analyticsService.watchEvent $scope, 'studentFilter', 'Teacher View - Grading Tab' - analyticsService.watchEvent $scope, 'sortOrder', 'Teacher View - Grading Tab' - analyticsService.watchEvent $scope, 'currentPage', 'Teacher View - Grading Tab', 'Selected Page' - - $scope.selectStudent = (student) -> - $scope.selectedStudent = student - $scope.project = null - newProjectService.loadProject(student, $scope.unit).subscribe({ - next: (project) -> $scope.project = project - error: (message) -> alertService.error( message, 6000) - }) -) diff --git a/src/app/units/states/portfolios/portfolios.component.html b/src/app/units/states/portfolios/portfolios.component.html new file mode 100644 index 0000000000..40d45df3cb --- /dev/null +++ b/src/app/units/states/portfolios/portfolios.component.html @@ -0,0 +1,65 @@ + + @if (unit) { + + @for (tab of tabs; track tab.routeSegment) { + + } + + +
+ @switch (currentTab.routeSegment) { + @case ('select') { + + } + @case ('progress') { + @if (selectedProject) { + + } + } + @case ('student-notes') { + @if (selectedProject) { +
+

Student Notes for {{ selectedProject.student.name }}

+

+ Use these notes for private staff discussions about the student. Students cannot + view them. +

+ + +
+ } + } + @case ('portfolio') { + @if (selectedProject) { + + } + } + @case ('assessment') { + @if (selectedProject) { + + } + } + } +
+ } +
diff --git a/src/app/units/states/portfolios/portfolios.component.scss b/src/app/units/states/portfolios/portfolios.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/units/states/portfolios/portfolios.component.ts b/src/app/units/states/portfolios/portfolios.component.ts new file mode 100644 index 0000000000..62e93f6ef5 --- /dev/null +++ b/src/app/units/states/portfolios/portfolios.component.ts @@ -0,0 +1,314 @@ +import {ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit} from '@angular/core'; +import {MatTabChangeEvent} from '@angular/material/tabs'; +import {ActivatedRoute, ParamMap, Router} from '@angular/router'; +import {BehaviorSubject, Observable, Subscription, distinctUntilChanged, of} from 'rxjs'; +import {Project} from 'src/app/api/models/project'; +import {Unit} from 'src/app/api/models/unit'; +import {ProjectService} from 'src/app/api/services/project.service'; +import {UserService} from 'src/app/api/services/user.service'; +import {AlertService} from 'src/app/common/services/alert.service'; +import { + DEFAULT_PORTFOLIO_LIST_FILTERS, + PortfolioListFilters, +} from './directives/portfolios-list/portfolios-list.component'; + +type PortfolioTabKey = 'select' | 'progress' | 'student-notes' | 'portfolio' | 'assessment'; + +interface PortfolioTab { + label: string; + routeSegment: PortfolioTabKey; + requiresProject: boolean; +} + +@Component({ + selector: 'f-portfolios', + templateUrl: './portfolios.component.html', + styleUrl: './portfolios.component.scss', + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class PortfoliosComponent implements OnInit, OnDestroy { + @Input() unit$: Observable; + + public readonly tabs: PortfolioTab[] = [ + {label: 'Select Student', routeSegment: 'select', requiresProject: false}, + {label: 'View Progress', routeSegment: 'progress', requiresProject: true}, + {label: 'View Student Notes', routeSegment: 'student-notes', requiresProject: true}, + {label: 'View Portfolio', routeSegment: 'portfolio', requiresProject: true}, + {label: 'Assess Portfolio', routeSegment: 'assessment', requiresProject: true}, + ]; + + public unit: Unit = null; + public selectedProject: Project | null = null; + public selectedProject$: BehaviorSubject = new BehaviorSubject(null); + public loadingStudents = true; + public currentTab: PortfolioTab = this.tabs[0]; + public portfolioListFilters: PortfolioListFilters = {...DEFAULT_PORTFOLIO_LIST_FILTERS}; + + private subscriptions: Subscription[] = []; + private selectedProjectId: number | null = null; + private progressTaskSelectionUrlBaseCache: unknown[] | null = null; + private progressTaskSelectionUrlBaseKey: string | null = null; + private routeParamsSubscribed = false; + + constructor( + private projectService: ProjectService, + private router: Router, + private route: ActivatedRoute, + private alertService: AlertService, + private userService: UserService, + ) {} + + public ngOnInit(): void { + this.unit$ = this.unit$ ?? of(this.route.parent.snapshot.data.unit); + this.subscriptions.push( + this.unit$.pipe(distinctUntilChanged((a, b) => a?.id === b?.id)).subscribe({ + next: (unit) => { + const unitChanged = this.unit != null && this.unit.id !== unit.id; + this.unit = unit; + + if (unitChanged) { + this.resetStudentSelection(); + } + + if ( + this.userService.currentUser.systemRole === 'Admin' || + this.userService.currentUser.systemRole === 'Convenor' + ) { + this.unit.loadD2lMapping().subscribe(); + } + + this.loadingStudents = true; + this.loadStudents(); + this.subscribeToRouteParams(); + }, + error: (error) => { + this.alertService.error(`Failed to load unit: ${error}`, 6000); + this.router.navigateByUrl('/home'); + }, + }), + ); + } + + public ngOnDestroy(): void { + this.subscriptions.forEach((subscription) => subscription.unsubscribe()); + } + + public get currentIndex(): number { + const index = this.tabs.findIndex((tab) => tab.routeSegment === this.currentTab.routeSegment); + return index >= 0 ? index : 0; + } + + public get progressTaskSelectionUrlBase(): unknown[] | null { + if (!this.unit || !this.selectedProject) { + return null; + } + + // Cache the array so the binding keeps the same reference between change detection passes. + // A new literal each call makes the input dirty every check, which pushes the progress tab + // to re-emit its project over and over. + const key = `${this.unit.id}/${this.selectedProject.id}`; + if (key !== this.progressTaskSelectionUrlBaseKey) { + this.progressTaskSelectionUrlBaseKey = key; + this.progressTaskSelectionUrlBaseCache = [ + '/units', + this.unit.id, + 'students', + 'portfolios', + this.selectedProject.id, + 'progress', + ]; + } + + return this.progressTaskSelectionUrlBaseCache; + } + + public studentSelected(project: Project): void { + this.navigateToProject(project.id, 'progress'); + } + + public portfolioListFiltersChange(filters: PortfolioListFilters): void { + this.portfolioListFilters = {...filters}; + + if (this.currentTab.routeSegment === 'select') { + this.router.navigate([], { + relativeTo: this.route, + queryParams: this.portfolioListFilterQueryParams(), + replaceUrl: true, + }); + } + } + + public onTabChange(event: MatTabChangeEvent): void { + const nextTab = this.tabs[event.index] ?? this.tabs[0]; + this.currentTab = nextTab; + + if (nextTab.routeSegment === 'select' || !this.selectedProject) { + this.router.navigate(['/units', this.unit.id, 'students', 'portfolios'], { + queryParams: this.portfolioListFilterQueryParams(), + replaceUrl: true, + }); + return; + } + + this.navigateToProject(this.selectedProject.id, nextTab.routeSegment); + } + + // The selected student belongs to the unit we just left, so drop it and go back to + // the student list rather than showing someone who is not enrolled in the new unit. + private resetStudentSelection(): void { + const hadSelection = this.selectedProjectId != null; + + this.selectedProjectId = null; + this.selectedProject = null; + this.selectedProject$.next(null); + this.currentTab = this.tabs[0]; + + if (hadSelection) { + this.router.navigate(['/units', this.unit.id, 'students', 'portfolios'], { + queryParams: this.portfolioListFilterQueryParams(), + replaceUrl: true, + }); + } + } + + // The unit stream fires again on every unit change, so this has to be idempotent or + // a switch would stack a second pair of route subscriptions on top of the first. + private subscribeToRouteParams(): void { + if (this.routeParamsSubscribed) { + return; + } + + this.routeParamsSubscribed = true; + this.subscriptions.push( + this.route.paramMap.subscribe((params) => { + this.updateCurrentTabFromState(params.get('tab'), params.get('projectId')); + }), + this.route.queryParamMap.subscribe((params) => { + this.updatePortfolioListFiltersFromQueryParams(params); + }), + ); + } + + private loadStudents(): void { + this.projectService.loadStudents(this.unit, false).subscribe({ + next: () => { + this.loadingStudents = false; + }, + error: (error) => { + this.alertService.error(`Failed to load unit: ${error}`, 6000); + this.router.navigateByUrl('/home'); + }, + }); + } + + private updatePortfolioListFiltersFromQueryParams(params: ParamMap): void { + const filters: PortfolioListFilters = { + portfolioFilter: params.get('portfolio') === 'all' ? 'all' : 'submitted_only', + tutorialFilter: params.get('tutorial') === 'mine' ? 'mine' : 'all', + gradeFilter: this.gradeFilterFromQueryParam(params.get('grade')), + filterText: params.get('search') ?? '', + }; + + if (!this.portfolioListFiltersEqual(filters, this.portfolioListFilters)) { + this.portfolioListFilters = filters; + } + } + + private gradeFilterFromQueryParam(grade: string | null): number | null { + if (grade === null) { + return null; + } + + const gradeValue = Number(grade); + return Number.isFinite(gradeValue) ? gradeValue : null; + } + + private portfolioListFilterQueryParams(): Record { + return { + portfolio: + this.portfolioListFilters.portfolioFilter === DEFAULT_PORTFOLIO_LIST_FILTERS.portfolioFilter + ? null + : this.portfolioListFilters.portfolioFilter, + tutorial: + this.portfolioListFilters.tutorialFilter === DEFAULT_PORTFOLIO_LIST_FILTERS.tutorialFilter + ? null + : this.portfolioListFilters.tutorialFilter, + grade: this.portfolioListFilters.gradeFilter, + search: this.portfolioListFilters.filterText.trim() || null, + }; + } + + private portfolioListFiltersEqual(a: PortfolioListFilters, b: PortfolioListFilters): boolean { + return ( + a.portfolioFilter === b.portfolioFilter && + a.tutorialFilter === b.tutorialFilter && + a.gradeFilter === b.gradeFilter && + a.filterText === b.filterText + ); + } + + private updateCurrentTabFromState( + tabParam?: string | null, + projectIdParam?: string | null, + ): void { + const projectId = projectIdParam ? Number(projectIdParam) : null; + const requestedTab = this.tabFromRoute(tabParam, !!projectId); + + this.currentTab = requestedTab; + + if (!projectId) { + this.selectedProjectId = null; + this.selectedProject = null; + this.selectedProject$.next(null); + return; + } + + if (this.selectedProject?.id === projectId) { + return; + } + + this.loadProject(projectId); + } + + private tabFromRoute(tabParam: string | null, hasProject: boolean): PortfolioTab { + if (!hasProject) { + return this.tabs[0]; + } + + const routeTab = this.tabs.find( + (tab) => tab.routeSegment === tabParam && tab.routeSegment !== 'select', + ); + + return routeTab ?? this.tabs.find((tab) => tab.routeSegment === 'progress') ?? this.tabs[0]; + } + + private loadProject(projectId: number): void { + this.selectedProjectId = projectId; + + this.projectService.loadProject(projectId, this.unit).subscribe({ + next: (project) => { + if (this.selectedProjectId !== project.id) { + return; + } + + this.selectedProject = project; + this.selectedProject$.next(project); + }, + error: (error) => { + this.selectedProjectId = null; + this.selectedProject = null; + this.selectedProject$.next(null); + this.alertService.error(`Failed to load project: ${error}`, 6000); + console.error(error); + }, + }); + } + + private navigateToProject(projectId: number, tab: PortfolioTabKey): void { + this.router.navigate(['/units', this.unit.id, 'students', 'portfolios', projectId, tab], { + queryParams: this.portfolioListFilterQueryParams(), + replaceUrl: true, + }); + } +} diff --git a/src/app/units/states/portfolios/portfolios.scss b/src/app/units/states/portfolios/portfolios.scss deleted file mode 100644 index 704ea095ff..0000000000 --- a/src/app/units/states/portfolios/portfolios.scss +++ /dev/null @@ -1,12 +0,0 @@ -.unit-student-portfolio-list { - .select-portfolio-grade.btn-group { - @media (min-width: $screen-sm-max) { - margin: auto 1.2ex; - } - label.btn { - padding: 4px 7px; - font-size: 0.9em; - height: 34px; - } - } -} diff --git a/src/app/units/states/portfolios/portfolios.tpl.html b/src/app/units/states/portfolios/portfolios.tpl.html deleted file mode 100644 index 51f09b077c..0000000000 --- a/src/app/units/states/portfolios/portfolios.tpl.html +++ /dev/null @@ -1,274 +0,0 @@ -
- - - {{tab.title}} - - - -
-
-
-

Mark Portfolios

- Assess student portfolios -
-
-
-
- - -
-
- - -
-
- -
-
- -
-
- - - -
- -
- -
-
- -

No portfolios found

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - Student - - - - Name - - - - Tutor - - - - Tutorial - - - - - Target - - - - Submitted as - - - - - Stats - - - - Portfolio? - - - - Grade - -
{{student.student.studentId || student.student.username}}{{student.student.name}}{{student.tutorNames()}}{{student.shortTutorialDescription()}} - - - - - - - {{bar.value}}% - - - {{student.hasPortfolio ? "Yes" : "No"}}{{student.grade}}
-
- -
-
-
-
-

Portfolio Details

- Review portfolio and assign grade. -
-
-
- -

Select student to view portfolio and assign grade

-
-
-
-
-
-
-

Review Progress of {{selectedStudent.student.name}}

- Review the students progress through the unit's tasks. -
-
-
- -
-
- -
-
-
-

Review Portfolio of {{selectedStudent.student.name}}

- View or download portfolio for assessment. -
-
-
- -

No Portfolio Submitted

-
- -
- -
-
-
-

Grade for {{selectedStudent.student.name}}

- Assign Grade for this work. -
-
-
-
- -
- -
-
{{results.name}}
-

- -

-
-
- -
-
-
-
-
diff --git a/src/app/units/states/portfolios/upload-grades/upload-grades.component.html b/src/app/units/states/portfolios/upload-grades/upload-grades.component.html new file mode 100644 index 0000000000..f42a9a62a0 --- /dev/null +++ b/src/app/units/states/portfolios/upload-grades/upload-grades.component.html @@ -0,0 +1,3 @@ + diff --git a/src/app/units/states/portfolios/upload-grades/upload-grades.component.scss b/src/app/units/states/portfolios/upload-grades/upload-grades.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/units/states/portfolios/upload-grades/upload-grades.component.ts b/src/app/units/states/portfolios/upload-grades/upload-grades.component.ts new file mode 100644 index 0000000000..fe302d5415 --- /dev/null +++ b/src/app/units/states/portfolios/upload-grades/upload-grades.component.ts @@ -0,0 +1,58 @@ +import {ChangeDetectionStrategy, Component, Input, OnInit} from '@angular/core'; +import {SidekiqJob} from 'src/app/api/models/sidekiq-job'; +import {Unit} from 'src/app/api/models/unit'; +import {CsvResultModalService} from 'src/app/common/modals/csv-result-modal/csv-result-modal.service'; +import {CsvUploadModalService} from 'src/app/common/modals/csv-upload-modal/csv-upload-modal.service'; +import {SidekiqProgressModalService} from 'src/app/common/modals/sidekiq-progress-modal/sidekiq-progress-modal.service'; +import {AlertService} from 'src/app/common/services/alert.service'; + +@Component({ + selector: 'f-upload-grades', + templateUrl: 'upload-grades.component.html', + styleUrl: 'upload-grades.component.scss', + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class UploadGradesComponent implements OnInit { + @Input() unit: Unit; + + constructor( + private sidekiqModalService: SidekiqProgressModalService, + private csvUploadModal: CsvUploadModalService, + private csvResultModal: CsvResultModalService, + private alertService: AlertService, + ) {} + + public ngOnInit(): void { + if (!this.unit) { + return console.error(`Invalid unit`); + } + } + + public uploadGradesCSV() { + this.csvUploadModal.show( + 'Upload Student Grades as CSV', + 'Import student grades', + { + file: {name: 'Feedback Templates CSV Data', type: 'csv'}, + }, + this.unit.gradesCSVUploadUrl, + (response: SidekiqJob) => { + if (!response) { + this.alertService.error('Failed to import grades', 6000); + return; + } + + this.sidekiqModalService.show('Import student grades', response.id).subscribe({ + next: (job) => { + this.csvResultModal.show('Student grade import results', JSON.parse(job.result)); + }, + error: (error) => { + console.error(error); + this.alertService.error('Failed to import grades', 6000); + }, + }); + }, + ); + } +} diff --git a/src/app/units/states/rollover/directives/directives.coffee b/src/app/units/states/rollover/directives/directives.coffee deleted file mode 100644 index 4f892f1291..0000000000 --- a/src/app/units/states/rollover/directives/directives.coffee +++ /dev/null @@ -1,3 +0,0 @@ -angular.module('doubtfire.units.states.rollover.directives', [ - 'doubtfire.units.states.rollover.directives.unit-dates-selector' -]) \ No newline at end of file diff --git a/src/app/units/states/rollover/directives/unit-dates-selector/unit-dates-selector.coffee b/src/app/units/states/rollover/directives/unit-dates-selector/unit-dates-selector.coffee deleted file mode 100644 index 07569a20a0..0000000000 --- a/src/app/units/states/rollover/directives/unit-dates-selector/unit-dates-selector.coffee +++ /dev/null @@ -1,79 +0,0 @@ -angular.module('doubtfire.units.states.rollover.directives.unit-dates-selector', []) - -# -# Editor for the basic details of a unit, such as the name, code -# start and end dates etc. -# -.directive('unitDatesSelector', -> - replace: true - restrict: 'E' - templateUrl: 'units/states/rollover/directives/unit-dates-selector/unit-dates-selector.tpl.html' - controller: ($scope, $state, $rootScope, DoubtfireConstants, alertService, newTeachingPeriodService) -> - $scope.calOptions = { - startOpened: false - endOpened: false - } - - # Get the configurable, external name of Doubtfire - $scope.externalName = DoubtfireConstants.ExternalName - - $scope.saveData = { - id: $scope.unit.id, - toPeriod: null, - startDate: null, - endDate: null - } - - # get the teaching periods- gets an object with the loaded teaching periods - newTeachingPeriodService.cache.values.subscribe( - (periods) -> - $scope.teachingPeriodValues = [{value: undefined, text: "None"}] - other = periods.filter((tp) -> tp.endDate > Date.now()).map((p) -> {value: p, text: "#{p.year} #{p.period}"}) - _.each other, (d) -> $scope.teachingPeriodValues.push(d) - - if (periods.length > 0) - $scope.saveData.toPeriod = periods[periods.length - 1] - ) - - $scope.teachingPeriodSelected = ($event) -> - $scope.saveData.toPeriod = $event - - # Datepicker opener - $scope.open = ($event, pickerData) -> - $event.preventDefault() - $event.stopPropagation() - - if pickerData == 'start' - $scope.calOptions.startOpened = ! $scope.calOptions.startOpened - $scope.calOptions.endOpened = false - else - $scope.calOptions.startOpened = false - $scope.calOptions.endOpened = ! $scope.calOptions.endOpened - - $scope.dateOptions = { - formatYear: 'yy', - startingDay: 1 - } - - $scope.saveUnit = -> - if $scope.saveData.toPeriod - body = { - teaching_period_id: $scope.saveData.toPeriod.id - } - else - body = { - start_date: $scope.saveData.startDate - end_date: $scope.saeData.endDate - } - $scope.unit.rolloverTo(body).subscribe({ - next: (response) -> - alertService.success( "Unit created.", 2000) - $state.go("units/admin", {unitId: response.id}) - error: (response) -> - alertService.error "Error creating unit - #{response}" - - }) - - - -) diff --git a/src/app/units/states/rollover/directives/unit-dates-selector/unit-dates-selector.tpl.html b/src/app/units/states/rollover/directives/unit-dates-selector/unit-dates-selector.tpl.html deleted file mode 100644 index 08d6a1a87d..0000000000 --- a/src/app/units/states/rollover/directives/unit-dates-selector/unit-dates-selector.tpl.html +++ /dev/null @@ -1,65 +0,0 @@ -
-
-
-

Create Unit

- Duplicate an existing unit by specifying the time period. -
-
-
-
- -
- -
-
- -
- -
-
- - - - - -
-
-
- -
- -
-
- - - - - -
-
-
-
- -
-
-
diff --git a/src/app/units/states/rollover/rollover.coffee b/src/app/units/states/rollover/rollover.coffee deleted file mode 100644 index 0bbeff1152..0000000000 --- a/src/app/units/states/rollover/rollover.coffee +++ /dev/null @@ -1,40 +0,0 @@ -angular.module('doubtfire.units.states.rollover', [ - 'doubtfire.units.states.rollover.directives' -]) - -.config(($stateProvider) -> - $stateProvider.state 'units/rollover', { - parent: 'units/index' - url: '/rollover' - controller: 'RolloverUnitState' - templateUrl: 'units/states/rollover/rollover.tpl.html' - data: - task: 'Unit Rollover' - pageTitle: "_Unit Rollover_" - roleWhitelist: ['Convenor', 'Admin'] - } -) -.controller("RolloverUnitState", ($scope, $state, $stateParams, newUserService, alertService, newUnitService, GlobalStateService) -> - unitId = +$stateParams.unitId - return $state.go('home') unless unitId - - GlobalStateService.onLoad () -> - # Load assessing unit role - $scope.unitRole = GlobalStateService.loadedUnitRoles.currentValues.find((unitRole) -> unitRole.unit.id == unitId) - - if (! $scope.unitRole?) && ( newUserService.currentUser.role == "Admin" ) - $scope.unitRole = newUserService.adminRoleFor(unitId, newUserService.currentUser) - - # Go home if no unit role was found - return $state.go('home') unless $scope.unitRole? - - GlobalStateService.setView("UNIT", $scope.unitRole) - - newUnitService.get(unitId).subscribe({ - next: (unit)-> $scope.unit = unit - error: (err)-> - alertService.error( "Error loading unit: " + err, 8000) - setTimeout((()-> $state.go('home')), 5000) - }) - -) diff --git a/src/app/units/states/rollover/rollover.component.html b/src/app/units/states/rollover/rollover.component.html new file mode 100644 index 0000000000..57aa93020c --- /dev/null +++ b/src/app/units/states/rollover/rollover.component.html @@ -0,0 +1,67 @@ + + + + Copy {{ unit?.code }} {{ unit?.nameAndPeriod }} + + + +

+ Duplicate this unit by copying it to the indicated teaching period, or a custom start and + end date. +

+ +
+ + Teaching Period + + Custom Date + @for (period of teachingPeriods; track period) { + {{ period.year }} {{ period.period }} + } + + + +
+ @if (!teachingPeriod) { + + Start Date + + DD/MM/YYYY + + + + + End Date + + DD/MM/YYYY + + + + } @else { + + {{ teachingPeriod.name }} Start Date + + + + {{ teachingPeriod.name }} End Date + + + } +
+
+ + + +
+
+
diff --git a/src/app/units/states/rollover/rollover.component.scss b/src/app/units/states/rollover/rollover.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/units/states/rollover/rollover.component.ts b/src/app/units/states/rollover/rollover.component.ts new file mode 100644 index 0000000000..a9c255c5bd --- /dev/null +++ b/src/app/units/states/rollover/rollover.component.ts @@ -0,0 +1,86 @@ +import {ChangeDetectionStrategy, Component, Input, OnInit} from '@angular/core'; +import {ActivatedRoute, Router} from '@angular/router'; +import {TeachingPeriod} from 'src/app/api/models/teaching-period'; +import {Unit} from 'src/app/api/models/unit'; +import {TeachingPeriodService} from 'src/app/api/services/teaching-period.service'; +import {UnitService} from 'src/app/api/services/unit.service'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {GlobalStateService, ViewType} from 'src/app/projects/states/index/global-state.service'; + +@Component({ + selector: 'f-rollover', + templateUrl: './rollover.component.html', + styleUrl: './rollover.component.scss', + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class RolloverComponent implements OnInit { + @Input() unitId: number; + + public unit: Unit; + + public teachingPeriods: TeachingPeriod[] = []; + + public teachingPeriod: TeachingPeriod; + + public newStartDate: Date; + public newEndDate: Date; + + constructor( + private globalStateService: GlobalStateService, + private unitService: UnitService, + private alertService: AlertService, + private router: Router, + private route: ActivatedRoute, + private teachingPeriodService: TeachingPeriodService, + ) {} + ngOnInit(): void { + this.unitId = this.unitId ?? Number(this.route.parent?.snapshot.paramMap.get('unitId')); + this.globalStateService.onLoad(() => { + this.unitService.get(this.unitId).subscribe({ + next: (unit) => { + this.unit = unit; + this.globalStateService.setView(ViewType.UNIT, unit); + setTimeout(() => { + this.initUnit(); + }); + }, + error: (error) => { + this.alertService.error(`Failed to load unit: ${error}`, 6000); + this.router.navigateByUrl('/home'); + }, + }); + }); + } + + initUnit() { + this.teachingPeriodService.cache.values.subscribe((periods) => { + this.teachingPeriods = periods; + this.teachingPeriods = periods.filter((p) => p.endDate.getTime() > Date.now()); + if (this.teachingPeriods.length) { + this.teachingPeriod = this.teachingPeriods[this.teachingPeriods.length - 1]; + } + }); + } + + createUnit() { + const body = this.teachingPeriod + ? {teaching_period_id: this.teachingPeriod.id} + : {start_date: this.newStartDate, end_date: this.newEndDate}; + + this.unit.rolloverTo(body).subscribe({ + next: (response) => { + this.alertService.success(`Unit created`, 2000); + if (this.route.parent?.snapshot.data.unit) { + this.router.navigate(['/units', response.id, 'admin']); + return; + } + + this.router.navigate(['/units', response.id, 'admin']); + }, + error: (error) => { + this.alertService.error(`Error creating unit: ${error}`, 6000); + }, + }); + } +} diff --git a/src/app/units/states/rollover/rollover.tpl.html b/src/app/units/states/rollover/rollover.tpl.html deleted file mode 100644 index aa2a80abe2..0000000000 --- a/src/app/units/states/rollover/rollover.tpl.html +++ /dev/null @@ -1,3 +0,0 @@ -
- -
diff --git a/src/app/units/states/states.coffee b/src/app/units/states/states.coffee deleted file mode 100644 index 7d68872f4a..0000000000 --- a/src/app/units/states/states.coffee +++ /dev/null @@ -1,11 +0,0 @@ -angular.module('doubtfire.units.states', [ - # 'doubtfire.units.states.all' - 'doubtfire.units.states.index' - 'doubtfire.units.states.edit' - 'doubtfire.units.states.tasks' - 'doubtfire.units.states.groups' - 'doubtfire.units.states.students' - 'doubtfire.units.states.analytics' - 'doubtfire.units.states.portfolios' - 'doubtfire.units.states.rollover' -]) diff --git a/src/app/units/states/students-list/students-list.coffee b/src/app/units/states/students-list/students-list.coffee deleted file mode 100644 index 7d2f363e1c..0000000000 --- a/src/app/units/states/students-list/students-list.coffee +++ /dev/null @@ -1,123 +0,0 @@ -angular.module('doubtfire.units.states.students', []) -# -# State for convenors and tutors to view students -# -.config(($stateProvider) -> - $stateProvider.state 'units/students/list', { - parent: 'units/index' - url: '/students' - templateUrl: "units/states/students-list/students-list.tpl.html" - controller: "UnitStudentsStateCtrl" - data: - task: "Student List" - pageTitle: "_Home_" - roleWhitelist: ['Tutor', 'Convenor', 'Admin', 'Auditor'] - } -) -.controller("UnitStudentsStateCtrl", ($scope, $state, $filter, $timeout, UnitStudentEnrolmentModal, alertService, newTaskService, gradeService, analyticsService, newUserService) -> - # Filtering - applyFilters = -> - filteredProjects = $filter('showStudents')($scope.unit.students, $scope.staffFilter, $scope.tutor) - # At this point know the length of all students - allStudentsLength = filteredProjects.length - # Apply filter for projects and determine to show CSV button - filteredProjects = $filter('projectFilter')(filteredProjects, $scope.searchText) if $scope.searchText?.trim().length > 0 - # Paginate and sort - $scope.filteredProjects = $filter('paginateAndSort')(filteredProjects, $scope.pagination, $scope.tableSort) - - # Pagination values - $scope.pagination = - currentPage: 1 - maxSize: 15 - pageSize: 15 - totalSize: null - show: false - onChange: applyFilters - - # Initial sort orders - $scope.tableSort = - order: 'name' - reverse: false - - # Staff filter options (convenor should see all) - $scope.staffFilter = { - Convenor: 'all', - Tutor: 'mine' - }[$scope.unitRole.role] - - # Scope for student name - $scope.tutor = newUserService.currentUser - - # Send initial apply filter - applyFilters() - - # Table sorting - $scope.sortTableBy = (column) -> - if column == 'flags' - $scope.showSearchOptions = true - $timeout(-> - document.querySelector('#students-list .panel-body.search-options .form-group.flag-sort button:first-child').focus() - , 500) - $scope.tableSort.reverse = !$scope.tableSort.reverse if $scope.tableSort.order == column - return - $scope.tableSort.order = column - $scope.tableSort.reverse = !$scope.tableSort.reverse - applyFilters() - - # Changing staff filter reapplies filter - $scope.staffFilterChanged = (newFilter) -> - $scope.staffFilter = newFilter - applyFilters() - - # Changing search text reapplies filter - $scope.searchTextChanged = applyFilters - - # CSV header func - $scope.getCSVHeader = -> - result = ['username', 'name', 'email', 'portfolio'] - if $scope.unit.tutorialStreamsCache.size > 0 - _.each $scope.unit.tutorialStreams, (ts) -> - result.push ts.abbreviation - else - result.push 'tutorial' - result - - # CSV data row func - $scope.getCSVData = -> - filteredProjects = $filter('filter')($filter('showStudents')($scope.unit.students, $scope.staffFilter, $scope.tutor), $scope.searchText) - result = [] - angular.forEach(filteredProjects, (project) -> - row = {} - row['username'] = project.student.username - row['name'] = project.student.name - row['email'] = project.student.email - row['portfolio'] = project.portfolioStatus - if $scope.unit.tutorialStreamsCache.size > 0 - _.each $scope.unit.tutorialStreams, (ts) -> - row[ts.abbreviation] = project.tutorialForStream(ts)?.abbreviation || '' - else - row['tutorial'] = project.tutorials[0]?.abbreviation || '' - result.push row - ) - result - - # Expose the status labels and classes for the bar stats - $scope.statusClass = newTaskService.statusClass - $scope.statusText = newTaskService.statusText - - # View a student - $scope.viewStudent = (student) -> - $state.go("projects/dashboard", {projectId: student.id, tutor: true, taskAbbr:''}) - - # Sets the flag sorting - $scope.sortTableByFlag = (flag) -> - if $scope.tableSort.order == flag - $scope.tableSort.reverse = !$scope.tableSort.reverse - else - $scope.tableSort.order = flag - - # Shows the enrolment modal - $scope.showEnrolModal = -> - analyticsService.event 'Teacher View - Students Tab', 'Enrol Student' - UnitStudentEnrolmentModal.show $scope.unit -) diff --git a/src/app/units/states/students-list/students-list.component.html b/src/app/units/states/students-list/students-list.component.html new file mode 100644 index 0000000000..f063ca56d2 --- /dev/null +++ b/src/app/units/states/students-list/students-list.component.html @@ -0,0 +1,222 @@ + +
+
+

Students

+

Browse progress, filter tutorial groups, and manage enrolments.

+
+ + + Search for students or tutors... + + + @for (suggestion of filteredSuggestions; track suggestion) { + {{ suggestion }} + } + + +
+ +
+ + All Tutorials + My Tutorials + +
+ + @if (loadingStudents) { +
+
+ @for (width of ['4%', '10%', '16%', '28%', '8%', '8%', '8%', '10%', '10%']; track $index) { + + } +
+ + @for (row of [0, 1, 2, 3, 4, 5, 6, 7]; track row) { +
+ + + + + @for (column of [0, 1, 2, 3, 4]; track column) { + + } +
+ } +
+ } + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Username + {{ project.student?.username || 'N/A' }} + Name + {{ project.student?.name || 'N/A' }} + Stats +
+ @for (bar of project.taskStats; track bar.key) { +
+ @if (bar.key === 'not_started') { + {{ bar.value }}% + } + @if (bar.key === 'complete') { + {{ bar.value }}% + } +
+ } +
+
Target Grade + + Portfolio + @if (project.hasPortfolio) { + menu_book + } + Similarity + @if (project.similarityFlag) { + visibility + } + Campus +
+ +
+
Tutorial +
+ +
+
+ No students were found using the filters specified. +
+
+ +
+
+ + +
+ + +
+
diff --git a/src/app/units/states/students-list/students-list.component.spec.ts b/src/app/units/states/students-list/students-list.component.spec.ts new file mode 100644 index 0000000000..6acd328e1a --- /dev/null +++ b/src/app/units/states/students-list/students-list.component.spec.ts @@ -0,0 +1,104 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {MatPaginator} from '@angular/material/paginator'; +import {ActivatedRoute, Router} from '@angular/router'; +import {BehaviorSubject, of} from 'rxjs'; +import {Project} from 'src/app/api/models/project'; +import {Unit} from 'src/app/api/models/unit'; +import {ProjectService} from 'src/app/api/services/project.service'; +import {TaskService} from 'src/app/api/services/task.service'; +import {UserService} from 'src/app/api/services/user.service'; +import {UnitStudentEnrolmentModalService} from '../../modals/unit-student-enrolment-modal/unit-student-enrolment-modal.service'; +import {UnitRootStateComponent} from '../../unit-root-state.component'; +import {StudentsListComponent} from './students-list.component'; + +function studentStub(name: string): Project { + return { + student: {name, username: name.toLowerCase()}, + hasTutor: () => true, + matches: () => true, + } as unknown as Project; +} + +function unitStub(id: number, studentNames: string[]): Unit { + const students = studentNames.map(studentStub); + + return { + id, + myRole: 'Convenor', + students, + studentFilterTypeAheadData: studentNames, + studentCache: {values: of(students)}, + } as unknown as Unit; +} + +describe('StudentsListComponent', () => { + const unitA = unitStub(1, ['Ana Amos']); + const unitB = unitStub(2, ['Bo Barnes']); + let routeData: BehaviorSubject<{unit: Unit}>; + + beforeEach(async () => { + routeData = new BehaviorSubject<{unit: Unit}>({unit: unitA}); + + await TestBed.configureTestingModule({ + declarations: [UnitRootStateComponent, StudentsListComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: {data: routeData, parent: {snapshot: {data: {}}}}, + }, + {provide: Router, useValue: {}}, + {provide: UserService, useValue: {currentUser: {id: 1}}}, + { + provide: TaskService, + useValue: {statusColors: new Map(), statusLabels: new Map()}, + }, + {provide: ProjectService, useValue: {loadStudents: () => of([])}}, + {provide: UnitStudentEnrolmentModalService, useValue: {}}, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(UnitRootStateComponent, {set: {template: ''}}) + .overrideComponent(StudentsListComponent, {set: {template: ''}}) + .compileComponents(); + }); + + it('shows the students of the unit the route resolved, and follows it to another unit', () => { + const root = TestBed.createComponent(UnitRootStateComponent).componentInstance; + root.ngOnInit(); + + const component = TestBed.createComponent(StudentsListComponent).componentInstance; + component.paginator = {firstPage: () => {}} as unknown as MatPaginator; + + // The outlet hands the child its stream before the child runs ngOnInit. + root.onActivate(component); + component.ngOnInit(); + + expect(component.unit.id).toBe(unitA.id); + expect(component.dataSource.data.map((project) => project.student.name)).toEqual(['Ana Amos']); + + routeData.next({unit: unitB}); + + expect(component.unit.id).toBe(unitB.id); + expect(component.dataSource.data.map((project) => project.student.name)).toEqual(['Bo Barnes']); + }); + + it('does not rebuild the list when the route resolves the same unit again', () => { + const root = TestBed.createComponent(UnitRootStateComponent).componentInstance; + root.ngOnInit(); + + const component = TestBed.createComponent(StudentsListComponent).componentInstance; + component.paginator = {firstPage: () => {}} as unknown as MatPaginator; + + root.onActivate(component); + component.ngOnInit(); + + const firstData = component.dataSource.data; + + routeData.next({unit: unitStub(unitA.id, ['Ana Amos'])}); + + expect(component.unit.id).toBe(unitA.id); + expect(component.dataSource.data).toBe(firstData); + }); +}); diff --git a/src/app/units/states/students-list/students-list.component.ts b/src/app/units/states/students-list/students-list.component.ts new file mode 100644 index 0000000000..5459ad7fd2 --- /dev/null +++ b/src/app/units/states/students-list/students-list.component.ts @@ -0,0 +1,292 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + Input, + OnDestroy, + OnInit, + ViewChild, +} from '@angular/core'; +import {MatPaginator} from '@angular/material/paginator'; +import {MatSort, Sort} from '@angular/material/sort'; +import {MatTableDataSource} from '@angular/material/table'; +import {ActivatedRoute, Router} from '@angular/router'; +import {Observable, Subscription, distinctUntilChanged, finalize, first, of} from 'rxjs'; +import { + Project, + ProjectService, + TaskService, + TaskStatusEnum, + Unit, + UserService, +} from 'src/app/api/models/doubtfire-model'; +import {UnitStudentEnrolmentModalService} from '../../modals/unit-student-enrolment-modal/unit-student-enrolment-modal.service'; + +// State for both convenors and tutors to access student list +@Component({ + selector: 'f-students-list', + templateUrl: './students-list.component.html', + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class StudentsListComponent implements OnInit, AfterViewInit, OnDestroy { + @Input() unit$: Observable; + + @ViewChild(MatSort) sort: MatSort; + @ViewChild(MatPaginator) paginator: MatPaginator; + + displayedColumns: string[] = [ + 'avatar', + 'username', + 'name', + 'stats', + 'grade', + 'portfolio', + 'similarity', + 'campus', + 'tutorial', + ]; + dataSource: MatTableDataSource = new MatTableDataSource([]); + + searchText = ''; + staffFilter: 'all' | 'mine' = 'all'; + filteredSuggestions: string[] = []; + loadingStudents = true; + unit: Unit; + + private subscriptions: Subscription[] = []; + private studentCacheSub?: Subscription; + public sortState: Sort = {active: 'name', direction: 'asc'}; + + constructor( + private enrolModal: UnitStudentEnrolmentModalService, + private router: Router, + private route: ActivatedRoute, + private userService: UserService, + private taskService: TaskService, + private projectService: ProjectService, + ) {} + + ngOnInit(): void { + this.unit$ = this.unit$ ?? of(this.route.parent.snapshot.data.unit); + this.subscriptions.push( + this.unit$?.pipe(distinctUntilChanged((a, b) => a?.id === b?.id)).subscribe((unit) => { + if (!unit) { + this.loadingStudents = false; + return; + } + + this.loadingStudents = true; + this.unit = unit; + this.staffFilter = unit.myRole === 'Tutor' ? 'mine' : 'all'; + + this.studentCacheSub?.unsubscribe(); + this.studentCacheSub = this.unit.studentCache.values.subscribe(() => { + this.updateSuggestions(); + this.updateDataSource(); + }); + + this.updateSuggestions(); + this.updateDataSource(); + this.projectService + .loadStudents(this.unit) + .pipe( + first(), + finalize(() => { + this.loadingStudents = false; + }), + ) + .subscribe(); + }), + ); + } + + ngAfterViewInit(): void { + this.dataSource.paginator = this.paginator; + this.updateDataSource(); + } + + ngOnDestroy(): void { + this.studentCacheSub?.unsubscribe(); + this.subscriptions.forEach((subscription) => subscription.unsubscribe()); + } + + public onSearchChange(): void { + this.updateSuggestions(); + this.updateDataSource(true); + } + + public setStaffFilter(filter: 'all' | 'mine'): void { + this.staffFilter = filter; + this.updateDataSource(true); + } + + public sortTableData(sort: Sort): void { + if (!sort.active || sort.direction === '') { + this.sortState = {active: 'name', direction: 'asc'}; + } else { + this.sortState = sort; + } + + this.updateDataSource(); + } + + public viewStudent(project: Project): void { + this.router.navigate(['/projects', project.id, 'dashboard']); + } + + public showEnrolModal(): void { + this.enrolModal.show(this.unit); + } + + public exportCsv(): void { + const rows = [ + this.csvHeader(), + ...this.filteredProjects().map((project) => this.csvRow(project)), + ]; + const csvContent = rows + .map((row) => row.map((value) => this.csvEscape(value)).join(',')) + .join('\n'); + const blob = new Blob([csvContent], {type: 'text/csv;charset=utf-8;'}); + const link = document.createElement('a'); + + link.href = URL.createObjectURL(blob); + link.download = 'student-project-export.csv'; + link.click(); + + URL.revokeObjectURL(link.href); + } + + public statusColor(status: TaskStatusEnum): string { + return this.taskService.statusColors.get(status) || '#cbd5e1'; + } + + public statusLabel(status: TaskStatusEnum): string { + return this.taskService.statusLabels.get(status) || status; + } + + public showBarLabel(value: number): boolean { + return Number.isFinite(value) && value >= 10; + } + + private updateSuggestions(): void { + const searchValue = this.searchText.trim().toLowerCase(); + const suggestions = Array.from(new Set(this.unit?.studentFilterTypeAheadData ?? [])); + + this.filteredSuggestions = suggestions + .filter((item) => !searchValue || item.toLowerCase().includes(searchValue)) + .slice(0, 8); + } + + private updateDataSource(resetPagination: boolean = false): void { + if (!this.paginator) { + return; + } + + const students = this.filteredProjects(); + + this.dataSource.data = students; + + if (resetPagination) { + this.paginator?.firstPage(); + } + } + + private filteredProjects(): Project[] { + const searchValue = this.searchText.trim().toLowerCase(); + const currentUser = this.userService.currentUser; + + return [...(this.unit?.students ?? [])] + .filter((project) => (this.staffFilter === 'mine' ? project.hasTutor(currentUser) : true)) + .filter((project) => (searchValue ? this.matchesSearch(project, searchValue) : true)) + .sort((a, b) => this.compareProjects(a, b)); + } + + private matchesSearch(project: Project, searchValue: string): boolean { + return ( + project.matches(searchValue) || project.student.username?.toLowerCase().includes(searchValue) + ); + } + + private compareProjects(a: Project, b: Project): number { + const direction = this.sortState.direction === 'desc' ? -1 : 1; + const aValue = this.sortValue(a, this.sortState.active); + const bValue = this.sortValue(b, this.sortState.active); + + if (aValue === bValue) { + return 0; + } + + if (aValue == null) { + return -1 * direction; + } + + if (bValue == null) { + return 1 * direction; + } + + return aValue < bValue ? -1 * direction : 1 * direction; + } + + private sortValue(project: Project, active: string): number | string { + switch (active) { + case 'username': + return project.student.username?.toLowerCase() || ''; + case 'name': + return project.student.name?.toLowerCase() || ''; + case 'stats': + return project.orderScale ?? 0; + case 'grade': + return project.targetGrade ?? -1; + case 'portfolio': + return project.portfolioStatus ?? -1; + case 'similarity': + return project.similarityFlag ? 1 : 0; + case 'campus': + return project.campus?.name?.toLowerCase() || ''; + case 'tutorial': + return project.shortTutorialDescription().toLowerCase(); + default: + return project.student.name?.toLowerCase() || ''; + } + } + + private csvHeader(): string[] { + const result = ['username', 'name', 'email', 'portfolio']; + + if (this.unit.tutorialStreamsCache.size > 0) { + this.unit.tutorialStreams.forEach((stream) => result.push(stream.abbreviation)); + } else { + result.push('tutorial'); + } + + return result; + } + + private csvRow(project: Project): string[] { + const row = [ + project.student.username || '', + project.student.name || '', + project.student.email || '', + String(project.portfolioStatus ?? ''), + ]; + + if (this.unit.tutorialStreamsCache.size > 0) { + this.unit.tutorialStreams.forEach((stream) => { + row.push(project.tutorialForStream(stream)?.abbreviation || ''); + }); + } else { + row.push(project.tutorials[0]?.abbreviation || ''); + } + + return row; + } + + private csvEscape(value: string): string { + const normalized = String(value ?? ''); + if (/[",\n]/.test(normalized)) { + return `"${normalized.replace(/"/g, '""')}"`; + } + return normalized; + } +} diff --git a/src/app/units/states/students-list/students-list.scss b/src/app/units/states/students-list/students-list.scss index 034083aea9..e69de29bb2 100644 --- a/src/app/units/states/students-list/students-list.scss +++ b/src/app/units/states/students-list/students-list.scss @@ -1,80 +0,0 @@ -#students-list { - // Flag icons - $flag-icon-width: 35px; - $number-of-flags: 3; - table thead th { - vertical-align: middle; - } - a.sort-by-flag-anchor { - &:hover { - text-decoration: none; - .show-link { - text-decoration: underline; - } - } - } - a.sort-by-flag-anchor + .flag-sort-options { - display: flex; - max-width: $flag-icon-width * $number-of-flags; - margin: 0.5em 0; - align-items: center; - justify-content: space-between; - .flag-sort { - i, - grade-icon { - display: inline-block; - } - } - .flag-sort > i:hover { - color: $link-hover-color; - } - .flag-sort > grade-icon:hover span { - background-color: $link-hover-color; - } - } - table { - td.flags-data { - .flags { - max-width: $flag-icon-width * $number-of-flags; - display: flex; - align-items: center; - justify-content: space-between; - } - } - td.task-progress-bar { - padding-right: 3ex; - .progress { - border-radius: 10px; - } - } - td.avatar { - text-align: right; - user-icon { - margin-right: 1ex; - width: 2.5em; - height: 2.5em; - } - } - th.avatar { - width: 05%; - } - th.username { - width: 10%; - } - th.name { - width: 10%; - } - th.stats { - width: 30%; - } - th.flags { - width: 10%; - } - th.tutorial { - width: 15%; - } - th.campus { - width: 10%; - } - } -} diff --git a/src/app/units/states/students-list/students-list.tpl.html b/src/app/units/states/students-list/students-list.tpl.html deleted file mode 100644 index 929cefe386..0000000000 --- a/src/app/units/states/students-list/students-list.tpl.html +++ /dev/null @@ -1,182 +0,0 @@ -
-
-
-
- - - - -
- -
-
-
- -
-
- - -
-
-
-
- -
-
- - - -
-
-

- Click the button twice to reverse the sort ordering. -

-
-
-
-
-

No students found

-

- No students were found using the filters specified. -

-
-
- - - - - - - - - - - - - - - - - - - - - - - -
- - Username - - - - Name - - - - Stats - - - - Flags - - - - - Campus - - - - Tutorial - -
- - - {{project.student.username || "N/A"}} - - {{project.student.name}} - - - - {{bar.value !== bar.value ? 'No Interaction' : (bar.value + '%')}} - - - - - - - - - - - - - - - - - - -
- -
-
diff --git a/src/app/units/states/tasks/definition/definition.coffee b/src/app/units/states/tasks/definition/definition.coffee index bd0bad8241..9dbdc249ea 100644 --- a/src/app/units/states/tasks/definition/definition.coffee +++ b/src/app/units/states/tasks/definition/definition.coffee @@ -22,6 +22,7 @@ angular.module('doubtfire.units.states.tasks.definition', [ .controller('TaskDefinitionStateCtrl', ($scope, newTaskService) -> $scope.taskData.source = newTaskService.queryTasksForTaskExplorer.bind(newTaskService) + $scope.viewType = 'explorer' $scope.taskData.taskDefMode = true $scope.showSearchOptions = true $scope.filters = { diff --git a/src/app/units/states/tasks/inbox/directives/inbox-dashboard/inbox-dashboard.component.html b/src/app/units/states/tasks/inbox/directives/inbox-dashboard/inbox-dashboard.component.html new file mode 100644 index 0000000000..a0d54a811a --- /dev/null +++ b/src/app/units/states/tasks/inbox/directives/inbox-dashboard/inbox-dashboard.component.html @@ -0,0 +1,126 @@ +
+ @if (task) { +
+ + + + + + + Similarities + @if (task.similaritiesDetected) { + warning + } + + + + + + + + + + Student Notes + @if (task.project.staffNoteCount) { + + {{ task.project.staffNoteCount }} + + } + + + + @if (canAccessTutorNotes) { + + } + + + + + + + + +
+ +
+ @switch (currentTab) { + @case (InboxDashboardTab.submission) { +
+ @if (task.hasPdf) { + + } @else { +
+ + description_off + +
+ } +
+ } + @case (InboxDashboardTab.taskSheet) { +
+ @if (task.definition.hasTaskSheet) { + + } @else { +
+ + subtitles_off + +
+ } +
+ } + @case (InboxDashboardTab.staffNotes) { +
+ +
+ } + @case (InboxDashboardTab.tutorNotes) { +
+ @if (canAccessTutorNotes) { + + } +
+ } + @case (InboxDashboardTab.similarities) { +
+ +
+ } + @case (InboxDashboardTab.overseer) { +
+ +
+ } + } +
+ } @else { +
+ subtitles_off +

Select a task to review

+
+ } +
diff --git a/src/app/units/states/tasks/inbox/directives/inbox-dashboard/inbox-dashboard.component.scss b/src/app/units/states/tasks/inbox/directives/inbox-dashboard/inbox-dashboard.component.scss new file mode 100644 index 0000000000..ef71185df4 --- /dev/null +++ b/src/app/units/states/tasks/inbox/directives/inbox-dashboard/inbox-dashboard.component.scss @@ -0,0 +1,20 @@ +:host ::ng-deep .inbox-dashboard-tabs { + .mat-mdc-tab-header { + justify-content: center; + max-width: 100%; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + } + + .mat-mdc-tab-label-container { + flex: 0 0 auto; + margin: 0 auto; + overflow: visible; + } + + .mat-mdc-tab-list, + .mat-mdc-tab-labels { + width: max-content; + } +} diff --git a/src/app/units/states/tasks/inbox/directives/inbox-dashboard/inbox-dashboard.component.ts b/src/app/units/states/tasks/inbox/directives/inbox-dashboard/inbox-dashboard.component.ts new file mode 100644 index 0000000000..f699ffef09 --- /dev/null +++ b/src/app/units/states/tasks/inbox/directives/inbox-dashboard/inbox-dashboard.component.ts @@ -0,0 +1,132 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnChanges, + Output, + SimpleChanges, +} from '@angular/core'; +import {MatTabChangeEvent} from '@angular/material/tabs'; +import {UnitRole} from 'src/app/api/models/doubtfire-model'; +import {Task} from 'src/app/api/models/task'; +import {UserService} from 'src/app/api/services/user.service'; +import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; + +enum InboxDashboardTab { + submission = 0, + taskSheet = 1, + similarities = 2, + overseer = 3, + staffNotes = 4, + tutorNotes = 5, +} + +@Component({ + selector: 'f-inbox-dashboard', + templateUrl: './inbox-dashboard.component.html', + styleUrls: ['./inbox-dashboard.component.scss'], + host: {'class': 'block h-full'}, + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class InboxDashboardComponent implements OnChanges { + @Input() task: Task; + @Output() visiblePdfUrlChange: EventEmitter = new EventEmitter(); + + public readonly InboxDashboardTab = InboxDashboardTab; + public currentTab: InboxDashboardTab = InboxDashboardTab.submission; + public currentIndex = InboxDashboardTab.submission; + + constructor( + private fileDownloader: FileDownloaderService, + private userService: UserService, + ) {} + + ngOnChanges(changes: SimpleChanges): void { + if (changes.task) { + this.selectDefaultTab(); + } + } + + onTabChange(event: MatTabChangeEvent): void { + this.setSelectedTab(event.index as InboxDashboardTab); + } + + downloadSubmission(): void { + if (!this.task?.hasPdf) { + return; + } + + this.fileDownloader.downloadFile(this.task.submissionUrl(true), 'submission.pdf'); + } + + downloadSubmittedFiles(): void { + if (!this.task) { + return; + } + + this.fileDownloader.downloadFile(this.task.submittedFilesUrl(), 'submitted-files.zip'); + } + + public get overseerEnabled(): boolean { + return this.task?.overseerEnabled ?? false; + } + + private selectDefaultTab(): void { + if (this.task) { + this.setSelectedTab(InboxDashboardTab.submission); + } else { + this.currentTab = InboxDashboardTab.submission; + this.currentIndex = InboxDashboardTab.submission; + this.visiblePdfUrlChange.emit(null); + } + } + + private setSelectedTab(tab: InboxDashboardTab): void { + this.currentTab = tab; + this.currentIndex = tab; + this.visiblePdfUrlChange.emit(this.pdfUrlForTab(tab)); + } + + private pdfUrlForTab(tab: InboxDashboardTab): string { + if (!this.task) { + return null; + } + + switch (tab) { + case InboxDashboardTab.submission: + return this.task.hasPdf ? this.task.submissionUrl() : null; + case InboxDashboardTab.taskSheet: + return this.task.definition?.hasTaskSheet ? this.task.definition.getTaskPDFUrl() : null; + default: + return null; + } + } + + public get currentUnitRole(): UnitRole | undefined { + const currentUser = this.userService.currentUser; + return this.task.unit.staff.find((ur) => ur.user.id === currentUser.id); + } + + public get canAccessTutorNotes(): boolean { + const tutor = this.task.tutor; + if (!tutor) { + return false; + } + + if (!this.currentUnitRole) { + return false; + } + + // Ensure the unit is mapped correctly to access the mentor + tutor.unit = this.task.unit; + + const canAccess = + this.currentUnitRole.role === 'Convenor' || + this.currentUnitRole.role === 'Admin' || + (tutor.mentor && tutor.mentor.id === this.currentUnitRole.id); + + return canAccess; + } +} diff --git a/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.component.html b/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.component.html new file mode 100644 index 0000000000..1b680fae11 --- /dev/null +++ b/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.component.html @@ -0,0 +1,43 @@ +

+
+
+ @if (action === 'show_less') { + thumb_up + } @else if (action === 'show_more') { + flag + } @else if (action === 'dismiss_ok') { + check_circle + } @else if (action === 'dismiss_good') { + task_alt + } @else if (action === 'overturn') { + gavel + } @else if (action === 'upheld') { + verified + } +
+
+
{{ title }}
+ + Tutor: {{ task.tutor.user.name }} +
+
+

+ +

+ {{ description }} +

+ @if (showDismissAll) { +

+ + Apply this action to all {{ task.definition.abbreviation }} {{ task.definition.name }} tasks + from {{ task.tutor?.user.name ?? 'N/A' }}, currently waiting for moderation + +

+ } +
+ + + + diff --git a/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.component.scss b/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.component.ts b/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.component.ts new file mode 100644 index 0000000000..abfd42c886 --- /dev/null +++ b/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.component.ts @@ -0,0 +1,50 @@ +import {ChangeDetectionStrategy, Component, Inject, OnInit} from '@angular/core'; +import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; +import {FeedbackModerationActionType} from 'src/app/api/models/task'; +import {Task} from 'src/app/api/models/task'; +import {TaskService} from 'src/app/api/services/task.service'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {ConfirmModerationModalData} from './confirm-moderation-modal.service'; + +@Component({ + selector: 'f-confirm-moderation-modal', + templateUrl: './confirm-moderation-modal.component.html', + styleUrl: './confirm-moderation-modal.component.scss', + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class ConfirmModerationModalComponent implements OnInit { + task: Task; + title: string; + description: string; + action: FeedbackModerationActionType; + showDismissAll: boolean; + callback: (applyToAll: boolean) => void; + + dismissAllTasks: boolean = false; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: ConfirmModerationModalData, + private alerts: AlertService, + private taskService: TaskService, + ) {} + + ngOnInit() { + this.task = this.data.task; + this.title = this.data.title; + this.description = this.data.description; + this.action = this.data.action; + this.showDismissAll = this.data.showDismissAll; + this.callback = this.data.callback; + } + + public runCallback() { + this.callback(this.dismissAllTasks); + this.dismiss(); + } + + public dismiss() { + this.dialogRef.close(); + } +} diff --git a/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.service.ts b/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.service.ts new file mode 100644 index 0000000000..f1fb96e002 --- /dev/null +++ b/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.service.ts @@ -0,0 +1,47 @@ +import {Injectable} from '@angular/core'; +import {MatDialog} from '@angular/material/dialog'; +import {FeedbackModerationActionType} from 'src/app/api/models/task'; +import {Task} from 'src/app/api/models/task'; +import {ConfirmModerationModalComponent} from './confirm-moderation-modal.component'; + +export interface ConfirmModerationModalData { + task: Task; + title: string; + description: string; + action: FeedbackModerationActionType; + showDismissAll: boolean; + callback: (applyToAll: boolean) => void; +} + +@Injectable({ + providedIn: 'root', +}) +export class ConfirmModerationModalService { + constructor(public dialog: MatDialog) {} + + public show( + task: Task, + title: string, + description: string, + action: FeedbackModerationActionType, + showDismissAll: boolean, + callback: (applyToAll: boolean) => void, + ) { + const _dialogRef = this.dialog.open< + ConfirmModerationModalComponent, + ConfirmModerationModalData + >(ConfirmModerationModalComponent, { + data: { + task, + title, + description, + action, + showDismissAll, + callback, + }, + position: {top: '2.5%'}, + width: '100%', + maxWidth: '650px', + }); + } +} diff --git a/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.html b/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.html new file mode 100644 index 0000000000..3862dc4a8b --- /dev/null +++ b/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.html @@ -0,0 +1,75 @@ + + + @if (task.moderationType === 'random_sample' || task.moderationType === 'first_feedback') { + + + + + } @else if (task.moderationType === 'escalation') { + + + + } + diff --git a/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.scss b/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.scss new file mode 100644 index 0000000000..168ca35fb9 --- /dev/null +++ b/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.scss @@ -0,0 +1,27 @@ +.center-buttons { + text-align: center; + + .button { + // margin: 0 6px; + transform: scale(0.8); + border-radius: 100%; + } + + .large-button { + transform: scale(1.15); + background-color: white; + } + + .large-button:hover { + background-color: #f5f5f5; + } + + .extra-large-button { + padding-top: 7px; + transform: scale(1.25); + } + + .extra-large-button:hover { + filter: brightness(0.85); + } +} diff --git a/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.ts b/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.ts new file mode 100644 index 0000000000..c1ae2b0225 --- /dev/null +++ b/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.ts @@ -0,0 +1,133 @@ +import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; +import {Router} from '@angular/router'; +import {FeedbackModerationActionType, Task} from 'src/app/api/models/task'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {ConfirmModerationModalService} from './confirm-moderation-modal/confirm-moderation-modal.service'; + +@Component({ + selector: 'f-moderation', + templateUrl: './moderation.component.html', + styleUrl: './moderation.component.scss', + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class ModerationComponent { + @Input() task: Task; + + public moderated: Map = new Map(); + + constructor( + private alertService: AlertService, + private confirmModerationModal: ConfirmModerationModalService, + private router: Router, + ) {} + + overturn() { + this.confirmModerationModal.show( + this.task, + 'Overturn', + `The original feedback will be revised and the task outcome updated. + Provide clear, constructive feedback explaining the changes made, and record any guidance for the tutor in the moderation notes section.`, + 'overturn', + false, + () => { + this.moderateTask('overturn'); + }, + ); + } + + upheld() { + this.confirmModerationModal.show( + this.task, + 'Upheld', + `After reviewing the task and feedback, you are satisfied that the original marking and comments are appropriate and align with the assessment criteria. + No changes will be made. + Ensure any concerns raised by the student have been considered and addressed where necessary.`, + 'upheld', + false, + () => { + this.moderateTask('upheld'); + }, + ); + } + + showMore() { + this.confirmModerationModal.show( + this.task, + 'Show more from this tutor', + `There were concerns with feedback and you would like to see more tasks from this tutor. You will review this task again if further feedback is provided.`, + 'show_more', + false, + () => { + this.moderateTask('show_more'); + }, + ); + } + + showLess() { + this.confirmModerationModal.show( + this.task, + 'Show less from this tutor', + `The feedback provided by the tutor is satisfactory and you would like to see less of their feedback for moderation. You won't see this task again.`, + 'show_less', + true, + (applyToAll) => { + this.moderateTask('show_less', applyToAll); + }, + ); + } + + dismiss() { + this.confirmModerationModal.show( + this.task, + 'Dismiss from moderation', + 'This task will be removed from moderation and will not get a follow up. This will not affect how many tasks from this tutor are selected for moderation.', + 'dismiss_ok', + true, + (applyToAll) => { + this.moderateTask('dismiss_ok', applyToAll); + }, + ); + } + + snooze() { + this.confirmModerationModal.show( + this.task, + 'Snooze task', + 'This task will be hidden from moderation until the tutor leaves new feedback. This will not affect how many tasks from this tutor are selected for moderation.', + 'snooze', + false, + (applyToAll) => { + this.moderateTask('snooze', applyToAll); + }, + ); + } + + private moderateTask(action: FeedbackModerationActionType, applyToAll: boolean = false) { + this.task.moderateFeedback(action, applyToAll).subscribe({ + next: (_response) => { + if (applyToAll) { + this.alertService.success( + `Tasks moderated successfully. Refresh the list to view the updated queue.`, + 6000, + ); + } else { + this.alertService.success(`Task moderated successfully`, 2000); + } + + this.setModerated(this.task); + }, + error: (error) => { + this.alertService.error(`Failed to moderate task: ${error}`, 6000); + }, + }); + } + + private setModerated(task: Task) { + this.moderated.set(task, true); + } + + public isModerated(task: Task): boolean { + return this.moderated.get(task); + } +} diff --git a/src/app/units/states/tasks/inbox/directives/staff-task-list/batch-feedback-workflow-dialog/batch-feedback-workflow-dialog.component.html b/src/app/units/states/tasks/inbox/directives/staff-task-list/batch-feedback-workflow-dialog/batch-feedback-workflow-dialog.component.html new file mode 100644 index 0000000000..6413191bdd --- /dev/null +++ b/src/app/units/states/tasks/inbox/directives/staff-task-list/batch-feedback-workflow-dialog/batch-feedback-workflow-dialog.component.html @@ -0,0 +1,36 @@ +

Upload Batch Feedback Zip — {{ taskLabel }}

+ + +
+ fact_check +
+

+ Prepare a batch feedback zip for {{ taskLabel }}. +

+

+ Include a marks.csv file and a folder for each student using their username. +

+
+
+ +
+

Each username folder should contain a PDF named after the student username.

+

We expect files to follow the pattern username/username.pdf.

+
+ +
+

marks.csv Columns

+

+ Student Username | Student ID | Status | + Comment +

+
+
+ + + + + diff --git a/src/app/units/states/tasks/inbox/directives/staff-task-list/batch-feedback-workflow-dialog/batch-feedback-workflow-dialog.component.ts b/src/app/units/states/tasks/inbox/directives/staff-task-list/batch-feedback-workflow-dialog/batch-feedback-workflow-dialog.component.ts new file mode 100644 index 0000000000..3ff4d4f302 --- /dev/null +++ b/src/app/units/states/tasks/inbox/directives/staff-task-list/batch-feedback-workflow-dialog/batch-feedback-workflow-dialog.component.ts @@ -0,0 +1,38 @@ +import {ChangeDetectionStrategy, Component, Inject} from '@angular/core'; +import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; +import {TaskDefinition, Unit} from 'src/app/api/models/doubtfire-model'; + +export interface BatchFeedbackWorkflowDialogData { + unit: Unit; + taskDefinition?: TaskDefinition; + myStudentsOnly?: boolean; +} + +@Component({ + selector: 'f-batch-feedback-workflow-dialog', + templateUrl: './batch-feedback-workflow-dialog.component.html', + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class BatchFeedbackWorkflowDialogComponent { + constructor( + @Inject(MAT_DIALOG_DATA) public data: BatchFeedbackWorkflowDialogData, + private dialogRef: MatDialogRef, + ) {} + + get taskLabel(): string { + if (!this.data.taskDefinition) { + return this.data.unit.code; + } + + return `${this.data.taskDefinition.abbreviation} ${this.data.taskDefinition.name}`; + } + + continueToUpload(): void { + this.dialogRef.close({openUpload: true}); + } + + close(): void { + this.dialogRef.close(); + } +} diff --git a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.html b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.html index bb9c9ba8ce..876fa4d2c5 100644 --- a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.html +++ b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.html @@ -1,66 +1,70 @@ -
+
-
@if (collapsable) { - - } @if (!isTaskDefMode) { - - } @if (isTaskDefMode) { - + + } + @if (!isTaskDefMode) { + + } + @if (isTaskDefMode) { + } - + @if (unitRole?.role === 'Convenor') { + + } +
@@ -85,26 +103,28 @@ >
-
+
Task - All Task Definitions + All Task Definitions @for (td of unit.taskDefinitionCache.values | async; track td) { - - {{ td.abbreviation + ' - ' + td.name }} - + + {{ td.abbreviation + ' - ' + td.name }} + }
-
+
Students @for (t of studentFilter; track t) { - - {{ t.inboxDescription }} - + + {{ t.inboxDescription }} + } - +
+
+ + Tutors + + @for (g of tutorGroups; track g.label) { + + @for (t of g.options; track t.id) { + + {{ t.inboxDescription }} + + } + + } + + +
@@ -140,74 +189,155 @@ - -
+ +
-
+
-
@if (!isNarrow) { -
- -
+
+ +
} -
- hourglass_bottom -
+ @if (loading) { +
+ @for (row of skeletonRows; track row) { +
+ + +
+ + +
+ +
+ } +
+ }
done_all
- @if (filteredTasks) { - - - - @if (task) { -
+ @if (filteredTasks.length) { +
{{ filteredTasks.length }} Tasks
+ } + + -
- - -
-

{{ task.project.student.name }}

-
- {{ task.definition.abbreviation }} - - {{ task.definition.name }} -
- -
- @if (task.hasGrade()) { -
- {{ task.gradeDesc() }} -
- } - - @if (!isTaskDefMode) { -
- - - - - + +
+ + + + @if (task.pinned) { + + } + + + + +
+
+
- } -
- -
- } - + } + - - - + + + }
diff --git a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.scss b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.scss index 2b8b37d357..55dc304727 100644 --- a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.scss +++ b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.scss @@ -2,11 +2,11 @@ @use '@angular/material' as mat; // $my-palette: mat.define-palette(mat.$indigo-palette); -@import '../../../../../../../theme.scss'; -@import '../../../../../../../styles/mixins/task-list.scss'; -@import '../../../../../../../styles/mixins/scrollable.scss'; +@use 'theme' as *; +@use 'styles/mixins/task-list' as *; +@use 'styles/mixins/scrollable' as *; -$my-palette: mat.define-palette($md-formatif); +$my-palette: mat.m2-define-palette($md-formatif); :host { --background-gray: rgba(0, 0, 0, 0.04); @@ -35,6 +35,8 @@ $warn-color: #f44336; display: flex; justify-content: center; align-items: center; + // Fills the .overflow box now that it is no longer wrapped in a 40px icon button. + height: 100%; .mat-icon { color: #bcc0c4; @@ -160,6 +162,31 @@ user-icon { width: 40px; margin-right: -10px; margin-left: -10px; + // Holds its size while the pin indicator is swapped out, because the options button + // that replaces it is taken out of the flow so it can sit in the tab order unseen. + height: 40px; + position: relative; +} + +// The submission options button used to be removed from the DOM until the row was +// hovered, which put it out of reach of the keyboard. It now always sits in the tab +// order, stacked over the pin indicator, and is only made visible when the row is +// hovered or the button itself has focus. +.task-options { + position: absolute; + top: 0; + left: 0; + opacity: 0; + pointer-events: none; +} + +// The :focus arm is the safety net. rowActionsShown() should already have put the hover +// class on the row by the time the button is focused, but focus must never land on +// something the user cannot see, so the button reveals itself regardless. +.inbox-entry.hover .task-options, +.task-options:focus { + opacity: 1; + pointer-events: auto; } .mat-mdc-list-item { @@ -177,7 +204,7 @@ user-icon { background-color: transparent; &.active { - background-color: mat.get-color-from-palette($my-palette, 500); + background-color: mat.m2-get-color-from-palette($my-palette, 500); } &.active.similarities { diff --git a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.spec.ts b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.spec.ts index fbf1d43a79..01ee24bcec 100644 --- a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.spec.ts +++ b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.spec.ts @@ -1,63 +1,274 @@ -// import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -// import { MatDialogModule } from '@angular/material/dialog'; -// import { MatMenuModule } from '@angular/material/menu'; -// import { alertService, currentUser, groupService, taskDefinition, Unit } from 'src/app/ajs-upgraded-providers'; - -// import { StaffTaskListComponent } from './staff-task-list.component'; - -// describe('StaffTaskListComponent', () => { -// let component: StaffTaskListComponent; -// let fixture: ComponentFixture; -// let taskDefinitionStub: jasmine.SpyObj; -// let unitStub: jasmine.SpyObj; -// let currentUserStub: jasmine.SpyObj; -// let groupServiceStub: jasmine.SpyObj; -// let alertServiceStub: jasmine.SpyObj; - -// beforeEach( -// waitForAsync(() => { -// unitStub = { -// tasksForDefinition: [], -// }; -// currentUserStub = { -// profile: { name: 'Bob Marley' }, -// }; - -// TestBed.configureTestingModule({ -// declarations: [StaffTaskListComponent], -// imports: [ -// MatDialogModule, -// MatMenuModule -// ], -// providers: [ -// { provide: taskDefinition, useValue: taskDefinitionStub }, -// { provide: currentUser, useValue: currentUserStub }, -// { provide: groupService, useValue: groupServiceStub }, -// { provide: alertService, useValue: alertServiceStub }, -// ], -// }).compileComponents(); -// }) -// ); - -// beforeEach(() => { -// fixture = TestBed.createComponent(StaffTaskListComponent); -// component = fixture.componentInstance; - -// component.taskData = { -// selectedTask: null, -// }; -// component.unit = { -// tutorialsForUserName: () => [], -// tutorials: [], -// }; -// component.unitRole = { -// role: 'Convenor', -// }; - -// fixture.detectChanges(); -// }); - -// it('should create', () => { -// expect(component).toBeTruthy(); -// }); -// }); +import {HotkeysService} from '@ngneat/hotkeys'; +import {beforeEach, describe, expect, it} from 'vitest'; +import {NO_ERRORS_SCHEMA, SimpleChange} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatDialog} from '@angular/material/dialog'; +import {ActivatedRoute, Router} from '@angular/router'; +import {EMPTY, Subject, of} from 'rxjs'; +import {UserService} from 'src/app/api/models/doubtfire-model'; +import {Task} from 'src/app/api/models/task'; +import {Unit} from 'src/app/api/models/unit'; +import {UnitRole} from 'src/app/api/models/unit-role'; +import {TaskDefinitionService} from 'src/app/api/services/task-definition.service'; +import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; +import {CsvResultModalService} from 'src/app/common/modals/csv-result-modal/csv-result-modal.service'; +import {CsvUploadModalService} from 'src/app/common/modals/csv-upload-modal/csv-upload-modal.service'; +import {SidekiqProgressModalService} from 'src/app/common/modals/sidekiq-progress-modal/sidekiq-progress-modal.service'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {SelectedTaskService} from 'src/app/projects/states/dashboard/selected-task.service'; +import {StaffTaskListComponent} from './staff-task-list.component'; + +const hotkeysServiceStub = { + getHotkeys: () => [], + addShortcut: () => EMPTY, + removeShortcuts: () => {}, +}; +const emptyProvider = {}; + +function unitStub(id: number, tutorialAbbreviation: string): Unit { + return { + id, + staff: [], + tutorials: [{id: id * 10, abbreviation: tutorialAbbreviation, description: 'Lab'}], + tutorialsForUserName: () => [], + } as unknown as Unit; +} + +function unitRoleStub(id: number): UnitRole { + return {id, role: 'Convenor'} as unknown as UnitRole; +} + +describe('StaffTaskListComponent', () => { + let component: StaffTaskListComponent; + let fixture: ComponentFixture; + let sourcedUnitIds: number[]; + + beforeEach(async () => { + sourcedUnitIds = []; + + await TestBed.configureTestingModule({ + declarations: [StaffTaskListComponent], + providers: [ + {provide: SelectedTaskService, useValue: {setSelectedTask: () => {}}}, + {provide: AlertService, useValue: emptyProvider}, + {provide: FileDownloaderService, useValue: emptyProvider}, + {provide: MatDialog, useValue: emptyProvider}, + {provide: CsvUploadModalService, useValue: emptyProvider}, + {provide: CsvResultModalService, useValue: emptyProvider}, + {provide: UserService, useValue: {currentUser: {name: 'A Tutor'}}}, + {provide: HotkeysService, useValue: hotkeysServiceStub}, + {provide: Router, useValue: {navigate: () => {}}}, + {provide: ActivatedRoute, useValue: emptyProvider}, + {provide: TaskDefinitionService, useValue: emptyProvider}, + {provide: SidekiqProgressModalService, useValue: emptyProvider}, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(StaffTaskListComponent, {set: {template: ''}}) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(StaffTaskListComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + // The screen the header dropdown can actually leave stale. This ran only in task + // definition mode before, so the inbox kept the previous unit's list and tutors. + it('rebuilds its tasks and its student filter in inbox mode when the unit changes', () => { + const unitA = unitStub(1, 'LA1'); + const unitB = unitStub(2, 'LB1'); + + component.unit = unitA; + component.unitRole = unitRoleStub(11); + component.filters = {}; + component.taskData = { + source: (unit: Unit) => { + sourcedUnitIds.push(unit.id); + return of([]); + }, + selectedTask: null, + taskKey: null, + onSelectedTaskChange: () => {}, + taskDefMode: false, + }; + + component.ngOnInit(); + + expect(sourcedUnitIds).toEqual([1]); + expect(component.studentFilter.map((option) => option.abbreviation)).toContain('LA1'); + + component.unit = unitB; + component.unitRole = unitRoleStub(12); + component.filters = {}; + component.ngOnChanges({unit: new SimpleChange(unitA, unitB, false)}); + + expect(sourcedUnitIds).toEqual([1, 2]); + expect(component.studentFilter.map((option) => option.abbreviation)).toContain('LB1'); + expect(component.studentFilter.map((option) => option.abbreviation)).not.toContain('LA1'); + }); + + // The parent hands down a partial filters object on every unit change, which used to + // wipe the defaults this component installs for itself. + it('reinstates its own filter defaults when the parent replaces the filters object', () => { + const unitA = unitStub(1, 'LA1'); + const unitB = unitStub(2, 'LB1'); + + component.unit = unitA; + component.unitRole = unitRoleStub(11); + component.filters = {}; + component.taskData = { + source: () => of([]), + selectedTask: null, + taskKey: null, + onSelectedTaskChange: () => {}, + taskDefMode: false, + }; + + component.ngOnInit(); + + component.unit = unitB; + component.unitRole = unitRoleStub(12); + component.filters = {}; + component.ngOnChanges({unit: new SimpleChange(unitA, unitB, false)}); + + expect(component.filters.tutorialIdSelected).toBe('all'); + expect(component.filters.unitRoleIdSelected).toBe('all'); + }); + + it('cancels the previous task query when the unit changes', () => { + const unitA = unitStub(1, 'LA1'); + const unitB = unitStub(2, 'LB1'); + const requests: Map> = new Map(); + + component.unit = unitA; + component.unitRole = unitRoleStub(11); + component.filters = {}; + component.taskData = { + source: (unit: Unit) => { + const request: Subject = new Subject(); + requests.set(unit.id, request); + return request; + }, + selectedTask: null, + taskKey: null, + onSelectedTaskChange: () => {}, + taskDefMode: false, + }; + + component.ngOnInit(); + + component.unit = unitB; + component.unitRole = unitRoleStub(12); + component.filters = {}; + component.ngOnChanges({unit: new SimpleChange(unitA, unitB, false)}); + + requests.get(unitA.id)?.next([]); + expect(component.loading).toBe(true); + + requests.get(unitB.id)?.next([]); + expect(component.loading).toBe(false); + }); + + describe('row actions', () => { + let task: Task; + + beforeEach(() => { + task = {id: 4, hover: false, optionsOpened: false} as Task; + }); + + it('reveals the row actions when the pointer is over the row', () => { + component.showTaskActionsForPointer(task); + + expect(component.rowActionsShown(task)).toBe(true); + }); + + it('leaves the row actions alone on a device that has opted out of hover', () => { + component.allowHover = false; + + component.showTaskActionsForPointer(task); + + expect(component.rowActionsShown(task)).toBe(false); + }); + + it('reveals the row actions when the options button takes keyboard focus', () => { + component.showTaskActionsForFocus(task); + + expect(component.rowActionsShown(task)).toBe(true); + }); + + it('still reveals the row actions on focus where hover is not available', () => { + component.allowHover = false; + + component.showTaskActionsForFocus(task); + + expect(component.rowActionsShown(task)).toBe(true); + }); + + it('hides the row actions again once focus leaves', () => { + component.showTaskActionsForFocus(task); + component.hideTaskActionsForFocus(task); + + expect(component.rowActionsShown(task)).toBe(false); + }); + + it('keeps the row actions up while the overflow menu holds the focus', () => { + component.showTaskActionsForFocus(task); + task.optionsOpened = true; + component.hideTaskActionsForFocus(task); + + expect(component.rowActionsShown(task)).toBe(true); + }); + + it('holds only one row open at a time', () => { + const other = {id: 5, hover: false, optionsOpened: false} as Task; + + component.showTaskActionsForFocus(task); + component.showTaskActionsForFocus(other); + + expect(component.rowActionsShown(task)).toBe(false); + expect(component.rowActionsShown(other)).toBe(true); + }); + + it('does not let a late blur close a row another row already claimed', () => { + const other = {id: 5, hover: false, optionsOpened: false} as Task; + + component.showTaskActionsForFocus(task); + component.showTaskActionsForFocus(other); + component.hideTaskActionsForFocus(task); + + expect(component.rowActionsShown(other)).toBe(true); + }); + + // The regression this pair guards: blur used to run the same handler as mouseout, so + // tabbing off the options button faded it out from under a pointer still on the row. + it('keeps the row actions up when focus leaves but the pointer is still on the row', () => { + component.showTaskActionsForPointer(task); + component.showTaskActionsForFocus(task); + component.hideTaskActionsForFocus(task); + + expect(component.rowActionsShown(task)).toBe(true); + }); + + it('keeps the row actions up when the pointer leaves but focus is still on the button', () => { + component.showTaskActionsForPointer(task); + component.showTaskActionsForFocus(task); + component.hideTaskActions(task); + + expect(component.rowActionsShown(task)).toBe(true); + }); + + it('closes the row only once both the pointer and the keyboard have left', () => { + component.showTaskActionsForPointer(task); + component.showTaskActionsForFocus(task); + component.hideTaskActions(task); + component.hideTaskActionsForFocus(task); + + expect(component.rowActionsShown(task)).toBe(false); + }); + }); +}); diff --git a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts index 95b498af20..0c2cab54c5 100644 --- a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts +++ b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts @@ -1,71 +1,89 @@ /* eslint-disable no-shadow, @typescript-eslint/no-shadow */ - +import {HotkeysService} from '@ngneat/hotkeys'; import { + ChangeDetectionStrategy, Component, - OnInit, Input, OnChanges, + OnDestroy, + OnInit, SimpleChanges, - HostListener, - ViewChild, TemplateRef, - OnDestroy, + ViewChild, } from '@angular/core'; -import {TasksOfTaskDefinitionPipe} from 'src/app/common/filters/tasks-of-task-definition.pipe'; -import {TasksInTutorialsPipe} from 'src/app/common/filters/tasks-in-tutorials.pipe'; -import {TasksForInboxSearchPipe} from 'src/app/common/filters/tasks-for-inbox-search.pipe'; import {MatDialog} from '@angular/material/dialog'; -import {Unit} from 'src/app/api/models/unit'; -import {UnitRole} from 'src/app/api/models/unit-role'; +import {ActivatedRoute, Router} from '@angular/router'; +import {Observable, Subscription} from 'rxjs'; import { - Tutorial, - UserService, - Task, Project, + Task, TaskDefinition, + Tutorial, + UserService, } from 'src/app/api/models/doubtfire-model'; -import {Observable} from 'rxjs'; -import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; +import {SidekiqJob} from 'src/app/api/models/sidekiq-job'; +import {Unit} from 'src/app/api/models/unit'; +import {UnitRole} from 'src/app/api/models/unit-role'; +import {TaskDefinitionService} from 'src/app/api/services/task-definition.service'; import {AppInjector} from 'src/app/app-injector'; +import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; +import {TasksByTutorPipe} from 'src/app/common/filters/tasks-by-tutor.pipe'; +import {TasksForInboxSearchPipe} from 'src/app/common/filters/tasks-for-inbox-search.pipe'; +import {TasksInTutorialsPipe} from 'src/app/common/filters/tasks-in-tutorials.pipe'; +import {TasksOfTaskDefinitionPipe} from 'src/app/common/filters/tasks-of-task-definition.pipe'; +import {CsvResultModalService} from 'src/app/common/modals/csv-result-modal/csv-result-modal.service'; +import {CsvUploadModalService} from 'src/app/common/modals/csv-upload-modal/csv-upload-modal.service'; +import {SidekiqProgressModalService} from 'src/app/common/modals/sidekiq-progress-modal/sidekiq-progress-modal.service'; +import {AlertService} from 'src/app/common/services/alert.service'; import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; import {SelectedTaskService} from 'src/app/projects/states/dashboard/selected-task.service'; -import {AlertService} from 'src/app/common/services/alert.service'; -import {HotkeysService} from '@ngneat/hotkeys'; +import {BatchFeedbackWorkflowDialogComponent} from './batch-feedback-workflow-dialog/batch-feedback-workflow-dialog.component'; @Component({ selector: 'df-staff-task-list', templateUrl: './staff-task-list.component.html', styleUrls: ['./staff-task-list.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, }) export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { - @ViewChild('searchDialog') searchDialog: TemplateRef; + @ViewChild('searchDialog') searchDialog: TemplateRef; + + private taskRequestSub?: Subscription; @Input() task: Task; @Input() project: Project; @Input() taskData: { - source: (unit: Unit, taskDef: TaskDefinition | number) => Observable; - selectedTask: Task; - taskKey: string; - onSelectedTaskChange: (task: Task) => void; + source: ( + unit: Unit, + taskDef?: TaskDefinition | number, + fetchMyStudentsOnly?: boolean, + ) => Observable; + selectedTask: Task | null; + taskKey: unknown; + onSelectedTaskChange: (task: Task | null) => void; taskDefMode: boolean; }; @Input() unit: Unit; @Input() unitRole: UnitRole; - @Input() filters: { + @Input() filters: Partial<{ taskDefinition: TaskDefinition; tutorials: Tutorial[]; forceStream: boolean; studentName: string; - tutorialIdSelected: any; + tutorialIdSelected: string | number; + unitRoleIdSelected: number | string; taskDefinitionIdSelected: number | TaskDefinition; - }; + }>; @Input() showSearchOptions = true; @Input() isNarrow: boolean; + @Input() viewType: 'inbox' | 'explorer' | 'moderation' | 'overflow'; + userHasTutorials: boolean; - filteredTasks: any[] = null; + filteredTasks: Task[] = null; studentFilter: { id: number | string; @@ -75,16 +93,25 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { tutorial?: Tutorial; }[] = null; - tasks: any[] = null; + tutorGroups: { + label: string; + options: {id: string | number; inboxDescription: string | undefined}[]; + }[] = []; + + tasks: Task[] = null; - watchingTaskKey: any; + // hasJplagReport: boolean = false; + + watchingTaskKey: boolean; panelOpenState = false; loading = true; + skeletonRows = Array.from({length: 12}, (_, index) => index); definedTasksPipe = new TasksOfTaskDefinitionPipe(); tasksInTutorialsPipe = new TasksInTutorialsPipe(); taskWithStudentNamePipe = new TasksForInboxSearchPipe(); + tasksByTutorPipe = new TasksByTutorPipe(); // Let's call having a source of tasksForDefinition plus having a task definition // auto-selected with the search options open task def mode -- i.e., the mode // for selecting tasks by task definitions @@ -97,52 +124,73 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { taskDefSort = 0; tutorialSort = 0; - originalFilteredTasks: any[] = null; + originalFilteredTasks: Task[] = null; allowHover = true; + toggleTutorialSort() { + this.tutorialSort = (this.tutorialSort + 1) % this.states.length; + } + + // Track if all tasks have already been fetched + // Avoids redundant API calls when changing tutorial filters + fetchedAllTasks: boolean = false; + constructor( private selectedTaskService: SelectedTaskService, private alertService: AlertService, private fileDownloaderService: FileDownloaderService, public dialog: MatDialog, + private csvUploadModal: CsvUploadModalService, + private csvResultModal: CsvResultModalService, private userService: UserService, private hotkeys: HotkeysService, + private router: Router, + private route: ActivatedRoute, + private taskDefinitionService: TaskDefinitionService, + private sidekiqProgressModalService: SidekiqProgressModalService, ) {} ngOnChanges(changes: SimpleChanges): void { - if ( - ((changes.unit && - !changes.unit?.isFirstChange && - changes.unit.currentValue.id && - changes.unit.previousValue.id !== changes.unit.currentValue.id) || - this.tasks == null) && - this.isTaskDefMode && - this.filters - ) { - this.refreshData(); + if (changes.taskData && !changes.taskData.isFirstChange() && this.tasks?.length) { + this.setTaskDefFromTaskKey(this.taskData.taskKey); + this.syncSelectedTaskFromTaskKey(); + } + + const unitChanged = + !!changes.unit && + !changes.unit.isFirstChange() && + changes.unit.currentValue?.id && + changes.unit.previousValue?.id !== changes.unit.currentValue?.id; + + // This used to sit behind an isTaskDefMode guard, so the inbox kept the previous + // unit's students, tutors and tasks on screen after a unit switch. + if (unitChanged && this.unit && this.unitRole) { + this.initialiseForUnit(); } } + ngOnDestroy(): void { - this.hotkeys.removeShortcuts('meta.shift.arrowdown'); - this.hotkeys.removeShortcuts('meta.shift.arrowup'); + this.taskRequestSub?.unsubscribe(); + this.hotkeys.removeShortcuts('control.shift.arrowdown'); + this.hotkeys.removeShortcuts('control.shift.arrowup'); } ngOnInit(): void { const registeredHotkeys = this.hotkeys.getHotkeys().map((hotkey) => hotkey.keys); - if (!registeredHotkeys.includes('meta.shift.arrowdown')) { + if (!registeredHotkeys.includes('control.shift.arrowdown')) { this.hotkeys .addShortcut({ - keys: 'meta.shift.arrowdown', + keys: 'control.shift.arrowdown', description: 'Select next task', }) .subscribe(() => this.nextTask()); } - if (!registeredHotkeys.includes('meta.shift.arrowup')) { + if (!registeredHotkeys.includes('control.shift.arrowup')) { this.hotkeys .addShortcut({ - keys: 'meta.shift.arrowup', + keys: 'control.shift.arrowup', description: 'Select previous task', }) .subscribe(() => this.previousTask()); @@ -154,16 +202,41 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { this.allowHover = false; } + this.initialiseForUnit(); + } + + // The filter defaults, the student and tutor lists and the task query are all built + // from the routed unit, so they have to be rebuilt when it changes. The router reuses + // this component across a unit switch, so ngOnInit does not run a second time. + private initialiseForUnit(): void { + this.fetchedAllTasks = false; + // Does the current user have any tutorials? this.userHasTutorials = this.unit.tutorialsForUserName(this.userService.currentUser.name)?.length > 0; + const staff = this.unit.staff.slice(); + + const byName = (a: UnitRole, b: UnitRole) => + (a.user?.name ?? '').localeCompare(b.user?.name ?? ''); + + const mentored = staff + .filter((ur) => ur.mentorId === this.unitRole.id) + .slice() + .sort(byName); + + const allTutors = staff.slice().sort(byName); + const shouldDefaultToMyStudents = + (this.unitRole.role === 'Tutor' || this.unitRole.role === 'Convenor') && + this.userHasTutorials; + this.filters = Object.assign( { studentName: null, - tutorialIdSelected: - (this.unitRole.role === 'Tutor' || 'Convenor') && this.userHasTutorials ? 'mine' : 'all', + tutorialIdSelected: shouldDefaultToMyStudents ? 'mine' : 'all', tutorials: [], + unitRoleIdSelected: + mentored.length > 0 && this.viewType === 'moderation' ? 'mentoring_all' : 'all', taskDefinitionIdSelected: null, taskDefinition: null, forceStream: true, @@ -191,8 +264,34 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { }; }), ]; + this.tutorGroups = [ + ...(mentored.length > 0 + ? [ + { + label: 'My Tutors (Mentoring)', + options: [ + {id: 'mentoring_all', inboxDescription: 'Show All Mine'}, + ...mentored.map((ur) => ({ + id: ur.id, + inboxDescription: ur.user?.name, + })), + ], + }, + ] + : []), + { + label: 'All Tutors', + options: [ + {id: 'all', inboxDescription: 'Show All'}, + ...allTutors.map((ur) => ({ + id: ur.id, + inboxDescription: ur.user?.name, + })), + ], + }, + ]; - this.tutorialIdChanged(); + this.tutorialIdChanged(false); this.setTaskDefFromTaskKey(this.taskData.taskKey); @@ -208,28 +307,118 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { downloadSubmissionPdfs() { const taskDef = this.filters.taskDefinition; - this.fileDownloaderService.downloadFile( - `${AppInjector.get(DoubtfireConstants).API_URL}/submission/unit/${ - this.unit.id - }/task_definitions/${taskDef.id}/student_pdfs`, - `${this.unit.code}-${taskDef.abbreviation}-pdfs.zip`, - ); + this.taskDefinitionService.zipSubmissionPdfs(taskDef).subscribe({ + next: (newJob) => { + this.sidekiqProgressModalService + .show(`Downloading submission pdfs for ${taskDef.abbreviation}`, newJob.id) + .subscribe({ + next: (_job) => { + this.fileDownloaderService.downloadFile( + `${AppInjector.get(DoubtfireConstants).API_URL}/submission/unit/${ + this.unit.id + }/task_definitions/${taskDef.id}/student_pdfs`, + `${this.unit.code}-${taskDef.abbreviation}-pdfs.zip`, + ); + }, + }); + }, + error: (error) => { + this.alertService.error(error, 6000); + }, + }); } - downloadSubmissions() { + downloadSubmissionFiles() { + const taskDef = this.filters.taskDefinition; + this.taskDefinitionService.zipSubmissionFiles(taskDef).subscribe({ + next: (newJob) => { + this.sidekiqProgressModalService + .show(`Downloading submission files for ${taskDef.abbreviation}`, newJob.id) + .subscribe({ + next: (_job) => { + this.fileDownloaderService.downloadFile( + `${AppInjector.get(DoubtfireConstants).API_URL}/submission/unit/${ + this.unit.id + }/task_definitions/${taskDef.id}/download_submissions`, + `${this.unit.code}-${taskDef.abbreviation}-submissions.zip`, + ); + }, + }); + }, + error: (error) => { + this.alertService.error(error, 6000); + }, + }); + } + + openBatchFeedbackDialog() { + const taskDefinition = this.filters.taskDefinition ?? undefined; + + if (!taskDefinition) { + this.alertService.error('Select a task definition before uploading batch feedback.', 5000); + return; + } + + const dialogRef = this.dialog.open(BatchFeedbackWorkflowDialogComponent, { + width: '100%', + maxWidth: '840px', + data: { + unit: this.unit, + taskDefinition, + myStudentsOnly: this.filters.tutorialIdSelected === 'mine', + }, + }); + + dialogRef.afterClosed().subscribe((result) => { + if (!result?.openUpload) { + return; + } + + this.csvUploadModal.show( + `Upload ${taskDefinition.abbreviation} Batch Feedback Zip`, + '', + { + file: {name: 'Batch Feedback Zip', type: 'zip'}, + }, + this.unit.getBatchFeedbackUploadUrl(taskDefinition), + (response: SidekiqJob) => { + if (!response?.id) { + this.alertService.error('Batch feedback upload failed.', 6000); + return; + } + + this.sidekiqProgressModalService + .show(`Uploading ${taskDefinition.abbreviation} Batch Feedback`, response.id) + .subscribe({ + next: (job) => { + this.csvResultModal.show('Batch Feedback Upload Results', JSON.parse(job.result)); + this.refreshData(); + }, + error: (error) => { + console.error(error); + this.alertService.error('Batch feedback upload failed.', 6000); + }, + }); + }, + ); + }); + } + + downloadJPLAGReport() { const taskDef = this.filters.taskDefinition; this.fileDownloaderService.downloadFile( - `${AppInjector.get(DoubtfireConstants).API_URL}/submission/unit/${ - this.unit.id - }/task_definitions/${taskDef.id}/download_submissions`, - `${this.unit.code}-${taskDef.abbreviation}-submissions.zip`, + taskDef.getJplagReportUrl(), + `${this.unit.code}-${taskDef.abbreviation}-jplag-report.zip`, ); + + const url = this.router.serializeUrl(this.router.createUrlTree(['/jplag-report-viewer'])); + window.open(url, '_blank'); } openDialog() { const dialogRef = this.dialog.open(this.searchDialog); - dialogRef.afterClosed().subscribe((result) => {}); + dialogRef.afterClosed().subscribe(); } refreshTasks(): void { @@ -245,7 +434,17 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { this.filters.forceStream, ); } + + if (this.filters.unitRoleIdSelected) { + filteredTasks = this.tasksByTutorPipe.transform( + this.unitRole, + filteredTasks, + this.filters.unitRoleIdSelected, + ); + } + filteredTasks = this.taskWithStudentNamePipe.transform(filteredTasks, this.filters.studentName); + filteredTasks = this.sortPinnedTasksFirst(filteredTasks); this.filteredTasks = filteredTasks; if (this.filteredTasks != null) { @@ -255,38 +454,77 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { this.taskDefSort = 0; this.tutorialSort = 0; - // Fix selected task. - if (this.taskData.selectedTask && filteredTasks?.includes(this.taskData.selectedTask)) { + // Clear selected task only when the active filters hide it. + if ( + this.taskData.selectedTask && + !filteredTasks?.some((task) => task?.hasTaskKey(this.taskData.selectedTask.taskKey())) + ) { this.setSelectedTask(null); } } openTaskDefs() { // Automatically "open" the task definition select element if in task def mode - const selectEl: any = document.querySelector( + const selectEl = document.querySelector( 'select[ng-model="filters.taskDefinitionIdSelected"]', - ) as any; + ); + if (!selectEl) { + return; + } selectEl.size = 10; selectEl.focus(); } - tutorialIdChanged(): void { - const tutorialId = this.filters.tutorialIdSelected; + unitRoleIdChanged(attemptRefreshData: boolean = true): void { + this.applyFilters(); + + const isExplorerView = this.isTaskDefMode; + if (attemptRefreshData && !this.fetchedAllTasks && !isExplorerView) { + this.refreshData(); + } + } + + tutorialIdChanged( + attemptRefreshData: boolean = true, + selectedTutorialId: string | number = this.filters.tutorialIdSelected, + ): void { + this.filters.tutorialIdSelected = selectedTutorialId; + const tutorialId = selectedTutorialId; + + if (attemptRefreshData) { + this.router.navigate([], { + relativeTo: this.route, + queryParams: {students: tutorialId}, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + } - const filterOption = this.studentFilter.find((f) => f.id === tutorialId); + const filterOption = this.studentFilter.find((f) => String(f.id) === String(tutorialId)); + + if (!filterOption) { + return; + } this.filters.forceStream = filterOption.forceStream; if (tutorialId === 'mine') { this.filters.tutorials = this.unit.tutorialsForUserName(this.userService.currentUser.name); + this.filters.unitRoleIdSelected = 'all'; } else if (tutorialId === 'all') { // Ignore tutorials filter this.filters.tutorials = null; } else { this.filters.tutorials = [filterOption.tutorial]; + this.filters.unitRoleIdSelected = 'all'; } this.applyFilters(); + + const isExplorerView = this.isTaskDefMode; + if (attemptRefreshData && !this.fetchedAllTasks && !isExplorerView) { + this.refreshData(); + } } // Task definition options @@ -323,32 +561,110 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { return this.tasks.find((t) => t?.hasTaskKey(key)); } + private syncSelectedTaskFromTaskKey(): void { + if (!this.tasks?.length) { + return; + } + + if (!this.taskData.taskKey) { + this.setSelectedTask(null); + return; + } + + const task = this.findTaskForTaskKey(this.taskData.taskKey); + if (task) { + this.setSelectedTask(task); + } + } + // Callback to refresh data from the task source private refreshData() { + const fetchMyStudentsOnly = this.filters.tutorialIdSelected === 'mine'; + this.loading = true; + // A unit or filter change can start a second query before the previous one + // returns. Cancel the older query so it cannot land late and put stale tasks + // back on screen after the component has moved to the new unit. + this.taskRequestSub?.unsubscribe(); // Tasks for feedback or tasks for task, depending on the data source - this.taskData.source(this.unit, this.filters?.taskDefinitionIdSelected).subscribe({ - next: (response) => { - this.tasks = response; - this.applyFilters(); - this.loading = false; - - // Load initial set task, either the one provided (by the URL) - // then load actual task in now or the first task that applies - // to the given set of filters. - const task = this.findTaskForTaskKey(this.taskData.taskKey); - this.setSelectedTask(task); - - // For when URL has been manually changed, set the selected task - // using new array of tasks loaded from the new taskKey - if (!this.watchingTaskKey) { - this.watchingTaskKey = true; - } - }, - error: (message) => { - this.alertService.error(message, 6000); - }, - }); + this.taskRequestSub = this.taskData + .source(this.unit, this.filters?.taskDefinitionIdSelected, fetchMyStudentsOnly) + .subscribe({ + next: (response) => { + this.tasks = response; + this.applyFilters(); + this.loading = false; + + this.fetchedAllTasks = !fetchMyStudentsOnly && !this.isTaskDefMode; + + // If the URL carries a task key, load that task once the query results arrive. + this.syncSelectedTaskFromTaskKey(); + + // For when URL has been manually changed, set the selected task + // using new array of tasks loaded from the new taskKey + if (!this.watchingTaskKey) { + this.watchingTaskKey = true; + } + }, + error: (message) => { + this.alertService.error(message, 6000); + this.loading = false; + }, + }); + } + + /** + * The task whose row actions are being held open by keyboard focus, if any. Focus is + * tracked separately from task.hover so that neither path can close the other: tabbing + * away used to run the same handler as mouseout and would fade the options button out + * from under a pointer that was still sitting on the row. + */ + focusedTaskId: number | null = null; + + /** + * Reveal the row actions because the pointer is over the row. Touch devices opt out + * of hover entirely via allowHover, which is why this is not simply `true`. + */ + showTaskActionsForPointer(task: Task) { + task.hover = this.allowHover; + } + + /** + * Hide the row actions again once the pointer leaves. This is the original mouseout + * behaviour and it deliberately touches nothing the keyboard owns. + */ + hideTaskActions(task: Task) { + task.hover = task.optionsOpened; + } + + /** + * Reveal the row actions because the submission options button took keyboard focus. + * Unlike the pointer path this always applies, since a keyboard is usable on a touch + * device even when hover is not. + */ + showTaskActionsForFocus(task: Task) { + this.focusedTaskId = task.id; + } + + /** + * Release the keyboard's hold on the row. Another row may already have claimed focus + * by the time this runs, so only the row that took it can give it back. + */ + hideTaskActionsForFocus(task: Task) { + if (this.focusedTaskId === task.id) { + this.focusedTaskId = null; + } + } + + /** + * Whether the row is showing its submission options in place of the pin indicator. Any + * one of the three reasons is enough: the pointer is on the row, the keyboard is on the + * options button, or the overflow menu it opened is still up. The menu case has to be + * here as well as in the pointer path, because opening the menu from the keyboard moves + * focus into the menu and so blurs the button that opened it. + */ + rowActionsShown(task: Task): boolean { + return task.hover || task.optionsOpened || this.focusedTaskId === task.id; } setSelectedTask(task: Task) { @@ -362,20 +678,20 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { } } - private scrollToTaskInList(task) { - const taskEl = document.querySelector(`staff-task-list #${task.taskKeyToIdString()}`) as any; + private scrollToTaskInList(task: Task) { + const taskEl = document.querySelector(`#${task.taskKeyToIdString()}`) as + | (HTMLElement & { + scrollIntoViewIfNeeded?: (options?: ScrollIntoViewOptions) => void; + }) + | null; if (!taskEl) { return; } - const funcName = taskEl.scrollIntoViewIfNeeded - ? 'scrollIntoViewIfNeeded' - : taskEl.scrollIntoView - ? 'scrollIntoView' - : ''; - if (!funcName) { - return; + if (taskEl.scrollIntoViewIfNeeded) { + taskEl.scrollIntoViewIfNeeded({behavior: 'smooth'}); + } else { + taskEl.scrollIntoView({behavior: 'smooth'}); } - taskEl[funcName]({behavior: 'smooth'}); } isSelectedTask(task: Task) { @@ -385,6 +701,9 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { } nextTask(): void { + if (!this.filteredTasks) { + return; + } const currentTaskIndex = this.filteredTasks.findIndex((task) => this.isSelectedTask(task)); if (currentTaskIndex >= this.filteredTasks.length) { return; @@ -425,6 +744,45 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { } togglePin(task: Task) { - task.pinned ? task.unpin() : task.pin(); + if (task.id === undefined) { + // Can't pin a task that doesn't actually exist yet + this.alertService.error(`This task can't be pinned yet`, 3000); + return; + } + const refreshOrdering = () => this.applyFilters(); + if (task.pinned) { + task.unpin(refreshOrdering); + } else { + task.pin(refreshOrdering); + } + } + + getWarningIcon(task: Task): 'warning' | 'overflow' | null { + if (!task.submissionDate) { + return null; + } + if (task.status !== 'ready_for_feedback') { + return null; + } + + const daysSinceSubmission = task.daysSinceSubmission(); + + if (daysSinceSubmission >= task.unit.feedbackOverflowThresholdDays) { + return 'overflow'; + } + + if (daysSinceSubmission >= task.unit.feedbackWarningThresholdDays) { + return 'warning'; + } + + return null; + } + + private sortPinnedTasksFirst(tasks: Task[]): Task[] { + if (!this.isTaskDefMode || !tasks?.length) { + return tasks; + } + + return [...tasks].sort((a, b) => Number(b.pinned) - Number(a.pinned)); } } diff --git a/src/app/units/states/tasks/inbox/directives/task-claim/task-claim.component.html b/src/app/units/states/tasks/inbox/directives/task-claim/task-claim.component.html new file mode 100644 index 0000000000..044efc6403 --- /dev/null +++ b/src/app/units/states/tasks/inbox/directives/task-claim/task-claim.component.html @@ -0,0 +1,34 @@ +
+ +
diff --git a/src/app/units/states/tasks/inbox/directives/task-claim/task-claim.component.scss b/src/app/units/states/tasks/inbox/directives/task-claim/task-claim.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/units/states/tasks/inbox/directives/task-claim/task-claim.component.ts b/src/app/units/states/tasks/inbox/directives/task-claim/task-claim.component.ts new file mode 100644 index 0000000000..e79654c560 --- /dev/null +++ b/src/app/units/states/tasks/inbox/directives/task-claim/task-claim.component.ts @@ -0,0 +1,46 @@ +import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; +import {MatSnackBar} from '@angular/material/snack-bar'; +import {Task} from 'src/app/api/models/task'; +import {UnitRole} from 'src/app/api/models/unit-role'; +import {TaskService} from 'src/app/api/services/task.service'; +import {UserService} from 'src/app/api/services/user.service'; +import {AlertService} from 'src/app/common/services/alert.service'; + +@Component({ + selector: 'f-task-claim', + templateUrl: './task-claim.component.html', + styleUrl: './task-claim.component.scss', + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class TaskClaimComponent { + @Input() selectedTask: Task; + + constructor( + private taskService: TaskService, + private alertService: AlertService, + private snackBar: MatSnackBar, + private userService: UserService, + ) {} + + public get currentUnitRole(): UnitRole | undefined { + const currentUser = this.userService.currentUser; + return this.selectedTask.unit.staff.find((ur) => ur.user.id === currentUser.id); + } + + claimTask(task: Task) { + this.taskService.claimTask(task).subscribe({ + next: (_response) => { + this.alertService.success(`Successfully claimed task`, 6000); + this.snackBar.open( + `Successfully claimed task. This task will be automatically unclaimed after 30 minutes of inactivity.`, + 'OK', + ); + task.claimedByUnitRoleId = this.currentUnitRole.id; + }, + error: (error) => { + this.alertService.error(`Failed to claim task ${error}`, 6000); + }, + }); + } +} diff --git a/src/app/units/states/tasks/inbox/inbox.coffee b/src/app/units/states/tasks/inbox/inbox.coffee deleted file mode 100644 index 9f04950ca0..0000000000 --- a/src/app/units/states/tasks/inbox/inbox.coffee +++ /dev/null @@ -1,24 +0,0 @@ -angular.module('doubtfire.units.states.tasks.inbox', []) - -# -# Task inbox for tasks for tutors to deal with (i.e., tasksForTaskInbox) -# -.config(($stateProvider) -> - $stateProvider.state 'units/tasks/inbox', { - parent: 'units/tasks' - url: '/inbox/{taskKey:any}' - templateUrl: "units/states/tasks/inbox/inbox.tpl.html" - controller: "TaskInboxStateCtrl" - params: - taskKey: dynamic: true - data: - task: "Task Inbox" - pageTitle: "_Home_" - roleWhitelist: ['Tutor', 'Convenor', 'Admin', 'Auditor', 'Auditor'] - } -) - -.controller('TaskInboxStateCtrl', ($scope, newTaskService) -> - $scope.taskData.source = newTaskService.queryTasksForTaskInbox.bind(newTaskService) - $scope.taskData.taskDefMode = false -) diff --git a/src/app/units/states/tasks/inbox/inbox.component.html b/src/app/units/states/tasks/inbox/inbox.component.html index 84edcabe13..7be5352086 100644 --- a/src/app/units/states/tasks/inbox/inbox.component.html +++ b/src/app/units/states/tasks/inbox/inbox.component.html @@ -1,86 +1,232 @@ -
-
-
- @if (taskData) { - - } -
- @if (subs$ | async) { -
+ +
+ + @for (row of [0, 1, 2, 3, 4, 5, 6, 7]; track row) { +
+ + +
+ + +
+ +
} +
+
+ + + + -
- +@if (!isMobileView) { +
+
+
+ @if (loading) { + + } @else if (taskData) { + + } +
+ @if (subs$ | async) { +
+ } + +
+ @if (loading) { + + } @else { + + } +
+ @if (isCommentsNarrow && commentsCollapsed && !loading && taskData?.selectedTask) { + + } +
+
+ @if (isCommentsNarrow && !commentsCollapsed) { + + } + @if (loading) { + + } @else { + + } +
+ +
+} @else { +
-
- + #inboxpanel + class="inbox-panel mobile overflow-hidden" + id="inboxpanel" + [hidden]="taskSelected" + > + @if (loading) { + + } @else if (taskData) { + + }
-
- -
+
+
+ +

{{ taskData?.selectedTask?.project.student.nickname }}

+
+ + +
-
-
- @if (taskData) { - - } -
-
-
- -

{{ taskData?.selectedTask?.project.student.nickname }}

-
-
+ @if (isCommentsNarrow && commentsCollapsed && !loading && taskData?.selectedTask) { + + } +
- description - - -
- -
- + @if (isCommentsNarrow && !commentsCollapsed) { + + } + @if (!loading && taskData?.selectedTask) { + + } +
-
+} diff --git a/src/app/units/states/tasks/inbox/inbox.component.scss b/src/app/units/states/tasks/inbox/inbox.component.scss index 0b0bc8df17..302682d039 100644 --- a/src/app/units/states/tasks/inbox/inbox.component.scss +++ b/src/app/units/states/tasks/inbox/inbox.component.scss @@ -1,42 +1,127 @@ -.staff-task-list {} +.staff-task-list { +} .task-comment-panel { - padding-left: 12px; - padding-bottom: 0px; + padding-left: 12px; + padding-bottom: 0px; - &.mobile { - padding-left: 0px; - padding-bottom: 0px; - margin-bottom: 10px; - } + &.mobile { + padding-left: 0px; + padding-bottom: 0px; + margin-bottom: 10px; + } } .resizer { - background-color: #f5f5f5; - z-index: 200; - width: 10px //css on hover + background-color: #f5f5f5; + z-index: 200; + width: 3px; } .resizer:hover, .resizer.hovering { - cursor: col-resize; - background: linear-gradient(white, white) no-repeat center/2px 98%; + cursor: col-resize; + background: linear-gradient(white, white) no-repeat center/2px 98%; } .inbox-panel { - border-radius: 10px; - background-color: white; - padding: 8px; - min-width: 60px; - width: 350px; + border-radius: 10px; + background-color: white; + padding: 8px; + min-width: 60px; + width: 350px; - &.mobile { - max-width: 100%; - width: 100%; - padding: 0px; - } + &.mobile { + max-width: 100%; + width: 100%; + padding: 0px; + } } .task-comment-panel.inbox-panel { - // padding: 0px 0px 0px 5px !important; + border-radius: 0; + // padding: 0px 0px 0px 5px !important; +} + +.comments-floating-toggle { + position: fixed; + top: 50%; + right: -20px; + z-index: 300; + opacity: 0.55; + transition: + opacity 150ms ease, + transform 150ms ease; + transform: translateY(-50%); +} + +.comments-floating-toggle mat-icon { + position: absolute; + inset: 0; + margin: auto; + font-size: 20px; + height: 20px; + width: 20px; + transition: opacity 150ms ease; +} + +.comments-floating-toggle:hover, +.comments-floating-toggle:focus-visible { + opacity: 1; + transform: translate(-20px, -50%); +} + +.comments-floating-toggle__edge-icon { + opacity: 1; + transform: translateX(-6px); +} + +.comments-floating-toggle__hover-icon { + opacity: 0; +} + +.comments-floating-toggle:hover .comments-floating-toggle__edge-icon, +.comments-floating-toggle:focus-visible .comments-floating-toggle__edge-icon { + opacity: 0; +} + +.comments-floating-toggle:hover .comments-floating-toggle__hover-icon, +.comments-floating-toggle:focus-visible .comments-floating-toggle__hover-icon { + opacity: 1; +} + +.comments-sidebar { + position: relative; +} + +.comments-sidebar--narrow { + flex-basis: 375px !important; + min-width: 375px; + width: 375px !important; + border-radius: 0 !important; + box-shadow: -2px 0 6px rgba(15, 23, 42, 0.08); + z-index: 250; +} + +.comments-sidebar--mobile { + position: fixed; + top: 56px; + right: 0; + bottom: 0; + min-width: 375px; + width: 375px !important; + border-radius: 0 !important; + padding: 8px; + box-shadow: -2px 0 6px rgba(15, 23, 42, 0.08); + z-index: 250; +} + +.comments-sidebar__collapse { + position: absolute; + top: 50%; + left: -20px; + z-index: 260; + transform: translateY(-50%); + background: white; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.18); } diff --git a/src/app/units/states/tasks/inbox/inbox.component.spec.ts b/src/app/units/states/tasks/inbox/inbox.component.spec.ts index d90eb5cb30..408a72a359 100644 --- a/src/app/units/states/tasks/inbox/inbox.component.spec.ts +++ b/src/app/units/states/tasks/inbox/inbox.component.spec.ts @@ -1,6 +1,25 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import {HotkeysService} from '@ngneat/hotkeys'; +import {MediaObserver} from 'ng-flex-layout'; +import {beforeEach, describe, expect, it} from 'vitest'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatDialog} from '@angular/material/dialog'; +import {Router} from '@angular/router'; +import {EMPTY} from 'rxjs'; +import {UserService} from 'src/app/api/services/user.service'; +import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; +import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; +import {SelectedTaskService} from 'src/app/projects/states/dashboard/selected-task.service'; +import {InboxComponent} from './inbox.component'; -import { InboxComponent } from './inbox.component'; +const selectedTaskServiceStub = { + currentPdfUrl$: EMPTY, + selectedTask$: EMPTY, +}; +const hotkeysServiceStub = { + removeShortcuts: () => {}, +}; +const emptyProvider = {}; describe('InboxComponent', () => { let component: InboxComponent; @@ -8,13 +27,26 @@ describe('InboxComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [ InboxComponent ] + declarations: [InboxComponent], + providers: [ + {provide: HotkeysService, useValue: hotkeysServiceStub}, + {provide: SelectedTaskService, useValue: selectedTaskServiceStub}, + {provide: MediaObserver, useValue: emptyProvider}, + {provide: FileDownloaderService, useValue: emptyProvider}, + {provide: Router, useValue: emptyProvider}, + {provide: MatDialog, useValue: emptyProvider}, + {provide: UserService, useValue: emptyProvider}, + {provide: DoubtfireConstants, useValue: emptyProvider}, + ], + schemas: [NO_ERRORS_SCHEMA], }) - .compileComponents(); + .overrideComponent(InboxComponent, {set: {template: ''}}) + .compileComponents(); + }); + beforeEach(() => { fixture = TestBed.createComponent(InboxComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { diff --git a/src/app/units/states/tasks/inbox/inbox.component.ts b/src/app/units/states/tasks/inbox/inbox.component.ts index 41716a3e40..e0e107c4e3 100644 --- a/src/app/units/states/tasks/inbox/inbox.component.ts +++ b/src/app/units/states/tasks/inbox/inbox.component.ts @@ -1,6 +1,9 @@ -import {CdkDragEnd, CdkDragStart, CdkDragMove} from '@angular/cdk/drag-drop'; +import {HotkeysHelpComponent, HotkeysService} from '@ngneat/hotkeys'; +import {MediaObserver} from 'ng-flex-layout'; +import {CdkDragEnd, CdkDragMove, CdkDragStart} from '@angular/cdk/drag-drop'; +import {BreakpointObserver} from '@angular/cdk/layout'; import { - AfterViewInit, + ChangeDetectionStrategy, Component, ElementRef, Input, @@ -8,41 +11,71 @@ import { OnInit, ViewChild, } from '@angular/core'; -import {MediaObserver} from 'ng-flex-layout'; -import {UIRouter} from '@uirouter/angular'; -import {auditTime, merge, Observable, of, Subject, tap, withLatestFrom} from 'rxjs'; +import {MatDialog} from '@angular/material/dialog'; +import {Router} from '@angular/router'; +import {Observable, Subject, auditTime, merge, of, takeUntil, tap, withLatestFrom} from 'rxjs'; +import {Tutorial} from 'src/app/api/models/doubtfire-model'; import {Task} from 'src/app/api/models/task'; +import {TaskDefinition} from 'src/app/api/models/task-definition'; import {Unit} from 'src/app/api/models/unit'; import {UnitRole} from 'src/app/api/models/unit-role'; +import {UserService} from 'src/app/api/services/user.service'; import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; +import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; import {SelectedTaskService} from 'src/app/projects/states/dashboard/selected-task.service'; -import {HotkeysService, HotkeysHelpComponent} from '@ngneat/hotkeys'; -import {MatDialog} from '@angular/material/dialog'; -import {UserService} from 'src/app/api/services/user.service'; + +interface InboxTaskData { + source: ( + unit: Unit, + taskDef?: TaskDefinition | number, + fetchMyStudentsOnly?: boolean, + ) => Observable | null; + selectedTask: Task | null; + taskKey: unknown; + onSelectedTaskChange: (task: Task | null) => void; + taskDefMode: boolean; +} @Component({ selector: 'f-inbox', templateUrl: './inbox.component.html', styleUrls: ['./inbox.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, }) -export class InboxComponent implements OnInit, AfterViewInit { +export class InboxComponent implements OnInit, OnDestroy { @Input() unit: Unit; @Input() unitRole: UnitRole; - @Input() taskData: {selectedTask: Task; any}; - + @Input() taskData: InboxTaskData; + @Input() loading = false; + @Input() filters: Partial<{ + taskDefinition: TaskDefinition; + tutorials: Tutorial[]; + forceStream: boolean; + studentName: string; + tutorialIdSelected: string | number; + taskDefinitionIdSelected: number | TaskDefinition; + }>; + @Input() showSearchOptions: boolean; @ViewChild('inboxpanel') inboxPanel: ElementRef; @ViewChild('commentspanel') commentspanel: ElementRef; + @Input() viewType: 'inbox' | 'explorer' | 'moderation' | 'overflow'; + subs$: Observable; - private inboxStartSize$ = new Subject(); - private dragMove$ = new Subject<{event: CdkDragMove; div: HTMLDivElement}>(); + private inboxStartSize$: Subject = new Subject(); + private dragMove$: Subject<{event: CdkDragMove; div: HTMLDivElement}> = new Subject(); private dragMoveAudited$; + private readonly destroy$: Subject = new Subject(); + private readonly commentsBreakpoint = '(max-width: 999.98px)'; - protected filters; - protected showSearchOptions; + // protected filters; + // protected showSearchOptions; public taskSelected = false; + public isCommentsNarrow = false; + public commentsCollapsed = false; visiblePdfUrl: string; @@ -50,14 +83,24 @@ export class InboxComponent implements OnInit, AfterViewInit { return this.inboxPanel?.nativeElement.getBoundingClientRect().width < 150; } + get isMobileView(): boolean { + return this.mediaObserver.isActive('xs'); + } + + get commentsPanelCollapsed(): boolean { + return this.isCommentsNarrow && this.commentsCollapsed; + } + constructor( private hotkeys: HotkeysService, private selectedTask: SelectedTaskService, public mediaObserver: MediaObserver, public fileDownloader: FileDownloaderService, - private router: UIRouter, + private router: Router, public dialog: MatDialog, private userService: UserService, + private constants: DoubtfireConstants, + private breakpointObserver: BreakpointObserver, ) { this.selectedTask.currentPdfUrl$.subscribe((url) => { this.visiblePdfUrl = url; @@ -67,35 +110,69 @@ export class InboxComponent implements OnInit, AfterViewInit { this.taskSelected = task != null; }); } - ngAfterViewInit(): void { - console.log('ngAfterViewInit'); - const markers = ['Admin', 'Convenor', 'Tutor', 'Student']; - if (markers.includes(this.userService.currentUser?.role)) { + ngOnInit(): void { + this.breakpointObserver + .observe(this.commentsBreakpoint) + .pipe(takeUntil(this.destroy$)) + .subscribe(({matches}) => { + this.isCommentsNarrow = matches; + this.commentsCollapsed = matches; + window.dispatchEvent(new Event('resize')); + }); + + const registeredHotkeys = this.hotkeys.getHotkeys().map((hotkey) => hotkey.keys); + + if (!registeredHotkeys.includes('shift.?')) { this.hotkeys.registerHelpModal(() => { const ref = this.dialog.open(HotkeysHelpComponent, { // width: '250px', }); - ref.componentInstance.title = 'Formatif Marking Shortcuts'; + ref.componentInstance.title = `${this.constants.ExternalName.value} Feedback Shortcuts`; ref.componentInstance.dismiss.subscribe(() => ref.close()); }); } - } - ngOnInit(): void { - // this.hotkeys - // .addShortcut({ - // keys: 'control.c', - // description: 'Mark selected task as complete', - // }) - // .subscribe(() => this.selectedTask.selectedTask?.updateTaskStatus('complete')); - - // this.hotkeys - // .addShortcut({ - // keys: 'control.f', - // description: 'Mark selected task as fix', - // }) - // .subscribe(() => this.selectedTask.selectedTask?.updateTaskStatus('fix_and_resubmit')); + if (!registeredHotkeys.includes('control.shift.f')) { + this.hotkeys + .addShortcut({ + keys: 'control.shift.f', + description: 'Mark selected task as fix', + }) + .subscribe(() => this.selectedTask.selectedTask?.updateTaskStatus('fix_and_resubmit')); + } + + if (!registeredHotkeys.includes('control.shift.c')) { + this.hotkeys + .addShortcut({ + keys: 'control.Shift.c', + description: 'Mark selected task as complete', + }) + .subscribe(() => { + const task = this.selectedTask.selectedTask; + if (!task) { + return; + } + + if (!task.canMarkComplete) { + return; + } + + task.updateTaskStatus('complete'); + }); + } + + if (!registeredHotkeys.includes('control.shift.d')) { + this.hotkeys + .addShortcut({ + keys: 'control.shift.d', + description: 'Mark selected task as discuss', + }) + .subscribe(() => { + const task = this.selectedTask.selectedTask; + task?.updateTaskStatus(task.status === 'discuss' ? 'rediscuss' : 'discuss'); + }); + } this.dragMoveAudited$ = this.dragMove$.pipe( withLatestFrom(this.inboxStartSize$), @@ -128,7 +205,23 @@ export class InboxComponent implements OnInit, AfterViewInit { window.dispatchEvent(new Event('resize')); } + ngOnDestroy(): void { + document.body.classList.remove('split-pane-resizing'); + this.destroy$.next(); + this.destroy$.complete(); + this.hotkeys.removeShortcuts('control.shift.d'); + this.hotkeys.removeShortcuts('control.shift.f'); + this.hotkeys.removeShortcuts('control.shift.c'); + this.hotkeys.removeShortcuts('shift.?'); + } + + public toggleCommentsPanel(): void { + this.commentsCollapsed = !this.commentsCollapsed; + window.dispatchEvent(new Event('resize')); + } + startedDragging(event: CdkDragStart, div: HTMLDivElement) { + document.body.classList.add('split-pane-resizing'); event.source.element.nativeElement.classList.add('hovering'); const w = div.getBoundingClientRect().width; this.inboxStartSize$.next(w); @@ -141,23 +234,31 @@ export class InboxComponent implements OnInit, AfterViewInit { // eslint-disable-next-line @typescript-eslint/no-unused-vars stoppedDragging(event: CdkDragEnd, _div: HTMLDivElement) { + document.body.classList.remove('split-pane-resizing'); event.source.element.nativeElement.classList.remove('hovering'); } goToStudent(): void { - this.router.stateService.go('projects/dashboard', { - projectId: this.taskData.selectedTask.project.id, - tutor: true, - taskAbbr: '', - }); + // this.router.navigateByUrl('projects/dashboard', { + // projectId: this.taskData.selectedTask.project.id, + // tutor: true, + // taskAbbr: '', + // }); + this.router.navigate(['/projects', this.taskData.selectedTask.project.id, 'dashboard']); } openPdfInNewTab(): void { - if (this.taskData.selectedTask.hasPdf) { - this.fileDownloader.downloadFile( - this.visiblePdfUrl, - `${this.taskData.selectedTask.definition.abbreviation}.pdf`, - ); + if (!this.visiblePdfUrl || !this.taskData?.selectedTask) { + return; } + + const task = this.taskData.selectedTask; + const taskSheetUrl = task.definition.getTaskPDFUrl(); + const fileName = + this.visiblePdfUrl === taskSheetUrl + ? `${task.definition.abbreviation}-task-sheet.pdf` + : `${task.definition.abbreviation}.pdf`; + + this.fileDownloader.downloadFile(this.visiblePdfUrl, fileName); } } diff --git a/src/app/units/states/tasks/inbox/inbox.scss b/src/app/units/states/tasks/inbox/inbox.scss deleted file mode 100644 index d5ba842b3e..0000000000 --- a/src/app/units/states/tasks/inbox/inbox.scss +++ /dev/null @@ -1,4 +0,0 @@ -f-inbox { - height: $main-view-max-height; - display: flex; -} \ No newline at end of file diff --git a/src/app/units/states/tasks/inbox/inbox.tpl.html b/src/app/units/states/tasks/inbox/inbox.tpl.html deleted file mode 100644 index d77bb14cc2..0000000000 --- a/src/app/units/states/tasks/inbox/inbox.tpl.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/app/units/states/tasks/inbox/unit-task-inbox-state.component.html b/src/app/units/states/tasks/inbox/unit-task-inbox-state.component.html new file mode 100644 index 0000000000..1aba157340 --- /dev/null +++ b/src/app/units/states/tasks/inbox/unit-task-inbox-state.component.html @@ -0,0 +1,10 @@ + diff --git a/src/app/units/states/tasks/inbox/unit-task-inbox-state.component.spec.ts b/src/app/units/states/tasks/inbox/unit-task-inbox-state.component.spec.ts new file mode 100644 index 0000000000..27c5515f65 --- /dev/null +++ b/src/app/units/states/tasks/inbox/unit-task-inbox-state.component.spec.ts @@ -0,0 +1,186 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {ActivatedRoute, ParamMap, Router, convertToParamMap} from '@angular/router'; +import {Observable, ReplaySubject, Subject, of} from 'rxjs'; +import {Unit} from 'src/app/api/models/unit'; +import {ProjectService} from 'src/app/api/services/project.service'; +import {TaskService} from 'src/app/api/services/task.service'; +import {UnitService} from 'src/app/api/services/unit.service'; +import {UserService} from 'src/app/api/services/user.service'; +import {SelectedTaskService} from 'src/app/projects/states/dashboard/selected-task.service'; +import {GlobalStateService} from 'src/app/projects/states/index/global-state.service'; +import {UnitTaskInboxStateComponent} from './unit-task-inbox-state.component'; + +function unitStub(id: number): Unit { + return {id, taskDefinitions: []} as unknown as Unit; +} + +describe('UnitTaskInboxStateComponent', () => { + const emptyParamMap = convertToParamMap({}); + let fetchedUnitIds: number[]; + let unitFetches: Map>; + let holdUnitFetches: boolean; + let paramMap$: Subject; + let queryParamMap$: Subject; + + // Answers straight away unless a test holds the response back, so it can stage a + // request for the unit the user has already navigated away from. + function fetchUnit(unitId: number): Observable { + fetchedUnitIds.push(unitId); + const response: ReplaySubject = new ReplaySubject(1); + unitFetches.set(unitId, response); + + if (!holdUnitFetches) { + response.next(unitStub(unitId)); + } + + return response; + } + + beforeEach(async () => { + fetchedUnitIds = []; + unitFetches = new Map(); + holdUnitFetches = false; + // Subjects rather than of(...), which completes on subscribe and so can never + // show a leak, which is what the teardown test below is for. + paramMap$ = new Subject(); + queryParamMap$ = new Subject(); + + await TestBed.configureTestingModule({ + declarations: [UnitTaskInboxStateComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + snapshot: { + data: {routeMode: 'inbox'}, + paramMap: emptyParamMap, + queryParamMap: emptyParamMap, + }, + paramMap: paramMap$, + queryParamMap: queryParamMap$, + parent: {parent: {snapshot: {data: {}}}}, + }, + }, + {provide: Router, useValue: {navigate: () => {}}}, + { + provide: UnitService, + useValue: { + fetch: (unitId: number) => fetchUnit(unitId), + get: (unitId: number) => fetchUnit(unitId), + }, + }, + {provide: ProjectService, useValue: {loadStudents: () => of([])}}, + { + provide: TaskService, + useValue: {queryTasksForTaskInbox: () => of([]), taskKeyFromString: () => null}, + }, + {provide: UserService, useValue: {currentUser: {id: 1, role: 'Convenor'}}}, + { + provide: GlobalStateService, + useValue: { + setInboxState: () => {}, + setNotInboxState: () => {}, + setView: () => {}, + currentViewAndEntitySubject$: {value: null}, + loadedUnitRoles: {currentValues: []}, + }, + }, + {provide: SelectedTaskService, useValue: {setSelectedTask: () => {}}}, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(UnitTaskInboxStateComponent, {set: {template: ''}}) + .compileComponents(); + }); + + it('loads the new unit when the route resolves a different unit', () => { + const component = TestBed.createComponent(UnitTaskInboxStateComponent).componentInstance; + const unit$: Subject = new Subject(); + component.unit$ = unit$; + component.ngOnInit(); + + unit$.next(unitStub(101)); + expect(fetchedUnitIds).toEqual([101]); + expect(component.unit.id).toBe(101); + + unit$.next(unitStub(102)); + expect(fetchedUnitIds).toEqual([101, 102]); + expect(component.unit.id).toBe(102); + }); + + it('does not reload when the route resolves the same unit again', () => { + const component = TestBed.createComponent(UnitTaskInboxStateComponent).componentInstance; + const unit$: Subject = new Subject(); + component.unit$ = unit$; + component.ngOnInit(); + + unit$.next(unitStub(201)); + unit$.next(unitStub(201)); + + expect(fetchedUnitIds).toEqual([201]); + }); + + // Gates card step 7. The two route subscriptions used to be left running for the + // rest of the session, and unit$ alone cannot show that because first() closed it. + it('stops listening to the route once it is destroyed', () => { + const component = TestBed.createComponent(UnitTaskInboxStateComponent).componentInstance; + const unit$: Subject = new Subject(); + component.unit$ = unit$; + component.ngOnInit(); + + unit$.next(unitStub(301)); + expect(paramMap$.observed).toBe(true); + expect(queryParamMap$.observed).toBe(true); + + component.ngOnDestroy(); + unit$.next(unitStub(302)); + + expect(fetchedUnitIds).toEqual([301]); + expect(unit$.observed).toBe(false); + expect(paramMap$.observed).toBe(false); + expect(queryParamMap$.observed).toBe(false); + }); + + // Gates the out-of-order case: the request for the unit we left must be dropped, not + // allowed to land late and put that unit back on screen. + it('drops a slow request for the unit that was navigated away from', () => { + const component = TestBed.createComponent(UnitTaskInboxStateComponent).componentInstance; + const unit$: Subject = new Subject(); + component.unit$ = unit$; + component.ngOnInit(); + + holdUnitFetches = true; + + unit$.next(unitStub(401)); + unit$.next(unitStub(402)); + expect(fetchedUnitIds).toEqual([401, 402]); + + unitFetches.get(402).next(unitStub(402)); + expect(component.unit.id).toBe(402); + + // Unit 401's request finally answers, long after the user moved on. + unitFetches.get(401).next(unitStub(401)); + + expect(component.unit.id).toBe(402); + }); + + it('drops filters that refer to the previous unit', () => { + const component = TestBed.createComponent(UnitTaskInboxStateComponent).componentInstance; + const unit$: Subject = new Subject(); + component.unit$ = unit$; + component.ngOnInit(); + + unit$.next(unitStub(501)); + component.filters = { + tutorialIdSelected: 5010, + tutorials: [{id: 5010}] as never, + unitRoleIdSelected: 5011, + }; + + unit$.next(unitStub(502)); + + expect(component.filters).toEqual({}); + }); +}); diff --git a/src/app/units/states/tasks/inbox/unit-task-inbox-state.component.ts b/src/app/units/states/tasks/inbox/unit-task-inbox-state.component.ts new file mode 100644 index 0000000000..252117de24 --- /dev/null +++ b/src/app/units/states/tasks/inbox/unit-task-inbox-state.component.ts @@ -0,0 +1,355 @@ +import {ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit} from '@angular/core'; +import {ActivatedRoute, Router} from '@angular/router'; +import { + Observable, + Subject, + distinctUntilChanged, + first, + of, + switchMap, + takeUntil, + tap, +} from 'rxjs'; +import { + ProjectService, + TaskDefinition, + Tutorial, + Unit, + UnitRole, + UnitService, + UserService, +} from 'src/app/api/models/doubtfire-model'; +import {Task} from 'src/app/api/models/task'; +import {TaskService} from 'src/app/api/services/task.service'; +import {SelectedTaskService} from 'src/app/projects/states/dashboard/selected-task.service'; +import {GlobalStateService, ViewType} from 'src/app/projects/states/index/global-state.service'; + +export type UnitTaskViewType = 'inbox' | 'explorer' | 'moderation' | 'overflow'; +export type UnitTaskRouteMode = 'inbox' | 'definition' | 'moderation' | 'overflow'; + +export interface TaskKey { + studentId: string | number; + taskDefAbbr: string; +} + +export interface TaskFilters { + taskDefinition: TaskDefinition | null; + tutorials: Tutorial[] | null; + forceStream: boolean; + studentName: string | null; + tutorialIdSelected: string | number; + unitRoleIdSelected: string | number; + taskDefinitionIdSelected: number | TaskDefinition | null; +} + +type TaskSource = ( + unit: Unit, + taskDef?: TaskDefinition | number, + fetchMyStudentsOnly?: boolean, +) => Observable; + +@Component({ + selector: 'f-unit-task-inbox-state', + templateUrl: './unit-task-inbox-state.component.html', + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class UnitTaskInboxStateComponent implements OnInit, OnDestroy { + private static readonly UNIT_REFRESH_INTERVAL_MS = 60_000; + private static readonly lastUnitFetchAt: Map = new Map(); + + @Input() public unit$: Observable; + @Input() public routeMode: UnitTaskRouteMode = 'inbox'; + + public viewType: UnitTaskViewType = 'inbox'; + public showSearchOptions = true; + + public unit: Unit; + public unitRole: UnitRole; + public studentsLoaded = false; + public filters: Partial = {}; + + public get inboxLoading(): boolean { + return !(this.unit && this.unitRole && this.studentsLoaded); + } + + public taskData: { + taskKey: TaskKey | null; + source: TaskSource; + selectedTask: Task | null; + onSelectedTaskChange: (task: Task | null) => void; + taskDefMode: boolean; + } = { + taskKey: null, + source: null, + selectedTask: null, + onSelectedTaskChange: (task: Task | null) => { + this.taskData.selectedTask = task; + this.taskData.taskKey = task?.taskKey() ?? null; + this.setTaskKeyAsUrlParams(task); + }, + taskDefMode: false, + }; + + private readonly destroy$: Subject = new Subject(); + + constructor( + private taskService: TaskService, + private globalStateService: GlobalStateService, + private userService: UserService, + private unitService: UnitService, + private projectService: ProjectService, + private route: ActivatedRoute, + private router: Router, + private selectedTaskService: SelectedTaskService, + ) {} + + ngOnInit(): void { + this.globalStateService.setInboxState(); + this.clearSelectedTask(); + + this.routeMode = this.route.snapshot.data.routeMode ?? this.routeMode; + this.configureRouteMode(); + this.setTaskKeyFromRoute(); + + const routeUnit = this.route.parent?.parent?.snapshot.data.unit; + if (!this.unit$ && routeUnit) { + this.unit$ = of(routeUnit); + } + + // switchMap so a slow request for the unit we just left is cancelled rather than + // landing late and putting that unit back on screen. + this.unit$ + .pipe( + distinctUntilChanged((a, b) => a?.id === b?.id), + tap(() => { + this.studentsLoaded = false; + }), + switchMap((currentUnit) => this.loadUnit(currentUnit.id).pipe(first())), + takeUntil(this.destroy$), + ) + .subscribe((unit) => this.loadInboxData(unit)); + + this.route.paramMap.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.setTaskKeyFromRoute(); + }); + + this.route.queryParamMap.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.setTaskKeyFromRoute(); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + this.globalStateService.setNotInboxState(); + this.clearSelectedTask(); + } + + private clearSelectedTask(): void { + this.taskData.selectedTask = null; + this.taskData.taskKey = null; + this.selectedTaskService.setSelectedTask(null); + } + + private loadUnit(unitId: number): Observable { + if (this.shouldRefreshUnit(unitId)) { + return this.unitService + .fetch(unitId) + .pipe(tap(() => UnitTaskInboxStateComponent.lastUnitFetchAt.set(unitId, Date.now()))); + } + + return this.unitService.get(unitId); + } + + private shouldRefreshUnit(unitId: number): boolean { + const lastFetchedAt = UnitTaskInboxStateComponent.lastUnitFetchAt.get(unitId); + + return ( + !lastFetchedAt || + Date.now() - lastFetchedAt > UnitTaskInboxStateComponent.UNIT_REFRESH_INTERVAL_MS + ); + } + + private getTaskSource(): TaskSource { + switch (this.routeMode) { + case 'definition': + return this.taskService.queryTasksForTaskExplorer.bind(this.taskService); + case 'moderation': + return this.taskService.queryTasksForMentorModeration.bind(this.taskService); + case 'overflow': + return this.taskService.queryTasksForOverflow.bind(this.taskService); + case 'inbox': + default: + return this.taskService.queryTasksForTaskInbox.bind(this.taskService); + } + } + + private configureRouteMode(): void { + this.taskData.source = this.getTaskSource(); + + switch (this.routeMode) { + case 'definition': + this.viewType = 'explorer'; + this.showSearchOptions = true; + this.taskData.taskDefMode = true; + break; + case 'moderation': + this.viewType = 'moderation'; + this.showSearchOptions = false; + this.taskData.taskDefMode = false; + break; + case 'overflow': + this.viewType = 'overflow'; + this.showSearchOptions = false; + this.taskData.taskDefMode = false; + break; + case 'inbox': + default: + this.viewType = 'inbox'; + this.showSearchOptions = false; + this.taskData.taskDefMode = false; + break; + } + } + + private loadInboxData(unit: Unit): void { + this.unit = unit; + this.unitRole = this.findUnitRole(unit.id); + if (this.unitRole) { + this.unitRole.unit = unit; + this.globalStateService.setView(ViewType.UNIT, this.unitRole); + } else { + this.globalStateService.setView(ViewType.UNIT, unit); + } + + // Filter objects contain tutorials and role ids from one unit. Reusing the + // previous object after a route change can leave the new unit filtered by a + // tutorial that does not exist there, making a successfully loaded inbox + // appear empty. Start from the new route's overrides for each unit instead. + this.filters = {...this.getFilterOverrides(unit)}; + + this.projectService + .loadStudents(unit) + .pipe(first(), takeUntil(this.destroy$)) + .subscribe({ + next: () => { + this.studentsLoaded = true; + }, + error: () => { + this.studentsLoaded = true; + }, + }); + } + + private getFilterOverrides(unit: Unit): Partial { + const selectedStudents = this.route.snapshot.queryParamMap.get('students'); + + switch (this.routeMode) { + case 'definition': + return { + tutorialIdSelected: selectedStudents ?? 'all', + taskDefinitionIdSelected: unit.taskDefinitions?.[0]?.id ?? null, + }; + case 'moderation': + case 'overflow': + return { + tutorialIdSelected: selectedStudents ?? 'all', + taskDefinition: null, + taskDefinitionIdSelected: null, + }; + case 'inbox': + default: + return selectedStudents ? {tutorialIdSelected: selectedStudents} : {}; + } + } + + private findUnitRole(unitId: number): UnitRole { + const currentView = this.globalStateService.currentViewAndEntitySubject$.value; + + if (currentView?.viewType === ViewType.UNIT) { + const currentUnitRole = currentView.entity as UnitRole; + if (currentUnitRole?.unit?.id === unitId) { + return currentUnitRole; + } + } + + let unitRole = this.globalStateService.loadedUnitRoles.currentValues.find( + (role) => role.unit.id === unitId, + ); + + if ( + !unitRole && + (this.userService.currentUser.role === 'Admin' || + this.userService.currentUser.role === 'Auditor') + ) { + unitRole = this.userService.adminOrAuditorRoleFor( + this.userService.currentUser.role, + unitId, + this.userService.currentUser, + ); + } + + return unitRole; + } + + private setTaskKeyAsUrlParams(task: Task | null): void { + const nextTaskKey = task?.taskKeyToUrlString() ?? null; + const currentTaskKey = this.currentTaskKeyFromRoute(); + + if (currentTaskKey === nextTaskKey) { + return; + } + + if (this.route.parent?.parent?.snapshot.data.unit) { + const [studentId, taskDefAbbr] = nextTaskKey?.split('/') ?? [null, null]; + + this.router.navigate([], { + relativeTo: this.route, + replaceUrl: true, + queryParams: { + students: this.filters.tutorialIdSelected ?? null, + studentId, + taskDefAbbr, + }, + queryParamsHandling: 'merge', + }); + return; + } + } + + private setTaskKeyFromUrlParams(taskKeyString?: string): void { + this.taskData.taskKey = this.taskService.taskKeyFromString(taskKeyString); + } + + private setTaskKeyFromRoute(): void { + const studentId = + this.route.snapshot.queryParamMap.get('studentId') ?? + this.route.snapshot.paramMap.get('studentId'); + const taskDefAbbr = + this.route.snapshot.queryParamMap.get('taskDefAbbr') ?? + this.route.snapshot.paramMap.get('taskDefAbbr'); + + if (studentId && taskDefAbbr) { + this.taskData.taskKey = {studentId, taskDefAbbr}; + return; + } + + this.setTaskKeyFromUrlParams(); + } + + private currentTaskKeyFromRoute(): string | null { + const studentId = + this.route.snapshot.queryParamMap.get('studentId') ?? + this.route.snapshot.paramMap.get('studentId'); + const taskDefAbbr = + this.route.snapshot.queryParamMap.get('taskDefAbbr') ?? + this.route.snapshot.paramMap.get('taskDefAbbr'); + + if (!studentId || !taskDefAbbr) { + return null; + } + + return `${studentId}/${taskDefAbbr}`; + } +} diff --git a/src/app/units/states/tasks/tasks-viewer.service.spec.ts b/src/app/units/states/tasks/tasks-viewer.service.spec.ts deleted file mode 100644 index 79439a0410..0000000000 --- a/src/app/units/states/tasks/tasks-viewer.service.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { TasksViewerService } from './tasks-viewer.service'; - -describe('TasksViewerService', () => { - let service: TasksViewerService; - - beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(TasksViewerService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/src/app/units/states/tasks/tasks-viewer.service.ts b/src/app/units/states/tasks/tasks-viewer.service.ts deleted file mode 100644 index f2b2b627ed..0000000000 --- a/src/app/units/states/tasks/tasks-viewer.service.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Injectable } from '@angular/core'; -import { Observable, Subject } from 'rxjs'; -import { TaskDefinition } from 'src/app/api/models/task-definition'; - -@Injectable({ - providedIn: 'root' -}) -export class TasksViewerService { - selectedTaskDef = new Subject(); - taskSelected = new Subject(); - - public setSelectedTaskDef(taskDef: TaskDefinition) { - this.selectedTaskDef.next(taskDef); - this.taskSelected.next(true); - } - - constructor() { } -} diff --git a/src/app/units/states/tasks/tasks-viewer/tasks-viewer.component.html b/src/app/units/states/tasks/tasks-viewer/tasks-viewer.component.html deleted file mode 100644 index 845e7203a5..0000000000 --- a/src/app/units/states/tasks/tasks-viewer/tasks-viewer.component.html +++ /dev/null @@ -1,40 +0,0 @@ - -
-
- -
- -
- -
- - -
-
-
- -
- -
-
-
-
- - -
-
- -
- -
-
- -
- -
- -
-
-
diff --git a/src/app/units/states/tasks/tasks-viewer/tasks-viewer.component.scss b/src/app/units/states/tasks/tasks-viewer/tasks-viewer.component.scss deleted file mode 100644 index cddaf52b1d..0000000000 --- a/src/app/units/states/tasks/tasks-viewer/tasks-viewer.component.scss +++ /dev/null @@ -1,20 +0,0 @@ -.vertical-panel { - border-radius: 10px; - background-color: white; - padding: 8px; - min-width: 60px; - width: 450px; - height: 100%; - - &.mobile { - max-width: 100%; - width: 100%; - padding: 0px; - } -} - -.vertical-spacer { - background-color: #f5f5f5; - z-index: 200; - width: 10px -} diff --git a/src/app/units/states/tasks/tasks-viewer/tasks-viewer.component.spec.ts b/src/app/units/states/tasks/tasks-viewer/tasks-viewer.component.spec.ts deleted file mode 100644 index bc7a35f057..0000000000 --- a/src/app/units/states/tasks/tasks-viewer/tasks-viewer.component.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { TasksViewerComponent } from './tasks-viewer.component'; - -describe('TasksViewerComponent', () => { - let component: TasksViewerComponent; - let fixture: ComponentFixture; - - beforeEach(() => { - TestBed.configureTestingModule({ - declarations: [TasksViewerComponent], - }); - fixture = TestBed.createComponent(TasksViewerComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/units/states/tasks/tasks-viewer/tasks-viewer.component.ts b/src/app/units/states/tasks/tasks-viewer/tasks-viewer.component.ts deleted file mode 100644 index af43e37ca4..0000000000 --- a/src/app/units/states/tasks/tasks-viewer/tasks-viewer.component.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Component, Input, OnInit } from '@angular/core'; -import { TaskDefinition } from 'src/app/api/models/task-definition'; -import { TasksViewerService } from '../tasks-viewer.service'; -import { Unit } from 'src/app/api/models/unit'; - -@Component({ - selector: 'f-tasks-viewer', - templateUrl: './tasks-viewer.component.html', - styleUrls: ['./tasks-viewer.component.scss'], -}) -export class TasksViewerComponent implements OnInit { - @Input() taskDefs: TaskDefinition[]; - @Input() unit: Unit; - selectedTaskDef: TaskDefinition; - taskSelected: boolean; - - constructor(private taskViewerService: TasksViewerService) {} - - ngOnInit() { - this.taskViewerService.selectedTaskDef.subscribe((taskDef) => { - this.selectedTaskDef = taskDef; - }); - - this.taskViewerService.taskSelected.subscribe((taskSelected) => { - this.taskSelected = taskSelected; - }); - } -} diff --git a/src/app/units/states/tasks/tasks.coffee b/src/app/units/states/tasks/tasks.coffee deleted file mode 100644 index 141e0364c6..0000000000 --- a/src/app/units/states/tasks/tasks.coffee +++ /dev/null @@ -1,68 +0,0 @@ -angular.module('doubtfire.units.states.tasks', [ - 'doubtfire.units.states.tasks.inbox' - 'doubtfire.units.states.tasks.definition' - 'doubtfire.units.states.tasks.viewer' -]) - -# -# Teacher child state for units for task-related activites -# -.config(($stateProvider) -> - $stateProvider.state 'units/tasks', { - abstract: true - parent: 'units/index' - url: '/tasks' - controller: 'UnitsTasksStateCtrl' - template: '' - data: - pageTitle: "_Home_" - roleWhitelist: ['Tutor', 'Convenor', 'Admin', 'Auditor'] - } -) - -.controller('UnitsTasksStateCtrl', ($scope, $state, newTaskService, listenerService, $transition$) -> - # Cleanup - listeners = listenerService.listenTo($scope) - - # Task data wraps: - # * the URL task composite key (project username + task def abbreviation) sourced from the URL, - # * the task source used for the task inbox list, - # * the actual selectedTask reference - # * the callback for when a task is updated (accepts the new task) - $scope.taskData = { - taskKey: null - source: null - selectedTask: null - onSelectedTaskChange: (task) -> - taskKey = task?.taskKey() - $scope.taskData.taskKey = taskKey - setTaskKeyAsUrlParams(task) - } - - # Sets URL parameters for the task key - setTaskKeyAsUrlParams = (task) -> - # Change URL of new task without notify - $state.go($state.$current, {taskKey: task?.taskKeyToUrlString()}, {notify: false}) - - # Sets task key from URL parameters - setTaskKeyFromUrlParams = (taskKeyString) -> - # Propagate selected task change downward to search for actual task - # inside the task inbox list - $scope.taskData.taskKey = newTaskService.taskKeyFromString(taskKeyString) - - # Child states will use taskKey to notify what task has been - # selected by the child on first load. - taskKey = $transition$.params().taskKey - setTaskKeyFromUrlParams(taskKey) - - # Whenever the state is changed, we look at the taskKey in the URL params - # see if we can set it as an actual taskKey object - listeners.push $scope.$on '$stateChangeStart', ($event, toState, toParams, fromState, fromParams) -> - setTaskKeyFromUrlParams(toParams.taskKey) - # Use preventDefault to prevent destroying the child state's - # scope if they are the same states. Otherwise, if they are - # the same, we destroy the state's scope and recreate it again - # unnecessarily; doing so will cause a re-request in the task - # list which is not required. - $event.preventDefault() if fromState == toState && fromParams.unitId == toParams.unitId -) diff --git a/src/app/units/states/tasks/viewer/directives/directives.coffee b/src/app/units/states/tasks/viewer/directives/directives.coffee deleted file mode 100644 index 1624d23757..0000000000 --- a/src/app/units/states/tasks/viewer/directives/directives.coffee +++ /dev/null @@ -1,5 +0,0 @@ -angular.module('doubtfire.units.states.tasks.viewer.directives', [ - # 'doubtfire.units.states.tasks.viewer.directives.unit-task-list' - # 'doubtfire.units.states.tasks.viewer.directives.task-sheet-view' - # 'doubtfire.units.states.tasks.viewer.directives.task-details-view' -]) diff --git a/src/app/units/states/tasks/viewer/directives/f-task-details-view/f-task-details-view.component.html b/src/app/units/states/tasks/viewer/directives/f-task-details-view/f-task-details-view.component.html deleted file mode 100644 index 2ee4198b7c..0000000000 --- a/src/app/units/states/tasks/viewer/directives/f-task-details-view/f-task-details-view.component.html +++ /dev/null @@ -1,9 +0,0 @@ -
-
- -
- -
- subtitles_off -
-
diff --git a/src/app/units/states/tasks/viewer/directives/f-task-details-view/f-task-details-view.component.scss b/src/app/units/states/tasks/viewer/directives/f-task-details-view/f-task-details-view.component.scss deleted file mode 100644 index dda643e643..0000000000 --- a/src/app/units/states/tasks/viewer/directives/f-task-details-view/f-task-details-view.component.scss +++ /dev/null @@ -1,11 +0,0 @@ -@use '@angular/material' as mat; - -@import '../../../../../../../theme.scss'; - -$my-palette: mat.define-palette($md-formatif); - -.title { - font-size: 16px; - font-weight: bold; - padding-bottom: 15px; -} diff --git a/src/app/units/states/tasks/viewer/directives/f-task-details-view/f-task-details-view.component.ts b/src/app/units/states/tasks/viewer/directives/f-task-details-view/f-task-details-view.component.ts deleted file mode 100644 index 57ea0b7993..0000000000 --- a/src/app/units/states/tasks/viewer/directives/f-task-details-view/f-task-details-view.component.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Component, Input, OnInit } from '@angular/core'; -import { TaskDefinition } from 'src/app/api/models/task-definition'; -import { Unit } from 'src/app/api/models/unit'; -import { TasksViewerService } from '../../../tasks-viewer.service'; - -@Component({ - selector: 'f-task-details-view', - templateUrl: './f-task-details-view.component.html', - styleUrls: ['./f-task-details-view.component.scss'], -}) -export class FTaskDetailsViewComponent implements OnInit { - @Input() taskDef: TaskDefinition; - @Input() unit: Unit; - - constructor(private tasksViewerService: TasksViewerService) {} - - ngOnInit() { - this.tasksViewerService.selectedTaskDef.subscribe((taskDef) => { - this.taskDef = taskDef; - }); - } -} diff --git a/src/app/units/states/tasks/viewer/directives/f-task-sheet-view/f-task-sheet-view.component.html b/src/app/units/states/tasks/viewer/directives/f-task-sheet-view/f-task-sheet-view.component.html deleted file mode 100644 index af1f34d5ae..0000000000 --- a/src/app/units/states/tasks/viewer/directives/f-task-sheet-view/f-task-sheet-view.component.html +++ /dev/null @@ -1,17 +0,0 @@ -
- @switch (taskDef) { @case (taskDef) { @if (taskDef?.hasTaskSheet) { - - - - } @else { - -
- subtitles_off No Task Sheet -
- - } } } - - - - -
diff --git a/src/app/units/states/tasks/viewer/directives/f-task-sheet-view/f-task-sheet-view.component.ts b/src/app/units/states/tasks/viewer/directives/f-task-sheet-view/f-task-sheet-view.component.ts deleted file mode 100644 index acddd72281..0000000000 --- a/src/app/units/states/tasks/viewer/directives/f-task-sheet-view/f-task-sheet-view.component.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Component, Input, OnInit } from '@angular/core'; -import { TaskDefinition } from 'src/app/api/models/task-definition'; -import { TasksViewerService } from '../../../tasks-viewer.service'; - -@Component({ - selector: 'f-task-sheet-view', - templateUrl: './f-task-sheet-view.component.html', - styleUrls: ['./f-task-sheet-view.component.scss'], -}) -export class FTaskSheetViewComponent implements OnInit { - @Input() taskDef: TaskDefinition; - - constructor(private taskViewerService: TasksViewerService) {} - - ngOnInit(): void { - this.taskViewerService.selectedTaskDef.subscribe((taskDef) => { - this.taskDef = taskDef; - }); - } -} diff --git a/src/app/units/states/tasks/viewer/directives/f-unit-task-list/f-unit-task-list.component.html b/src/app/units/states/tasks/viewer/directives/f-unit-task-list/f-unit-task-list.component.html deleted file mode 100644 index 1ca78334f0..0000000000 --- a/src/app/units/states/tasks/viewer/directives/f-unit-task-list/f-unit-task-list.component.html +++ /dev/null @@ -1,61 +0,0 @@ -
- -
-
- -
- -
-
-
- - -
- - @if (filteredTasks.length === 0) { -
No tasks to display
- } - @for (task of filteredTasks; track task) { - - @if (task) { -
-
-
- -
-

{{ task.name }}

- - - @if (task.isGroupTask()) { - group - } -
{{ gradeNames[task.targetGrade] }} Task
-
- -
-
-
-
-
- } -
- } -
-
-
diff --git a/src/app/units/states/tasks/viewer/directives/f-unit-task-list/f-unit-task-list.component.scss b/src/app/units/states/tasks/viewer/directives/f-unit-task-list/f-unit-task-list.component.scss deleted file mode 100644 index 0d460d0ffd..0000000000 --- a/src/app/units/states/tasks/viewer/directives/f-unit-task-list/f-unit-task-list.component.scss +++ /dev/null @@ -1,85 +0,0 @@ -@use '@angular/material' as mat; - -@import '../../../../../../../theme.scss'; -@import '../../../../../../../styles/mixins/scrollable.scss'; - -$my-palette: mat.define-palette($md-formatif); - -:host { - --background-gray: rgba(0, 0, 0, 0.04); - padding-right: 8px; -} - -::ng-deep .cdk-virtual-scroll-content-wrapper { - width: 100%; -} - -.task-list { - height: 100%; -} - -.scrollable { - height: 100%; - @include scrollable(); -} - -.omnisearch { - font-size: 15px; - padding-right: 10px; - text-align: left; - - &.expanded { - height: 100%; - } - - .search { - background-color: transparent; - border: hidden; - outline: none; - } - - .mdc-icon-button { - display: flex; - } -} - -.full-width { - width: 100%; -} - -.task-list-data { - margin-top: 0px; - padding-left: 6px; - - .task-title { - font-size: 15px; - margin-bottom: -2px; - margin-top: 8px; - } - - .task-details { - margin-bottom: 0px; - font-size: 12px; - } -} - -.mat-mdc-list-item .item-content { - height: 60px; - border-radius: 6px; -} - -.mat-mdc-list-item .item-content:hover { - cursor: pointer; -} - -.mat-mdc-list-item .item-content.selected { - background-color: var(--background-gray); -} - -.task-inbox.narrow-width .mat-mdc-list-item .item-content.selected { - margin-right: 12px; -} - -.tasks-viewport { - height: 100%; -} diff --git a/src/app/units/states/tasks/viewer/directives/f-unit-task-list/f-unit-task-list.component.spec.ts b/src/app/units/states/tasks/viewer/directives/f-unit-task-list/f-unit-task-list.component.spec.ts deleted file mode 100644 index dc592e52cc..0000000000 --- a/src/app/units/states/tasks/viewer/directives/f-unit-task-list/f-unit-task-list.component.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { FUnitTaskListComponent } from './f-unit-task-list.component'; - -describe('FUnitTaskListComponent', () => { - let component: FUnitTaskListComponent; - let fixture: ComponentFixture; - - beforeEach(() => { - TestBed.configureTestingModule({ - declarations: [FUnitTaskListComponent], - }); - fixture = TestBed.createComponent(FUnitTaskListComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/units/states/tasks/viewer/directives/f-unit-task-list/f-unit-task-list.component.ts b/src/app/units/states/tasks/viewer/directives/f-unit-task-list/f-unit-task-list.component.ts deleted file mode 100644 index 2212477466..0000000000 --- a/src/app/units/states/tasks/viewer/directives/f-unit-task-list/f-unit-task-list.component.ts +++ /dev/null @@ -1,47 +0,0 @@ -import {Component, Input, OnInit} from '@angular/core'; -import {Grade} from 'src/app/api/models/grade'; -import {TaskDefinition} from 'src/app/api/models/doubtfire-model'; -import {TaskDefinitionNamePipe} from 'src/app/common/filters/task-definition-name.pipe'; -import {TasksViewerService} from '../../../tasks-viewer.service'; - -@Component({ - selector: 'f-unit-task-list', - templateUrl: './f-unit-task-list.component.html', - styleUrls: ['./f-unit-task-list.component.scss'], -}) -export class FUnitTaskListComponent implements OnInit { - @Input() unitTasks: TaskDefinition[]; - filteredTasks: TaskDefinition[]; // list of tasks which match the taskSearch term - taskSearch: string = ''; // task search term from user input - taskDefinitionNamePipe = new TaskDefinitionNamePipe(); - protected gradeNames: string[] = Grade.GRADES; - selectedTaskDef: TaskDefinition; - taskSelected: boolean; - - constructor(private taskViewerService: TasksViewerService) {} - - applyFilters() { - this.filteredTasks = this.taskDefinitionNamePipe.transform(this.unitTasks, this.taskSearch); - } - - ngOnInit(): void { - this.applyFilters(); - - this.taskViewerService.selectedTaskDef.subscribe((taskDef) => { - this.selectedTaskDef = taskDef; - }); - this.taskViewerService.selectedTaskDef.next(this.unitTasks[0]); - - this.taskViewerService.taskSelected.subscribe((taskSelected) => { - this.taskSelected = taskSelected; - }); - } - - setSelectedTask(task: TaskDefinition) { - this.taskViewerService.setSelectedTaskDef(task); - } - - isSelectedTask(task: TaskDefinition) { - return this.selectedTaskDef.id == task.id; - } -} diff --git a/src/app/units/states/tasks/viewer/directives/unit-task-list/unit-task-list.coffee b/src/app/units/states/tasks/viewer/directives/unit-task-list/unit-task-list.coffee deleted file mode 100644 index aa30c611e4..0000000000 --- a/src/app/units/states/tasks/viewer/directives/unit-task-list/unit-task-list.coffee +++ /dev/null @@ -1,41 +0,0 @@ -angular.module('doubtfire.units.states.tasks.viewer.directives.unit-task-list', []) - -.directive('unitTaskList', -> - restrict: 'E' - templateUrl: 'units/states/tasks/viewer/directives/unit-task-list/unit-task-list.tpl.html' - scope: - unit: '=' - # Function to invoke to refresh tasks - refreshTasks: '=?' - unitTasks: '=' - selectedTaskDef: '=' - controller: ($scope, $timeout, $filter, gradeService, listenerService) -> - listeners = listenerService.listenTo($scope) - # Set up initial filtered tasks - $scope.filteredTasks = [] - # Set up filters - $scope.filters = { - taskSearch:null - } - # Sets new filteredTasks variable - applyFilters = -> - filteredTasks = $filter('taskDefinitionName')($scope.unitTasks, $scope.filters.taskSearch) - filteredTasks = $filter('orderBy')(filteredTasks, 'task.seq') - $scope.filteredTasks = filteredTasks - # Apply filters first-time - applyFilters() - # When refreshing tasks, we are just reloading the active tasks - $scope.refreshTasks = applyFilters - # Expose grade service names - $scope.gradeNames = gradeService.grades - # On task name change, reapply filters - $scope.taskNameChanged = applyFilters - # UI call to change currently selected task - $scope.setSelectedTask = (task) -> - $scope.selectedTaskDef = task - - - $scope.isSelectedTask = (task) -> - # Compare by definition - task.id == $scope.selectedTaskDef?.id -) diff --git a/src/app/units/states/tasks/viewer/directives/unit-task-list/unit-task-list.scss b/src/app/units/states/tasks/viewer/directives/unit-task-list/unit-task-list.scss deleted file mode 100644 index b17c5d3b7b..0000000000 --- a/src/app/units/states/tasks/viewer/directives/unit-task-list/unit-task-list.scss +++ /dev/null @@ -1,3 +0,0 @@ -unit-task-list { - @include task-list(); -} diff --git a/src/app/units/states/tasks/viewer/directives/unit-task-list/unit-task-list.tpl.html b/src/app/units/states/tasks/viewer/directives/unit-task-list/unit-task-list.tpl.html deleted file mode 100644 index 4a0db812c4..0000000000 --- a/src/app/units/states/tasks/viewer/directives/unit-task-list/unit-task-list.tpl.html +++ /dev/null @@ -1,30 +0,0 @@ -
-
- -
-
    -
  • -
    -

    {{task.name}}

    -

    - {{task.abbreviation}} - {{gradeNames[task.targetGrade]}} Task -

    -
    - -
  • - -
  • No tasks to display.
  • -
-
diff --git a/src/app/units/states/tasks/viewer/viewer.coffee b/src/app/units/states/tasks/viewer/viewer.coffee deleted file mode 100644 index 0dde935439..0000000000 --- a/src/app/units/states/tasks/viewer/viewer.coffee +++ /dev/null @@ -1,23 +0,0 @@ -angular.module('doubtfire.units.states.tasks.viewer', [ - 'doubtfire.units.states.tasks.viewer.directives' -]) - -# -# Give feedback when on one-to-one for students (i.e., tasksRequiringFeedback) -# -.config(($stateProvider) -> - $stateProvider.state 'units/tasks/viewer', { - parent: 'units/tasks' - url: '/viewer' - templateUrl: "units/states/tasks/viewer/viewer.tpl.html" - controller: "TaskViewerStateCtrl" - data: - task: "Task List" - pageTitle: "_Home_" - roleWhitelist: ['Tutor', 'Convenor', 'Admin', 'Auditor'] - } -) - -.controller('TaskViewerStateCtrl', ($scope) -> - $scope.taskDefs = $scope.unit.taskDefinitions -) diff --git a/src/app/units/states/tasks/viewer/viewer.scss b/src/app/units/states/tasks/viewer/viewer.scss deleted file mode 100644 index 382bfdc247..0000000000 --- a/src/app/units/states/tasks/viewer/viewer.scss +++ /dev/null @@ -1,4 +0,0 @@ -f-tasks-viewer { - height: calc($main-view-max-height + 70px); // temporary fix to reclaim the footer region for this specific page - display: flex; -} diff --git a/src/app/units/states/tasks/viewer/viewer.tpl.html b/src/app/units/states/tasks/viewer/viewer.tpl.html deleted file mode 100644 index 358d6f33a2..0000000000 --- a/src/app/units/states/tasks/viewer/viewer.tpl.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/app/units/task-viewer/directives/task-details-view/task-details-view.component.html b/src/app/units/task-viewer/directives/task-details-view/task-details-view.component.html new file mode 100644 index 0000000000..ffa6367f9b --- /dev/null +++ b/src/app/units/task-viewer/directives/task-details-view/task-details-view.component.html @@ -0,0 +1,21 @@ +
+
+ + + Task Details + + {{ panelOpenState() ? 'Hide' : 'Show' }} task details + + + + +
+ +
+ subtitles_off +
+
diff --git a/src/app/units/task-viewer/directives/task-details-view/task-details-view.component.scss b/src/app/units/task-viewer/directives/task-details-view/task-details-view.component.scss new file mode 100644 index 0000000000..dea6d51225 --- /dev/null +++ b/src/app/units/task-viewer/directives/task-details-view/task-details-view.component.scss @@ -0,0 +1,5 @@ +.title { + font-size: 16px; + font-weight: bold; + padding-bottom: 15px; +} diff --git a/src/app/units/task-viewer/directives/task-details-view/task-details-view.component.ts b/src/app/units/task-viewer/directives/task-details-view/task-details-view.component.ts new file mode 100644 index 0000000000..5360b13f58 --- /dev/null +++ b/src/app/units/task-viewer/directives/task-details-view/task-details-view.component.ts @@ -0,0 +1,17 @@ +import {ChangeDetectionStrategy, Component, Input, signal} from '@angular/core'; +import {TaskDefinition} from 'src/app/api/models/task-definition'; +import {Unit} from 'src/app/api/models/unit'; + +@Component({ + selector: 'f-task-details-view', + templateUrl: './task-details-view.component.html', + styleUrls: ['./task-details-view.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class FTaskDetailsViewComponent { + @Input() taskDef: TaskDefinition; + @Input() unit: Unit; + + public readonly panelOpenState = signal(false); +} diff --git a/src/app/units/task-viewer/directives/task-sheet-view/task-sheet-view.component.html b/src/app/units/task-viewer/directives/task-sheet-view/task-sheet-view.component.html new file mode 100644 index 0000000000..1a475cc9cf --- /dev/null +++ b/src/app/units/task-viewer/directives/task-sheet-view/task-sheet-view.component.html @@ -0,0 +1,17 @@ +
+ @switch (taskDef) { + @case (taskDef) { + @if (taskDef?.hasTaskSheet) { + + } @else { +
+ subtitles_off No Task Sheet +
+ } + } + } + + + + +
diff --git a/src/app/units/task-viewer/directives/task-sheet-view/task-sheet-view.component.scss b/src/app/units/task-viewer/directives/task-sheet-view/task-sheet-view.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/units/task-viewer/directives/task-sheet-view/task-sheet-view.component.spec.ts b/src/app/units/task-viewer/directives/task-sheet-view/task-sheet-view.component.spec.ts new file mode 100644 index 0000000000..2767eb6e97 --- /dev/null +++ b/src/app/units/task-viewer/directives/task-sheet-view/task-sheet-view.component.spec.ts @@ -0,0 +1,27 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {FTaskSheetViewComponent} from './task-sheet-view.component'; + +describe('FTaskSheetViewComponent', () => { + let component: FTaskSheetViewComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [FTaskSheetViewComponent], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(FTaskSheetViewComponent, {set: {template: ''}}) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(FTaskSheetViewComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/units/task-viewer/directives/task-sheet-view/task-sheet-view.component.ts b/src/app/units/task-viewer/directives/task-sheet-view/task-sheet-view.component.ts new file mode 100644 index 0000000000..7f027aab91 --- /dev/null +++ b/src/app/units/task-viewer/directives/task-sheet-view/task-sheet-view.component.ts @@ -0,0 +1,13 @@ +import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; +import {TaskDefinition} from 'src/app/api/models/task-definition'; + +@Component({ + selector: 'f-task-sheet-view', + templateUrl: './task-sheet-view.component.html', + styleUrls: ['./task-sheet-view.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class FTaskSheetViewComponent { + @Input() taskDef: TaskDefinition; +} diff --git a/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.html b/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.html new file mode 100644 index 0000000000..809fc698d3 --- /dev/null +++ b/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.html @@ -0,0 +1,325 @@ + +
+ + +
+ @if (task.numNewComments > 0) { + + {{ task.numNewComments }} + + } + @if (task.similaritiesDetected) { + + + visibility + + + } +
+ +
+ @if (task.hasGrade()) { + + + {{ task.gradeDesc() }} + + + } + @if (task.hasQualityPoints()) { + + {{ task.qualityPts }} + + {{ task.definition.maxQualityPts }} + + } + @if (task.isDueSoon() && !task.inFinalState()) { + + + schedule + + + } + @if (task.betweenDueDateAndDeadlineDate() && !task.isPastDeadline() && !task.inFinalState()) { + + + schedule + + + } + @if (task.isPastDeadline() && !task.inFinalState()) { + + + schedule + + ! + + } +
+
+
+ +
+ @if (!isCollapsed) { +
+
+
+ + +
+ + +
+
+ Sort tasks +
+
+ @for (option of sortOptions; track option.value) { + + } +
+ +
+ Filters +
+
+ + Hide completed tasks + + + Show tasks beyond target grade + +
+ +
+ +
+
+
+
+
+ } + +
+ + @for (taskDef of filteredTaskDefinitions; track taskDef) { + + @if (taskDef) { + @if (isCollapsed) { +
+ + {{ taskDef.abbreviation }} + + @if (taskListItem(taskDef); as task) { + + } +
+ } @else { +
+
+
+
+
{{ taskDef.name }}
+ +
+ @if (taskDef.isGroupTask()) { + groups + } @else { + person + } + +
+ {{ taskDef.abbreviation }} + - + {{ gradeNames[taskDef.targetGrade] }} Task +
+ @if (taskListItem(taskDef); as task) { + @if (taskStartApproaching(task)) { + + event_upcoming + {{ taskStartLabel(task) }} + + } @else if (taskOngoing(task)) { + + event_upcoming + Start now + + } @else if (!task.isBeforeStartDate() && !task.inSubmittedState()) { + + + hourglass_bottom + + {{ task.timeToDue() }} + + } + } +
+
+
+
+ @if (taskListItem(taskDef); as task) { + + } +
+ } + } +
+ } @empty { +
No tasks to display
+ } + + @if (mode === 'project' && project) { + + @if (isCollapsed) { + + + history_edu + +
+ +
+
+ } @else { + + } +
+ } +
+
+
diff --git a/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.scss b/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.scss new file mode 100644 index 0000000000..c770a87254 --- /dev/null +++ b/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.scss @@ -0,0 +1,108 @@ +@use '@angular/material' as mat; + +@use 'theme' as *; +@use 'styles/mixins/scrollable' as *; + +$my-palette: mat.m2-define-palette($md-formatif); + +:host { + --background-gray: rgba(0, 0, 0, 0.04); + padding-right: 8px; +} + +:host(.collapsed) { + padding-right: 0; + + .mat-mdc-list-item { + overflow: visible; + } + + ::ng-deep .mdc-list-item__content { + overflow: visible; + } +} + +::ng-deep .cdk-virtual-scroll-content-wrapper { + width: 100%; +} + +.task-list { + height: 100%; +} + +.scrollable { + height: 100%; + @include scrollable(); +} + +.omnisearch { + font-size: 15px; + padding-right: 10px; + text-align: left; + + &.expanded { + height: 100%; + } + + .search { + background-color: transparent; + border: hidden; + outline: none; + } + + .mdc-icon-button { + display: flex; + } +} + +.task-list-menu-icon { + width: 20px; + height: 20px; + font-size: 20px; + line-height: 20px; +} + +::ng-deep .task-list-filters-menu.mat-mdc-menu-panel { + margin-left: 50px; +} + +.full-width { + width: 100%; +} + +.task-list-data { + margin-top: 0px; + padding-left: 6px; + + .task-title { + font-size: 15px; + margin-bottom: -2px; + margin-top: 8px; + } + + .task-details { + margin-bottom: 0px; + font-size: 12px; + } +} + +.mat-mdc-list-item .item-content { + height: 60px; + border-radius: 6px; +} + +.mat-mdc-list-item .item-content:hover { + cursor: pointer; +} + +.mat-mdc-list-item.selected { + background-color: var(--background-gray); +} + +.task-inbox.narrow-width .mat-mdc-list-item .item-content.selected { + margin-right: 12px; +} + +.tasks-viewport { + height: 100%; +} diff --git a/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.spec.ts b/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.spec.ts new file mode 100644 index 0000000000..391b63fd71 --- /dev/null +++ b/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.spec.ts @@ -0,0 +1,483 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {NO_ERRORS_SCHEMA, SimpleChange} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {ActivatedRoute, Router, convertToParamMap} from '@angular/router'; +import {BehaviorSubject, Subject} from 'rxjs'; +import {Project, Task, TaskDefinition} from 'src/app/api/models/doubtfire-model'; +import {FUnitTaskListComponent} from './unit-task-list.component'; + +const emptyProvider = {}; + +const flushTaskSelection = async (): Promise => { + await new Promise((resolve) => queueMicrotask(resolve)); +}; + +const taskDefinition = ( + id: number, + abbreviation: string, + startDate = new Date(2026, 0, id + 1), + targetGrade = 0, +): TaskDefinition => + ({ + id, + seq: id, + abbreviation, + name: abbreviation, + startDate, + targetGrade, + targetGradeText: 'Pass', + }) as TaskDefinition; + +const taskForDefinition = ( + definition: TaskDefinition, + topWeight: number, + numNewComments = 0, +): Task => + ({ + definition, + topWeight, + numNewComments, + }) as Task; + +const studentProject = () => + ({ + id: 10, + targetGrade: 0, + unit: {id: 20}, + calcTopTasks: () => undefined, + }) as unknown as Project; + +// Mirrors the wiring ngOnInit does, without the route lookup it also performs. +const openTaskDefinition = ( + component: FUnitTaskListComponent, + taskDef: TaskDefinition, +): BehaviorSubject => { + const selectedTaskDefinition$: BehaviorSubject = new BehaviorSubject(taskDef); + component.selectedTaskDefinition$ = selectedTaskDefinition$; + selectedTaskDefinition$.subscribe((value) => (component.selectedTaskDef = value)); + + return selectedTaskDefinition$; +}; + +describe('FUnitTaskListComponent', () => { + let component: FUnitTaskListComponent; + let fixture: ComponentFixture; + let routeParamMap$: Subject>; + + beforeEach(async () => { + vi.spyOn(Storage.prototype, 'getItem').mockReturnValue(null); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => undefined); + routeParamMap$ = new Subject>(); + + await TestBed.configureTestingModule({ + declarations: [FUnitTaskListComponent], + providers: [ + {provide: Router, useValue: emptyProvider}, + { + provide: ActivatedRoute, + useValue: {paramMap: routeParamMap$.asObservable()}, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(FUnitTaskListComponent, {set: {template: ''}}) + .compileComponents(); + }); + + beforeEach(() => { + vi.stubGlobal('localStorage', { + getItem: vi.fn(() => null), + setItem: vi.fn(), + }); + fixture = TestBed.createComponent(FUnitTaskListComponent); + component = fixture.componentInstance; + component.taskDefinitions = []; + component.tasks = []; + component.selectedTaskDefinition$ = new BehaviorSubject(null); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => vi.unstubAllGlobals()); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('follows task route changes without recreating the component', async () => { + const firstTask = taskDefinition(1, '1.1P'); + const secondTask = taskDefinition(2, '2.3P'); + + component.taskDefinitions = [firstTask, secondTask]; + + fixture.detectChanges(); + + routeParamMap$.next( + convertToParamMap({ + taskAbbreviation: firstTask.abbreviation, + }), + ); + + await flushTaskSelection(); + + expect(component.selectedTaskDefinition$.value).toBe(firstTask); + expect(component.selectedTaskDef).toBe(firstTask); + + routeParamMap$.next( + convertToParamMap({ + taskAbbreviation: secondTask.abbreviation, + }), + ); + + await flushTaskSelection(); + + expect(component.selectedTaskDefinition$.value).toBe(secondTask); + expect(component.selectedTaskDef).toBe(secondTask); + }); + + // The unit resolves progressively, so on a hard refresh of a deep link the + // route parameter arrives while taskDefinitions is still empty. Nothing used to + // re-apply it once the list turned up, and the screen landed on no task. + it('applies a deep linked task once the task definitions arrive', async () => { + const task = taskDefinition(1, '1.1P'); + + component.taskDefinitions = []; + fixture.detectChanges(); + + routeParamMap$.next(convertToParamMap({taskAbbreviation: task.abbreviation})); + await flushTaskSelection(); + + expect(component.selectedTaskDefinition$.value).toBeNull(); + + component.taskDefinitions = [task]; + component.ngOnChanges({taskDefinitions: {} as never}); + await flushTaskSelection(); + + expect(component.selectedTaskDefinition$.value).toBe(task); + }); + + it('clears a stale selection when the route task does not exist', async () => { + const existingTask = taskDefinition(1, '1.1P'); + + component.taskDefinitions = [existingTask]; + + fixture.detectChanges(); + + routeParamMap$.next( + convertToParamMap({ + taskAbbreviation: existingTask.abbreviation, + }), + ); + + await flushTaskSelection(); + + expect(component.selectedTaskDefinition$.value).toBe(existingTask); + + routeParamMap$.next( + convertToParamMap({ + taskAbbreviation: 'does-not-exist', + }), + ); + + await flushTaskSelection(); + + expect(component.selectedTaskDefinition$.value).toBeNull(); + expect(component.selectedTaskDef).toBeNull(); + }); + + it('clears the selection when the task parameter is removed', async () => { + const existingTask = taskDefinition(1, '1.1P'); + + component.taskDefinitions = [existingTask]; + + fixture.detectChanges(); + + routeParamMap$.next( + convertToParamMap({ + taskAbbreviation: existingTask.abbreviation, + }), + ); + + await flushTaskSelection(); + + expect(component.selectedTaskDefinition$.value).toBe(existingTask); + + routeParamMap$.next(convertToParamMap({})); + + await flushTaskSelection(); + + expect(component.selectedTaskDefinition$.value).toBeNull(); + expect(component.selectedTaskDef).toBeNull(); + }); + + it('stops following route changes after the component is destroyed', async () => { + const firstTask = taskDefinition(1, '1.1P'); + const secondTask = taskDefinition(2, '2.3P'); + + component.taskDefinitions = [firstTask, secondTask]; + + fixture.detectChanges(); + + routeParamMap$.next( + convertToParamMap({ + taskAbbreviation: firstTask.abbreviation, + }), + ); + + await flushTaskSelection(); + + expect(component.selectedTaskDefinition$.value).toBe(firstTask); + + component.ngOnDestroy(); + + routeParamMap$.next( + convertToParamMap({ + taskAbbreviation: secondTask.abbreviation, + }), + ); + + await flushTaskSelection(); + + expect(component.selectedTaskDefinition$.value).toBe(firstTask); + expect(component.selectedTaskDef).toBe(firstTask); + }); + + it('sorts task definitions by task top weight by default', () => { + const middlePriorityTask = taskDefinition(0, 'C'); + const lowPriorityTask = taskDefinition(1, 'A'); + const highPriorityTask = taskDefinition(2, 'B'); + + component.taskDefinitions = [lowPriorityTask, middlePriorityTask, highPriorityTask]; + + component.tasks = [ + taskForDefinition(middlePriorityTask, 1), + taskForDefinition(lowPriorityTask, 2), + taskForDefinition(highPriorityTask, 0), + ]; + + component.applyFilters(); + + expect(component.filteredTaskDefinitions).toEqual([ + highPriorityTask, + middlePriorityTask, + lowPriorityTask, + ]); + }); + + it('restores top weight order when switching back to default sorting', () => { + const middlePriorityTask = taskDefinition(0, 'C'); + const lowPriorityTask = taskDefinition(1, 'A'); + const highPriorityTask = taskDefinition(2, 'B'); + + component.taskDefinitions = [lowPriorityTask, middlePriorityTask, highPriorityTask]; + + component.tasks = [ + taskForDefinition(middlePriorityTask, 1), + taskForDefinition(lowPriorityTask, 2), + taskForDefinition(highPriorityTask, 0), + ]; + + component.setSortBy('abbreviation'); + + expect(component.filteredTaskDefinitions).toEqual([ + lowPriorityTask, + highPriorityTask, + middlePriorityTask, + ]); + + component.setSortBy('default'); + + expect(component.filteredTaskDefinitions).toEqual([ + highPriorityTask, + middlePriorityTask, + lowPriorityTask, + ]); + }); + + it('falls back to task definition sequence when no task is available', () => { + const firstTask = taskDefinition(0, 'C'); + const secondTask = taskDefinition(1, 'A'); + const thirdTask = taskDefinition(2, 'B'); + + component.taskDefinitions = [thirdTask, firstTask, secondTask]; + + component.applyFilters(); + + expect(component.filteredTaskDefinitions).toEqual([firstTask, secondTask, thirdTask]); + }); + + it('shows only tasks at or below the project target grade by default', () => { + const passTask = taskDefinition(1, 'P1', undefined, 0); + const creditTask = taskDefinition(2, 'C1', undefined, 1); + const distinctionTask = taskDefinition(3, 'D1', undefined, 2); + component.project = { + id: 10, + targetGrade: 0, + unit: {id: 20}, + } as Project; + component.targetGrade = 0; + component.taskDefinitions = [passTask, creditTask, distinctionTask]; + component.tasks = []; + + component.applyFilters(); + + expect(component.filteredTaskDefinitions).toEqual([passTask]); + expect(component.activeViewPreferenceCount).toBe(1); + }); + + it('reveals tasks beyond the target grade only when explicitly selected', () => { + const passTask = taskDefinition(1, 'P1', undefined, 0); + const creditTask = taskDefinition(2, 'C1', undefined, 1); + component.project = { + id: 10, + targetGrade: 0, + unit: {id: 20}, + } as Project; + component.targetGrade = 0; + component.taskDefinitions = [passTask, creditTask]; + component.tasks = []; + + component.toggleShowAboveTargetGrade(true); + + expect(component.filteredTaskDefinitions).toEqual([passTask, creditTask]); + expect(component.activeViewPreferenceCount).toBe(0); + + component.resetViewPreferences(); + + expect(component.filteredTaskDefinitions).toEqual([passTask]); + expect(component.activeViewPreferenceCount).toBe(1); + }); + + it('reapplies target-grade filtering when the selected target changes', () => { + const passTask = taskDefinition(1, 'P1', undefined, 0); + const creditTask = taskDefinition(2, 'C1', undefined, 1); + component.project = { + id: 10, + targetGrade: 0, + unit: {id: 20}, + } as Project; + component.targetGrade = 0; + component.taskDefinitions = [passTask, creditTask]; + component.tasks = []; + component.applyFilters(); + + component.targetGrade = 1; + component.project.targetGrade = 1; + component.ngOnChanges({targetGrade: new SimpleChange(0, 1, false)}); + + expect(component.filteredTaskDefinitions).toEqual([passTask, creditTask]); + }); + + it('does not grade-filter an all-tasks list without a student project', () => { + const passTask = taskDefinition(1, 'P1', undefined, 0); + const distinctionTask = taskDefinition(2, 'D1', undefined, 2); + component.mode = 'all-tasks'; + component.taskDefinitions = [passTask, distinctionTask]; + component.tasks = []; + + component.applyFilters(); + + expect(component.filteredTaskDefinitions).toEqual([passTask, distinctionTask]); + }); + + it('keeps the open task selected when the search term stops matching it', () => { + const openTask = taskDefinition(1, 'P1'); + const otherTask = taskDefinition(2, 'P2'); + component.project = studentProject(); + component.targetGrade = 0; + component.taskDefinitions = [openTask, otherTask]; + component.tasks = []; + const selectedTaskDefinition$ = openTaskDefinition(component, openTask); + + component.searchText = 'P1'; + component.applyFilters(); + + expect(component.filteredTaskDefinitions).toEqual([openTask]); + expect(selectedTaskDefinition$.value).toBe(openTask); + + component.searchText = 'P2'; + component.applyFilters(); + + expect(component.filteredTaskDefinitions).toEqual([otherTask]); + expect(selectedTaskDefinition$.value).toBe(openTask); + }); + + it('drops the selection when a view filter hides the open task', () => { + const passTask = taskDefinition(1, 'P1', undefined, 0); + const creditTask = taskDefinition(2, 'C1', undefined, 1); + component.project = studentProject(); + component.targetGrade = 0; + component.taskDefinitions = [passTask, creditTask]; + component.tasks = []; + component.toggleShowAboveTargetGrade(true); + const selectedTaskDefinition$ = openTaskDefinition(component, creditTask); + + component.toggleShowAboveTargetGrade(false); + + expect(component.filteredTaskDefinitions).toEqual([passTask]); + expect(selectedTaskDefinition$.value).toBeNull(); + }); + + it('drops the selection when the open task leaves the task definitions', () => { + const passTask = taskDefinition(1, 'P1'); + const removedTask = taskDefinition(2, 'P2'); + component.project = studentProject(); + component.targetGrade = 0; + component.taskDefinitions = [passTask, removedTask]; + component.tasks = []; + const selectedTaskDefinition$ = openTaskDefinition(component, removedTask); + + component.taskDefinitions = [passTask]; + component.applyFilters(); + + expect(selectedTaskDefinition$.value).toBeNull(); + }); + + it('badges the filter button while tasks beyond the target grade are hidden', () => { + component.project = studentProject(); + component.targetGrade = 0; + component.taskDefinitions = [taskDefinition(1, 'P1')]; + component.tasks = []; + + component.applyFilters(); + + expect(component.hidingTasksAboveTargetGrade).toBe(true); + expect(component.activeViewPreferenceCount).toBe(1); + expect(component.hasNonDefaultViewPreferences).toBe(false); + + component.toggleShowAboveTargetGrade(true); + + expect(component.hidingTasksAboveTargetGrade).toBe(false); + expect(component.activeViewPreferenceCount).toBe(0); + expect(component.hasNonDefaultViewPreferences).toBe(true); + }); + + it('does not badge target-grade hiding on an all-tasks list', () => { + component.mode = 'all-tasks'; + component.taskDefinitions = [taskDefinition(1, 'P1')]; + component.tasks = []; + + component.applyFilters(); + + expect(component.activeViewPreferenceCount).toBe(0); + }); + + it('still shows a task beyond the target grade when it has unread comments', () => { + const passTask = taskDefinition(1, 'P1', undefined, 0); + const creditTask = taskDefinition(2, 'C1', undefined, 1); + const distinctionTask = taskDefinition(3, 'D1', undefined, 2); + component.project = studentProject(); + component.targetGrade = 0; + component.taskDefinitions = [passTask, creditTask, distinctionTask]; + component.tasks = [ + taskForDefinition(creditTask, 2, 3), + taskForDefinition(distinctionTask, 3, 0), + ]; + + component.applyFilters(); + + expect(component.filteredTaskDefinitions).toEqual([passTask, creditTask]); + }); +}); diff --git a/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.ts b/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.ts new file mode 100644 index 0000000000..ca4cba8cc3 --- /dev/null +++ b/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.ts @@ -0,0 +1,613 @@ +import { + ChangeDetectionStrategy, + Component, + HostBinding, + Input, + OnChanges, + OnDestroy, + OnInit, + SimpleChanges, +} from '@angular/core'; +import {ActivatedRoute, Router} from '@angular/router'; +import {BehaviorSubject, Subject, takeUntil} from 'rxjs'; +import {Project, Task, TaskDefinition} from 'src/app/api/models/doubtfire-model'; +import {TaskDefinitionNamePipe} from 'src/app/common/filters/task-definition-name.pipe'; + +type TaskListSortOption = 'default' | 'abbreviation' | 'targetDate' | 'startDate' | 'dueDate'; + +type TaskListSortDirection = 'asc' | 'desc'; + +interface TaskListViewPreferences { + sortBy: TaskListSortOption; + sortDirection: TaskListSortDirection; + hideCompleted: boolean; + showAboveTargetGrade: boolean; +} + +interface TaskListSortOptionView { + value: TaskListSortOption; + label: string; + icon: string; +} + +const DEFAULT_VIEW_PREFERENCES: TaskListViewPreferences = { + sortBy: 'default', + sortDirection: 'asc', + hideCompleted: false, + showAboveTargetGrade: false, +}; + +const START_APPROACHING_DAYS = 7; + +@Component({ + selector: 'f-unit-task-list', + templateUrl: './unit-task-list.component.html', + styleUrls: ['./unit-task-list.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class FUnitTaskListComponent implements OnChanges, OnInit, OnDestroy { + private readonly destroy$: Subject = new Subject(); + private routeTaskAbbreviation: string | null = null; + + @Input() mode: 'project' | 'all-tasks'; + @Input() project: Project; + @Input() targetGrade: number; + @Input() taskDefinitions: readonly TaskDefinition[]; + @Input() tasks: readonly Task[]; + @Input() isCollapsed = false; + @Input() selectionUrlBase: unknown[] | null = null; + + @HostBinding('class.collapsed') + public get collapsedHostClass(): boolean { + return this.isCollapsed; + } + + // What is the selected task definition + @Input() selectedTaskDefinition$: BehaviorSubject; + selectedTaskDef: TaskDefinition; + + // @Output() selectedTask: EventEmitter = new EventEmitter(); + + filteredTaskDefinitions: TaskDefinition[]; // list of tasks which match the taskSearch term + searchText: string = ''; // task search term from user input + taskDefinitionNamePipe = new TaskDefinitionNamePipe(); + viewPreferences: TaskListViewPreferences = {...DEFAULT_VIEW_PREFERENCES}; + sortOptions: TaskListSortOptionView[] = [ + {value: 'default', label: 'Priority', icon: 'sort'}, + {value: 'startDate', label: 'Start date', icon: 'event_available'}, + {value: 'targetDate', label: 'Target date', icon: 'event'}, + {value: 'dueDate', label: 'Due date', icon: 'event_repeat'}, + {value: 'abbreviation', label: 'Abbreviation', icon: 'sort_by_alpha'}, + ]; + + protected get gradeNames(): Record { + const unit = this.project?.unit ?? this.taskDefinitions?.[0]?.unit; + return Object.fromEntries( + (unit?.gradeDefinitions ?? []).map((definition) => [definition.value, definition.label]), + ); + } + + constructor( + private angularRouter: Router, + private route: ActivatedRoute, + ) {} + + applyFilters() { + this.refreshTopTaskWeights(); + + const matchingTaskDefinitions = this.taskDefinitionNamePipe.transform( + this.taskDefinitions, + this.searchText, + ); + + this.filteredTaskDefinitions = matchingTaskDefinitions + .filter((taskDef) => this.shouldShowTaskDefinition(taskDef)) + .sort((a, b) => this.compareTaskDefinitions(a, b)); + + this.deselectHiddenTaskDefinition(); + } + + // The search term only narrows what the list shows, so typing must not close the + // open task. Drop the selection when the task itself has gone or a view filter + // hides it. + private deselectHiddenTaskDefinition(): void { + if (!this.selectedTaskDef) { + return; + } + + const selectedTaskDefinition = this.taskDefinitions?.find( + (taskDef) => taskDef.id === this.selectedTaskDef.id, + ); + + if (selectedTaskDefinition && this.shouldShowTaskDefinition(selectedTaskDefinition)) { + return; + } + + this.selectedTaskDefinition$?.next(null); + this.replaceSelectionUrl(null); + } + + public setSortBy(sortBy: TaskListSortOption): void { + const sortDirection = + sortBy === 'default' + ? DEFAULT_VIEW_PREFERENCES.sortDirection + : this.viewPreferences.sortBy === sortBy + ? this.toggledSortDirection + : 'asc'; + + this.viewPreferences = { + ...this.viewPreferences, + sortBy, + sortDirection, + }; + this.persistViewPreferences(); + this.applyFilters(); + } + + public toggleHideCompleted(value: boolean): void { + this.viewPreferences = { + ...this.viewPreferences, + hideCompleted: value, + }; + this.persistViewPreferences(); + this.applyFilters(); + } + + public toggleShowAboveTargetGrade(value: boolean): void { + this.viewPreferences = { + ...this.viewPreferences, + showAboveTargetGrade: value, + }; + this.persistViewPreferences(); + this.applyFilters(); + } + + public resetViewPreferences(): void { + this.viewPreferences = {...DEFAULT_VIEW_PREFERENCES}; + this.persistViewPreferences(); + this.applyFilters(); + } + + public get selectedSortLabel(): string { + const label = + this.sortOptions.find((option) => option.value === this.viewPreferences.sortBy)?.label ?? + 'Priority'; + + if (this.viewPreferences.sortBy === 'default') { + return label; + } + + return `${label} ${this.viewPreferences.sortDirection}`; + } + + public sortStateIconFor(option: TaskListSortOptionView): string { + if (option.value === 'default') { + return this.viewPreferences.sortBy === 'default' ? 'check' : ''; + } + + if (this.viewPreferences.sortBy !== option.value) { + return ''; + } + + return this.viewPreferences.sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'; + } + + public get activeViewPreferenceCount(): number { + return ( + (this.viewPreferences.sortBy !== 'default' ? 1 : 0) + + (this.viewPreferences.hideCompleted ? 1 : 0) + + (this.hidingTasksAboveTargetGrade ? 1 : 0) + ); + } + + // The list hides tasks beyond the target grade unless the student opts in, so the + // badge has to count the hiding, not the opt-in that switches it off. + public get hidingTasksAboveTargetGrade(): boolean { + return !this.viewPreferences.showAboveTargetGrade && this.effectiveTargetGrade !== null; + } + + public get hasNonDefaultViewPreferences(): boolean { + return ( + this.viewPreferences.sortBy !== DEFAULT_VIEW_PREFERENCES.sortBy || + this.viewPreferences.sortDirection !== DEFAULT_VIEW_PREFERENCES.sortDirection || + this.viewPreferences.hideCompleted !== DEFAULT_VIEW_PREFERENCES.hideCompleted || + this.viewPreferences.showAboveTargetGrade !== DEFAULT_VIEW_PREFERENCES.showAboveTargetGrade + ); + } + + ngOnChanges(changes: SimpleChanges): void { + if ('project' in changes || 'taskDefinitions' in changes) { + this.loadViewPreferences(); + } + + if ( + 'project' in changes || + 'targetGrade' in changes || + 'taskDefinitions' in changes || + 'tasks' in changes + ) { + this.applyFilters(); + this.applyRouteTaskSelection(); + } + } + + public get hasTasks(): boolean { + return this.tasks && this.tasks.length > 0; + } + + public taskForTaskDef(taskDef: TaskDefinition): Task { + if (!this.hasTasks || !taskDef) { + return null; + } + + return this.tasks.find((task) => task.definition.id === taskDef?.id); + } + + public taskListItem(taskDef: TaskDefinition): Task { + return this.taskForTaskDef(taskDef); + } + + public taskStartApproaching(task: Task): boolean { + return ( + !!task && + !task.inFinalState() && + task.isBeforeStartDate() && + task.daysUntilStartDate() <= START_APPROACHING_DAYS + ); + } + + public taskStartLabel(task: Task): string { + const days = task.daysUntilStartDate(); + + if (days <= 0) { + return 'Start today'; + } + + return `Start in ${days} ${days === 1 ? 'day' : 'days'}`; + } + + public taskOngoing(task: Task): boolean { + if (!task || task.inFinalState()) { + return false; + } + + const now = Date.now(); + const startTime = this.dateTime(task.startDate); + const dueTime = this.dateTime(task.localDueDate()); + + return now >= startTime && now < dueTime; + } + + /* + TODO: There's still an issue where loading the route for the first time will cause child components (like task-dashboard) to load trigger OnInit and OnChanges twice... + Causing duplicate queries to submission_details and task comments. + One hack would be to always keep the task-dashboard rendered using [hidden]. + */ + + ngOnInit(): void { + this.loadViewPreferences(); + this.applyFilters(); + + // Watch for changes in the selected task definition... including from us + this.selectedTaskDefinition$.pipe(takeUntil(this.destroy$)).subscribe((taskDef) => { + this.selectedTaskDef = taskDef; + }); + + // // TODO: Remove the service + // this.taskViewerService.selectedTaskDef.subscribe((taskDef) => { + // this.selectedTaskDef = taskDef; + // }); + + // this.taskViewerService.taskSelected.subscribe((taskSelected) => { + // this.taskSelected = taskSelected; + // }); + + // // Select the first task definition by default + // if (this.taskDefinitions.length > 0) { + // this.setSelectedTaskDefinition(this.taskDefinitions[0]); + // } + + // Follow the selected task in the url, rather than reading it once. + // + // Angular reuses this component when only a route parameter changes, so + // going from .../dashboard/1.1P to .../dashboard/2.3P never runs ngOnInit + // again. Reading route.snapshot here left the first task selected and the + // second one never opened, which is what any in-app link to another task on + // a dashboard the user is already looking at runs into. A notification + // linking to a task is exactly that. + this.route.paramMap.pipe(takeUntil(this.destroy$)).subscribe((params) => { + this.routeTaskAbbreviation = params.get('taskAbbreviation'); + queueMicrotask(() => this.applyRouteTaskSelection()); + }); + } + + // Applies whatever the route currently names, using the task definitions that + // exist at the moment it runs. Called from the paramMap subscription and again + // from ngOnChanges, because the unit resolves progressively and taskDefinitions + // is usually still empty when the parameter first arrives. Without the second + // call a hard refresh on a deep link lands on nothing. + private applyRouteTaskSelection(): void { + const current = this.selectedTaskDefinition$.value; + + const nextTaskDefinition = this.routeTaskAbbreviation + ? (this.taskDefinitions?.find( + (taskDefinition) => taskDefinition.abbreviation === this.routeTaskAbbreviation, + ) ?? null) + : null; + + // An empty list means the unit has not finished resolving, not that the task + // is missing. Leave the selection alone and wait for ngOnChanges to call back + // once the definitions arrive. A loaded list that does not contain the + // abbreviation is a different thing and still clears the selection below. + if (this.routeTaskAbbreviation && !this.taskDefinitions?.length) { + return; + } + + if (nextTaskDefinition && this.isTaskDefinitionAboveTargetGrade(nextTaskDefinition)) { + this.viewPreferences = {...this.viewPreferences, showAboveTargetGrade: true}; + this.persistViewPreferences(); + this.applyFilters(); + } + + // The comparison is what stops a click looping. Selecting a task navigates, + // that navigation makes paramMap emit, and the emission comes straight back + // in here. It also collapses the duplicate write from the second rendered + // instance of this component in the parent template. + if (nextTaskDefinition !== current) { + this.selectedTaskDefinition$.next(nextTaskDefinition); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + setSelectedTaskDefinition(taskDef: TaskDefinition) { + if (this.isSelectedTaskDefinition(taskDef)) { + this.selectedTaskDefinition$.next(null); + this.replaceSelectionUrl(null); + } else { + this.selectedTaskDefinition$.next(taskDef); + this.replaceSelectionUrl(taskDef); + } + + // this.selectedTaskDefinition.emit(taskDef); + // const selectedTask = this.taskForTaskDef(taskDef); + // if (selectedTask) { + // this.selectedTask$.next(selectedTask); + // } + + //TODO: remove + // this.taskViewerService.setSelectedTaskDef(taskDef); + } + + public isSelectedTaskDefinition(taskDef: TaskDefinition): boolean { + return this.selectedTaskDef?.id === taskDef?.id; + } + + private replaceSelectionUrl(taskDef: TaskDefinition | null): void { + const urlTree = this.buildSelectionUrlTree(taskDef); + if (!urlTree) { + return; + } + + this.angularRouter.navigateByUrl(urlTree, {replaceUrl: true}); + } + + private buildSelectionUrlTree(taskDef: TaskDefinition | null) { + if (this.selectionUrlBase) { + return this.angularRouter.createUrlTree( + taskDef ? [...this.selectionUrlBase, taskDef.abbreviation] : this.selectionUrlBase, + ); + } + + const unitId = this.route.parent?.snapshot.paramMap.get('unitId'); + if (this.route.parent?.snapshot.data.unit && unitId) { + return this.angularRouter.createUrlTree( + taskDef ? ['/units', unitId, 'tasks', taskDef.abbreviation] : ['/units', unitId, 'tasks'], + ); + } + + const projectId = this.route.parent?.snapshot.paramMap.get('projectId'); + if (this.route.parent?.snapshot.data.project && projectId) { + return this.angularRouter.createUrlTree( + taskDef + ? ['/projects', projectId, 'dashboard', taskDef.abbreviation] + : ['/projects', projectId, 'dashboard'], + ); + } + + return null; + } + + private shouldShowTaskDefinition(taskDef: TaskDefinition): boolean { + if (!taskDef) { + return false; + } + + const task = this.taskForTaskDef(taskDef); + + if (this.viewPreferences.hideCompleted && task?.inFinalState() && !this.hasNewComments(task)) { + return false; + } + + return !( + !this.viewPreferences.showAboveTargetGrade && + this.isTaskDefinitionAboveTargetGrade(taskDef) && + !this.hasNewComments(task) + ); + } + + private isTaskDefinitionAboveTargetGrade(taskDef: TaskDefinition): boolean { + const targetGrade = this.effectiveTargetGrade; + + return targetGrade !== null && taskDef.targetGrade > targetGrade; + } + + private get effectiveTargetGrade(): number | null { + const targetGrade = this.targetGrade ?? this.project?.targetGrade; + + if (!this.project || targetGrade === undefined || targetGrade === null) { + return null; + } + + return targetGrade; + } + + private hasNewComments(task: Task): boolean { + return (task?.numNewComments ?? 0) > 0; + } + + private compareTaskDefinitions(a: TaskDefinition, b: TaskDefinition): number { + let result: number; + + switch (this.viewPreferences.sortBy) { + case 'abbreviation': + result = this.compareStrings(a?.abbreviation, b?.abbreviation); + break; + case 'targetDate': + result = this.compareDates(this.targetDateFor(a), this.targetDateFor(b)); + break; + case 'startDate': + result = this.compareDates(this.startDateFor(a), this.startDateFor(b)); + break; + case 'dueDate': + result = this.compareDates(this.feedbackDateFor(a), this.feedbackDateFor(b)); + break; + default: + result = this.defaultSortWeightFor(a) - this.defaultSortWeightFor(b); + } + + return this.viewPreferences.sortDirection === 'asc' ? result : result * -1; + } + + private refreshTopTaskWeights(): void { + if (this.project && this.hasTasks) { + this.project.calcTopTasks(); + } + } + + private defaultSortWeightFor(taskDef: TaskDefinition): number { + return this.taskForTaskDef(taskDef)?.topWeight ?? taskDef?.seq ?? Number.MAX_SAFE_INTEGER; + } + + private targetDateFor(taskDef: TaskDefinition): Date { + return this.taskForTaskDef(taskDef)?.localDueDate() ?? taskDef?.targetDate; + } + + private startDateFor(taskDef: TaskDefinition): Date { + return this.taskForTaskDef(taskDef)?.startDate ?? taskDef?.startDate; + } + + private feedbackDateFor(taskDef: TaskDefinition): Date { + return this.taskForTaskDef(taskDef)?.localDeadlineDate() ?? taskDef?.localDeadlineDate(); + } + + private compareDates(a: Date, b: Date): number { + const aTime = this.dateTime(a); + const bTime = this.dateTime(b); + + if (aTime === bTime) { + return 0; + } + + return aTime - bTime; + } + + private compareStrings(a: string, b: string): number { + return (a ?? '').localeCompare(b ?? '', undefined, { + numeric: true, + sensitivity: 'base', + }); + } + + private dateTime(date: Date): number { + const time = date ? new Date(date).getTime() : NaN; + return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER; + } + + private loadViewPreferences(): void { + const rawPreferences = this.viewPreferencesStorage?.getItem(this.viewPreferencesStorageKey); + + if (!rawPreferences) { + this.viewPreferences = {...DEFAULT_VIEW_PREFERENCES}; + return; + } + + try { + const parsedPreferences = JSON.parse(rawPreferences) as Partial; + const migratedSort = this.migrateSortOption(parsedPreferences.sortBy); + this.viewPreferences = { + sortBy: migratedSort.sortBy, + sortDirection: this.isSortDirection(parsedPreferences.sortDirection) + ? parsedPreferences.sortDirection + : migratedSort.sortDirection, + hideCompleted: !!parsedPreferences.hideCompleted, + // The legacy preference was named `hideAboveTargetGrade` and defaulted to false, + // which made higher-grade tasks visible. Treat legacy records as the new default. + showAboveTargetGrade: parsedPreferences.showAboveTargetGrade === true, + }; + } catch { + this.viewPreferences = {...DEFAULT_VIEW_PREFERENCES}; + } + } + + private persistViewPreferences(): void { + this.viewPreferencesStorage?.setItem( + this.viewPreferencesStorageKey, + JSON.stringify(this.viewPreferences), + ); + } + + private isSortOption(value: unknown): value is TaskListSortOption { + return this.sortOptions.some((option) => option.value === value); + } + + private isSortDirection(value: unknown): value is TaskListSortDirection { + return value === 'asc' || value === 'desc'; + } + + private migrateSortOption(value: unknown): { + sortBy: TaskListSortOption; + sortDirection: TaskListSortDirection; + } { + if (this.isSortOption(value)) { + return {sortBy: value, sortDirection: DEFAULT_VIEW_PREFERENCES.sortDirection}; + } + + const migrations: Record< + string, + {sortBy: TaskListSortOption; sortDirection: TaskListSortDirection} + > = { + abbreviationAsc: {sortBy: 'abbreviation', sortDirection: 'asc'}, + abbreviationDesc: {sortBy: 'abbreviation', sortDirection: 'desc'}, + targetDateAsc: {sortBy: 'targetDate', sortDirection: 'asc'}, + targetDateDesc: {sortBy: 'targetDate', sortDirection: 'desc'}, + startDateAsc: {sortBy: 'startDate', sortDirection: 'asc'}, + startDateDesc: {sortBy: 'startDate', sortDirection: 'desc'}, + feedbackDateAsc: {sortBy: 'dueDate', sortDirection: 'asc'}, + feedbackDateDesc: {sortBy: 'dueDate', sortDirection: 'desc'}, + }; + + return migrations[value as string] ?? DEFAULT_VIEW_PREFERENCES; + } + + private get toggledSortDirection(): TaskListSortDirection { + return this.viewPreferences.sortDirection === 'asc' ? 'desc' : 'asc'; + } + + private get viewPreferencesStorageKey(): string { + const unitId = this.project?.unit?.id ?? this.taskDefinitions?.[0]?.unit?.id ?? 'unknown'; + return `ontrack.unitTaskList.${unitId}.viewPreferences`; + } + + private get viewPreferencesStorage(): Storage | null { + try { + return globalThis.localStorage ?? null; + } catch { + return null; + } + } +} diff --git a/src/app/units/task-viewer/task-viewer-state.component.html b/src/app/units/task-viewer/task-viewer-state.component.html new file mode 100644 index 0000000000..33a824d88e --- /dev/null +++ b/src/app/units/task-viewer/task-viewer-state.component.html @@ -0,0 +1,58 @@ +@if (unit$ | async; as unit) { +
+ +
+
+ +
+ +
+
+ +
+ @if (selectedTaskDefinition$ | async; as selectedTaskDef) { +
+
+ +
+ +
+ } +
+
+
+ +
+
+ +
+
+
+ +
+
+ +
+ +
+
+
+} diff --git a/src/app/units/task-viewer/task-viewer-state.component.scss b/src/app/units/task-viewer/task-viewer-state.component.scss new file mode 100644 index 0000000000..f2e8670fcd --- /dev/null +++ b/src/app/units/task-viewer/task-viewer-state.component.scss @@ -0,0 +1,30 @@ +.vertical-panel { + border-radius: 10px; + background-color: white; + padding: 8px; + min-width: 60px; + width: 450px; + height: 100%; + + &.mobile { + max-width: 100%; + width: 100%; + padding: 0px; + } +} + +.vertical-spacer { + background-color: #f5f5f5; + z-index: 200; + width: 10px; +} + +// HACK - issue access this from the main style.scss +$main-view-max-height: calc((var(--vh, 1vh) * (100)) - 85px); + +f-task-viewer-state { + height: calc( + $main-view-max-height + 70px + ); // temporary fix to reclaim the footer region for this specific page + display: flex; +} diff --git a/src/app/units/task-viewer/task-viewer-state.component.ts b/src/app/units/task-viewer/task-viewer-state.component.ts new file mode 100644 index 0000000000..66aa9cd90c --- /dev/null +++ b/src/app/units/task-viewer/task-viewer-state.component.ts @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; +import {ActivatedRoute, Router} from '@angular/router'; +import {BehaviorSubject, Observable} from 'rxjs'; +import {of} from 'rxjs'; +import {TaskDefinition, Unit} from 'src/app/api/models/doubtfire-model'; + +@Component({ + selector: 'f-task-viewer-state', + templateUrl: './task-viewer-state.component.html', + styleUrl: './task-viewer-state.component.scss', + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class TaskViewerStateComponent { + @Input() public unit$: Observable; + + constructor( + private route: ActivatedRoute, + private router: Router, + ) { + this.unit$ = of(this.route.parent.snapshot.data.unit); + + // The task abbreviation is deliberately not read here. This component is + // reused when only the route parameter changes, so a snapshot read runs once + // and leaves the first task selected for the life of the screen. The child + // task list follows route.paramMap instead, and it is the one component + // shared by all three routes that carry a :taskAbbreviation, so the read + // belongs there and only there. + } + + /** + * Monitor and publish the selected task definition for child components. + * We monitor the task definition list for changes in selected task definition. + */ + selectedTaskDefinition$: BehaviorSubject = new BehaviorSubject( + null, + ); + + public get taskSelected(): boolean { + return this.selectedTaskDef !== null; + } + + public get selectedTaskDef(): TaskDefinition { + return this.selectedTaskDefinition$.value; + } + + public clearTaskSelection(): void { + this.selectedTaskDefinition$.next(null); + if (this.route.parent?.snapshot.data.unit) { + this.router.navigate(['../tasks'], {relativeTo: this.route, replaceUrl: true}); + return; + } + } +} diff --git a/src/app/units/unit-root-state.component.css b/src/app/units/unit-root-state.component.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/units/unit-root-state.component.html b/src/app/units/unit-root-state.component.html new file mode 100644 index 0000000000..945f7bea61 --- /dev/null +++ b/src/app/units/unit-root-state.component.html @@ -0,0 +1,10 @@ +@if (unit || (unit$ | async)) { + +} @else { +
+ +

Loading unit details...

+
+} diff --git a/src/app/units/unit-root-state.component.spec.ts b/src/app/units/unit-root-state.component.spec.ts new file mode 100644 index 0000000000..8b4a005948 --- /dev/null +++ b/src/app/units/unit-root-state.component.spec.ts @@ -0,0 +1,90 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import {Component, Input, NO_ERRORS_SCHEMA} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {ActivatedRouteSnapshot, Router, RouterModule, provideRouter} from '@angular/router'; +import {Observable, firstValueFrom, of} from 'rxjs'; +import {Unit} from 'src/app/api/models/unit'; +import {UnitRootStateComponent} from './unit-root-state.component'; + +// The two components below are inert test stubs, so their one-line templates belong +// here rather than in files of their own. +/* eslint-disable @angular-eslint/component-max-inline-declarations */ + +function unitStub(id: number): Unit { + return {id} as unknown as Unit; +} + +@Component({ + selector: 'f-unit-child-stub', + template: '', + standalone: false, +}) +class UnitChildStubComponent { + @Input() public unit$: Observable; +} + +@Component({ + selector: 'f-unit-root-host', + template: '', + standalone: false, +}) +class UnitRootHostComponent {} + +describe('UnitRootStateComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [UnitRootStateComponent, UnitChildStubComponent, UnitRootHostComponent], + imports: [RouterModule], + providers: [ + provideRouter([ + { + path: 'units/:unitId', + component: UnitRootStateComponent, + resolve: { + unit: (route: ActivatedRouteSnapshot) => + of(unitStub(Number(route.paramMap.get('unitId')))), + }, + children: [{path: 'students', component: UnitChildStubComponent}], + }, + ]), + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + // Guards the (activate) binding on the outlet. Without it the children fall back to + // their own snapshot read and the whole fix is inert, with no other test noticing. + it('hands its unit stream to the child the outlet activates, and keeps it on the url', async () => { + const fixture = TestBed.createComponent(UnitRootHostComponent); + const router = TestBed.inject(Router); + fixture.detectChanges(); + + await router.navigateByUrl('/units/1/students'); + fixture.detectChanges(); + + const child = fixture.debugElement.query(By.directive(UnitChildStubComponent)) + .componentInstance as UnitChildStubComponent; + expect(child.unit$).toBeDefined(); + expect(await firstValueFrom(child.unit$)).toMatchObject({id: 1}); + + await router.navigateByUrl('/units/2/students'); + fixture.detectChanges(); + + const reused = fixture.debugElement.query(By.directive(UnitChildStubComponent)) + .componentInstance as UnitChildStubComponent; + expect(reused).toBe(child); + expect(await firstValueFrom(child.unit$)).toMatchObject({id: 2}); + }); + + it('leaves a child that already built its own unit stream alone', () => { + const fixture = TestBed.createComponent(UnitRootStateComponent); + const root = fixture.componentInstance; + const own = of(unitStub(9)); + const child: {unit$?: Observable} = {unit$: own}; + + root.onActivate(child); + + expect(child.unit$).toBe(own); + }); +}); diff --git a/src/app/units/unit-root-state.component.ts b/src/app/units/unit-root-state.component.ts new file mode 100644 index 0000000000..ba36b3ad28 --- /dev/null +++ b/src/app/units/unit-root-state.component.ts @@ -0,0 +1,52 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import {ChangeDetectionStrategy, Component, Input, OnInit} from '@angular/core'; +import {ActivatedRoute} from '@angular/router'; +import {Observable, filter, map, shareReplay} from 'rxjs'; +import {Unit} from 'src/app/api/models/doubtfire-model'; + +interface UnitRouteChild { + unit$?: Observable; +} + +@Component({ + selector: 'f-unit-root-state', + templateUrl: './unit-root-state.component.html', + styleUrl: './unit-root-state.component.css', + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class UnitRootStateComponent implements OnInit { + @Input() public unit$: Observable; + public unit: Unit; + + // route.data emits again whenever the unit resolver re-runs, and it re-runs whenever + // :unitId changes, so this stream follows the url even when the router reuses the + // component instances underneath it. + public readonly routeUnit$ = this.route.data.pipe( + map((data) => data.unit as Unit), + filter((unit): unit is Unit => unit != null), + shareReplay({bufferSize: 1, refCount: true}), + ); + + constructor(private route: ActivatedRoute) {} + + ngOnInit(): void { + this.route.data.subscribe((data) => { + this.unit = data.unit; + }); + } + + // The outlet emits this after creating the child but before its first change + // detection, so the stream is in place before the child runs ngOnInit. + // + // project-root-state guards the same assignment with `'project$' in component`. + // That check cannot work here: useDefineForClassFields is off, so an @Input with + // no initialiser is never written to the instance and the `in` test is always + // false. Guarding on the value instead leaves a child that has already built its + // own unit$ alone, which keeps task-viewer-state on the behaviour RTE-02 owns. + onActivate(component: UnitRouteChild): void { + if (component && !component.unit$) { + component.unit$ = this.unit$ ?? this.routeUnit$; + } + } +} diff --git a/src/app/units/unit.resolver.ts b/src/app/units/unit.resolver.ts new file mode 100644 index 0000000000..6a7be4417b --- /dev/null +++ b/src/app/units/unit.resolver.ts @@ -0,0 +1,81 @@ +import {inject} from '@angular/core'; +import {ResolveFn} from '@angular/router'; +import {Observable, first} from 'rxjs'; +import {Unit, UnitRole, UnitService, UserService} from 'src/app/api/models/doubtfire-model'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {GlobalStateService, ViewType} from 'src/app/projects/states/index/global-state.service'; + +export const resolveUnit: ResolveFn = (route, state) => { + const unitService = inject(UnitService); + const globalState = inject(GlobalStateService); + const userService = inject(UserService); + const alertService = inject(AlertService); + const unitId = Number(route.paramMap.get('unitId')); + + return new Observable((observer) => { + globalState.onLoad(() => { + let unitRole = globalState.loadedUnitRoles.currentValues.find( + (role) => role.unit.id === unitId, + ); + + if ( + !unitRole && + (userService.currentUser.role === 'Admin' || userService.currentUser.role === 'Auditor') + ) { + unitRole = userService.adminOrAuditorRoleFor( + userService.currentUser.role, + unitId, + userService.currentUser, + ); + } + + const resolveProgressively = shouldResolveUnitProgressively(state.url, unitId); + if (resolveProgressively) { + const unit = + unitRole?.unit ?? unitService.cache.getOrCreate(unitId, unitService, {id: unitId}); + globalState.setView(ViewType.UNIT, routeEntity(unit, unitRole)); + observer.next(unit); + observer.complete(); + return; + } + + unitService.get(unitId).subscribe({ + next: (unit) => { + globalState.setView(ViewType.UNIT, routeEntity(unit, unitRole)); + if (!resolveProgressively) { + observer.next(unit); + observer.complete(); + } + }, + error: (err) => { + if (unitRole?.unit) { + globalState.setView(ViewType.UNIT, unitRole); + if (!resolveProgressively) { + observer.next(unitRole.unit); + observer.complete(); + } + return; + } + + alertService.error('Error loading unit: ' + err, 8000); + observer.error(err); + }, + }); + }); + }).pipe(first()); +}; + +// Task routes can activate their outer shell while their component fetches detailed data. +function shouldResolveUnitProgressively(url: string, unitId: number): boolean { + const pathname = url.split('?')[0]; + return new RegExp(`^/units/${unitId}/tasks(?:/|$)`).test(pathname); +} + +function routeEntity(unit: Unit, unitRole?: UnitRole): Unit | UnitRole { + if (!unitRole) { + return unit; + } + + unitRole.unit = unit; + return unitRole; +} diff --git a/src/app/units/units.coffee b/src/app/units/units.coffee deleted file mode 100644 index 1aab3228fc..0000000000 --- a/src/app/units/units.coffee +++ /dev/null @@ -1,4 +0,0 @@ -angular.module('doubtfire.units', [ - 'doubtfire.units.modals' - 'doubtfire.units.states' -]) diff --git a/src/app/visualisations/achievement-box-plot.coffee b/src/app/visualisations/achievement-box-plot.coffee deleted file mode 100644 index 7ef7646829..0000000000 --- a/src/app/visualisations/achievement-box-plot.coffee +++ /dev/null @@ -1,67 +0,0 @@ -angular.module('doubtfire.visualisations.achievement-box-plot', []) -.directive 'achievementBoxPlot', -> - replace: true - restrict: 'E' - templateUrl: 'visualisations/visualisation.tpl.html' - scope: - rawData: '=data' - type: '=' - unit: '=' - pctHolder: '=' - height: '=?' - showLegend: '=?' - controller: ($scope, $timeout, Visualisation, outcomeService, $sce) -> - $scope.showLegend = unless $scope.showLegend? then true else $scope.showLegend - $scope.height = unless $scope.height? then 600 else $scope.height - - iloValues = outcomeService.calculateTargets($scope.unit, $scope.unit, $scope.unit.taskStatusFactor) - iloMaxes = _.mapValues iloValues, (d, k) -> _.reduce d, ((memo, value) -> memo + value), 0 - rangeMax = _.max _.values(iloMaxes) - - refreshData = (newData) -> - isPct = ($scope.pctHolder? and $scope.pctHolder.pct) - if isPct - $scope.options.chart.yDomain = [0, 1] - else - $scope.options.chart.yDomain = [0, rangeMax] - - $scope.data = _.map newData, (d, id) -> - max = iloMaxes[id] - if (! max?) or max == 0 or ! isPct - max = 1 - label = _.find($scope.unit.ilos, { id: +id }).abbreviation - { - label: $sce.getTrustedHtml(label), - values: { - Q1: d.lower / max - Q2: d.median / max - Q3: d.upper / max - whisker_low: d.min / max - whisker_high: d.max / max - } - } - $timeout -> - if $scope.api?.refresh? - $scope.api.refresh() - - [$scope.options, $scope.config] = Visualisation 'boxPlotChart', 'ILO Achievement Box Plot', { - x: (d) -> d.label - height: $scope.height - showXAxis: $scope.showLegend - margin: - top: if $scope.showLegend then 20 else 20 - right: if $scope.showLegend then 10 else 10 - bottom: if $scope.showLegend then 60 else 20 - left: if $scope.showLegend then 80 else 40 - yAxis: - axisLabel: if $scope.showLegend then "ILO Achievement" - tooltip: - enabled: $scope.showLegend - maxBoxWidth: 75 - yDomain: [0, 1] - }, {}, 'Achievement Box Plot' - - # $scope.$watch 'rawData', refreshData($scope.rawData) - $scope.$watch 'pctHolder.pct', (newData, oldData) -> if newData != oldData then refreshData($scope.rawData) - - refreshData($scope.rawData) diff --git a/src/app/visualisations/achievement-custom-bar-chart.coffee b/src/app/visualisations/achievement-custom-bar-chart.coffee deleted file mode 100644 index 6eafad4c6d..0000000000 --- a/src/app/visualisations/achievement-custom-bar-chart.coffee +++ /dev/null @@ -1,677 +0,0 @@ -angular.module('doubtfire.visualisations.achievement-custom-bar-chart', []) -.directive 'achievementCustomBarChart', -> - replace: true - restrict: 'E' - templateUrl: 'visualisations/visualisation.tpl.html' - scope: - project: '=' - unit: '=' - - controller: ($scope, Visualisation, outcomeService, gradeService, $sce) -> - $scope.showLegend = if $scope.showLegend? then $scope.showLegend else true - unless nv.models.achievementBar? - nv.models.achievementBar = -> - chart = (selection) -> - renderWatch.reset() - selection.each (data) -> - availableWidth = width - (margin.left) - (margin.right) - availableHeight = height - (margin.top) - (margin.bottom) - container = d3.select(this) - nv.utils.initSVG container - #add series index to each data point for reference - data.forEach (series, i) -> - series.values.forEach (point) -> - point.series = i - return - return - # Setup Scales - # remap and flatten the data for use in calculating the scales' domains - seriesData = if xDomain and yDomain then [] else data.map(((d) -> - d.values.map ((d, i) -> - { - x: getX(d, i) - y: getY(d, i) - y0: d.y0 - } - ) - )) - x.domain(xDomain or d3.merge(seriesData).map((d) -> - d.x - )).rangeBands xRange or [ - 0 - availableWidth - ], .1 - y.domain yDomain or d3.extent(d3.merge(seriesData).map((d) -> - d.y - ).concat(forceY)) - # If showValues, pad the Y axis range to account for label height - if showValues - y.range yRange or [ - availableHeight - (if y.domain()[0] < 0 then 12 else 0) - if y.domain()[1] > 0 then 12 else 0 - ] - else - y.range yRange or [ - availableHeight - 0 - ] - #store old scales if they exist - x0 = x0 or x - y0 = y0 or y.copy().range([ - y(0) - y(0) - ]) - # Setup containers and skeleton of chart - wrap = container.selectAll('g.nv-wrap.nv-discretebar').data([ data ]) - wrapEnter = wrap.enter().append('g').attr('class', 'nvd3 nv-wrap nv-discretebar') - gEnter = wrapEnter.append('g') - g = wrap.select('g') - gEnter.append('g').attr 'class', 'nv-groups' - wrap.attr 'transform', 'translate(' + margin.left + ',' + margin.top + ')' - # TODO: (@macite) by definition, the discrete bar should not have multiple groups, will modify/remove later - groups = wrap.select('.nv-groups').selectAll('.nv-group').data(((d) -> - d - ), (d) -> - d.key - ) - groups.enter().append('g').style('stroke-opacity', 1e-6).style 'fill-opacity', 1e-6 - groups.exit().watchTransition(renderWatch, 'discreteBar: exit groups').style('stroke-opacity', 1e-6).style('fill-opacity', 1e-6).remove() - groups.attr('class', (d, i) -> - 'nv-group nv-series-' + i - ) - groups.watchTransition(renderWatch, 'discreteBar: groups').style('stroke-opacity', 1).style 'fill-opacity', .75 - - if targets? && _.size(targets) > 0 - # Create the background bars... match one set per ILO - backBarSeries = groups.selectAll('g.nv-backBarSeries').data((d) -> - d.values - ) - backBarSeries.exit().remove() - backBarSeries.enter().append('g') - backBarSeries.attr('class', (d, i) -> - 'nv-backBarSeries nv-backSeries-' + i - ).classed 'hover', (d) -> - d.hover - - backBars = backBarSeries.selectAll('g.nv-backBar').data( (d,i) -> _.map(_.values(d.targets), (v) -> { value: v, key: d.label } ) ) - backBars.exit().remove() - backBarsEnter = backBars.enter().append('g') - - backBarsEnter.append('rect').attr('height', 10).attr 'width', x.rangeBand() * 0.9 / data.length - backBarsEnter.on('mouseover', (d, i) -> - d3.select(this).classed 'hover', true - dispatch.elementMouseover - data: gradeService.grades[i] - index: i - color: d.value.color - return - ).on('mouseout', (d, i) -> - d3.select(this).classed 'hover', false - dispatch.elementMouseout - data: gradeService.grades[i] - index: i - color: d.value.color - return - ).on('mousemove', (d, i) -> - dispatch.elementMousemove - data: gradeService.grades[i] - index: i - color: d.value.color - return - ).on('click', (d, i) -> - element = this - dispatch.elementClick - data: gradeService.grades[i] - index: i - color: d.value.color - event: d3.event - element: element - d3.event.stopPropagation() - return - ).on('dblclick', (d, i) -> - dispatch.elementDblClick - data: gradeService.grades[i] - index: i - color: d.value.color - d3.event.stopPropagation() - return - ) - - backBars.attr('class', (d, i, j) -> - if d.value.height < 0 then 'nv-backBar negative' else 'nv-backBar positive' - ).select('rect').attr('class', rectClass).style('fill-opacity', '0.2'). - style('fill', (d) -> d.value.color). - watchTransition(renderWatch, 'discreteBar: backBars rect').attr 'width', x.rangeBand() * .9 / data.length - backBars.watchTransition(renderWatch, 'discreteBar: backBars').attr('transform', (d, i, j) -> - left = x(d.key) + x.rangeBand() * .05 - top = y(d.value.height + d.value.offset) - 'translate(' + left + ', ' + top + ')' - ).select('rect').attr 'height', (d, i, j) -> - Math.max Math.abs(y(d.value.height) - y(0)), 1 - - bars = groups.selectAll('g.nv-bar').data((d) -> - d.values - ) - bars.exit().remove() - barsEnter = bars.enter().append('g').on('mouseover', (d, i) -> - # TODO: (@macite) figure out why j works above, but not here - d3.select(this).classed 'hover', true - dispatch.elementMouseover - data: d - index: i - color: d3.select(this).style('fill') - return - ).on('mouseout', (d, i) -> - d3.select(this).classed 'hover', false - dispatch.elementMouseout - data: d - index: i - color: d3.select(this).style('fill') - return - ).on('mousemove', (d, i) -> - dispatch.elementMousemove - data: d - index: i - color: d3.select(this).style('fill') - return - ).on('click', (d, i) -> - element = this - dispatch.elementClick - data: d - index: i - color: d3.select(this).style('fill') - event: d3.event - element: element - d3.event.stopPropagation() - return - ).on('dblclick', (d, i) -> - dispatch.elementDblClick - data: d - index: i - color: d3.select(this).style('fill') - d3.event.stopPropagation() - return - ) - - barsEnter.append('rect').attr('height', 0).attr 'width', x.rangeBand() * .5 / data.length - if showValues - barsEnter.append('text').attr 'text-anchor', 'middle' - bars.select('text').text((d, i) -> - valueFormat getY(d, i) - ).watchTransition(renderWatch, 'discreteBar: bars text').attr('x', x.rangeBand() * .5 / 2).attr 'y', (d, i) -> - if getY(d, i) < 0 then y(getY(d, i)) - y(0) + 12 else -4 - else - bars.selectAll('text').remove() - bars.attr('class', (d, i) -> - if getY(d, i) < 0 then 'nv-bar negative' else 'nv-bar positive' - ).style('fill', (d, i) -> - d.color or color(d, i) - ).style('stroke', (d, i) -> - d.color or color(d, i) - ).select('rect').attr('class', rectClass).watchTransition(renderWatch, 'discreteBar: bars rect').attr 'width', x.rangeBand() * .5 / data.length - bars.watchTransition(renderWatch, 'discreteBar: bars').attr('transform', (d, i) -> - left = x(getX(d, i)) + x.rangeBand() * .25 - top = if getY(d, i) < 0 then y(0) else if y(0) - y(getY(d, i)) < 1 then y(0) - 1 else y(getY(d, i)) - 'translate(' + left + ', ' + top + ')' - ).select('rect').attr 'height', (d, i) -> - Math.max Math.abs(y(getY(d, i)) - y(0)), 1 - #store old scales for use in transitions on update - x0 = x.copy() - y0 = y.copy() - return - renderWatch.renderEnd 'achievementBar immediate' - chart - - 'use strict' - #============================================================ - # Public Variables with Default Settings - #------------------------------------------------------------ - margin = - top: 0 - right: 0 - bottom: 0 - left: 0 - width = 960 - height = 500 - id = Math.floor(Math.random() * 10000) - container = undefined - x = d3.scale.ordinal() - y = d3.scale.linear() - - getX = (d) -> - d.x - - getY = (d) -> - d.y - - forceY = [ 0 ] - color = nv.utils.defaultColor() - showValues = false - valueFormat = d3.format(',.2f') - xDomain = undefined - yDomain = undefined - xRange = undefined - yRange = undefined - dispatch = d3.dispatch('chartClick', 'elementClick', 'elementDblClick', 'elementMouseover', 'elementMouseout', 'elementMousemove', 'renderEnd') - rectClass = 'discreteBar' - duration = 250 - #============================================================ - # Private Variables - #------------------------------------------------------------ - x0 = undefined - y0 = undefined - renderWatch = nv.utils.renderWatch(dispatch, duration) - #============================================================ - # Expose Public Variables - #------------------------------------------------------------ - chart.dispatch = dispatch - chart.options = nv.utils.optionsFunc.bind(chart) - chart._options = Object.create({}, - width: - get: -> - width - set: (_) -> - width = _ - return - height: - get: -> - height - set: (_) -> - height = _ - return - forceY: - get: -> - forceY - set: (_) -> - forceY = _ - return - showValues: - get: -> - showValues - set: (_) -> - showValues = _ - return - x: - get: -> - getX - set: (_) -> - getX = _ - return - y: - get: -> - getY - set: (_) -> - getY = _ - return - xScale: - get: -> - x - set: (_) -> - x = _ - return - yScale: - get: -> - y - set: (_) -> - y = _ - return - xDomain: - get: -> - xDomain - set: (_) -> - xDomain = _ - return - yDomain: - get: -> - yDomain - set: (_) -> - yDomain = _ - return - xRange: - get: -> - xRange - set: (_) -> - xRange = _ - return - yRange: - get: -> - yRange - set: (_) -> - yRange = _ - return - valueFormat: - get: -> - valueFormat - set: (_) -> - valueFormat = _ - return - id: - get: -> - id - set: (_) -> - id = _ - return - rectClass: - get: -> - rectClass - set: (_) -> - rectClass = _ - return - margin: - get: -> - margin - set: (_) -> - margin.top = if _.top != undefined then _.top else margin.top - margin.right = if _.right != undefined then _.right else margin.right - margin.bottom = if _.bottom != undefined then _.bottom else margin.bottom - margin.left = if _.left != undefined then _.left else margin.left - return - color: - get: -> - color - set: (_) -> - color = nv.utils.getColor(_) - return - duration: - get: -> - duration - set: (_) -> - duration = _ - renderWatch.reset duration - return - ) - nv.utils.initOptions chart - chart - - # - # Chart that contains stacked bars in the background, overlaid by other bars... - # - nv.models.achievementBarChart = -> - chart = (selection) -> - renderWatch.reset() - renderWatch.models achievementbar - if showXAxis - renderWatch.models xAxis - if showYAxis - renderWatch.models yAxis - selection.each (data) -> - container = d3.select(this) - that = this - nv.utils.initSVG container - availableWidth = nv.utils.availableWidth(width, container, margin) - availableHeight = nv.utils.availableHeight(height, container, margin) - - chart.update = -> - dispatch.beforeUpdate() - container.transition().duration(duration).call chart - return - - chart.container = this - - # Display No Data message if there's nothing to show. - if (!data) or (!data.length) or data.filter(((d) -> d.values.length)).length <= 0 - nv.utils.noData chart, container - return chart - else - container.selectAll('.nv-noData').remove() - - # Setup Scales - x = achievementbar.xScale() - y = achievementbar.yScale().clamp(true) - # Setup containers and skeleton of chart - wrap = container.selectAll('g.nv-wrap.nv-discreteBarWithAxes').data([ data ]) - gEnter = wrap.enter().append('g').attr('class', 'nvd3 nv-wrap nv-discreteBarWithAxes').append('g') - defsEnter = gEnter.append('defs') - g = wrap.select('g') - gEnter.append('g').attr 'class', 'nv-x nv-axis' - gEnter.append('g').attr('class', 'nv-y nv-axis').append('g').attr('class', 'nv-zeroLine').append 'line' - gEnter.append('g').attr 'class', 'nv-barsWrap' - gEnter.append('g').attr 'class', 'nv-legendWrap' - g.attr 'transform', 'translate(' + margin.left + ',' + margin.top + ')' - if showLegend - legend.width availableWidth - g.select('.nv-legendWrap').datum(data).call legend - if margin.top != legend.height() - margin.top = legend.height() - availableHeight = nv.utils.availableHeight(height, container, margin) - wrap.select('.nv-legendWrap').attr 'transform', 'translate(0,' + -margin.top + ')' - if rightAlignYAxis - g.select('.nv-y.nv-axis').attr 'transform', 'translate(' + availableWidth + ',0)' - if rightAlignYAxis - g.select('.nv-y.nv-axis').attr 'transform', 'translate(' + availableWidth + ',0)' - # Main Chart Component(s) - achievementbar.width(availableWidth).height availableHeight - barsWrap = g.select('.nv-barsWrap').datum(data.filter((d) -> - !d.disabled - )) - barsWrap.transition().call achievementbar - defsEnter.append('clipPath').attr('id', 'nv-x-label-clip-' + achievementbar.id()).append 'rect' - g.select('#nv-x-label-clip-' + achievementbar.id() + ' rect').attr('width', x.rangeBand() * (if staggerLabels then 2 else 1)).attr('height', 16).attr 'x', -x.rangeBand() / (if staggerLabels then 1 else 2) - # Setup Axes - if showXAxis - xAxis.scale(x)._ticks(nv.utils.calcTicksX(availableWidth / 100, data)).tickSize -availableHeight, 0 - g.select('.nv-x.nv-axis').attr 'transform', 'translate(0,' + (y.range()[0] + (if achievementbar.showValues() and y.domain()[0] < 0 then 16 else 0)) + ')' - g.select('.nv-x.nv-axis').call xAxis - xTicks = g.select('.nv-x.nv-axis').selectAll('g') - if staggerLabels - xTicks.selectAll('text').attr 'transform', (d, i, j) -> - 'translate(0,' + (if j % 2 == 0 then '5' else '17') + ')' - if rotateLabels - xTicks.selectAll('.tick text').attr('transform', 'rotate(' + rotateLabels + ' 0,0)').style 'text-anchor', if rotateLabels > 0 then 'start' else 'end' - if wrapLabels - g.selectAll('.tick text').call nv.utils.wrapTicks, chart.xAxis.rangeBand() - if showYAxis - yAxis.scale(y)._ticks(nv.utils.calcTicksY(availableHeight / 36, data)).tickSize -availableWidth, 0 - g.select('.nv-y.nv-axis').call yAxis - # Zero line - g.select('.nv-zeroLine line').attr('x1', 0).attr('x2', if rightAlignYAxis then -availableWidth else availableWidth).attr('y1', y(0)).attr 'y2', y(0) - return - renderWatch.renderEnd 'achievementBar chart immediate' - chart - - 'use strict' - #============================================================ - # Public Variables with Default Settings - #------------------------------------------------------------ - achievementbar = nv.models.achievementBar() - xAxis = nv.models.axis() - yAxis = nv.models.axis() - legend = nv.models.legend() - tooltip = nv.models.tooltip() - margin = - top: 15 - right: 10 - bottom: 50 - left: 60 - width = null - height = null - color = nv.utils.getColor() - showLegend = false - showXAxis = true - showYAxis = true - rightAlignYAxis = false - staggerLabels = false - wrapLabels = false - rotateLabels = 0 - x = undefined - y = undefined - noData = null - dispatch = d3.dispatch('beforeUpdate', 'renderEnd') - duration = 250 - xAxis.orient('bottom').showMaxMin(false).tickFormat (d) -> - d - yAxis.orient(if rightAlignYAxis then 'right' else 'left').tickFormat d3.format(',.1f') - tooltip.duration(0).headerEnabled(false).keyFormatter (d, i) -> - xAxis.tickFormat() d, i - #============================================================ - # Private Variables - #------------------------------------------------------------ - renderWatch = nv.utils.renderWatch(dispatch, duration) - #============================================================ - # Event Handling/Dispatching (out of chart's scope) - #------------------------------------------------------------ - achievementbar.dispatch.on 'elementMouseover.tooltip', (evt) -> - key = chart.x()(evt.data) - unless key? - key = "#{evt.data} task range" - else - key = "Your progress with #{key}" - evt['series'] = - key: key - # value: chart.y()(evt.data) - color: evt.color - tooltip.data(evt).hidden false - return - achievementbar.dispatch.on 'elementMouseout.tooltip', (evt) -> - tooltip.hidden true - return - achievementbar.dispatch.on 'elementMousemove.tooltip', (evt) -> - tooltip() - return - #============================================================ - # Expose Public Variables - #------------------------------------------------------------ - chart.dispatch = dispatch - chart.achievementbar = achievementbar - chart.legend = legend - chart.xAxis = xAxis - chart.yAxis = yAxis - chart.tooltip = tooltip - chart.options = nv.utils.optionsFunc.bind(chart) - chart._options = Object.create({}, - width: - get: -> - width - set: (_) -> - width = _ - return - height: - get: -> - height - set: (_) -> - height = _ - return - showLegend: - get: -> - showLegend - set: (_) -> - showLegend = _ - return - staggerLabels: - get: -> - staggerLabels - set: (_) -> - staggerLabels = _ - return - rotateLabels: - get: -> - rotateLabels - set: (_) -> - rotateLabels = _ - return - wrapLabels: - get: -> - wrapLabels - set: (_) -> - wrapLabels = ! !_ - return - showXAxis: - get: -> - showXAxis - set: (_) -> - showXAxis = _ - return - showYAxis: - get: -> - showYAxis - set: (_) -> - showYAxis = _ - return - noData: - get: -> - noData - set: (_) -> - noData = _ - return - margin: - get: -> - margin - set: (_) -> - margin.top = if _.top != undefined then _.top else margin.top - margin.right = if _.right != undefined then _.right else margin.right - margin.bottom = if _.bottom != undefined then _.bottom else margin.bottom - margin.left = if _.left != undefined then _.left else margin.left - return - duration: - get: -> - duration - set: (_) -> - duration = _ - renderWatch.reset duration - achievementbar.duration duration - xAxis.duration duration - yAxis.duration duration - return - color: - get: -> - color - set: (_) -> - color = nv.utils.getColor(_) - achievementbar.color color - legend.color color - return - rightAlignYAxis: - get: -> - rightAlignYAxis - set: (_) -> - rightAlignYAxis = _ - yAxis.orient if _ then 'right' else 'left' - return - ) - nv.utils.inheritOptions chart, achievementbar - nv.utils.initOptions chart - chart - - - # - # Get the data and options for the chart... - # - targets = outcomeService.calculateTargets($scope.unit, $scope.unit, $scope.unit.taskStatusFactor) - currentProgress = outcomeService.calculateProgress($scope.unit, $scope.project) - - achievementData = { - key: "Learning Achievement" - values: [] - } - - max = 0 - - _.each $scope.unit.ilos, (ilo) -> - iloTargets = { } - iloTargets[0] = { offset: 0, height: targets[ilo.id][0], color: gradeService.gradeColors.P } - iloTargets[1] = { offset: iloTargets[0].offset + iloTargets[0].height, height: targets[ilo.id][1], color: gradeService.gradeColors.C } - iloTargets[2] = { offset: iloTargets[1].offset + iloTargets[1].height, height: targets[ilo.id][2], color: gradeService.gradeColors.D } - iloTargets[3] = { offset: iloTargets[2].offset + iloTargets[2].height, height: targets[ilo.id][3], color: gradeService.gradeColors.HD } - - if iloTargets[3].offset + iloTargets[3].height > max - max = iloTargets[3].offset + iloTargets[3].height - - achievementData.values.push { - label: $sce.getTrustedHtml(ilo.name) - value: currentProgress[0][ilo.id] # 0 = staff value - targets: iloTargets - } - - [$scope.options, $scope.config] = Visualisation 'achievementBarChart', 'ILO Achievement Bar Chart', { - height: 600 - duration: 500 - yDomain: [0, max] - showValues: false - showYAxis: false - showLegend: false - x: (d) -> d.label - y: (d) -> d.value - color: (d) -> '#373737' - }, {} - - $scope.data = [ achievementData ] diff --git a/src/app/visualisations/alignment-bar-chart.coffee b/src/app/visualisations/alignment-bar-chart.coffee deleted file mode 100644 index b137cb1dbe..0000000000 --- a/src/app/visualisations/alignment-bar-chart.coffee +++ /dev/null @@ -1,41 +0,0 @@ -angular.module('doubtfire.visualisations.alignment-bar-chart', []) -.directive 'alignmentBarChart', -> - replace: true - restrict: 'E' - templateUrl: 'visualisations/visualisation.tpl.html' - scope: - project: '=' - unit: '=' - source: '=' - taskStatusFactor: '=' - controller: ($scope, Visualisation, gradeService, outcomeService) -> - xFn = (d) -> d.label - yFn = (d) -> d.value - - [$scope.options, $scope.config] = Visualisation 'multiBarChart', 'ILO Alignment Bar Chart', { - clipEdge: yes - stacked: no - height: 200 - duration: 500 - color: (d) -> - gradeService.gradeColors[gradeService.gradeAcronyms[d.key]] - x: xFn - y: yFn - forceY: 0 - showYAxis: no - }, {} - - $scope.data = [] - - $scope.calculateAlignmentVisualisation = (source, taskStatusFactor) -> - unit = $scope.unit - _.extend $scope.data, outcomeService.targetsByGrade($scope.unit, source) - - if $scope.api? - $scope.api.update() - - $scope.calculateAlignmentVisualisation($scope.source, $scope.taskStatusFactor) - - $scope.$on('UpdateAlignmentChart', -> - $scope.calculateAlignmentVisualisation($scope.source, $scope.taskStatusFactor) - ) diff --git a/src/app/visualisations/alignment-bullet-chart.coffee b/src/app/visualisations/alignment-bullet-chart.coffee deleted file mode 100644 index 9ef1f3f9e1..0000000000 --- a/src/app/visualisations/alignment-bullet-chart.coffee +++ /dev/null @@ -1,591 +0,0 @@ -angular.module('doubtfire.visualisations.alignment-bullet-chart', []) -.directive 'alignmentBulletChart', -> - replace: true - restrict: 'E' - templateUrl: 'visualisations/visualisation.tpl.html' - scope: - project: '=' - unit: '=' - ilo: '=' - targets: '=' - currentProgress: '=' - classStats: '=' - showLegend: '=?' - - controller: ($scope, gradeService, Visualisation, $sce) -> - $scope.showLegend = if $scope.showLegend? then $scope.showLegend else true - unless nv.models.iloBullet? - # Chart design based on the recommendations of Stephen Few. Implementation - # based on the work of Clint Ivy, Jamie Love, and Jason Davies. - # http://projects.instantcognition.com/protovis/bulletchart/ - nv.models.iloBullet = -> - chart = (selection) -> - selection.each (d, i) -> - availableWidth = width - (if $scope.legend then margin.left else 0) - (if $scope.legend then margin.right else 0) - availableHeight = height - (margin.top) - (margin.bottom) - container = d3.select(this) - nv.utils.initSVG container - rangez = ranges.call(this, d, i).slice().reverse() - markerz = markers.call(this, d, i).slice().sort(d3.descending) - measurez = measures.call(this, d, i) - rangeLabelz = rangeLabels.call(this, d, i).slice().reverse() - markerLabelz = markerLabels.call(this, d, i).slice() - measureLabelz = measureLabels.call(this, d, i).slice() - - # Setup Scales - # Compute the new x-scale. - x1 = d3.scale.linear().domain(d3.extent(d3.merge([ - forceX - rangez - ]))).range(if reverse then [ - availableWidth - 0 - ] else [ - 0 - availableWidth - ]) - # Retrieve the old x-scale, if this is an update. - x0 = @__chart__ or d3.scale.linear().domain([ - 0 - Infinity - ]).range(x1.range()) - - # Stash the new scale. - @__chart__ = x1 - - # Get the range values - rangeHD = rangez[0] - rangeD = rangez[1] - rangeC = rangez[2] - rangeP = rangez[3] - - # Setup containers and skeleton of chart - wrap = container.selectAll('g.nv-wrap.nv-bullet').data([ d ]) - wrapEnter = wrap.enter().append('g').attr('class', 'nvd3 nv-wrap nv-bullet') - gEnter = wrapEnter.append('g') - g = wrap.select('g') - gEnter.append('rect').attr('class', 'nv-range nv-rangeHD').style('fill-opacity', '0.2').style('fill', gradeService.gradeColors.HD) - gEnter.append('rect').attr('class', 'nv-range nv-rangeD' ).style('fill-opacity', '0.2').style('fill', gradeService.gradeColors.D) - gEnter.append('rect').attr('class', 'nv-range nv-rangeC' ).style('fill-opacity', '0.2').style('fill', gradeService.gradeColors.C) - gEnter.append('rect').attr('class', 'nv-range nv-rangeP' ).style('fill-opacity', '0.2').style('fill', gradeService.gradeColors.P) - gEnter.append('rect').attr 'class', 'nv-measure' - gEnter.append('rect').attr('class', 'nv-median').style('fill-opacity', '1.0').style('fill','#ffffff') - wrap.attr 'transform', 'translate(' + margin.left + ',' + margin.top + ')' - - w0 = (d) -> - Math.abs x0(d) - x0(0) - - w1 = (d) -> - Math.abs x1(d) - x1(0) - - xp0 = (d) -> - if d > 0 then x0(d) else x0(0) - - xp1 = (d) -> - if d > 0 then x1(d) else x1(0) - - g.select('rect.nv-rangeHD') - .attr('height', availableHeight) - .attr('width', w1(rangeHD - rangeD) ) - .attr('x', xp1(rangeD)) - .datum rangeHD - - g.select('rect.nv-rangeD') - .attr('height', availableHeight) - .attr('width', w1(rangeD - rangeC)) - .attr('x', xp1(rangeC)) - .datum rangeD - - g.select('rect.nv-rangeC') - .attr('height', availableHeight) - .attr('width', w1(rangeC - rangeP)) - .attr('x', xp1(rangeP)) - .datum rangeC - - g.select('rect.nv-rangeP') - .attr('height', availableHeight) - .attr('width', w1(rangeP)) - .attr('x', xp1(0)) - .datum rangeP - - if measurez? - g.select('rect.nv-median') - .style('fill', "#373737") - .style('fill-opacity',0.6) - .attr('height', availableHeight) - .attr('y', 0) - .attr('width', 2) - .attr('x', xp1(measurez.median)) - .on('mouseover', -> - dispatch.elementMouseover - value: measurez.median - label: 'The average student is here' - color: d3.select(this).style('fill') - return) - .on('mousemove', -> - dispatch.elementMousemove - value: measurez.median - label: 'Class Median' - color: d3.select(this).style('fill') - return) - .on 'mouseout', -> - dispatch.elementMouseout - value: measurez.median - label: 'Class Median' - color: d3.select(this).style('fill') - return - - g.select('rect.nv-measure') - .style('fill', "#373737") - .style('fill-opacity',0.6) - .attr('height', availableHeight * 2 / 3) - .attr('y', availableHeight / 6) - .attr('width', xp1(measurez.upper) - xp1(measurez.lower)) - .attr('x', xp1(measurez.lower)) - .on('mouseover', -> - dispatch.elementMouseover - value: measurez[0] - label: measureLabelz[0] or 'Current' - color: d3.select(this).style('fill') - return) - .on('mousemove', -> - dispatch.elementMousemove - value: measurez[0] - label: measureLabelz[0] or 'Current' - color: d3.select(this).style('fill') - return) - .on 'mouseout', -> - dispatch.elementMouseout - value: measurez[0] - label: measureLabelz[0] or 'Current' - color: d3.select(this).style('fill') - return - - h3 = availableHeight / 6 - markerData = markerz.map((marker, index) -> - { - value: marker - label: markerLabelz[index] - } - ) - gEnter.selectAll('path.nv-markerTriangle') - .data(markerData) - .enter() - .append('path') - .attr('class', 'nv-markerTriangle') - .attr('d', (d,i) -> - if i % 2 == 0 - 'M0,' + h3 + 'L' + h3 + ',' + -h3 + ' ' + -h3 + ',' + -h3 + 'Z' - else - 'M0,' + -h3 + 'L' + -h3 + ',0L0,' + h3 + 'L' + h3 + ',0Z' - ) - .on('mouseover', (d) -> - dispatch.elementMouseover( - value: d.value - label: d.label or 'Previous' - color: d3.select(this).style('fill') - pos: [ - x1(d.value) - availableHeight / 2 - ]) - return ) - .on('mousemove', (d) -> - dispatch.elementMousemove( - value: d.value - label: d.label or 'Previous' - color: d3.select(this).style('fill') ) - return ) - .on 'mouseout', (d, i) -> - dispatch.elementMouseout( - value: d.value - label: d.label or 'Previous' - color: d3.select(this).style('fill')) - return - - g.selectAll('path.nv-markerTriangle') - .data(markerData).attr 'transform', (d) -> - 'translate(' + x1(d.value) + ',' + availableHeight / 2 + ')' - - wrap.selectAll('.nv-range').on('mouseover', (d, i) -> - label = rangeLabelz[i] or (if !i then 'Maximum' else if i == 1 then 'Mean' else 'Minimum') - dispatch.elementMouseover - value: d - label: label - color: d3.select(this).style('fill') - return - ).on('mousemove', -> - dispatch.elementMousemove - value: measurez[0] - label: measureLabelz[0] or 'Previous' - color: d3.select(this).style('fill') - return - ).on 'mouseout', (d, i) -> - label = rangeLabelz[i] or (if !i then 'Maximum' else if i == 1 then 'Mean' else 'Minimum') - dispatch.elementMouseout - value: d - label: label - color: d3.select(this).style('fill') - return - return - chart - - 'use strict' - #============================================================ - # Public Variables with Default Settings - #------------------------------------------------------------ - margin = - top: 0 - right: 0 - bottom: 0 - left: 0 - orient = 'left' - reverse = false - - ranges = (d) -> - d.ranges - - markers = (d) -> - if d.markers then d.markers else [] - - measures = (d) -> - d.measures - - rangeLabels = (d) -> - if d.rangeLabels then d.rangeLabels else [] - - markerLabels = (d) -> - if d.markerLabels then d.markerLabels else [] - - measureLabels = (d) -> - if d.measureLabels then d.measureLabels else [] - - forceX = [ 0 ] - width = 380 - height = 30 - container = null - tickFormat = null - color = nv.utils.getColor([ '#1f77b4' ]) - dispatch = d3.dispatch('elementMouseover', 'elementMouseout', 'elementMousemove') - #============================================================ - # Expose Public Variables - #------------------------------------------------------------ - chart.dispatch = dispatch - chart.options = nv.utils.optionsFunc.bind(chart) - chart._options = Object.create({}, - ranges: - get: -> - ranges - set: (_) -> - ranges = _ - return - markers: - get: -> - markers - set: (_) -> - markers = _ - return - measures: - get: -> - measures - set: (_) -> - measures = _ - return - forceX: - get: -> - forceX - set: (_) -> - forceX = _ - return - width: - get: -> - width - set: (_) -> - width = _ - return - height: - get: -> - height - set: (_) -> - height = _ - return - tickFormat: - get: -> - tickFormat - set: (_) -> - tickFormat = _ - return - margin: - get: -> - margin - set: (_) -> - margin.top = if _.top != undefined then _.top else margin.top - margin.right = if _.right != undefined then _.right else margin.right - margin.bottom = if _.bottom != undefined then _.bottom else margin.bottom - margin.left = if _.left != undefined then _.left else margin.left - return - orient: - get: -> - orient - set: (_) -> - # left, right, top, bottom - orient = _ - reverse = orient == 'right' or orient == 'bottom' - return - color: - get: -> - color - set: (_) -> - color = nv.utils.getColor(_) - return - ) - nv.utils.initOptions chart - chart - - # --- - # generated by js2coffee 2.1.0 - - - # Chart design based on NVD3 bullet chart. - nv.models.iloChart = -> - #============================================================ - # Public Variables with Default Settings - #------------------------------------------------------------ - - bullet = nv.models.iloBullet() - tooltip = nv.models.tooltip() - - # TODO: (@macite) top & bottom - orient = 'left' - reverse = false - margin = if $scope.legend then { top: 5, right: 40, bottom: 5, left: 120 } else { top: 5, right: 5, bottom: 5, left: 5 } - - ranges = (d) -> d.ranges - rangeLabels = (d) -> d.rangeLabels - markers = (d) -> if d.markers then d.markers else [] - measures = (d) -> d.measures - - width = null - height = 55 - tickFormat = null - ticks = null - noData = null - dispatch = d3.dispatch - - tooltip - .duration(0) - .headerEnabled(false) - - chart = (selection) -> - selection.each (d, i) -> - container = d3.select(this) - nv.utils.initSVG(container) - - availableWidth = nv.utils.availableWidth(width, container, margin) - availableHeight = height - margin.top - margin.bottom - that = this - - chart.update = -> chart(selection) - chart.container = this - - # Display No Data message if there's nothing to show. - if (!d || !ranges.call(this, d, i)) - nv.utils.noData(chart, container) - return chart - else - container.selectAll('.nv-noData').remove() - - rangez = ranges.call(this, d, i).slice().sort(d3.descending) - markerz = markers.call(this, d, i).slice().sort(d3.descending) - measurez = measures.call(this, d, i) - - # Setup containers and skeleton of chart - wrap = container.selectAll('g.nv-wrap.nv-bulletChart').data([d]) - wrapEnter = wrap.enter().append('g').attr('class', 'nvd3 nv-wrap nv-bulletChart') - gEnter = wrapEnter.append('g') - g = wrap.select('g') - - gEnter.append('g').attr('class', 'nv-bulletWrap') - gEnter.append('g').attr('class', 'nv-titles') if $scope.legend - - wrap.attr('transform', 'translate(' + (if $scope.legend then margin.left else 10) + ',' + margin.top + ')') - - # Compute the new x-scale. - # TODO: (@macite) need to allow forceX and forceY, and xDomain, yDomain - x1 = d3.scale.linear() - .domain([0, Math.max(rangez[0], (markerz[0] || 0), measurez[0])]) - .range(if reverse then [availableWidth, 0] else [0, availableWidth]) - - # Retrieve the old x-scale, if this is an update. - x0 = this.__chart__ || d3.scale.linear() - .domain([0, Infinity]) - .range(x1.range()) - - # Stash the new scale. - this.__chart__ = x1 - - # TODO: (@macite) could optimize by precalculating x0(0) and x1(0) - w0 = (d) -> Math.abs(x0(d) - x0(0)) - w1 = (d) -> Math.abs(x1(d) - x1(0)) - - if $scope.legend - title = gEnter.select('.nv-titles').append('g') - .attr('text-anchor', 'end') - .attr('transform', 'translate(-6,' + (height - margin.top - margin.bottom) / 2 + ')') - - title.append('text') - .attr('class', 'nv-title') - .text((d) -> d.title ) - - title.append('text') - .attr('class', 'nv-subtitle') - .attr('dy', '1em') - .text( (d) -> d.subtitle ) - - bullet - .width(availableWidth) - .height(availableHeight) - - bulletWrap = g.select('.nv-bulletWrap') - d3.transition(bulletWrap).call(bullet) - - # # Compute the tick format. - # format = tickFormat || x1.tickFormat( availableWidth / 100 ) - - # # Update the tick groups. - # tick = g.selectAll('g.nv-tick') - # .data x1.ticks( if ticks then ticks else (availableWidth / 50) ), (d) -> - # this.textContent || format(d) - - # # Initialize the ticks with the old scale, x0. - # tickEnter = tick.enter().append('g') - # .attr('class', 'nv-tick') - # .attr('transform', (d) -> 'translate(' + x0(d) + ',0)' ) - # .style('opacity', 1e-6) - - # tickEnter.append('line') - # .attr('y1', availableHeight) - # .attr('y2', availableHeight * 7 / 6) - - # tickEnter.append('text') - # .attr('text-anchor', 'middle') - # .attr('dy', '1em') - # .attr('y', availableHeight * 7 / 6) - # .text(format) - - # # Transition the updating ticks to the new scale, x1. - # tickUpdate = d3.transition(tick) - # .attr('transform', (d) -> 'translate(' + x1(d) + ',0)') - # .style('opacity', 1) - - # tickUpdate.select('line') - # .attr('y1', availableHeight) - # .attr('y2', availableHeight * 7 / 6) - - # tickUpdate.select('text') - # .attr('y', availableHeight * 7 / 6) - - # #Transition the exiting ticks to the new scale, x1. - # d3.transition(tick.exit()) - # .attr('transform', (d) -> 'translate(' + x1(d) + ',0)' ) - # .style('opacity', 1e-6 ) - # .remove( ) - # end selection each - - d3.timer.flush() - chart - - #============================================================ - # Event Handling/Dispatching (out of chart's scope) - #------------------------------------------------------------ - bullet.dispatch.on 'elementMouseover.tooltip', (evt) -> - evt['series'] = { - key: evt.label - # value: evt.value - color: evt.color - } - tooltip.data(evt).hidden(false) - - bullet.dispatch.on 'elementMouseout.tooltip', (evt) -> - tooltip.hidden(true) - - bullet.dispatch.on 'elementMousemove.tooltip', (evt) -> - tooltip() - - #============================================================ - # Expose Public Variables - #------------------------------------------------------------ - - chart.bullet = bullet - chart.dispatch = dispatch - chart.tooltip = tooltip - - chart.options = nv.utils.optionsFunc.bind(chart) - - chart._options = Object.create({}, { - # simple options, just get/set the necessary values - ranges: {get: (-> ranges), set: ((_)-> ranges=_) }, # ranges (bad, satisfactory, good) - rangeLabels: {get: (-> rangeLabels), set: ((_)-> rangeLabels=_) }, # ranges (bad, satisfactory, good) - markers: {get: (-> markers), set: ((_) -> markers=_) }, # markers (previous, goal) - measures: {get: (-> measures), set: ((_) -> measures=_) }, # measures (actual, forecast) - width: {get: (-> width), set: ((_) -> width=_) }, - height: {get: (-> height), set: ((_) -> height=_) }, - tickFormat: {get: (-> tickFormat), set: ((_) -> tickFormat=_) }, - ticks: {get: (-> ticks), set: ((_) -> ticks=_) }, - noData: {get: (-> noData), set: ((_) -> noData=_) }, - - # options that require extra logic in the setter - margin: {get: (-> margin), set: (_) -> - margin.top = if _.top? then _.top else margin.top - margin.right = if _.right? then _.right else margin.right - margin.bottom = if _.bottom? then _.bottom else margin.bottom - margin.left = if _.left? then _.left else margin.left - }, - orient: {get: (-> orient), set: (_) -> # left, right, top, bottom - orient = _ - reverse = orient == 'right' || orient == 'bottom' - } - }) - - nv.utils.inheritOptions(chart, bullet) - nv.utils.initOptions(chart) - - chart - - [$scope.options, $scope.config] = Visualisation 'iloChart', 'ILO Alignment Bullet Chart', { - height: 60 - duration: 500 - }, {} - - targetP = $scope.targets[$scope.ilo.id][0] - targetC = targetP + $scope.targets[$scope.ilo.id][1] - targetD = targetC + $scope.targets[$scope.ilo.id][2] - targetHD = targetD + $scope.targets[$scope.ilo.id][3] - - $scope.data = { - "title": $sce.getTrustedHtml($scope.ilo.abbreviation), #Label the bullet chart - "subtitle": $sce.getTrustedHtml($scope.ilo.name), #sub-label for bullet chart - "ranges":[targetP,targetC,targetD,targetHD], #Minimum, mean and maximum values. - "rangeLabels":['Pass','Credit','Distinction','High Distinction'], #Minimum, mean and maximum values. - "measures": {}, #Value representing current measurement (the thick blue line in the example) - "measureLabels": ['Most of the class are in this area'] - "markers":[] #Place a marker on the chart (the white triangle marker) - "markerLabels": ['Your Suggested Progress - Staff Suggestion', 'Your Current Progress - Self Reflection'] - } - - if $scope.classStats? && $scope.classStats.title? - $scope.data.measureLabels[0] = $scope.classStats.title - - updateProgress = -> - if $scope.currentProgress? - _.each $scope.currentProgress, (d, i) -> - $scope.data.markers[i] = $scope.currentProgress[i][$scope.ilo.id] - if $scope.currentProgress[i].title? - $scope.data.markerLabels[i] = $scope.currentProgress[i].title - - $scope.data.measures = if $scope.classStats? && $scope.classStats[$scope.ilo.id]? then $scope.classStats[$scope.ilo.id] else 0 - - updateProgress() - - $scope.$on('ProgressUpdated', -> - updateProgress() - ) diff --git a/src/app/visualisations/progress-burndown-chart.scss b/src/app/visualisations/progress-burndown-chart.scss index a3f5591bae..b18611e1f0 100644 --- a/src/app/visualisations/progress-burndown-chart.scss +++ b/src/app/visualisations/progress-burndown-chart.scss @@ -1,13 +1,17 @@ progress-burndown-chart { // Dashed for time line .dashed { - stroke-dasharray: 5,5; + stroke-dasharray: 5, 5; } // :last-child applies to hide time line from legend .nv-legendWrap .nv-series:last-child { display: none; } // since we're hiding time line we can move the rest right - .nv-legend{ transform: translate(50px, -15px); } - .nv-legendWrap{ transform: translate(50px, -15px); } + .nv-legend { + transform: translate(50px, -15px); + } + .nv-legendWrap { + transform: translate(50px, -15px); + } } diff --git a/src/app/visualisations/progress-burndown-chart/progress-burndown-chart.component.html b/src/app/visualisations/progress-burndown-chart/progress-burndown-chart.component.html new file mode 100644 index 0000000000..4c6914b24b --- /dev/null +++ b/src/app/visualisations/progress-burndown-chart/progress-burndown-chart.component.html @@ -0,0 +1,95 @@ +
+
+ + +
+ + @if (temp.length > 0) { +
+ @for (series of temp; track series.name; let index = $index) { + + } +
+ } + + @if (demoMode.enabled) { + @if (peerMedianState === 'ready') { +
+ + + + Demo peer median through today: anonymous aggregate sample data, not live cohort data. + +
+ } @else { +
+ @switch (peerMedianState) { + @case ('loading') { + + Loading peer comparison… + } + + @case ('suppressed') { + + + Peer comparison is hidden because the anonymous cohort threshold was not met. + + } + + @case ('unavailable') { + + Peer median is unavailable right now. + } + + @case ('disabled') { + + Peer comparison is disabled for this unit. + } + + @case ('error') { + + Peer median could not be loaded. Please try again later. + } + } +
+ } + } +
diff --git a/src/app/visualisations/progress-burndown-chart/progress-burndown-chart.component.scss b/src/app/visualisations/progress-burndown-chart/progress-burndown-chart.component.scss new file mode 100644 index 0000000000..08a778c281 --- /dev/null +++ b/src/app/visualisations/progress-burndown-chart/progress-burndown-chart.component.scss @@ -0,0 +1,149 @@ +:host { + display: block; + width: 100%; + min-width: 0; +} + +.progress-burndown { + display: grid; + width: 100%; + min-width: 0; + gap: 0.75rem; + padding-bottom: 1rem; +} + +.progress-burndown__chart { + width: 100%; + min-width: 0; + height: clamp(20rem, 30vw, 26rem); +} + +.progress-burndown-chart { + display: block; + width: 100%; + height: 100%; + min-width: 0; +} + +.progress-burndown__legend { + display: flex; + flex-wrap: wrap; + align-items: stretch; + gap: 0.5rem; + width: 100%; + min-width: 0; +} + +.progress-burndown__legend-item { + display: inline-flex; + align-items: center; + gap: 0.45rem; + min-width: 0; + max-width: 100%; + padding: 0.4rem 0.6rem; + border: 1px solid rgba(127, 127, 127, 0.35); + border-radius: 0.4rem; + background: rgba(127, 127, 127, 0.08); + color: inherit; + font: inherit; + line-height: 1.25; + cursor: pointer; + + &:hover { + background: rgba(127, 127, 127, 0.16); + } + + &:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; + } +} + +.progress-burndown__legend-item--hidden { + background: #f3f4f6; + color: #4b5563; + + .progress-burndown__legend-label { + text-decoration: line-through; + } +} + +.progress-burndown__legend-swatch { + width: 0.8rem; + height: 0.8rem; + flex: 0 0 0.8rem; + border-radius: 0.2rem; +} + +.progress-burndown__legend-label { + min-width: 0; + overflow-wrap: anywhere; + text-align: left; +} + +.progress-burndown__message { + display: flex; + align-items: flex-start; + gap: 0.5rem; + width: 100%; + min-width: 0; + box-sizing: border-box; + padding: 0.65rem 0.75rem; + border-radius: 0.45rem; + background: rgba(127, 127, 127, 0.1); + font-size: 0.875rem; + line-height: 1.4; + overflow-wrap: anywhere; + + > span { + flex: 1 1 auto; + min-width: 0; + } + + mat-icon, + mat-spinner { + flex: 0 0 auto; + margin-top: 0.05rem; + } + + mat-icon { + width: 1.25rem; + height: 1.25rem; + font-size: 1.25rem; + line-height: 1.25rem; + } +} + +@media (max-width: 639px) { + .progress-burndown { + gap: 0.65rem; + } + + .progress-burndown__chart { + height: 20rem; + } + + .progress-burndown__legend { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.4rem; + } + + .progress-burndown__legend-item { + width: 100%; + + &:last-child:nth-child(odd) { + grid-column: 1 / -1; + } + } +} + +@media (max-width: 379px) { + .progress-burndown__legend { + grid-template-columns: minmax(0, 1fr); + } + + .progress-burndown__legend-item:last-child:nth-child(odd) { + grid-column: auto; + } +} diff --git a/src/app/visualisations/progress-burndown-chart/progress-burndown-chart.component.spec.ts b/src/app/visualisations/progress-burndown-chart/progress-burndown-chart.component.spec.ts new file mode 100644 index 0000000000..1815819eb3 --- /dev/null +++ b/src/app/visualisations/progress-burndown-chart/progress-burndown-chart.component.spec.ts @@ -0,0 +1,366 @@ +import {describe, expect, it, vi} from 'vitest'; +import {SimpleChange, ViewContainerRef} from '@angular/core'; +import {Subject, of, throwError} from 'rxjs'; +import { + PeerProgressResponse, + PeerProgressService, + PeerProgressState, + Project, +} from 'src/app/api/models/doubtfire-model'; +import {DemoModeStore} from 'src/app/demo/demo-mode.store'; +import {ProgressBurndownChartComponent} from './progress-burndown-chart.component'; + +describe('ProgressBurndownChartComponent peer comparison', () => { + const EXISTING_SERIES = ['Target', 'Projected', 'To Submit', 'To Complete']; + + function makeProject(): Project { + const startDate = new Date(2026, 6, 1); + const endDate = new Date(2026, 7, 31); + + return { + id: 123, + targetGrade: 2, + unit: { + startDate, + endDate, + }, + burndownChartData: [ + { + key: 'Target', + values: [ + [startDate.getTime(), 1], + [endDate.getTime(), 0], + ], + }, + { + key: 'Projected', + values: [ + [startDate.getTime(), 0.9], + [endDate.getTime(), 0.1], + ], + }, + { + key: 'To Submit', + values: [ + [startDate.getTime(), 0.8], + [endDate.getTime(), 0.2], + ], + }, + { + key: 'To Complete', + values: [ + [startDate.getTime(), 0.7], + [endDate.getTime(), 0.3], + ], + }, + ], + refreshBurndownChartData: vi.fn(), + } as unknown as Project; + } + + function makeResponse( + project: Project, + state: PeerProgressState, + targetGrade: number = project.targetGrade, + ): PeerProgressResponse { + return { + project_id: project.id, + target_grade: targetGrade, + state, + median_burndown: + state === 'ready' + ? [ + { + date: project.unit.startDate.toISOString(), + remaining: 0.75, + }, + { + date: project.unit.endDate.toISOString(), + remaining: 0.25, + }, + ] + : [], + }; + } + + function makeHarness( + getCohortMedian: ReturnType, + demoEnabled = true, + ): { + component: ProgressBurndownChartComponent; + project: Project; + } { + const project = makeProject(); + + const component = new ProgressBurndownChartComponent( + {} as ViewContainerRef, + { + getCohortMedian, + } as unknown as PeerProgressService, + {enabled: demoEnabled} as DemoModeStore, + 'en-US', + ); + + component.project = project; + component.unit = project.unit; + component.grade = project.targetGrade; + + return { + component, + project, + }; + } + + function initialise(component: ProgressBurndownChartComponent): void { + // Angular calls ngOnChanges before ngOnInit for initial inputs. + component.ngOnChanges({ + grade: new SimpleChange(undefined, component.grade, true), + }); + + component.ngOnInit(); + } + + function seriesNames(component: ProgressBurndownChartComponent): string[] { + return component.data.map((series) => series.name); + } + + it('does not request or render synthetic peer data when demo mode is off', () => { + const getCohortMedian = vi.fn(); + const {component} = makeHarness(getCohortMedian, false); + + initialise(component); + + expect(getCohortMedian).not.toHaveBeenCalled(); + expect(seriesNames(component)).toEqual(EXISTING_SERIES); + expect(component.peerMedianState).toBe('disabled'); + }); + + function expectOnlyExistingSeries(component: ProgressBurndownChartComponent): void { + expect(seriesNames(component)).toEqual(EXISTING_SERIES); + } + + it('makes one initial request and keeps the real lines while loading', () => { + const request: Subject = new Subject(); + const getCohortMedian = vi.fn().mockReturnValue(request.asObservable()); + + const {component, project} = makeHarness(getCohortMedian); + + initialise(component); + + expect(getCohortMedian).toHaveBeenCalledTimes(1); + expect(getCohortMedian).toHaveBeenCalledWith(project, 2); + expect(project.refreshBurndownChartData).toHaveBeenCalledTimes(1); + expect(component.peerMedianState).toBe('loading'); + expectOnlyExistingSeries(component); + + component.ngOnDestroy(); + }); + + it('appends the demo median without replacing existing lines', () => { + const getCohortMedian = vi.fn(); + const {component, project} = makeHarness(getCohortMedian); + + getCohortMedian.mockReturnValue(of(makeResponse(project, 'ready'))); + + initialise(component); + + expect(component.peerMedianState).toBe('ready'); + expect(seriesNames(component)).toEqual([...EXISTING_SERIES, 'Peer median (demo)']); + + const peerSeries = component.data.find((series) => series.name === 'Peer median (demo)'); + + expect(peerSeries?.series.map((point) => point.value)).toEqual([75, 25]); + }); + + it('withholds the median when the response is suppressed', () => { + const getCohortMedian = vi.fn(); + const {component, project} = makeHarness(getCohortMedian); + + getCohortMedian.mockReturnValue(of(makeResponse(project, 'suppressed'))); + + initialise(component); + + expect(component.peerMedianState).toBe('suppressed'); + expectOnlyExistingSeries(component); + }); + + it('keeps the existing lines when peer data is unavailable', () => { + const getCohortMedian = vi.fn(); + const {component, project} = makeHarness(getCohortMedian); + + getCohortMedian.mockReturnValue(of(makeResponse(project, 'unavailable'))); + + initialise(component); + + expect(component.peerMedianState).toBe('unavailable'); + expectOnlyExistingSeries(component); + }); + + it('keeps the existing lines when peer comparison is disabled', () => { + const getCohortMedian = vi.fn(); + const {component, project} = makeHarness(getCohortMedian); + + getCohortMedian.mockReturnValue(of(makeResponse(project, 'disabled'))); + + initialise(component); + + expect(component.peerMedianState).toBe('disabled'); + expectOnlyExistingSeries(component); + }); + + it('shows an error state without removing the existing lines', () => { + const getCohortMedian = vi.fn().mockReturnValue(throwError(() => new Error('network down'))); + + const {component} = makeHarness(getCohortMedian); + + initialise(component); + + expect(component.peerMedianState).toBe('error'); + expectOnlyExistingSeries(component); + }); + + it('does not mutate the source burndown data while redrawing', () => { + const getCohortMedian = vi.fn(); + const {component, project} = makeHarness(getCohortMedian); + + getCohortMedian.mockReturnValue(of(makeResponse(project, 'ready'))); + + const sourceBefore = JSON.stringify(project.burndownChartData); + + initialise(component); + component.updateData(); + + expect(JSON.stringify(project.burndownChartData)).toBe(sourceBefore); + }); + + it('cancels the old grade request and only applies the current result', () => { + const firstRequest: Subject = new Subject(); + const secondRequest: Subject = new Subject(); + + const getCohortMedian = vi + .fn() + .mockReturnValueOnce(firstRequest.asObservable()) + .mockReturnValueOnce(secondRequest.asObservable()); + + const {component, project} = makeHarness(getCohortMedian); + + initialise(component); + + project.targetGrade = 3; + component.grade = 3; + + component.ngOnChanges({ + grade: new SimpleChange(2, 3, false), + }); + + expect(getCohortMedian).toHaveBeenCalledTimes(2); + expect(getCohortMedian).toHaveBeenNthCalledWith(2, project, 3); + expect(component.peerMedianState).toBe('loading'); + expectOnlyExistingSeries(component); + + // This must have no effect because its subscription was cancelled. + firstRequest.next(makeResponse(project, 'ready', 2)); + + expect(component.peerMedianState).toBe('loading'); + expectOnlyExistingSeries(component); + + secondRequest.next(makeResponse(project, 'ready', 3)); + + expect(component.peerMedianState).toBe('ready'); + expect(seriesNames(component)).toEqual([...EXISTING_SERIES, 'Peer median (demo)']); + + component.ngOnDestroy(); + }); + + it('refreshes both chart and peer data when a reused route receives a new project', () => { + const getCohortMedian = vi.fn((currentProject: Project) => + of(makeResponse(currentProject, 'unavailable')), + ); + const {component, project} = makeHarness(getCohortMedian); + + initialise(component); + + const nextProject = makeProject(); + nextProject.id = 456; + component.project = nextProject; + component.unit = nextProject.unit; + + component.ngOnChanges({ + project: new SimpleChange(project, nextProject, false), + unit: new SimpleChange(project.unit, nextProject.unit, false), + }); + + expect(nextProject.refreshBurndownChartData).toHaveBeenCalledTimes(1); + expect(getCohortMedian).toHaveBeenLastCalledWith(nextProject, nextProject.targetGrade); + }); + + it('toggles one accessible legend series without hiding the peer series', () => { + const getCohortMedian = vi.fn(); + const {component, project} = makeHarness(getCohortMedian); + + getCohortMedian.mockReturnValue(of(makeResponse(project, 'ready'))); + initialise(component); + + component.onSelect('Target'); + + expect(component.isDataShown('Target')).toBe(false); + expect(component.isDataShown('Peer median (demo)')).toBe(true); + expect(seriesNames(component)).not.toContain('Target'); + expect(component.temp.map((series) => series.name)).toContain('Target'); + + component.onSelect('Target'); + + expect(component.isDataShown('Target')).toBe(true); + expect(seriesNames(component)).toContain('Target'); + }); + + it('does not mistake a genuine all-zero series for a hidden series', () => { + const getCohortMedian = vi.fn(); + const {component, project} = makeHarness(getCohortMedian, false); + + project.burndownChartData.push({ + key: 'Zero progress', + values: [ + [project.unit.startDate.getTime(), 0], + [project.unit.endDate.getTime(), 0], + ], + }); + + initialise(component); + + expect(component.isDataShown('Zero progress')).toBe(true); + expect(seriesNames(component)).toContain('Zero progress'); + }); + + it('hides axis titles on narrow screens', () => { + const {component} = makeHarness(vi.fn()); + const originalWidth = window.innerWidth; + + try { + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: 639, + }); + + component.onViewportResize(); + + expect(component.showXAxisLabel).toBe(false); + expect(component.showYAxisLabel).toBe(false); + + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: 640, + }); + + component.onViewportResize(); + + expect(component.showXAxisLabel).toBe(true); + expect(component.showYAxisLabel).toBe(true); + } finally { + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: originalWidth, + }); + } + }); +}); diff --git a/src/app/visualisations/progress-burndown-chart/progress-burndown-chart.component.ts b/src/app/visualisations/progress-burndown-chart/progress-burndown-chart.component.ts new file mode 100644 index 0000000000..d450315ada --- /dev/null +++ b/src/app/visualisations/progress-burndown-chart/progress-burndown-chart.component.ts @@ -0,0 +1,379 @@ +import {Color, ScaleType} from '@swimlane/ngx-charts'; +import {formatDate} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + HostListener, + Inject, + Input, + LOCALE_ID, + OnChanges, + OnDestroy, + OnInit, + SimpleChanges, + ViewContainerRef, +} from '@angular/core'; +import {Subscription} from 'rxjs'; +import { + PeerMedianPoint, + PeerProgressResponse, + PeerProgressService, + PeerProgressState, + Project, + Unit, +} from 'src/app/api/models/doubtfire-model'; +import {ChartBaseComponent} from 'src/app/common/chart-base/chart-base-component/chart-base-component.component'; +import {DemoModeStore} from 'src/app/demo/demo-mode.store'; + +interface BurndownPoint { + name: string; + value: number; +} + +interface BurndownSeries { + name: string; + series: BurndownPoint[]; +} + +type PeerMedianState = 'loading' | 'error' | PeerProgressState; + +@Component({ + selector: 'f-progress-burndown-chart', + templateUrl: './progress-burndown-chart.component.html', + styleUrls: ['./progress-burndown-chart.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class ProgressBurndownChartComponent + extends ChartBaseComponent + implements OnChanges, OnDestroy, OnInit +{ + @Input() project: Project; + @Input() unit: Unit; + @Input() grade: number; + + data: BurndownSeries[] = []; + temp: BurndownSeries[] = []; + + // Chart options + legend: boolean = false; + showLabels: boolean = true; + animations: boolean = true; + xAxis: boolean = true; + yAxis: boolean = true; + showYAxisLabel: boolean = true; + showXAxisLabel: boolean = true; + xAxisLabel: string = 'Time'; + yAxisLabel: string = 'Tasks Remaining'; + // ngx-charts hands the scheme domain to the series by position, so the full palette is + // kept here and the scheme is narrowed to whatever is on show. + private readonly seriesPalette: string[] = [ + '#AAAAAA', + '#777777', + '#0079d8', + '#E01B5D', + '#7C3AED', + ]; + colorScheme: Color = { + name: 'Burndown', + selectable: true, + group: ScaleType.Ordinal, + domain: [...this.seriesPalette], + }; + + yScaleMin: number = 0; + yScaleMax: number = 100; + + /** Drives the privacy-safe status message below the chart. */ + peerMedianState: PeerMedianState = 'disabled'; + + private seriesVisibility: Record = {}; + private peerMedian: PeerMedianPoint[] = []; + private activePeerMedianRequest?: Subscription; + private peerMedianRequestVersion: number = 0; + private initialised: boolean = false; + + constructor( + public viewContainerRef: ViewContainerRef, + private peerProgressService: PeerProgressService, + readonly demoMode: DemoModeStore, + @Inject(LOCALE_ID) private locale: string, + ) { + super(viewContainerRef); + this.data = []; + this.temp = []; + } + + ngOnInit(): void { + this.initialised = true; + this.updateResponsiveAxisLabels(); + + this.project.refreshBurndownChartData(); + this.updateData(); + + this.data.forEach((item) => { + this.seriesVisibility[item.name] = true; + }); + + if (this.demoMode.enabled) { + this.loadPeerMedian(); + } + } + + ngOnChanges(changes: SimpleChanges): void { + const projectChanged = + !!changes.project && + !changes.project.firstChange && + changes.project.currentValue !== changes.project.previousValue; + const unitChanged = + !!changes.unit && + !changes.unit.firstChange && + changes.unit.currentValue !== changes.unit.previousValue; + const gradeChanged = + !!changes.grade && + !changes.grade.firstChange && + changes.grade.currentValue !== undefined && + changes.grade.currentValue !== changes.grade.previousValue; + + // Angular calls ngOnChanges before ngOnInit. Ignore that first call so the + // initial peer request is made exactly once from ngOnInit. Refresh for + // project and unit input changes as well as target-grade changes because + // Angular can reuse this component while switching between project routes. + if (!this.initialised || (!projectChanged && !unitChanged && !gradeChanged)) { + return; + } + + this.project.refreshBurndownChartData(); + this.updateData(); + + // The comparison cohort changes with the project, unit, or target grade. + if (this.demoMode.enabled) { + this.loadPeerMedian(); + } else { + this.peerMedianState = 'disabled'; + this.peerMedian = []; + this.updateData(); + } + } + + @HostListener('window:resize') + onViewportResize(): void { + this.updateResponsiveAxisLabels(); + } + + ngOnDestroy(): void { + this.peerMedianRequestVersion++; + this.activePeerMedianRequest?.unsubscribe(); + } + + private updateResponsiveAxisLabels(): void { + const viewportWidth = typeof window === 'undefined' ? 1024 : window.innerWidth; + + const showAxisTitles = viewportWidth >= 640; + + this.showYAxisLabel = showAxisTitles; + this.showXAxisLabel = showAxisTitles; + } + + private loadPeerMedian(): void { + const requestVersion = ++this.peerMedianRequestVersion; + const requestedProjectId = this.project.id; + const requestedGrade = this.grade ?? this.project.targetGrade; + + // Remove the old peer line immediately and cancel the previous request. + // This prevents an old grade result from overwriting the current grade. + this.activePeerMedianRequest?.unsubscribe(); + + this.peerMedianState = 'loading'; + this.peerMedian = []; + this.updateData(); + + this.activePeerMedianRequest = this.peerProgressService + .getCohortMedian(this.project, requestedGrade) + .subscribe({ + next: (response) => { + if (requestVersion !== this.peerMedianRequestVersion) { + return; + } + + // Fail safely if an adapter returns data for a different project or + // grade than the one currently displayed. + if ( + response.project_id !== requestedProjectId || + response.target_grade !== requestedGrade + ) { + this.peerMedian = []; + this.peerMedianState = 'unavailable'; + this.updateData(); + return; + } + + this.applyPeerMedianResponse(response); + this.updateData(); + }, + + error: () => { + if (requestVersion !== this.peerMedianRequestVersion) { + return; + } + + this.peerMedian = []; + this.peerMedianState = 'error'; + this.updateData(); + }, + }); + } + + private applyPeerMedianResponse(response: PeerProgressResponse): void { + this.peerMedian = []; + + switch (response.state) { + case 'ready': + if (response.median_burndown.length === 0) { + this.peerMedianState = 'unavailable'; + return; + } + + this.peerMedian = [...response.median_burndown]; + this.peerMedianState = 'ready'; + return; + + case 'suppressed': + case 'unavailable': + case 'disabled': + this.peerMedianState = response.state; + return; + } + } + + updateData(): void { + const chartData = this.project?.burndownChartData; + const locale = this.locale; + const startDate: Date = this.project.unit.startDate; + const endDate: Date = this.project.unit.endDate; + + if (!chartData) { + this.temp = []; + this.data = []; + return; + } + + // Preserve the existing OnTrack burndown series. New chart values are + // created instead of changing project.burndownChartData in place. + const formattedData: BurndownSeries[] = chartData.map((series) => ({ + name: series.key, + series: series.values + .filter((value) => value[0] >= startDate.getTime() && value[0] <= endDate.getTime()) + .map((value) => ({ + name: formatDate(new Date(value[0]), 'd MMM', locale), + value: Math.round(Math.max(0, value[1]) * 100), + })), + })); + + // Keep the existing workaround for the chart's y-axis bounds. + const target = formattedData.find((series) => series.name === 'Target'); + + if (target) { + const start = target.series.find( + (point) => point.name === formatDate(new Date(startDate), 'd MMM', locale), + ); + + const end = target.series.find( + (point) => point.name === formatDate(new Date(endDate), 'd MMM', locale), + ); + + if (start) { + start.value = 100; + } + + if (end) { + end.value = 0; + } + } + + // This label is intentionally explicit while the adapter uses mock data. + if (this.peerMedianState === 'ready' && this.peerMedian.length > 0) { + formattedData.push( + this.toSeries('Peer median (demo)', this.peerMedian, startDate, endDate, locale), + ); + } + + this.temp = JSON.parse(JSON.stringify(formattedData)); + this.seriesVisibility = Object.fromEntries( + formattedData.map((series) => [series.name, this.seriesVisibility[series.name] !== false]), + ); + this.applyVisibility(); + } + + private toSeries( + name: string, + points: PeerMedianPoint[], + startDate: Date, + endDate: Date, + locale: string, + ): BurndownSeries { + return { + name, + series: points + .filter((point) => { + const time = new Date(point.date).getTime(); + + return time >= startDate.getTime() && time <= endDate.getTime(); + }) + .map((point) => ({ + name: formatDate(new Date(point.date), 'd MMM', locale), + value: Math.round(point.remaining * 100), + })), + }; + } + + onSelect(event: string | BurndownPoint): void { + if (!this.isLegend(event)) { + return; + } + + this.toggleSeries(event); + } + + toggleSeries(name: string): void { + if (!this.temp.some((series) => series.name === name)) { + return; + } + + this.seriesVisibility[name] = !this.isDataShown(name); + this.applyVisibility(); + } + + // A hidden series is dropped from the chart data. Zeroing its points instead left the + // line drawn flat along the x axis while its legend button said it was off. + private applyVisibility(): void { + const shown = this.temp + .map((series, index) => ({series, index})) + .filter((entry) => this.isDataShown(entry.series.name)); + + this.data = shown.map((entry) => ({ + name: entry.series.name, + series: entry.series.series.map((point) => ({...point})), + })); + this.colorScheme = { + ...this.colorScheme, + domain: shown.map((entry) => this.seriesColor(entry.index)), + }; + } + + isLegend(event: string | BurndownPoint): event is string { + return typeof event === 'string'; + } + + isDataShown(name: string): boolean { + return this.seriesVisibility[name] !== false; + } + + seriesColor(index: number): string { + return this.seriesPalette[index % this.seriesPalette.length]; + } + + public formatPerc(input: number): string { + return `${input}%`; + } +} diff --git a/src/app/visualisations/student-task-status-pie-chart.coffee b/src/app/visualisations/student-task-status-pie-chart.coffee deleted file mode 100644 index 12a8ce47ad..0000000000 --- a/src/app/visualisations/student-task-status-pie-chart.coffee +++ /dev/null @@ -1,41 +0,0 @@ -angular.module('doubtfire.visualisations.student-task-status-pie-chart', []) -.directive 'studentTaskStatusPieChart', -> - replace: true - restrict: 'E' - templateUrl: 'visualisations/visualisation.tpl.html' - scope: - project: '=' - updateData: '=?' - controller: ($scope, newTaskService, Visualisation) -> - colors = newTaskService.statusColors - $scope.data = [] - - $scope.updateData = -> - $scope.data.length = 0 - newTaskService.statusLabels.forEach( (label, key) -> - count = $scope.project.tasksByStatus(key).length - $scope.data.push { key: label, y: count, statusKey: key } - ) - - if $scope.api - $scope.api.update() - - $scope.$on 'TaskStatusUpdated', $scope.updateData - - $scope.updateData() - - [$scope.options, $scope.config] = Visualisation 'pieChart', 'Student Task Status Pie Chart', { - color: (d, i) -> - colors.get(d.statusKey) - x: (d) -> d.key - y: (d) -> d.y - showLabels: no - tooltip: - valueFormatter: (d) -> - fixed = d.toFixed() - pct = Math.round((d / $scope.project.activeTasks().length) * 100) - task = if fixed is "1" then "task" else "tasks" - "#{fixed} #{task} (#{pct}%)" - keyFormatter: (d) -> - d - }, {} diff --git a/src/app/visualisations/task-status-pie-chart/task-status-pie-chart.component.html b/src/app/visualisations/task-status-pie-chart/task-status-pie-chart.component.html new file mode 100644 index 0000000000..46aefcea58 --- /dev/null +++ b/src/app/visualisations/task-status-pie-chart/task-status-pie-chart.component.html @@ -0,0 +1,13 @@ +
+ + +
diff --git a/src/app/visualisations/task-status-pie-chart/task-status-pie-chart.component.scss b/src/app/visualisations/task-status-pie-chart/task-status-pie-chart.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/visualisations/task-status-pie-chart/task-status-pie-chart.component.ts b/src/app/visualisations/task-status-pie-chart/task-status-pie-chart.component.ts new file mode 100644 index 0000000000..42948d4ae7 --- /dev/null +++ b/src/app/visualisations/task-status-pie-chart/task-status-pie-chart.component.ts @@ -0,0 +1,88 @@ +import {LegendPosition} from '@swimlane/ngx-charts'; +import { + ChangeDetectionStrategy, + Component, + Input, + OnChanges, + OnInit, + SimpleChanges, +} from '@angular/core'; +import {Project, TaskStatus, Unit} from 'src/app/api/models/doubtfire-model'; +import {ChartBaseComponent} from 'src/app/common/chart-base/chart-base-component/chart-base-component.component'; + +@Component({ + selector: 'f-task-status-pie-chart', + templateUrl: './task-status-pie-chart.component.html', + styleUrls: ['./task-status-pie-chart.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class TaskStatusPieChartComponent extends ChartBaseComponent implements OnChanges, OnInit { + @Input() project: Project; + @Input() unit?: Unit; + @Input() grade: number; + + data: {name: string; value: number}[] = []; + colors: {name: string; value: string}[]; + view: number[] = [700, 400]; + legendPosition: LegendPosition = LegendPosition.Below; + + ngOnInit(): void { + this.updateData(); + } + + ngOnChanges(changes: SimpleChanges): void { + if ('grade' in changes && changes.grade.currentValue !== undefined) { + this.updateData(); + } + } + + updateData(): void { + if (this.project) { + const taskCounts = new Map(TaskStatus.STATUS_KEYS.map((status) => [status, 0])); + const activeTasks = this.project.activeTasks(); + activeTasks.forEach((task) => { + if (task.status) { + taskCounts.set(task.status, (taskCounts.get(task.status) || 0) + 1); + } + }); + + const sortOrder = [ + 'not_started', + 'feedback_exceeded', + 'redo', + 'need_help', + 'working_on_it', + 'fix_and_resubmit', + 'ready_for_feedback', + 'discuss', + 'demonstrate', + 'complete', + 'fail', + 'time_exceeded', + ]; + + this.data = Array.from(taskCounts) + .map(([status, count]) => { + return { + name: TaskStatus.STATUS_LABELS.get(status), + value: count, + }; + }) + .filter((task) => task.value > 0 || sortOrder.includes(task.name)) + .sort((a, b) => { + let aIndex = sortOrder.indexOf(a.name); + let bIndex = sortOrder.indexOf(b.name); + + aIndex = aIndex === -1 ? sortOrder.length : aIndex; + bIndex = bIndex === -1 ? sortOrder.length : bIndex; + + return aIndex - bIndex; + }); + + this.colors = Array.from(TaskStatus.STATUS_COLORS).map(([status, color]) => { + return {name: TaskStatus.STATUS_LABELS.get(status), value: color}; + }); + } + } +} diff --git a/src/app/visualisations/task-visualisation/task-visualisation.component.html b/src/app/visualisations/task-visualisation/task-visualisation.component.html new file mode 100644 index 0000000000..34665575a8 --- /dev/null +++ b/src/app/visualisations/task-visualisation/task-visualisation.component.html @@ -0,0 +1,14 @@ +
+ @for (status of data; track status.status) { +
+ {{ status.value }} + {{ status.name }} +
+ } +
diff --git a/src/app/visualisations/task-visualisation/task-visualisation.component.scss b/src/app/visualisations/task-visualisation/task-visualisation.component.scss new file mode 100644 index 0000000000..f9e62b8980 --- /dev/null +++ b/src/app/visualisations/task-visualisation/task-visualisation.component.scss @@ -0,0 +1,27 @@ +.task-status-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr)); + gap: 1rem; +} + +.task-status-card { + display: flex; + min-height: 10rem; + flex-direction: column; + justify-content: center; + border-radius: 0.25rem; + padding: 1.5rem; +} + +.task-status-value { + font-size: clamp(2.5rem, 4vw, 4rem); + line-height: 1; +} + +.task-status-label { + margin-top: 1.25rem; + overflow-wrap: anywhere; + font-size: 1rem; + font-weight: 500; + line-height: 1.35; +} diff --git a/src/app/visualisations/task-visualisation/task-visualisation.component.spec.ts b/src/app/visualisations/task-visualisation/task-visualisation.component.spec.ts new file mode 100644 index 0000000000..74e2f2430d --- /dev/null +++ b/src/app/visualisations/task-visualisation/task-visualisation.component.spec.ts @@ -0,0 +1,83 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import {SimpleChange} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {Project} from 'src/app/api/models/doubtfire-model'; +import {TaskVisualisationComponent} from './task-visualisation.component'; + +describe('TaskVisualisationComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TaskVisualisationComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TaskVisualisationComponent); + fixture.componentInstance.project = { + activeTasks: () => [{status: 'complete'}, {status: 'not_started'}], + } as unknown as Project; + fixture.detectChanges(); + }); + + it('renders complete status labels without chart ellipsis or a false click affordance', () => { + const host = fixture.nativeElement as HTMLElement; + const cards = Array.from(host.querySelectorAll('[role="listitem"]')); + const awaitingFeedback = cards.find((card) => card.textContent.includes('Awaiting Feedback')); + + expect(awaitingFeedback).toBeTruthy(); + expect(awaitingFeedback?.textContent).not.toContain('...'); + expect(awaitingFeedback?.getAttribute('aria-label')).toBe('Awaiting Feedback: 0'); + expect(awaitingFeedback?.tagName).toBe('DIV'); + expect(host.querySelector('ngx-charts-number-card')).toBeNull(); + expect(host.querySelector('button')).toBeNull(); + }); + + it('keeps every rendered status card above the 4.5:1 contrast floor', () => { + const host = fixture.nativeElement as HTMLElement; + const cards = Array.from(host.querySelectorAll('[role="listitem"]')); + + const luminance = (color: string) => { + const [red, green, blue] = color + .replace(/[^\d,]/g, '') + .split(',') + .map((channel) => { + const value = Number(channel) / 255; + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; + }; + const contrast = (card: HTMLElement) => { + const text = luminance(card.style.color); + const background = luminance(card.style.backgroundColor); + return (Math.max(text, background) + 0.05) / (Math.min(text, background) + 0.05); + }; + + expect(cards.length).toBeGreaterThan(0); + expect(cards.filter((card) => contrast(card) < 4.5).map((card) => card.textContent)).toEqual( + [], + ); + }); +}); + +describe('TaskVisualisationComponent route reuse', () => { + it('updates counts for a different project at the same grade', () => { + const firstProject = { + id: 2, + activeTasks: () => [{status: 'complete'}], + } as unknown as Project; + const nextProject = { + id: 18, + activeTasks: () => [{status: 'ready_for_feedback'}], + } as unknown as Project; + const component = new TaskVisualisationComponent(); + component.project = firstProject; + component.grade = 0; + component.ngOnInit(); + + component.project = nextProject; + component.ngOnChanges({project: new SimpleChange(firstProject, nextProject, false)}); + + expect(component.data.find(({name}) => name === 'Awaiting Feedback')?.value).toBe(1); + expect(component.data.find(({name}) => name === 'Complete')?.value).toBe(0); + }); +}); diff --git a/src/app/visualisations/task-visualisation/task-visualisation.component.ts b/src/app/visualisations/task-visualisation/task-visualisation.component.ts new file mode 100644 index 0000000000..646792ce53 --- /dev/null +++ b/src/app/visualisations/task-visualisation/task-visualisation.component.ts @@ -0,0 +1,107 @@ +import { + ChangeDetectionStrategy, + Component, + Input, + OnChanges, + OnInit, + SimpleChanges, +} from '@angular/core'; +import {Project, TaskStatus, TaskStatusEnum} from 'src/app/api/models/doubtfire-model'; + +interface TaskStatusSummary { + status: TaskStatusEnum; + name: string; + value: number; + color: string; + textColor: string; +} + +@Component({ + selector: 'f-task-visualisation', + templateUrl: './task-visualisation.component.html', + styleUrls: ['./task-visualisation.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class TaskVisualisationComponent implements OnChanges, OnInit { + @Input() project: Project; + @Input() grade: number; + + data: TaskStatusSummary[] = []; + + ngOnInit(): void { + this.updateData(); + } + + ngOnChanges(changes: SimpleChanges): void { + const projectChanged = 'project' in changes && !changes.project.firstChange; + const gradeChanged = 'grade' in changes && changes.grade.currentValue !== undefined; + + if (projectChanged || gradeChanged) { + this.updateData(); + } + } + + updateData(): void { + if (this.project) { + const taskCounts = new Map(TaskStatus.STATUS_KEYS.map((status) => [status, 0])); + const activeTasks = this.project.activeTasks(); + activeTasks.forEach((task) => { + if (task.status) { + taskCounts.set(task.status, (taskCounts.get(task.status) || 0) + 1); + } + }); + + const sortOrder: TaskStatusEnum[] = [ + 'complete', + 'discuss', + 'ready_for_feedback', + 'working_on_it', + 'not_started', + ]; + + this.data = Array.from(taskCounts) + .map(([status, count]) => { + const color = TaskStatus.STATUS_COLORS.get(status) ?? '#64748b'; + return { + status, + name: TaskStatus.STATUS_LABELS.get(status) ?? status, + value: count, + color, + textColor: this.contrastingTextColor(color), + }; + }) + .filter((task) => task.value > 0 || sortOrder.includes(task.status)) + .sort((a, b) => { + let aIndex = sortOrder.indexOf(a.status); + let bIndex = sortOrder.indexOf(b.status); + + aIndex = aIndex === -1 ? sortOrder.length : aIndex; + bIndex = bIndex === -1 ? sortOrder.length : bIndex; + + return aIndex - bIndex; + }); + } + } + + private contrastingTextColor(color: string): string { + const hex = color.replace('#', ''); + if (!/^[0-9a-f]{6}$/i.test(hex)) { + return '#ffffff'; + } + + const channels = [0, 2, 4].map((index) => parseInt(hex.slice(index, index + 2), 16) / 255); + const [red, green, blue] = channels.map((channel) => + channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4, + ); + // #111827 was too light to be the dark option here. Against ready_for_feedback + // (#0079D8) it reached 3.99:1 and white only 4.44:1, so the card that always renders + // sat under the 4.5:1 AA floor for normal text. Black clears the floor on every + // status colour, and the divisor now matches the colour it is measuring. + const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue; + const whiteContrast = 1.05 / (luminance + 0.05); + const darkContrast = (luminance + 0.05) / 0.05; + + return whiteContrast >= darkContrast ? '#ffffff' : '#000000'; + } +} diff --git a/src/app/visualisations/visualisations.coffee b/src/app/visualisations/visualisations.coffee index f7882be92e..16808e4129 100644 --- a/src/app/visualisations/visualisations.coffee +++ b/src/app/visualisations/visualisations.coffee @@ -1,17 +1,14 @@ +# Component is no longer used and is unlinked from the app +# This file is left for reference, as these need to be migrated to be used in the Unit Analytics section + angular.module('doubtfire.visualisations', [ 'doubtfire.visualisations.summary-task-status-scatter' - 'doubtfire.visualisations.progress-burndown-chart' - 'doubtfire.visualisations.alignment-bar-chart' - 'doubtfire.visualisations.alignment-bullet-chart' - 'doubtfire.visualisations.student-task-status-pie-chart' 'doubtfire.visualisations.task-status-pie-chart' 'doubtfire.visualisations.target-grade-pie-chart' 'doubtfire.visualisations.task-completion-box-plot' - 'doubtfire.visualisations.achievement-box-plot' - 'doubtfire.visualisations.achievement-custom-bar-chart' ]) -.factory('Visualisation', ($interval, analyticsService) -> +.factory('Visualisation', ($interval) -> Visualisation = (type, visualisationName, opts, conf, titleOpts, subtitleOpts) -> DEFAULT_OPTS = objectequality: yes @@ -54,9 +51,6 @@ angular.module('doubtfire.visualisations', [ dirtyConf = angular.extend {}, DEFAULT_CONF, conf - # Google tracking - analyticsService.event 'Visualisations', 'Created Visualisation', visualisationName - [ { chart: dirtyOpts, title: titleOpts, subtitle: subtitleOpts }, dirtyConf ] diff --git a/src/app/welcome/welcome.component.html b/src/app/welcome/welcome.component.html index 26780c3d47..40bc7d8228 100644 --- a/src/app/welcome/welcome.component.html +++ b/src/app/welcome/welcome.component.html @@ -1,17 +1,19 @@ -
- - -
-
+
+ + +
+
-
- Homepage Logo +
+ Homepage Logo

{{ externalName.value }}

-

Welcome to {{ externalName.value }}

- +

Welcome to {{ externalName.value }}

+ @if (!loading) { + + }
-
\ No newline at end of file +
diff --git a/src/app/welcome/welcome.component.scss b/src/app/welcome/welcome.component.scss index 605000967d..a8d6875022 100644 --- a/src/app/welcome/welcome.component.scss +++ b/src/app/welcome/welcome.component.scss @@ -1 +1 @@ -@import '../../styles/common/hero-sidebar-layout.scss'; +@use 'styles/common/hero-sidebar-layout' as *; diff --git a/src/app/welcome/welcome.component.spec.ts b/src/app/welcome/welcome.component.spec.ts index 59c1c8d8ef..4725c4d866 100644 --- a/src/app/welcome/welcome.component.spec.ts +++ b/src/app/welcome/welcome.component.spec.ts @@ -1,13 +1,58 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { WelcomeComponent } from './welcome.component'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {Router} from '@angular/router'; +import {BehaviorSubject} from 'rxjs'; +import {AuthenticationService} from 'src/app/api/services/authentication.service'; +import {UserService} from 'src/app/api/services/user.service'; +import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; +import {GlobalStateService} from 'src/app/projects/states/index/global-state.service'; +import {WelcomeComponent} from './welcome.component'; describe('WelcomeComponent', () => { let component: WelcomeComponent; let fixture: ComponentFixture; + let afterAuthCallback: ((result: boolean) => void) | undefined; + let routerStub: {navigateByUrl: ReturnType}; + let globalStateStub: {hideHeader: ReturnType}; + let userServiceStub: {currentUser: {hasRunFirstTimeSetup: boolean}}; beforeEach(async () => { + afterAuthCallback = undefined; + routerStub = { + navigateByUrl: vi.fn(), + }; + globalStateStub = { + hideHeader: vi.fn(), + }; + userServiceStub = { + currentUser: { + hasRunFirstTimeSetup: false, + }, + }; + await TestBed.configureTestingModule({ declarations: [WelcomeComponent], + providers: [ + { + provide: DoubtfireConstants, + useValue: { + ExternalName: new BehaviorSubject('OnTrack'), + }, + }, + {provide: GlobalStateService, useValue: globalStateStub}, + { + provide: AuthenticationService, + useValue: { + afterAuthCall: vi.fn((callback: (result: boolean) => void) => { + afterAuthCallback = callback; + }), + }, + }, + {provide: Router, useValue: routerStub}, + {provide: UserService, useValue: userServiceStub}, + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); @@ -20,4 +65,22 @@ describe('WelcomeComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should hide the header while the welcome page is active', () => { + expect(globalStateStub.hideHeader).toHaveBeenCalled(); + }); + + it('should redirect unauthenticated users to sign in', () => { + expect(afterAuthCallback).toBeDefined(); + + afterAuthCallback?.(false); + + expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/sign_in'); + }); + + it('should show the expected welcome heading', () => { + const element: HTMLElement = fixture.nativeElement; + + expect(element.textContent).toContain('Welcome to OnTrack'); + }); }); diff --git a/src/app/welcome/welcome.component.ts b/src/app/welcome/welcome.component.ts index f6aaafb270..afa619b690 100644 --- a/src/app/welcome/welcome.component.ts +++ b/src/app/welcome/welcome.component.ts @@ -1,14 +1,43 @@ -import { Component, OnInit } from '@angular/core'; -import { DoubtfireConstants } from 'src/app/config/constants/doubtfire-constants'; +import {ChangeDetectionStrategy, Component, OnInit} from '@angular/core'; +import {Router} from '@angular/router'; +import {AuthenticationService} from 'src/app/api/services/authentication.service'; +import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; +import {UserService} from '../api/services/user.service'; +import {GlobalStateService} from '../projects/states/index/global-state.service'; @Component({ selector: 'f-welcome', templateUrl: './welcome.component.html', styleUrls: ['./welcome.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, }) export class WelcomeComponent implements OnInit { - constructor(private constants: DoubtfireConstants) {} - ngOnInit(): void {} + public loading = true; + + constructor( + private constants: DoubtfireConstants, + private globalState: GlobalStateService, + private authenticationService: AuthenticationService, + private router: Router, + private userService: UserService, + ) {} public externalName = this.constants.ExternalName; + + ngOnInit(): void { + this.globalState.hideHeader(); + + this.authenticationService.afterAuthCall((result) => { + if (!result) { + return this.router.navigateByUrl('/sign_in'); + } + + if (this.userService.currentUser.hasRunFirstTimeSetup) { + return this.router.navigateByUrl('/home'); + } + + this.loading = false; + }); + } } diff --git a/src/assets/icons/android-chrome-maskable-512x512.png b/src/assets/icons/android-chrome-maskable-512x512.png new file mode 100644 index 0000000000..ca17d722a5 Binary files /dev/null and b/src/assets/icons/android-chrome-maskable-512x512.png differ diff --git a/src/assets/images/formatif-isolated-lottie.json b/src/assets/images/formatif-isolated-lottie.json index f934cfb0a7..9b32225328 100644 --- a/src/assets/images/formatif-isolated-lottie.json +++ b/src/assets/images/formatif-isolated-lottie.json @@ -1 +1,3019 @@ -{"v":"4.8.0","meta":{"g":"LottieFiles AE 3.4.3","a":"","k":"","d":"","tc":""},"fr":24,"ip":0,"op":92,"w":3840,"h":2160,"nm":"icon - isolated","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"NULL","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[1920,1080,0],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":33,"s":[100,100,100]},{"t":59,"s":[165,165,100]}],"ix":6}},"ao":0,"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":3,"nm":"main position","parent":1,"sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.167,"y":0.167},"t":36,"s":[50,50,0],"to":[0,0,0],"ti":[0,0,0]},{"t":54,"s":[464,50,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":3,"nm":"scale","parent":2,"sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[50,50,0],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":23,"s":[200,200,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":35,"s":[118,118,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":38,"s":[90,90,100]},{"t":49,"s":[100,100,100]}],"ix":6}},"ao":0,"ip":23,"op":240,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":3,"nm":"arrow position","parent":3,"sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.107,"y":1},"o":{"x":0.533,"y":0},"t":23,"s":[50,-8,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.19,"y":0.642},"o":{"x":0.333,"y":0},"t":35,"s":[119.897,36,0],"to":[0,0,0],"ti":[392.376,75.374,0]},{"i":{"x":0.833,"y":0.874},"o":{"x":1,"y":0.713},"t":38,"s":[-307.61,-289.928,0],"to":[-832.969,-114.56,0],"ti":[20.667,921.704,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.115,"y":1},"t":49,"s":[-374,-134,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.538,"y":1},"o":{"x":1,"y":0},"t":53,"s":[-374,-175,0],"to":[0,0,0],"ti":[1.605,-28.883,0]},{"i":{"x":0.538,"y":1},"o":{"x":0.167,"y":0},"t":59,"s":[-374,37,0],"to":[0,0,0],"ti":[1.605,-28.883,0]},{"t":65,"s":[-374,-8,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":23,"op":243,"st":3,"bm":0},{"ddd":0,"ind":5,"ty":3,"nm":"rotation","parent":4,"sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.303],"y":[0]},"t":23,"s":[-45]},{"i":{"x":[0.773],"y":[0.886]},"o":{"x":[0.333],"y":[0]},"t":35,"s":[-45]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.06]},"t":38,"s":[-71]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.089]},"t":42,"s":[-118.941]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.087]},"t":45,"s":[-186.591]},{"t":49,"s":[-360]}],"ix":10},"p":{"a":0,"k":[18.883,127.949,0],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":23,"op":243,"st":3,"bm":0},{"ddd":0,"ind":6,"ty":3,"nm":"rough edges","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[1920,1080,0],"ix":2},"a":{"a":0,"k":[1920,1080,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ef":[{"ty":5,"nm":"Roughen Edges","np":17,"mn":"ADBE Roughen Edges","ix":1,"en":1,"ef":[{"ty":7,"nm":"Edge Type","mn":"ADBE Roughen Edges-0001","ix":1,"v":{"a":0,"k":1,"ix":1}},{"ty":2,"nm":"Edge Color","mn":"ADBE Roughen Edges-0010","ix":2,"v":{"a":0,"k":[0.6,0.2,0,1],"ix":2}},{"ty":0,"nm":"Border","mn":"ADBE Roughen Edges-0002","ix":3,"v":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":12,"s":[17.4]},{"t":19,"s":[2.7]}],"ix":3}},{"ty":0,"nm":"Edge Sharpness","mn":"ADBE Roughen Edges-0003","ix":4,"v":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":12,"s":[1]},{"t":19,"s":[0.1]}],"ix":4}},{"ty":0,"nm":"Fractal Influence","mn":"ADBE Roughen Edges-0004","ix":5,"v":{"a":0,"k":1,"ix":5}},{"ty":0,"nm":"Scale","mn":"ADBE Roughen Edges-0005","ix":6,"v":{"a":0,"k":1000,"ix":6}},{"ty":0,"nm":"Stretch Width or Height","mn":"ADBE Roughen Edges-0006","ix":7,"v":{"a":0,"k":0,"ix":7}},{"ty":3,"nm":"Offset (Turbulence)","mn":"ADBE Roughen Edges-0007","ix":8,"v":{"a":0,"k":[0,0],"ix":8}},{"ty":0,"nm":"Complexity","mn":"ADBE Roughen Edges-0008","ix":9,"v":{"a":0,"k":2,"ix":9}},{"ty":0,"nm":"Evolution","mn":"ADBE Roughen Edges-0009","ix":10,"v":{"a":0,"k":0,"ix":10}},{"ty":6,"nm":"Evolution Options","mn":"ADBE Roughen Edges-0011","ix":11,"v":0},{"ty":7,"nm":"Cycle Evolution","mn":"ADBE Roughen Edges-0012","ix":12,"v":{"a":0,"k":0,"ix":12}},{"ty":0,"nm":"Cycle (in Revolutions)","mn":"ADBE Roughen Edges-0013","ix":13,"v":{"a":0,"k":1,"ix":13}},{"ty":0,"nm":"Random Seed","mn":"ADBE Roughen Edges-0014","ix":14,"v":{"a":0,"k":0,"ix":14}},{"ty":6,"nm":"Random Seed","mn":"ADBE Roughen Edges-0015","ix":15,"v":0}]}],"ip":0,"op":19,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":3,"nm":"echo","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[1920,1080,0],"ix":2},"a":{"a":0,"k":[1920,1080,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ef":[{"ty":5,"nm":"Echo","np":7,"mn":"ADBE Echo","ix":1,"en":1,"ef":[{"ty":0,"nm":"Echo Time (seconds)","mn":"ADBE Echo-0001","ix":1,"v":{"a":0,"k":-0.001,"ix":1}},{"ty":0,"nm":"Number Of Echoes","mn":"ADBE Echo-0002","ix":2,"v":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":31,"s":[10]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":34,"s":[67]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":49,"s":[67]},{"t":53,"s":[10]}],"ix":2}},{"ty":0,"nm":"Starting Intensity","mn":"ADBE Echo-0003","ix":3,"v":{"a":0,"k":1,"ix":3}},{"ty":0,"nm":"Decay","mn":"ADBE Echo-0004","ix":4,"v":{"a":0,"k":1,"ix":4}},{"ty":7,"nm":"Echo Operator","mn":"ADBE Echo-0005","ix":5,"v":{"a":0,"k":5,"ix":5}}]}],"ip":31,"op":53,"st":3,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"top 2","parent":5,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[73.565,-23.785,0],"ix":2},"a":{"a":0,"k":[1488.448,1026.164,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ef":[{"ty":5,"nm":"ShapeNir Slider","np":3,"mn":"ADBE Slider Control","ix":1,"en":1,"ef":[{"ty":0,"nm":"Slider","mn":"ADBE Slider Control-0001","ix":1,"v":{"a":0,"k":100,"ix":1}}]}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.17,"y":1},"o":{"x":0.167,"y":0},"t":58,"s":[{"i":[[0,0],[0,0],[-13.503,-13.506],[0,0],[9.065,-9.068],[0,0],[0,0],[9.068,9.071],[0,0],[0,0]],"o":[[0,0],[13.506,-13.506],[0,0],[9.065,9.068],[0,0],[0,0],[-9.068,9.074],[0,0],[0,0],[0,0]],"v":[[-61.716,-44.574],[-57.525,-48.766],[-21.582,-61.577],[-24.345,-64.482],[-24.342,-31.648],[-24.342,-31.648],[-24.342,-31.648],[-57.186,-31.645],[-65.907,-40.383],[-61.716,-44.574]],"c":false}]},{"t":64,"s":[{"i":[[0,0],[0,0],[-13.503,-13.506],[0,0],[9.065,-9.068],[0,0],[0,0],[9.068,9.071],[0,0],[0,0]],"o":[[0,0],[13.506,-13.506],[0,0],[9.065,9.068],[0,0],[0,0],[-9.068,9.074],[0,0],[0,0],[0,0]],"v":[[-74.672,-57.386],[-70.481,-61.577],[-21.582,-61.577],[72.063,32.066],[72.066,64.9],[72.066,64.9],[72.066,64.9],[39.223,64.903],[-78.863,-53.195],[-74.672,-57.386]],"c":false}]}],"ix":2},"nm":"Tracciato 1_forAnimation","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.211764708161,0.568627476692,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Riempimento 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1542.448,1082.164],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Gruppo 3","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1,"x":"var $bm_rt;\nvar mN01 = 52.2496645130989;\nvar mN02 = 100;\nvar mN01Edt = 52.2496645130989;\nvar mN02Edt = 98.6485408913042;\nvar mRatiosM = [\n 0,\n 1\n ];\nvar mRatiosF = [\n 0,\n 1\n ];\nfunction mLinear(aInput, aInNumF, aInNumL, aOutNumF, aOutNumL) {\n var mInputLgt = $bm_sub(aInNumL, aInNumF);\n var mOutputLgt = $bm_sub(aOutNumL, aOutNumF);\n var mRatio = $bm_div(mOutputLgt, mInputLgt);\n if (aInNumF <= aInput && aInput <= aInNumL) {\n var mOutput = $bm_sum($bm_mul($bm_sub(aInput, aInNumF), mRatio), aOutNumF);\n } else {\n var mOutput = aInput;\n }\n return mOutput;\n}\nvar mSld = $bm_div(thisLayer('ADBE Effect Parade')('ShapeNir Slider')('ADBE Slider Control-0001'), 100);\nif (mSld <= 0) {\n var mRst = mN01;\n} else if (mSld >= 1) {\n var mRst = mN02;\n} else {\n for (var i = 1; i < mRatiosM.length; i++) {\n if (mRatiosM[i - 1] <= mSld && mSld < mRatiosM[i]) {\n var mCtl = mLinear(mSld, mRatiosM[$bm_sub(i, 1)], mRatiosM[i], mRatiosF[$bm_sub(i, 1)], mRatiosF[i]);\n break;\n }\n }\n var mRst = mLinear(mCtl, 0, 1, mN01Edt, mN02Edt);\n}\n$bm_rt = mRst;"},"e":{"a":0,"k":100,"ix":2,"x":"var $bm_rt;\nvar mN01 = 52.2496645130989;\nvar mN02 = 0;\nvar mN01Edt = 52.2496645130989;\nvar mN02Edt = 1.35145910869578;\nvar mRatiosM = [\n 0,\n 1\n ];\nvar mRatiosL = [\n 0,\n 1\n ];\nfunction mLinear(aInput, aInNumF, aInNumL, aOutNumF, aOutNumL) {\n var mInputLgt = $bm_sub(aInNumL, aInNumF);\n var mOutputLgt = $bm_sub(aOutNumL, aOutNumF);\n var mRatio = $bm_div(mOutputLgt, mInputLgt);\n if (aInNumF <= aInput && aInput <= aInNumL) {\n var mOutput = $bm_sum($bm_mul($bm_sub(aInput, aInNumF), mRatio), aOutNumF);\n } else {\n var mOutput = aInput;\n }\n return mOutput;\n}\nvar mSld = $bm_div(thisLayer('ADBE Effect Parade')('ShapeNir Slider')('ADBE Slider Control-0001'), 100);\nif (mSld <= 0) {\n var mRst = mN01;\n} else if (mSld >= 1) {\n var mRst = mN02;\n} else {\n for (var i = 1; i < mRatiosM.length; i++) {\n if (mRatiosM[i - 1] <= mSld && mSld < mRatiosM[i]) {\n var mCtl = mLinear(mSld, mRatiosM[$bm_sub(i, 1)], mRatiosM[i], mRatiosL[$bm_sub(i, 1)], mRatiosL[i]);\n break;\n }\n }\n var mRst = mLinear(mCtl, 0, 1, mN01Edt, mN02Edt);\n}\n$bm_rt = mRst;"},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Taglia tracciati 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":1,"k":[{"i":{"x":0.17,"y":1},"o":{"x":0.167,"y":0},"t":58,"s":[{"i":[[0,0],[5.418,5.419],[17.468,17.469],[25.537,-25.537]],"o":[[-3.662,6.731],[0,0],[-25.536,-25.537],[0,0]],"v":[[1518.105,1050.515],[1501.682,1034.099],[1545.158,1077.713],[1480.731,1037.589]],"c":false}]},{"t":64,"s":[{"i":[[0,0],[5.418,5.419],[17.468,17.469],[25.537,-25.537]],"o":[[-3.662,6.731],[0,0],[-25.536,-25.537],[0,0]],"v":[[1614.514,1147.064],[1598.091,1130.648],[1545.158,1077.713],[1467.776,1024.778]],"c":false}]}],"ix":2},"nm":"Tracciato 1_Bone","mn":"ADBE Vector Shape - Group","hd":false}],"ip":53,"op":126,"st":6,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"top_extra","parent":10,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":45,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":29,"s":[1502.093,1035.094,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":34,"s":[1495.062,1036.428,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":51,"s":[1495.062,1036.428,0],"to":[0,0,0],"ti":[0,0,0]},{"t":53,"s":[1502.312,1036.428,0]}],"ix":2},"a":{"a":0,"k":[-98.5,-102,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":29,"s":[46.5,46.5,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":32,"s":[50,50,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":49,"s":[50,50,100]},{"t":53,"s":[45.2,45.2,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[94,94],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Tracciato ellisse 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.211761474609,0.568603515625,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Riempimento 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-98.5,-102],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellisse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":29,"op":53,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"top","parent":5,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[73.565,-23.785,0],"ix":2},"a":{"a":0,"k":[1488.448,1026.164,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ef":[{"ty":5,"nm":"ShapeNir Slider","np":3,"mn":"ADBE Slider Control","ix":1,"en":1,"ef":[{"ty":0,"nm":"Slider","mn":"ADBE Slider Control-0001","ix":1,"v":{"a":1,"k":[{"i":{"x":[0.17],"y":[1]},"o":{"x":[0.55],"y":[0]},"t":6,"s":[0]},{"t":23,"s":[100]}],"ix":1}}]}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.17,"y":1},"o":{"x":0.55,"y":0},"t":6,"s":[{"i":[[0,0],[0,0],[-13.503,-13.506],[0,0],[9.065,-9.068],[0,0],[0,0],[9.068,9.071],[0,0],[0,0]],"o":[[0,0],[13.506,-13.506],[0,0],[9.065,9.068],[0,0],[0,0],[-9.068,9.074],[0,0],[0,0],[0,0]],"v":[[-74.672,-57.386],[-70.481,-61.577],[-21.582,-61.577],[194.283,154.505],[194.286,187.34],[194.286,187.34],[194.286,187.34],[161.442,187.343],[-78.863,-53.195],[-74.672,-57.386]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.55,"y":0},"t":23,"s":[{"i":[[0,0],[0,0],[-13.503,-13.506],[0,0],[9.065,-9.068],[0,0],[0,0],[9.068,9.071],[0,0],[0,0]],"o":[[0,0],[13.506,-13.506],[0,0],[9.065,9.068],[0,0],[0,0],[-9.068,9.074],[0,0],[0,0],[0,0]],"v":[[-74.672,-57.386],[-70.481,-61.577],[-21.582,-61.577],[72.063,32.066],[72.066,64.9],[72.066,64.9],[72.066,64.9],[39.223,64.903],[-78.863,-53.195],[-74.672,-57.386]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0.167},"t":26,"s":[{"i":[[0,0],[0,0],[-13.503,-13.506],[0,0],[9.065,-9.068],[0,0],[0,0],[9.068,9.071],[2.122,2.171],[0,0]],"o":[[0,0],[13.506,-13.506],[0,0],[9.065,9.068],[0,0],[0,0],[-9.068,9.074],[0,0],[-2.122,-2.171],[0,0]],"v":[[-70.801,-53.558],[-66.61,-57.749],[-21.582,-61.577],[43.259,3.22],[43.262,36.055],[43.262,36.055],[43.262,36.055],[10.419,36.058],[-66.621,-43.95],[-70.801,-53.558]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":29,"s":[{"i":[[0,0],[0,0],[-13.503,-13.506],[0,0],[9.065,-9.068],[0,0],[0,0],[9.068,9.071],[0,0],[0,0]],"o":[[0,0],[13.506,-13.506],[0,0],[9.065,9.068],[0,0],[0,0],[-9.068,9.074],[0,0],[0,0],[0,0]],"v":[[-61.716,-44.574],[-57.525,-48.766],[-21.582,-61.577],[-24.345,-64.482],[-24.342,-31.648],[-24.342,-31.648],[-24.342,-31.648],[-57.186,-31.645],[-65.907,-40.383],[-61.716,-44.574]],"c":false}]},{"i":{"x":0.45,"y":1},"o":{"x":0.167,"y":0},"t":53,"s":[{"i":[[0,0],[0,0],[-13.503,-13.506],[0,0],[9.065,-9.068],[0,0],[0,0],[9.068,9.071],[0,0],[0,0]],"o":[[0,0],[13.506,-13.506],[0,0],[9.065,9.068],[0,0],[0,0],[-9.068,9.074],[0,0],[0,0],[0,0]],"v":[[-61.716,-44.574],[-57.525,-48.766],[-21.582,-61.577],[-24.345,-64.482],[-24.342,-31.648],[-24.342,-31.648],[-24.342,-31.648],[-57.186,-31.645],[-65.907,-40.383],[-61.716,-44.574]],"c":false}]},{"t":59,"s":[{"i":[[0,0],[0,0],[-13.503,-13.506],[0,0],[9.065,-9.068],[0,0],[0,0],[9.068,9.071],[0,0],[0,0]],"o":[[0,0],[13.506,-13.506],[0,0],[9.065,9.068],[0,0],[0,0],[-9.068,9.074],[0,0],[0,0],[0,0]],"v":[[-74.672,-57.386],[-70.481,-61.577],[-21.582,-61.577],[72.063,32.066],[72.066,64.9],[72.066,64.9],[72.066,64.9],[39.223,64.903],[-78.863,-53.195],[-74.672,-57.386]],"c":false}]}],"ix":2},"nm":"Tracciato 1_forAnimation","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.211764708161,0.568627476692,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Riempimento 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1542.448,1082.164],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Gruppo 3","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1,"x":"var $bm_rt;\nvar mN01 = 52.2496645130989;\nvar mN02 = 100;\nvar mN01Edt = 52.2496645130989;\nvar mN02Edt = 98.6485408913042;\nvar mRatiosM = [\n 0,\n 1\n ];\nvar mRatiosF = [\n 0,\n 1\n ];\nfunction mLinear(aInput, aInNumF, aInNumL, aOutNumF, aOutNumL) {\n var mInputLgt = $bm_sub(aInNumL, aInNumF);\n var mOutputLgt = $bm_sub(aOutNumL, aOutNumF);\n var mRatio = $bm_div(mOutputLgt, mInputLgt);\n if (aInNumF <= aInput && aInput <= aInNumL) {\n var mOutput = $bm_sum($bm_mul($bm_sub(aInput, aInNumF), mRatio), aOutNumF);\n } else {\n var mOutput = aInput;\n }\n return mOutput;\n}\nvar mSld = $bm_div(thisLayer('ADBE Effect Parade')('ShapeNir Slider')('ADBE Slider Control-0001'), 100);\nif (mSld <= 0) {\n var mRst = mN01;\n} else if (mSld >= 1) {\n var mRst = mN02;\n} else {\n for (var i = 1; i < mRatiosM.length; i++) {\n if (mRatiosM[i - 1] <= mSld && mSld < mRatiosM[i]) {\n var mCtl = mLinear(mSld, mRatiosM[$bm_sub(i, 1)], mRatiosM[i], mRatiosF[$bm_sub(i, 1)], mRatiosF[i]);\n break;\n }\n }\n var mRst = mLinear(mCtl, 0, 1, mN01Edt, mN02Edt);\n}\n$bm_rt = mRst;"},"e":{"a":0,"k":100,"ix":2,"x":"var $bm_rt;\nvar mN01 = 52.2496645130989;\nvar mN02 = 0;\nvar mN01Edt = 52.2496645130989;\nvar mN02Edt = 1.35145910869578;\nvar mRatiosM = [\n 0,\n 1\n ];\nvar mRatiosL = [\n 0,\n 1\n ];\nfunction mLinear(aInput, aInNumF, aInNumL, aOutNumF, aOutNumL) {\n var mInputLgt = $bm_sub(aInNumL, aInNumF);\n var mOutputLgt = $bm_sub(aOutNumL, aOutNumF);\n var mRatio = $bm_div(mOutputLgt, mInputLgt);\n if (aInNumF <= aInput && aInput <= aInNumL) {\n var mOutput = $bm_sum($bm_mul($bm_sub(aInput, aInNumF), mRatio), aOutNumF);\n } else {\n var mOutput = aInput;\n }\n return mOutput;\n}\nvar mSld = $bm_div(thisLayer('ADBE Effect Parade')('ShapeNir Slider')('ADBE Slider Control-0001'), 100);\nif (mSld <= 0) {\n var mRst = mN01;\n} else if (mSld >= 1) {\n var mRst = mN02;\n} else {\n for (var i = 1; i < mRatiosM.length; i++) {\n if (mRatiosM[i - 1] <= mSld && mSld < mRatiosM[i]) {\n var mCtl = mLinear(mSld, mRatiosM[$bm_sub(i, 1)], mRatiosM[i], mRatiosL[$bm_sub(i, 1)], mRatiosL[i]);\n break;\n }\n }\n var mRst = mLinear(mCtl, 0, 1, mN01Edt, mN02Edt);\n}\n$bm_rt = mRst;"},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Taglia tracciati 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":1,"k":[{"i":{"x":0.17,"y":1},"o":{"x":0.55,"y":0},"t":6,"s":[{"i":[[0,0],[5.418,5.419],[17.468,17.469],[25.537,-25.537]],"o":[[-3.662,6.731],[0,0],[-25.536,-25.537],[0,0]],"v":[[1736.734,1269.504],[1720.31,1253.088],[1545.158,1077.713],[1467.776,1024.778]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.55,"y":0},"t":23,"s":[{"i":[[0,0],[5.418,5.419],[17.468,17.469],[25.537,-25.537]],"o":[[-3.662,6.731],[0,0],[-25.536,-25.537],[0,0]],"v":[[1614.514,1147.064],[1598.091,1130.648],[1545.158,1077.713],[1467.776,1024.778]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":29,"s":[{"i":[[0,0],[5.418,5.419],[17.468,17.469],[25.537,-25.537]],"o":[[-3.662,6.731],[0,0],[-25.536,-25.537],[0,0]],"v":[[1518.105,1050.515],[1501.682,1034.099],[1545.158,1077.713],[1480.731,1037.589]],"c":false}]},{"i":{"x":0.45,"y":1},"o":{"x":0.167,"y":0},"t":53,"s":[{"i":[[0,0],[5.418,5.419],[17.468,17.469],[25.537,-25.537]],"o":[[-3.662,6.731],[0,0],[-25.536,-25.537],[0,0]],"v":[[1518.105,1050.515],[1501.682,1034.099],[1545.158,1077.713],[1480.731,1037.589]],"c":false}]},{"t":59,"s":[{"i":[[0,0],[5.418,5.419],[17.468,17.469],[25.537,-25.537]],"o":[[-3.662,6.731],[0,0],[-25.536,-25.537],[0,0]],"v":[[1614.514,1147.064],[1598.091,1130.648],[1545.158,1077.713],[1467.776,1024.778]],"c":false}]}],"ix":2},"nm":"Tracciato 1_Bone","mn":"ADBE Vector Shape - Group","hd":false}],"ip":6,"op":29,"st":6,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"mid 2","parent":5,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[15,16,0],"ix":2},"a":{"a":0,"k":[1429.883,1065.949,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ef":[{"ty":5,"nm":"ShapeNir Slider","np":3,"mn":"ADBE Slider Control","ix":1,"en":1,"ef":[{"ty":0,"nm":"Slider","mn":"ADBE Slider Control-0001","ix":1,"v":{"a":0,"k":100,"ix":1}}]}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.45,"y":1},"o":{"x":0.167,"y":0},"t":55.5,"s":[{"i":[[0,0],[0,0],[0,0],[6.673,-6.673],[0,0],[0,0],[6.673,6.673],[0,0],[0,0]],"o":[[0,0],[0,0],[6.671,6.673],[0,0],[0,0],[-6.673,6.671],[0,0],[0,0],[0,0]],"v":[[-30.953,-29.75],[-18.872,-41.83],[-9.362,-32.208],[-9.362,-8.045],[-9.362,-8.045],[-9.362,-8.045],[-33.522,-8.045],[-43.033,-17.67],[-30.953,-29.75]],"c":false}]},{"t":61.5,"s":[{"i":[[0,0],[0,0],[0,0],[9.068,-9.068],[0,0],[0,0],[9.068,9.068],[0,0],[0,0]],"o":[[0,0],[0,0],[9.065,9.068],[0,0],[0,0],[-9.068,9.065],[0,0],[0,0],[0,0]],"v":[[-36.143,-36.135],[-19.727,-52.551],[45.759,12.917],[45.759,45.751],[45.759,45.751],[45.759,45.751],[12.927,45.751],[-52.559,-19.719],[-36.143,-36.135]],"c":false}]}],"ix":2},"nm":"Tracciato 1_forAnimation","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.223529413342,0.223529413342,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Riempimento 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1464.883,1099.949],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Gruppo 2","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1,"x":"var $bm_rt;\nvar mN01 = 50.0000976522834;\nvar mN02 = 100;\nvar mN01Edt = 50.0000976522834;\nvar mN02Edt = 92.3777446239903;\nvar mRatiosM = [\n 0,\n 1\n ];\nvar mRatiosF = [\n 0,\n 1\n ];\nfunction mLinear(aInput, aInNumF, aInNumL, aOutNumF, aOutNumL) {\n var mInputLgt = $bm_sub(aInNumL, aInNumF);\n var mOutputLgt = $bm_sub(aOutNumL, aOutNumF);\n var mRatio = $bm_div(mOutputLgt, mInputLgt);\n if (aInNumF <= aInput && aInput <= aInNumL) {\n var mOutput = $bm_sum($bm_mul($bm_sub(aInput, aInNumF), mRatio), aOutNumF);\n } else {\n var mOutput = aInput;\n }\n return mOutput;\n}\nvar mSld = $bm_div(thisLayer('ADBE Effect Parade')('ShapeNir Slider')('ADBE Slider Control-0001'), 100);\nif (mSld <= 0) {\n var mRst = mN01;\n} else if (mSld >= 1) {\n var mRst = mN02;\n} else {\n for (var i = 1; i < mRatiosM.length; i++) {\n if (mRatiosM[i - 1] <= mSld && mSld < mRatiosM[i]) {\n var mCtl = mLinear(mSld, mRatiosM[$bm_sub(i, 1)], mRatiosM[i], mRatiosF[$bm_sub(i, 1)], mRatiosF[i]);\n break;\n }\n }\n var mRst = mLinear(mCtl, 0, 1, mN01Edt, mN02Edt);\n}\n$bm_rt = mRst;"},"e":{"a":0,"k":100,"ix":2,"x":"var $bm_rt;\nvar mN01 = 50.0000976522834;\nvar mN02 = 0;\nvar mN01Edt = 50.0000976522834;\nvar mN02Edt = 7.62225537600966;\nvar mRatiosM = [\n 0,\n 1\n ];\nvar mRatiosL = [\n 0,\n 1\n ];\nfunction mLinear(aInput, aInNumF, aInNumL, aOutNumF, aOutNumL) {\n var mInputLgt = $bm_sub(aInNumL, aInNumF);\n var mOutputLgt = $bm_sub(aOutNumL, aOutNumF);\n var mRatio = $bm_div(mOutputLgt, mInputLgt);\n if (aInNumF <= aInput && aInput <= aInNumL) {\n var mOutput = $bm_sum($bm_mul($bm_sub(aInput, aInNumF), mRatio), aOutNumF);\n } else {\n var mOutput = aInput;\n }\n return mOutput;\n}\nvar mSld = $bm_div(thisLayer('ADBE Effect Parade')('ShapeNir Slider')('ADBE Slider Control-0001'), 100);\nif (mSld <= 0) {\n var mRst = mN01;\n} else if (mSld >= 1) {\n var mRst = mN02;\n} else {\n for (var i = 1; i < mRatiosM.length; i++) {\n if (mRatiosM[i - 1] <= mSld && mSld < mRatiosM[i]) {\n var mCtl = mLinear(mSld, mRatiosM[$bm_sub(i, 1)], mRatiosM[i], mRatiosL[$bm_sub(i, 1)], mRatiosL[i]);\n break;\n }\n }\n var mRst = mLinear(mCtl, 0, 1, mN01Edt, mN02Edt);\n}\n$bm_rt = mRst;"},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Taglia tracciati 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":1,"k":[{"i":{"x":0.45,"y":1},"o":{"x":0.167,"y":0},"t":55.5,"s":[{"i":[[0,0],[11.939,11.936],[0,0]],"o":[[-0.527,-16.874],[-7.951,-7.949],[0,0]],"v":[[1455.522,1091.904],[1458.026,1094.288],[1433.931,1070.199]],"c":false}]},{"t":61.5,"s":[{"i":[[0,0],[16.224,16.22],[0,0]],"o":[[-0.717,-22.93],[-10.805,-10.802],[0,0]],"v":[[1510.642,1145.701],[1461.483,1096.548],[1428.74,1063.814]],"c":false}]}],"ix":2},"nm":"Tracciato 1_Bone","mn":"ADBE Vector Shape - Group","hd":false}],"ip":53,"op":126,"st":3,"bm":0},{"ddd":0,"ind":12,"ty":4,"nm":"mid_extra","parent":13,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":45,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":29,"s":[1442.756,1079.784,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":34,"s":[1450.145,1086.357,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":51,"s":[1450.145,1086.357,0],"to":[0,0,0],"ti":[0,0,0]},{"t":53,"s":[1443.895,1080.232,0]}],"ix":2},"a":{"a":0,"k":[-120,40,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":29,"s":[37.2,37.2,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":34,"s":[50,50,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":49,"s":[50,50,100]},{"t":53,"s":[35.7,35.7,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[93,93],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Tracciato ellisse 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.223510742188,0.223510742188,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Riempimento 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-120,40],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellisse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":29,"op":53,"st":0,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"mid","parent":5,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[15,16,0],"ix":2},"a":{"a":0,"k":[1429.883,1065.949,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ef":[{"ty":5,"nm":"ShapeNir Slider","np":3,"mn":"ADBE Slider Control","ix":1,"en":1,"ef":[{"ty":0,"nm":"Slider","mn":"ADBE Slider Control-0001","ix":1,"v":{"a":1,"k":[{"i":{"x":[0.17],"y":[1]},"o":{"x":[0.55],"y":[0]},"t":3,"s":[0]},{"t":23,"s":[100]}],"ix":1}}]}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.17,"y":1},"o":{"x":0.55,"y":0},"t":3,"s":[{"i":[[0,0],[0,0],[0,0],[9.068,-9.068],[0,0],[0,0],[9.068,9.068],[0,0],[0,0]],"o":[[0,0],[0,0],[9.065,9.068],[0,0],[0,0],[-9.068,9.065],[0,0],[0,0],[0,0]],"v":[[-36.143,-36.135],[-19.727,-52.551],[119.506,86.956],[119.506,119.79],[119.506,119.79],[119.506,119.79],[86.674,119.79],[-52.559,-19.719],[-36.143,-36.135]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.55,"y":0},"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[9.068,-9.068],[0,0],[0,0],[9.068,9.068],[0,0],[0,0]],"o":[[0,0],[0,0],[9.065,9.068],[0,0],[0,0],[-9.068,9.065],[0,0],[0,0],[0,0]],"v":[[-36.143,-36.135],[-19.727,-52.551],[45.759,12.917],[45.759,45.751],[45.759,45.751],[45.759,45.751],[12.927,45.751],[-52.559,-19.719],[-36.143,-36.135]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[6.673,-6.673],[0,0],[0,0],[6.673,6.673],[0,0],[0,0]],"o":[[0,0],[0,0],[6.671,6.673],[0,0],[0,0],[-6.673,6.671],[0,0],[0,0],[0,0]],"v":[[-30.953,-29.75],[-18.872,-41.83],[-9.362,-32.208],[-9.362,-8.045],[-9.362,-8.045],[-9.362,-8.045],[-33.522,-8.045],[-43.033,-17.67],[-30.953,-29.75]],"c":false}]},{"i":{"x":0.45,"y":1},"o":{"x":0.167,"y":0},"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[6.673,-6.673],[0,0],[0,0],[6.673,6.673],[0,0],[0,0]],"o":[[0,0],[0,0],[6.671,6.673],[0,0],[0,0],[-6.673,6.671],[0,0],[0,0],[0,0]],"v":[[-30.953,-29.75],[-18.872,-41.83],[-9.362,-32.208],[-9.362,-8.045],[-9.362,-8.045],[-9.362,-8.045],[-33.522,-8.045],[-43.033,-17.67],[-30.953,-29.75]],"c":false}]},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[9.068,-9.068],[0,0],[0,0],[9.068,9.068],[0,0],[0,0]],"o":[[0,0],[0,0],[9.065,9.068],[0,0],[0,0],[-9.068,9.065],[0,0],[0,0],[0,0]],"v":[[-36.143,-36.135],[-19.727,-52.551],[45.759,12.917],[45.759,45.751],[45.759,45.751],[45.759,45.751],[12.927,45.751],[-52.559,-19.719],[-36.143,-36.135]],"c":false}]}],"ix":2},"nm":"Tracciato 1_forAnimation","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.223529413342,0.223529413342,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Riempimento 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1464.883,1099.949],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Gruppo 2","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1,"x":"var $bm_rt;\nvar mN01 = 50.0000976522834;\nvar mN02 = 100;\nvar mN01Edt = 50.0000976522834;\nvar mN02Edt = 92.3777446239903;\nvar mRatiosM = [\n 0,\n 1\n ];\nvar mRatiosF = [\n 0,\n 1\n ];\nfunction mLinear(aInput, aInNumF, aInNumL, aOutNumF, aOutNumL) {\n var mInputLgt = $bm_sub(aInNumL, aInNumF);\n var mOutputLgt = $bm_sub(aOutNumL, aOutNumF);\n var mRatio = $bm_div(mOutputLgt, mInputLgt);\n if (aInNumF <= aInput && aInput <= aInNumL) {\n var mOutput = $bm_sum($bm_mul($bm_sub(aInput, aInNumF), mRatio), aOutNumF);\n } else {\n var mOutput = aInput;\n }\n return mOutput;\n}\nvar mSld = $bm_div(thisLayer('ADBE Effect Parade')('ShapeNir Slider')('ADBE Slider Control-0001'), 100);\nif (mSld <= 0) {\n var mRst = mN01;\n} else if (mSld >= 1) {\n var mRst = mN02;\n} else {\n for (var i = 1; i < mRatiosM.length; i++) {\n if (mRatiosM[i - 1] <= mSld && mSld < mRatiosM[i]) {\n var mCtl = mLinear(mSld, mRatiosM[$bm_sub(i, 1)], mRatiosM[i], mRatiosF[$bm_sub(i, 1)], mRatiosF[i]);\n break;\n }\n }\n var mRst = mLinear(mCtl, 0, 1, mN01Edt, mN02Edt);\n}\n$bm_rt = mRst;"},"e":{"a":0,"k":100,"ix":2,"x":"var $bm_rt;\nvar mN01 = 50.0000976522834;\nvar mN02 = 0;\nvar mN01Edt = 50.0000976522834;\nvar mN02Edt = 7.62225537600966;\nvar mRatiosM = [\n 0,\n 1\n ];\nvar mRatiosL = [\n 0,\n 1\n ];\nfunction mLinear(aInput, aInNumF, aInNumL, aOutNumF, aOutNumL) {\n var mInputLgt = $bm_sub(aInNumL, aInNumF);\n var mOutputLgt = $bm_sub(aOutNumL, aOutNumF);\n var mRatio = $bm_div(mOutputLgt, mInputLgt);\n if (aInNumF <= aInput && aInput <= aInNumL) {\n var mOutput = $bm_sum($bm_mul($bm_sub(aInput, aInNumF), mRatio), aOutNumF);\n } else {\n var mOutput = aInput;\n }\n return mOutput;\n}\nvar mSld = $bm_div(thisLayer('ADBE Effect Parade')('ShapeNir Slider')('ADBE Slider Control-0001'), 100);\nif (mSld <= 0) {\n var mRst = mN01;\n} else if (mSld >= 1) {\n var mRst = mN02;\n} else {\n for (var i = 1; i < mRatiosM.length; i++) {\n if (mRatiosM[i - 1] <= mSld && mSld < mRatiosM[i]) {\n var mCtl = mLinear(mSld, mRatiosM[$bm_sub(i, 1)], mRatiosM[i], mRatiosL[$bm_sub(i, 1)], mRatiosL[i]);\n break;\n }\n }\n var mRst = mLinear(mCtl, 0, 1, mN01Edt, mN02Edt);\n}\n$bm_rt = mRst;"},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Taglia tracciati 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":1,"k":[{"i":{"x":0.17,"y":1},"o":{"x":0.55,"y":0},"t":3,"s":[{"i":[[0,0],[16.224,16.22],[0,0]],"o":[[-0.717,-22.93],[-10.805,-10.802],[0,0]],"v":[[1584.389,1219.739],[1461.483,1096.548],[1428.74,1063.814]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.55,"y":0},"t":23,"s":[{"i":[[0,0],[16.224,16.22],[0,0]],"o":[[-0.717,-22.93],[-10.805,-10.802],[0,0]],"v":[[1510.642,1145.701],[1461.483,1096.548],[1428.74,1063.814]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":29,"s":[{"i":[[0,0],[11.939,11.936],[0,0]],"o":[[-0.527,-16.874],[-7.951,-7.949],[0,0]],"v":[[1455.522,1091.904],[1458.026,1094.288],[1433.931,1070.199]],"c":false}]},{"i":{"x":0.45,"y":1},"o":{"x":0.167,"y":0},"t":53,"s":[{"i":[[0,0],[11.939,11.936],[0,0]],"o":[[-0.527,-16.874],[-7.951,-7.949],[0,0]],"v":[[1455.522,1091.904],[1458.026,1094.288],[1433.931,1070.199]],"c":false}]},{"t":59,"s":[{"i":[[0,0],[16.224,16.22],[0,0]],"o":[[-0.717,-22.93],[-10.805,-10.802],[0,0]],"v":[[1510.642,1145.701],[1461.483,1096.548],[1428.74,1063.814]],"c":false}]}],"ix":2},"nm":"Tracciato 1_Bone","mn":"ADBE Vector Shape - Group","hd":false}],"ip":3,"op":29,"st":3,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"bottom 2","parent":5,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-2.559,65.635,0],"ix":2},"a":{"a":0,"k":[1412.324,1115.584,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ef":[{"ty":5,"nm":"ShapeNir Slider","np":3,"mn":"ADBE Slider Control","ix":1,"en":1,"ef":[{"ty":0,"nm":"Slider","mn":"ADBE Slider Control-0001","ix":1,"v":{"a":0,"k":100,"ix":1}}]}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.45,"y":1},"o":{"x":0.167,"y":0},"t":53,"s":[{"i":[[0,0],[0,0],[0,0],[6.334,6.334],[-6.333,6.334],[0,0],[0,0]],"o":[[0,0],[0,0],[-6.334,6.333],[-6.334,-6.334],[0,0],[0,0],[0,0]],"v":[[6.738,-2.735],[18.731,9.258],[7.38,20.609],[-16.081,20.083],[-16.606,-3.378],[-5.255,-14.728],[6.738,-2.735]],"c":false}]},{"t":59,"s":[{"i":[[0,0],[0,0],[0,0],[9.068,9.068],[-9.068,9.068],[0,0],[0,0]],"o":[[0,0],[0,0],[-9.068,9.068],[-9.068,-9.068],[0,0],[0,0],[0,0]],"v":[[11.444,-11.445],[28.615,5.726],[12.364,21.977],[-21.225,21.225],[-21.977,-12.364],[-5.726,-28.615],[11.444,-11.445]],"c":false}]}],"ix":2},"nm":"Tracciato 1_forAnimation","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.337254911661,0.223529413342,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Riempimento 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1399.865,1127.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Gruppo 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1,"x":"var $bm_rt;\nvar mN01 = 50.0000191622908;\nvar mN02 = 100;\nvar mN01Edt = 50.0000191622908;\nvar mN02Edt = 85.6245824308121;\nvar mRatiosM = [\n 0,\n 0.1472024328579,\n 0.50257634091374,\n 1\n ];\nvar mRatiosF = [\n 0,\n 0.31314999326651,\n 0.62416910908086,\n 1\n ];\nfunction mLinear(aInput, aInNumF, aInNumL, aOutNumF, aOutNumL) {\n var mInputLgt = $bm_sub(aInNumL, aInNumF);\n var mOutputLgt = $bm_sub(aOutNumL, aOutNumF);\n var mRatio = $bm_div(mOutputLgt, mInputLgt);\n if (aInNumF <= aInput && aInput <= aInNumL) {\n var mOutput = $bm_sum($bm_mul($bm_sub(aInput, aInNumF), mRatio), aOutNumF);\n } else {\n var mOutput = aInput;\n }\n return mOutput;\n}\nvar mSld = $bm_div(thisLayer('ADBE Effect Parade')('ShapeNir Slider')('ADBE Slider Control-0001'), 100);\nif (mSld <= 0) {\n var mRst = mN01;\n} else if (mSld >= 1) {\n var mRst = mN02;\n} else {\n for (var i = 1; i < mRatiosM.length; i++) {\n if (mRatiosM[i - 1] <= mSld && mSld < mRatiosM[i]) {\n var mCtl = mLinear(mSld, mRatiosM[$bm_sub(i, 1)], mRatiosM[i], mRatiosF[$bm_sub(i, 1)], mRatiosF[i]);\n break;\n }\n }\n var mRst = mLinear(mCtl, 0, 1, mN01Edt, mN02Edt);\n}\n$bm_rt = mRst;"},"e":{"a":0,"k":100,"ix":2,"x":"var $bm_rt;\nvar mN01 = 50.0000191622908;\nvar mN02 = 0;\nvar mN01Edt = 50.0000191622908;\nvar mN02Edt = 14.3754175691879;\nvar mRatiosM = [\n 0,\n 0.1472024328579,\n 0.50257634091374,\n 1\n ];\nvar mRatiosL = [\n 0,\n 0.31314966173997,\n 0.62416844828369,\n 1\n ];\nfunction mLinear(aInput, aInNumF, aInNumL, aOutNumF, aOutNumL) {\n var mInputLgt = $bm_sub(aInNumL, aInNumF);\n var mOutputLgt = $bm_sub(aOutNumL, aOutNumF);\n var mRatio = $bm_div(mOutputLgt, mInputLgt);\n if (aInNumF <= aInput && aInput <= aInNumL) {\n var mOutput = $bm_sum($bm_mul($bm_sub(aInput, aInNumF), mRatio), aOutNumF);\n } else {\n var mOutput = aInput;\n }\n return mOutput;\n}\nvar mSld = $bm_div(thisLayer('ADBE Effect Parade')('ShapeNir Slider')('ADBE Slider Control-0001'), 100);\nif (mSld <= 0) {\n var mRst = mN01;\n} else if (mSld >= 1) {\n var mRst = mN02;\n} else {\n for (var i = 1; i < mRatiosM.length; i++) {\n if (mRatiosM[i - 1] <= mSld && mSld < mRatiosM[i]) {\n var mCtl = mLinear(mSld, mRatiosM[$bm_sub(i, 1)], mRatiosM[i], mRatiosL[$bm_sub(i, 1)], mRatiosL[i]);\n break;\n }\n }\n var mRst = mLinear(mCtl, 0, 1, mN01Edt, mN02Edt);\n}\n$bm_rt = mRst;"},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Taglia tracciati 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":1,"k":[{"i":{"x":0.45,"y":1},"o":{"x":0.167,"y":0},"t":53,"s":[{"i":[[0,0],[-1.108,1.108],[-2.676,2.676],[-1.873,1.873],[0,0]],"o":[[0,0],[2.676,-2.676],[0,0],[1.873,-1.873],[0,0]],"v":[[1383.785,1147.112],[1387.144,1143.753],[1395.253,1135.644],[1400.928,1129.969],[1406.603,1124.294]],"c":false}]},{"t":59,"s":[{"i":[[0,0],[-1.587,1.587],[-3.831,3.831],[-2.681,2.681],[0,0]],"o":[[0,0],[3.831,-3.831],[0,0],[2.681,-2.681],[0,0]],"v":[[1378.641,1148.253],[1383.45,1143.444],[1395.059,1131.835],[1403.185,1123.709],[1411.31,1115.584]],"c":false}]}],"ix":2},"nm":"Tracciato 1_Bone","mn":"ADBE Vector Shape - Group","hd":false}],"ip":53,"op":126,"st":0,"bm":0},{"ddd":0,"ind":15,"ty":4,"nm":"bottom_extra","parent":16,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":45,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":29,"s":[1395.693,1135.648,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":34,"s":[1398.671,1131.534,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":51,"s":[1398.671,1131.534,0],"to":[0,0,0],"ti":[0,0,0]},{"t":53,"s":[1396.171,1134.409,0]}],"ix":2},"a":{"a":0,"k":[-114,181,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":29,"s":[38.5,38.5,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":34,"s":[50,50,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":49,"s":[50,50,100]},{"t":53,"s":[37,37,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[91,91],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Tracciato ellisse 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.337249755859,0.223510742188,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Riempimento 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-114,181],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellisse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":29,"op":53,"st":0,"bm":0},{"ddd":0,"ind":16,"ty":4,"nm":"bottom","parent":5,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-2.559,65.635,0],"ix":2},"a":{"a":0,"k":[1412.324,1115.584,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ef":[{"ty":5,"nm":"ShapeNir Slider","np":3,"mn":"ADBE Slider Control","ix":1,"en":1,"ef":[{"ty":0,"nm":"Slider","mn":"ADBE Slider Control-0001","ix":1,"v":{"a":1,"k":[{"i":{"x":[0.17],"y":[1]},"o":{"x":[0.55],"y":[0]},"t":0,"s":[0]},{"t":23,"s":[100]}],"ix":1}}]}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.17,"y":1},"o":{"x":0.55,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[9.068,9.068],[-9.068,9.068],[0,0],[0,0]],"o":[[0,0],[0,0],[-9.068,9.068],[-9.068,-9.068],[0,0],[0,0],[0,0]],"v":[[11.444,-11.445],[28.615,5.726],[-95.63,130.357],[-129.219,129.605],[-129.971,96.016],[-5.726,-28.615],[11.444,-11.445]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.55,"y":0},"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[9.068,9.068],[-9.068,9.068],[0,0],[0,0]],"o":[[0,0],[0,0],[-9.068,9.068],[-9.068,-9.068],[0,0],[0,0],[0,0]],"v":[[11.444,-11.445],[28.615,5.726],[12.364,21.977],[-21.225,21.225],[-21.977,-12.364],[-5.726,-28.615],[11.444,-11.445]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0.167},"t":28,"s":[{"i":[[-3.749,-3.55],[1.525,-6.804],[0,0],[6.838,6.838],[-6.838,6.838],[-5.985,0.184],[0,0]],"o":[[3.749,3.55],[-1.525,6.804],[-6.838,6.838],[-6.838,-6.838],[0,0],[5.985,-0.184],[0,0]],"v":[[12.19,-8.82],[20.555,8.606],[8.3,20.861],[-17.03,20.294],[-17.597,-5.036],[-5.342,-17.291],[12.19,-8.82]],"c":false}]},{"t":29,"s":[{"i":[[0,0],[0,0],[0,0],[6.334,6.334],[-6.333,6.334],[0,0],[0,0]],"o":[[0,0],[0,0],[-6.334,6.333],[-6.334,-6.334],[0,0],[0,0],[0,0]],"v":[[6.738,-2.735],[18.731,9.258],[7.38,20.609],[-16.081,20.083],[-16.606,-3.378],[-5.255,-14.728],[6.738,-2.735]],"c":false}]}],"ix":2},"nm":"Tracciato 1_forAnimation","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.337254911661,0.223529413342,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Riempimento 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[1399.865,1127.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Gruppo 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1,"x":"var $bm_rt;\nvar mN01 = 50.0000191622908;\nvar mN02 = 100;\nvar mN01Edt = 50.0000191622908;\nvar mN02Edt = 85.6245824308121;\nvar mRatiosM = [\n 0,\n 0.1472024328579,\n 0.50257634091374,\n 1\n ];\nvar mRatiosF = [\n 0,\n 0.31314999326651,\n 0.62416910908086,\n 1\n ];\nfunction mLinear(aInput, aInNumF, aInNumL, aOutNumF, aOutNumL) {\n var mInputLgt = $bm_sub(aInNumL, aInNumF);\n var mOutputLgt = $bm_sub(aOutNumL, aOutNumF);\n var mRatio = $bm_div(mOutputLgt, mInputLgt);\n if (aInNumF <= aInput && aInput <= aInNumL) {\n var mOutput = $bm_sum($bm_mul($bm_sub(aInput, aInNumF), mRatio), aOutNumF);\n } else {\n var mOutput = aInput;\n }\n return mOutput;\n}\nvar mSld = $bm_div(thisLayer('ADBE Effect Parade')('ShapeNir Slider')('ADBE Slider Control-0001'), 100);\nif (mSld <= 0) {\n var mRst = mN01;\n} else if (mSld >= 1) {\n var mRst = mN02;\n} else {\n for (var i = 1; i < mRatiosM.length; i++) {\n if (mRatiosM[i - 1] <= mSld && mSld < mRatiosM[i]) {\n var mCtl = mLinear(mSld, mRatiosM[$bm_sub(i, 1)], mRatiosM[i], mRatiosF[$bm_sub(i, 1)], mRatiosF[i]);\n break;\n }\n }\n var mRst = mLinear(mCtl, 0, 1, mN01Edt, mN02Edt);\n}\n$bm_rt = mRst;"},"e":{"a":0,"k":100,"ix":2,"x":"var $bm_rt;\nvar mN01 = 50.0000191622908;\nvar mN02 = 0;\nvar mN01Edt = 50.0000191622908;\nvar mN02Edt = 14.3754175691879;\nvar mRatiosM = [\n 0,\n 0.1472024328579,\n 0.50257634091374,\n 1\n ];\nvar mRatiosL = [\n 0,\n 0.31314966173997,\n 0.62416844828369,\n 1\n ];\nfunction mLinear(aInput, aInNumF, aInNumL, aOutNumF, aOutNumL) {\n var mInputLgt = $bm_sub(aInNumL, aInNumF);\n var mOutputLgt = $bm_sub(aOutNumL, aOutNumF);\n var mRatio = $bm_div(mOutputLgt, mInputLgt);\n if (aInNumF <= aInput && aInput <= aInNumL) {\n var mOutput = $bm_sum($bm_mul($bm_sub(aInput, aInNumF), mRatio), aOutNumF);\n } else {\n var mOutput = aInput;\n }\n return mOutput;\n}\nvar mSld = $bm_div(thisLayer('ADBE Effect Parade')('ShapeNir Slider')('ADBE Slider Control-0001'), 100);\nif (mSld <= 0) {\n var mRst = mN01;\n} else if (mSld >= 1) {\n var mRst = mN02;\n} else {\n for (var i = 1; i < mRatiosM.length; i++) {\n if (mRatiosM[i - 1] <= mSld && mSld < mRatiosM[i]) {\n var mCtl = mLinear(mSld, mRatiosM[$bm_sub(i, 1)], mRatiosM[i], mRatiosL[$bm_sub(i, 1)], mRatiosL[i]);\n break;\n }\n }\n var mRst = mLinear(mCtl, 0, 1, mN01Edt, mN02Edt);\n}\n$bm_rt = mRst;"},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Taglia tracciati 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":1,"k":[{"i":{"x":0.17,"y":1},"o":{"x":0.55,"y":0},"t":0,"s":[{"i":[[0,0],[-1.587,1.587],[-3.831,3.831],[-2.681,2.681],[0,0]],"o":[[0,0],[3.831,-3.831],[0,0],[2.681,-2.681],[0,0]],"v":[[1270.646,1256.634],[1275.455,1251.825],[1287.065,1240.215],[1403.185,1123.709],[1411.31,1115.584]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.55,"y":0},"t":23,"s":[{"i":[[0,0],[-1.587,1.587],[-3.831,3.831],[-2.681,2.681],[0,0]],"o":[[0,0],[3.831,-3.831],[0,0],[2.681,-2.681],[0,0]],"v":[[1378.641,1148.253],[1383.45,1143.444],[1395.059,1131.835],[1403.185,1123.709],[1411.31,1115.584]],"c":false}]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0.167},"t":28,"s":[{"i":[[0,0],[-1.197,1.197],[-2.889,2.889],[-2.022,2.022],[0,0]],"o":[[0,0],[2.889,-2.889],[0,0],[2.022,-2.022],[0,0]],"v":[[1382.835,1147.323],[1386.462,1143.696],[1395.217,1134.941],[1401.345,1128.813],[1412.057,1118.207]],"c":false}]},{"t":29,"s":[{"i":[[0,0],[-1.108,1.108],[-2.676,2.676],[-1.873,1.873],[0,0]],"o":[[0,0],[2.676,-2.676],[0,0],[1.873,-1.873],[0,0]],"v":[[1383.785,1147.112],[1387.144,1143.753],[1395.253,1135.644],[1400.928,1129.969],[1406.603,1124.294]],"c":false}]}],"ix":2},"nm":"Tracciato 1_Bone","mn":"ADBE Vector Shape - Group","hd":false}],"ip":0,"op":29,"st":0,"bm":0}],"markers":[]} \ No newline at end of file +{ + "v": "4.8.0", + "meta": {"g": "LottieFiles AE 3.4.3", "a": "", "k": "", "d": "", "tc": ""}, + "fr": 24, + "ip": 0, + "op": 92, + "w": 3840, + "h": 2160, + "nm": "icon - isolated", + "ddd": 0, + "assets": [], + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 3, + "nm": "NULL", + "sr": 1, + "ks": { + "o": {"a": 0, "k": 0, "ix": 11}, + "r": {"a": 0, "k": 0, "ix": 10}, + "p": {"a": 0, "k": [1920, 1080, 0], "ix": 2}, + "a": {"a": 0, "k": [50, 50, 0], "ix": 1}, + "s": { + "a": 1, + "k": [ + { + "i": {"x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 0.833]}, + "o": {"x": [0.167, 0.167, 0.167], "y": [0.167, 0.167, 0.167]}, + "t": 33, + "s": [100, 100, 100] + }, + {"t": 59, "s": [165, 165, 100]} + ], + "ix": 6 + } + }, + "ao": 0, + "ip": 0, + "op": 240, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 3, + "nm": "main position", + "parent": 1, + "sr": 1, + "ks": { + "o": {"a": 0, "k": 0, "ix": 11}, + "r": {"a": 0, "k": 0, "ix": 10}, + "p": { + "a": 1, + "k": [ + { + "i": {"x": 0.2, "y": 1}, + "o": {"x": 0.167, "y": 0.167}, + "t": 36, + "s": [50, 50, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + {"t": 54, "s": [464, 50, 0]} + ], + "ix": 2 + }, + "a": {"a": 0, "k": [50, 50, 0], "ix": 1}, + "s": {"a": 0, "k": [100, 100, 100], "ix": 6} + }, + "ao": 0, + "ip": 0, + "op": 240, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 3, + "ty": 3, + "nm": "scale", + "parent": 2, + "sr": 1, + "ks": { + "o": {"a": 0, "k": 0, "ix": 11}, + "r": {"a": 0, "k": 0, "ix": 10}, + "p": {"a": 0, "k": [50, 50, 0], "ix": 2}, + "a": {"a": 0, "k": [50, 50, 0], "ix": 1}, + "s": { + "a": 1, + "k": [ + { + "i": {"x": [0.667, 0.667, 0.667], "y": [1, 1, 1]}, + "o": {"x": [0.333, 0.333, 0.333], "y": [0, 0, 0]}, + "t": 23, + "s": [200, 200, 100] + }, + { + "i": {"x": [0.667, 0.667, 0.667], "y": [1, 1, 1]}, + "o": {"x": [0.333, 0.333, 0.333], "y": [0, 0, 0]}, + "t": 35, + "s": [118, 118, 100] + }, + { + "i": {"x": [0.667, 0.667, 0.667], "y": [1, 1, 1]}, + "o": {"x": [0.333, 0.333, 0.333], "y": [0, 0, 0]}, + "t": 38, + "s": [90, 90, 100] + }, + {"t": 49, "s": [100, 100, 100]} + ], + "ix": 6 + } + }, + "ao": 0, + "ip": 23, + "op": 240, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 4, + "ty": 3, + "nm": "arrow position", + "parent": 3, + "sr": 1, + "ks": { + "o": {"a": 0, "k": 0, "ix": 11}, + "r": {"a": 0, "k": 0, "ix": 10}, + "p": { + "a": 1, + "k": [ + { + "i": {"x": 0.107, "y": 1}, + "o": {"x": 0.533, "y": 0}, + "t": 23, + "s": [50, -8, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + { + "i": {"x": 0.19, "y": 0.642}, + "o": {"x": 0.333, "y": 0}, + "t": 35, + "s": [119.897, 36, 0], + "to": [0, 0, 0], + "ti": [392.376, 75.374, 0] + }, + { + "i": {"x": 0.833, "y": 0.874}, + "o": {"x": 1, "y": 0.713}, + "t": 38, + "s": [-307.61, -289.928, 0], + "to": [-832.969, -114.56, 0], + "ti": [20.667, 921.704, 0] + }, + { + "i": {"x": 0.667, "y": 1}, + "o": {"x": 0.115, "y": 1}, + "t": 49, + "s": [-374, -134, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + { + "i": {"x": 0.538, "y": 1}, + "o": {"x": 1, "y": 0}, + "t": 53, + "s": [-374, -175, 0], + "to": [0, 0, 0], + "ti": [1.605, -28.883, 0] + }, + { + "i": {"x": 0.538, "y": 1}, + "o": {"x": 0.167, "y": 0}, + "t": 59, + "s": [-374, 37, 0], + "to": [0, 0, 0], + "ti": [1.605, -28.883, 0] + }, + {"t": 65, "s": [-374, -8, 0]} + ], + "ix": 2 + }, + "a": {"a": 0, "k": [50, 50, 0], "ix": 1}, + "s": {"a": 0, "k": [100, 100, 100], "ix": 6} + }, + "ao": 0, + "ip": 23, + "op": 243, + "st": 3, + "bm": 0 + }, + { + "ddd": 0, + "ind": 5, + "ty": 3, + "nm": "rotation", + "parent": 4, + "sr": 1, + "ks": { + "o": {"a": 0, "k": 0, "ix": 11}, + "r": { + "a": 1, + "k": [ + {"i": {"x": [0.667], "y": [1]}, "o": {"x": [0.303], "y": [0]}, "t": 23, "s": [-45]}, + {"i": {"x": [0.773], "y": [0.886]}, "o": {"x": [0.333], "y": [0]}, "t": 35, "s": [-45]}, + { + "i": {"x": [0.833], "y": [0.833]}, + "o": {"x": [0.167], "y": [0.06]}, + "t": 38, + "s": [-71] + }, + { + "i": {"x": [0.833], "y": [0.833]}, + "o": {"x": [0.167], "y": [0.089]}, + "t": 42, + "s": [-118.941] + }, + { + "i": {"x": [0.667], "y": [1]}, + "o": {"x": [0.167], "y": [0.087]}, + "t": 45, + "s": [-186.591] + }, + {"t": 49, "s": [-360]} + ], + "ix": 10 + }, + "p": {"a": 0, "k": [18.883, 127.949, 0], "ix": 2}, + "a": {"a": 0, "k": [50, 50, 0], "ix": 1}, + "s": {"a": 0, "k": [100, 100, 100], "ix": 6} + }, + "ao": 0, + "ip": 23, + "op": 243, + "st": 3, + "bm": 0 + }, + { + "ddd": 0, + "ind": 6, + "ty": 3, + "nm": "rough edges", + "sr": 1, + "ks": { + "o": {"a": 0, "k": 100, "ix": 11}, + "r": {"a": 0, "k": 0, "ix": 10}, + "p": {"a": 0, "k": [1920, 1080, 0], "ix": 2}, + "a": {"a": 0, "k": [1920, 1080, 0], "ix": 1}, + "s": {"a": 0, "k": [100, 100, 100], "ix": 6} + }, + "ao": 0, + "ef": [ + { + "ty": 5, + "nm": "Roughen Edges", + "np": 17, + "mn": "ADBE Roughen Edges", + "ix": 1, + "en": 1, + "ef": [ + { + "ty": 7, + "nm": "Edge Type", + "mn": "ADBE Roughen Edges-0001", + "ix": 1, + "v": {"a": 0, "k": 1, "ix": 1} + }, + { + "ty": 2, + "nm": "Edge Color", + "mn": "ADBE Roughen Edges-0010", + "ix": 2, + "v": {"a": 0, "k": [0.6, 0.2, 0, 1], "ix": 2} + }, + { + "ty": 0, + "nm": "Border", + "mn": "ADBE Roughen Edges-0002", + "ix": 3, + "v": { + "a": 1, + "k": [ + { + "i": {"x": [0.833], "y": [0.833]}, + "o": {"x": [0.167], "y": [0.167]}, + "t": 12, + "s": [17.4] + }, + {"t": 19, "s": [2.7]} + ], + "ix": 3 + } + }, + { + "ty": 0, + "nm": "Edge Sharpness", + "mn": "ADBE Roughen Edges-0003", + "ix": 4, + "v": { + "a": 1, + "k": [ + { + "i": {"x": [0.833], "y": [0.833]}, + "o": {"x": [0.167], "y": [0.167]}, + "t": 12, + "s": [1] + }, + {"t": 19, "s": [0.1]} + ], + "ix": 4 + } + }, + { + "ty": 0, + "nm": "Fractal Influence", + "mn": "ADBE Roughen Edges-0004", + "ix": 5, + "v": {"a": 0, "k": 1, "ix": 5} + }, + { + "ty": 0, + "nm": "Scale", + "mn": "ADBE Roughen Edges-0005", + "ix": 6, + "v": {"a": 0, "k": 1000, "ix": 6} + }, + { + "ty": 0, + "nm": "Stretch Width or Height", + "mn": "ADBE Roughen Edges-0006", + "ix": 7, + "v": {"a": 0, "k": 0, "ix": 7} + }, + { + "ty": 3, + "nm": "Offset (Turbulence)", + "mn": "ADBE Roughen Edges-0007", + "ix": 8, + "v": {"a": 0, "k": [0, 0], "ix": 8} + }, + { + "ty": 0, + "nm": "Complexity", + "mn": "ADBE Roughen Edges-0008", + "ix": 9, + "v": {"a": 0, "k": 2, "ix": 9} + }, + { + "ty": 0, + "nm": "Evolution", + "mn": "ADBE Roughen Edges-0009", + "ix": 10, + "v": {"a": 0, "k": 0, "ix": 10} + }, + {"ty": 6, "nm": "Evolution Options", "mn": "ADBE Roughen Edges-0011", "ix": 11, "v": 0}, + { + "ty": 7, + "nm": "Cycle Evolution", + "mn": "ADBE Roughen Edges-0012", + "ix": 12, + "v": {"a": 0, "k": 0, "ix": 12} + }, + { + "ty": 0, + "nm": "Cycle (in Revolutions)", + "mn": "ADBE Roughen Edges-0013", + "ix": 13, + "v": {"a": 0, "k": 1, "ix": 13} + }, + { + "ty": 0, + "nm": "Random Seed", + "mn": "ADBE Roughen Edges-0014", + "ix": 14, + "v": {"a": 0, "k": 0, "ix": 14} + }, + {"ty": 6, "nm": "Random Seed", "mn": "ADBE Roughen Edges-0015", "ix": 15, "v": 0} + ] + } + ], + "ip": 0, + "op": 19, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 7, + "ty": 3, + "nm": "echo", + "sr": 1, + "ks": { + "o": {"a": 0, "k": 100, "ix": 11}, + "r": {"a": 0, "k": 0, "ix": 10}, + "p": {"a": 0, "k": [1920, 1080, 0], "ix": 2}, + "a": {"a": 0, "k": [1920, 1080, 0], "ix": 1}, + "s": {"a": 0, "k": [100, 100, 100], "ix": 6} + }, + "ao": 0, + "ef": [ + { + "ty": 5, + "nm": "Echo", + "np": 7, + "mn": "ADBE Echo", + "ix": 1, + "en": 1, + "ef": [ + { + "ty": 0, + "nm": "Echo Time (seconds)", + "mn": "ADBE Echo-0001", + "ix": 1, + "v": {"a": 0, "k": -0.001, "ix": 1} + }, + { + "ty": 0, + "nm": "Number Of Echoes", + "mn": "ADBE Echo-0002", + "ix": 2, + "v": { + "a": 1, + "k": [ + { + "i": {"x": [0.833], "y": [0.833]}, + "o": {"x": [0.167], "y": [0.167]}, + "t": 31, + "s": [10] + }, + { + "i": {"x": [0.833], "y": [0.833]}, + "o": {"x": [0.167], "y": [0.167]}, + "t": 34, + "s": [67] + }, + { + "i": {"x": [0.833], "y": [0.833]}, + "o": {"x": [0.167], "y": [0.167]}, + "t": 49, + "s": [67] + }, + {"t": 53, "s": [10]} + ], + "ix": 2 + } + }, + { + "ty": 0, + "nm": "Starting Intensity", + "mn": "ADBE Echo-0003", + "ix": 3, + "v": {"a": 0, "k": 1, "ix": 3} + }, + { + "ty": 0, + "nm": "Decay", + "mn": "ADBE Echo-0004", + "ix": 4, + "v": {"a": 0, "k": 1, "ix": 4} + }, + { + "ty": 7, + "nm": "Echo Operator", + "mn": "ADBE Echo-0005", + "ix": 5, + "v": {"a": 0, "k": 5, "ix": 5} + } + ] + } + ], + "ip": 31, + "op": 53, + "st": 3, + "bm": 0 + }, + { + "ddd": 0, + "ind": 8, + "ty": 4, + "nm": "top 2", + "parent": 5, + "sr": 1, + "ks": { + "o": {"a": 0, "k": 100, "ix": 11}, + "r": {"a": 0, "k": 0, "ix": 10}, + "p": {"a": 0, "k": [73.565, -23.785, 0], "ix": 2}, + "a": {"a": 0, "k": [1488.448, 1026.164, 0], "ix": 1}, + "s": {"a": 0, "k": [100, 100, 100], "ix": 6} + }, + "ao": 0, + "ef": [ + { + "ty": 5, + "nm": "ShapeNir Slider", + "np": 3, + "mn": "ADBE Slider Control", + "ix": 1, + "en": 1, + "ef": [ + { + "ty": 0, + "nm": "Slider", + "mn": "ADBE Slider Control-0001", + "ix": 1, + "v": {"a": 0, "k": 100, "ix": 1} + } + ] + } + ], + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 1, + "k": [ + { + "i": {"x": 0.17, "y": 1}, + "o": {"x": 0.167, "y": 0}, + "t": 58, + "s": [ + { + "i": [ + [0, 0], + [0, 0], + [-13.503, -13.506], + [0, 0], + [9.065, -9.068], + [0, 0], + [0, 0], + [9.068, 9.071], + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [13.506, -13.506], + [0, 0], + [9.065, 9.068], + [0, 0], + [0, 0], + [-9.068, 9.074], + [0, 0], + [0, 0], + [0, 0] + ], + "v": [ + [-61.716, -44.574], + [-57.525, -48.766], + [-21.582, -61.577], + [-24.345, -64.482], + [-24.342, -31.648], + [-24.342, -31.648], + [-24.342, -31.648], + [-57.186, -31.645], + [-65.907, -40.383], + [-61.716, -44.574] + ], + "c": false + } + ] + }, + { + "t": 64, + "s": [ + { + "i": [ + [0, 0], + [0, 0], + [-13.503, -13.506], + [0, 0], + [9.065, -9.068], + [0, 0], + [0, 0], + [9.068, 9.071], + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [13.506, -13.506], + [0, 0], + [9.065, 9.068], + [0, 0], + [0, 0], + [-9.068, 9.074], + [0, 0], + [0, 0], + [0, 0] + ], + "v": [ + [-74.672, -57.386], + [-70.481, -61.577], + [-21.582, -61.577], + [72.063, 32.066], + [72.066, 64.9], + [72.066, 64.9], + [72.066, 64.9], + [39.223, 64.903], + [-78.863, -53.195], + [-74.672, -57.386] + ], + "c": false + } + ] + } + ], + "ix": 2 + }, + "nm": "Tracciato 1_forAnimation", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": {"a": 0, "k": [0.211764708161, 0.568627476692, 1, 1], "ix": 4}, + "o": {"a": 0, "k": 100, "ix": 5}, + "r": 1, + "bm": 0, + "nm": "Riempimento 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": {"a": 0, "k": [1542.448, 1082.164], "ix": 2}, + "a": {"a": 0, "k": [0, 0], "ix": 1}, + "s": {"a": 0, "k": [100, 100], "ix": 3}, + "r": {"a": 0, "k": 0, "ix": 6}, + "o": {"a": 0, "k": 100, "ix": 7}, + "sk": {"a": 0, "k": 0, "ix": 4}, + "sa": {"a": 0, "k": 0, "ix": 5}, + "nm": "Transform" + } + ], + "nm": "Gruppo 3", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + }, + { + "ty": "tm", + "s": { + "a": 0, + "k": 0, + "ix": 1, + "x": "var $bm_rt;\nvar mN01 = 52.2496645130989;\nvar mN02 = 100;\nvar mN01Edt = 52.2496645130989;\nvar mN02Edt = 98.6485408913042;\nvar mRatiosM = [\n 0,\n 1\n ];\nvar mRatiosF = [\n 0,\n 1\n ];\nfunction mLinear(aInput, aInNumF, aInNumL, aOutNumF, aOutNumL) {\n var mInputLgt = $bm_sub(aInNumL, aInNumF);\n var mOutputLgt = $bm_sub(aOutNumL, aOutNumF);\n var mRatio = $bm_div(mOutputLgt, mInputLgt);\n if (aInNumF <= aInput && aInput <= aInNumL) {\n var mOutput = $bm_sum($bm_mul($bm_sub(aInput, aInNumF), mRatio), aOutNumF);\n } else {\n var mOutput = aInput;\n }\n return mOutput;\n}\nvar mSld = $bm_div(thisLayer('ADBE Effect Parade')('ShapeNir Slider')('ADBE Slider Control-0001'), 100);\nif (mSld <= 0) {\n var mRst = mN01;\n} else if (mSld >= 1) {\n var mRst = mN02;\n} else {\n for (var i = 1; i < mRatiosM.length; i++) {\n if (mRatiosM[i - 1] <= mSld && mSld < mRatiosM[i]) {\n var mCtl = mLinear(mSld, mRatiosM[$bm_sub(i, 1)], mRatiosM[i], mRatiosF[$bm_sub(i, 1)], mRatiosF[i]);\n break;\n }\n }\n var mRst = mLinear(mCtl, 0, 1, mN01Edt, mN02Edt);\n}\n$bm_rt = mRst;" + }, + "e": { + "a": 0, + "k": 100, + "ix": 2, + "x": "var $bm_rt;\nvar mN01 = 52.2496645130989;\nvar mN02 = 0;\nvar mN01Edt = 52.2496645130989;\nvar mN02Edt = 1.35145910869578;\nvar mRatiosM = [\n 0,\n 1\n ];\nvar mRatiosL = [\n 0,\n 1\n ];\nfunction mLinear(aInput, aInNumF, aInNumL, aOutNumF, aOutNumL) {\n var mInputLgt = $bm_sub(aInNumL, aInNumF);\n var mOutputLgt = $bm_sub(aOutNumL, aOutNumF);\n var mRatio = $bm_div(mOutputLgt, mInputLgt);\n if (aInNumF <= aInput && aInput <= aInNumL) {\n var mOutput = $bm_sum($bm_mul($bm_sub(aInput, aInNumF), mRatio), aOutNumF);\n } else {\n var mOutput = aInput;\n }\n return mOutput;\n}\nvar mSld = $bm_div(thisLayer('ADBE Effect Parade')('ShapeNir Slider')('ADBE Slider Control-0001'), 100);\nif (mSld <= 0) {\n var mRst = mN01;\n} else if (mSld >= 1) {\n var mRst = mN02;\n} else {\n for (var i = 1; i < mRatiosM.length; i++) {\n if (mRatiosM[i - 1] <= mSld && mSld < mRatiosM[i]) {\n var mCtl = mLinear(mSld, mRatiosM[$bm_sub(i, 1)], mRatiosM[i], mRatiosL[$bm_sub(i, 1)], mRatiosL[i]);\n break;\n }\n }\n var mRst = mLinear(mCtl, 0, 1, mN01Edt, mN02Edt);\n}\n$bm_rt = mRst;" + }, + "o": {"a": 0, "k": 0, "ix": 3}, + "m": 1, + "ix": 2, + "nm": "Taglia tracciati 1", + "mn": "ADBE Vector Filter - Trim", + "hd": false + }, + { + "ind": 2, + "ty": "sh", + "ix": 3, + "ks": { + "a": 1, + "k": [ + { + "i": {"x": 0.17, "y": 1}, + "o": {"x": 0.167, "y": 0}, + "t": 58, + "s": [ + { + "i": [ + [0, 0], + [5.418, 5.419], + [17.468, 17.469], + [25.537, -25.537] + ], + "o": [ + [-3.662, 6.731], + [0, 0], + [-25.536, -25.537], + [0, 0] + ], + "v": [ + [1518.105, 1050.515], + [1501.682, 1034.099], + [1545.158, 1077.713], + [1480.731, 1037.589] + ], + "c": false + } + ] + }, + { + "t": 64, + "s": [ + { + "i": [ + [0, 0], + [5.418, 5.419], + [17.468, 17.469], + [25.537, -25.537] + ], + "o": [ + [-3.662, 6.731], + [0, 0], + [-25.536, -25.537], + [0, 0] + ], + "v": [ + [1614.514, 1147.064], + [1598.091, 1130.648], + [1545.158, 1077.713], + [1467.776, 1024.778] + ], + "c": false + } + ] + } + ], + "ix": 2 + }, + "nm": "Tracciato 1_Bone", + "mn": "ADBE Vector Shape - Group", + "hd": false + } + ], + "ip": 53, + "op": 126, + "st": 6, + "bm": 0 + }, + { + "ddd": 0, + "ind": 9, + "ty": 4, + "nm": "top_extra", + "parent": 10, + "sr": 1, + "ks": { + "o": {"a": 0, "k": 100, "ix": 11}, + "r": {"a": 0, "k": 45, "ix": 10}, + "p": { + "a": 1, + "k": [ + { + "i": {"x": 0.833, "y": 0.833}, + "o": {"x": 0.167, "y": 0.167}, + "t": 29, + "s": [1502.093, 1035.094, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + { + "i": {"x": 0.833, "y": 0.833}, + "o": {"x": 0.167, "y": 0.167}, + "t": 34, + "s": [1495.062, 1036.428, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + { + "i": {"x": 0.833, "y": 0.833}, + "o": {"x": 0.167, "y": 0.167}, + "t": 51, + "s": [1495.062, 1036.428, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + {"t": 53, "s": [1502.312, 1036.428, 0]} + ], + "ix": 2 + }, + "a": {"a": 0, "k": [-98.5, -102, 0], "ix": 1}, + "s": { + "a": 1, + "k": [ + { + "i": {"x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 0.833]}, + "o": {"x": [0.167, 0.167, 0.167], "y": [0.167, 0.167, 0.167]}, + "t": 29, + "s": [46.5, 46.5, 100] + }, + { + "i": {"x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 0.833]}, + "o": {"x": [0.167, 0.167, 0.167], "y": [0.167, 0.167, 0.167]}, + "t": 32, + "s": [50, 50, 100] + }, + { + "i": {"x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 0.833]}, + "o": {"x": [0.167, 0.167, 0.167], "y": [0.167, 0.167, 0.167]}, + "t": 49, + "s": [50, 50, 100] + }, + {"t": 53, "s": [45.2, 45.2, 100]} + ], + "ix": 6 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "d": 1, + "ty": "el", + "s": {"a": 0, "k": [94, 94], "ix": 2}, + "p": {"a": 0, "k": [0, 0], "ix": 3}, + "nm": "Tracciato ellisse 1", + "mn": "ADBE Vector Shape - Ellipse", + "hd": false + }, + { + "ty": "fl", + "c": {"a": 0, "k": [0.211761474609, 0.568603515625, 1, 1], "ix": 4}, + "o": {"a": 0, "k": 100, "ix": 5}, + "r": 1, + "bm": 0, + "nm": "Riempimento 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": {"a": 0, "k": [-98.5, -102], "ix": 2}, + "a": {"a": 0, "k": [0, 0], "ix": 1}, + "s": {"a": 0, "k": [100, 100], "ix": 3}, + "r": {"a": 0, "k": 0, "ix": 6}, + "o": {"a": 0, "k": 100, "ix": 7}, + "sk": {"a": 0, "k": 0, "ix": 4}, + "sa": {"a": 0, "k": 0, "ix": 5}, + "nm": "Transform" + } + ], + "nm": "Ellisse 1", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 29, + "op": 53, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 10, + "ty": 4, + "nm": "top", + "parent": 5, + "sr": 1, + "ks": { + "o": {"a": 0, "k": 100, "ix": 11}, + "r": {"a": 0, "k": 0, "ix": 10}, + "p": {"a": 0, "k": [73.565, -23.785, 0], "ix": 2}, + "a": {"a": 0, "k": [1488.448, 1026.164, 0], "ix": 1}, + "s": {"a": 0, "k": [100, 100, 100], "ix": 6} + }, + "ao": 0, + "ef": [ + { + "ty": 5, + "nm": "ShapeNir Slider", + "np": 3, + "mn": "ADBE Slider Control", + "ix": 1, + "en": 1, + "ef": [ + { + "ty": 0, + "nm": "Slider", + "mn": "ADBE Slider Control-0001", + "ix": 1, + "v": { + "a": 1, + "k": [ + {"i": {"x": [0.17], "y": [1]}, "o": {"x": [0.55], "y": [0]}, "t": 6, "s": [0]}, + {"t": 23, "s": [100]} + ], + "ix": 1 + } + } + ] + } + ], + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 1, + "k": [ + { + "i": {"x": 0.17, "y": 1}, + "o": {"x": 0.55, "y": 0}, + "t": 6, + "s": [ + { + "i": [ + [0, 0], + [0, 0], + [-13.503, -13.506], + [0, 0], + [9.065, -9.068], + [0, 0], + [0, 0], + [9.068, 9.071], + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [13.506, -13.506], + [0, 0], + [9.065, 9.068], + [0, 0], + [0, 0], + [-9.068, 9.074], + [0, 0], + [0, 0], + [0, 0] + ], + "v": [ + [-74.672, -57.386], + [-70.481, -61.577], + [-21.582, -61.577], + [194.283, 154.505], + [194.286, 187.34], + [194.286, 187.34], + [194.286, 187.34], + [161.442, 187.343], + [-78.863, -53.195], + [-74.672, -57.386] + ], + "c": false + } + ] + }, + { + "i": {"x": 0.833, "y": 0.833}, + "o": {"x": 0.55, "y": 0}, + "t": 23, + "s": [ + { + "i": [ + [0, 0], + [0, 0], + [-13.503, -13.506], + [0, 0], + [9.065, -9.068], + [0, 0], + [0, 0], + [9.068, 9.071], + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [13.506, -13.506], + [0, 0], + [9.065, 9.068], + [0, 0], + [0, 0], + [-9.068, 9.074], + [0, 0], + [0, 0], + [0, 0] + ], + "v": [ + [-74.672, -57.386], + [-70.481, -61.577], + [-21.582, -61.577], + [72.063, 32.066], + [72.066, 64.9], + [72.066, 64.9], + [72.066, 64.9], + [39.223, 64.903], + [-78.863, -53.195], + [-74.672, -57.386] + ], + "c": false + } + ] + }, + { + "i": {"x": 0.833, "y": 1}, + "o": {"x": 0.167, "y": 0.167}, + "t": 26, + "s": [ + { + "i": [ + [0, 0], + [0, 0], + [-13.503, -13.506], + [0, 0], + [9.065, -9.068], + [0, 0], + [0, 0], + [9.068, 9.071], + [2.122, 2.171], + [0, 0] + ], + "o": [ + [0, 0], + [13.506, -13.506], + [0, 0], + [9.065, 9.068], + [0, 0], + [0, 0], + [-9.068, 9.074], + [0, 0], + [-2.122, -2.171], + [0, 0] + ], + "v": [ + [-70.801, -53.558], + [-66.61, -57.749], + [-21.582, -61.577], + [43.259, 3.22], + [43.262, 36.055], + [43.262, 36.055], + [43.262, 36.055], + [10.419, 36.058], + [-66.621, -43.95], + [-70.801, -53.558] + ], + "c": false + } + ] + }, + { + "i": {"x": 0.833, "y": 1}, + "o": {"x": 0.167, "y": 0}, + "t": 29, + "s": [ + { + "i": [ + [0, 0], + [0, 0], + [-13.503, -13.506], + [0, 0], + [9.065, -9.068], + [0, 0], + [0, 0], + [9.068, 9.071], + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [13.506, -13.506], + [0, 0], + [9.065, 9.068], + [0, 0], + [0, 0], + [-9.068, 9.074], + [0, 0], + [0, 0], + [0, 0] + ], + "v": [ + [-61.716, -44.574], + [-57.525, -48.766], + [-21.582, -61.577], + [-24.345, -64.482], + [-24.342, -31.648], + [-24.342, -31.648], + [-24.342, -31.648], + [-57.186, -31.645], + [-65.907, -40.383], + [-61.716, -44.574] + ], + "c": false + } + ] + }, + { + "i": {"x": 0.45, "y": 1}, + "o": {"x": 0.167, "y": 0}, + "t": 53, + "s": [ + { + "i": [ + [0, 0], + [0, 0], + [-13.503, -13.506], + [0, 0], + [9.065, -9.068], + [0, 0], + [0, 0], + [9.068, 9.071], + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [13.506, -13.506], + [0, 0], + [9.065, 9.068], + [0, 0], + [0, 0], + [-9.068, 9.074], + [0, 0], + [0, 0], + [0, 0] + ], + "v": [ + [-61.716, -44.574], + [-57.525, -48.766], + [-21.582, -61.577], + [-24.345, -64.482], + [-24.342, -31.648], + [-24.342, -31.648], + [-24.342, -31.648], + [-57.186, -31.645], + [-65.907, -40.383], + [-61.716, -44.574] + ], + "c": false + } + ] + }, + { + "t": 59, + "s": [ + { + "i": [ + [0, 0], + [0, 0], + [-13.503, -13.506], + [0, 0], + [9.065, -9.068], + [0, 0], + [0, 0], + [9.068, 9.071], + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [13.506, -13.506], + [0, 0], + [9.065, 9.068], + [0, 0], + [0, 0], + [-9.068, 9.074], + [0, 0], + [0, 0], + [0, 0] + ], + "v": [ + [-74.672, -57.386], + [-70.481, -61.577], + [-21.582, -61.577], + [72.063, 32.066], + [72.066, 64.9], + [72.066, 64.9], + [72.066, 64.9], + [39.223, 64.903], + [-78.863, -53.195], + [-74.672, -57.386] + ], + "c": false + } + ] + } + ], + "ix": 2 + }, + "nm": "Tracciato 1_forAnimation", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": {"a": 0, "k": [0.211764708161, 0.568627476692, 1, 1], "ix": 4}, + "o": {"a": 0, "k": 100, "ix": 5}, + "r": 1, + "bm": 0, + "nm": "Riempimento 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": {"a": 0, "k": [1542.448, 1082.164], "ix": 2}, + "a": {"a": 0, "k": [0, 0], "ix": 1}, + "s": {"a": 0, "k": [100, 100], "ix": 3}, + "r": {"a": 0, "k": 0, "ix": 6}, + "o": {"a": 0, "k": 100, "ix": 7}, + "sk": {"a": 0, "k": 0, "ix": 4}, + "sa": {"a": 0, "k": 0, "ix": 5}, + "nm": "Transform" + } + ], + "nm": "Gruppo 3", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + }, + { + "ty": "tm", + "s": { + "a": 0, + "k": 0, + "ix": 1, + "x": "var $bm_rt;\nvar mN01 = 52.2496645130989;\nvar mN02 = 100;\nvar mN01Edt = 52.2496645130989;\nvar mN02Edt = 98.6485408913042;\nvar mRatiosM = [\n 0,\n 1\n ];\nvar mRatiosF = [\n 0,\n 1\n ];\nfunction mLinear(aInput, aInNumF, aInNumL, aOutNumF, aOutNumL) {\n var mInputLgt = $bm_sub(aInNumL, aInNumF);\n var mOutputLgt = $bm_sub(aOutNumL, aOutNumF);\n var mRatio = $bm_div(mOutputLgt, mInputLgt);\n if (aInNumF <= aInput && aInput <= aInNumL) {\n var mOutput = $bm_sum($bm_mul($bm_sub(aInput, aInNumF), mRatio), aOutNumF);\n } else {\n var mOutput = aInput;\n }\n return mOutput;\n}\nvar mSld = $bm_div(thisLayer('ADBE Effect Parade')('ShapeNir Slider')('ADBE Slider Control-0001'), 100);\nif (mSld <= 0) {\n var mRst = mN01;\n} else if (mSld >= 1) {\n var mRst = mN02;\n} else {\n for (var i = 1; i < mRatiosM.length; i++) {\n if (mRatiosM[i - 1] <= mSld && mSld < mRatiosM[i]) {\n var mCtl = mLinear(mSld, mRatiosM[$bm_sub(i, 1)], mRatiosM[i], mRatiosF[$bm_sub(i, 1)], mRatiosF[i]);\n break;\n }\n }\n var mRst = mLinear(mCtl, 0, 1, mN01Edt, mN02Edt);\n}\n$bm_rt = mRst;" + }, + "e": { + "a": 0, + "k": 100, + "ix": 2, + "x": "var $bm_rt;\nvar mN01 = 52.2496645130989;\nvar mN02 = 0;\nvar mN01Edt = 52.2496645130989;\nvar mN02Edt = 1.35145910869578;\nvar mRatiosM = [\n 0,\n 1\n ];\nvar mRatiosL = [\n 0,\n 1\n ];\nfunction mLinear(aInput, aInNumF, aInNumL, aOutNumF, aOutNumL) {\n var mInputLgt = $bm_sub(aInNumL, aInNumF);\n var mOutputLgt = $bm_sub(aOutNumL, aOutNumF);\n var mRatio = $bm_div(mOutputLgt, mInputLgt);\n if (aInNumF <= aInput && aInput <= aInNumL) {\n var mOutput = $bm_sum($bm_mul($bm_sub(aInput, aInNumF), mRatio), aOutNumF);\n } else {\n var mOutput = aInput;\n }\n return mOutput;\n}\nvar mSld = $bm_div(thisLayer('ADBE Effect Parade')('ShapeNir Slider')('ADBE Slider Control-0001'), 100);\nif (mSld <= 0) {\n var mRst = mN01;\n} else if (mSld >= 1) {\n var mRst = mN02;\n} else {\n for (var i = 1; i < mRatiosM.length; i++) {\n if (mRatiosM[i - 1] <= mSld && mSld < mRatiosM[i]) {\n var mCtl = mLinear(mSld, mRatiosM[$bm_sub(i, 1)], mRatiosM[i], mRatiosL[$bm_sub(i, 1)], mRatiosL[i]);\n break;\n }\n }\n var mRst = mLinear(mCtl, 0, 1, mN01Edt, mN02Edt);\n}\n$bm_rt = mRst;" + }, + "o": {"a": 0, "k": 0, "ix": 3}, + "m": 1, + "ix": 2, + "nm": "Taglia tracciati 1", + "mn": "ADBE Vector Filter - Trim", + "hd": false + }, + { + "ind": 2, + "ty": "sh", + "ix": 3, + "ks": { + "a": 1, + "k": [ + { + "i": {"x": 0.17, "y": 1}, + "o": {"x": 0.55, "y": 0}, + "t": 6, + "s": [ + { + "i": [ + [0, 0], + [5.418, 5.419], + [17.468, 17.469], + [25.537, -25.537] + ], + "o": [ + [-3.662, 6.731], + [0, 0], + [-25.536, -25.537], + [0, 0] + ], + "v": [ + [1736.734, 1269.504], + [1720.31, 1253.088], + [1545.158, 1077.713], + [1467.776, 1024.778] + ], + "c": false + } + ] + }, + { + "i": {"x": 0.833, "y": 1}, + "o": {"x": 0.55, "y": 0}, + "t": 23, + "s": [ + { + "i": [ + [0, 0], + [5.418, 5.419], + [17.468, 17.469], + [25.537, -25.537] + ], + "o": [ + [-3.662, 6.731], + [0, 0], + [-25.536, -25.537], + [0, 0] + ], + "v": [ + [1614.514, 1147.064], + [1598.091, 1130.648], + [1545.158, 1077.713], + [1467.776, 1024.778] + ], + "c": false + } + ] + }, + { + "i": {"x": 0.833, "y": 1}, + "o": {"x": 0.167, "y": 0}, + "t": 29, + "s": [ + { + "i": [ + [0, 0], + [5.418, 5.419], + [17.468, 17.469], + [25.537, -25.537] + ], + "o": [ + [-3.662, 6.731], + [0, 0], + [-25.536, -25.537], + [0, 0] + ], + "v": [ + [1518.105, 1050.515], + [1501.682, 1034.099], + [1545.158, 1077.713], + [1480.731, 1037.589] + ], + "c": false + } + ] + }, + { + "i": {"x": 0.45, "y": 1}, + "o": {"x": 0.167, "y": 0}, + "t": 53, + "s": [ + { + "i": [ + [0, 0], + [5.418, 5.419], + [17.468, 17.469], + [25.537, -25.537] + ], + "o": [ + [-3.662, 6.731], + [0, 0], + [-25.536, -25.537], + [0, 0] + ], + "v": [ + [1518.105, 1050.515], + [1501.682, 1034.099], + [1545.158, 1077.713], + [1480.731, 1037.589] + ], + "c": false + } + ] + }, + { + "t": 59, + "s": [ + { + "i": [ + [0, 0], + [5.418, 5.419], + [17.468, 17.469], + [25.537, -25.537] + ], + "o": [ + [-3.662, 6.731], + [0, 0], + [-25.536, -25.537], + [0, 0] + ], + "v": [ + [1614.514, 1147.064], + [1598.091, 1130.648], + [1545.158, 1077.713], + [1467.776, 1024.778] + ], + "c": false + } + ] + } + ], + "ix": 2 + }, + "nm": "Tracciato 1_Bone", + "mn": "ADBE Vector Shape - Group", + "hd": false + } + ], + "ip": 6, + "op": 29, + "st": 6, + "bm": 0 + }, + { + "ddd": 0, + "ind": 11, + "ty": 4, + "nm": "mid 2", + "parent": 5, + "sr": 1, + "ks": { + "o": {"a": 0, "k": 100, "ix": 11}, + "r": {"a": 0, "k": 0, "ix": 10}, + "p": {"a": 0, "k": [15, 16, 0], "ix": 2}, + "a": {"a": 0, "k": [1429.883, 1065.949, 0], "ix": 1}, + "s": {"a": 0, "k": [100, 100, 100], "ix": 6} + }, + "ao": 0, + "ef": [ + { + "ty": 5, + "nm": "ShapeNir Slider", + "np": 3, + "mn": "ADBE Slider Control", + "ix": 1, + "en": 1, + "ef": [ + { + "ty": 0, + "nm": "Slider", + "mn": "ADBE Slider Control-0001", + "ix": 1, + "v": {"a": 0, "k": 100, "ix": 1} + } + ] + } + ], + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 1, + "k": [ + { + "i": {"x": 0.45, "y": 1}, + "o": {"x": 0.167, "y": 0}, + "t": 55.5, + "s": [ + { + "i": [ + [0, 0], + [0, 0], + [0, 0], + [6.673, -6.673], + [0, 0], + [0, 0], + [6.673, 6.673], + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0], + [6.671, 6.673], + [0, 0], + [0, 0], + [-6.673, 6.671], + [0, 0], + [0, 0], + [0, 0] + ], + "v": [ + [-30.953, -29.75], + [-18.872, -41.83], + [-9.362, -32.208], + [-9.362, -8.045], + [-9.362, -8.045], + [-9.362, -8.045], + [-33.522, -8.045], + [-43.033, -17.67], + [-30.953, -29.75] + ], + "c": false + } + ] + }, + { + "t": 61.5, + "s": [ + { + "i": [ + [0, 0], + [0, 0], + [0, 0], + [9.068, -9.068], + [0, 0], + [0, 0], + [9.068, 9.068], + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0], + [9.065, 9.068], + [0, 0], + [0, 0], + [-9.068, 9.065], + [0, 0], + [0, 0], + [0, 0] + ], + "v": [ + [-36.143, -36.135], + [-19.727, -52.551], + [45.759, 12.917], + [45.759, 45.751], + [45.759, 45.751], + [45.759, 45.751], + [12.927, 45.751], + [-52.559, -19.719], + [-36.143, -36.135] + ], + "c": false + } + ] + } + ], + "ix": 2 + }, + "nm": "Tracciato 1_forAnimation", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": {"a": 0, "k": [0.223529413342, 0.223529413342, 1, 1], "ix": 4}, + "o": {"a": 0, "k": 100, "ix": 5}, + "r": 1, + "bm": 0, + "nm": "Riempimento 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": {"a": 0, "k": [1464.883, 1099.949], "ix": 2}, + "a": {"a": 0, "k": [0, 0], "ix": 1}, + "s": {"a": 0, "k": [100, 100], "ix": 3}, + "r": {"a": 0, "k": 0, "ix": 6}, + "o": {"a": 0, "k": 100, "ix": 7}, + "sk": {"a": 0, "k": 0, "ix": 4}, + "sa": {"a": 0, "k": 0, "ix": 5}, + "nm": "Transform" + } + ], + "nm": "Gruppo 2", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + }, + { + "ty": "tm", + "s": { + "a": 0, + "k": 0, + "ix": 1, + "x": "var $bm_rt;\nvar mN01 = 50.0000976522834;\nvar mN02 = 100;\nvar mN01Edt = 50.0000976522834;\nvar mN02Edt = 92.3777446239903;\nvar mRatiosM = [\n 0,\n 1\n ];\nvar mRatiosF = [\n 0,\n 1\n ];\nfunction mLinear(aInput, aInNumF, aInNumL, aOutNumF, aOutNumL) {\n var mInputLgt = $bm_sub(aInNumL, aInNumF);\n var mOutputLgt = $bm_sub(aOutNumL, aOutNumF);\n var mRatio = $bm_div(mOutputLgt, mInputLgt);\n if (aInNumF <= aInput && aInput <= aInNumL) {\n var mOutput = $bm_sum($bm_mul($bm_sub(aInput, aInNumF), mRatio), aOutNumF);\n } else {\n var mOutput = aInput;\n }\n return mOutput;\n}\nvar mSld = $bm_div(thisLayer('ADBE Effect Parade')('ShapeNir Slider')('ADBE Slider Control-0001'), 100);\nif (mSld <= 0) {\n var mRst = mN01;\n} else if (mSld >= 1) {\n var mRst = mN02;\n} else {\n for (var i = 1; i < mRatiosM.length; i++) {\n if (mRatiosM[i - 1] <= mSld && mSld < mRatiosM[i]) {\n var mCtl = mLinear(mSld, mRatiosM[$bm_sub(i, 1)], mRatiosM[i], mRatiosF[$bm_sub(i, 1)], mRatiosF[i]);\n break;\n }\n }\n var mRst = mLinear(mCtl, 0, 1, mN01Edt, mN02Edt);\n}\n$bm_rt = mRst;" + }, + "e": { + "a": 0, + "k": 100, + "ix": 2, + "x": "var $bm_rt;\nvar mN01 = 50.0000976522834;\nvar mN02 = 0;\nvar mN01Edt = 50.0000976522834;\nvar mN02Edt = 7.62225537600966;\nvar mRatiosM = [\n 0,\n 1\n ];\nvar mRatiosL = [\n 0,\n 1\n ];\nfunction mLinear(aInput, aInNumF, aInNumL, aOutNumF, aOutNumL) {\n var mInputLgt = $bm_sub(aInNumL, aInNumF);\n var mOutputLgt = $bm_sub(aOutNumL, aOutNumF);\n var mRatio = $bm_div(mOutputLgt, mInputLgt);\n if (aInNumF <= aInput && aInput <= aInNumL) {\n var mOutput = $bm_sum($bm_mul($bm_sub(aInput, aInNumF), mRatio), aOutNumF);\n } else {\n var mOutput = aInput;\n }\n return mOutput;\n}\nvar mSld = $bm_div(thisLayer('ADBE Effect Parade')('ShapeNir Slider')('ADBE Slider Control-0001'), 100);\nif (mSld <= 0) {\n var mRst = mN01;\n} else if (mSld >= 1) {\n var mRst = mN02;\n} else {\n for (var i = 1; i < mRatiosM.length; i++) {\n if (mRatiosM[i - 1] <= mSld && mSld < mRatiosM[i]) {\n var mCtl = mLinear(mSld, mRatiosM[$bm_sub(i, 1)], mRatiosM[i], mRatiosL[$bm_sub(i, 1)], mRatiosL[i]);\n break;\n }\n }\n var mRst = mLinear(mCtl, 0, 1, mN01Edt, mN02Edt);\n}\n$bm_rt = mRst;" + }, + "o": {"a": 0, "k": 0, "ix": 3}, + "m": 1, + "ix": 2, + "nm": "Taglia tracciati 1", + "mn": "ADBE Vector Filter - Trim", + "hd": false + }, + { + "ind": 2, + "ty": "sh", + "ix": 3, + "ks": { + "a": 1, + "k": [ + { + "i": {"x": 0.45, "y": 1}, + "o": {"x": 0.167, "y": 0}, + "t": 55.5, + "s": [ + { + "i": [ + [0, 0], + [11.939, 11.936], + [0, 0] + ], + "o": [ + [-0.527, -16.874], + [-7.951, -7.949], + [0, 0] + ], + "v": [ + [1455.522, 1091.904], + [1458.026, 1094.288], + [1433.931, 1070.199] + ], + "c": false + } + ] + }, + { + "t": 61.5, + "s": [ + { + "i": [ + [0, 0], + [16.224, 16.22], + [0, 0] + ], + "o": [ + [-0.717, -22.93], + [-10.805, -10.802], + [0, 0] + ], + "v": [ + [1510.642, 1145.701], + [1461.483, 1096.548], + [1428.74, 1063.814] + ], + "c": false + } + ] + } + ], + "ix": 2 + }, + "nm": "Tracciato 1_Bone", + "mn": "ADBE Vector Shape - Group", + "hd": false + } + ], + "ip": 53, + "op": 126, + "st": 3, + "bm": 0 + }, + { + "ddd": 0, + "ind": 12, + "ty": 4, + "nm": "mid_extra", + "parent": 13, + "sr": 1, + "ks": { + "o": {"a": 0, "k": 100, "ix": 11}, + "r": {"a": 0, "k": 45, "ix": 10}, + "p": { + "a": 1, + "k": [ + { + "i": {"x": 0.833, "y": 0.833}, + "o": {"x": 0.167, "y": 0.167}, + "t": 29, + "s": [1442.756, 1079.784, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + { + "i": {"x": 0.833, "y": 0.833}, + "o": {"x": 0.167, "y": 0.167}, + "t": 34, + "s": [1450.145, 1086.357, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + { + "i": {"x": 0.833, "y": 0.833}, + "o": {"x": 0.167, "y": 0.167}, + "t": 51, + "s": [1450.145, 1086.357, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + {"t": 53, "s": [1443.895, 1080.232, 0]} + ], + "ix": 2 + }, + "a": {"a": 0, "k": [-120, 40, 0], "ix": 1}, + "s": { + "a": 1, + "k": [ + { + "i": {"x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 0.833]}, + "o": {"x": [0.167, 0.167, 0.167], "y": [0.167, 0.167, 0.167]}, + "t": 29, + "s": [37.2, 37.2, 100] + }, + { + "i": {"x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 0.833]}, + "o": {"x": [0.167, 0.167, 0.167], "y": [0.167, 0.167, 0.167]}, + "t": 34, + "s": [50, 50, 100] + }, + { + "i": {"x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 0.833]}, + "o": {"x": [0.167, 0.167, 0.167], "y": [0.167, 0.167, 0.167]}, + "t": 49, + "s": [50, 50, 100] + }, + {"t": 53, "s": [35.7, 35.7, 100]} + ], + "ix": 6 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "d": 1, + "ty": "el", + "s": {"a": 0, "k": [93, 93], "ix": 2}, + "p": {"a": 0, "k": [0, 0], "ix": 3}, + "nm": "Tracciato ellisse 1", + "mn": "ADBE Vector Shape - Ellipse", + "hd": false + }, + { + "ty": "fl", + "c": {"a": 0, "k": [0.223510742188, 0.223510742188, 1, 1], "ix": 4}, + "o": {"a": 0, "k": 100, "ix": 5}, + "r": 1, + "bm": 0, + "nm": "Riempimento 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": {"a": 0, "k": [-120, 40], "ix": 2}, + "a": {"a": 0, "k": [0, 0], "ix": 1}, + "s": {"a": 0, "k": [100, 100], "ix": 3}, + "r": {"a": 0, "k": 0, "ix": 6}, + "o": {"a": 0, "k": 100, "ix": 7}, + "sk": {"a": 0, "k": 0, "ix": 4}, + "sa": {"a": 0, "k": 0, "ix": 5}, + "nm": "Transform" + } + ], + "nm": "Ellisse 1", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 29, + "op": 53, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 13, + "ty": 4, + "nm": "mid", + "parent": 5, + "sr": 1, + "ks": { + "o": {"a": 0, "k": 100, "ix": 11}, + "r": {"a": 0, "k": 0, "ix": 10}, + "p": {"a": 0, "k": [15, 16, 0], "ix": 2}, + "a": {"a": 0, "k": [1429.883, 1065.949, 0], "ix": 1}, + "s": {"a": 0, "k": [100, 100, 100], "ix": 6} + }, + "ao": 0, + "ef": [ + { + "ty": 5, + "nm": "ShapeNir Slider", + "np": 3, + "mn": "ADBE Slider Control", + "ix": 1, + "en": 1, + "ef": [ + { + "ty": 0, + "nm": "Slider", + "mn": "ADBE Slider Control-0001", + "ix": 1, + "v": { + "a": 1, + "k": [ + {"i": {"x": [0.17], "y": [1]}, "o": {"x": [0.55], "y": [0]}, "t": 3, "s": [0]}, + {"t": 23, "s": [100]} + ], + "ix": 1 + } + } + ] + } + ], + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 1, + "k": [ + { + "i": {"x": 0.17, "y": 1}, + "o": {"x": 0.55, "y": 0}, + "t": 3, + "s": [ + { + "i": [ + [0, 0], + [0, 0], + [0, 0], + [9.068, -9.068], + [0, 0], + [0, 0], + [9.068, 9.068], + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0], + [9.065, 9.068], + [0, 0], + [0, 0], + [-9.068, 9.065], + [0, 0], + [0, 0], + [0, 0] + ], + "v": [ + [-36.143, -36.135], + [-19.727, -52.551], + [119.506, 86.956], + [119.506, 119.79], + [119.506, 119.79], + [119.506, 119.79], + [86.674, 119.79], + [-52.559, -19.719], + [-36.143, -36.135] + ], + "c": false + } + ] + }, + { + "i": {"x": 0.833, "y": 1}, + "o": {"x": 0.55, "y": 0}, + "t": 23, + "s": [ + { + "i": [ + [0, 0], + [0, 0], + [0, 0], + [9.068, -9.068], + [0, 0], + [0, 0], + [9.068, 9.068], + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0], + [9.065, 9.068], + [0, 0], + [0, 0], + [-9.068, 9.065], + [0, 0], + [0, 0], + [0, 0] + ], + "v": [ + [-36.143, -36.135], + [-19.727, -52.551], + [45.759, 12.917], + [45.759, 45.751], + [45.759, 45.751], + [45.759, 45.751], + [12.927, 45.751], + [-52.559, -19.719], + [-36.143, -36.135] + ], + "c": false + } + ] + }, + { + "i": {"x": 0.833, "y": 1}, + "o": {"x": 0.167, "y": 0}, + "t": 29, + "s": [ + { + "i": [ + [0, 0], + [0, 0], + [0, 0], + [6.673, -6.673], + [0, 0], + [0, 0], + [6.673, 6.673], + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0], + [6.671, 6.673], + [0, 0], + [0, 0], + [-6.673, 6.671], + [0, 0], + [0, 0], + [0, 0] + ], + "v": [ + [-30.953, -29.75], + [-18.872, -41.83], + [-9.362, -32.208], + [-9.362, -8.045], + [-9.362, -8.045], + [-9.362, -8.045], + [-33.522, -8.045], + [-43.033, -17.67], + [-30.953, -29.75] + ], + "c": false + } + ] + }, + { + "i": {"x": 0.45, "y": 1}, + "o": {"x": 0.167, "y": 0}, + "t": 53, + "s": [ + { + "i": [ + [0, 0], + [0, 0], + [0, 0], + [6.673, -6.673], + [0, 0], + [0, 0], + [6.673, 6.673], + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0], + [6.671, 6.673], + [0, 0], + [0, 0], + [-6.673, 6.671], + [0, 0], + [0, 0], + [0, 0] + ], + "v": [ + [-30.953, -29.75], + [-18.872, -41.83], + [-9.362, -32.208], + [-9.362, -8.045], + [-9.362, -8.045], + [-9.362, -8.045], + [-33.522, -8.045], + [-43.033, -17.67], + [-30.953, -29.75] + ], + "c": false + } + ] + }, + { + "t": 59, + "s": [ + { + "i": [ + [0, 0], + [0, 0], + [0, 0], + [9.068, -9.068], + [0, 0], + [0, 0], + [9.068, 9.068], + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0], + [9.065, 9.068], + [0, 0], + [0, 0], + [-9.068, 9.065], + [0, 0], + [0, 0], + [0, 0] + ], + "v": [ + [-36.143, -36.135], + [-19.727, -52.551], + [45.759, 12.917], + [45.759, 45.751], + [45.759, 45.751], + [45.759, 45.751], + [12.927, 45.751], + [-52.559, -19.719], + [-36.143, -36.135] + ], + "c": false + } + ] + } + ], + "ix": 2 + }, + "nm": "Tracciato 1_forAnimation", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": {"a": 0, "k": [0.223529413342, 0.223529413342, 1, 1], "ix": 4}, + "o": {"a": 0, "k": 100, "ix": 5}, + "r": 1, + "bm": 0, + "nm": "Riempimento 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": {"a": 0, "k": [1464.883, 1099.949], "ix": 2}, + "a": {"a": 0, "k": [0, 0], "ix": 1}, + "s": {"a": 0, "k": [100, 100], "ix": 3}, + "r": {"a": 0, "k": 0, "ix": 6}, + "o": {"a": 0, "k": 100, "ix": 7}, + "sk": {"a": 0, "k": 0, "ix": 4}, + "sa": {"a": 0, "k": 0, "ix": 5}, + "nm": "Transform" + } + ], + "nm": "Gruppo 2", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + }, + { + "ty": "tm", + "s": { + "a": 0, + "k": 0, + "ix": 1, + "x": "var $bm_rt;\nvar mN01 = 50.0000976522834;\nvar mN02 = 100;\nvar mN01Edt = 50.0000976522834;\nvar mN02Edt = 92.3777446239903;\nvar mRatiosM = [\n 0,\n 1\n ];\nvar mRatiosF = [\n 0,\n 1\n ];\nfunction mLinear(aInput, aInNumF, aInNumL, aOutNumF, aOutNumL) {\n var mInputLgt = $bm_sub(aInNumL, aInNumF);\n var mOutputLgt = $bm_sub(aOutNumL, aOutNumF);\n var mRatio = $bm_div(mOutputLgt, mInputLgt);\n if (aInNumF <= aInput && aInput <= aInNumL) {\n var mOutput = $bm_sum($bm_mul($bm_sub(aInput, aInNumF), mRatio), aOutNumF);\n } else {\n var mOutput = aInput;\n }\n return mOutput;\n}\nvar mSld = $bm_div(thisLayer('ADBE Effect Parade')('ShapeNir Slider')('ADBE Slider Control-0001'), 100);\nif (mSld <= 0) {\n var mRst = mN01;\n} else if (mSld >= 1) {\n var mRst = mN02;\n} else {\n for (var i = 1; i < mRatiosM.length; i++) {\n if (mRatiosM[i - 1] <= mSld && mSld < mRatiosM[i]) {\n var mCtl = mLinear(mSld, mRatiosM[$bm_sub(i, 1)], mRatiosM[i], mRatiosF[$bm_sub(i, 1)], mRatiosF[i]);\n break;\n }\n }\n var mRst = mLinear(mCtl, 0, 1, mN01Edt, mN02Edt);\n}\n$bm_rt = mRst;" + }, + "e": { + "a": 0, + "k": 100, + "ix": 2, + "x": "var $bm_rt;\nvar mN01 = 50.0000976522834;\nvar mN02 = 0;\nvar mN01Edt = 50.0000976522834;\nvar mN02Edt = 7.62225537600966;\nvar mRatiosM = [\n 0,\n 1\n ];\nvar mRatiosL = [\n 0,\n 1\n ];\nfunction mLinear(aInput, aInNumF, aInNumL, aOutNumF, aOutNumL) {\n var mInputLgt = $bm_sub(aInNumL, aInNumF);\n var mOutputLgt = $bm_sub(aOutNumL, aOutNumF);\n var mRatio = $bm_div(mOutputLgt, mInputLgt);\n if (aInNumF <= aInput && aInput <= aInNumL) {\n var mOutput = $bm_sum($bm_mul($bm_sub(aInput, aInNumF), mRatio), aOutNumF);\n } else {\n var mOutput = aInput;\n }\n return mOutput;\n}\nvar mSld = $bm_div(thisLayer('ADBE Effect Parade')('ShapeNir Slider')('ADBE Slider Control-0001'), 100);\nif (mSld <= 0) {\n var mRst = mN01;\n} else if (mSld >= 1) {\n var mRst = mN02;\n} else {\n for (var i = 1; i < mRatiosM.length; i++) {\n if (mRatiosM[i - 1] <= mSld && mSld < mRatiosM[i]) {\n var mCtl = mLinear(mSld, mRatiosM[$bm_sub(i, 1)], mRatiosM[i], mRatiosL[$bm_sub(i, 1)], mRatiosL[i]);\n break;\n }\n }\n var mRst = mLinear(mCtl, 0, 1, mN01Edt, mN02Edt);\n}\n$bm_rt = mRst;" + }, + "o": {"a": 0, "k": 0, "ix": 3}, + "m": 1, + "ix": 2, + "nm": "Taglia tracciati 1", + "mn": "ADBE Vector Filter - Trim", + "hd": false + }, + { + "ind": 2, + "ty": "sh", + "ix": 3, + "ks": { + "a": 1, + "k": [ + { + "i": {"x": 0.17, "y": 1}, + "o": {"x": 0.55, "y": 0}, + "t": 3, + "s": [ + { + "i": [ + [0, 0], + [16.224, 16.22], + [0, 0] + ], + "o": [ + [-0.717, -22.93], + [-10.805, -10.802], + [0, 0] + ], + "v": [ + [1584.389, 1219.739], + [1461.483, 1096.548], + [1428.74, 1063.814] + ], + "c": false + } + ] + }, + { + "i": {"x": 0.833, "y": 1}, + "o": {"x": 0.55, "y": 0}, + "t": 23, + "s": [ + { + "i": [ + [0, 0], + [16.224, 16.22], + [0, 0] + ], + "o": [ + [-0.717, -22.93], + [-10.805, -10.802], + [0, 0] + ], + "v": [ + [1510.642, 1145.701], + [1461.483, 1096.548], + [1428.74, 1063.814] + ], + "c": false + } + ] + }, + { + "i": {"x": 0.833, "y": 1}, + "o": {"x": 0.167, "y": 0}, + "t": 29, + "s": [ + { + "i": [ + [0, 0], + [11.939, 11.936], + [0, 0] + ], + "o": [ + [-0.527, -16.874], + [-7.951, -7.949], + [0, 0] + ], + "v": [ + [1455.522, 1091.904], + [1458.026, 1094.288], + [1433.931, 1070.199] + ], + "c": false + } + ] + }, + { + "i": {"x": 0.45, "y": 1}, + "o": {"x": 0.167, "y": 0}, + "t": 53, + "s": [ + { + "i": [ + [0, 0], + [11.939, 11.936], + [0, 0] + ], + "o": [ + [-0.527, -16.874], + [-7.951, -7.949], + [0, 0] + ], + "v": [ + [1455.522, 1091.904], + [1458.026, 1094.288], + [1433.931, 1070.199] + ], + "c": false + } + ] + }, + { + "t": 59, + "s": [ + { + "i": [ + [0, 0], + [16.224, 16.22], + [0, 0] + ], + "o": [ + [-0.717, -22.93], + [-10.805, -10.802], + [0, 0] + ], + "v": [ + [1510.642, 1145.701], + [1461.483, 1096.548], + [1428.74, 1063.814] + ], + "c": false + } + ] + } + ], + "ix": 2 + }, + "nm": "Tracciato 1_Bone", + "mn": "ADBE Vector Shape - Group", + "hd": false + } + ], + "ip": 3, + "op": 29, + "st": 3, + "bm": 0 + }, + { + "ddd": 0, + "ind": 14, + "ty": 4, + "nm": "bottom 2", + "parent": 5, + "sr": 1, + "ks": { + "o": {"a": 0, "k": 100, "ix": 11}, + "r": {"a": 0, "k": 0, "ix": 10}, + "p": {"a": 0, "k": [-2.559, 65.635, 0], "ix": 2}, + "a": {"a": 0, "k": [1412.324, 1115.584, 0], "ix": 1}, + "s": {"a": 0, "k": [100, 100, 100], "ix": 6} + }, + "ao": 0, + "ef": [ + { + "ty": 5, + "nm": "ShapeNir Slider", + "np": 3, + "mn": "ADBE Slider Control", + "ix": 1, + "en": 1, + "ef": [ + { + "ty": 0, + "nm": "Slider", + "mn": "ADBE Slider Control-0001", + "ix": 1, + "v": {"a": 0, "k": 100, "ix": 1} + } + ] + } + ], + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 1, + "k": [ + { + "i": {"x": 0.45, "y": 1}, + "o": {"x": 0.167, "y": 0}, + "t": 53, + "s": [ + { + "i": [ + [0, 0], + [0, 0], + [0, 0], + [6.334, 6.334], + [-6.333, 6.334], + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0], + [-6.334, 6.333], + [-6.334, -6.334], + [0, 0], + [0, 0], + [0, 0] + ], + "v": [ + [6.738, -2.735], + [18.731, 9.258], + [7.38, 20.609], + [-16.081, 20.083], + [-16.606, -3.378], + [-5.255, -14.728], + [6.738, -2.735] + ], + "c": false + } + ] + }, + { + "t": 59, + "s": [ + { + "i": [ + [0, 0], + [0, 0], + [0, 0], + [9.068, 9.068], + [-9.068, 9.068], + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0], + [-9.068, 9.068], + [-9.068, -9.068], + [0, 0], + [0, 0], + [0, 0] + ], + "v": [ + [11.444, -11.445], + [28.615, 5.726], + [12.364, 21.977], + [-21.225, 21.225], + [-21.977, -12.364], + [-5.726, -28.615], + [11.444, -11.445] + ], + "c": false + } + ] + } + ], + "ix": 2 + }, + "nm": "Tracciato 1_forAnimation", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": {"a": 0, "k": [0.337254911661, 0.223529413342, 1, 1], "ix": 4}, + "o": {"a": 0, "k": 100, "ix": 5}, + "r": 1, + "bm": 0, + "nm": "Riempimento 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": {"a": 0, "k": [1399.865, 1127.029], "ix": 2}, + "a": {"a": 0, "k": [0, 0], "ix": 1}, + "s": {"a": 0, "k": [100, 100], "ix": 3}, + "r": {"a": 0, "k": 0, "ix": 6}, + "o": {"a": 0, "k": 100, "ix": 7}, + "sk": {"a": 0, "k": 0, "ix": 4}, + "sa": {"a": 0, "k": 0, "ix": 5}, + "nm": "Transform" + } + ], + "nm": "Gruppo 1", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + }, + { + "ty": "tm", + "s": { + "a": 0, + "k": 0, + "ix": 1, + "x": "var $bm_rt;\nvar mN01 = 50.0000191622908;\nvar mN02 = 100;\nvar mN01Edt = 50.0000191622908;\nvar mN02Edt = 85.6245824308121;\nvar mRatiosM = [\n 0,\n 0.1472024328579,\n 0.50257634091374,\n 1\n ];\nvar mRatiosF = [\n 0,\n 0.31314999326651,\n 0.62416910908086,\n 1\n ];\nfunction mLinear(aInput, aInNumF, aInNumL, aOutNumF, aOutNumL) {\n var mInputLgt = $bm_sub(aInNumL, aInNumF);\n var mOutputLgt = $bm_sub(aOutNumL, aOutNumF);\n var mRatio = $bm_div(mOutputLgt, mInputLgt);\n if (aInNumF <= aInput && aInput <= aInNumL) {\n var mOutput = $bm_sum($bm_mul($bm_sub(aInput, aInNumF), mRatio), aOutNumF);\n } else {\n var mOutput = aInput;\n }\n return mOutput;\n}\nvar mSld = $bm_div(thisLayer('ADBE Effect Parade')('ShapeNir Slider')('ADBE Slider Control-0001'), 100);\nif (mSld <= 0) {\n var mRst = mN01;\n} else if (mSld >= 1) {\n var mRst = mN02;\n} else {\n for (var i = 1; i < mRatiosM.length; i++) {\n if (mRatiosM[i - 1] <= mSld && mSld < mRatiosM[i]) {\n var mCtl = mLinear(mSld, mRatiosM[$bm_sub(i, 1)], mRatiosM[i], mRatiosF[$bm_sub(i, 1)], mRatiosF[i]);\n break;\n }\n }\n var mRst = mLinear(mCtl, 0, 1, mN01Edt, mN02Edt);\n}\n$bm_rt = mRst;" + }, + "e": { + "a": 0, + "k": 100, + "ix": 2, + "x": "var $bm_rt;\nvar mN01 = 50.0000191622908;\nvar mN02 = 0;\nvar mN01Edt = 50.0000191622908;\nvar mN02Edt = 14.3754175691879;\nvar mRatiosM = [\n 0,\n 0.1472024328579,\n 0.50257634091374,\n 1\n ];\nvar mRatiosL = [\n 0,\n 0.31314966173997,\n 0.62416844828369,\n 1\n ];\nfunction mLinear(aInput, aInNumF, aInNumL, aOutNumF, aOutNumL) {\n var mInputLgt = $bm_sub(aInNumL, aInNumF);\n var mOutputLgt = $bm_sub(aOutNumL, aOutNumF);\n var mRatio = $bm_div(mOutputLgt, mInputLgt);\n if (aInNumF <= aInput && aInput <= aInNumL) {\n var mOutput = $bm_sum($bm_mul($bm_sub(aInput, aInNumF), mRatio), aOutNumF);\n } else {\n var mOutput = aInput;\n }\n return mOutput;\n}\nvar mSld = $bm_div(thisLayer('ADBE Effect Parade')('ShapeNir Slider')('ADBE Slider Control-0001'), 100);\nif (mSld <= 0) {\n var mRst = mN01;\n} else if (mSld >= 1) {\n var mRst = mN02;\n} else {\n for (var i = 1; i < mRatiosM.length; i++) {\n if (mRatiosM[i - 1] <= mSld && mSld < mRatiosM[i]) {\n var mCtl = mLinear(mSld, mRatiosM[$bm_sub(i, 1)], mRatiosM[i], mRatiosL[$bm_sub(i, 1)], mRatiosL[i]);\n break;\n }\n }\n var mRst = mLinear(mCtl, 0, 1, mN01Edt, mN02Edt);\n}\n$bm_rt = mRst;" + }, + "o": {"a": 0, "k": 0, "ix": 3}, + "m": 1, + "ix": 2, + "nm": "Taglia tracciati 1", + "mn": "ADBE Vector Filter - Trim", + "hd": false + }, + { + "ind": 2, + "ty": "sh", + "ix": 3, + "ks": { + "a": 1, + "k": [ + { + "i": {"x": 0.45, "y": 1}, + "o": {"x": 0.167, "y": 0}, + "t": 53, + "s": [ + { + "i": [ + [0, 0], + [-1.108, 1.108], + [-2.676, 2.676], + [-1.873, 1.873], + [0, 0] + ], + "o": [ + [0, 0], + [2.676, -2.676], + [0, 0], + [1.873, -1.873], + [0, 0] + ], + "v": [ + [1383.785, 1147.112], + [1387.144, 1143.753], + [1395.253, 1135.644], + [1400.928, 1129.969], + [1406.603, 1124.294] + ], + "c": false + } + ] + }, + { + "t": 59, + "s": [ + { + "i": [ + [0, 0], + [-1.587, 1.587], + [-3.831, 3.831], + [-2.681, 2.681], + [0, 0] + ], + "o": [ + [0, 0], + [3.831, -3.831], + [0, 0], + [2.681, -2.681], + [0, 0] + ], + "v": [ + [1378.641, 1148.253], + [1383.45, 1143.444], + [1395.059, 1131.835], + [1403.185, 1123.709], + [1411.31, 1115.584] + ], + "c": false + } + ] + } + ], + "ix": 2 + }, + "nm": "Tracciato 1_Bone", + "mn": "ADBE Vector Shape - Group", + "hd": false + } + ], + "ip": 53, + "op": 126, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 15, + "ty": 4, + "nm": "bottom_extra", + "parent": 16, + "sr": 1, + "ks": { + "o": {"a": 0, "k": 100, "ix": 11}, + "r": {"a": 0, "k": 45, "ix": 10}, + "p": { + "a": 1, + "k": [ + { + "i": {"x": 0.833, "y": 0.833}, + "o": {"x": 0.167, "y": 0.167}, + "t": 29, + "s": [1395.693, 1135.648, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + { + "i": {"x": 0.833, "y": 0.833}, + "o": {"x": 0.167, "y": 0.167}, + "t": 34, + "s": [1398.671, 1131.534, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + { + "i": {"x": 0.833, "y": 0.833}, + "o": {"x": 0.167, "y": 0.167}, + "t": 51, + "s": [1398.671, 1131.534, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + {"t": 53, "s": [1396.171, 1134.409, 0]} + ], + "ix": 2 + }, + "a": {"a": 0, "k": [-114, 181, 0], "ix": 1}, + "s": { + "a": 1, + "k": [ + { + "i": {"x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 0.833]}, + "o": {"x": [0.167, 0.167, 0.167], "y": [0.167, 0.167, 0.167]}, + "t": 29, + "s": [38.5, 38.5, 100] + }, + { + "i": {"x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 0.833]}, + "o": {"x": [0.167, 0.167, 0.167], "y": [0.167, 0.167, 0.167]}, + "t": 34, + "s": [50, 50, 100] + }, + { + "i": {"x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 0.833]}, + "o": {"x": [0.167, 0.167, 0.167], "y": [0.167, 0.167, 0.167]}, + "t": 49, + "s": [50, 50, 100] + }, + {"t": 53, "s": [37, 37, 100]} + ], + "ix": 6 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "d": 1, + "ty": "el", + "s": {"a": 0, "k": [91, 91], "ix": 2}, + "p": {"a": 0, "k": [0, 0], "ix": 3}, + "nm": "Tracciato ellisse 1", + "mn": "ADBE Vector Shape - Ellipse", + "hd": false + }, + { + "ty": "fl", + "c": {"a": 0, "k": [0.337249755859, 0.223510742188, 1, 1], "ix": 4}, + "o": {"a": 0, "k": 100, "ix": 5}, + "r": 1, + "bm": 0, + "nm": "Riempimento 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": {"a": 0, "k": [-114, 181], "ix": 2}, + "a": {"a": 0, "k": [0, 0], "ix": 1}, + "s": {"a": 0, "k": [100, 100], "ix": 3}, + "r": {"a": 0, "k": 0, "ix": 6}, + "o": {"a": 0, "k": 100, "ix": 7}, + "sk": {"a": 0, "k": 0, "ix": 4}, + "sa": {"a": 0, "k": 0, "ix": 5}, + "nm": "Transform" + } + ], + "nm": "Ellisse 1", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 29, + "op": 53, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 16, + "ty": 4, + "nm": "bottom", + "parent": 5, + "sr": 1, + "ks": { + "o": {"a": 0, "k": 100, "ix": 11}, + "r": {"a": 0, "k": 0, "ix": 10}, + "p": {"a": 0, "k": [-2.559, 65.635, 0], "ix": 2}, + "a": {"a": 0, "k": [1412.324, 1115.584, 0], "ix": 1}, + "s": {"a": 0, "k": [100, 100, 100], "ix": 6} + }, + "ao": 0, + "ef": [ + { + "ty": 5, + "nm": "ShapeNir Slider", + "np": 3, + "mn": "ADBE Slider Control", + "ix": 1, + "en": 1, + "ef": [ + { + "ty": 0, + "nm": "Slider", + "mn": "ADBE Slider Control-0001", + "ix": 1, + "v": { + "a": 1, + "k": [ + {"i": {"x": [0.17], "y": [1]}, "o": {"x": [0.55], "y": [0]}, "t": 0, "s": [0]}, + {"t": 23, "s": [100]} + ], + "ix": 1 + } + } + ] + } + ], + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 1, + "k": [ + { + "i": {"x": 0.17, "y": 1}, + "o": {"x": 0.55, "y": 0}, + "t": 0, + "s": [ + { + "i": [ + [0, 0], + [0, 0], + [0, 0], + [9.068, 9.068], + [-9.068, 9.068], + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0], + [-9.068, 9.068], + [-9.068, -9.068], + [0, 0], + [0, 0], + [0, 0] + ], + "v": [ + [11.444, -11.445], + [28.615, 5.726], + [-95.63, 130.357], + [-129.219, 129.605], + [-129.971, 96.016], + [-5.726, -28.615], + [11.444, -11.445] + ], + "c": false + } + ] + }, + { + "i": {"x": 0.833, "y": 0.833}, + "o": {"x": 0.55, "y": 0}, + "t": 23, + "s": [ + { + "i": [ + [0, 0], + [0, 0], + [0, 0], + [9.068, 9.068], + [-9.068, 9.068], + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0], + [-9.068, 9.068], + [-9.068, -9.068], + [0, 0], + [0, 0], + [0, 0] + ], + "v": [ + [11.444, -11.445], + [28.615, 5.726], + [12.364, 21.977], + [-21.225, 21.225], + [-21.977, -12.364], + [-5.726, -28.615], + [11.444, -11.445] + ], + "c": false + } + ] + }, + { + "i": {"x": 0.833, "y": 1}, + "o": {"x": 0.167, "y": 0.167}, + "t": 28, + "s": [ + { + "i": [ + [-3.749, -3.55], + [1.525, -6.804], + [0, 0], + [6.838, 6.838], + [-6.838, 6.838], + [-5.985, 0.184], + [0, 0] + ], + "o": [ + [3.749, 3.55], + [-1.525, 6.804], + [-6.838, 6.838], + [-6.838, -6.838], + [0, 0], + [5.985, -0.184], + [0, 0] + ], + "v": [ + [12.19, -8.82], + [20.555, 8.606], + [8.3, 20.861], + [-17.03, 20.294], + [-17.597, -5.036], + [-5.342, -17.291], + [12.19, -8.82] + ], + "c": false + } + ] + }, + { + "t": 29, + "s": [ + { + "i": [ + [0, 0], + [0, 0], + [0, 0], + [6.334, 6.334], + [-6.333, 6.334], + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0], + [-6.334, 6.333], + [-6.334, -6.334], + [0, 0], + [0, 0], + [0, 0] + ], + "v": [ + [6.738, -2.735], + [18.731, 9.258], + [7.38, 20.609], + [-16.081, 20.083], + [-16.606, -3.378], + [-5.255, -14.728], + [6.738, -2.735] + ], + "c": false + } + ] + } + ], + "ix": 2 + }, + "nm": "Tracciato 1_forAnimation", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": {"a": 0, "k": [0.337254911661, 0.223529413342, 1, 1], "ix": 4}, + "o": {"a": 0, "k": 100, "ix": 5}, + "r": 1, + "bm": 0, + "nm": "Riempimento 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": {"a": 0, "k": [1399.865, 1127.029], "ix": 2}, + "a": {"a": 0, "k": [0, 0], "ix": 1}, + "s": {"a": 0, "k": [100, 100], "ix": 3}, + "r": {"a": 0, "k": 0, "ix": 6}, + "o": {"a": 0, "k": 100, "ix": 7}, + "sk": {"a": 0, "k": 0, "ix": 4}, + "sa": {"a": 0, "k": 0, "ix": 5}, + "nm": "Transform" + } + ], + "nm": "Gruppo 1", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + }, + { + "ty": "tm", + "s": { + "a": 0, + "k": 0, + "ix": 1, + "x": "var $bm_rt;\nvar mN01 = 50.0000191622908;\nvar mN02 = 100;\nvar mN01Edt = 50.0000191622908;\nvar mN02Edt = 85.6245824308121;\nvar mRatiosM = [\n 0,\n 0.1472024328579,\n 0.50257634091374,\n 1\n ];\nvar mRatiosF = [\n 0,\n 0.31314999326651,\n 0.62416910908086,\n 1\n ];\nfunction mLinear(aInput, aInNumF, aInNumL, aOutNumF, aOutNumL) {\n var mInputLgt = $bm_sub(aInNumL, aInNumF);\n var mOutputLgt = $bm_sub(aOutNumL, aOutNumF);\n var mRatio = $bm_div(mOutputLgt, mInputLgt);\n if (aInNumF <= aInput && aInput <= aInNumL) {\n var mOutput = $bm_sum($bm_mul($bm_sub(aInput, aInNumF), mRatio), aOutNumF);\n } else {\n var mOutput = aInput;\n }\n return mOutput;\n}\nvar mSld = $bm_div(thisLayer('ADBE Effect Parade')('ShapeNir Slider')('ADBE Slider Control-0001'), 100);\nif (mSld <= 0) {\n var mRst = mN01;\n} else if (mSld >= 1) {\n var mRst = mN02;\n} else {\n for (var i = 1; i < mRatiosM.length; i++) {\n if (mRatiosM[i - 1] <= mSld && mSld < mRatiosM[i]) {\n var mCtl = mLinear(mSld, mRatiosM[$bm_sub(i, 1)], mRatiosM[i], mRatiosF[$bm_sub(i, 1)], mRatiosF[i]);\n break;\n }\n }\n var mRst = mLinear(mCtl, 0, 1, mN01Edt, mN02Edt);\n}\n$bm_rt = mRst;" + }, + "e": { + "a": 0, + "k": 100, + "ix": 2, + "x": "var $bm_rt;\nvar mN01 = 50.0000191622908;\nvar mN02 = 0;\nvar mN01Edt = 50.0000191622908;\nvar mN02Edt = 14.3754175691879;\nvar mRatiosM = [\n 0,\n 0.1472024328579,\n 0.50257634091374,\n 1\n ];\nvar mRatiosL = [\n 0,\n 0.31314966173997,\n 0.62416844828369,\n 1\n ];\nfunction mLinear(aInput, aInNumF, aInNumL, aOutNumF, aOutNumL) {\n var mInputLgt = $bm_sub(aInNumL, aInNumF);\n var mOutputLgt = $bm_sub(aOutNumL, aOutNumF);\n var mRatio = $bm_div(mOutputLgt, mInputLgt);\n if (aInNumF <= aInput && aInput <= aInNumL) {\n var mOutput = $bm_sum($bm_mul($bm_sub(aInput, aInNumF), mRatio), aOutNumF);\n } else {\n var mOutput = aInput;\n }\n return mOutput;\n}\nvar mSld = $bm_div(thisLayer('ADBE Effect Parade')('ShapeNir Slider')('ADBE Slider Control-0001'), 100);\nif (mSld <= 0) {\n var mRst = mN01;\n} else if (mSld >= 1) {\n var mRst = mN02;\n} else {\n for (var i = 1; i < mRatiosM.length; i++) {\n if (mRatiosM[i - 1] <= mSld && mSld < mRatiosM[i]) {\n var mCtl = mLinear(mSld, mRatiosM[$bm_sub(i, 1)], mRatiosM[i], mRatiosL[$bm_sub(i, 1)], mRatiosL[i]);\n break;\n }\n }\n var mRst = mLinear(mCtl, 0, 1, mN01Edt, mN02Edt);\n}\n$bm_rt = mRst;" + }, + "o": {"a": 0, "k": 0, "ix": 3}, + "m": 1, + "ix": 2, + "nm": "Taglia tracciati 1", + "mn": "ADBE Vector Filter - Trim", + "hd": false + }, + { + "ind": 2, + "ty": "sh", + "ix": 3, + "ks": { + "a": 1, + "k": [ + { + "i": {"x": 0.17, "y": 1}, + "o": {"x": 0.55, "y": 0}, + "t": 0, + "s": [ + { + "i": [ + [0, 0], + [-1.587, 1.587], + [-3.831, 3.831], + [-2.681, 2.681], + [0, 0] + ], + "o": [ + [0, 0], + [3.831, -3.831], + [0, 0], + [2.681, -2.681], + [0, 0] + ], + "v": [ + [1270.646, 1256.634], + [1275.455, 1251.825], + [1287.065, 1240.215], + [1403.185, 1123.709], + [1411.31, 1115.584] + ], + "c": false + } + ] + }, + { + "i": {"x": 0.833, "y": 0.833}, + "o": {"x": 0.55, "y": 0}, + "t": 23, + "s": [ + { + "i": [ + [0, 0], + [-1.587, 1.587], + [-3.831, 3.831], + [-2.681, 2.681], + [0, 0] + ], + "o": [ + [0, 0], + [3.831, -3.831], + [0, 0], + [2.681, -2.681], + [0, 0] + ], + "v": [ + [1378.641, 1148.253], + [1383.45, 1143.444], + [1395.059, 1131.835], + [1403.185, 1123.709], + [1411.31, 1115.584] + ], + "c": false + } + ] + }, + { + "i": {"x": 0.833, "y": 1}, + "o": {"x": 0.167, "y": 0.167}, + "t": 28, + "s": [ + { + "i": [ + [0, 0], + [-1.197, 1.197], + [-2.889, 2.889], + [-2.022, 2.022], + [0, 0] + ], + "o": [ + [0, 0], + [2.889, -2.889], + [0, 0], + [2.022, -2.022], + [0, 0] + ], + "v": [ + [1382.835, 1147.323], + [1386.462, 1143.696], + [1395.217, 1134.941], + [1401.345, 1128.813], + [1412.057, 1118.207] + ], + "c": false + } + ] + }, + { + "t": 29, + "s": [ + { + "i": [ + [0, 0], + [-1.108, 1.108], + [-2.676, 2.676], + [-1.873, 1.873], + [0, 0] + ], + "o": [ + [0, 0], + [2.676, -2.676], + [0, 0], + [1.873, -1.873], + [0, 0] + ], + "v": [ + [1383.785, 1147.112], + [1387.144, 1143.753], + [1395.253, 1135.644], + [1400.928, 1129.969], + [1406.603, 1124.294] + ], + "c": false + } + ] + } + ], + "ix": 2 + }, + "nm": "Tracciato 1_Bone", + "mn": "ADBE Vector Shape - Group", + "hd": false + } + ], + "ip": 0, + "op": 29, + "st": 0, + "bm": 0 + } + ], + "markers": [] +} diff --git a/src/assets/images/institution-logo.png b/src/assets/images/institution-logo.png new file mode 100644 index 0000000000..b1d13c1071 Binary files /dev/null and b/src/assets/images/institution-logo.png differ diff --git a/src/assets/images/logo.png b/src/assets/images/logo.png new file mode 100644 index 0000000000..a874b1e6d0 Binary files /dev/null and b/src/assets/images/logo.png differ diff --git a/src/assets/wav-worker.js b/src/assets/wav-worker.js index 85948edd13..9f0f1651ec 100644 --- a/src/assets/wav-worker.js +++ b/src/assets/wav-worker.js @@ -72,4 +72,4 @@ onmessage = function (e) { } else if (e.data[0] === 'close') { self.close(); } -}; \ No newline at end of file +}; diff --git a/src/common/i18n/localize.js b/src/common/i18n/localize.js deleted file mode 100644 index 35335f73ca..0000000000 --- a/src/common/i18n/localize.js +++ /dev/null @@ -1,168 +0,0 @@ -/* - * An AngularJS Localization Service - * - * Written by Jim Lavin - * http://codingsmackdown.tv - * - */ - -angular.module('localization', []) -// localization service responsible for retrieving resource files from the server and -// managing the translation dictionary -.factory('localize', ['$http', '$rootScope', '$window', '$filter', function($http, $rootScope, $window, $filter) { - var localize = { - // use the $window service to get the language of the user's browser - language: $window.navigator.userLanguage || $window.navigator.language, - // array to hold the localized resource string entries - dictionary: [], - // flag to indicate if the service hs loaded the resource file - resourceFileLoaded: false, - - // success handler for all server communication - successCallback: function(data) { - // store the returned array in the dictionary - localize.dictionary = data; - // set the flag that the resource are loaded - localize.resourceFileLoaded = true; - // broadcast that the file has been loaded - $rootScope.$broadcast('i18nReady'); - }, - - // allows setting of language on the fly - setLanguage: function(value) { - localize.language = value; - localize.initLocalizedResources(); - }, - - // loads the language resource file from the server - initLocalizedResources: function() { - // build the url to retrieve the localized resource file - var url = 'src/i18n/resources-locale_' + localize.language + '.js'; - // request the resource file - $http({ - method: "GET", - url: url, - cache: false - }).success(localize.successCallback).error(function() { - // the request failed set the url to the default resource file - var url = 'src/i18n/resources-locale_default.js'; - // request the default resource file - $http({ - method: "GET", - url: url, - cache: false - }).success(localize.successCallback); - }); - }, - - // checks the dictionary for a localized resource string - getLocalizedString: function(value) { - // default the result to an empty string - var result = ''; - - // make sure the dictionary has valid data - if ((localize.dictionary.length !== 0) && (localize.dictionary.length > 0)) { - // use the filter service to only return those entries which match the value - // and only take the first result - var entry = $filter('filter')(localize.dictionary, function(element) { - return element.key === value; - })[0]; - - // set the result - result = entry.value; - } - // return the value to the call - return result; - } - }; - - // force the load of the resource file - localize.initLocalizedResources(); - - // return the local instance when called - return localize; -}]) -// simple translation filter -// usage {{ TOKEN | i18n }} -.filter('i18n', ['localize', function(localize) { - return function(input) { - return localize.getLocalizedString(input); - }; -}]) -// translation directive that can handle dynamic strings -// updates the text value of the attached element -// usage -// or -// -.directive('i18n', ['localize', function(localize) { - var i18nDirective = { - restrict: "EAC", - updateText: function(elm, token) { - var values = token.split('|'); - if (values.length >= 1) { - // construct the tag to insert into the element - var tag = localize.getLocalizedString(values[0]); - // update the element only if data was returned - if ((tag !== null) && (tag !== undefined) && (tag !== '')) { - if (values.length > 1) { - for (var index = 1; index < values.length; index++) { - var target = '{' + (index - 1) + '}'; - tag = tag.replace(target, values[index]); - } - } - // insert the text into the element - elm.text(tag); - } - } - }, - - link: function(scope, elm, attrs) { - scope.$on('i18nReady', function() { - i18nDirective.updateText(elm, attrs.i18n); - }); - - attrs.$observe('i18n', function(value) { - i18nDirective.updateText(elm, attrs.i18n); - }); - } - }; - - return i18nDirective; -}]) -// translation directive that can handle dynamic strings -// updates the attribute value of the attached element -// usage -// or -// -.directive('i18nAttr', ['localize', function(localize) { - var i18NAttrDirective = { - restrict: "EAC", - updateText: function(elm, token) { - var values = token.split('|'); - // construct the tag to insert into the element - var tag = localize.getLocalizedString(values[0]); - // update the element only if data was returned - if ((tag !== null) && (tag !== undefined) && (tag !== '')) { - if (values.length > 2) { - for (var index = 2; index < values.length; index++) { - var target = '{' + (index - 2) + '}'; - tag = tag.replace(target, values[index]); - } - } - // insert the text into the element - elm.attr(values[1], tag); - } - }, - link: function(scope, elm, attrs) { - scope.$on('localizeResourcesUpdated', function() { - i18NAttrDirective.updateText(elm, attrs.i18nAttr); - }); - - attrs.$observe('i18nAttr', function(value) { - i18NAttrDirective.updateText(elm, value); - }); - } - }; - - return i18NAttrDirective; -}]); diff --git a/src/common/utilService/utilService.coffee b/src/common/utilService/utilService.coffee deleted file mode 100644 index 0203f10d04..0000000000 --- a/src/common/utilService/utilService.coffee +++ /dev/null @@ -1,23 +0,0 @@ -angular.module("utilService", []) - -.filter "fromNow", -> - return (date) -> - return moment(new Date(date)).fromNow() - -.filter "titleize", -> - (input) -> _.titleize input - -.filter "humanize", -> - (input) -> _.humanize input - -.directive "autoFillSync", ($timeout) -> - - # A directive to ensure browser form auto-fill works, since Angular doesn't support it. - # See: http://stackoverflow.com/a/14966711 - require: "ngModel" - link: (scope, elem, attrs, ngModel) -> - origVal = elem.val() - $timeout (-> - newVal = elem.val() - ngModel.$setViewValue newVal if ngModel.$pristine and origVal isnt newVal - ), 500 diff --git a/src/environments/environment.prod.spec.ts b/src/environments/environment.prod.spec.ts new file mode 100644 index 0000000000..5b0dc75d17 --- /dev/null +++ b/src/environments/environment.prod.spec.ts @@ -0,0 +1,12 @@ +import {describe, expect, it} from 'vitest'; +import {environment} from './environment.prod'; + +describe('production environment', () => { + it('fails closed for demos and unresolved optional telemetry settings', () => { + expect(environment.production).toBe(true); + expect(environment.enableDemoTools).toBe(false); + expect(environment.sentryDsn).toBe(''); + expect(environment.sentryRelease).toBe(''); + expect(environment.sentryDist).toBe(''); + }); +}); diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index 3612073bc3..21dbe97419 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -1,3 +1,22 @@ +/** + * Docker replaces optional build values before Angular compiles this file. + * A direct production build leaves the `${...}` marker intact; normalising + * unresolved markers to an empty string keeps optional telemetry disabled. + */ +const optionalBuildValue = (value: string): string => + value.startsWith('${') && value.endsWith('}') ? '' : value; + export const environment = { - production: true + production: true, + + // Not read in production: `production: true` already enables the service + // worker. Present so both environment files have the same shape. + enableServiceWorker: false, + + // Demo mode must never be enabled by a production build. + enableDemoTools: false, + + sentryDsn: optionalBuildValue('${SENTRY_DSN}'), + sentryRelease: optionalBuildValue('${SENTRY_RELEASE}'), + sentryDist: optionalBuildValue('${SENTRY_DIST}'), }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 72cd63978b..fec5e071b0 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -3,7 +3,24 @@ // The list of file replacements can be found in `angular.json`. export const environment = { - production: false + production: false, + + // Runs the service worker outside a production build. Push notifications + // cannot arrive without it, because the service worker is the thing that + // receives them. + // + // Turn this off if the worker gets in your way. It caches, so a stale bundle + // after a rebuild is the usual symptom, and clearing it is a manual step. + // See docs/service-worker.md. + enableServiceWorker: true, + + // Makes the local-only demo controls available. The mode itself still starts + // off and is stored only for the current browser tab. + enableDemoTools: true, + + sentryDsn: '', + sentryRelease: '', + sentryDist: '', }; /* diff --git a/src/i18n/resources-locale_default.js b/src/i18n/resources-locale_default.js index 40adeca3f1..02afa75d4b 100644 --- a/src/i18n/resources-locale_default.js +++ b/src/i18n/resources-locale_default.js @@ -1,4 +1,4 @@ [ - { "key":"_Home_", "value":"Home" }, - { "key":"_User_", "value":"User" } -] + {'key': '_Home_', 'value': 'Home'}, + {'key': '_User_', 'value': 'User'}, +]; diff --git a/src/i18n/resources-locale_en-AU.js b/src/i18n/resources-locale_en-AU.js index 6a2a83110e..02afa75d4b 100644 --- a/src/i18n/resources-locale_en-AU.js +++ b/src/i18n/resources-locale_en-AU.js @@ -1,4 +1,4 @@ [ - { "key":"_Home_", "value":"Home" }, - { "key":"_User_", "value":"User" } -] + {'key': '_Home_', 'value': 'Home'}, + {'key': '_User_', 'value': 'User'}, +]; diff --git a/src/i18n/resources-locale_en-GB.js b/src/i18n/resources-locale_en-GB.js index 40adeca3f1..02afa75d4b 100644 --- a/src/i18n/resources-locale_en-GB.js +++ b/src/i18n/resources-locale_en-GB.js @@ -1,4 +1,4 @@ [ - { "key":"_Home_", "value":"Home" }, - { "key":"_User_", "value":"User" } -] + {'key': '_Home_', 'value': 'Home'}, + {'key': '_User_', 'value': 'User'}, +]; diff --git a/src/i18n/resources-locale_en-US.js b/src/i18n/resources-locale_en-US.js index 40adeca3f1..02afa75d4b 100644 --- a/src/i18n/resources-locale_en-US.js +++ b/src/i18n/resources-locale_en-US.js @@ -1,4 +1,4 @@ [ - { "key":"_Home_", "value":"Home" }, - { "key":"_User_", "value":"User" } -] + {'key': '_Home_', 'value': 'Home'}, + {'key': '_User_', 'value': 'User'}, +]; diff --git a/src/index.html b/src/index.html index 54fed2d067..e1ac26e210 100644 --- a/src/index.html +++ b/src/index.html @@ -1,37 +1,31 @@ - - - + + - + Loading... - + - - + + - - + + - + - + @@ -39,19 +33,9 @@ - - - - - - - - -
+ + diff --git a/src/karma-ci.conf.js b/src/karma-ci.conf.js deleted file mode 100644 index 4393b51ebf..0000000000 --- a/src/karma-ci.conf.js +++ /dev/null @@ -1,32 +0,0 @@ -// Karma configuration file, see link for more information -// https://karma-runner.github.io/0.13/config/configuration-file.html - -module.exports = function (config) { - config.set({ - logLevel: config.LOG_DEBUG, - basePath: '', - frameworks: ['jasmine', '@angular-devkit/build-angular'], - plugins: [ - require('karma-jasmine'), - require('karma-chrome-launcher'), - require('karma-jasmine-html-reporter'), - require('karma-coverage-istanbul-reporter'), - require('@angular-devkit/build-angular/plugins/karma'), - ], - client: { - clearContext: false, // leave Jasmine Spec Runner output visible in browser - }, - coverageIstanbulReporter: { - dir: require('path').join(__dirname, 'coverage'), - reports: ['html', 'lcovonly'], - fixWebpackSourcePaths: true, - }, - angularCli: { - environment: 'dev', - }, - reporters: ['progress', 'kjhtml'], - port: 9876, - singleRun: true, - browsers: ['ChromeHeadless'], - }); -}; diff --git a/src/karma.conf.js b/src/karma.conf.js deleted file mode 100644 index 5aa0bbb00d..0000000000 --- a/src/karma.conf.js +++ /dev/null @@ -1,40 +0,0 @@ -// Karma configuration file, see link for more information -// https://karma-runner.github.io/0.13/config/configuration-file.html - -module.exports = function (config) { - config.set({ - logLevel: config.LOG_DEBUG, - basePath: '', - frameworks: ['jasmine', '@angular-devkit/build-angular'], - plugins: [ - require('karma-jasmine'), - require('karma-chrome-launcher'), - require('karma-jasmine-html-reporter'), - require('karma-coverage-istanbul-reporter'), - require('@angular-devkit/build-angular/plugins/karma'), - ], - client: { - clearContext: false, // leave Jasmine Spec Runner output visible in browser - }, - coverageIstanbulReporter: { - dir: require('path').join(__dirname, 'coverage'), - reports: ['html', 'lcovonly'], - fixWebpackSourcePaths: true, - }, - angularCli: { - environment: 'dev', - }, - reporters: ['progress', 'kjhtml'], - port: 9876, - colors: true, - autoWatch: true, - browsers: ['Chrome'], - singleRun: false, - customLaunchers: { - ChromeDebug: { - base: 'Chrome', - flags: ['--remote-debugging-port=9333'], - }, - }, - }); -}; diff --git a/src/main.ts b/src/main.ts index a03768c5fc..7ede38d3ef 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,37 +1,49 @@ -// Make sure that angular is loaded before anything else! -import 'angular/angular.js'; - -import { enableProdMode, NgZone, Type } from '@angular/core'; -import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; - -import { environment } from 'src/environments/environment'; - -import { DoubtfireAngularJSModule } from './app/doubtfire-angularjs.module'; -import { DoubtfireAngularModule } from './app/doubtfire-angular.module'; - -import { UIRouter, UrlService } from '@uirouter/core'; +import * as Sentry from '@sentry/angular'; +import {enableProdMode, provideZoneChangeDetection} from '@angular/core'; +import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; +import {environment} from 'src/environments/environment'; +import {DoubtfireAngularModule} from './app/doubtfire-angular.module'; +import { + captureAndScrubAuthCallback, + redactAuthCallbackFromUrl, +} from './app/security/auth-callback'; + +// Authentication callbacks may contain a one-time credential. Remove it from +// browser history before any telemetry SDK or application code can observe it. +const telemetrySafe = captureAndScrubAuthCallback(); + +if (environment.sentryDsn && telemetrySafe) { + Sentry.init({ + dsn: environment.sentryDsn, + tunnel: '/api/client-reports', + release: environment.sentryRelease || undefined, + dist: environment.sentryDist || undefined, + // Keep telemetry to redacted error events for this release. Browser tracing + // and Session Replay can observe iframe/resource URLs outside beforeSend; + // legacy SCORM URLs still contain a reusable credential in their path. + // They must stay disabled until that launch protocol is redesigned. + enableLogs: false, + sendDefaultPii: false, + beforeSend(event) { + if (event.request?.url) { + event.request.url = redactAuthCallbackFromUrl(event.request.url); + } + return event; + }, + beforeBreadcrumb(breadcrumb) { + const url = breadcrumb.data?.['url']; + if (typeof url === 'string') { + breadcrumb.data['url'] = redactAuthCallbackFromUrl(url); + } + return breadcrumb; + }, + }); +} if (environment.production) { enableProdMode(); } -// Using AngularJS config block, call `deferIntercept()`. -// This tells UI-Router to delay the initial URL sync (until all bootstrapping is complete) -DoubtfireAngularJSModule.config(['$urlServiceProvider', ($urlService: UrlService) => $urlService.deferIntercept()]); - -// Manually bootstrap the Angular app -platformBrowserDynamic() - .bootstrapModule(DoubtfireAngularModule) - .then((platformRef) => { - // Intialize the Angular Module - // get() the UIRouter instance from DI to initialize the router - const urlService: UrlService = platformRef.injector.get(UIRouter as Type).urlService; - - // Instruct UIRouter to listen to URL changes - function startUIRouter() { - urlService.listen(); - urlService.sync(); - } - - platformRef.injector.get(NgZone).run(startUIRouter); - }); +platformBrowserDynamic().bootstrapModule(DoubtfireAngularModule, { + applicationProviders: [provideZoneChangeDetection()], +}); diff --git a/src/manifest.webmanifest b/src/manifest.webmanifest index 0ddf9e42d5..c6df094d7f 100644 --- a/src/manifest.webmanifest +++ b/src/manifest.webmanifest @@ -1,6 +1,6 @@ { - "name": "formatif learning and feedback", - "short_name": "formatif", + "name": "OnTrack learning and feedback", + "short_name": "OnTrack", "icons": [ { "src": "/assets/icons/android-chrome-192x192.png", @@ -11,6 +11,12 @@ "src": "/assets/icons/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" + }, + { + "src": "/assets/icons/android-chrome-maskable-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" } ], "theme_color": "#3939ff", diff --git a/src/polyfills.ts b/src/polyfills.ts index b602050f34..c5f35359ae 100644 --- a/src/polyfills.ts +++ b/src/polyfills.ts @@ -19,7 +19,7 @@ * Zone JS is required by default for Angular itself. */ import 'zone.js'; // Included with Angular CLI. -(window as any).global = window; +window.global = globalThis; /*************************************************************************************************** * APPLICATION IMPORTS diff --git a/src/styles.scss b/src/styles.scss index b0756f5acd..99cd35e979 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -1,13 +1,48 @@ // For more information: https://material.angular.io/guide/theming @use '@angular/material' as mat; +@use '@worktile/gantt/styles/index'; +@use 'theme' as *; +@use 'styles/common/typeface'; +@use 'tailwindcss'; +@config '../tailwind.config.js'; -@include mat.core(); +@include mat.elevation-classes(); +@include mat.app-background(); -@import './theme.scss'; +@import '../node_modules/angular-calendar/css/angular-calendar.css'; -@tailwind base; -@tailwind components; -@tailwind utilities; +// Keep Angular's [hidden] bindings stronger than display utilities such as Tailwind's .flex. +[hidden] { + display: none !important; +} + +$main-view-top-padding: 30px; +$main-view-bottom-padding: $main-view-top-padding; + +$main-view-max-height: calc((var(--vh, 1vh) * (100)) - 85px); + +// This is the new Angular Material 3 theme file which we will migrate to +// @use './styles/m3-theme.scss'; + +// this is a pre-defined green palette for testing. +// $theme: mat.define-theme(( +// color: ( +// theme-type: light, +// primary: mat.$spring-green-palette, +// ), +// typography: ( +// brand-family: 'Grotesk', +// bold-weight: 900 + +// ), +// density: ( +// scale: -1 +// ) +// )); + +// html { +// @include mat.all-component-themes($theme); +// } .mat-mdc-form-field.mat-mdc-form-field.mat-mdc-form-field.mat-mdc-form-field.mat-mdc-form-field .mdc-notched-outline__notch { @@ -35,3 +70,34 @@ } } } + +.table-selectable { + .mat-mdc-row:hover .mat-mdc-cell { + background-color: rgb(243, 243, 243); + } + .mat-mdc-row .mat-mdc-cell { + cursor: pointer; + } +} + +.mat-mdc-progress-spinner circle, +.mat-spinner circle { + stroke: #ddd !important; +} + +/* Ensures smooth resizing of task list in project dashboard */ +body.split-pane-resizing { + cursor: col-resize; + user-select: none; +} + +body.split-pane-resizing iframe, +body.split-pane-resizing object, +body.split-pane-resizing embed, +body.split-pane-resizing pdf-viewer { + pointer-events: none !important; +} + +.mat-badge-accent .mat-badge-content { + color: white; +} diff --git a/src/styles/common/center-full-screen.scss b/src/styles/common/center-full-screen.scss deleted file mode 100644 index 0ddbddf36d..0000000000 --- a/src/styles/common/center-full-screen.scss +++ /dev/null @@ -1,9 +0,0 @@ -// -// Ensures an element takes up the full screen and centered -// -.center-full-screen { - @include flex-center; - @media (min-width: $screen-sm) { - height: 100vh; - } -} diff --git a/src/styles/common/doubtfire-cards.scss b/src/styles/common/doubtfire-cards.scss deleted file mode 100644 index 6646ba5a2c..0000000000 --- a/src/styles/common/doubtfire-cards.scss +++ /dev/null @@ -1,8 +0,0 @@ - -@mixin doubtfire-card { - border-radius: 10px; -} - -.mat-mdc-card.danger-card { - @include doubtfire-card(); -} diff --git a/src/styles/common/extensions/col-xl.scss b/src/styles/common/extensions/col-xl.scss deleted file mode 100644 index 2b1c99052e..0000000000 --- a/src/styles/common/extensions/col-xl.scss +++ /dev/null @@ -1,110 +0,0 @@ -// From https://gist.github.com/juukie/d71133e69877b46f060e - -$screen-xl: 1560px !default; -$screen-xl-min: $screen-xl !default; -$screen-xl-desktop: $screen-xl-min !default; -$screen-lg-max: ($screen-xl-min - 1) !default; -$container-xlarge-desktop: (1530px + $grid-gutter-width) !default; -$container-xl: $container-xlarge-desktop !default; - -.container { - // @include container-fixed; No need for, already done. - @media (min-width: $screen-xl-min) { - width: $container-xl; - } -} - -// xLarge grid -// -// Columns, offsets, pushes, and pulls for the large desktop device range. - -@media (min-width: $screen-xl-min) { - @include make-grid(xl); -} - -// Generate the xlarge columns -@mixin make-xl-column($columns, $gutter: $grid-gutter-width) { - position: relative; - min-height: 1px; - padding-left: ($gutter / 2); - padding-right: ($gutter / 2); - - @media (min-width: $screen-xl-min) { - float: left; - width: percentage(($columns / $grid-columns)); - } -} -@mixin make-xl-column-offset($columns) { - @media (min-width: $screen-xl-min) { - margin-left: percentage(($columns / $grid-columns)); - } -} -@mixin make-xl-column-push($columns) { - @media (min-width: $screen-xl-min) { - left: percentage(($columns / $grid-columns)); - } -} -@mixin make-xl-column-pull($columns) { - @media (min-width: $screen-xl-min) { - right: percentage(($columns / $grid-columns)); - } -} - -@mixin make-grid-columns($i: 1, $list: ".col-xl-#{$i}") { - @for $i from (1 + 1) through $grid-columns { - $list: "#{$list}, .col-xl-#{$i}"; - } - #{$list} { - position: relative; - // Prevent columns from collapsing when empty - min-height: 1px; - // Inner gutter via padding - padding-left: ($grid-gutter-width / 2); - padding-right: ($grid-gutter-width / 2); - } -} - -@include make-grid-columns; - -@include responsive-invisibility('.visible-xl'); - -.visible-xl-block, -.visible-xl-inline, -.visible-xl-inline-block { - display: none !important; -} - -@media (min-width: $screen-xl-min) { - @include responsive-invisibility('.visible-lg'); - @include responsive-visibility('.visible-xl'); -} -.visible-xl-block { - @media (min-width: $screen-xl-min) { - display: block !important; - } -} -.visible-xl-inline { - @media (min-width: $screen-xl-min) { - display: inline !important; - } -} -.visible-xl-inline-block { - @media (min-width: $screen-xl-min) { - display: inline-block !important; - } -} - -@media (min-width: $screen-lg-min) and (max-width: $screen-lg-max) { - @include responsive-invisibility('.hidden-lg'); -} - -@media (min-width: $screen-xl-min) { - @include responsive-invisibility('.hidden-xl'); - @include responsive-visibility('.hidden-lg'); - - .visible-lg-block, - .visible-lg-inline, - .visible-lg-inline-block { - display: none !important; - } -} diff --git a/src/styles/common/extensions/fa-document-o.scss b/src/styles/common/extensions/fa-document-o.scss deleted file mode 100644 index 3427b08681..0000000000 --- a/src/styles/common/extensions/fa-document-o.scss +++ /dev/null @@ -1,2 +0,0 @@ -// Alias for .fa-file-pdf-o -.#{$fa-css-prefix}-file-document-o:before { content: $fa-var-file-pdf-o; } diff --git a/src/styles/common/extensions/panel-footer-toolbar.scss b/src/styles/common/extensions/panel-footer-toolbar.scss deleted file mode 100644 index 325dc960c8..0000000000 --- a/src/styles/common/extensions/panel-footer-toolbar.scss +++ /dev/null @@ -1,43 +0,0 @@ -.panel .panel-footer -{ - // With buttons in a small screen - @media (max-width: $screen-sm) - { - // Increase tapping area - & > * - { - width: 100%; - } - .btn-group, - .btn, - input - { - height: 4em; - width: 100%; - } - .btn - { - margin-top: 1em; - } - ul.pagination - { - margin-top: 1em; - width: 100%; - flex: 1; - display: flex; - overflow: auto; - li - { - flex: 1; - } - li > a - { - width: 100%; - height: 4em; - display: flex; - align-items: center; - justify-content: center; - } - } - } -} diff --git a/src/styles/common/extensions/panel-heading-toolbar.scss b/src/styles/common/extensions/panel-heading-toolbar.scss deleted file mode 100644 index d3a2e6621c..0000000000 --- a/src/styles/common/extensions/panel-heading-toolbar.scss +++ /dev/null @@ -1,55 +0,0 @@ -// For all panel headings -.panel .panel-heading { - // With buttons in a small screen - @media (max-width: $screen-sm) { - // Increase tapping area - & > * { - width: 100%; - } - .btn-group, .btn, input { - height: 4em; - width: 100%; - } - } - .toolbar { - @media (max-width: $screen-sm) { - margin: 1.5em auto; - width: 100%; - text-align: left; - } - .btn-group, .buttons > .btn { - @media (min-width: $screen-sm-max) { - margin-right: 1ex; - } - } - .buttons { - float: right; - @media (max-width: $screen-sm) { - width: 100%; - .btn-group { - margin-top: 1em; - display: flex; - float: left; - float: right; - clear: both; - .btn { - flex: 1 - } - .btn { - @include flex-center; - } - } - } - } - form[role="search"] { - float: right; - margin-right: 1.5ex; - @media (max-width: $screen-sm) { - margin-right: 0; - width: 100%; - margin-top: 1em; - padding: 0 !important; - } - } - } -} diff --git a/src/styles/common/extensions/pointer.scss b/src/styles/common/extensions/pointer.scss deleted file mode 100644 index 88a143e2c3..0000000000 --- a/src/styles/common/extensions/pointer.scss +++ /dev/null @@ -1,6 +0,0 @@ -// -// All anchors are pointers, as well as anything with the .pointer class -// -a, .pointer { - cursor: pointer; -} diff --git a/src/styles/common/extensions/strong.scss b/src/styles/common/extensions/strong.scss deleted file mode 100644 index bb400cbf84..0000000000 --- a/src/styles/common/extensions/strong.scss +++ /dev/null @@ -1,6 +0,0 @@ -// -// Strong font weight using a .strong class -// -.strong { - font-weight: bold; -} diff --git a/src/styles/common/five-cols.scss b/src/styles/common/five-cols.scss deleted file mode 100644 index 8ce22d31a0..0000000000 --- a/src/styles/common/five-cols.scss +++ /dev/null @@ -1,25 +0,0 @@ -.col-xs-15 { - width: 20%; - float: left; -} - -@media (min-width: 768px) { -.col-sm-15 { - width: 20%; - float: left; - } -} - -@media (min-width: 992px) { - .col-md-15 { - width: 20%; - float: left; - } -} - -@media (min-width: 1200px) { - .col-lg-15 { - width: 20%; - float: left; - } -} \ No newline at end of file diff --git a/src/styles/common/grade-colors.scss b/src/styles/common/grade-colors.scss deleted file mode 100644 index 61a6a0f8c4..0000000000 --- a/src/styles/common/grade-colors.scss +++ /dev/null @@ -1,13 +0,0 @@ -// -// Colors for associated grades -// -// ******************************************************** -// IMPORTANT: Grade colours must also be matched -// in the grade-service.coffee file -// ******************************************************** -// - -$grade-color-p: darken(#FF0000, 10%); -$grade-color-c: darken(#FF8000, 5%); -$grade-color-d: darken(#0080FF, 10%); -$grade-color-hd: darken(#80FF00, 15%); diff --git a/src/styles/common/hero-sidebar-layout.scss b/src/styles/common/hero-sidebar-layout.scss index ab2213bcab..ce68a9efd7 100644 --- a/src/styles/common/hero-sidebar-layout.scss +++ b/src/styles/common/hero-sidebar-layout.scss @@ -8,7 +8,7 @@ font-size: 24px; } -.subcontainer { +.content-panel { overflow-y: hidden; height: auto; padding-top: 42px; @@ -48,8 +48,8 @@ h1 { } } -f-sign-in .container, -f-welcome .container { +f-sign-in .content-container, +f-welcome .content-container { max-width: 416px; width: 100%; margin: auto; @@ -77,7 +77,7 @@ f-welcome .container { .sidebar { display: none; /* Hide the element */ } - .wordmark{ + .wordmark { display: flex; /* Show the element */ } } diff --git a/src/styles/common/overrides/badge-overrides.scss b/src/styles/common/overrides/badge-overrides.scss deleted file mode 100644 index 32057cdf12..0000000000 --- a/src/styles/common/overrides/badge-overrides.scss +++ /dev/null @@ -1,8 +0,0 @@ -// -// Overrides for all badges -// -.badge { - border-radius: 1em; - text-align: center; - display: inline-block; -} diff --git a/src/styles/common/overrides/body-overrides.scss b/src/styles/common/overrides/body-overrides.scss deleted file mode 100644 index c2b92ccf27..0000000000 --- a/src/styles/common/overrides/body-overrides.scss +++ /dev/null @@ -1,36 +0,0 @@ -// -// Overrides for the body (i.e., global overrdies) -// -$main-view-top-padding: 30px; -$main-view-bottom-padding: $main-view-top-padding; - -$main-view-max-height: calc((var(--vh, 1vh) * (100)) - 85px); - -/* override browser default */ -html, -body { - margin: 0; - padding: 0; - // background-color: #f5f5f5; -} - -/* use viewport-relative units to cover page fully */ -body { - height: 100vh; - height: $main-view-max-height; - width: 100vw; - // font-family: 'Roboto', Helvetica; - // font-size: 1.2em; - // line-height: 2; - // font-weight: normal; -} - -/* include border and padding in element width and height */ -* { - box-sizing: border-box; -} - -// body>[ui-view="header"]:not(:empty)+[ui-view="main"] { -// padding-top: $main-view-top-padding; -// padding-bottom: $main-view-bottom-padding; -// } diff --git a/src/styles/common/overrides/header-overrides.scss b/src/styles/common/overrides/header-overrides.scss deleted file mode 100644 index 970bfd5e06..0000000000 --- a/src/styles/common/overrides/header-overrides.scss +++ /dev/null @@ -1,7 +0,0 @@ -// -// Sets all font weights to the headers as 400 -// - -h1, h2, h3, h4, h5, h6 { - font-weight: 400; -} diff --git a/src/styles/common/overrides/label-overrides.scss b/src/styles/common/overrides/label-overrides.scss deleted file mode 100644 index 3280c214cb..0000000000 --- a/src/styles/common/overrides/label-overrides.scss +++ /dev/null @@ -1,11 +0,0 @@ -// -// Overrides for the font size to make labels larger -// -.label { - font-size: 100% !important; -} - -.label.label-unit { - @include label-variant($label-info-bg); - margin-right: 1ex; -} diff --git a/src/styles/common/overrides/nav-tabs-overrides.scss b/src/styles/common/overrides/nav-tabs-overrides.scss deleted file mode 100644 index 8a20557cf2..0000000000 --- a/src/styles/common/overrides/nav-tabs-overrides.scss +++ /dev/null @@ -1,16 +0,0 @@ -// -// Override nav tabs's content to be separated -// -.nav.nav-tabs + .tab-content { - margin-top: 1.5em; -} - -// Support badges in nav pills -.nav.nav-pills tab-heading { - display: flex; - justify-content: center; - align-items: center; - .comment-count { - margin-left: 1ex; - } -} diff --git a/src/styles/common/overrides/nvd3-graph-overrides.scss b/src/styles/common/overrides/nvd3-graph-overrides.scss deleted file mode 100644 index 108d0fe58a..0000000000 --- a/src/styles/common/overrides/nvd3-graph-overrides.scss +++ /dev/null @@ -1,6 +0,0 @@ -// -// Override to center all nvd3 SVGs -// -svg.nvd3-svg { - margin: 0 auto; -} diff --git a/src/styles/common/overrides/panel-overrides.scss b/src/styles/common/overrides/panel-overrides.scss deleted file mode 100644 index 28506f7d48..0000000000 --- a/src/styles/common/overrides/panel-overrides.scss +++ /dev/null @@ -1,118 +0,0 @@ -// -// Global overrides for panels -// -.panel-footer .pagination, .modal-footer .pagination { - margin: 0; -} - -.panel-body + .panel-heading, .panel-body + * > .panel-heading { - border-top: 1px solid #ddd !important; - border-radius: 0 !important; -} - -.panel-heading > .pull-left { - padding-left: 0 !important; // Override for col-sm-* -} -.panel-heading > .pull-right { - padding-right: 0 !important; // Override for col-sm-* -} - -@mixin panel-row { - display: flex; - .panel { - flex: 1; - display: flex; - flex-direction: column; - } - .panel > *:not(.panel-heading):not(.panel-footer):not(.panel-toolbar) { - flex: 1; - } - .panel-heading, .panel-body, .panel-footer { - width: 100%; - } - .panel + .panel { - margin-left: $panel-body-padding; - } - // Panels with file dropzones - .panel-footer .well { - background-color: #fff; - } - .panel h3.file-title { - margin-top: 0; - font-size: 1em; - } - .panel .drop.well, .panel .file-uploader { - margin: 0; - } -} -.panel-row { - @include panel-row(); -} - -// New panel loading panel -.panel-body.panel-loading { - @extend .text-muted; - @extend .text-center; - @extend .strong; - font-size: 1.3em; - padding-top: $panel-body-padding * 4; - padding-bottom: $panel-body-padding * 4; - i { - margin-right: 0.9ex; - } -} - -.panel.panel-scrollable { - overflow: hidden; - .panel-body { - overflow: scroll; - height: 100%; - padding-bottom: $panel-body-padding * 3; - } -} - -// Search options panel -.panel-body.panel-collapseable { - background-color: lighten($gray-lighter, 2%); -} - -// Two panel bodies next to eachother (include those with one nested in custom element) -.panel-body:not(.ng-hide) + * > .panel-body, .panel-body:not(.ng-hide) + .panel-body { - padding-top: 0; -} -// Remove extra margin for callouts in padding -.panel-body > .callout:first-child { - margin-top: 0; -} -.panel-body > .callout:last-child { - margin-bottom: 0; -} - -// Full screen set of panels -.panel-full-screen { - height: $main-view-max-height; - & > *, & > * > .panel { - height: 100%; - } -} - -.panel-marking-context { - padding: 0; - .panel { - margin: 0; - border: none; - border-radius: 0; - background-color: transparent; - } - .panel-submission-marking-context .panel-body { - padding: 60px; - } -} - -.panel.panel-pdf { - @include flex-panel; - .panel-body.has-pdf { - padding: 0; - position: relative; - } -} \ No newline at end of file diff --git a/src/styles/common/overrides/rating-overrides.scss b/src/styles/common/overrides/rating-overrides.scss deleted file mode 100644 index a5986acf8f..0000000000 --- a/src/styles/common/overrides/rating-overrides.scss +++ /dev/null @@ -1,12 +0,0 @@ -// The generated from ui-bootstrap becomes this -span[role="slider"] { - outline: none; -} -.rating-outline { - color: lighten($brand-warning, 30%); - -webkit-text-stroke-width: 1px; - -webkit-text-stroke-color: $brand-warning; -} -.rating-disabled { - color: lighten($text-muted, 30%); -} diff --git a/src/styles/common/overrides/table-overrides.scss b/src/styles/common/overrides/table-overrides.scss deleted file mode 100644 index 4a9d444e8c..0000000000 --- a/src/styles/common/overrides/table-overrides.scss +++ /dev/null @@ -1,14 +0,0 @@ -// -// Global overrides for tables -// -table.table-pointer { - tr { - cursor: pointer; - } -} - -table > tbody > tr { - & > td, & > th { - vertical-align: middle !important; - } -} diff --git a/src/styles/common/task-status-colors.scss b/src/styles/common/task-status-colors.scss index 82320d524b..be4b37f690 100644 --- a/src/styles/common/task-status-colors.scss +++ b/src/styles/common/task-status-colors.scss @@ -1,9 +1,11 @@ +@use 'sass:color'; + // // Colours for task statuses // // ******************************************************** // IMPORTANT: Task status colours must also be matched -// in the task-service.coffee file +// in the task-status.ts file // ******************************************************** // @@ -14,74 +16,92 @@ $task-status-foreground-color-dark: #444; $task-status-colors: ( ready-for-feedback: ( base: #0079d8, - dark: darken(#0079d8, 15%), - light: lighten(#0079d8, 15%), + dark: color.adjust(#0079d8, $lightness: -15%), + light: color.adjust(#0079d8, $lightness: 15%), fore: $task-status-foreground-color-light, ), not-started: ( base: #cccccc, - dark: darken(#cccccc, 15%), - light: lighten(#cccccc, 15%), + dark: color.adjust(#cccccc, $lightness: -15%), + light: color.adjust(#cccccc, $lightness: 15%), fore: $task-status-foreground-color-dark, ), working-on-it: ( base: #eb8f06, - dark: darken(#eb8f06, 15%), - light: lighten(#eb8f06, 15%), + dark: color.adjust(#eb8f06, $lightness: -15%), + light: color.adjust(#eb8f06, $lightness: 15%), fore: $task-status-foreground-color-light, ), need-help: ( base: #a48fce, - dark: darken(#a48fce, 15%), - light: lighten(#a48fce, 15%), + dark: color.adjust(#a48fce, $lightness: -15%), + light: color.adjust(#a48fce, $lightness: 15%), fore: $task-status-foreground-color-light, ), fix-and-resubmit: ( base: #f2d85c, - dark: darken(#f2d85c, 15%), - light: lighten(#f2d85c, 15%), + dark: color.adjust(#f2d85c, $lightness: -15%), + light: color.adjust(#f2d85c, $lightness: 15%), fore: $task-status-foreground-color-dark, ), feedback-exceeded: ( base: #d46b54, - dark: darken(#d46b54, 15%), - light: lighten(#d46b54, 15%), + dark: color.adjust(#d46b54, $lightness: -15%), + light: color.adjust(#d46b54, $lightness: 15%), fore: $task-status-foreground-color-light, ), redo: ( base: #804000, - dark: darken(#804000, 15%), - light: lighten(#804000, 15%), + dark: color.adjust(#804000, $lightness: -15%), + light: color.adjust(#804000, $lightness: 15%), fore: $task-status-foreground-color-light, ), discuss: ( base: #31b0d5, - dark: darken(#31b0d5, 15%), - light: lighten(#31b0d5, 15%), + dark: color.adjust(#31b0d5, $lightness: -15%), + light: color.adjust(#31b0d5, $lightness: 15%), + fore: $task-status-foreground-color-light, + ), + rediscuss: ( + base: #126352, + dark: color.adjust(#126352, $lightness: -15%), + light: color.adjust(#126352, $lightness: 15%), fore: $task-status-foreground-color-light, ), demonstrate: ( base: #428bca, - dark: darken(#428bca, 15%), - light: lighten(#428bca, 15%), + dark: color.adjust(#428bca, $lightness: -15%), + light: color.adjust(#428bca, $lightness: 15%), fore: $task-status-foreground-color-light, ), complete: ( base: #5bb75b, - dark: darken(#5bb75b, 20%), - light: lighten(#5bb75b, 15%), + dark: color.adjust(#5bb75b, $lightness: -20%), + light: color.adjust(#5bb75b, $lightness: 15%), fore: $task-status-foreground-color-light, ), fail: ( base: #d93713, - dark: darken(#d93713, 15%), - light: lighten(#d93713, 15%), + dark: color.adjust(#d93713, $lightness: -15%), + light: color.adjust(#d93713, $lightness: 15%), fore: $task-status-foreground-color-light, ), time-exceeded: ( base: #d93713, - dark: darken(#d93713, 15%), - light: lighten(#d93713, 15%), + dark: color.adjust(#d93713, $lightness: -15%), + light: color.adjust(#d93713, $lightness: 15%), + fore: $task-status-foreground-color-light, + ), + assess-in-portfolio: ( + base: #f2d85c, + dark: color.adjust(#f2d85c, $lightness: -15%), + light: color.adjust(#f2d85c, $lightness: 15%), + fore: $task-status-foreground-color-dark, + ), + attention-required: ( + base: #f1814d, + dark: color.adjust(#f1814d, $lightness: -15%), + light: color.adjust(#f1814d, $lightness: 15%), fore: $task-status-foreground-color-light, ), ); diff --git a/src/styles/common/text.scss b/src/styles/common/text.scss deleted file mode 100644 index 9b97f6c291..0000000000 --- a/src/styles/common/text.scss +++ /dev/null @@ -1,4 +0,0 @@ -.with-icon { - display: flex; - align-items: center; -} \ No newline at end of file diff --git a/src/styles/common/typeface.scss b/src/styles/common/typeface.scss index d31e2038a5..c45e90c1aa 100644 --- a/src/styles/common/typeface.scss +++ b/src/styles/common/typeface.scss @@ -1,54 +1,75 @@ // TODO: Evaluate when this file can be removed -$roboto-path: './fonts/roboto'; +$roboto-path: '/assets/fonts/roboto'; @font-face { font-family: 'Roboto'; font-style: normal; font-weight: 300; - src: local('Roboto Light'), local('Roboto-Light'), url('#{$roboto-path}/Roboto-Light.woff') format('woff'); + src: + local('Roboto Light'), + local('Roboto-Light'), + url('#{$roboto-path}/Roboto-Light.woff') format('woff'); } @font-face { font-family: 'Roboto'; font-style: normal; font-weight: 400; - src: local('Roboto'), local('Roboto-Regular'), url('#{$roboto-path}/Roboto-Regular.woff') format('woff'); + src: + local('Roboto'), + local('Roboto-Regular'), + url('#{$roboto-path}/Roboto-Regular.woff') format('woff'); } @font-face { font-family: 'Roboto'; font-style: normal; font-weight: 500; - src: local('Roboto Medium'), local('Roboto-Medium'), url('#{$roboto-path}/Roboto-Medium.woff') format('woff'); + src: + local('Roboto Medium'), + local('Roboto-Medium'), + url('#{$roboto-path}/Roboto-Medium.woff') format('woff'); } @font-face { font-family: 'Roboto'; font-style: normal; font-weight: 700; - src: local('Roboto Bold'), local('Roboto-Bold'), url('#{$roboto-path}/Roboto-Bold.woff') format('woff'); + src: + local('Roboto Bold'), + local('Roboto-Bold'), + url('#{$roboto-path}/Roboto-Bold.woff') format('woff'); } @font-face { font-family: 'Roboto'; font-style: italic; font-weight: 400; - src: local('Roboto Italic'), local('Roboto-Italic'), url('#{$roboto-path}/Roboto-Italic.woff') format('woff'); + src: + local('Roboto Italic'), + local('Roboto-Italic'), + url('#{$roboto-path}/Roboto-Italic.woff') format('woff'); } @font-face { font-family: 'Grotesk'; font-style: normal; - src: local('Grotesk'), url(/assets/fonts/grotesk/grotesk-regular.otf) format('opentype'); + src: + local('Grotesk'), + url(/assets/fonts/grotesk/grotesk-regular.otf) format('opentype'); } @font-face { font-family: 'Grotesk'; font-style: italic; - src: local('Grotesk'), url(/assets/fonts/grotesk/grotesk-italic.otf) format('opentype'); + src: + local('Grotesk'), + url(/assets/fonts/grotesk/grotesk-italic.otf) format('opentype'); } @font-face { font-family: 'Grotesk'; font-style: bold; - src: local('Grotesk'), url(/assets/fonts/grotesk/grotesk-bold.otf) format('opentype'); + src: + local('Grotesk'), + url(/assets/fonts/grotesk/grotesk-bold.otf) format('opentype'); } .mat-icon { diff --git a/src/styles/config/font-awesome.scss b/src/styles/config/font-awesome.scss deleted file mode 100644 index 87945b4a94..0000000000 --- a/src/styles/config/font-awesome.scss +++ /dev/null @@ -1,7 +0,0 @@ -// -// This config file provides overrides for bootstrap and is injected before -// font awesome is imported -// - -// Provide the font path to the fa-fonts -$fa-font-path: './fonts/font-awesome'; diff --git a/src/styles/m3-theme.scss b/src/styles/m3-theme.scss new file mode 100644 index 0000000000..1e094b5f2f --- /dev/null +++ b/src/styles/m3-theme.scss @@ -0,0 +1,177 @@ +// This file was generated by running 'ng generate @angular/material:m3-theme'. +// Proceed with caution if making changes to this file. + +@use 'sass:map'; +@use '@angular/material' as mat; + +// Note: Color palettes are generated from primary: #3939FF +$_palettes: ( + primary: ( + 0: #000000, + 10: #05006d, + 20: #33314e, + 25: #1100cb, + 30: #1702eb, + 35: #2a26f5, + 40: #3b3bff, + 50: #5e62ff, + 60: #7f84ff, + 70: #9fa3ff, + 80: #c0c1ff, + 90: #e1e0ff, + 95: #f1efff, + 98: #fcf8ff, + 99: #fffbff, + 100: #ffffff, + ), + secondary: ( + 0: #000000, + 10: #191a2c, + 20: #2e2f42, + 25: #393a4d, + 30: #454559, + 35: #505165, + 40: #5d5d72, + 50: #75758b, + 60: #8f8fa5, + 70: #aaa9c1, + 80: #c5c4dd, + 90: #e2e0f9, + 95: #f1efff, + 98: #fcf8ff, + 99: #fffbff, + 100: #ffffff, + ), + tertiary: ( + 0: #000000, + 10: #2e1125, + 20: #46263b, + 25: #523146, + 30: #5f3c52, + 35: #6c475e, + 40: #79536a, + 50: #936b83, + 60: #af849d, + 70: #cb9eb8, + 80: #e8b9d4, + 90: #ffd8ed, + 95: #ffecf4, + 98: #fff8f9, + 99: #fffbff, + 100: #ffffff, + ), + neutral: ( + 0: #000000, + 10: #1c1b1f, + 20: #313034, + 25: #3c3b3f, + 30: #47464a, + 35: #535256, + 40: #5f5e62, + 50: #78767a, + 60: #929094, + 70: #adaaaf, + 80: #c8c5ca, + 90: #e5e1e6, + 95: #f3eff4, + 98: #fcf8fd, + 99: #fffbff, + 100: #ffffff, + 4: #0e0e11, + 6: #131316, + 12: #201f23, + 17: #2a292d, + 22: #353438, + 24: #39393c, + 87: #dcd9de, + 92: #eae7ec, + 94: #f0edf1, + 96: #f6f2f7, + ), + neutral-variant: ( + 0: #000000, + 10: #1b1b23, + 20: #303038, + 25: #3b3b43, + 30: #46464f, + 35: #52515b, + 40: #5e5d67, + 50: #777680, + 60: #918f9a, + 70: #acaab4, + 80: #c7c5d0, + 90: #e4e1ec, + 95: #f2effa, + 98: #fcf8ff, + 99: #fffbff, + 100: #ffffff, + ), + error: ( + 0: #000000, + 10: #410002, + 20: #690005, + 25: #7e0007, + 30: #93000a, + 35: #a80710, + 40: #ba1a1a, + 50: #de3730, + 60: #ff5449, + 70: #ff897d, + 80: #ffb4ab, + 90: #ffdad6, + 95: #ffedea, + 98: #fff8f7, + 99: #fffbff, + 100: #ffffff, + ), +); + +$_rest: ( + secondary: map.get($_palettes, secondary), + neutral: map.get($_palettes, neutral), + neutral-variant: map.get($_palettes, neutral-variant), + error: map.get($_palettes, error), +); +$_primary: map.merge(map.get($_palettes, primary), $_rest); +$_tertiary: map.merge(map.get($_palettes, tertiary), $_rest); + +$light-theme: mat.define-theme( + ( + color: ( + theme-type: light, + primary: $_primary, + tertiary: $_tertiary, + use-system-variables: true, + system-variables-prefix: sys, + ), + typography: ( + use-system-variables: true, + system-variables-prefix: sys, + plain-family: "Inter, 'open-sans', 'Roboto'", + brand-family: "'Grotesk'", + bold-weight: 900, + medium-weight: 500, + regular-weight: 300, + ), + ) +); +$dark-theme: mat.define-theme( + ( + color: ( + theme-type: dark, + primary: $_primary, + tertiary: $_tertiary, + use-system-variables: true, + system-variables-prefix: sys, + ), + typography: ( + use-system-variables: true, + system-variables-prefix: sys, + plain-family: "Inter, 'open-sans', 'Roboto'", + brand-family: "'Grotesk'", + bold-weight: 900, + medium-weight: 500, + regular-weight: 300, + ), + ) +); diff --git a/src/styles/mixins/animations/fade-in.scss b/src/styles/mixins/animations/fade-in.scss deleted file mode 100644 index 9bf65d7f79..0000000000 --- a/src/styles/mixins/animations/fade-in.scss +++ /dev/null @@ -1,10 +0,0 @@ -// -// A fade in animation -// -@keyframes animation-fade-in { - from { opacity: 0; } - to { opacity: 1; } -} -@mixin animation-fade-in($seconds) { - animation: animation-fade-in $seconds; -} diff --git a/src/styles/mixins/animations/grow.scss b/src/styles/mixins/animations/grow.scss deleted file mode 100644 index 73222c87c6..0000000000 --- a/src/styles/mixins/animations/grow.scss +++ /dev/null @@ -1,28 +0,0 @@ -// -// Grow animation from 0 scale to 1 scale -// -@keyframes animation-grow { - 0% { - opacity: 0; - transform: scale(.3); - } - - 50% { - opacity: 1; - transform: scale(1.05); - } - - 70% { - transform: scale(.9); - } - - 100% { - transform: scale(1); - } -} - -@mixin animation-grow($time: 0.5s) { - animation-name: animation-grow; - animation-duration: $time; - animation-fill-mode: both; -} diff --git a/src/styles/mixins/animations/slide-down.scss b/src/styles/mixins/animations/slide-down.scss deleted file mode 100644 index cf741fc82b..0000000000 --- a/src/styles/mixins/animations/slide-down.scss +++ /dev/null @@ -1,10 +0,0 @@ -// -// Slide animation from -2em to initial top -// -@keyframes animation-slide-down { - from { margin-top: -2em; } - to { margin-top: inherit; } -} -@mixin animation-slide-down($seconds) { - animation: animation-slide-down $seconds; -} diff --git a/src/styles/mixins/animations/wobble.scss b/src/styles/mixins/animations/wobble.scss deleted file mode 100644 index 89f12fa8f3..0000000000 --- a/src/styles/mixins/animations/wobble.scss +++ /dev/null @@ -1,32 +0,0 @@ -// -// Animation to wobble an element side to side -// -@keyframes animation-wobble { - 0%, 100% { transform: translateX(+5px); } - 25%, 75% { transform: translateX(0); } - 50% { transform: translateX(-5px); } -} - -@mixin animation-wobble { - animation-duration: 1s; - animation-fill-mode: both; - animation-timing-function: ease-in-out; - animation-iteration-count:infinite; - animation-name: animation-wobble; -} - -// -// Animation to wobble an element side but only once -// -@keyframes animation-wobble-once { - 0%, 100% { transform: translateX(0); } - 25% { transform: translateX(+5px); } - 75% { transform: translateX(-5px); } -} - -@mixin animation-wobble-once { - animation-duration: 0.15s; - animation-fill-mode: both; - animation-iteration-count: 2; - animation-name: animation-wobble-once; -} diff --git a/src/styles/mixins/callout.scss b/src/styles/mixins/callout.scss deleted file mode 100644 index dd7689f76e..0000000000 --- a/src/styles/mixins/callout.scss +++ /dev/null @@ -1,20 +0,0 @@ -// -// A callout -// -@mixin callout($color, $bgcolor: lighten($color, 35%)) { - display: block; - margin: 20px 0; - padding: 15px 30px 15px 15px; - border-left: 5px solid $color; - background-color: $bgcolor; - h1, h2, h3, h4, h5, h6 { - margin-top: 0; - color: $color; - } - p:last-child { - margin-bottom: 0; - } - code, .highlight { - background-color: #fff; - } -} diff --git a/src/styles/mixins/dropdown-selector.scss b/src/styles/mixins/dropdown-selector.scss deleted file mode 100644 index 01036b0cca..0000000000 --- a/src/styles/mixins/dropdown-selector.scss +++ /dev/null @@ -1,49 +0,0 @@ -@mixin dropdown-selector { - height: 100%; - padding-left: 0px; - padding-right: 0px; - // first button - & > button { - height: 100%; - width: 100%; - label, i { - text-align: center; - } - label { - font-size: 1.5em; - margin: 0px; - } - label.label.label-info { - font-size: 1em; - margin-left: 0.5ex; - cursor: pointer; - } - // not a split button group - span.caret { - margin-left: 1ex; - } - } - // open button - &.open > button { - border-bottom-left-radius: 0px; - border-bottom-right-radius: 0px; - } - // menu underneath - .dropdown-menu { - margin-top: 0px; - border-top-left-radius: 0px; - border-top-right-radius: 0px; - @media (max-width: $screen-sm) { - position: relative; - bottom: 0; - left: 0; - right: 0; - top: 0; - width: 100%; - } - } -} - -.dropdown-selector { - @include dropdown-selector; -} diff --git a/src/styles/mixins/flex-center.scss b/src/styles/mixins/flex-center.scss deleted file mode 100644 index e741a95c92..0000000000 --- a/src/styles/mixins/flex-center.scss +++ /dev/null @@ -1,11 +0,0 @@ -// -// Uses flex to align the div contents in the center -// -@mixin flex-center { - display: flex; - flex-wrap: wrap; - align-items: center; - align-content: center; - justify-content: center; - height: 100%; -} diff --git a/src/styles/mixins/flex-panel.scss b/src/styles/mixins/flex-panel.scss deleted file mode 100644 index a74cd163fd..0000000000 --- a/src/styles/mixins/flex-panel.scss +++ /dev/null @@ -1,8 +0,0 @@ -@mixin flex-panel { - display: flex; - flex-wrap: wrap; - flex-direction: column; - .panel-body { - flex: 1; - } -} diff --git a/src/styles/mixins/large-notice-block.scss b/src/styles/mixins/large-notice-block.scss deleted file mode 100644 index 7f138f6551..0000000000 --- a/src/styles/mixins/large-notice-block.scss +++ /dev/null @@ -1,22 +0,0 @@ -// -// A large notice block is a large text with an icon -// -@mixin large-notice-block { - @include flex-center; - font-size: 2em; - text-align: center; - font-weight: 300; - & > * { - display: block; - width: 100%; - margin-top: 0.75em; - margin-bottom: 0.75em; - } - &.panel-body { - padding: 30px; - } -} - -.large-notice-block { - @include large-notice-block; -} diff --git a/src/styles/mixins/logo-font-rendering.scss b/src/styles/mixins/logo-font-rendering.scss deleted file mode 100644 index 68624bc823..0000000000 --- a/src/styles/mixins/logo-font-rendering.scss +++ /dev/null @@ -1,9 +0,0 @@ -// -// Font rendering for the DF logo -// - -@mixin logo-font-rendering { - -webkit-font-smoothing: antialiased; - letter-spacing: -1px; - font-weight: 300; -} diff --git a/src/styles/mixins/no-select.scss b/src/styles/mixins/no-select.scss deleted file mode 100644 index cb2ed50433..0000000000 --- a/src/styles/mixins/no-select.scss +++ /dev/null @@ -1,4 +0,0 @@ -@mixin no-select { - user-select: none; - cursor: default; -} diff --git a/src/styles/mixins/remove-list-padding.scss b/src/styles/mixins/remove-list-padding.scss deleted file mode 100644 index 1b5a1b0690..0000000000 --- a/src/styles/mixins/remove-list-padding.scss +++ /dev/null @@ -1,4 +0,0 @@ -@mixin remove-list-padding { - -webkit-padding-start: 0; - padding-left: 0; -} diff --git a/src/styles/mixins/task-list.scss b/src/styles/mixins/task-list.scss index 7709d87fea..f89e866b82 100644 --- a/src/styles/mixins/task-list.scss +++ b/src/styles/mixins/task-list.scss @@ -1,5 +1,6 @@ -@import './task-status-colors-generator.scss'; -@import '../common/variables.scss'; +@use 'sass:color'; +@use 'styles/mixins/task-status-colors-generator' as *; +@use 'styles/common/variables' as *; @mixin custom-box-shadow($color) { box-shadow: -15px 0 $color inset; @@ -43,40 +44,60 @@ text-decoration: none; // background-color: $list-group-hover-bg; &.ready-for-feedback { - @include custom-box-shadow(lighten(task-status-color('ready-for-feedback'), 15%)); + @include custom-box-shadow( + color.adjust(task-status-color('ready-for-feedback'), $lightness: 15%) + ); } &.not-started { - @include custom-box-shadow(lighten(task-status-color('not-started'), 5%)); + @include custom-box-shadow(color.adjust(task-status-color('not-started'), $lightness: 5%)); } &.working-on-it { - @include custom-box-shadow(lighten(task-status-color('working-on-it'), 15%)); + @include custom-box-shadow( + color.adjust(task-status-color('working-on-it'), $lightness: 15%) + ); } &.need-help { - @include custom-box-shadow(lighten(task-status-color('need-help'), 15%)); + @include custom-box-shadow(color.adjust(task-status-color('need-help'), $lightness: 15%)); } &.fix-and-resubmit { - @include custom-box-shadow(lighten(task-status-color('fix-and-resubmit'), 15%)); + @include custom-box-shadow( + color.adjust(task-status-color('fix-and-resubmit'), $lightness: 15%) + ); } &.feedback-exceeded { - @include custom-box-shadow(lighten(task-status-color('feedback-exceeded'), 15%)); + @include custom-box-shadow( + color.adjust(task-status-color('feedback-exceeded'), $lightness: 15%) + ); } &.fail { - @include custom-box-shadow(lighten(task-status-color('fail'), 15%)); + @include custom-box-shadow(color.adjust(task-status-color('fail'), $lightness: 15%)); } &.redo { - @include custom-box-shadow(lighten(task-status-color('redo'), 15%)); + @include custom-box-shadow(color.adjust(task-status-color('redo'), $lightness: 15%)); } &.discuss { - @include custom-box-shadow(lighten(task-status-color('discuss'), 15%)); + @include custom-box-shadow(color.adjust(task-status-color('discuss'), $lightness: 15%)); } &.demonstrate { - @include custom-box-shadow(lighten(task-status-color('demonstrate'), 15%)); + @include custom-box-shadow(color.adjust(task-status-color('demonstrate'), $lightness: 15%)); } &.complete { - @include custom-box-shadow(lighten(task-status-color('complete'), 15%)); + @include custom-box-shadow(color.adjust(task-status-color('complete'), $lightness: 15%)); } &.time-exceeded { - @include custom-box-shadow(lighten(task-status-color('time-exceeded'), 15%)); + @include custom-box-shadow( + color.adjust(task-status-color('time-exceeded'), $lightness: 15%) + ); + } + &.assess-in-portfolio { + @include custom-box-shadow( + color.adjust(task-status-color('assess-in-portfolio'), $lightness: 15%) + ); + } + &.attention-required { + @include custom-box-shadow( + color.adjust(task-status-color('attention-required'), $lightness: 15%) + ); } } &.selected { @@ -116,6 +137,12 @@ &.time-exceeded { @include custom-box-shadow(task-status-color('time-exceeded')); } + &.assess-in-portfolio { + @include custom-box-shadow(task-status-color('assess-in-portfolio')); + } + &.attention-required { + @include custom-box-shadow(task-status-color('attention-required')); + } } .task-badges { width: 50px; @@ -142,6 +169,13 @@ & + .task-subscript-badge { margin-right: -3px; } + .material-icons, + mat-icon { + font-size: 12px; + height: 12px; + line-height: 12px; + width: 12px; + } &.grade-badge { background-color: #000000; font-size: 9px; @@ -151,7 +185,7 @@ font-size: 9px; } &.soon-badge { - background-color: lighten(#eb8f06, 15%); + background-color: color.adjust(#eb8f06, $lightness: 15%); color: black; } &.due-badge { diff --git a/src/styles/mixins/task-status-colors-generator.scss b/src/styles/mixins/task-status-colors-generator.scss index e37542874a..b351aca6c6 100644 --- a/src/styles/mixins/task-status-colors-generator.scss +++ b/src/styles/mixins/task-status-colors-generator.scss @@ -1,4 +1,5 @@ -@import '../common/task-status-colors.scss'; +@use 'sass:map'; +@use 'styles/common/task-status-colors' as *; // // Mixins to use that helps color task statuses @@ -7,16 +8,16 @@ @function task-status-color($status, $variant: null) { // If variant isn't specified, we get the base by default @if $variant == null { - @return map-get(map-get($task-status-colors, $status), base); + @return map.get(map.get($task-status-colors, $status), base); } @else { - @return map-get(map-get($task-status-colors, $status), $variant); + @return map.get(map.get($task-status-colors, $status), $variant); } } @mixin task-status-color($status) { - color: task-status-color($status, fore); + color: task-status-color($status, fore) !important; $base-color: task-status-color($status); - background-color: $base-color; + background-color: $base-color !important; @if $status == 'complete' { $lighter-color: task-status-color($status, light); @include gradient-vertical($lighter-color, $base-color); @@ -47,6 +48,9 @@ @mixin task-status-color-discuss { @include task-status-color('discuss'); } +@mixin task-status-color-rediscuss { + @include task-status-color('rediscuss'); +} @mixin task-status-color-demonstrate { @include task-status-color('demonstrate'); } @@ -59,3 +63,9 @@ @mixin task-status-color-time-exceeded { @include task-status-color('time-exceeded'); } +@mixin task-status-color-assess-in-portfolio { + @include task-status-color('assess-in-portfolio'); +} +@mixin task-status-color-attention-required { + @include task-status-color('attention-required'); +} diff --git a/src/styles/modules/callout.scss b/src/styles/modules/callout.scss deleted file mode 100644 index 6e4ba5f663..0000000000 --- a/src/styles/modules/callout.scss +++ /dev/null @@ -1,22 +0,0 @@ -// -// Callouts used for standout information -// -.callout-primary { - @include callout($brand-primary, lighten($brand-primary, 45%)); -} - -.callout-danger { - @include callout($brand-danger, lighten($brand-danger, 30%)); -} - -.callout-warning { - @include callout($brand-warning, lighten($brand-warning, 30%)); -} - -.callout-info { - @include callout($brand-info, lighten($brand-info, 30%)); -} - -.callout-success { - @include callout($brand-success, lighten($brand-success, 30%)); -} diff --git a/src/styles/modules/cards.scss b/src/styles/modules/cards.scss deleted file mode 100644 index c6b07da5a9..0000000000 --- a/src/styles/modules/cards.scss +++ /dev/null @@ -1,67 +0,0 @@ -.card { - $card-height-lg: 500px; - $card-height-md: $card-height-lg / 2; - $card-height-sm: $card-height-md / 2; - $card-body-padding: $panel-body-padding; - $card-footer-padding: $card-body-padding + 5px; - $card-header-padding: $card-body-padding $card-footer-padding; - - margin-bottom: $card-footer-padding; - background-color: darken(#fff, 0.75%); - transition: box-shadow .25s; - box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), - 0 1px 5px 0 rgba(0, 0, 0, 0.12), - 0 3px 1px -2px rgba(0, 0, 0, 0.2); - - &.card-default > .card-heading { background-color: $panel-default-heading-bg; } - &.card-primary > .card-heading { background-color: $brand-primary; } - &.card-danger > .card-heading { background-color: $brand-danger; } - &.card-success > .card-heading { background-color: $brand-success; } - &.card-warning > .card-heading { background-color: $brand-warning; } - &.card-info > .card-heading { background-color: $brand-info; } - &.card-danger, &.card-success, &.card-warning, &.card-info, &.card-primary { - .card-heading { - color: #fff; - .text-muted { - color: darken(#fff, 1%); - } - } - } - .card-heading { - padding: $card-header-padding; - border-bottom: 1px solid rgba(160, 160, 160, 0.2); - } - .card-body { - padding: $card-body-padding; - } - .card-footer { - position: relative; - background-color: inherit; - padding: $card-footer-padding; - border-top: 1px solid rgba(160, 160, 160, 0.2); - } - - &.card-lg { - min-height: $card-height-lg; - } - &.card-md { - min-height: $card-height-md; - } - &.card-sm { - min-height: $card-height-sm; - } - - & > .list-group { - margin-bottom: 0; - .list-group-item { - border-width: 1px 0; - border-radius: 0; - &:first-child { - border-top-width: 0; - } - &:last-child { - border-bottom-width: 0; - } - } - } -} diff --git a/src/styles/modules/doubtfire-logo.scss b/src/styles/modules/doubtfire-logo.scss deleted file mode 100644 index 395918c8e8..0000000000 --- a/src/styles/modules/doubtfire-logo.scss +++ /dev/null @@ -1,33 +0,0 @@ -// -// Styles for the Doubtfire Logo -// - -h1.logo { - @include logo-font-rendering; - font-size: 4em; -} -i.logo { - display: inline-block; - width: 1em; - height: 1em; - background-image: url('/assets/images/logo.svg'); - background-size: 100%; - background-repeat: no-repeat; - background-position: center; -} - -.welcome-to-doubtfire { - margin-top: 6em; - margin-bottom: 3em; - h1, p { - @include logo-font-rendering; - font-family: "Grotesk"; - } - p.lead { - color: #000; - } - i.logo { - width: 10em; - height: 10em; - } -} diff --git a/src/styles/modules/drilldown-visualiser.scss b/src/styles/modules/drilldown-visualiser.scss deleted file mode 100644 index 5f382b2719..0000000000 --- a/src/styles/modules/drilldown-visualiser.scss +++ /dev/null @@ -1,22 +0,0 @@ -// -// A drilldown for visualisations to click into graphs and see more -// about those graphs -// -.drilldown-visualiser { - .overview-chart { - cursor: pointer; - } - .overview-charts .overview-chart { - height: 250px; - cursor: pointer; - .no-data-to-display { - height: 60%; - text-align: center; - padding-top: 30%; - border: 1px dashed #ddd; - color: #ddd; - border-radius: 1em; - margin: 10%; - } - } -} diff --git a/src/styles/modules/panel-fullscreen.scss b/src/styles/modules/panel-fullscreen.scss deleted file mode 100644 index d9abc44ad3..0000000000 --- a/src/styles/modules/panel-fullscreen.scss +++ /dev/null @@ -1,61 +0,0 @@ -// -// Fullscreen panel mode styles -// -.panel.fullscreen { - border-radius: 0; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - margin: 0; - z-index: 4; - border-bottom: none; - - // Flex stuff - display: flex; - flex-direction: column; - - .panel-heading { - border-radius: 0; - } - - .panel-body.student-list { - height: 20vh; - resize: vertical; - max-height: 40vh; - overflow: scroll; - border-bottom: 2px solid #428bca; - box-shadow: 1px 1px 3px; - } - .panel-body.project-show { - overflow-y: scroll; - flex: 1; - position: relative; - - &.no-padding { - padding: 0px; - } - - .panel, .panel-heading { - border-radius: 0; - } - - .nothing-selected { - @include large-notice-block; - position: absolute; - bottom: 0; - top: 0; - left: 0; - right: 0; - font-size: 3em; - text-align: center; - font-weight: 300; - i, p { - display: block; - width: 100%; - margin-bottom: 0.75em; - } - } - } -} diff --git a/src/styles/modules/project-task-bar.scss b/src/styles/modules/project-task-bar.scss deleted file mode 100644 index 78d15e07f6..0000000000 --- a/src/styles/modules/project-task-bar.scss +++ /dev/null @@ -1,96 +0,0 @@ -// -// Student's progress bar styling -// -.task-progress { - tr { - vertical-align: middle; - } - - margin-bottom: 0px; -} - -.progress-bar { - span { - font-size: 8pt; - } - - &[aria-valuenow='1'], - &[aria-valuenow='2'] { - min-width: 0px; - } - - &[aria-valuenow='0'] { - min-width: 0px; - } -} - -.task-progress-bar { - min-width: 128px; - width: 360px; - - .panel-group { - margin-bottom: 0px; - } -} - -.task-progress-accordion, -.progress-progress-bar { - .panel-heading { - padding-top: 5px; - padding-bottom: 5px; - padding-left: 10px; - padding-right: 10px; - } -} - -.progress-bar-ready-for-feedback { - @include task-status-color-ready-for-feedback; -} -.progress-bar-not-started { - @include task-status-color-not-started; -} -.progress-bar-working-on-it { - @include task-status-color-working-on-it; -} -.progress-bar-need-help { - @include task-status-color-need-help; -} -.progress-bar-fix-and-resubmit { - @include task-status-color-fix-and-resubmit; -} -.progress-bar-feedback-exceeded { - @include task-status-color-feedback-exceeded; -} -.progress-bar-fail { - @include task-status-color-fail; -} -.progress-bar-redo { - @include task-status-color-redo; -} -.progress-bar-discuss { - @include task-status-color-discuss; -} -.progress-bar-demonstrate { - @include task-status-color-demonstrate; -} -.progress-bar-complete { - @include task-status-color-complete; -} - -.progress-bar-not-started { - @include task-status-color-not-started; -} -.progress-bar-one-week-late { - @include task-status-color-working-on-it; -} -.progress-bar-two-weeks-late { - @include task-status-color-fail; -} -.progress-bar-on-time { - @include task-status-color-complete; -} - -.progress-progress-bar { - height: 30px; - margin-bottom: 0px; -} diff --git a/src/styles/modules/rationale-wrapper.scss b/src/styles/modules/rationale-wrapper.scss deleted file mode 100644 index 08693f7810..0000000000 --- a/src/styles/modules/rationale-wrapper.scss +++ /dev/null @@ -1,30 +0,0 @@ -// -// Text editor for rationale-providers -// -.rationale-wrapper { - display: table; - margin-top: 2em; -} -.rationale { - display: table-cell; - height: 200px; - overflow: scroll; - padding: 30px; - border-radius: 6px; - font-size: 1.5em; - cursor: pointer; - label { - font-size: 0.75em; - text-transform: uppercase; - } - &.no-rationale { - border: 1px dashed #aaa; - vertical-align: middle; - text-align: center; - color: #aaa; - } - &:hover { - border-color: #428bca; - color: #428bca; - } -} diff --git a/src/styles/modules/tabset-icon.scss b/src/styles/modules/tabset-icon.scss deleted file mode 100644 index a1f721008d..0000000000 --- a/src/styles/modules/tabset-icon.scss +++ /dev/null @@ -1,18 +0,0 @@ -// -// Styling for tab headers with icons in them -// -.tabset-icon tab-heading { - text-align: center; - i { - display: block; - width: 100%; - padding-bottom: 5px; - } - i:not(:last-child) { - margin-right: 1ex; - } - i:not(:last-child), i + i:last-child { - display: inline-block; - width: inherit; - } -} diff --git a/src/styles/modules/task-status.scss b/src/styles/modules/task-status.scss deleted file mode 100644 index 74d5963f48..0000000000 --- a/src/styles/modules/task-status.scss +++ /dev/null @@ -1,183 +0,0 @@ -// -// Styling for all task status classes -// -.task-status { - &.ready-for-feedback { - @include task-status-color-ready-for-feedback; - } - &.not-started { - @include task-status-color-not-started; - } - &.working-on-it { - @include task-status-color-working-on-it; - } - &.need-help { - @include task-status-color-need-help; - } - &.fix-and-resubmit { - @include task-status-color-fix-and-resubmit; - } - &.feedback-exceeded { - @include task-status-color-feedback-exceeded; - } - &.fail { - @include task-status-color-fail; - } - &.redo { - @include task-status-color-redo; - } - &.discuss { - @include task-status-color-discuss; - } - &.demonstrate { - @include task-status-color-demonstrate; - } - &.complete { - @include task-status-color-complete; - } - &.time-exceeded { - @include task-status-color-time-exceeded; - } - - &.ready-for-feedback:hover { - background-color: task-status-color('ready-for-feedback'); - } - &.not-started:hover { - background-color: task-status-color('not-started'); - } - &.working-on-it:hover { - background-color: task-status-color('working-on-it'); - } - &.need-help:hover { - background-color: task-status-color('need-help'); - } - &.fix-and-resubmit:hover { - background-color: task-status-color('fix-and-resubmit'); - } - &.feedback-exceeded:hover { - background-color: task-status-color('feedback-exceeded'); - } - &.fail:hover { - background-color: task-status-color('fail'); - } - &.redo:hover { - background-color: task-status-color('redo'); - } - &.discuss:hover { - background-color: task-status-color('discuss'); - } - &.demonstrate:hover { - background-color: task-status-color('demonstrate'); - } - &.complete:hover { - background-color: task-status-color('complete'); - } - &.time-exceeded:hover { - background-color: task-status-color('time-exceeded'); - } -} - -.task-status > .btn-default { - &.ready-for-feedback, - &.ready-for-feedback.active, - &.ready-for-feedback:hover { - @include task-status-color-ready-for-feedback; - } - &.not-started, - &.not-started.active, - &.not-started:hover { - @include task-status-color-not-started; - } - &.working-on-it, - &.working-on-it.active, - &.working-on-it:hover { - @include task-status-color-working-on-it; - } - &.need-help, - &.need-help.active, - &.need-help:hover { - @include task-status-color-need-help; - } - &.fix-and-resubmit, - &.fix-and-resubmit.active, - &.fix-and-resubmit:hover { - @include task-status-color-fix-and-resubmit; - } - &.feedback-exceeded, - &.feedback-exceeded.active, - &.feedback-exceeded:hover { - @include task-status-color-feedback-exceeded; - } - &.fail, - &.fail.active, - &.fail:hover { - @include task-status-color-fail; - } - &.redo, - &.redo.active, - &.redo:hover { - @include task-status-color-redo; - } - &.discuss, - &.discuss.active, - &.discuss:hover { - @include task-status-color-discuss; - } - &.demonstrate, - &.demonstrate.active, - &.demonstrate:hover { - @include task-status-color-demonstrate; - } - &.complete, - &.complete.active, - &.complete:hover { - @include task-status-color-complete; - } - &.time-exceeded, - &.time-exceeded.active, - &.time-exceeded:hover { - @include task-status-color-time-exceeded; - } - - &.ready-for-feedback { - background-color: lighten(task-status-color('ready-for-feedback'), 15%); - } - &.not-started { - background-color: lighten(task-status-color('not-started'), 15%); - } - &.working-on-it { - background-color: lighten(task-status-color('working-on-it'), 15%); - } - &.need-help { - background-color: lighten(task-status-color('need-help'), 10%); - } - &.fix-and-resubmit { - background-color: lighten(task-status-color('fix-and-resubmit'), 15%); - } - &.feedback-exceeded { - background-color: lighten(task-status-color('feedback-exceeded'), 15%); - } - &.fail { - background-color: lighten(task-status-color('fail'), 15%); - } - &.redo { - background-color: lighten(task-status-color('redo'), 15%); - } - &.discuss { - background-color: lighten(task-status-color('discuss'), 15%); - } - &.demonstrate { - background-color: lighten(task-status-color('demonstrate'), 15%); - } - &.complete { - background-color: lighten(task-status-color('complete'), 15%); - } - &.time-exceeded { - background-color: lighten(task-status-color('time-exceeded'), 15%); - } -} - -i.task-status-icon { - display: block; - margin-bottom: 7px; -} diff --git a/src/styles/vendor-config/bootstrap.scss b/src/styles/vendor-config/bootstrap.scss deleted file mode 100644 index e086df4bd0..0000000000 --- a/src/styles/vendor-config/bootstrap.scss +++ /dev/null @@ -1,13 +0,0 @@ -// -// This config file provides overrides for bootstrap and is injected before -// bootstrap is imported -// - -// Provide the font path to the glyphicons -$icon-font-path: './fonts/bootstrap/'; - -// (Re)definition of screen sizes -$screen-xl: 1900px !default; -$screen-xl-min: $screen-xl !default; -$screen-lg: 1400px !default; -$screen-lg-max: ($screen-xl-min - 1) !default; diff --git a/src/styles/vendor-config/font-awesome.scss b/src/styles/vendor-config/font-awesome.scss deleted file mode 100644 index e073ddedf2..0000000000 --- a/src/styles/vendor-config/font-awesome.scss +++ /dev/null @@ -1,7 +0,0 @@ -// -// This config file provides overrides for bootstrap and is injected before -// font awesome is imported -// - -// Provide the font path to the glyphicons -$fa-font-path: './fonts/font-awesome'; diff --git a/src/tailwind-intellisense.css b/src/tailwind-intellisense.css new file mode 100644 index 0000000000..911f6e88a7 --- /dev/null +++ b/src/tailwind-intellisense.css @@ -0,0 +1,2 @@ +@import 'tailwindcss'; +@config '../tailwind.config.js'; diff --git a/src/test.ts b/src/test.ts deleted file mode 100644 index 280230b87b..0000000000 --- a/src/test.ts +++ /dev/null @@ -1,23 +0,0 @@ -// This file is required by karma.conf.js and loads recursively all the .spec and framework files - -import 'zone.js/dist/long-stack-trace-zone'; -import 'zone.js/dist/proxy.js'; -import 'zone.js/dist/sync-test'; -import 'zone.js/dist/jasmine-patch'; -import 'zone.js/dist/async-test'; -import 'zone.js/dist/fake-async-test'; -import { getTestBed } from '@angular/core/testing'; -import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; - -// Unfortunately there's no typing for the `__karma__` variable. Just declare it as any. -declare const __karma__: any; - -// Prevent Karma from running prematurely. -__karma__.loaded = () => {}; - -// First, initialize the Angular testing environment. -getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), { - teardown: { destroyAfterEach: false } -}); -// Finally, start Karma to run the tests. -__karma__.start(); diff --git a/src/theme.scss b/src/theme.scss index 047760452b..e3bd33aba1 100644 --- a/src/theme.scss +++ b/src/theme.scss @@ -1,7 +1,8 @@ @use 'sass:map'; @use '@angular/material' as mat; -@include mat.core(); +@include mat.elevation-classes(); +@include mat.app-background(); /* For use in src/lib/core/theming/_palette.scss */ $md-formatif: ( @@ -106,25 +107,43 @@ $md-formatif-warn: ( $font-family-primary: "'Grotesk'"; $font-family-accent: "Inter, 'open-sans', 'Roboto'"; -$mat-typography-primary-config: mat.define-typography-config( - $headline-1: mat.define-typography-level(112px, 112px, 300, $font-family-primary, $letter-spacing: -0.05em), - $headline-2: mat.define-typography-level(56px, 56px, 400, $font-family-primary, $letter-spacing: -0.02em), - $headline-3: mat.define-typography-level(45px, 48px, 400, $font-family-primary, $letter-spacing: -0.005em), - $headline-4: mat.define-typography-level(34px, 40px, 400, $font-family-primary), - $headline-5: mat.define-typography-level(24px, 32px, 400, $font-family-primary), - $headline-6: mat.define-typography-level(20px, 32px, 500, $font-family-primary), - $subtitle-1: mat.define-typography-level(16px, 28px, 400, $font-family-primary), - $body-1: mat.define-typography-level(15px, 24px, 400, $font-family-accent), - $subtitle-2: mat.define-typography-level(14px, 24px, 500, $font-family-accent), - $body-2: mat.define-typography-level(14px, 20px, 400, $font-family-accent), - $caption: mat.define-typography-level(12px, 20px, 400, $font-family-accent), - $button: mat.define-typography-level(14px, 14px, 500, $font-family-accent), +$mat-typography-primary-config: mat.m2-define-typography-config( + $headline-1: mat.m2-define-typography-level( + 112px, + 112px, + 300, + $font-family-primary, + $letter-spacing: -0.05em + ), + $headline-2: mat.m2-define-typography-level( + 56px, + 56px, + 400, + $font-family-primary, + $letter-spacing: -0.02em + ), + $headline-3: mat.m2-define-typography-level( + 45px, + 48px, + 400, + $font-family-primary, + $letter-spacing: -0.005em + ), + $headline-4: mat.m2-define-typography-level(34px, 40px, 400, $font-family-primary), + $headline-5: mat.m2-define-typography-level(24px, 32px, 400, $font-family-primary), + $headline-6: mat.m2-define-typography-level(20px, 32px, 500, $font-family-primary), + $subtitle-1: mat.m2-define-typography-level(16px, 28px, 400, $font-family-primary), + $body-1: mat.m2-define-typography-level(15px, 24px, 400, $font-family-accent), + $subtitle-2: mat.m2-define-typography-level(14px, 24px, 500, $font-family-accent), + $body-2: mat.m2-define-typography-level(14px, 20px, 400, $font-family-accent), + $caption: mat.m2-define-typography-level(12px, 20px, 400, $font-family-accent), + $button: mat.m2-define-typography-level(14px, 14px, 500, $font-family-accent), ); -$primary: mat.define-palette($md-formatif); -$accent: mat.define-palette($md-formatif-accent); -$warn: mat.define-palette($md-formatif-warn); +$primary: mat.m2-define-palette($md-formatif); +$accent: mat.m2-define-palette($md-formatif-accent); +$warn: mat.m2-define-palette($md-formatif-warn); -$theme: mat.define-light-theme( +$theme: mat.m2-define-light-theme( ( color: ( primary: $primary, @@ -135,9 +154,9 @@ $theme: mat.define-light-theme( ) ); -$color-config: mat.get-color-config($theme); +$color-config: mat.m2-get-color-config($theme); $primary-palette: map.get($color-config, 'primary'); -$primary-color: mat.get-color-from-palette($primary-palette, 500); +$primary-color: mat.m2-get-color-from-palette($primary-palette, 500); $accent-palette: map.get($color-config, 'accent'); $warn-palette: map.get($color-config, 'warn'); $is-dark-theme: map.get($color-config, 'is-dark'); diff --git a/src/tsconfig.app.json b/src/tsconfig.app.json index 4e165072c4..3eb37000e7 100644 --- a/src/tsconfig.app.json +++ b/src/tsconfig.app.json @@ -5,5 +5,13 @@ "types": [] }, "files": ["main.ts", "polyfills.ts"], - "include": ["src/**/*.d.ts", "src/**/*.ts"] + "include": ["src/**/*.d.ts", "src/**/*.ts"], + "angularCompilerOptions": { + "extendedDiagnostics": { + "checks": { + "nullishCoalescingNotNullable": "suppress", + "optionalChainNotNullable": "suppress" + } + } + } } diff --git a/src/tsconfig.spec.json b/src/tsconfig.spec.json index aabc7e3d73..8d291fc904 100644 --- a/src/tsconfig.spec.json +++ b/src/tsconfig.spec.json @@ -2,17 +2,16 @@ "extends": "../tsconfig.json", "compilerOptions": { "outDir": "../out-tsc/spec", - "types": [ - "jasmine", - "node" - ] + "typeRoots": ["../node_modules/@types", "../node_modules"], + "types": ["vitest/globals", "node"] }, - "files": [ - "test.ts", - "polyfills.ts" - ], - "include": [ - "**/*.spec.ts", - "**/*.d.ts" - ] -} \ No newline at end of file + "include": ["**/*.spec.ts", "**/*.d.ts", "vitest-setup.ts"], + "angularCompilerOptions": { + "extendedDiagnostics": { + "checks": { + "nullishCoalescingNotNullable": "suppress", + "optionalChainNotNullable": "suppress" + } + } + } +} diff --git a/src/vitest-setup.ts b/src/vitest-setup.ts new file mode 100644 index 0000000000..e2b9a2ab21 --- /dev/null +++ b/src/vitest-setup.ts @@ -0,0 +1,10 @@ +import {vi} from 'vitest'; +import 'zone.js/plugins/vitest-patch'; + +// The full Angular suite compiles and instantiates many large TestBed modules in +// parallel. Give those hooks enough headroom under CI load while keeping a +// finite timeout so genuine hangs still fail the run. +vi.setConfig({ + hookTimeout: 30_000, + testTimeout: 30_000, +}); diff --git a/tsconfig.json b/tsconfig.json index c1f136d7b7..d695845b5b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,14 +1,16 @@ { "compileOnSave": false, "compilerOptions": { + "ignoreDeprecations": "6.0", "baseUrl": "./", - "downlevelIteration": true, "importHelpers": true, "module": "es2020", + "rootDir": ".", "outDir": "./build", "sourceMap": true, + "esModuleInterop": true, "declaration": false, - "moduleResolution": "node", + "moduleResolution": "bundler", "emitDecoratorMetadata": true, "experimentalDecorators": true, "noImplicitAny": false, @@ -17,11 +19,11 @@ "strictPropertyInitialization": false, "target": "ES2022", "typeRoots": ["node_modules/@types"], - "lib": ["es2020", "dom", "ES2021.String"], "useDefineForClassFields": false, - "allowSyntheticDefaultImports": true + "skipLibCheck": true }, "angularCompilerOptions": { - "strictTemplates": false + "strictTemplates": true, + "typeCheckHostBindings": false } }